Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ gx setup --target /mainfolder
gx setup --target /mainfolder --no-recursive
```

### Fresh repos + Docker Compose

On a brand-new repo, `gx setup` now prints the next real steps too: commit the scaffold, start the first agent branch, and add `origin` if you want finish/merge flows to leave the machine.

If the repo already has `docker-compose.yml`, `docker-compose.yaml`, `compose.yml`, or `compose.yaml`, setup also points you at the bundled Docker loader:

```sh
GUARDEX_DOCKER_SERVICE=app bash scripts/guardex-docker-loader.sh -- npm test
```

When the service is already running, the loader uses `docker compose exec`; otherwise it falls back to `docker compose run --rm`.

### Protected branches

```sh
Expand Down
74 changes: 72 additions & 2 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,20 @@ const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master'];
const DEFAULT_BASE_BRANCH = 'dev';
const DEFAULT_SYNC_STRATEGY = 'rebase';
const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60;
const COMPOSE_HINT_FILES = [
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
];

const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');

const TEMPLATE_FILES = [
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
'scripts/codex-agent.sh',
'scripts/guardex-docker-loader.sh',
'scripts/review-bot-watch.sh',
'scripts/agent-worktree-prune.sh',
'scripts/agent-file-locks.py',
Expand All @@ -105,6 +112,7 @@ const TEMPLATE_FILES = [
const REQUIRED_WORKFLOW_FILES = [
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
'scripts/guardex-docker-loader.sh',
'scripts/agent-worktree-prune.sh',
'scripts/agent-file-locks.py',
'scripts/guardex-env.sh',
Expand Down Expand Up @@ -133,6 +141,7 @@ const REQUIRED_PACKAGE_SCRIPTS = {
'agent:safety:scan': 'gx status --strict',
'agent:safety:fix': 'gx setup --repair',
'agent:safety:doctor': 'gx doctor',
'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh',
'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
'agent:finish': 'gx finish --all',
};
Expand All @@ -141,6 +150,7 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([
'scripts/agent-branch-start.sh',
'scripts/agent-branch-finish.sh',
'scripts/codex-agent.sh',
'scripts/guardex-docker-loader.sh',
'scripts/review-bot-watch.sh',
'scripts/agent-worktree-prune.sh',
'scripts/agent-file-locks.py',
Expand Down Expand Up @@ -2565,6 +2575,66 @@ function currentBranchName(repoRoot) {
return branch;
}

function repoHasHeadCommit(repoRoot) {
return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
}

function readBranchDisplayName(repoRoot) {
const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
if (symbolic.status === 0) {
const branch = String(symbolic.stdout || '').trim();
if (!branch) {
return '(unknown)';
}
return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
}

const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
if (detached.status === 0) {
return `(detached at ${String(detached.stdout || '').trim()})`;
}
return '(unknown)';
}

function repoHasOriginRemote(repoRoot) {
return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
}

function detectComposeHintFiles(repoRoot) {
return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
}

function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
const branchDisplay = readBranchDisplayName(repoRoot);
const hasHeadCommit = repoHasHeadCommit(repoRoot);
const hasOrigin = repoHasOriginRemote(repoRoot);
const composeFiles = detectComposeHintFiles(repoRoot);
if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
return;
}

const label = repoLabel ? ` ${repoLabel}` : '';
if (!hasHeadCommit) {
console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
console.log(
`[${TOOL_NAME}] First agent flow${label}: ` +
`bash scripts/agent-branch-start.sh "<task>" "codex" -> ` +
`python3 scripts/agent-file-locks.py claim --branch "$(git branch --show-current)" <file...> -> ` +
`bash scripts/agent-branch-finish.sh --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
);
}
if (!hasOrigin) {
console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
}
if (composeFiles.length > 0) {
console.log(
`[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
`Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
);
}
}

function workingTreeIsDirty(repoRoot) {
const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
if (result.status !== 0) {
Expand Down Expand Up @@ -4078,8 +4148,7 @@ function runFixInternal(options) {
function runScanInternal(options) {
const repoRoot = resolveRepoRoot(options.target);
const guardexToggle = resolveGuardexRepoToggle(repoRoot);
const currentBranchResult = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
const branch = currentBranchResult.status === 0 ? currentBranchResult.stdout.trim() : '(unknown)';
const branch = readBranchDisplayName(repoRoot);
if (!guardexToggle.enabled) {
return {
repoRoot,
Expand Down Expand Up @@ -5216,6 +5285,7 @@ function setup(rawArgs) {
} else if (autoFinishSummary.details.length > 0) {
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
}
printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);

aggregateErrors += scanResult.errors;
aggregateWarnings += scanResult.warnings;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Why

- `gx setup` currently finishes a fresh-repo bootstrap with generic OpenSpec links only, even when the repo still has no commits and no `origin` remote. That leaves the user with the safety files installed but no concrete "what do I do next?" guidance.
- On an empty repo, the setup scan currently prints `Branch: (unknown)` instead of the actual unborn branch name, which makes the first-run output feel rough.
- Compose-based repos often need a small wrapper for running Guardex-adjacent commands inside the app container, but setup does not currently scaffold one.

## What Changes

- Show the real unborn branch name during setup/scan and add a fresh-repo onboarding block with the bootstrap commit, first agent branch flow, and missing-remote hint.
- Add a managed `scripts/guardex-docker-loader.sh` helper plus the matching `package.json` script so new repos get the Docker entry point immediately.
- Surface the Docker loader in setup output only when a compose file is present, and add regression coverage for the fresh-repo + Docker bootstrap path.

## Impact

- Affected surfaces:
- `bin/multiagent-safety.js`
- `scripts/guardex-docker-loader.sh`
- `templates/scripts/guardex-docker-loader.sh`
- `test/install.test.js`
- `README.md`
- Risk is low and scoped to setup/bootstrap output plus one new helper script.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## ADDED Requirements

### Requirement: fresh repo setup onboarding
Guardex setup SHALL tell the user what to do next when the target repo has not been committed or published yet.

#### Scenario: unborn branch bootstrap
- **WHEN** the user runs `gx setup` in a repo whose current branch has no commits yet
- **THEN** the setup scan output shows the actual unborn branch name instead of `(unknown)`
- **AND** the setup success output includes a bootstrap-commit hint
- **AND** the setup success output includes the first agent branch -> lock claim -> finish flow for that repo

#### Scenario: setup on repo without origin
- **WHEN** the target repo has no `origin` remote
- **THEN** the setup success output explains that finish and auto-merge flows remain local until a remote is added

### Requirement: docker compose loader bootstrap
Guardex setup SHALL install a repo-local Docker compose loader helper and surface it when compose files are present.

#### Scenario: setup scaffolds docker loader
- **WHEN** the user runs `gx setup`
- **THEN** the repo contains an executable `scripts/guardex-docker-loader.sh`
- **AND** `package.json` includes `agent:docker:load`

#### Scenario: compose repo gets docker hint
- **WHEN** the target repo contains a compose file such as `docker-compose.yml` or `compose.yaml`
- **THEN** the setup success output mentions `scripts/guardex-docker-loader.sh`
- **AND** the output tells the user to set `GUARDEX_DOCKER_SERVICE`
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-improve-setup-new-repo-docker-loader-2026-04-21-11-41`.
- [x] 1.2 Define normative requirements in `specs/setup-fresh-repo-experience/spec.md`.

## 2. Implementation

- [x] 2.1 Patch setup/scan so fresh repos show the unborn branch name and clearer next-step onboarding hints.
- [x] 2.2 Add the managed Docker loader script and wire it into the bootstrap template + package scripts.
- [x] 2.3 Update README/docs for the new Docker helper and fresh setup guidance.
- [x] 2.4 Add regression coverage for the new repo onboarding and Docker loader behavior.

## 3. Verification

- [x] 3.1 Run `node --check bin/multiagent-safety.js` plus an end-to-end temp-repo bootstrap repro covering the fresh setup hints and Docker loader dispatch.
- [x] 3.2 Run `openspec validate agent-codex-improve-setup-new-repo-docker-loader-2026-04-21-11-41 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

## 4. Cleanup

- [ ] 4.1 Run `bash scripts/agent-branch-finish.sh --branch agent/codex/improve-setup-new-repo-docker-loader-2026-04-21-11-41 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 4.2 Record PR URL and final merge state.
- [ ] 4.3 Confirm sandbox worktree and refs are cleaned up.
123 changes: 123 additions & 0 deletions scripts/guardex-docker-loader.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
compose_file="${GUARDEX_DOCKER_COMPOSE_FILE:-}"
service="${GUARDEX_DOCKER_SERVICE:-}"
mode="${GUARDEX_DOCKER_MODE:-auto}"
workdir_override="${GUARDEX_DOCKER_WORKDIR:-}"

usage() {
cat >&2 <<'EOF'
Usage: bash scripts/guardex-docker-loader.sh [--] <command...>

Environment:
GUARDEX_DOCKER_SERVICE=<compose-service> required unless compose defines exactly one service
GUARDEX_DOCKER_COMPOSE_FILE=<path> optional docker compose file override
GUARDEX_DOCKER_MODE=auto|exec|run default: auto
GUARDEX_DOCKER_WORKDIR=<path> optional working directory override inside the container
EOF
}

choose_compose_cmd() {
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
printf 'docker compose'
return 0
fi
if command -v docker-compose >/dev/null 2>&1; then
printf 'docker-compose'
return 0
fi
return 1
}

mapfile_from_lines() {
local raw="$1"
local -n out_ref="$2"
out_ref=()
while IFS= read -r line; do
[[ -n "$line" ]] || continue
out_ref+=("$line")
done <<<"$raw"
}

if [[ "${1:-}" == "--" ]]; then
shift
fi

if [[ $# -eq 0 ]]; then
usage
exit 1
fi

if [[ "$mode" != "auto" && "$mode" != "exec" && "$mode" != "run" ]]; then
echo "[guardex-docker-loader] Invalid GUARDEX_DOCKER_MODE: $mode" >&2
usage
exit 1
fi

compose_cmd_raw="$(choose_compose_cmd)" || {
echo "[guardex-docker-loader] Docker Compose is not available. Install docker compose or docker-compose first." >&2
exit 1
}
IFS=' ' read -r -a compose_cmd <<<"$compose_cmd_raw"
compose_args=()
if [[ -n "$compose_file" ]]; then
compose_args=(-f "$compose_file")
fi

cd "$repo_root"

services_raw="$("${compose_cmd[@]}" "${compose_args[@]}" config --services 2>/dev/null || true)"
declare -a services
mapfile_from_lines "$services_raw" services
if [[ ${#services[@]} -eq 0 ]]; then
echo "[guardex-docker-loader] No Docker Compose services found. Add a compose file or set GUARDEX_DOCKER_COMPOSE_FILE." >&2
exit 1
fi

if [[ -z "$service" ]]; then
if [[ ${#services[@]} -eq 1 ]]; then
service="${services[0]}"
else
echo "[guardex-docker-loader] Multiple services found (${services[*]}). Set GUARDEX_DOCKER_SERVICE." >&2
exit 1
fi
fi

service_known=0
for candidate in "${services[@]}"; do
if [[ "$candidate" == "$service" ]]; then
service_known=1
break
fi
done
if [[ $service_known -ne 1 ]]; then
echo "[guardex-docker-loader] Compose service not found: $service" >&2
exit 1
fi

run_mode="$mode"
if [[ "$run_mode" == "auto" ]]; then
run_mode="run"
running_raw="$("${compose_cmd[@]}" "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
declare -a running_services
mapfile_from_lines "$running_raw" running_services
for candidate in "${running_services[@]}"; do
if [[ "$candidate" == "$service" ]]; then
run_mode="exec"
break
fi
done
fi

workdir_args=()
if [[ -n "$workdir_override" ]]; then
workdir_args=(-w "$workdir_override")
fi

if [[ "$run_mode" == "exec" ]]; then
exec "${compose_cmd[@]}" "${compose_args[@]}" exec -T "${workdir_args[@]}" "$service" "$@"
fi

exec "${compose_cmd[@]}" "${compose_args[@]}" run --rm -T "${workdir_args[@]}" "$service" "$@"
Loading