@imdeadpool/guardex 7.0.12 → 7.0.14

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
@@ -1,7 +1,13 @@
1
1
  # GitGuardex — Guardian T-Rex for your repo
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/%40imdeadpool%2Fguardex?color=cb3837&logo=npm)](https://www.npmjs.com/package/@imdeadpool/guardex)
4
- [![CI](https://github.com/recodeee/gitguardex/actions/workflows/ci.yml/badge.svg)](https://github.com/recodeee/gitguardex/actions/workflows/ci.yml)
3
+ [![npm version](https://img.shields.io/npm/v/%40imdeadpool%2Fguardex?label=npm&color=cb3837&logo=npm)](https://www.npmjs.com/package/@imdeadpool/guardex)
4
+ [![npm downloads](https://img.shields.io/npm/dm/%40imdeadpool%2Fguardex?label=downloads&color=0b76c5)](https://www.npmjs.com/package/@imdeadpool/guardex)
5
+ [![GitHub stars](https://img.shields.io/github/stars/recodeee/gitguardex?label=stars&color=d4ac0d)](https://github.com/recodeee/gitguardex/stargazers)
6
+ [![License](https://img.shields.io/npm/l/%40imdeadpool%2Fguardex?label=License&color=97ca00)](./LICENSE)
7
+
8
+ [![CI](https://img.shields.io/github/actions/workflow/status/recodeee/gitguardex/ci.yml?branch=main&label=CI)](https://github.com/recodeee/gitguardex/actions/workflows/ci.yml)
9
+ [![Release](https://img.shields.io/github/actions/workflow/status/recodeee/gitguardex/release.yml?label=Release)](https://github.com/recodeee/gitguardex/actions/workflows/release.yml)
10
+ [![CodeQL](https://img.shields.io/github/actions/workflow/status/recodeee/gitguardex/codeql.yml?branch=main&label=CodeQL)](https://github.com/recodeee/gitguardex/actions/workflows/codeql.yml)
5
11
  [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/recodeee/gitguardex/badge)](https://securityscorecards.dev/viewer/?uri=github.com/recodeee/gitguardex)
6
12
 
7
13
  **GitGuardex is a safety layer for parallel agent work in git repos.** If you're running more than one Codex or Claude agent on the same codebase, this is what keeps them from deleting each other's work.
@@ -20,6 +26,8 @@ I was running ~30 Codex agents in parallel and hit a wall: they kept working on
20
26
 
21
27
  GitGuardex exists to stop that loop. Every agent gets its own worktree, claims the files it's touching, and can't clobber files another agent has claimed. Your local branch stays clean; agents stay in their lanes.
22
28
 
29
+ ### Solution
30
+
23
31
  ```mermaid
24
32
  flowchart LR
25
33
  A[Agent A adds assertions in a shared test] --> S[Several agents touch the same files]
@@ -34,6 +42,8 @@ flowchart LR
34
42
  I --> S
35
43
  ```
36
44
 
45
+ ### Dashboard
46
+
37
47
  ![Multi-agent dashboard example](https://raw.githubusercontent.com/recodeee/gitguardex/main/docs/images/dashboard-multi-agent.png)
38
48
 
39
49
  Coming soon: [recodee.com](https://recodee.com) — live account health, usage, routing, and capacity in one place.
@@ -277,6 +287,7 @@ npm i -g oh-my-codex
277
287
  ```
278
288
 
279
289
  Repo: <https://github.com/Yeachan-Heo/oh-my-codex>
290
+ [![GitHub stars](https://img.shields.io/github/stars/Yeachan-Heo/oh-my-codex?style=social)](https://github.com/Yeachan-Heo/oh-my-codex)
280
291
 
281
292
  ### oh-my-claudecode — Claude Code equivalent
282
293
 
@@ -287,6 +298,7 @@ npm i -g oh-my-claude-sisyphus@latest
287
298
  ```
288
299
 
289
300
  Repo: <https://github.com/Yeachan-Heo/oh-my-claudecode>
301
+ [![GitHub stars](https://img.shields.io/github/stars/Yeachan-Heo/oh-my-claudecode?style=social)](https://github.com/Yeachan-Heo/oh-my-claudecode)
290
302
 
291
303
  ### Caveman — output compression for long agent runs
292
304
 
@@ -297,6 +309,7 @@ npx skills add JuliusBrussee/caveman
297
309
  ```
298
310
 
299
311
  Repo: <https://github.com/JuliusBrussee/caveman>
312
+ [![GitHub stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=social)](https://github.com/JuliusBrussee/caveman)
300
313
 
301
314
  ### Cavemem — local persistent memory for agents
302
315
 
@@ -309,6 +322,7 @@ cavemem status
309
322
  ```
310
323
 
311
324
  Repo: <https://github.com/JuliusBrussee/cavemem>
325
+ [![GitHub stars](https://img.shields.io/github/stars/JuliusBrussee/cavemem?style=social)](https://github.com/JuliusBrussee/cavemem)
312
326
 
313
327
  ### Cavekit — spec-driven build loop
314
328
 
@@ -319,6 +333,7 @@ npx skills add JuliusBrussee/cavekit
319
333
  ```
320
334
 
321
335
  Repo: <https://github.com/JuliusBrussee/cavekit>
336
+ [![GitHub stars](https://img.shields.io/github/stars/JuliusBrussee/cavekit?style=social)](https://github.com/JuliusBrussee/cavekit)
322
337
 
323
338
  ### OpenSpec — spec-driven workflows
324
339
 
@@ -329,6 +344,7 @@ npm i -g @fission-ai/openspec
329
344
  ```
330
345
 
331
346
  Repo: <https://github.com/Fission-AI/OpenSpec>
347
+ [![GitHub stars](https://img.shields.io/github/stars/Fission-AI/OpenSpec?style=social)](https://github.com/Fission-AI/OpenSpec)
332
348
 
333
349
  ### codex-auth — multi-account switcher
334
350
 
@@ -344,6 +360,7 @@ codex-auth current
344
360
  ```
345
361
 
346
362
  Repo: [recodeecom/codex-account-switcher-cli](https://github.com/recodeecom/codex-account-switcher-cli)
363
+ [![GitHub stars](https://img.shields.io/github/stars/recodeecom/codex-account-switcher-cli?style=social)](https://github.com/recodeecom/codex-account-switcher-cli)
347
364
 
348
365
  ### GitHub CLI (`gh`)
349
366
 
@@ -490,6 +507,15 @@ npm pack --dry-run
490
507
  <details>
491
508
  <summary><strong>v7.x</strong></summary>
492
509
 
510
+ ### v7.0.14
511
+ - Bumped `@imdeadpool/guardex` from `7.0.13` → `7.0.14` after npm rejected a republish over the already-published `7.0.13`.
512
+ - No package payload changes beyond the release metadata bump; this release exists so `npm publish` can proceed with a fresh semver.
513
+
514
+ ### v7.0.13
515
+ - `gx status` and `gx setup` now present the Claude companion as `oh-my-claudecode` while still installing the published npm package `oh-my-claude-sisyphus`.
516
+ - When that dependency is inactive or the user declines the optional install, Guardex now prints the upstream repo URL so the missing dependency is explicit instead of hidden behind the npm package name.
517
+ - Bumped `@imdeadpool/guardex` from `7.0.12` → `7.0.13` after npm rejected a republish over the already-published `7.0.12`.
518
+
493
519
  ### v7.0.12
494
520
  - Fixed the self-update handoff after `gx` installs a newer global package. When the on-disk install advances, GitGuardex now restarts into the installed CLI instead of continuing in the old process and printing the stale in-memory version.
495
521
  - This removes the confusing `Updated to latest published version` followed by `CLI: ...7.0.10` mismatch that happened when `7.0.11` finished installing during the same `gx` invocation.
@@ -13,15 +13,26 @@ const SHORT_TOOL_NAME = 'gx';
13
13
  const LEGACY_NAMES = ['guardex', 'multiagent-safety'];
14
14
  const OPENSPEC_PACKAGE = '@fission-ai/openspec';
15
15
  const OMC_PACKAGE = 'oh-my-claude-sisyphus';
16
+ const OMC_REPO_URL = 'https://github.com/Yeachan-Heo/oh-my-claudecode';
16
17
  const CAVEMEM_PACKAGE = 'cavemem';
17
18
  const NPX_BIN = process.env.GUARDEX_NPX_BIN || 'npx';
18
19
  const GUARDEX_HOME_DIR = path.resolve(process.env.GUARDEX_HOME_DIR || os.homedir());
20
+ const GLOBAL_TOOLCHAIN_SERVICES = [
21
+ { name: 'oh-my-codex', packageName: 'oh-my-codex' },
22
+ {
23
+ name: 'oh-my-claudecode',
24
+ packageName: OMC_PACKAGE,
25
+ dependencyUrl: OMC_REPO_URL,
26
+ },
27
+ { name: OPENSPEC_PACKAGE, packageName: OPENSPEC_PACKAGE },
28
+ { name: CAVEMEM_PACKAGE, packageName: CAVEMEM_PACKAGE },
29
+ {
30
+ name: '@imdeadpool/codex-account-switcher',
31
+ packageName: '@imdeadpool/codex-account-switcher',
32
+ },
33
+ ];
19
34
  const GLOBAL_TOOLCHAIN_PACKAGES = [
20
- 'oh-my-codex',
21
- OMC_PACKAGE,
22
- OPENSPEC_PACKAGE,
23
- CAVEMEM_PACKAGE,
24
- '@imdeadpool/codex-account-switcher',
35
+ ...GLOBAL_TOOLCHAIN_SERVICES.map((service) => service.packageName),
25
36
  ];
26
37
  const OPTIONAL_LOCAL_COMPANION_TOOLS = [
27
38
  {
@@ -104,15 +115,26 @@ const REQUIRED_WORKFLOW_FILES = [
104
115
  ];
105
116
 
106
117
  const REQUIRED_PACKAGE_SCRIPTS = {
118
+ 'agent:codex': 'bash ./scripts/codex-agent.sh',
107
119
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
108
120
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
109
- 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh',
121
+ 'agent:cleanup': 'gx cleanup',
110
122
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
111
123
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
124
+ 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
112
125
  'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release',
113
126
  'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status',
114
127
  'agent:plan:init': 'bash ./scripts/openspec/init-plan-workspace.sh',
115
128
  'agent:change:init': 'bash ./scripts/openspec/init-change-workspace.sh',
129
+ 'agent:protect:list': 'gx protect list',
130
+ 'agent:branch:sync': 'gx sync',
131
+ 'agent:branch:sync:check': 'gx sync --check',
132
+ 'agent:safety:setup': 'gx setup',
133
+ 'agent:safety:scan': 'gx status --strict',
134
+ 'agent:safety:fix': 'gx setup --repair',
135
+ 'agent:safety:doctor': 'gx doctor',
136
+ 'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
137
+ 'agent:finish': 'gx finish --all',
116
138
  };
117
139
 
118
140
  const EXECUTABLE_RELATIVE_PATHS = new Set([
@@ -154,26 +176,15 @@ const GITIGNORE_MARKER_END = '# multiagent-safety:END';
154
176
  const MANAGED_GITIGNORE_PATHS = [
155
177
  '.omx/',
156
178
  '.omc/',
157
- 'scripts/agent-branch-start.sh',
158
- 'scripts/agent-branch-finish.sh',
159
- 'scripts/codex-agent.sh',
160
- 'scripts/review-bot-watch.sh',
161
- 'scripts/agent-worktree-prune.sh',
162
- 'scripts/agent-file-locks.py',
163
- 'scripts/guardex-env.sh',
164
- 'scripts/install-agent-git-hooks.sh',
165
- 'scripts/openspec/init-plan-workspace.sh',
166
- 'scripts/openspec/init-change-workspace.sh',
167
- '.githooks/pre-commit',
168
- '.githooks/pre-push',
169
- '.githooks/post-merge',
170
- '.githooks/post-checkout',
179
+ 'scripts/*',
180
+ '.githooks',
171
181
  'oh-my-codex/',
172
182
  '.codex/skills/gitguardex/SKILL.md',
173
183
  '.codex/skills/guardex-merge-skills-to-dev/SKILL.md',
174
184
  '.claude/commands/gitguardex.md',
175
185
  LOCK_FILE_RELATIVE,
176
186
  ];
187
+ const REPO_SCAFFOLD_DIRECTORIES = ['bin'];
177
188
  const OMX_SCAFFOLD_DIRECTORIES = [
178
189
  '.omx',
179
190
  '.omx/state',
@@ -255,14 +266,14 @@ const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in thi
255
266
  2) Bootstrap: gx setup
256
267
  3) Repair: gx doctor
257
268
  4) Task loop: bash scripts/codex-agent.sh "<task>" "<agent>"
258
- or branch-start -> claim -> branch-finish
269
+ or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish
259
270
  5) Finish: gx finish --all
260
271
  6) Cleanup: gx cleanup
261
272
  7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
262
273
  8) Optional: gx protect add release staging
263
274
  9) Optional: gx sync --check && gx sync
264
275
  10) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
265
- 11) Fork sync: cp .github/pull.yml.example .github/pull.yml
276
+ 11) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
266
277
  `;
267
278
 
268
279
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
@@ -270,6 +281,7 @@ gh --version
270
281
  gx setup
271
282
  gx doctor
272
283
  bash scripts/codex-agent.sh "<task>" "<agent>"
284
+ python3 scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>
273
285
  gx finish --all
274
286
  gx cleanup
275
287
  gx protect add release staging
@@ -587,9 +599,27 @@ function toDestinationPath(relativeTemplatePath) {
587
599
  throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
588
600
  }
589
601
 
590
- function ensureParentDir(filePath, dryRun) {
602
+ function ensureParentDir(repoRoot, filePath, dryRun) {
591
603
  if (dryRun) return;
592
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
604
+
605
+ const parentDir = path.dirname(filePath);
606
+ const relativeParentDir = path.relative(repoRoot, parentDir);
607
+ const segments = relativeParentDir.split(path.sep).filter(Boolean);
608
+ let currentPath = repoRoot;
609
+
610
+ for (const segment of segments) {
611
+ currentPath = path.join(currentPath, segment);
612
+ if (fs.existsSync(currentPath) && !fs.statSync(currentPath).isDirectory()) {
613
+ const blockingPath = path.relative(repoRoot, currentPath) || path.basename(currentPath);
614
+ const targetPath = path.relative(repoRoot, filePath) || path.basename(filePath);
615
+ throw new Error(
616
+ `Path conflict: ${blockingPath} exists as a file, but ${targetPath} needs it to be a directory. ` +
617
+ `Remove or rename ${blockingPath} and rerun '${SHORT_TOOL_NAME} setup'.`,
618
+ );
619
+ }
620
+ }
621
+
622
+ fs.mkdirSync(parentDir, { recursive: true });
593
623
  }
594
624
 
595
625
  function ensureExecutable(destinationPath, relativePath, dryRun) {
@@ -624,7 +654,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
624
654
  }
625
655
  }
626
656
 
627
- ensureParentDir(destinationPath, dryRun);
657
+ ensureParentDir(repoRoot, destinationPath, dryRun);
628
658
  if (!dryRun) {
629
659
  fs.writeFileSync(destinationPath, sourceContent, 'utf8');
630
660
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
@@ -662,7 +692,7 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
662
692
  return { status: 'skipped-conflict', file: destinationRelativePath };
663
693
  }
664
694
 
665
- ensureParentDir(destinationPath, dryRun);
695
+ ensureParentDir(repoRoot, destinationPath, dryRun);
666
696
  if (!dryRun) {
667
697
  fs.writeFileSync(destinationPath, sourceContent, 'utf8');
668
698
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
@@ -678,6 +708,22 @@ function lockFilePath(repoRoot) {
678
708
  function ensureOmxScaffold(repoRoot, dryRun) {
679
709
  const operations = [];
680
710
 
711
+ for (const relativeDir of REPO_SCAFFOLD_DIRECTORIES) {
712
+ const absoluteDir = path.join(repoRoot, relativeDir);
713
+ if (fs.existsSync(absoluteDir)) {
714
+ if (!fs.statSync(absoluteDir).isDirectory()) {
715
+ throw new Error(`Expected directory at ${relativeDir} but found a file.`);
716
+ }
717
+ operations.push({ status: 'unchanged', file: relativeDir });
718
+ continue;
719
+ }
720
+
721
+ if (!dryRun) {
722
+ fs.mkdirSync(absoluteDir, { recursive: true });
723
+ }
724
+ operations.push({ status: 'created', file: relativeDir });
725
+ }
726
+
681
727
  for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
682
728
  const absoluteDir = path.join(repoRoot, relativeDir);
683
729
  if (fs.existsSync(absoluteDir)) {
@@ -965,10 +1011,9 @@ function parseCommonArgs(rawArgs, defaults) {
965
1011
  return options;
966
1012
  }
967
1013
 
968
- function parseSetupArgs(rawArgs, defaults) {
969
- const setupDefaults = {
1014
+ function parseRepoTraversalArgs(rawArgs, defaults) {
1015
+ const traversalDefaults = {
970
1016
  ...defaults,
971
- parentWorkspaceView: false,
972
1017
  recursive: true,
973
1018
  nestedMaxDepth: NESTED_REPO_DEFAULT_MAX_DEPTH,
974
1019
  nestedSkipDirs: [],
@@ -978,20 +1023,12 @@ function parseSetupArgs(rawArgs, defaults) {
978
1023
 
979
1024
  for (let index = 0; index < rawArgs.length; index += 1) {
980
1025
  const arg = rawArgs[index];
981
- if (arg === '--parent-workspace-view') {
982
- setupDefaults.parentWorkspaceView = true;
983
- continue;
984
- }
985
- if (arg === '--no-parent-workspace-view') {
986
- setupDefaults.parentWorkspaceView = false;
987
- continue;
988
- }
989
1026
  if (arg === '--no-recursive' || arg === '--no-nested' || arg === '--single-repo') {
990
- setupDefaults.recursive = false;
1027
+ traversalDefaults.recursive = false;
991
1028
  continue;
992
1029
  }
993
1030
  if (arg === '--recursive' || arg === '--nested') {
994
- setupDefaults.recursive = true;
1031
+ traversalDefaults.recursive = true;
995
1032
  continue;
996
1033
  }
997
1034
  if (arg === '--max-depth') {
@@ -1000,47 +1037,60 @@ function parseSetupArgs(rawArgs, defaults) {
1000
1037
  if (!Number.isFinite(parsed) || parsed < 1) {
1001
1038
  throw new Error('--max-depth requires a positive integer');
1002
1039
  }
1003
- setupDefaults.nestedMaxDepth = parsed;
1040
+ traversalDefaults.nestedMaxDepth = parsed;
1004
1041
  index += 1;
1005
1042
  continue;
1006
1043
  }
1007
1044
  if (arg === '--skip-nested') {
1008
1045
  const raw = requireValue(rawArgs, index, '--skip-nested');
1009
- setupDefaults.nestedSkipDirs.push(raw);
1046
+ traversalDefaults.nestedSkipDirs.push(raw);
1010
1047
  index += 1;
1011
1048
  continue;
1012
1049
  }
1013
1050
  if (arg === '--include-submodules') {
1014
- setupDefaults.includeSubmodules = true;
1051
+ traversalDefaults.includeSubmodules = true;
1015
1052
  continue;
1016
1053
  }
1017
1054
  forwardedArgs.push(arg);
1018
1055
  }
1019
1056
 
1020
- return parseCommonArgs(forwardedArgs, setupDefaults);
1057
+ return parseCommonArgs(forwardedArgs, traversalDefaults);
1021
1058
  }
1022
1059
 
1023
- function parseDoctorArgs(rawArgs) {
1024
- const options = {
1025
- target: process.cwd(),
1026
- strict: false,
1060
+ function parseSetupArgs(rawArgs, defaults) {
1061
+ const setupDefaults = {
1062
+ ...defaults,
1063
+ parentWorkspaceView: false,
1027
1064
  };
1065
+ const forwardedArgs = [];
1028
1066
 
1029
1067
  for (let index = 0; index < rawArgs.length; index += 1) {
1030
1068
  const arg = rawArgs[index];
1031
- if (arg === '--target' || arg === '-t') {
1032
- options.target = requireValue(rawArgs, index, '--target');
1033
- index += 1;
1069
+ if (arg === '--parent-workspace-view') {
1070
+ setupDefaults.parentWorkspaceView = true;
1034
1071
  continue;
1035
1072
  }
1036
- if (arg === '--strict') {
1037
- options.strict = true;
1073
+ if (arg === '--no-parent-workspace-view') {
1074
+ setupDefaults.parentWorkspaceView = false;
1038
1075
  continue;
1039
1076
  }
1040
- throw new Error(`Unknown option: ${arg}`);
1077
+ forwardedArgs.push(arg);
1041
1078
  }
1042
1079
 
1043
- return options;
1080
+ return parseRepoTraversalArgs(forwardedArgs, setupDefaults);
1081
+ }
1082
+
1083
+ function parseDoctorArgs(rawArgs) {
1084
+ return parseRepoTraversalArgs(rawArgs, {
1085
+ target: process.cwd(),
1086
+ dropStaleLocks: true,
1087
+ skipAgents: false,
1088
+ skipPackageJson: false,
1089
+ skipGitignore: false,
1090
+ dryRun: false,
1091
+ json: false,
1092
+ allowProtectedBaseWrite: false,
1093
+ });
1044
1094
  }
1045
1095
 
1046
1096
  function normalizeWorkspacePath(relativePath) {
@@ -1530,7 +1580,7 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1530
1580
 
1531
1581
  const finishResult = run(
1532
1582
  'bash',
1533
- [finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
1583
+ [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', '--wait-for-merge'],
1534
1584
  { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1535
1585
  );
1536
1586
  if (isSpawnFailure(finishResult)) {
@@ -1569,6 +1619,33 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1569
1619
  };
1570
1620
  }
1571
1621
 
1622
+ function syncProtectedBaseDoctorRepairs(options, blocked) {
1623
+ const fixPayload = runFixInternal({
1624
+ ...options,
1625
+ target: blocked.repoRoot,
1626
+ allowProtectedBaseWrite: true,
1627
+ });
1628
+ const changedOperations = fixPayload.operations.filter(
1629
+ (operation) => !['unchanged', 'skipped'].includes(operation.status),
1630
+ );
1631
+ const hookChanged = fixPayload.hookResult?.status && fixPayload.hookResult.status !== 'unchanged';
1632
+ const changedCount = changedOperations.length + (hookChanged ? 1 : 0);
1633
+
1634
+ if (changedCount === 0) {
1635
+ return {
1636
+ status: 'unchanged',
1637
+ note: 'managed repair files already aligned in protected branch workspace',
1638
+ fixPayload,
1639
+ };
1640
+ }
1641
+
1642
+ return {
1643
+ status: options.dryRun ? 'would-sync' : 'synced',
1644
+ note: `${options.dryRun ? 'would sync' : 'synced'} ${changedCount} managed repair item(s)`,
1645
+ fixPayload,
1646
+ };
1647
+ }
1648
+
1572
1649
  function runDoctorInSandbox(options, blocked) {
1573
1650
  const startResult = startDoctorSandbox(blocked);
1574
1651
  const metadata = startResult.metadata;
@@ -1592,6 +1669,10 @@ function runDoctorInSandbox(options, blocked) {
1592
1669
  note: 'sandbox doctor did not complete successfully',
1593
1670
  };
1594
1671
 
1672
+ let protectedBaseRepairSyncResult = {
1673
+ status: 'skipped',
1674
+ note: 'sandbox doctor did not complete successfully',
1675
+ };
1595
1676
  let lockSyncResult = {
1596
1677
  status: 'skipped',
1597
1678
  note: 'sandbox doctor did not complete successfully',
@@ -1609,6 +1690,7 @@ function runDoctorInSandbox(options, blocked) {
1609
1690
  note: 'sandbox doctor did not complete successfully',
1610
1691
  };
1611
1692
  if (nestedResult.status === 0) {
1693
+ protectedBaseRepairSyncResult = syncProtectedBaseDoctorRepairs(options, blocked);
1612
1694
  const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
1613
1695
  const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
1614
1696
  if (changedOmxPaths.length === 0) {
@@ -1697,6 +1779,7 @@ function runDoctorInSandbox(options, blocked) {
1697
1779
  JSON.stringify(
1698
1780
  {
1699
1781
  ...parsed,
1782
+ protectedBaseRepairSync: protectedBaseRepairSyncResult,
1700
1783
  sandboxOmxScaffoldSync: omxScaffoldSyncResult,
1701
1784
  sandboxLockSync: lockSyncResult,
1702
1785
  sandboxAutoCommit: autoCommitResult,
@@ -1737,6 +1820,16 @@ function runDoctorInSandbox(options, blocked) {
1737
1820
  console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
1738
1821
  }
1739
1822
 
1823
+ if (protectedBaseRepairSyncResult.status === 'synced') {
1824
+ console.log(`[${TOOL_NAME}] Synced repaired managed files back to protected branch workspace.`);
1825
+ } else if (protectedBaseRepairSyncResult.status === 'unchanged') {
1826
+ console.log(`[${TOOL_NAME}] Protected branch workspace already had the repaired managed files.`);
1827
+ } else if (protectedBaseRepairSyncResult.status === 'would-sync') {
1828
+ console.log(`[${TOOL_NAME}] Dry run: would sync repaired managed files back to protected branch workspace.`);
1829
+ } else {
1830
+ console.log(`[${TOOL_NAME}] Protected branch workspace repair sync skipped: ${protectedBaseRepairSyncResult.note}.`);
1831
+ }
1832
+
1740
1833
  if (finishResult.status === 'completed') {
1741
1834
  console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
1742
1835
  if (finishResult.stdout) process.stdout.write(finishResult.stdout);
@@ -3629,6 +3722,36 @@ function resolveGlobalInstallApproval(options) {
3629
3722
  return { approved: true, source: 'prompt' };
3630
3723
  }
3631
3724
 
3725
+ function getGlobalToolchainService(packageName) {
3726
+ const service = GLOBAL_TOOLCHAIN_SERVICES.find(
3727
+ (candidate) => candidate.packageName === packageName,
3728
+ );
3729
+ return service || { name: packageName, packageName };
3730
+ }
3731
+
3732
+ function formatGlobalToolchainServiceName(packageName) {
3733
+ return getGlobalToolchainService(packageName).name;
3734
+ }
3735
+
3736
+ function describeMissingGlobalDependencyWarnings(packageNames) {
3737
+ return packageNames
3738
+ .map((packageName) => getGlobalToolchainService(packageName))
3739
+ .filter((service) => service.dependencyUrl)
3740
+ .map(
3741
+ (service) =>
3742
+ `Guardex needs ${service.name} as a dependency: ${service.dependencyUrl}`,
3743
+ );
3744
+ }
3745
+
3746
+ function buildMissingCompanionInstallPrompt(missingPackages, missingLocalTools) {
3747
+ const dependencyWarnings = describeMissingGlobalDependencyWarnings(missingPackages);
3748
+ const installCommands = describeCompanionInstallCommands(missingPackages, missingLocalTools);
3749
+ const dependencyPrefix = dependencyWarnings.length > 0
3750
+ ? `${dependencyWarnings.join(' ')} `
3751
+ : '';
3752
+ return `${dependencyPrefix}Install missing companion tools now? (${installCommands.join(' && ')})`;
3753
+ }
3754
+
3632
3755
  function detectGlobalToolchainPackages() {
3633
3756
  const result = run(NPM_BIN, ['list', '-g', '--depth=0', '--json']);
3634
3757
  if (result.status !== 0) {
@@ -3721,17 +3844,15 @@ function describeCompanionInstallCommands(missingPackages, missingLocalTools) {
3721
3844
  return commands;
3722
3845
  }
3723
3846
 
3724
- function askGlobalInstallForMissing(options, missingPackages) {
3847
+ function askGlobalInstallForMissing(options, missingPackages, missingLocalTools) {
3725
3848
  const approval = resolveGlobalInstallApproval(options);
3726
3849
  if (!approval.approved) {
3727
3850
  return approval;
3728
3851
  }
3729
3852
 
3730
- const missingLocalTools = detectOptionalLocalCompanionTools().filter((tool) => tool.status !== 'active');
3731
- const installCommands = describeCompanionInstallCommands(missingPackages, missingLocalTools);
3732
3853
  if (approval.source === 'prompt') {
3733
3854
  const approved = promptYesNoStrict(
3734
- `Install missing companion tools now? (${installCommands.join(' && ')})`,
3855
+ buildMissingCompanionInstallPrompt(missingPackages, missingLocalTools),
3735
3856
  );
3736
3857
  return { approved, source: 'prompt' };
3737
3858
  }
@@ -3750,7 +3871,10 @@ function installGlobalToolchain(options) {
3750
3871
  console.log(`[${TOOL_NAME}] ⚠️ Could not detect global packages: ${detection.error}`);
3751
3872
  } else {
3752
3873
  if (detection.installed.length > 0) {
3753
- console.log(`[${TOOL_NAME}] Already installed globally: ${detection.installed.join(', ')}`);
3874
+ console.log(
3875
+ `[${TOOL_NAME}] Already installed globally: ` +
3876
+ `${detection.installed.map((pkg) => formatGlobalToolchainServiceName(pkg)).join(', ')}`,
3877
+ );
3754
3878
  }
3755
3879
  const installedLocalTools = localCompanionTools
3756
3880
  .filter((tool) => tool.status === 'active')
@@ -3765,9 +3889,14 @@ function installGlobalToolchain(options) {
3765
3889
 
3766
3890
  const missingPackages = detection.ok ? detection.missing : [...GLOBAL_TOOLCHAIN_PACKAGES];
3767
3891
  const missingLocalTools = localCompanionTools.filter((tool) => tool.status !== 'active');
3768
- const approval = askGlobalInstallForMissing(options, missingPackages);
3892
+ const approval = askGlobalInstallForMissing(options, missingPackages, missingLocalTools);
3769
3893
  if (!approval.approved) {
3770
- return { status: 'skipped', reason: approval.source };
3894
+ return {
3895
+ status: 'skipped',
3896
+ reason: approval.source,
3897
+ missingPackages,
3898
+ missingLocalTools,
3899
+ };
3771
3900
  }
3772
3901
 
3773
3902
  const installed = [];
@@ -3850,6 +3979,10 @@ function runInstallInternal(options) {
3850
3979
  }
3851
3980
  const operations = [];
3852
3981
 
3982
+ if (!options.skipGitignore) {
3983
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3984
+ }
3985
+
3853
3986
  operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3854
3987
 
3855
3988
  for (const templateFile of TEMPLATE_FILES) {
@@ -3857,9 +3990,6 @@ function runInstallInternal(options) {
3857
3990
  }
3858
3991
 
3859
3992
  operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
3860
- if (!options.skipGitignore) {
3861
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3862
- }
3863
3993
 
3864
3994
  if (!options.skipPackageJson) {
3865
3995
  operations.push(ensurePackageScripts(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
@@ -3894,6 +4024,10 @@ function runFixInternal(options) {
3894
4024
  }
3895
4025
  const operations = [];
3896
4026
 
4027
+ if (!options.skipGitignore) {
4028
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
4029
+ }
4030
+
3897
4031
  operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3898
4032
 
3899
4033
  for (const templateFile of TEMPLATE_FILES) {
@@ -3901,9 +4035,6 @@ function runFixInternal(options) {
3901
4035
  }
3902
4036
 
3903
4037
  operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
3904
- if (!options.skipGitignore) {
3905
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3906
- }
3907
4038
 
3908
4039
  const lockState = lockStateOrError(repoRoot);
3909
4040
  if (!lockState.ok) {
@@ -4142,11 +4273,21 @@ function status(rawArgs) {
4142
4273
 
4143
4274
  const toolchain = detectGlobalToolchainPackages();
4144
4275
  const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
4276
+ const service = getGlobalToolchainService(pkg);
4145
4277
  if (!toolchain.ok) {
4146
- return { name: pkg, status: 'unknown' };
4278
+ return {
4279
+ name: service.name,
4280
+ displayName: service.name,
4281
+ packageName: pkg,
4282
+ dependencyUrl: service.dependencyUrl || null,
4283
+ status: 'unknown',
4284
+ };
4147
4285
  }
4148
4286
  return {
4149
- name: pkg,
4287
+ name: service.name,
4288
+ displayName: service.name,
4289
+ packageName: pkg,
4290
+ dependencyUrl: service.dependencyUrl || null,
4150
4291
  status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
4151
4292
  };
4152
4293
  });
@@ -4224,6 +4365,13 @@ function status(rawArgs) {
4224
4365
  console.log(
4225
4366
  `[${TOOL_NAME}] Optional companion tools inactive: ${inactiveOptionalCompanions.join(', ')}`,
4226
4367
  );
4368
+ for (const warning of describeMissingGlobalDependencyWarnings(
4369
+ npmServices
4370
+ .filter((service) => service.status === 'inactive')
4371
+ .map((service) => service.packageName),
4372
+ )) {
4373
+ console.log(`[${TOOL_NAME}] ${warning}`);
4374
+ }
4227
4375
  console.log(
4228
4376
  `[${TOOL_NAME}] Run '${SHORT_TOOL_NAME} setup' to install missing companions with an explicit Y/N prompt.`,
4229
4377
  );
@@ -4357,26 +4505,116 @@ function scan(rawArgs) {
4357
4505
  }
4358
4506
 
4359
4507
  function doctor(rawArgs) {
4360
- const options = parseCommonArgs(rawArgs, {
4361
- target: process.cwd(),
4362
- dropStaleLocks: true,
4363
- skipAgents: false,
4364
- skipPackageJson: false,
4365
- skipGitignore: false,
4366
- dryRun: false,
4367
- json: false,
4368
- allowProtectedBaseWrite: false,
4369
- });
4508
+ const options = parseDoctorArgs(rawArgs);
4509
+ const topRepoRoot = resolveRepoRoot(options.target);
4510
+ const discoveredRepos = options.recursive
4511
+ ? discoverNestedGitRepos(topRepoRoot, {
4512
+ maxDepth: options.nestedMaxDepth,
4513
+ extraSkip: options.nestedSkipDirs,
4514
+ includeSubmodules: options.includeSubmodules,
4515
+ })
4516
+ : [topRepoRoot];
4517
+
4518
+ if (discoveredRepos.length > 1) {
4519
+ if (!options.json) {
4520
+ console.log(
4521
+ `[${TOOL_NAME}] Detected ${discoveredRepos.length} git repos under ${topRepoRoot}. ` +
4522
+ `Repairing each with doctor (use --single-repo to limit to the target).`,
4523
+ );
4524
+ }
4525
+
4526
+ const repoResults = [];
4527
+ let aggregateExitCode = 0;
4528
+ for (const repoPath of discoveredRepos) {
4529
+ if (!options.json) {
4530
+ console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} ──`);
4531
+ }
4532
+
4533
+ const nestedResult = run(
4534
+ process.execPath,
4535
+ [
4536
+ path.resolve(__filename),
4537
+ 'doctor',
4538
+ '--single-repo',
4539
+ '--target',
4540
+ repoPath,
4541
+ ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
4542
+ ...(options.skipAgents ? ['--skip-agents'] : []),
4543
+ ...(options.skipPackageJson ? ['--skip-package-json'] : []),
4544
+ ...(options.skipGitignore ? ['--no-gitignore'] : []),
4545
+ ...(options.dryRun ? ['--dry-run'] : []),
4546
+ ...(options.json ? ['--json'] : []),
4547
+ ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
4548
+ ],
4549
+ { cwd: topRepoRoot },
4550
+ );
4551
+ if (isSpawnFailure(nestedResult)) {
4552
+ throw nestedResult.error;
4553
+ }
4554
+
4555
+ const exitCode = typeof nestedResult.status === 'number' ? nestedResult.status : 1;
4556
+ if (exitCode !== 0 && aggregateExitCode === 0) {
4557
+ aggregateExitCode = exitCode;
4558
+ }
4559
+
4560
+ if (options.json) {
4561
+ let parsedResult = null;
4562
+ if (nestedResult.stdout) {
4563
+ try {
4564
+ parsedResult = JSON.parse(nestedResult.stdout);
4565
+ } catch {
4566
+ parsedResult = null;
4567
+ }
4568
+ }
4569
+ repoResults.push(
4570
+ parsedResult
4571
+ ? { repoRoot: repoPath, exitCode, result: parsedResult }
4572
+ : {
4573
+ repoRoot: repoPath,
4574
+ exitCode,
4575
+ stdout: nestedResult.stdout || '',
4576
+ stderr: nestedResult.stderr || '',
4577
+ },
4578
+ );
4579
+ } else {
4580
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
4581
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
4582
+ process.stdout.write('\n');
4583
+ }
4584
+ }
4370
4585
 
4371
- const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false });
4586
+ if (options.json) {
4587
+ process.stdout.write(
4588
+ JSON.stringify(
4589
+ {
4590
+ repoRoot: topRepoRoot,
4591
+ recursive: true,
4592
+ repos: repoResults,
4593
+ },
4594
+ null,
4595
+ 2,
4596
+ ) + '\n',
4597
+ );
4598
+ }
4599
+
4600
+ process.exitCode = aggregateExitCode;
4601
+ return;
4602
+ }
4603
+
4604
+ const singleRepoOptions = {
4605
+ ...options,
4606
+ target: topRepoRoot,
4607
+ };
4608
+
4609
+ const blocked = protectedBaseWriteBlock(singleRepoOptions, { requireBootstrap: false });
4372
4610
  if (blocked) {
4373
- runDoctorInSandbox(options, blocked);
4611
+ runDoctorInSandbox(singleRepoOptions, blocked);
4374
4612
  return;
4375
4613
  }
4376
4614
 
4377
- assertProtectedMainWriteAllowed(options, 'doctor');
4378
- const fixPayload = runFixInternal(options);
4379
- const scanResult = runScanInternal({ target: options.target, json: false });
4615
+ assertProtectedMainWriteAllowed(singleRepoOptions, 'doctor');
4616
+ const fixPayload = runFixInternal(singleRepoOptions);
4617
+ const scanResult = runScanInternal({ target: singleRepoOptions.target, json: false });
4380
4618
  const currentBaseBranch = currentBranchName(scanResult.repoRoot);
4381
4619
  const autoFinishSummary = scanResult.guardexEnabled === false
4382
4620
  ? {
@@ -4389,12 +4627,12 @@ function doctor(rawArgs) {
4389
4627
  }
4390
4628
  : autoFinishReadyAgentBranches(scanResult.repoRoot, {
4391
4629
  baseBranch: currentBaseBranch,
4392
- dryRun: options.dryRun,
4630
+ dryRun: singleRepoOptions.dryRun,
4393
4631
  });
4394
4632
  const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
4395
4633
  const musafe = safe;
4396
4634
 
4397
- if (options.json) {
4635
+ if (singleRepoOptions.json) {
4398
4636
  process.stdout.write(
4399
4637
  JSON.stringify(
4400
4638
  {
@@ -4405,7 +4643,7 @@ function doctor(rawArgs) {
4405
4643
  fix: {
4406
4644
  operations: fixPayload.operations,
4407
4645
  hookResult: fixPayload.hookResult,
4408
- dryRun: Boolean(options.dryRun),
4646
+ dryRun: Boolean(singleRepoOptions.dryRun),
4409
4647
  },
4410
4648
  scan: {
4411
4649
  guardexEnabled: scanResult.guardexEnabled !== false,
@@ -4424,7 +4662,7 @@ function doctor(rawArgs) {
4424
4662
  return;
4425
4663
  }
4426
4664
 
4427
- printOperations('Doctor/fix', fixPayload, options.dryRun);
4665
+ printOperations('Doctor/fix', fixPayload, singleRepoOptions.dryRun);
4428
4666
  printScanResult(scanResult, false);
4429
4667
  if (scanResult.guardexEnabled === false) {
4430
4668
  console.log(`[${TOOL_NAME}] Repo-local Guardex enforcement is intentionally disabled.`);
@@ -4881,6 +5119,13 @@ function setup(rawArgs) {
4881
5119
  `[${TOOL_NAME}] Skipping companion installs (non-interactive mode). ` +
4882
5120
  `Use --yes-global-install to force or run interactively for Y/N prompt.`,
4883
5121
  );
5122
+ } else if (globalInstallStatus.status === 'skipped') {
5123
+ console.log(`[${TOOL_NAME}] ⚠️ Companion installs skipped by user choice.`);
5124
+ for (const warning of describeMissingGlobalDependencyWarnings(
5125
+ globalInstallStatus.missingPackages || [],
5126
+ )) {
5127
+ console.log(`[${TOOL_NAME}] ⚠️ ${warning}`);
5128
+ }
4884
5129
  }
4885
5130
  const requiredSystemTools = detectRequiredSystemTools();
4886
5131
  const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "7.0.12",
3
+ "version": "7.0.14",
4
4
  "description": "GitGuardex: hardened multi-agent git guardrails for parallel agent work.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
@@ -1,21 +1,28 @@
1
1
  <!-- multiagent-safety:START -->
2
- ## Multi-Agent Safety Contract
2
+ ## Multi-Agent Execution Contract (GX)
3
3
 
4
- **Repo toggle.** `GUARDEX_ON=0|false|no|off` disables this contract. `GUARDEX_ON=1|true|yes|on` re-enables it.
4
+ **Repo toggle.** Guardex is enabled by default. If the repo root `.env` sets `GUARDEX_ON=0`, `false`, `no`, or `off`, treat this entire Guardex contract as disabled for the repo and do not require Guardex worktrees, lock claims, completion flow, or OpenSpec workflow until `GUARDEX_ON` is re-enabled.
5
5
 
6
- **Isolation.** One task = one `agent/*` branch + worktree. Start `scripts/agent-branch-start.sh "<task>" "<agent>"`. Base branches stay read-only. No `git checkout` on primary worktrees; use `git worktree add`. `.githooks/post-checkout` auto-reverts primary-branch switches unless `GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1`.
6
+ **Repo toggle examples.** Add one of these lines to the repo-root `.env` file:
7
+ `GUARDEX_ON=0` disables Guardex for that repo.
8
+ `GUARDEX_ON=1` explicitly enables Guardex for that repo again.
7
9
 
8
- **Ownership.** Claim before edits: `scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>`. Delete only claimed paths.
10
+ **Isolation.** Every task runs on a dedicated `agent/*` branch + worktree. Start with `scripts/agent-branch-start.sh "<task>" "<agent-name>"`. Treat the base branch (`main`/`dev`) as read-only while an agent branch is active. Never `git checkout <branch>` on a primary working tree (including nested repos); use `git worktree add` instead. The `.githooks/post-checkout` hook auto-reverts primary-branch switches during agent sessions - bypass only with `GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1`.
11
+ For every new task, including follow-up work in the same chat/session, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch instead of creating a fresh lane unless the user explicitly redirects scope.
12
+ Never implement directly on the local/base branch checkout; keep it unchanged and perform all edits in the agent sub-branch/worktree.
9
13
 
10
- **Handoff.** Post a one-line note before edits. Re-read latest handoffs before replacing nearby work.
14
+ **Ownership.** Before editing, claim files: `scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>`. Before deleting, confirm the path is in your claim. Don't edit outside your scope unless reassigned.
11
15
 
12
- **Completion.** Finish with `scripts/agent-branch-finish.sh --branch "<agent-branch>" --via-pr --wait-for-merge --cleanup` or `gx finish --all`. Done = commit pushed, PR URL recorded, state=`MERGED`, sandbox pruned. If blocked, append `BLOCKED:` and stop.
16
+ **Handoff gate.** Post a one-line handoff note (plan/change, owned scope, intended action) before editing. Re-read the latest handoffs before replacing others' code.
13
17
 
14
- **Parallel safety.** Never revert unrelated edits. Report conflicts.
18
+ **Completion.** Finish with `scripts/agent-branch-finish.sh --branch "<agent-branch>" --via-pr --wait-for-merge --cleanup` (or `gx finish --all`). Task is only complete when: commit pushed, PR URL recorded, state = `MERGED`, sandbox worktree pruned. If anything blocks, append a `BLOCKED:` note and stop - don't half-finish.
19
+ OMX completion policy: when a task is done, the agent must commit the task changes, push the agent branch, and create/update a PR before considering the branch complete.
15
20
 
16
- **Reporting.** Completion handoff includes files changed, behavior touched, verification commands/results, and risks/follow-ups.
21
+ **Parallel safety.** Assume other agents edit nearby. Never revert unrelated changes. Report conflicts in the handoff.
17
22
 
18
- **OpenSpec.** Keep `openspec/changes/<slug>/tasks.md` current. End task scaffolds with PR merge + sandbox cleanup evidence. Run `openspec validate --specs` before archive.
23
+ **Reporting.** Every completion handoff includes: files changed, behavior touched, verification commands + results, risks/follow-ups.
24
+
25
+ **OpenSpec (when change-driven).** Keep `openspec/changes/<slug>/tasks.md` checkboxes current during work, not batched at the end. Task scaffolds and manual task edits must include an explicit final completion/cleanup section that ends with PR merge + sandbox cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `scripts/agent-branch-finish.sh ... --cleanup`) and records PR URL + final `MERGED` evidence. Verify specs with `openspec validate --specs` before archive. Don't archive unverified.
19
26
 
20
27
  **Version bumps.** If a change bumps a published version, the same PR updates release notes/changelog.
21
28
  <!-- multiagent-safety:END -->
@@ -10,12 +10,18 @@ permissions:
10
10
 
11
11
  jobs:
12
12
  review:
13
- if: ${{ secrets.OPENAI_API_KEY != '' }}
14
13
  runs-on: ubuntu-latest
14
+ env:
15
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
15
16
  steps:
16
- - uses: anc95/ChatGPT-CodeReview@main
17
+ - name: Skip when OPENAI_API_KEY is missing
18
+ if: ${{ env.OPENAI_API_KEY == '' }}
19
+ run: echo "OPENAI_API_KEY is not configured; skipping Code Review workflow."
20
+
21
+ - uses: anc95/ChatGPT-CodeReview@1e3df152c1b85c12da580b206c91ad343460c584 # v1.0.23
22
+ if: ${{ env.OPENAI_API_KEY != '' }}
17
23
  env:
18
24
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19
- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
25
+ OPENAI_API_KEY: ${{ env.OPENAI_API_KEY }}
20
26
  OPENAI_API_ENDPOINT: https://api.openai.com/v1
21
27
  MODEL: gpt-4o-mini
@@ -8,11 +8,16 @@ FORCE_DIRTY=0
8
8
  DELETE_BRANCHES=0
9
9
  DELETE_REMOTE_BRANCHES=0
10
10
  ONLY_DIRTY_WORKTREES=0
11
+ INCLUDE_PR_MERGED=0
11
12
  TARGET_BRANCH=""
12
13
  IDLE_MINUTES=0
13
14
  NOW_EPOCH_RAW="${GUARDEX_PRUNE_NOW_EPOCH:-}"
14
15
  IDLE_SECONDS=0
15
16
  NOW_EPOCH=0
17
+ GH_BIN="${GUARDEX_GH_BIN:-gh}"
18
+ PR_MERGED_LOOKUP_DISABLED=0
19
+ PR_MERGED_LOOKUP_LOADED=0
20
+ declare -A MERGED_PR_BRANCHES=()
16
21
 
17
22
  if [[ -n "$BASE_BRANCH" ]]; then
18
23
  BASE_BRANCH_EXPLICIT=1
@@ -45,6 +50,10 @@ while [[ $# -gt 0 ]]; do
45
50
  ONLY_DIRTY_WORKTREES=1
46
51
  shift
47
52
  ;;
53
+ --include-pr-merged)
54
+ INCLUDE_PR_MERGED=1
55
+ shift
56
+ ;;
48
57
  --branch)
49
58
  TARGET_BRANCH="${2:-}"
50
59
  shift 2
@@ -55,7 +64,7 @@ while [[ $# -gt 0 ]]; do
55
64
  ;;
56
65
  *)
57
66
  echo "[agent-worktree-prune] Unknown argument: $1" >&2
58
- echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch <agent/...>] [--idle-minutes <minutes>]" >&2
67
+ echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--include-pr-merged] [--branch <agent/...>] [--idle-minutes <minutes>]" >&2
59
68
  exit 1
60
69
  ;;
61
70
  esac
@@ -101,6 +110,44 @@ resolve_base_branch() {
101
110
  printf '%s' ""
102
111
  }
103
112
 
113
+ load_merged_pr_branches() {
114
+ if [[ "$INCLUDE_PR_MERGED" -ne 1 ]]; then
115
+ return 1
116
+ fi
117
+ if [[ "$PR_MERGED_LOOKUP_DISABLED" -eq 1 ]]; then
118
+ return 1
119
+ fi
120
+ if [[ "$PR_MERGED_LOOKUP_LOADED" -eq 1 ]]; then
121
+ return 0
122
+ fi
123
+ if ! command -v "$GH_BIN" >/dev/null 2>&1; then
124
+ PR_MERGED_LOOKUP_DISABLED=1
125
+ return 1
126
+ fi
127
+
128
+ local merged_branches=""
129
+ merged_branches="$(
130
+ "$GH_BIN" pr list --state merged --base "$BASE_BRANCH" --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || true
131
+ )"
132
+ if [[ -n "$merged_branches" ]]; then
133
+ while IFS= read -r merged_branch; do
134
+ [[ -z "$merged_branch" ]] && continue
135
+ MERGED_PR_BRANCHES["$merged_branch"]=1
136
+ done <<< "$merged_branches"
137
+ fi
138
+ PR_MERGED_LOOKUP_LOADED=1
139
+ return 0
140
+ }
141
+
142
+ branch_has_merged_pr() {
143
+ local branch="$1"
144
+ if [[ "$INCLUDE_PR_MERGED" -ne 1 ]]; then
145
+ return 1
146
+ fi
147
+ load_merged_pr_branches || return 1
148
+ [[ -n "${MERGED_PR_BRANCHES[$branch]:-}" ]]
149
+ }
150
+
104
151
  if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
105
152
  echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2
106
153
  exit 1
@@ -342,6 +389,7 @@ process_entry() {
342
389
  fi
343
390
 
344
391
  local remove_reason=""
392
+ local branch_delete_mode="safe"
345
393
 
346
394
  if [[ -z "$branch_ref" ]]; then
347
395
  remove_reason="detached-worktree"
@@ -352,6 +400,9 @@ process_entry() {
352
400
  if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
353
401
  remove_reason="merged-agent-branch"
354
402
  fi
403
+ elif [[ "$DELETE_BRANCHES" -eq 1 ]] && branch_has_merged_pr "$branch"; then
404
+ remove_reason="merged-agent-pr"
405
+ branch_delete_mode="force"
355
406
  elif [[ "$ONLY_DIRTY_WORKTREES" -eq 1 ]] && is_clean_worktree "$wt"; then
356
407
  remove_reason="clean-agent-worktree"
357
408
  fi
@@ -383,13 +434,19 @@ process_entry() {
383
434
 
384
435
  if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then
385
436
  if [[ "$branch" == agent/* && "$DELETE_BRANCHES" -eq 1 ]]; then
386
- if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
437
+ local delete_flag="-d"
438
+ local deleted_label="merged"
439
+ if [[ "$branch_delete_mode" == "force" ]]; then
440
+ delete_flag="-D"
441
+ deleted_label="merged PR"
442
+ fi
443
+ if run_cmd git -C "$repo_root" branch "$delete_flag" "$branch" >/dev/null 2>&1; then
387
444
  removed_branches=$((removed_branches + 1))
388
- echo "[agent-worktree-prune] Deleted merged branch: ${branch}"
445
+ echo "[agent-worktree-prune] Deleted ${deleted_label} branch: ${branch}"
389
446
  if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
390
447
  if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
391
448
  run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true
392
- echo "[agent-worktree-prune] Deleted merged remote branch: ${branch}"
449
+ echo "[agent-worktree-prune] Deleted ${deleted_label} remote branch: ${branch}"
393
450
  fi
394
451
  fi
395
452
  fi
@@ -420,7 +477,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do
420
477
  current_branch_ref="${line#branch }"
421
478
  ;;
422
479
  esac
423
- done < <(git -C "$repo_root" worktree list --porcelain)
480
+ done < <(git -C "$repo_root" worktree list --porcelain)
424
481
 
425
482
  process_entry "$current_wt" "$current_branch_ref"
426
483
 
@@ -436,14 +493,27 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
436
493
  if ! branch_idle_gate "$branch" "" "stale-merged-branch"; then
437
494
  continue
438
495
  fi
496
+ merged_by_ancestor=0
497
+ merged_by_pr=0
439
498
  if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
440
- if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
499
+ merged_by_ancestor=1
500
+ elif branch_has_merged_pr "$branch"; then
501
+ merged_by_pr=1
502
+ fi
503
+ if [[ "$merged_by_ancestor" -eq 1 || "$merged_by_pr" -eq 1 ]]; then
504
+ delete_flag="-d"
505
+ deleted_label="merged"
506
+ if [[ "$merged_by_pr" -eq 1 && "$merged_by_ancestor" -eq 0 ]]; then
507
+ delete_flag="-D"
508
+ deleted_label="merged PR"
509
+ fi
510
+ if run_cmd git -C "$repo_root" branch "$delete_flag" "$branch" >/dev/null 2>&1; then
441
511
  removed_branches=$((removed_branches + 1))
442
- echo "[agent-worktree-prune] Deleted stale merged branch: ${branch}"
512
+ echo "[agent-worktree-prune] Deleted stale ${deleted_label} branch: ${branch}"
443
513
  if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then
444
514
  if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
445
515
  run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true
446
- echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}"
516
+ echo "[agent-worktree-prune] Deleted stale ${deleted_label} remote branch: ${branch}"
447
517
  fi
448
518
  fi
449
519
  fi