@imdeadpool/guardex 7.0.13 → 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
@@ -507,6 +507,10 @@ npm pack --dry-run
507
507
  <details>
508
508
  <summary><strong>v7.x</strong></summary>
509
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
+
510
514
  ### v7.0.13
511
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`.
512
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.
@@ -115,15 +115,26 @@ const REQUIRED_WORKFLOW_FILES = [
115
115
  ];
116
116
 
117
117
  const REQUIRED_PACKAGE_SCRIPTS = {
118
+ 'agent:codex': 'bash ./scripts/codex-agent.sh',
118
119
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
119
120
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
120
- 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh',
121
+ 'agent:cleanup': 'gx cleanup',
121
122
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
122
123
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
124
+ 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
123
125
  'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release',
124
126
  'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status',
125
127
  'agent:plan:init': 'bash ./scripts/openspec/init-plan-workspace.sh',
126
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',
127
138
  };
128
139
 
129
140
  const EXECUTABLE_RELATIVE_PATHS = new Set([
@@ -165,26 +176,15 @@ const GITIGNORE_MARKER_END = '# multiagent-safety:END';
165
176
  const MANAGED_GITIGNORE_PATHS = [
166
177
  '.omx/',
167
178
  '.omc/',
168
- 'scripts/agent-branch-start.sh',
169
- 'scripts/agent-branch-finish.sh',
170
- 'scripts/codex-agent.sh',
171
- 'scripts/review-bot-watch.sh',
172
- 'scripts/agent-worktree-prune.sh',
173
- 'scripts/agent-file-locks.py',
174
- 'scripts/guardex-env.sh',
175
- 'scripts/install-agent-git-hooks.sh',
176
- 'scripts/openspec/init-plan-workspace.sh',
177
- 'scripts/openspec/init-change-workspace.sh',
178
- '.githooks/pre-commit',
179
- '.githooks/pre-push',
180
- '.githooks/post-merge',
181
- '.githooks/post-checkout',
179
+ 'scripts/*',
180
+ '.githooks',
182
181
  'oh-my-codex/',
183
182
  '.codex/skills/gitguardex/SKILL.md',
184
183
  '.codex/skills/guardex-merge-skills-to-dev/SKILL.md',
185
184
  '.claude/commands/gitguardex.md',
186
185
  LOCK_FILE_RELATIVE,
187
186
  ];
187
+ const REPO_SCAFFOLD_DIRECTORIES = ['bin'];
188
188
  const OMX_SCAFFOLD_DIRECTORIES = [
189
189
  '.omx',
190
190
  '.omx/state',
@@ -266,14 +266,14 @@ const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in thi
266
266
  2) Bootstrap: gx setup
267
267
  3) Repair: gx doctor
268
268
  4) Task loop: bash scripts/codex-agent.sh "<task>" "<agent>"
269
- or branch-start -> claim -> branch-finish
269
+ or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish
270
270
  5) Finish: gx finish --all
271
271
  6) Cleanup: gx cleanup
272
272
  7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
273
273
  8) Optional: gx protect add release staging
274
274
  9) Optional: gx sync --check && gx sync
275
275
  10) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY
276
- 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
277
277
  `;
278
278
 
279
279
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
@@ -281,6 +281,7 @@ gh --version
281
281
  gx setup
282
282
  gx doctor
283
283
  bash scripts/codex-agent.sh "<task>" "<agent>"
284
+ python3 scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>
284
285
  gx finish --all
285
286
  gx cleanup
286
287
  gx protect add release staging
@@ -598,9 +599,27 @@ function toDestinationPath(relativeTemplatePath) {
598
599
  throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
599
600
  }
600
601
 
601
- function ensureParentDir(filePath, dryRun) {
602
+ function ensureParentDir(repoRoot, filePath, dryRun) {
602
603
  if (dryRun) return;
603
- 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 });
604
623
  }
605
624
 
606
625
  function ensureExecutable(destinationPath, relativePath, dryRun) {
@@ -635,7 +654,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
635
654
  }
636
655
  }
637
656
 
638
- ensureParentDir(destinationPath, dryRun);
657
+ ensureParentDir(repoRoot, destinationPath, dryRun);
639
658
  if (!dryRun) {
640
659
  fs.writeFileSync(destinationPath, sourceContent, 'utf8');
641
660
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
@@ -673,7 +692,7 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
673
692
  return { status: 'skipped-conflict', file: destinationRelativePath };
674
693
  }
675
694
 
676
- ensureParentDir(destinationPath, dryRun);
695
+ ensureParentDir(repoRoot, destinationPath, dryRun);
677
696
  if (!dryRun) {
678
697
  fs.writeFileSync(destinationPath, sourceContent, 'utf8');
679
698
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
@@ -689,6 +708,22 @@ function lockFilePath(repoRoot) {
689
708
  function ensureOmxScaffold(repoRoot, dryRun) {
690
709
  const operations = [];
691
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
+
692
727
  for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
693
728
  const absoluteDir = path.join(repoRoot, relativeDir);
694
729
  if (fs.existsSync(absoluteDir)) {
@@ -976,10 +1011,9 @@ function parseCommonArgs(rawArgs, defaults) {
976
1011
  return options;
977
1012
  }
978
1013
 
979
- function parseSetupArgs(rawArgs, defaults) {
980
- const setupDefaults = {
1014
+ function parseRepoTraversalArgs(rawArgs, defaults) {
1015
+ const traversalDefaults = {
981
1016
  ...defaults,
982
- parentWorkspaceView: false,
983
1017
  recursive: true,
984
1018
  nestedMaxDepth: NESTED_REPO_DEFAULT_MAX_DEPTH,
985
1019
  nestedSkipDirs: [],
@@ -989,20 +1023,12 @@ function parseSetupArgs(rawArgs, defaults) {
989
1023
 
990
1024
  for (let index = 0; index < rawArgs.length; index += 1) {
991
1025
  const arg = rawArgs[index];
992
- if (arg === '--parent-workspace-view') {
993
- setupDefaults.parentWorkspaceView = true;
994
- continue;
995
- }
996
- if (arg === '--no-parent-workspace-view') {
997
- setupDefaults.parentWorkspaceView = false;
998
- continue;
999
- }
1000
1026
  if (arg === '--no-recursive' || arg === '--no-nested' || arg === '--single-repo') {
1001
- setupDefaults.recursive = false;
1027
+ traversalDefaults.recursive = false;
1002
1028
  continue;
1003
1029
  }
1004
1030
  if (arg === '--recursive' || arg === '--nested') {
1005
- setupDefaults.recursive = true;
1031
+ traversalDefaults.recursive = true;
1006
1032
  continue;
1007
1033
  }
1008
1034
  if (arg === '--max-depth') {
@@ -1011,47 +1037,60 @@ function parseSetupArgs(rawArgs, defaults) {
1011
1037
  if (!Number.isFinite(parsed) || parsed < 1) {
1012
1038
  throw new Error('--max-depth requires a positive integer');
1013
1039
  }
1014
- setupDefaults.nestedMaxDepth = parsed;
1040
+ traversalDefaults.nestedMaxDepth = parsed;
1015
1041
  index += 1;
1016
1042
  continue;
1017
1043
  }
1018
1044
  if (arg === '--skip-nested') {
1019
1045
  const raw = requireValue(rawArgs, index, '--skip-nested');
1020
- setupDefaults.nestedSkipDirs.push(raw);
1046
+ traversalDefaults.nestedSkipDirs.push(raw);
1021
1047
  index += 1;
1022
1048
  continue;
1023
1049
  }
1024
1050
  if (arg === '--include-submodules') {
1025
- setupDefaults.includeSubmodules = true;
1051
+ traversalDefaults.includeSubmodules = true;
1026
1052
  continue;
1027
1053
  }
1028
1054
  forwardedArgs.push(arg);
1029
1055
  }
1030
1056
 
1031
- return parseCommonArgs(forwardedArgs, setupDefaults);
1057
+ return parseCommonArgs(forwardedArgs, traversalDefaults);
1032
1058
  }
1033
1059
 
1034
- function parseDoctorArgs(rawArgs) {
1035
- const options = {
1036
- target: process.cwd(),
1037
- strict: false,
1060
+ function parseSetupArgs(rawArgs, defaults) {
1061
+ const setupDefaults = {
1062
+ ...defaults,
1063
+ parentWorkspaceView: false,
1038
1064
  };
1065
+ const forwardedArgs = [];
1039
1066
 
1040
1067
  for (let index = 0; index < rawArgs.length; index += 1) {
1041
1068
  const arg = rawArgs[index];
1042
- if (arg === '--target' || arg === '-t') {
1043
- options.target = requireValue(rawArgs, index, '--target');
1044
- index += 1;
1069
+ if (arg === '--parent-workspace-view') {
1070
+ setupDefaults.parentWorkspaceView = true;
1045
1071
  continue;
1046
1072
  }
1047
- if (arg === '--strict') {
1048
- options.strict = true;
1073
+ if (arg === '--no-parent-workspace-view') {
1074
+ setupDefaults.parentWorkspaceView = false;
1049
1075
  continue;
1050
1076
  }
1051
- throw new Error(`Unknown option: ${arg}`);
1077
+ forwardedArgs.push(arg);
1052
1078
  }
1053
1079
 
1054
- 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
+ });
1055
1094
  }
1056
1095
 
1057
1096
  function normalizeWorkspacePath(relativePath) {
@@ -1541,7 +1580,7 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1541
1580
 
1542
1581
  const finishResult = run(
1543
1582
  'bash',
1544
- [finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
1583
+ [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', '--wait-for-merge'],
1545
1584
  { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1546
1585
  );
1547
1586
  if (isSpawnFailure(finishResult)) {
@@ -1580,6 +1619,33 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1580
1619
  };
1581
1620
  }
1582
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
+
1583
1649
  function runDoctorInSandbox(options, blocked) {
1584
1650
  const startResult = startDoctorSandbox(blocked);
1585
1651
  const metadata = startResult.metadata;
@@ -1603,6 +1669,10 @@ function runDoctorInSandbox(options, blocked) {
1603
1669
  note: 'sandbox doctor did not complete successfully',
1604
1670
  };
1605
1671
 
1672
+ let protectedBaseRepairSyncResult = {
1673
+ status: 'skipped',
1674
+ note: 'sandbox doctor did not complete successfully',
1675
+ };
1606
1676
  let lockSyncResult = {
1607
1677
  status: 'skipped',
1608
1678
  note: 'sandbox doctor did not complete successfully',
@@ -1620,6 +1690,7 @@ function runDoctorInSandbox(options, blocked) {
1620
1690
  note: 'sandbox doctor did not complete successfully',
1621
1691
  };
1622
1692
  if (nestedResult.status === 0) {
1693
+ protectedBaseRepairSyncResult = syncProtectedBaseDoctorRepairs(options, blocked);
1623
1694
  const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
1624
1695
  const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
1625
1696
  if (changedOmxPaths.length === 0) {
@@ -1708,6 +1779,7 @@ function runDoctorInSandbox(options, blocked) {
1708
1779
  JSON.stringify(
1709
1780
  {
1710
1781
  ...parsed,
1782
+ protectedBaseRepairSync: protectedBaseRepairSyncResult,
1711
1783
  sandboxOmxScaffoldSync: omxScaffoldSyncResult,
1712
1784
  sandboxLockSync: lockSyncResult,
1713
1785
  sandboxAutoCommit: autoCommitResult,
@@ -1748,6 +1820,16 @@ function runDoctorInSandbox(options, blocked) {
1748
1820
  console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
1749
1821
  }
1750
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
+
1751
1833
  if (finishResult.status === 'completed') {
1752
1834
  console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
1753
1835
  if (finishResult.stdout) process.stdout.write(finishResult.stdout);
@@ -3897,6 +3979,10 @@ function runInstallInternal(options) {
3897
3979
  }
3898
3980
  const operations = [];
3899
3981
 
3982
+ if (!options.skipGitignore) {
3983
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3984
+ }
3985
+
3900
3986
  operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3901
3987
 
3902
3988
  for (const templateFile of TEMPLATE_FILES) {
@@ -3904,9 +3990,6 @@ function runInstallInternal(options) {
3904
3990
  }
3905
3991
 
3906
3992
  operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
3907
- if (!options.skipGitignore) {
3908
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3909
- }
3910
3993
 
3911
3994
  if (!options.skipPackageJson) {
3912
3995
  operations.push(ensurePackageScripts(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
@@ -3941,6 +4024,10 @@ function runFixInternal(options) {
3941
4024
  }
3942
4025
  const operations = [];
3943
4026
 
4027
+ if (!options.skipGitignore) {
4028
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
4029
+ }
4030
+
3944
4031
  operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3945
4032
 
3946
4033
  for (const templateFile of TEMPLATE_FILES) {
@@ -3948,9 +4035,6 @@ function runFixInternal(options) {
3948
4035
  }
3949
4036
 
3950
4037
  operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
3951
- if (!options.skipGitignore) {
3952
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3953
- }
3954
4038
 
3955
4039
  const lockState = lockStateOrError(repoRoot);
3956
4040
  if (!lockState.ok) {
@@ -4421,26 +4505,116 @@ function scan(rawArgs) {
4421
4505
  }
4422
4506
 
4423
4507
  function doctor(rawArgs) {
4424
- const options = parseCommonArgs(rawArgs, {
4425
- target: process.cwd(),
4426
- dropStaleLocks: true,
4427
- skipAgents: false,
4428
- skipPackageJson: false,
4429
- skipGitignore: false,
4430
- dryRun: false,
4431
- json: false,
4432
- allowProtectedBaseWrite: false,
4433
- });
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
+ }
4434
4525
 
4435
- const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false });
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
+ }
4585
+
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 });
4436
4610
  if (blocked) {
4437
- runDoctorInSandbox(options, blocked);
4611
+ runDoctorInSandbox(singleRepoOptions, blocked);
4438
4612
  return;
4439
4613
  }
4440
4614
 
4441
- assertProtectedMainWriteAllowed(options, 'doctor');
4442
- const fixPayload = runFixInternal(options);
4443
- 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 });
4444
4618
  const currentBaseBranch = currentBranchName(scanResult.repoRoot);
4445
4619
  const autoFinishSummary = scanResult.guardexEnabled === false
4446
4620
  ? {
@@ -4453,12 +4627,12 @@ function doctor(rawArgs) {
4453
4627
  }
4454
4628
  : autoFinishReadyAgentBranches(scanResult.repoRoot, {
4455
4629
  baseBranch: currentBaseBranch,
4456
- dryRun: options.dryRun,
4630
+ dryRun: singleRepoOptions.dryRun,
4457
4631
  });
4458
4632
  const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
4459
4633
  const musafe = safe;
4460
4634
 
4461
- if (options.json) {
4635
+ if (singleRepoOptions.json) {
4462
4636
  process.stdout.write(
4463
4637
  JSON.stringify(
4464
4638
  {
@@ -4469,7 +4643,7 @@ function doctor(rawArgs) {
4469
4643
  fix: {
4470
4644
  operations: fixPayload.operations,
4471
4645
  hookResult: fixPayload.hookResult,
4472
- dryRun: Boolean(options.dryRun),
4646
+ dryRun: Boolean(singleRepoOptions.dryRun),
4473
4647
  },
4474
4648
  scan: {
4475
4649
  guardexEnabled: scanResult.guardexEnabled !== false,
@@ -4488,7 +4662,7 @@ function doctor(rawArgs) {
4488
4662
  return;
4489
4663
  }
4490
4664
 
4491
- printOperations('Doctor/fix', fixPayload, options.dryRun);
4665
+ printOperations('Doctor/fix', fixPayload, singleRepoOptions.dryRun);
4492
4666
  printScanResult(scanResult, false);
4493
4667
  if (scanResult.guardexEnabled === false) {
4494
4668
  console.log(`[${TOOL_NAME}] Repo-local Guardex enforcement is intentionally disabled.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "7.0.13",
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