@imdeadpool/guardex 5.0.8 → 5.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,12 @@ gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
71
71
  If you use `scripts/codex-agent.sh`, the finish flow is auto-run after the Codex session exits.
72
72
  It auto-commits sandbox changes, retries once after syncing if the branch moved behind base during the run, then pushes/opens PR merge flow against the current base branch.
73
73
 
74
+ If you run Codex in multiple existing agent worktrees directly (for example from VS Code Source Control), finalize all completed branches with:
75
+
76
+ ```sh
77
+ gx finish --all
78
+ ```
79
+
74
80
  ## Visual workflow
75
81
 
76
82
  ### Setup status
@@ -89,6 +95,10 @@ It auto-commits sandbox changes, retries once after syncing if the branch moved
89
95
 
90
96
  ![gx lock and delete guard screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-lock-guard.svg)
91
97
 
98
+ ### Real VS Code Source Control layout (exact screenshot)
99
+
100
+ ![Real VS Code Source Control layout](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-vscode-source-control-exact.png)
101
+
92
102
  ## Copy-paste: common commands
93
103
 
94
104
  ```sh
@@ -114,6 +124,9 @@ gx sync
114
124
  # continuously monitor open PRs targeting current branch and dispatch codex-agent review/merge tasks
115
125
  gx review --interval 30
116
126
 
127
+ # auto-commit finished agent branches and open/merge PR flow in one pass
128
+ gx finish --all
129
+
117
130
  # cleanup merged agent branches and hide clean stale agent worktrees
118
131
  gx cleanup
119
132
 
@@ -228,6 +241,19 @@ scripts/openspec/init-plan-workspace.sh
228
241
 
229
242
  If `package.json` exists, setup also adds `agent:*` helper scripts.
230
243
 
244
+ ## OpenSpec quick start after `gx setup`
245
+
246
+ If you enabled global OpenSpec install during setup (`@fission-ai/openspec`), use the full guide here:
247
+
248
+ - [`docs/openspec-getting-started.md`](./docs/openspec-getting-started.md)
249
+
250
+ ### OpenSpec in agent sub-branches
251
+
252
+ - `scripts/codex-agent.sh` enforces an OpenSpec workspace before it launches Codex in each sandbox branch/worktree.
253
+ - `scripts/agent-branch-start.sh` can also scaffold `openspec/plan/<agent-branch-slug>/` when you set `MUSAFETY_OPENSPEC_AUTO_INIT=true`.
254
+ - Set `MUSAFETY_OPENSPEC_AUTO_INIT=false` (default for `agent-branch-start`) to skip branch-start auto-bootstrap.
255
+ - Set `MUSAFETY_OPENSPEC_PLAN_SLUG=<kebab-case-slug>` to force a specific plan workspace name.
256
+
231
257
  ## Security and maintenance posture
232
258
 
233
259
  - CI matrix on Node 18/20/22 (`npm test`, `node --check`, `npm pack --dry-run`)
@@ -245,6 +271,12 @@ npm pack --dry-run
245
271
 
246
272
  ## Release notes
247
273
 
274
+ ### v5.0.9
275
+
276
+ - Enforced OpenSpec workspace bootstrap for sandbox agent execution: `scripts/codex-agent.sh` now initializes `openspec/plan/<agent-branch-slug>/` before launching Codex, and `scripts/agent-branch-start.sh` supports `MUSAFETY_OPENSPEC_AUTO_INIT` plus `MUSAFETY_OPENSPEC_PLAN_SLUG`.
277
+ - Tightened doctor auto-finish correctness: sandbox finish now waits for merge and exits non-zero if the PR closes without merge, so repair flows are not reported as complete when policy blocks merge.
278
+ - Updated package version from `5.0.8` to `5.0.9` for the next npm publish.
279
+
248
280
  ### v5.0.8
249
281
 
250
282
  - Fixed `bin/multiagent-safety.js` syntax regressions in the doctor sandbox flow (`Unexpected identifier` / `Unexpected end of input`) that were breaking CLI execution and CI tests.
@@ -128,6 +128,7 @@ const SUGGESTIBLE_COMMANDS = [
128
128
  'init',
129
129
  'doctor',
130
130
  'review',
131
+ 'finish',
131
132
  'report',
132
133
  'copy-prompt',
133
134
  'copy-commands',
@@ -148,6 +149,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
148
149
  ['init', 'Alias of setup (bootstrap + repair guardrails in a git repo)'],
149
150
  ['doctor', 'Repair safety setup drift, then verify repo safety'],
150
151
  ['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
152
+ ['finish', 'Auto-commit completed agent branches, then run PR finish flow'],
151
153
  ['copy-prompt', 'Print the AI-ready setup checklist'],
152
154
  ['copy-commands', 'Print setup checklist as executable commands only'],
153
155
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
@@ -198,6 +200,8 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
198
200
  - Finished branches stay available by default for audit/follow-up.
199
201
  Remove them explicitly when done:
200
202
  gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
203
+ - To finalize all completed agent branches in one pass:
204
+ gx finish --all
201
205
 
202
206
  6) Optional: create OpenSpec planning workspace:
203
207
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
@@ -222,6 +226,7 @@ bash scripts/codex-agent.sh "task" "agent-name"
222
226
  bash scripts/agent-branch-start.sh "task" "agent-name"
223
227
  python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
224
228
  bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
229
+ gx finish --all
225
230
  gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
226
231
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
227
232
  gx protect add release staging
@@ -628,6 +633,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
628
633
  'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
629
634
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
630
635
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
636
+ 'agent:finish': `${SHORT_TOOL_NAME} finish --all`,
631
637
  'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
632
638
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
633
639
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
@@ -1180,6 +1186,14 @@ function hasOriginRemote(repoRoot) {
1180
1186
  return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
1181
1187
  }
1182
1188
 
1189
+ function originRemoteLooksLikeGithub(repoRoot) {
1190
+ const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
1191
+ if (!originUrl) {
1192
+ return false;
1193
+ }
1194
+ return /github\.com[:/]/i.test(originUrl);
1195
+ }
1196
+
1183
1197
  function isCommandAvailable(commandName) {
1184
1198
  return run('which', [commandName]).status === 0;
1185
1199
  }
@@ -1211,6 +1225,13 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1211
1225
  note: 'origin remote missing; skipped auto-finish',
1212
1226
  };
1213
1227
  }
1228
+ const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
1229
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
1230
+ return {
1231
+ status: 'skipped',
1232
+ note: 'origin remote is not GitHub; skipped auto-finish PR flow',
1233
+ };
1234
+ }
1214
1235
 
1215
1236
  const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1216
1237
  if (!isCommandAvailable(ghBin)) {
@@ -1228,10 +1249,15 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1228
1249
  };
1229
1250
  }
1230
1251
 
1252
+ const rawWaitTimeoutSeconds = Number.parseInt(process.env.MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
1253
+ const waitTimeoutSeconds =
1254
+ Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
1255
+ const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
1256
+
1231
1257
  const finishResult = run(
1232
1258
  'bash',
1233
- [finishScript, '--branch', metadata.branch, '--via-pr'],
1234
- { cwd: metadata.worktreePath, timeout: 180_000 },
1259
+ [finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
1260
+ { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1235
1261
  );
1236
1262
  if (isSpawnFailure(finishResult)) {
1237
1263
  return {
@@ -1491,7 +1517,18 @@ function runDoctorInSandbox(options, blocked) {
1491
1517
  }
1492
1518
 
1493
1519
  if (typeof nestedResult.status === 'number') {
1494
- process.exitCode = nestedResult.status;
1520
+ let exitCode = nestedResult.status;
1521
+ if (exitCode === 0 && autoCommitResult.status === 'failed') {
1522
+ exitCode = 1;
1523
+ }
1524
+ if (
1525
+ exitCode === 0 &&
1526
+ autoCommitResult.status === 'committed' &&
1527
+ (finishResult.status === 'failed' || finishResult.status === 'pending')
1528
+ ) {
1529
+ exitCode = 1;
1530
+ }
1531
+ process.exitCode = exitCode;
1495
1532
  return;
1496
1533
  }
1497
1534
  process.exitCode = 1;
@@ -1914,6 +1951,12 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
1914
1951
  summary.details.push('Skipped auto-finish sweep (origin remote missing).');
1915
1952
  return summary;
1916
1953
  }
1954
+ const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
1955
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
1956
+ summary.enabled = false;
1957
+ summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
1958
+ return summary;
1959
+ }
1917
1960
 
1918
1961
  const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1919
1962
  if (run(ghBin, ['--version']).status !== 0) {
@@ -1973,6 +2016,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
1973
2016
  '--base',
1974
2017
  baseBranch,
1975
2018
  '--via-pr',
2019
+ '--wait-for-merge',
1976
2020
  '--cleanup',
1977
2021
  ];
1978
2022
  const finishResult = run('bash', finishArgs, { cwd: repoRoot });
@@ -2285,6 +2329,382 @@ function parseCleanupArgs(rawArgs) {
2285
2329
  return options;
2286
2330
  }
2287
2331
 
2332
+ function parseFinishArgs(rawArgs) {
2333
+ const options = {
2334
+ target: process.cwd(),
2335
+ base: '',
2336
+ branch: '',
2337
+ all: false,
2338
+ dryRun: false,
2339
+ waitForMerge: true,
2340
+ cleanup: true,
2341
+ keepRemote: false,
2342
+ noAutoCommit: false,
2343
+ failFast: false,
2344
+ commitMessage: '',
2345
+ };
2346
+
2347
+ for (let index = 0; index < rawArgs.length; index += 1) {
2348
+ const arg = rawArgs[index];
2349
+ if (arg === '--target') {
2350
+ const next = rawArgs[index + 1];
2351
+ if (!next) {
2352
+ throw new Error('--target requires a path value');
2353
+ }
2354
+ options.target = next;
2355
+ index += 1;
2356
+ continue;
2357
+ }
2358
+ if (arg === '--base') {
2359
+ const next = rawArgs[index + 1];
2360
+ if (!next) {
2361
+ throw new Error('--base requires a branch value');
2362
+ }
2363
+ options.base = next;
2364
+ index += 1;
2365
+ continue;
2366
+ }
2367
+ if (arg === '--branch') {
2368
+ const next = rawArgs[index + 1];
2369
+ if (!next) {
2370
+ throw new Error('--branch requires an agent/* branch value');
2371
+ }
2372
+ options.branch = next;
2373
+ index += 1;
2374
+ continue;
2375
+ }
2376
+ if (arg === '--commit-message') {
2377
+ const next = rawArgs[index + 1];
2378
+ if (!next) {
2379
+ throw new Error('--commit-message requires a value');
2380
+ }
2381
+ options.commitMessage = next;
2382
+ index += 1;
2383
+ continue;
2384
+ }
2385
+ if (arg === '--all') {
2386
+ options.all = true;
2387
+ continue;
2388
+ }
2389
+ if (arg === '--dry-run') {
2390
+ options.dryRun = true;
2391
+ continue;
2392
+ }
2393
+ if (arg === '--wait-for-merge') {
2394
+ options.waitForMerge = true;
2395
+ continue;
2396
+ }
2397
+ if (arg === '--no-wait-for-merge') {
2398
+ options.waitForMerge = false;
2399
+ continue;
2400
+ }
2401
+ if (arg === '--cleanup') {
2402
+ options.cleanup = true;
2403
+ continue;
2404
+ }
2405
+ if (arg === '--no-cleanup') {
2406
+ options.cleanup = false;
2407
+ continue;
2408
+ }
2409
+ if (arg === '--keep-remote') {
2410
+ options.keepRemote = true;
2411
+ continue;
2412
+ }
2413
+ if (arg === '--no-auto-commit') {
2414
+ options.noAutoCommit = true;
2415
+ continue;
2416
+ }
2417
+ if (arg === '--fail-fast') {
2418
+ options.failFast = true;
2419
+ continue;
2420
+ }
2421
+ throw new Error(`Unknown option: ${arg}`);
2422
+ }
2423
+
2424
+ if (options.branch && !options.branch.startsWith('agent/')) {
2425
+ throw new Error(`--branch must reference an agent/* branch (received: ${options.branch})`);
2426
+ }
2427
+
2428
+ return options;
2429
+ }
2430
+
2431
+ function listAgentWorktrees(repoRoot) {
2432
+ const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
2433
+ if (result.status !== 0) {
2434
+ throw new Error('Unable to list git worktrees for finish command');
2435
+ }
2436
+
2437
+ const entries = [];
2438
+ let currentPath = '';
2439
+ let currentBranchRef = '';
2440
+ const lines = String(result.stdout || '').split('\n');
2441
+ for (const line of lines) {
2442
+ if (!line.trim()) {
2443
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2444
+ entries.push({
2445
+ worktreePath: currentPath,
2446
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2447
+ });
2448
+ }
2449
+ currentPath = '';
2450
+ currentBranchRef = '';
2451
+ continue;
2452
+ }
2453
+ if (line.startsWith('worktree ')) {
2454
+ currentPath = line.slice('worktree '.length).trim();
2455
+ continue;
2456
+ }
2457
+ if (line.startsWith('branch ')) {
2458
+ currentBranchRef = line.slice('branch '.length).trim();
2459
+ continue;
2460
+ }
2461
+ }
2462
+ if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
2463
+ entries.push({
2464
+ worktreePath: currentPath,
2465
+ branch: currentBranchRef.replace(/^refs\/heads\//, ''),
2466
+ });
2467
+ }
2468
+
2469
+ return entries;
2470
+ }
2471
+
2472
+ function listLocalAgentBranchesForFinish(repoRoot) {
2473
+ const result = gitRun(
2474
+ repoRoot,
2475
+ ['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
2476
+ { allowFailure: true },
2477
+ );
2478
+ if (result.status !== 0) {
2479
+ throw new Error('Unable to list local agent branches');
2480
+ }
2481
+ return uniquePreserveOrder(
2482
+ String(result.stdout || '')
2483
+ .split('\n')
2484
+ .map((line) => line.trim())
2485
+ .filter((line) => line.startsWith('agent/')),
2486
+ );
2487
+ }
2488
+
2489
+ function gitQuietChangeResult(worktreePath, args) {
2490
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2491
+ if (result.status === 0) {
2492
+ return false;
2493
+ }
2494
+ if (result.status === 1) {
2495
+ return true;
2496
+ }
2497
+ throw new Error(
2498
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
2499
+ result.stderr || result.stdout || ''
2500
+ ).trim()}`,
2501
+ );
2502
+ }
2503
+
2504
+ function worktreeHasLocalChanges(worktreePath) {
2505
+ const hasUnstaged = gitQuietChangeResult(worktreePath, [
2506
+ 'diff',
2507
+ '--quiet',
2508
+ '--',
2509
+ '.',
2510
+ ':(exclude).omx/state/agent-file-locks.json',
2511
+ ]);
2512
+ if (hasUnstaged) {
2513
+ return true;
2514
+ }
2515
+
2516
+ const hasStaged = gitQuietChangeResult(worktreePath, [
2517
+ 'diff',
2518
+ '--cached',
2519
+ '--quiet',
2520
+ '--',
2521
+ '.',
2522
+ ':(exclude).omx/state/agent-file-locks.json',
2523
+ ]);
2524
+ if (hasStaged) {
2525
+ return true;
2526
+ }
2527
+
2528
+ const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
2529
+ stdio: 'pipe',
2530
+ });
2531
+ if (untracked.status !== 0) {
2532
+ throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
2533
+ }
2534
+ return String(untracked.stdout || '').trim().length > 0;
2535
+ }
2536
+
2537
+ function gitOutputLines(worktreePath, args) {
2538
+ const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
2539
+ if (result.status !== 0) {
2540
+ throw new Error(
2541
+ `git ${args.join(' ')} failed in ${worktreePath}: ${(
2542
+ result.stderr || result.stdout || ''
2543
+ ).trim()}`,
2544
+ );
2545
+ }
2546
+ return String(result.stdout || '')
2547
+ .split('\n')
2548
+ .map((line) => line.trim())
2549
+ .filter(Boolean);
2550
+ }
2551
+
2552
+ function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
2553
+ const lockScript = path.join(repoRoot, 'scripts', 'agent-file-locks.py');
2554
+ if (!fs.existsSync(lockScript)) {
2555
+ return;
2556
+ }
2557
+
2558
+ const changedFiles = uniquePreserveOrder([
2559
+ ...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2560
+ ...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
2561
+ ...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
2562
+ ]);
2563
+
2564
+ if (changedFiles.length > 0) {
2565
+ const claim = run('python3', [lockScript, 'claim', '--branch', branch, ...changedFiles], {
2566
+ cwd: repoRoot,
2567
+ stdio: 'pipe',
2568
+ });
2569
+ if (claim.status !== 0) {
2570
+ throw new Error(
2571
+ `Lock claim failed for ${branch}: ${(
2572
+ claim.stderr || claim.stdout || ''
2573
+ ).trim()}`,
2574
+ );
2575
+ }
2576
+ }
2577
+
2578
+ const deletedFiles = uniquePreserveOrder([
2579
+ ...gitOutputLines(worktreePath, [
2580
+ 'diff',
2581
+ '--name-only',
2582
+ '--diff-filter=D',
2583
+ '--',
2584
+ '.',
2585
+ ':(exclude).omx/state/agent-file-locks.json',
2586
+ ]),
2587
+ ...gitOutputLines(worktreePath, [
2588
+ 'diff',
2589
+ '--cached',
2590
+ '--name-only',
2591
+ '--diff-filter=D',
2592
+ '--',
2593
+ '.',
2594
+ ':(exclude).omx/state/agent-file-locks.json',
2595
+ ]),
2596
+ ]);
2597
+
2598
+ if (deletedFiles.length > 0) {
2599
+ const allowDelete = run('python3', [lockScript, 'allow-delete', '--branch', branch, ...deletedFiles], {
2600
+ cwd: repoRoot,
2601
+ stdio: 'pipe',
2602
+ });
2603
+ if (allowDelete.status !== 0) {
2604
+ throw new Error(
2605
+ `Delete-lock grant failed for ${branch}: ${(
2606
+ allowDelete.stderr || allowDelete.stdout || ''
2607
+ ).trim()}`,
2608
+ );
2609
+ }
2610
+ }
2611
+ }
2612
+
2613
+ function branchExists(repoRoot, branch) {
2614
+ const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
2615
+ allowFailure: true,
2616
+ });
2617
+ return result.status === 0;
2618
+ }
2619
+
2620
+ function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) {
2621
+ if (explicitBase) {
2622
+ return explicitBase;
2623
+ }
2624
+
2625
+ const branchSpecific = readGitConfig(repoRoot, `branch.${sourceBranch}.musafetyBase`);
2626
+ if (branchSpecific) {
2627
+ return branchSpecific;
2628
+ }
2629
+
2630
+ const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
2631
+ if (configured) {
2632
+ return configured;
2633
+ }
2634
+
2635
+ const current = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
2636
+ const currentBranch = String(current.stdout || '').trim();
2637
+ if (current.status === 0 && currentBranch && currentBranch !== 'HEAD' && !currentBranch.startsWith('agent/')) {
2638
+ return currentBranch;
2639
+ }
2640
+
2641
+ return DEFAULT_BASE_BRANCH;
2642
+ }
2643
+
2644
+ function branchMergedIntoBase(repoRoot, branch, baseBranch) {
2645
+ if (!branchExists(repoRoot, baseBranch)) {
2646
+ return false;
2647
+ }
2648
+ const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
2649
+ allowFailure: true,
2650
+ });
2651
+ if (result.status === 0) {
2652
+ return true;
2653
+ }
2654
+ if (result.status === 1) {
2655
+ return false;
2656
+ }
2657
+ throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
2658
+ }
2659
+
2660
+ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
2661
+ const hasChanges = worktreeHasLocalChanges(worktreePath);
2662
+ if (!hasChanges) {
2663
+ return { changed: false, committed: false };
2664
+ }
2665
+
2666
+ if (options.noAutoCommit) {
2667
+ throw new Error(
2668
+ `Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
2669
+ );
2670
+ }
2671
+
2672
+ if (options.dryRun) {
2673
+ return { changed: true, committed: false, dryRun: true };
2674
+ }
2675
+
2676
+ claimLocksForAutoCommit(repoRoot, worktreePath, branch);
2677
+
2678
+ const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
2679
+ if (addResult.status !== 0) {
2680
+ throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
2681
+ }
2682
+
2683
+ const stagedHasChanges = gitQuietChangeResult(worktreePath, [
2684
+ 'diff',
2685
+ '--cached',
2686
+ '--quiet',
2687
+ '--',
2688
+ '.',
2689
+ ':(exclude).omx/state/agent-file-locks.json',
2690
+ ]);
2691
+ if (!stagedHasChanges) {
2692
+ return { changed: true, committed: false };
2693
+ }
2694
+
2695
+ const commitMessage = options.commitMessage || `Auto-finish: ${branch}`;
2696
+ const commitResult = run('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { stdio: 'pipe' });
2697
+ if (commitResult.status !== 0) {
2698
+ throw new Error(
2699
+ `Auto-commit failed on '${branch}': ${(
2700
+ commitResult.stderr || commitResult.stdout || ''
2701
+ ).trim()}`,
2702
+ );
2703
+ }
2704
+
2705
+ return { changed: true, committed: true, message: commitMessage };
2706
+ }
2707
+
2288
2708
  function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
2289
2709
  if (strategy === 'rebase') {
2290
2710
  if (ffOnly) {
@@ -3487,6 +3907,129 @@ function cleanup(rawArgs) {
3487
3907
  process.exitCode = 0;
3488
3908
  }
3489
3909
 
3910
+ function finish(rawArgs) {
3911
+ const options = parseFinishArgs(rawArgs);
3912
+ const repoRoot = resolveRepoRoot(options.target);
3913
+ const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh');
3914
+
3915
+ if (!fs.existsSync(finishScript)) {
3916
+ throw new Error(`Missing finish script: ${finishScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
3917
+ }
3918
+
3919
+ const worktreeEntries = listAgentWorktrees(repoRoot);
3920
+ const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath]));
3921
+
3922
+ let candidateBranches = [];
3923
+ if (options.branch) {
3924
+ if (!branchExists(repoRoot, options.branch)) {
3925
+ throw new Error(`Local branch not found: ${options.branch}`);
3926
+ }
3927
+ candidateBranches = [options.branch];
3928
+ } else {
3929
+ candidateBranches = uniquePreserveOrder([
3930
+ ...listLocalAgentBranchesForFinish(repoRoot),
3931
+ ...worktreeEntries.map((entry) => entry.branch),
3932
+ ]);
3933
+ }
3934
+
3935
+ const candidates = [];
3936
+ for (const branch of candidateBranches) {
3937
+ const worktreePath = worktreeByBranch.get(branch) || '';
3938
+ const baseBranch = resolveFinishBaseBranch(repoRoot, branch, options.base);
3939
+ const hasChanges = worktreePath ? worktreeHasLocalChanges(worktreePath) : false;
3940
+ const alreadyMerged = branchMergedIntoBase(repoRoot, branch, baseBranch);
3941
+ if (options.all || options.branch || hasChanges || !alreadyMerged) {
3942
+ candidates.push({
3943
+ branch,
3944
+ baseBranch,
3945
+ worktreePath,
3946
+ hasChanges,
3947
+ alreadyMerged,
3948
+ });
3949
+ }
3950
+ }
3951
+
3952
+ if (candidates.length === 0) {
3953
+ console.log(`[${TOOL_NAME}] No pending agent branches to finish.`);
3954
+ process.exitCode = 0;
3955
+ return;
3956
+ }
3957
+
3958
+ let succeeded = 0;
3959
+ let failed = 0;
3960
+ let autoCommitted = 0;
3961
+
3962
+ for (const candidate of candidates) {
3963
+ const { branch, baseBranch, worktreePath } = candidate;
3964
+ console.log(
3965
+ `[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
3966
+ );
3967
+
3968
+ try {
3969
+ let commitState = { changed: false, committed: false };
3970
+ if (worktreePath) {
3971
+ commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
3972
+ }
3973
+
3974
+ if (commitState.committed) {
3975
+ autoCommitted += 1;
3976
+ console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
3977
+ } else if (commitState.changed && commitState.dryRun) {
3978
+ console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
3979
+ }
3980
+
3981
+ const finishArgs = [
3982
+ finishScript,
3983
+ '--branch',
3984
+ branch,
3985
+ '--base',
3986
+ baseBranch,
3987
+ '--via-pr',
3988
+ options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
3989
+ options.cleanup ? '--cleanup' : '--no-cleanup',
3990
+ ];
3991
+ if (options.keepRemote) {
3992
+ finishArgs.push('--keep-remote-branch');
3993
+ }
3994
+
3995
+ if (options.dryRun) {
3996
+ console.log(`[${TOOL_NAME}] [dry-run] Would run: bash ${finishArgs.join(' ')}`);
3997
+ succeeded += 1;
3998
+ continue;
3999
+ }
4000
+
4001
+ const finishResult = run('bash', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
4002
+ if (finishResult.stdout) {
4003
+ process.stdout.write(finishResult.stdout);
4004
+ }
4005
+ if (finishResult.stderr) {
4006
+ process.stderr.write(finishResult.stderr);
4007
+ }
4008
+ if (finishResult.status !== 0) {
4009
+ throw new Error(`agent-branch-finish exited with status ${finishResult.status}`);
4010
+ }
4011
+
4012
+ succeeded += 1;
4013
+ } catch (error) {
4014
+ failed += 1;
4015
+ console.error(`[${TOOL_NAME}] Finish failed for '${branch}': ${error.message}`);
4016
+ if (options.failFast) {
4017
+ break;
4018
+ }
4019
+ }
4020
+ }
4021
+
4022
+ console.log(
4023
+ `[${TOOL_NAME}] Finish summary: total=${candidates.length}, success=${succeeded}, failed=${failed}, autoCommitted=${autoCommitted}`,
4024
+ );
4025
+
4026
+ if (failed > 0) {
4027
+ throw new Error('finish command failed for one or more agent branches');
4028
+ }
4029
+
4030
+ process.exitCode = 0;
4031
+ }
4032
+
3490
4033
  function sync(rawArgs) {
3491
4034
  const options = parseSyncArgs(rawArgs);
3492
4035
  const repoRoot = resolveRepoRoot(options.target);
@@ -3815,6 +4358,11 @@ function main() {
3815
4358
  return;
3816
4359
  }
3817
4360
 
4361
+ if (command === 'finish') {
4362
+ finish(rest);
4363
+ return;
4364
+ }
4365
+
3818
4366
  if (command === 'report') {
3819
4367
  report(rest);
3820
4368
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "5.0.8",
3
+ "version": "5.0.9",
4
4
  "description": "GuardeX: the Guardian T-Rex for your repo, with hardened multi-agent git guardrails.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
@@ -45,9 +45,17 @@
45
45
  - Verification commands + results
46
46
  - Risks / follow-ups
47
47
 
48
- ## OpenSpec Plan Workspace (recommended)
48
+ ## OpenSpec Plan Workspace (required for agent sub-branch changes)
49
49
 
50
- When work needs a durable planning phase, scaffold a plan workspace before implementation:
50
+ OMX Codex execution flows must use OpenSpec. `scripts/codex-agent.sh` bootstraps a
51
+ per-branch plan workspace automatically under:
52
+
53
+ ```text
54
+ openspec/plan/<agent-branch-slug>/
55
+ ```
56
+
57
+ For manual `scripts/agent-branch-start.sh` usage, enable auto-bootstrap with
58
+ `MUSAFETY_OPENSPEC_AUTO_INIT=true` or scaffold manually before implementation:
51
59
 
52
60
  ```bash
53
61
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
@@ -338,7 +338,7 @@ is_local_branch_delete_error() {
338
338
 
339
339
  read_pr_state() {
340
340
  local state_line
341
- state_line="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | @tsv' 2>/dev/null || true)"
341
+ state_line="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | join("\u001f")' 2>/dev/null || true)"
342
342
  if [[ -z "$state_line" ]]; then
343
343
  return 1
344
344
  fi
@@ -346,7 +346,7 @@ read_pr_state() {
346
346
  local parsed_state=""
347
347
  local parsed_merged_at=""
348
348
  local parsed_url=""
349
- IFS=$'\t' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line"
349
+ IFS=$'\x1f' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line"
350
350
  PR_STATE="$parsed_state"
351
351
  PR_MERGED_AT="$parsed_merged_at"
352
352
  if [[ -n "$parsed_url" ]]; then
@@ -6,6 +6,8 @@ AGENT_NAME="agent"
6
6
  BASE_BRANCH=""
7
7
  BASE_BRANCH_EXPLICIT=0
8
8
  WORKTREE_ROOT_REL=".omx/agent-worktrees"
9
+ OPENSPEC_AUTO_INIT_RAW="${MUSAFETY_OPENSPEC_AUTO_INIT:-false}"
10
+ OPENSPEC_PLAN_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_PLAN_SLUG:-}"
9
11
  POSITIONAL_ARGS=()
10
12
 
11
13
  while [[ $# -gt 0 ]]; do
@@ -82,6 +84,31 @@ sanitize_slug() {
82
84
  printf '%s' "$slug"
83
85
  }
84
86
 
87
+ normalize_bool() {
88
+ local raw="${1:-}"
89
+ local fallback="${2:-0}"
90
+ local lowered
91
+ lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
92
+ case "$lowered" in
93
+ 1|true|yes|on) printf '1' ;;
94
+ 0|false|no|off) printf '0' ;;
95
+ '') printf '%s' "$fallback" ;;
96
+ *) printf '%s' "$fallback" ;;
97
+ esac
98
+ }
99
+
100
+ OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")"
101
+
102
+ resolve_openspec_plan_slug() {
103
+ local branch_name="$1"
104
+ local task_slug="$2"
105
+ if [[ -n "$OPENSPEC_PLAN_SLUG_OVERRIDE" ]]; then
106
+ sanitize_slug "$OPENSPEC_PLAN_SLUG_OVERRIDE" "$task_slug"
107
+ return 0
108
+ fi
109
+ sanitize_slug "${branch_name//\//-}" "$task_slug"
110
+ }
111
+
85
112
  resolve_active_codex_snapshot_name() {
86
113
  local override="${MUSAFETY_CODEX_AUTH_SNAPSHOT:-}"
87
114
  if [[ -n "$override" ]]; then
@@ -114,17 +141,6 @@ has_local_changes() {
114
141
  return 1
115
142
  }
116
143
 
117
- has_tracked_changes() {
118
- local root="$1"
119
- if ! git -C "$root" diff --quiet; then
120
- return 0
121
- fi
122
- if ! git -C "$root" diff --cached --quiet; then
123
- return 0
124
- fi
125
- return 1
126
- }
127
-
128
144
  resolve_protected_branches() {
129
145
  local root="$1"
130
146
  local raw
@@ -177,6 +193,43 @@ hydrate_local_helper_in_worktree() {
177
193
  echo "[agent-branch-start] Hydrated local helper in worktree: ${relative_path}"
178
194
  }
179
195
 
196
+ initialize_openspec_plan_workspace() {
197
+ local repo="$1"
198
+ local worktree="$2"
199
+ local plan_slug="$3"
200
+
201
+ hydrate_local_helper_in_worktree "$repo" "$worktree" "scripts/openspec/init-plan-workspace.sh"
202
+
203
+ if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then
204
+ return 0
205
+ fi
206
+
207
+ local openspec_script="${worktree}/scripts/openspec/init-plan-workspace.sh"
208
+ if [[ ! -f "$openspec_script" ]]; then
209
+ echo "[agent-branch-start] OpenSpec init script is missing in sandbox worktree." >&2
210
+ echo "[agent-branch-start] Run 'gx setup --target \"$repo\"' to repair templates, then retry." >&2
211
+ return 1
212
+ fi
213
+ if [[ ! -x "$openspec_script" ]]; then
214
+ chmod +x "$openspec_script" 2>/dev/null || true
215
+ fi
216
+
217
+ local init_output=""
218
+ if ! init_output="$(
219
+ cd "$worktree"
220
+ bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1
221
+ )"; then
222
+ printf '%s\n' "$init_output" >&2
223
+ echo "[agent-branch-start] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2
224
+ return 1
225
+ fi
226
+
227
+ if [[ -n "$init_output" ]]; then
228
+ printf '%s\n' "$init_output"
229
+ fi
230
+ echo "[agent-branch-start] OpenSpec plan workspace: ${worktree}/openspec/plan/${plan_slug}"
231
+ }
232
+
180
233
  if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
181
234
  echo "[agent-branch-start] Not inside a git repository." >&2
182
235
  exit 1
@@ -190,12 +243,15 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
190
243
  fi
191
244
 
192
245
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
193
- configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
194
- if [[ -n "$configured_base" ]]; then
195
- BASE_BRANCH="$configured_base"
246
+ current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
247
+ protected_branches_raw="$(resolve_protected_branches "$repo_root")"
248
+ if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_branch_name "$current_branch" "$protected_branches_raw"; then
249
+ BASE_BRANCH="$current_branch"
196
250
  else
197
- current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
198
- if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then
251
+ configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
252
+ if [[ -n "$configured_base" ]]; then
253
+ BASE_BRANCH="$configured_base"
254
+ elif [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then
199
255
  BASE_BRANCH="$current_branch"
200
256
  else
201
257
  BASE_BRANCH="dev"
@@ -235,6 +291,7 @@ done
235
291
  worktree_root="${repo_root}/${WORKTREE_ROOT_REL}"
236
292
  mkdir -p "$worktree_root"
237
293
  worktree_path="${worktree_root}/${branch_name//\//__}"
294
+ openspec_plan_slug="$(resolve_openspec_plan_slug "$branch_name" "$task_slug")"
238
295
 
239
296
  if [[ -e "$worktree_path" ]]; then
240
297
  echo "[agent-branch-start] Worktree path already exists: ${worktree_path}" >&2
@@ -243,10 +300,11 @@ fi
243
300
 
244
301
  auto_transfer_stash_ref=""
245
302
  auto_transfer_message=""
303
+ auto_transfer_source_branch=""
246
304
  current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
247
305
  protected_branches_raw="$(resolve_protected_branches "$repo_root")"
248
- if [[ "$current_branch" == "$BASE_BRANCH" ]] && is_protected_branch_name "$BASE_BRANCH" "$protected_branches_raw"; then
249
- if has_tracked_changes "$repo_root"; then
306
+ if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_branch_name "$current_branch" "$protected_branches_raw"; then
307
+ if has_local_changes "$repo_root"; then
250
308
  auto_transfer_message="musafety-auto-transfer-${timestamp}-${agent_slug}-${task_slug}"
251
309
  if git -C "$repo_root" stash push --include-untracked --message "$auto_transfer_message" >/dev/null 2>&1; then
252
310
  auto_transfer_stash_ref="$(
@@ -254,7 +312,8 @@ if [[ "$current_branch" == "$BASE_BRANCH" ]] && is_protected_branch_name "$BASE_
254
312
  | awk -v msg="$auto_transfer_message" '$0 ~ msg { ref=$1; sub(/:$/, "", ref); print ref; exit }'
255
313
  )"
256
314
  if [[ -n "$auto_transfer_stash_ref" ]]; then
257
- echo "[agent-branch-start] Detected local changes on protected base '${BASE_BRANCH}'. Moving them to '${branch_name}'..."
315
+ auto_transfer_source_branch="$current_branch"
316
+ echo "[agent-branch-start] Detected local changes on protected branch '${current_branch}'. Moving them to '${branch_name}'..."
258
317
  fi
259
318
  fi
260
319
  fi
@@ -270,19 +329,25 @@ fi
270
329
  if [[ -n "$auto_transfer_stash_ref" ]]; then
271
330
  if git -C "$worktree_path" stash apply "$auto_transfer_stash_ref" >/dev/null 2>&1; then
272
331
  git -C "$repo_root" stash drop "$auto_transfer_stash_ref" >/dev/null 2>&1 || true
273
- echo "[agent-branch-start] Moved local changes from '${BASE_BRANCH}' into '${branch_name}'."
332
+ transfer_label="${auto_transfer_source_branch:-$BASE_BRANCH}"
333
+ echo "[agent-branch-start] Moved local changes from '${transfer_label}' into '${branch_name}'."
274
334
  else
275
335
  echo "[agent-branch-start] Failed to auto-apply moved changes in new worktree." >&2
276
- echo "[agent-branch-start] Changes are preserved in ${auto_transfer_stash_ref} on ${BASE_BRANCH}." >&2
336
+ transfer_label="${auto_transfer_source_branch:-$BASE_BRANCH}"
337
+ echo "[agent-branch-start] Changes are preserved in ${auto_transfer_stash_ref} on ${transfer_label}." >&2
277
338
  echo "[agent-branch-start] Apply manually with: git -C \"$worktree_path\" stash apply \"${auto_transfer_stash_ref}\"" >&2
278
339
  exit 1
279
340
  fi
280
341
  fi
281
342
 
282
343
  hydrate_local_helper_in_worktree "$repo_root" "$worktree_path" "scripts/codex-agent.sh"
344
+ if ! initialize_openspec_plan_workspace "$repo_root" "$worktree_path" "$openspec_plan_slug"; then
345
+ exit 1
346
+ fi
283
347
 
284
348
  echo "[agent-branch-start] Created branch: ${branch_name}"
285
349
  echo "[agent-branch-start] Worktree: ${worktree_path}"
350
+ echo "[agent-branch-start] OpenSpec plan: openspec/plan/${openspec_plan_slug}"
286
351
  echo "[agent-branch-start] Next steps:"
287
352
  echo " cd \"${worktree_path}\""
288
353
  echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" <file...>"
@@ -10,6 +10,8 @@ AUTO_FINISH_RAW="${MUSAFETY_CODEX_AUTO_FINISH:-true}"
10
10
  AUTO_REVIEW_ON_CONFLICT_RAW="${MUSAFETY_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}"
11
11
  AUTO_CLEANUP_RAW="${MUSAFETY_CODEX_AUTO_CLEANUP:-true}"
12
12
  AUTO_WAIT_FOR_MERGE_RAW="${MUSAFETY_CODEX_WAIT_FOR_MERGE:-true}"
13
+ OPENSPEC_AUTO_INIT_RAW="${MUSAFETY_OPENSPEC_AUTO_INIT:-true}"
14
+ OPENSPEC_PLAN_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_PLAN_SLUG:-}"
13
15
 
14
16
  normalize_bool() {
15
17
  local raw="${1:-}"
@@ -28,6 +30,7 @@ AUTO_FINISH="$(normalize_bool "$AUTO_FINISH_RAW" "1")"
28
30
  AUTO_REVIEW_ON_CONFLICT="$(normalize_bool "$AUTO_REVIEW_ON_CONFLICT_RAW" "1")"
29
31
  AUTO_CLEANUP="$(normalize_bool "$AUTO_CLEANUP_RAW" "1")"
30
32
  AUTO_WAIT_FOR_MERGE="$(normalize_bool "$AUTO_WAIT_FOR_MERGE_RAW" "1")"
33
+ OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")"
31
34
 
32
35
  if [[ -n "$BASE_BRANCH" ]]; then
33
36
  BASE_BRANCH_EXPLICIT=1
@@ -136,6 +139,46 @@ sanitize_slug() {
136
139
  printf '%s' "$slug"
137
140
  }
138
141
 
142
+ resolve_openspec_plan_slug() {
143
+ local branch_name="$1"
144
+ local task_slug
145
+ task_slug="$(sanitize_slug "$TASK_NAME" "task")"
146
+ if [[ -n "$OPENSPEC_PLAN_SLUG_OVERRIDE" ]]; then
147
+ sanitize_slug "$OPENSPEC_PLAN_SLUG_OVERRIDE" "$task_slug"
148
+ return 0
149
+ fi
150
+ sanitize_slug "${branch_name//\//-}" "$task_slug"
151
+ }
152
+
153
+ hydrate_local_helper_in_worktree() {
154
+ local worktree="$1"
155
+ local relative_path="$2"
156
+ local worktree_target="${worktree}/${relative_path}"
157
+ local source_path=""
158
+
159
+ if [[ -e "$worktree_target" ]]; then
160
+ return 0
161
+ fi
162
+
163
+ if [[ -f "${repo_root}/${relative_path}" ]]; then
164
+ source_path="${repo_root}/${relative_path}"
165
+ elif [[ -f "${repo_root}/templates/${relative_path}" ]]; then
166
+ source_path="${repo_root}/templates/${relative_path}"
167
+ fi
168
+
169
+ if [[ -z "$source_path" ]]; then
170
+ return 0
171
+ fi
172
+
173
+ mkdir -p "$(dirname "$worktree_target")"
174
+ cp "$source_path" "$worktree_target"
175
+ if [[ -x "$source_path" ]]; then
176
+ chmod +x "$worktree_target"
177
+ fi
178
+
179
+ echo "[codex-agent] Hydrated local helper in sandbox: ${relative_path}"
180
+ }
181
+
139
182
  resolve_start_base_branch() {
140
183
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then
141
184
  printf '%s' "$BASE_BRANCH"
@@ -239,7 +282,7 @@ initial_repo_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/nu
239
282
  start_output=""
240
283
  start_status=0
241
284
  set +e
242
- start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1)"
285
+ start_output="$(MUSAFETY_OPENSPEC_AUTO_INIT=0 bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1)"
243
286
  start_status=$?
244
287
  set -e
245
288
 
@@ -363,6 +406,43 @@ sync_worktree_with_base() {
363
406
  return 0
364
407
  }
365
408
 
409
+ ensure_openspec_plan_workspace() {
410
+ local wt="$1"
411
+ local branch="$2"
412
+
413
+ if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then
414
+ return 0
415
+ fi
416
+
417
+ hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-plan-workspace.sh"
418
+
419
+ local openspec_script="${wt}/scripts/openspec/init-plan-workspace.sh"
420
+ if [[ ! -f "$openspec_script" ]]; then
421
+ echo "[codex-agent] Missing OpenSpec init script in sandbox: ${openspec_script}" >&2
422
+ echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2
423
+ return 1
424
+ fi
425
+ if [[ ! -x "$openspec_script" ]]; then
426
+ chmod +x "$openspec_script" 2>/dev/null || true
427
+ fi
428
+
429
+ local plan_slug
430
+ plan_slug="$(resolve_openspec_plan_slug "$branch")"
431
+ local init_output=""
432
+ if ! init_output="$(
433
+ cd "$wt"
434
+ bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1
435
+ )"; then
436
+ printf '%s\n' "$init_output" >&2
437
+ echo "[codex-agent] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2
438
+ return 1
439
+ fi
440
+ if [[ -n "$init_output" ]]; then
441
+ printf '%s\n' "$init_output"
442
+ fi
443
+ echo "[codex-agent] OpenSpec plan workspace: ${wt}/openspec/plan/${plan_slug}"
444
+ }
445
+
366
446
  worktree_has_changes() {
367
447
  local wt="$1"
368
448
  if ! git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then
@@ -579,6 +659,16 @@ if ! sync_worktree_with_base "$worktree_path"; then
579
659
  exit 1
580
660
  fi
581
661
 
662
+ worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
663
+ if [[ -z "$worktree_branch" || "$worktree_branch" == "HEAD" ]]; then
664
+ echo "[codex-agent] Could not determine sandbox branch for worktree: $worktree_path" >&2
665
+ exit 1
666
+ fi
667
+
668
+ if ! ensure_openspec_plan_workspace "$worktree_path" "$worktree_branch"; then
669
+ exit 1
670
+ fi
671
+
582
672
  echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path"
583
673
  cd "$worktree_path"
584
674
  set +e
@@ -590,8 +680,6 @@ cd "$repo_root"
590
680
  final_exit="$codex_exit"
591
681
  auto_finish_completed=0
592
682
 
593
- worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
594
-
595
683
  if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then
596
684
  if [[ "$AUTO_WAIT_FOR_MERGE" -eq 1 && "$AUTO_CLEANUP" -eq 1 ]]; then
597
685
  echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> wait for merge -> cleanup."