@imdeadpool/guardex 7.0.13 → 7.0.15

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.
@@ -64,7 +64,7 @@ const REQUIRED_SYSTEM_TOOLS = [
64
64
  },
65
65
  ];
66
66
  const MAINTAINER_RELEASE_REPO = path.resolve(
67
- process.env.GUARDEX_RELEASE_REPO || '/tmp/multiagent-safety',
67
+ process.env.GUARDEX_RELEASE_REPO || path.resolve(__dirname, '..'),
68
68
  );
69
69
  const NPM_BIN = process.env.GUARDEX_NPM_BIN || 'npm';
70
70
  const OPENSPEC_BIN = process.env.GUARDEX_OPENSPEC_BIN || 'openspec';
@@ -77,6 +77,12 @@ const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master'];
77
77
  const DEFAULT_BASE_BRANCH = 'dev';
78
78
  const DEFAULT_SYNC_STRATEGY = 'rebase';
79
79
  const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60;
80
+ const COMPOSE_HINT_FILES = [
81
+ 'docker-compose.yml',
82
+ 'docker-compose.yaml',
83
+ 'compose.yml',
84
+ 'compose.yaml',
85
+ ];
80
86
 
81
87
  const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates');
82
88
 
@@ -84,6 +90,7 @@ const TEMPLATE_FILES = [
84
90
  'scripts/agent-branch-start.sh',
85
91
  'scripts/agent-branch-finish.sh',
86
92
  'scripts/codex-agent.sh',
93
+ 'scripts/guardex-docker-loader.sh',
87
94
  'scripts/review-bot-watch.sh',
88
95
  'scripts/agent-worktree-prune.sh',
89
96
  'scripts/agent-file-locks.py',
@@ -105,6 +112,7 @@ const TEMPLATE_FILES = [
105
112
  const REQUIRED_WORKFLOW_FILES = [
106
113
  'scripts/agent-branch-start.sh',
107
114
  'scripts/agent-branch-finish.sh',
115
+ 'scripts/guardex-docker-loader.sh',
108
116
  'scripts/agent-worktree-prune.sh',
109
117
  'scripts/agent-file-locks.py',
110
118
  'scripts/guardex-env.sh',
@@ -115,21 +123,34 @@ const REQUIRED_WORKFLOW_FILES = [
115
123
  ];
116
124
 
117
125
  const REQUIRED_PACKAGE_SCRIPTS = {
126
+ 'agent:codex': 'bash ./scripts/codex-agent.sh',
118
127
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
119
128
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
120
- 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh',
129
+ 'agent:cleanup': 'gx cleanup',
121
130
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
122
131
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
132
+ 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
123
133
  'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release',
124
134
  'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status',
125
135
  'agent:plan:init': 'bash ./scripts/openspec/init-plan-workspace.sh',
126
136
  'agent:change:init': 'bash ./scripts/openspec/init-change-workspace.sh',
137
+ 'agent:protect:list': 'gx protect list',
138
+ 'agent:branch:sync': 'gx sync',
139
+ 'agent:branch:sync:check': 'gx sync --check',
140
+ 'agent:safety:setup': 'gx setup',
141
+ 'agent:safety:scan': 'gx status --strict',
142
+ 'agent:safety:fix': 'gx setup --repair',
143
+ 'agent:safety:doctor': 'gx doctor',
144
+ 'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh',
145
+ 'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
146
+ 'agent:finish': 'gx finish --all',
127
147
  };
128
148
 
129
149
  const EXECUTABLE_RELATIVE_PATHS = new Set([
130
150
  'scripts/agent-branch-start.sh',
131
151
  'scripts/agent-branch-finish.sh',
132
152
  'scripts/codex-agent.sh',
153
+ 'scripts/guardex-docker-loader.sh',
133
154
  'scripts/review-bot-watch.sh',
134
155
  'scripts/agent-worktree-prune.sh',
135
156
  'scripts/agent-file-locks.py',
@@ -162,35 +183,34 @@ const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
162
183
  const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
163
184
  const GITIGNORE_MARKER_START = '# multiagent-safety:START';
164
185
  const GITIGNORE_MARKER_END = '# multiagent-safety:END';
186
+ const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
187
+ const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees');
188
+ const AGENT_WORKTREE_RELATIVE_DIRS = [
189
+ CODEX_WORKTREE_RELATIVE_DIR,
190
+ CLAUDE_WORKTREE_RELATIVE_DIR,
191
+ ];
165
192
  const MANAGED_GITIGNORE_PATHS = [
166
193
  '.omx/',
167
194
  '.omc/',
195
+ 'scripts/*',
168
196
  '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
197
  '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',
198
+ '.githooks',
182
199
  'oh-my-codex/',
183
200
  '.codex/skills/gitguardex/SKILL.md',
184
201
  '.codex/skills/guardex-merge-skills-to-dev/SKILL.md',
185
202
  '.claude/commands/gitguardex.md',
186
203
  LOCK_FILE_RELATIVE,
187
204
  ];
205
+ const REPO_SCAFFOLD_DIRECTORIES = ['bin'];
188
206
  const OMX_SCAFFOLD_DIRECTORIES = [
189
207
  '.omx',
190
208
  '.omx/state',
191
209
  '.omx/logs',
192
210
  '.omx/plans',
193
- '.omx/agent-worktrees',
211
+ CODEX_WORKTREE_RELATIVE_DIR,
212
+ '.omc',
213
+ CLAUDE_WORKTREE_RELATIVE_DIR,
194
214
  ];
195
215
  const OMX_SCAFFOLD_FILES = new Map([
196
216
  ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'],
@@ -240,6 +260,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
240
260
  ['sync', 'Sync agent branches with origin/<base>'],
241
261
  ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
242
262
  ['cleanup', 'Prune merged/stale agent branches and worktrees'],
263
+ ['release', 'Create or update the current GitHub release with README-generated notes'],
243
264
  ['agents', 'Start/stop repo-scoped review + cleanup bots'],
244
265
  ['prompt', 'Print AI setup checklist (--exec, --snippet)'],
245
266
  ['report', 'Security/safety reports (e.g. OpenSSF scorecard)'],
@@ -260,20 +281,33 @@ const AGENT_BOT_DESCRIPTIONS = [
260
281
  ['agents', 'Start/stop review + cleanup bots for this repo'],
261
282
  ];
262
283
 
284
+ function envFlagIsTruthy(raw) {
285
+ const lowered = String(raw || '').trim().toLowerCase();
286
+ return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on';
287
+ }
288
+
289
+ function isClaudeCodeSession(env = process.env) {
290
+ return envFlagIsTruthy(env.CLAUDECODE) || Boolean(env.CLAUDE_CODE_SESSION_ID);
291
+ }
292
+
293
+ function defaultAgentWorktreeRelativeDir(env = process.env) {
294
+ return isClaudeCodeSession(env) ? CLAUDE_WORKTREE_RELATIVE_DIR : CODEX_WORKTREE_RELATIVE_DIR;
295
+ }
296
+
263
297
  const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in this repo.
264
298
 
265
299
  1) Install: npm i -g @imdeadpool/guardex && gh --version
266
300
  2) Bootstrap: gx setup
267
301
  3) Repair: gx doctor
268
302
  4) Task loop: bash scripts/codex-agent.sh "<task>" "<agent>"
269
- or branch-start -> claim -> branch-finish
303
+ or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish
270
304
  5) Finish: gx finish --all
271
305
  6) Cleanup: gx cleanup
272
306
  7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive
273
307
  8) Optional: gx protect add release staging
274
308
  9) Optional: gx sync --check && gx sync
275
309
  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
310
+ 11) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml
277
311
  `;
278
312
 
279
313
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
@@ -281,6 +315,7 @@ gh --version
281
315
  gx setup
282
316
  gx doctor
283
317
  bash scripts/codex-agent.sh "<task>" "<agent>"
318
+ python3 scripts/agent-file-locks.py claim --branch "<agent-branch>" <file...>
284
319
  gx finish --all
285
320
  gx cleanup
286
321
  gx protect add release staging
@@ -508,8 +543,6 @@ const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([
508
543
  '.venv',
509
544
  '.pnpm-store',
510
545
  ]);
511
- const NESTED_REPO_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees');
512
-
513
546
  function discoverNestedGitRepos(rootPath, opts = {}) {
514
547
  const maxDepth = Number.isFinite(opts.maxDepth) ? Math.max(1, opts.maxDepth) : NESTED_REPO_DEFAULT_MAX_DEPTH;
515
548
  const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []);
@@ -524,7 +557,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
524
557
  return path.resolve(resolvedRoot, raw);
525
558
  })();
526
559
 
527
- const workreeSkipAbsolute = path.join(resolvedRoot, NESTED_REPO_WORKTREE_RELATIVE_DIR);
560
+ const worktreeSkipAbsolutes = AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => path.join(resolvedRoot, relativeDir));
528
561
  const found = new Set();
529
562
  found.add(resolvedRoot);
530
563
 
@@ -556,7 +589,7 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
556
589
 
557
590
  if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
558
591
  if (shouldSkipDir(entry.name)) continue;
559
- if (entryPath === workreeSkipAbsolute) continue;
592
+ if (worktreeSkipAbsolutes.includes(entryPath)) continue;
560
593
  walk(entryPath, depth + 1);
561
594
  }
562
595
  }
@@ -598,9 +631,27 @@ function toDestinationPath(relativeTemplatePath) {
598
631
  throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
599
632
  }
600
633
 
601
- function ensureParentDir(filePath, dryRun) {
634
+ function ensureParentDir(repoRoot, filePath, dryRun) {
602
635
  if (dryRun) return;
603
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
636
+
637
+ const parentDir = path.dirname(filePath);
638
+ const relativeParentDir = path.relative(repoRoot, parentDir);
639
+ const segments = relativeParentDir.split(path.sep).filter(Boolean);
640
+ let currentPath = repoRoot;
641
+
642
+ for (const segment of segments) {
643
+ currentPath = path.join(currentPath, segment);
644
+ if (fs.existsSync(currentPath) && !fs.statSync(currentPath).isDirectory()) {
645
+ const blockingPath = path.relative(repoRoot, currentPath) || path.basename(currentPath);
646
+ const targetPath = path.relative(repoRoot, filePath) || path.basename(filePath);
647
+ throw new Error(
648
+ `Path conflict: ${blockingPath} exists as a file, but ${targetPath} needs it to be a directory. ` +
649
+ `Remove or rename ${blockingPath} and rerun '${SHORT_TOOL_NAME} setup'.`,
650
+ );
651
+ }
652
+ }
653
+
654
+ fs.mkdirSync(parentDir, { recursive: true });
604
655
  }
605
656
 
606
657
  function ensureExecutable(destinationPath, relativePath, dryRun) {
@@ -635,7 +686,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
635
686
  }
636
687
  }
637
688
 
638
- ensureParentDir(destinationPath, dryRun);
689
+ ensureParentDir(repoRoot, destinationPath, dryRun);
639
690
  if (!dryRun) {
640
691
  fs.writeFileSync(destinationPath, sourceContent, 'utf8');
641
692
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
@@ -673,7 +724,7 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
673
724
  return { status: 'skipped-conflict', file: destinationRelativePath };
674
725
  }
675
726
 
676
- ensureParentDir(destinationPath, dryRun);
727
+ ensureParentDir(repoRoot, destinationPath, dryRun);
677
728
  if (!dryRun) {
678
729
  fs.writeFileSync(destinationPath, sourceContent, 'utf8');
679
730
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
@@ -689,6 +740,22 @@ function lockFilePath(repoRoot) {
689
740
  function ensureOmxScaffold(repoRoot, dryRun) {
690
741
  const operations = [];
691
742
 
743
+ for (const relativeDir of REPO_SCAFFOLD_DIRECTORIES) {
744
+ const absoluteDir = path.join(repoRoot, relativeDir);
745
+ if (fs.existsSync(absoluteDir)) {
746
+ if (!fs.statSync(absoluteDir).isDirectory()) {
747
+ throw new Error(`Expected directory at ${relativeDir} but found a file.`);
748
+ }
749
+ operations.push({ status: 'unchanged', file: relativeDir });
750
+ continue;
751
+ }
752
+
753
+ if (!dryRun) {
754
+ fs.mkdirSync(absoluteDir, { recursive: true });
755
+ }
756
+ operations.push({ status: 'created', file: relativeDir });
757
+ }
758
+
692
759
  for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
693
760
  const absoluteDir = path.join(repoRoot, relativeDir);
694
761
  if (fs.existsSync(absoluteDir)) {
@@ -965,6 +1032,14 @@ function parseCommonArgs(rawArgs, defaults) {
965
1032
  options.allowProtectedBaseWrite = true;
966
1033
  continue;
967
1034
  }
1035
+ if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--wait-for-merge') {
1036
+ options.waitForMerge = true;
1037
+ continue;
1038
+ }
1039
+ if (Object.prototype.hasOwnProperty.call(options, 'waitForMerge') && arg === '--no-wait-for-merge') {
1040
+ options.waitForMerge = false;
1041
+ continue;
1042
+ }
968
1043
 
969
1044
  throw new Error(`Unknown option: ${arg}`);
970
1045
  }
@@ -976,10 +1051,9 @@ function parseCommonArgs(rawArgs, defaults) {
976
1051
  return options;
977
1052
  }
978
1053
 
979
- function parseSetupArgs(rawArgs, defaults) {
980
- const setupDefaults = {
1054
+ function parseRepoTraversalArgs(rawArgs, defaults) {
1055
+ const traversalDefaults = {
981
1056
  ...defaults,
982
- parentWorkspaceView: false,
983
1057
  recursive: true,
984
1058
  nestedMaxDepth: NESTED_REPO_DEFAULT_MAX_DEPTH,
985
1059
  nestedSkipDirs: [],
@@ -989,20 +1063,12 @@ function parseSetupArgs(rawArgs, defaults) {
989
1063
 
990
1064
  for (let index = 0; index < rawArgs.length; index += 1) {
991
1065
  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
1066
  if (arg === '--no-recursive' || arg === '--no-nested' || arg === '--single-repo') {
1001
- setupDefaults.recursive = false;
1067
+ traversalDefaults.recursive = false;
1002
1068
  continue;
1003
1069
  }
1004
1070
  if (arg === '--recursive' || arg === '--nested') {
1005
- setupDefaults.recursive = true;
1071
+ traversalDefaults.recursive = true;
1006
1072
  continue;
1007
1073
  }
1008
1074
  if (arg === '--max-depth') {
@@ -1011,47 +1077,61 @@ function parseSetupArgs(rawArgs, defaults) {
1011
1077
  if (!Number.isFinite(parsed) || parsed < 1) {
1012
1078
  throw new Error('--max-depth requires a positive integer');
1013
1079
  }
1014
- setupDefaults.nestedMaxDepth = parsed;
1080
+ traversalDefaults.nestedMaxDepth = parsed;
1015
1081
  index += 1;
1016
1082
  continue;
1017
1083
  }
1018
1084
  if (arg === '--skip-nested') {
1019
1085
  const raw = requireValue(rawArgs, index, '--skip-nested');
1020
- setupDefaults.nestedSkipDirs.push(raw);
1086
+ traversalDefaults.nestedSkipDirs.push(raw);
1021
1087
  index += 1;
1022
1088
  continue;
1023
1089
  }
1024
1090
  if (arg === '--include-submodules') {
1025
- setupDefaults.includeSubmodules = true;
1091
+ traversalDefaults.includeSubmodules = true;
1026
1092
  continue;
1027
1093
  }
1028
1094
  forwardedArgs.push(arg);
1029
1095
  }
1030
1096
 
1031
- return parseCommonArgs(forwardedArgs, setupDefaults);
1097
+ return parseCommonArgs(forwardedArgs, traversalDefaults);
1032
1098
  }
1033
1099
 
1034
- function parseDoctorArgs(rawArgs) {
1035
- const options = {
1036
- target: process.cwd(),
1037
- strict: false,
1100
+ function parseSetupArgs(rawArgs, defaults) {
1101
+ const setupDefaults = {
1102
+ ...defaults,
1103
+ parentWorkspaceView: false,
1038
1104
  };
1105
+ const forwardedArgs = [];
1039
1106
 
1040
1107
  for (let index = 0; index < rawArgs.length; index += 1) {
1041
1108
  const arg = rawArgs[index];
1042
- if (arg === '--target' || arg === '-t') {
1043
- options.target = requireValue(rawArgs, index, '--target');
1044
- index += 1;
1109
+ if (arg === '--parent-workspace-view') {
1110
+ setupDefaults.parentWorkspaceView = true;
1045
1111
  continue;
1046
1112
  }
1047
- if (arg === '--strict') {
1048
- options.strict = true;
1113
+ if (arg === '--no-parent-workspace-view') {
1114
+ setupDefaults.parentWorkspaceView = false;
1049
1115
  continue;
1050
1116
  }
1051
- throw new Error(`Unknown option: ${arg}`);
1117
+ forwardedArgs.push(arg);
1052
1118
  }
1053
1119
 
1054
- return options;
1120
+ return parseRepoTraversalArgs(forwardedArgs, setupDefaults);
1121
+ }
1122
+
1123
+ function parseDoctorArgs(rawArgs) {
1124
+ return parseRepoTraversalArgs(rawArgs, {
1125
+ target: process.cwd(),
1126
+ dropStaleLocks: true,
1127
+ skipAgents: false,
1128
+ skipPackageJson: false,
1129
+ skipGitignore: false,
1130
+ dryRun: false,
1131
+ json: false,
1132
+ allowProtectedBaseWrite: false,
1133
+ waitForMerge: true,
1134
+ });
1055
1135
  }
1056
1136
 
1057
1137
  function normalizeWorkspacePath(relativePath) {
@@ -1063,16 +1143,15 @@ function buildParentWorkspaceView(repoRoot) {
1063
1143
  const workspaceFileName = `${path.basename(repoRoot)}-branches.code-workspace`;
1064
1144
  const workspacePath = path.join(parentDir, workspaceFileName);
1065
1145
  const repoRelativePath = normalizeWorkspacePath(path.relative(parentDir, repoRoot) || '.');
1066
- const worktreesRelativePath = normalizeWorkspacePath(
1067
- path.join(repoRelativePath === '.' ? '' : repoRelativePath, '.omx', 'agent-worktrees'),
1068
- );
1069
1146
 
1070
1147
  return {
1071
1148
  workspacePath,
1072
1149
  payload: {
1073
1150
  folders: [
1074
1151
  { path: repoRelativePath },
1075
- { path: worktreesRelativePath },
1152
+ ...AGENT_WORKTREE_RELATIVE_DIRS.map((relativeDir) => ({
1153
+ path: normalizeWorkspacePath(path.join(repoRelativePath === '.' ? '' : repoRelativePath, relativeDir)),
1154
+ })),
1076
1155
  ],
1077
1156
  settings: {
1078
1157
  'scm.alwaysShowRepositories': true,
@@ -1156,6 +1235,40 @@ function assertProtectedMainWriteAllowed(options, commandName) {
1156
1235
  );
1157
1236
  }
1158
1237
 
1238
+ function runSetupBootstrapInternal(options) {
1239
+ const installPayload = runInstallInternal(options);
1240
+ installPayload.operations.push(
1241
+ ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)),
1242
+ );
1243
+
1244
+ let parentWorkspace = null;
1245
+ if (options.parentWorkspaceView) {
1246
+ installPayload.operations.push(
1247
+ ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)),
1248
+ );
1249
+ if (!options.dryRun) {
1250
+ parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
1251
+ }
1252
+ }
1253
+
1254
+ const fixPayload = runFixInternal({
1255
+ target: installPayload.repoRoot,
1256
+ dryRun: options.dryRun,
1257
+ force: options.force,
1258
+ dropStaleLocks: true,
1259
+ skipAgents: options.skipAgents,
1260
+ skipPackageJson: options.skipPackageJson,
1261
+ skipGitignore: options.skipGitignore,
1262
+ allowProtectedBaseWrite: options.allowProtectedBaseWrite,
1263
+ });
1264
+
1265
+ return {
1266
+ installPayload,
1267
+ fixPayload,
1268
+ parentWorkspace,
1269
+ };
1270
+ }
1271
+
1159
1272
  function extractAgentBranchStartMetadata(output) {
1160
1273
  const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
1161
1274
  const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
@@ -1169,7 +1282,7 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
1169
1282
  const resolvedTarget = path.resolve(targetPath);
1170
1283
  const relativeTarget = path.relative(repoRoot, resolvedTarget);
1171
1284
  if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
1172
- throw new Error(`doctor target must stay inside repo root when sandboxing: ${resolvedTarget}`);
1285
+ throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`);
1173
1286
  }
1174
1287
  if (!relativeTarget || relativeTarget === '.') {
1175
1288
  return worktreePath;
@@ -1177,6 +1290,16 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
1177
1290
  return path.join(worktreePath, relativeTarget);
1178
1291
  }
1179
1292
 
1293
+ function buildSandboxSetupArgs(options, sandboxTarget) {
1294
+ const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive'];
1295
+ if (options.force) args.push('--force');
1296
+ if (options.skipAgents) args.push('--skip-agents');
1297
+ if (options.skipPackageJson) args.push('--skip-package-json');
1298
+ if (options.skipGitignore) args.push('--no-gitignore');
1299
+ if (options.dryRun) args.push('--dry-run');
1300
+ return args;
1301
+ }
1302
+
1180
1303
  function buildSandboxDoctorArgs(options, sandboxTarget) {
1181
1304
  const args = ['doctor', '--target', sandboxTarget];
1182
1305
  if (options.dryRun) args.push('--dry-run');
@@ -1185,6 +1308,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
1185
1308
  if (options.skipPackageJson) args.push('--skip-package-json');
1186
1309
  if (options.skipGitignore) args.push('--no-gitignore');
1187
1310
  if (!options.dropStaleLocks) args.push('--keep-stale-locks');
1311
+ args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
1188
1312
  if (options.json) args.push('--json');
1189
1313
  return args;
1190
1314
  }
@@ -1220,7 +1344,7 @@ function ensureRepoBranch(repoRoot, branch) {
1220
1344
  return { ok: true, changed: true };
1221
1345
  }
1222
1346
 
1223
- function doctorSandboxBranchPrefix() {
1347
+ function protectedBaseSandboxBranchPrefix() {
1224
1348
  const now = new Date();
1225
1349
  const stamp = [
1226
1350
  now.getUTCFullYear(),
@@ -1234,15 +1358,15 @@ function doctorSandboxBranchPrefix() {
1234
1358
  return `agent/gx/${stamp}`;
1235
1359
  }
1236
1360
 
1237
- function doctorSandboxWorktreePath(repoRoot, branchName) {
1238
- return path.join(repoRoot, '.omx', 'agent-worktrees', branchName.replace(/\//g, '__'));
1361
+ function protectedBaseSandboxWorktreePath(repoRoot, branchName) {
1362
+ return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__'));
1239
1363
  }
1240
1364
 
1241
1365
  function gitRefExists(repoRoot, ref) {
1242
1366
  return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
1243
1367
  }
1244
1368
 
1245
- function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
1369
+ function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) {
1246
1370
  run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
1247
1371
  if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
1248
1372
  return `origin/${baseBranch}`;
@@ -1250,18 +1374,21 @@ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
1250
1374
  if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
1251
1375
  return baseBranch;
1252
1376
  }
1253
- throw new Error(`Unable to find base ref for sandbox doctor: ${baseBranch}`);
1377
+ if (currentBranchName(repoRoot) === baseBranch) {
1378
+ return null;
1379
+ }
1380
+ throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`);
1254
1381
  }
1255
1382
 
1256
- function startDoctorSandboxFallback(blocked) {
1257
- const branchPrefix = doctorSandboxBranchPrefix();
1383
+ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
1384
+ const branchPrefix = protectedBaseSandboxBranchPrefix();
1258
1385
  let selectedBranch = '';
1259
1386
  let selectedWorktreePath = '';
1260
1387
 
1261
1388
  for (let attempt = 0; attempt < 30; attempt += 1) {
1262
- const suffix = attempt === 0 ? 'gx-doctor' : `${attempt + 1}-gx-doctor`;
1389
+ const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`;
1263
1390
  const candidateBranch = `${branchPrefix}-${suffix}`;
1264
- const candidateWorktreePath = doctorSandboxWorktreePath(blocked.repoRoot, candidateBranch);
1391
+ const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch);
1265
1392
  if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
1266
1393
  continue;
1267
1394
  }
@@ -1274,20 +1401,36 @@ function startDoctorSandboxFallback(blocked) {
1274
1401
  }
1275
1402
 
1276
1403
  if (!selectedBranch || !selectedWorktreePath) {
1277
- throw new Error('Unable to allocate unique sandbox branch/worktree for doctor');
1404
+ throw new Error('Unable to allocate unique sandbox branch/worktree');
1278
1405
  }
1279
1406
 
1280
1407
  fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
1281
- const startRef = resolveDoctorSandboxStartRef(blocked.repoRoot, blocked.branch);
1282
- const addResult = run(
1283
- 'git',
1284
- ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef],
1285
- );
1408
+ const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch);
1409
+ const addArgs = startRef
1410
+ ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef]
1411
+ : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath];
1412
+ const addResult = run('git', addArgs);
1286
1413
  if (isSpawnFailure(addResult)) {
1287
1414
  throw addResult.error;
1288
1415
  }
1289
1416
  if (addResult.status !== 0) {
1290
- throw new Error((addResult.stderr || addResult.stdout || 'failed to create doctor sandbox').trim());
1417
+ throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim());
1418
+ }
1419
+
1420
+ if (!startRef) {
1421
+ const renameResult = run(
1422
+ 'git',
1423
+ ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch],
1424
+ { timeout: 20_000 },
1425
+ );
1426
+ if (isSpawnFailure(renameResult)) {
1427
+ throw renameResult.error;
1428
+ }
1429
+ if (renameResult.status !== 0) {
1430
+ throw new Error(
1431
+ (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(),
1432
+ );
1433
+ }
1291
1434
  }
1292
1435
 
1293
1436
  return {
@@ -1302,16 +1445,20 @@ function startDoctorSandboxFallback(blocked) {
1302
1445
  };
1303
1446
  }
1304
1447
 
1305
- function startDoctorSandbox(blocked) {
1448
+ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
1449
+ if (sandboxSuffix === 'gx-doctor') {
1450
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1451
+ }
1452
+
1306
1453
  const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
1307
1454
  if (!fs.existsSync(startScript)) {
1308
- return startDoctorSandboxFallback(blocked);
1455
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1309
1456
  }
1310
1457
 
1311
1458
  const startResult = run('bash', [
1312
1459
  startScript,
1313
1460
  '--task',
1314
- `${SHORT_TOOL_NAME}-doctor`,
1461
+ taskName,
1315
1462
  '--agent',
1316
1463
  SHORT_TOOL_NAME,
1317
1464
  '--base',
@@ -1321,7 +1468,7 @@ function startDoctorSandbox(blocked) {
1321
1468
  throw startResult.error;
1322
1469
  }
1323
1470
  if (startResult.status !== 0) {
1324
- return startDoctorSandboxFallback(blocked);
1471
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1325
1472
  }
1326
1473
 
1327
1474
  const metadata = extractAgentBranchStartMetadata(startResult.stdout);
@@ -1336,11 +1483,11 @@ function startDoctorSandbox(blocked) {
1336
1483
  if (!restoreResult.ok) {
1337
1484
  const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
1338
1485
  throw new Error(
1339
- `doctor sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1486
+ `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
1340
1487
  (detail ? `\n${detail}` : ''),
1341
1488
  );
1342
1489
  }
1343
- return startDoctorSandboxFallback(blocked);
1490
+ return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
1344
1491
  }
1345
1492
 
1346
1493
  return {
@@ -1350,6 +1497,59 @@ function startDoctorSandbox(blocked) {
1350
1497
  };
1351
1498
  }
1352
1499
 
1500
+ function cleanupProtectedBaseSandbox(repoRoot, metadata) {
1501
+ const result = {
1502
+ worktree: 'skipped',
1503
+ branch: 'skipped',
1504
+ note: 'missing sandbox metadata',
1505
+ };
1506
+
1507
+ if (!metadata?.worktreePath || !metadata?.branch) {
1508
+ return result;
1509
+ }
1510
+
1511
+ if (fs.existsSync(metadata.worktreePath)) {
1512
+ const removeResult = run(
1513
+ 'git',
1514
+ ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath],
1515
+ { timeout: 30_000 },
1516
+ );
1517
+ if (isSpawnFailure(removeResult)) {
1518
+ throw removeResult.error;
1519
+ }
1520
+ if (removeResult.status !== 0) {
1521
+ throw new Error(
1522
+ (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(),
1523
+ );
1524
+ }
1525
+ result.worktree = 'removed';
1526
+ } else {
1527
+ result.worktree = 'missing';
1528
+ }
1529
+
1530
+ if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) {
1531
+ const branchDeleteResult = run(
1532
+ 'git',
1533
+ ['-C', repoRoot, 'branch', '-D', metadata.branch],
1534
+ { timeout: 20_000 },
1535
+ );
1536
+ if (isSpawnFailure(branchDeleteResult)) {
1537
+ throw branchDeleteResult.error;
1538
+ }
1539
+ if (branchDeleteResult.status !== 0) {
1540
+ throw new Error(
1541
+ (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(),
1542
+ );
1543
+ }
1544
+ result.branch = 'deleted';
1545
+ } else {
1546
+ result.branch = 'missing';
1547
+ }
1548
+
1549
+ result.note = 'sandbox worktree pruned';
1550
+ return result;
1551
+ }
1552
+
1353
1553
  function parseGitPathList(output) {
1354
1554
  return String(output || '')
1355
1555
  .split('\n')
@@ -1388,6 +1588,59 @@ function collectDoctorDeletedPaths(worktreePath) {
1388
1588
  return Array.from(deleted);
1389
1589
  }
1390
1590
 
1591
+ function collectWorktreeDirtyPaths(worktreePath) {
1592
+ const dirty = new Set();
1593
+ const commands = [
1594
+ ['diff', '--name-only'],
1595
+ ['diff', '--cached', '--name-only'],
1596
+ ['ls-files', '--others', '--exclude-standard'],
1597
+ ];
1598
+ for (const gitArgs of commands) {
1599
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
1600
+ for (const filePath of parseGitPathList(result.stdout)) {
1601
+ dirty.add(filePath);
1602
+ }
1603
+ }
1604
+ return Array.from(dirty);
1605
+ }
1606
+
1607
+ function collectDoctorForceAddPaths(worktreePath) {
1608
+ return TEMPLATE_FILES
1609
+ .map((entry) => toDestinationPath(entry))
1610
+ .filter((relativePath) => relativePath.startsWith('scripts/') || relativePath.startsWith('.githooks/'))
1611
+ .filter((relativePath) => fs.existsSync(path.join(worktreePath, relativePath)));
1612
+ }
1613
+
1614
+ function stripDoctorSandboxLocks(rawContent, branchName) {
1615
+ if (!rawContent || !branchName) {
1616
+ return rawContent;
1617
+ }
1618
+ try {
1619
+ const parsed = JSON.parse(rawContent);
1620
+ const locks = parsed && typeof parsed === 'object' && parsed.locks && typeof parsed.locks === 'object'
1621
+ ? parsed.locks
1622
+ : null;
1623
+ if (!locks) {
1624
+ return rawContent;
1625
+ }
1626
+ let changed = false;
1627
+ const filteredLocks = {};
1628
+ for (const [filePath, lockInfo] of Object.entries(locks)) {
1629
+ if (lockInfo && lockInfo.branch === branchName) {
1630
+ changed = true;
1631
+ continue;
1632
+ }
1633
+ filteredLocks[filePath] = lockInfo;
1634
+ }
1635
+ if (!changed) {
1636
+ return rawContent;
1637
+ }
1638
+ return `${JSON.stringify({ ...parsed, locks: filteredLocks }, null, 2)}\n`;
1639
+ } catch {
1640
+ return rawContent;
1641
+ }
1642
+ }
1643
+
1391
1644
  function claimDoctorChangedLocks(metadata) {
1392
1645
  const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py');
1393
1646
  if (!fs.existsSync(lockScript) || !metadata.branch) {
@@ -1399,7 +1652,10 @@ function claimDoctorChangedLocks(metadata) {
1399
1652
  };
1400
1653
  }
1401
1654
 
1402
- const changedPaths = collectDoctorChangedPaths(metadata.worktreePath);
1655
+ const changedPaths = Array.from(new Set([
1656
+ ...collectDoctorChangedPaths(metadata.worktreePath),
1657
+ ...collectDoctorForceAddPaths(metadata.worktreePath),
1658
+ ]));
1403
1659
  const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
1404
1660
  if (changedPaths.length > 0) {
1405
1661
  run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], {
@@ -1431,7 +1687,19 @@ function autoCommitDoctorSandboxChanges(metadata) {
1431
1687
  }
1432
1688
 
1433
1689
  claimDoctorChangedLocks(metadata);
1434
- run('git', ['-C', metadata.worktreePath, 'add', '-A'], { timeout: 20_000 });
1690
+ run(
1691
+ 'git',
1692
+ ['-C', metadata.worktreePath, 'add', '-A', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
1693
+ { timeout: 20_000 },
1694
+ );
1695
+ const forceAddPaths = collectDoctorForceAddPaths(metadata.worktreePath);
1696
+ if (forceAddPaths.length > 0) {
1697
+ run(
1698
+ 'git',
1699
+ ['-C', metadata.worktreePath, 'add', '-f', '--', ...forceAddPaths],
1700
+ { timeout: 20_000 },
1701
+ );
1702
+ }
1435
1703
  const staged = run(
1436
1704
  'git',
1437
1705
  ['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
@@ -1496,7 +1764,7 @@ function doctorFinishFlowIsPending(output) {
1496
1764
  );
1497
1765
  }
1498
1766
 
1499
- function finishDoctorSandboxBranch(blocked, metadata) {
1767
+ function finishDoctorSandboxBranch(blocked, metadata, options = {}) {
1500
1768
  const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
1501
1769
  if (!fs.existsSync(finishScript)) {
1502
1770
  return {
@@ -1538,10 +1806,11 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1538
1806
  const waitTimeoutSeconds =
1539
1807
  Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
1540
1808
  const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
1809
+ const waitForMergeArg = options.waitForMerge === false ? '--no-wait-for-merge' : '--wait-for-merge';
1541
1810
 
1542
1811
  const finishResult = run(
1543
1812
  'bash',
1544
- [finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
1813
+ [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg],
1545
1814
  { cwd: metadata.worktreePath, timeout: finishTimeoutMs },
1546
1815
  );
1547
1816
  if (isSpawnFailure(finishResult)) {
@@ -1580,8 +1849,186 @@ function finishDoctorSandboxBranch(blocked, metadata) {
1580
1849
  };
1581
1850
  }
1582
1851
 
1852
+ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata, autoCommitResult, finishResult) {
1853
+ if (options.dryRun) {
1854
+ return {
1855
+ status: autoCommitResult.status === 'committed' ? 'would-merge' : 'skipped',
1856
+ note: autoCommitResult.status === 'committed'
1857
+ ? 'dry run: would fast-forward tracked doctor repairs into the protected base workspace'
1858
+ : 'dry run skips tracked repair merge',
1859
+ };
1860
+ }
1861
+
1862
+ if (autoCommitResult.status !== 'committed') {
1863
+ return {
1864
+ status: autoCommitResult.status === 'no-changes' ? 'unchanged' : 'skipped',
1865
+ note: autoCommitResult.status === 'no-changes'
1866
+ ? 'no tracked doctor repairs needed in the protected base workspace'
1867
+ : 'tracked doctor repair merge skipped',
1868
+ };
1869
+ }
1870
+
1871
+ if (finishResult.status !== 'skipped') {
1872
+ return {
1873
+ status: 'skipped',
1874
+ note: finishResult.status === 'failed'
1875
+ ? 'tracked doctor repairs remain in the sandbox after finish failure'
1876
+ : 'tracked doctor repairs are being delivered through the sandbox finish flow',
1877
+ };
1878
+ }
1879
+
1880
+ const allowedPaths = new Set([
1881
+ ...(autoCommitResult.stagedFiles || []),
1882
+ ...OMX_SCAFFOLD_DIRECTORIES,
1883
+ ...Array.from(OMX_SCAFFOLD_FILES.keys()),
1884
+ ...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
1885
+ 'bin',
1886
+ 'package.json',
1887
+ '.gitignore',
1888
+ 'AGENTS.md',
1889
+ ]);
1890
+ const dirtyPaths = collectWorktreeDirtyPaths(blocked.repoRoot);
1891
+ let stashRef = '';
1892
+ if (dirtyPaths.length > 0) {
1893
+ const unexpectedPaths = dirtyPaths.filter((filePath) => {
1894
+ if (allowedPaths.has(filePath)) {
1895
+ return false;
1896
+ }
1897
+ return !AGENT_WORKTREE_RELATIVE_DIRS.some(
1898
+ (relativeDir) => filePath === relativeDir || filePath.startsWith(`${relativeDir}/`),
1899
+ );
1900
+ });
1901
+ if (unexpectedPaths.length > 0) {
1902
+ return {
1903
+ status: 'failed',
1904
+ note: `protected branch workspace has unrelated local changes: ${unexpectedPaths.join(', ')}`,
1905
+ };
1906
+ }
1907
+ const stashMessage = `guardex-doctor-merge-${Date.now()}`;
1908
+ const stashResult = run(
1909
+ 'git',
1910
+ ['-C', blocked.repoRoot, 'stash', 'push', '--all', '--message', stashMessage],
1911
+ { timeout: 30_000 },
1912
+ );
1913
+ if (isSpawnFailure(stashResult)) {
1914
+ return {
1915
+ status: 'failed',
1916
+ note: 'could not stash protected branch doctor drift before merge',
1917
+ stdout: stashResult.stdout || '',
1918
+ stderr: stashResult.stderr || '',
1919
+ };
1920
+ }
1921
+ if (stashResult.status !== 0) {
1922
+ return {
1923
+ status: 'failed',
1924
+ note: 'stashing protected branch doctor drift failed',
1925
+ stdout: stashResult.stdout || '',
1926
+ stderr: stashResult.stderr || '',
1927
+ };
1928
+ }
1929
+
1930
+ const stashLookup = run(
1931
+ 'git',
1932
+ ['-C', blocked.repoRoot, 'stash', 'list'],
1933
+ { timeout: 20_000 },
1934
+ );
1935
+ stashRef = String(stashLookup.stdout || '')
1936
+ .split('\n')
1937
+ .find((line) => line.includes(stashMessage))
1938
+ ?.split(':')[0]
1939
+ ?.trim() || '';
1940
+ }
1941
+
1942
+ const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
1943
+ if (!restoreResult.ok) {
1944
+ if (stashRef) {
1945
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1946
+ }
1947
+ return {
1948
+ status: 'failed',
1949
+ note: `could not restore protected branch '${blocked.branch}' before applying sandbox repairs`,
1950
+ stdout: restoreResult.stdout || '',
1951
+ stderr: restoreResult.stderr || '',
1952
+ };
1953
+ }
1954
+
1955
+ const mergeResult = run(
1956
+ 'git',
1957
+ ['-C', blocked.repoRoot, 'merge', '--ff-only', metadata.branch],
1958
+ { timeout: 30_000 },
1959
+ );
1960
+ if (isSpawnFailure(mergeResult)) {
1961
+ if (stashRef) {
1962
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1963
+ }
1964
+ return {
1965
+ status: 'failed',
1966
+ note: 'tracked doctor repair merge errored',
1967
+ stdout: mergeResult.stdout || '',
1968
+ stderr: mergeResult.stderr || '',
1969
+ };
1970
+ }
1971
+ if (mergeResult.status !== 0) {
1972
+ if (stashRef) {
1973
+ run('git', ['-C', blocked.repoRoot, 'stash', 'apply', stashRef], { timeout: 30_000 });
1974
+ }
1975
+ return {
1976
+ status: 'failed',
1977
+ note: 'tracked doctor repair merge failed',
1978
+ stdout: mergeResult.stdout || '',
1979
+ stderr: mergeResult.stderr || '',
1980
+ };
1981
+ }
1982
+
1983
+ let cleanupResult;
1984
+ try {
1985
+ cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
1986
+ } catch (error) {
1987
+ return {
1988
+ status: 'failed',
1989
+ note: `tracked doctor repair merge succeeded but sandbox cleanup failed: ${error.message}`,
1990
+ stdout: mergeResult.stdout || '',
1991
+ stderr: mergeResult.stderr || '',
1992
+ };
1993
+ }
1994
+
1995
+ let hookRefreshResult;
1996
+ try {
1997
+ hookRefreshResult = configureHooks(blocked.repoRoot, false);
1998
+ } catch (error) {
1999
+ return {
2000
+ status: 'failed',
2001
+ note: `tracked doctor repair merge succeeded but local hook refresh failed: ${error.message}`,
2002
+ stdout: mergeResult.stdout || '',
2003
+ stderr: mergeResult.stderr || '',
2004
+ };
2005
+ }
2006
+
2007
+ if (stashRef) {
2008
+ run('git', ['-C', blocked.repoRoot, 'stash', 'drop', stashRef], { timeout: 20_000 });
2009
+ }
2010
+
2011
+ return {
2012
+ status: 'merged',
2013
+ note: 'fast-forwarded tracked doctor repairs into the protected base workspace',
2014
+ stdout: mergeResult.stdout || '',
2015
+ stderr: mergeResult.stderr || '',
2016
+ cleanup: cleanupResult,
2017
+ hookRefresh: hookRefreshResult,
2018
+ };
2019
+ }
2020
+
2021
+ function syncDoctorLocalSupportFiles(repoRoot, dryRun) {
2022
+ return TEMPLATE_FILES
2023
+ .filter((entry) => entry.startsWith('codex/') || entry.startsWith('claude/'))
2024
+ .map((entry) => ensureTemplateFilePresent(repoRoot, entry, dryRun));
2025
+ }
2026
+
1583
2027
  function runDoctorInSandbox(options, blocked) {
1584
- const startResult = startDoctorSandbox(blocked);
2028
+ const startResult = startProtectedBaseSandbox(blocked, {
2029
+ taskName: `${SHORT_TOOL_NAME}-doctor`,
2030
+ sandboxSuffix: 'gx-doctor',
2031
+ });
1585
2032
  const metadata = startResult.metadata;
1586
2033
 
1587
2034
  const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
@@ -1603,10 +2050,15 @@ function runDoctorInSandbox(options, blocked) {
1603
2050
  note: 'sandbox doctor did not complete successfully',
1604
2051
  };
1605
2052
 
2053
+ let protectedBaseRepairSyncResult = {
2054
+ status: 'skipped',
2055
+ note: 'sandbox doctor did not complete successfully',
2056
+ };
1606
2057
  let lockSyncResult = {
1607
2058
  status: 'skipped',
1608
2059
  note: 'sandbox doctor did not complete successfully',
1609
2060
  };
2061
+ let sandboxLockContent = null;
1610
2062
  let postSandboxAutoFinishSummary = {
1611
2063
  enabled: false,
1612
2064
  attempted: 0,
@@ -1639,7 +2091,7 @@ function runDoctorInSandbox(options, blocked) {
1639
2091
  if (!options.dryRun) {
1640
2092
  autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
1641
2093
  if (autoCommitResult.status === 'committed') {
1642
- finishResult = finishDoctorSandboxBranch(blocked, metadata);
2094
+ finishResult = finishDoctorSandboxBranch(blocked, metadata, options);
1643
2095
  } else if (autoCommitResult.status === 'no-changes') {
1644
2096
  finishResult = {
1645
2097
  status: 'skipped',
@@ -1675,7 +2127,11 @@ function runDoctorInSandbox(options, blocked) {
1675
2127
  note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
1676
2128
  };
1677
2129
  } else {
1678
- const sourceContent = fs.readFileSync(sandboxLockPath, 'utf8');
2130
+ const sourceContent = stripDoctorSandboxLocks(
2131
+ fs.readFileSync(sandboxLockPath, 'utf8'),
2132
+ metadata.branch,
2133
+ );
2134
+ sandboxLockContent = sourceContent;
1679
2135
  const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
1680
2136
  if (sourceContent === destinationContent) {
1681
2137
  lockSyncResult = {
@@ -1692,6 +2148,62 @@ function runDoctorInSandbox(options, blocked) {
1692
2148
  }
1693
2149
  }
1694
2150
 
2151
+ protectedBaseRepairSyncResult = mergeDoctorSandboxRepairsBackToProtectedBase(
2152
+ options,
2153
+ blocked,
2154
+ metadata,
2155
+ autoCommitResult,
2156
+ finishResult,
2157
+ );
2158
+
2159
+ syncDoctorLocalSupportFiles(blocked.repoRoot, Boolean(options.dryRun));
2160
+
2161
+ const postMergeOmxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
2162
+ const postMergeChangedOmxPaths = postMergeOmxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
2163
+ if (postMergeChangedOmxPaths.length === 0) {
2164
+ omxScaffoldSyncResult = {
2165
+ status: 'unchanged',
2166
+ note: '.omx scaffold already in sync',
2167
+ operations: postMergeOmxScaffoldOps,
2168
+ };
2169
+ } else {
2170
+ omxScaffoldSyncResult = {
2171
+ status: options.dryRun ? 'would-sync' : 'synced',
2172
+ note: `${options.dryRun ? 'would sync' : 'synced'} ${postMergeChangedOmxPaths.length} .omx path(s)`,
2173
+ operations: postMergeOmxScaffoldOps,
2174
+ };
2175
+ }
2176
+
2177
+ const postMergeBaseLockPath = path.join(blocked.repoRoot, LOCK_FILE_RELATIVE);
2178
+ if (sandboxLockContent === null) {
2179
+ lockSyncResult = {
2180
+ status: 'skipped',
2181
+ note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
2182
+ };
2183
+ } else if (!fs.existsSync(postMergeBaseLockPath)) {
2184
+ fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
2185
+ fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
2186
+ lockSyncResult = {
2187
+ status: 'synced',
2188
+ note: `${LOCK_FILE_RELATIVE} recreated from sandbox`,
2189
+ };
2190
+ } else {
2191
+ const destinationContent = fs.readFileSync(postMergeBaseLockPath, 'utf8');
2192
+ if (sandboxLockContent === destinationContent) {
2193
+ lockSyncResult = {
2194
+ status: 'unchanged',
2195
+ note: `${LOCK_FILE_RELATIVE} already in sync`,
2196
+ };
2197
+ } else {
2198
+ fs.mkdirSync(path.dirname(postMergeBaseLockPath), { recursive: true });
2199
+ fs.writeFileSync(postMergeBaseLockPath, sandboxLockContent, 'utf8');
2200
+ lockSyncResult = {
2201
+ status: 'synced',
2202
+ note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
2203
+ };
2204
+ }
2205
+ }
2206
+
1695
2207
  postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
1696
2208
  baseBranch: blocked.branch,
1697
2209
  dryRun: options.dryRun,
@@ -1708,6 +2220,7 @@ function runDoctorInSandbox(options, blocked) {
1708
2220
  JSON.stringify(
1709
2221
  {
1710
2222
  ...parsed,
2223
+ protectedBaseRepairSync: protectedBaseRepairSyncResult,
1711
2224
  sandboxOmxScaffoldSync: omxScaffoldSyncResult,
1712
2225
  sandboxLockSync: lockSyncResult,
1713
2226
  sandboxAutoCommit: autoCommitResult,
@@ -1738,14 +2251,38 @@ function runDoctorInSandbox(options, blocked) {
1738
2251
  if (nestedResult.status === 0) {
1739
2252
  if (autoCommitResult.status === 'committed') {
1740
2253
  console.log(
1741
- `[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
2254
+ `[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
2255
+ );
2256
+ } else if (autoCommitResult.status === 'failed') {
2257
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
2258
+ if (autoCommitResult.stdout) process.stdout.write(autoCommitResult.stdout);
2259
+ if (autoCommitResult.stderr) process.stderr.write(autoCommitResult.stderr);
2260
+ } else {
2261
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
2262
+ }
2263
+
2264
+ if (protectedBaseRepairSyncResult.status === 'merged') {
2265
+ console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
2266
+ } else if (protectedBaseRepairSyncResult.status === 'unchanged') {
2267
+ console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
2268
+ } else if (protectedBaseRepairSyncResult.status === 'would-merge') {
2269
+ console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
2270
+ } else if (protectedBaseRepairSyncResult.status === 'failed') {
2271
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${protectedBaseRepairSyncResult.note}.`);
2272
+ if (protectedBaseRepairSyncResult.stdout) process.stdout.write(protectedBaseRepairSyncResult.stdout);
2273
+ if (protectedBaseRepairSyncResult.stderr) process.stderr.write(protectedBaseRepairSyncResult.stderr);
2274
+ } else {
2275
+ console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${protectedBaseRepairSyncResult.note}.`);
2276
+ }
2277
+
2278
+ if (lockSyncResult.status === 'synced') {
2279
+ console.log(
2280
+ `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
1742
2281
  );
1743
- } else if (autoCommitResult.status === 'failed') {
1744
- console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
1745
- if (autoCommitResult.stdout) process.stdout.write(autoCommitResult.stdout);
1746
- if (autoCommitResult.stderr) process.stderr.write(autoCommitResult.stderr);
2282
+ } else if (lockSyncResult.status === 'unchanged') {
2283
+ console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1747
2284
  } else {
1748
- console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
2285
+ console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1749
2286
  }
1750
2287
 
1751
2288
  if (finishResult.status === 'completed') {
@@ -1763,22 +2300,13 @@ function runDoctorInSandbox(options, blocked) {
1763
2300
  if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1764
2301
  } else if (finishResult.status === 'failed') {
1765
2302
  console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
2303
+ console.log(`[guardex] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
1766
2304
  if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1767
2305
  if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1768
2306
  } else {
1769
2307
  console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
1770
2308
  }
1771
2309
 
1772
- if (lockSyncResult.status === 'synced') {
1773
- console.log(
1774
- `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
1775
- );
1776
- } else if (lockSyncResult.status === 'unchanged') {
1777
- console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1778
- } else {
1779
- console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1780
- }
1781
-
1782
2310
  if (postSandboxAutoFinishSummary.enabled) {
1783
2311
  console.log(
1784
2312
  `[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
@@ -1813,12 +2341,89 @@ function runDoctorInSandbox(options, blocked) {
1813
2341
  ) {
1814
2342
  exitCode = 1;
1815
2343
  }
2344
+ if (exitCode === 0 && protectedBaseRepairSyncResult.status === 'failed') {
2345
+ exitCode = 1;
2346
+ }
1816
2347
  process.exitCode = exitCode;
1817
2348
  return;
1818
2349
  }
1819
2350
  process.exitCode = 1;
1820
2351
  }
1821
2352
 
2353
+ function runSetupInSandbox(options, blocked, repoLabel = '') {
2354
+ const startResult = startProtectedBaseSandbox(blocked, {
2355
+ taskName: `${SHORT_TOOL_NAME}-setup`,
2356
+ sandboxSuffix: 'gx-setup',
2357
+ });
2358
+ const metadata = startResult.metadata;
2359
+
2360
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
2361
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
2362
+ console.log(
2363
+ `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` +
2364
+ 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.',
2365
+ );
2366
+
2367
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
2368
+ const nestedResult = run(
2369
+ process.execPath,
2370
+ [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
2371
+ { cwd: metadata.worktreePath },
2372
+ );
2373
+ if (isSpawnFailure(nestedResult)) {
2374
+ throw nestedResult.error;
2375
+ }
2376
+ if (nestedResult.status !== 0) {
2377
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
2378
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
2379
+ throw new Error(
2380
+ `sandboxed setup failed for protected branch '${blocked.branch}'. ` +
2381
+ `Inspect sandbox at ${metadata.worktreePath}`,
2382
+ );
2383
+ }
2384
+
2385
+ const syncOptions = {
2386
+ ...options,
2387
+ target: blocked.repoRoot,
2388
+ recursive: false,
2389
+ allowProtectedBaseWrite: true,
2390
+ };
2391
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions);
2392
+ printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun);
2393
+ printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun);
2394
+ if (!syncOptions.dryRun && parentWorkspace) {
2395
+ console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
2396
+ }
2397
+
2398
+ const scanResult = runScanInternal({ target: blocked.repoRoot, json: false });
2399
+ const currentBaseBranch = currentBranchName(scanResult.repoRoot);
2400
+ const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
2401
+ baseBranch: currentBaseBranch,
2402
+ dryRun: syncOptions.dryRun,
2403
+ });
2404
+ printScanResult(scanResult, false);
2405
+ if (autoFinishSummary.enabled) {
2406
+ console.log(
2407
+ `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
2408
+ );
2409
+ for (const detail of autoFinishSummary.details) {
2410
+ console.log(`[${TOOL_NAME}] ${detail}`);
2411
+ }
2412
+ } else if (autoFinishSummary.details.length > 0) {
2413
+ console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
2414
+ }
2415
+
2416
+ const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
2417
+ console.log(
2418
+ `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
2419
+ `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`,
2420
+ );
2421
+
2422
+ return {
2423
+ scanResult,
2424
+ };
2425
+ }
2426
+
1822
2427
  function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
1823
2428
  const remaining = [];
1824
2429
  let target = defaultTarget;
@@ -2000,6 +2605,19 @@ function inferGithubRepoFromOrigin(repoRoot) {
2000
2605
  return `github.com/${slug}`;
2001
2606
  }
2002
2607
 
2608
+ function inferGithubRepoSlug(rawValue) {
2609
+ const raw = String(rawValue || '').trim();
2610
+ if (!raw) return '';
2611
+ const match = raw.match(/github\.com[:/](.+?)(?:\.git)?$/i);
2612
+ if (!match) return '';
2613
+ const slug = String(match[1] || '')
2614
+ .replace(/^\/+/, '')
2615
+ .replace(/^github\.com\//i, '')
2616
+ .trim();
2617
+ if (!slug || !slug.includes('/')) return '';
2618
+ return slug;
2619
+ }
2620
+
2003
2621
  function resolveScorecardRepo(repoRoot, explicitRepo) {
2004
2622
  if (explicitRepo) {
2005
2623
  return explicitRepo.trim();
@@ -2483,6 +3101,66 @@ function currentBranchName(repoRoot) {
2483
3101
  return branch;
2484
3102
  }
2485
3103
 
3104
+ function repoHasHeadCommit(repoRoot) {
3105
+ return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
3106
+ }
3107
+
3108
+ function readBranchDisplayName(repoRoot) {
3109
+ const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
3110
+ if (symbolic.status === 0) {
3111
+ const branch = String(symbolic.stdout || '').trim();
3112
+ if (!branch) {
3113
+ return '(unknown)';
3114
+ }
3115
+ return repoHasHeadCommit(repoRoot) ? branch : `${branch} (unborn; no commits yet)`;
3116
+ }
3117
+
3118
+ const detached = gitRun(repoRoot, ['rev-parse', '--short', 'HEAD'], { allowFailure: true });
3119
+ if (detached.status === 0) {
3120
+ return `(detached at ${String(detached.stdout || '').trim()})`;
3121
+ }
3122
+ return '(unknown)';
3123
+ }
3124
+
3125
+ function repoHasOriginRemote(repoRoot) {
3126
+ return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
3127
+ }
3128
+
3129
+ function detectComposeHintFiles(repoRoot) {
3130
+ return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
3131
+ }
3132
+
3133
+ function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
3134
+ const branchDisplay = readBranchDisplayName(repoRoot);
3135
+ const hasHeadCommit = repoHasHeadCommit(repoRoot);
3136
+ const hasOrigin = repoHasOriginRemote(repoRoot);
3137
+ const composeFiles = detectComposeHintFiles(repoRoot);
3138
+ if (hasHeadCommit && hasOrigin && composeFiles.length === 0) {
3139
+ return;
3140
+ }
3141
+
3142
+ const label = repoLabel ? ` ${repoLabel}` : '';
3143
+ if (!hasHeadCommit) {
3144
+ console.log(`[${TOOL_NAME}] Fresh repo onboarding${label}: current branch is ${branchDisplay}.`);
3145
+ console.log(`[${TOOL_NAME}] Bootstrap commit${label}: git add . && git commit -m "bootstrap gitguardex"`);
3146
+ console.log(
3147
+ `[${TOOL_NAME}] First agent flow${label}: ` +
3148
+ `bash scripts/agent-branch-start.sh "<task>" "codex" -> ` +
3149
+ `python3 scripts/agent-file-locks.py claim --branch "$(git branch --show-current)" <file...> -> ` +
3150
+ `bash scripts/agent-branch-finish.sh --branch "$(git branch --show-current)" --base ${baseBranch} --via-pr --wait-for-merge`,
3151
+ );
3152
+ }
3153
+ if (!hasOrigin) {
3154
+ console.log(`[${TOOL_NAME}] No origin remote${label}: finish and auto-merge flows stay local until you add one.`);
3155
+ }
3156
+ if (composeFiles.length > 0) {
3157
+ console.log(
3158
+ `[${TOOL_NAME}] Docker Compose helper${label}: detected ${composeFiles.join(', ')}. ` +
3159
+ `Set GUARDEX_DOCKER_SERVICE and run 'bash scripts/guardex-docker-loader.sh -- <command...>'.`,
3160
+ );
3161
+ }
3162
+ }
3163
+
2486
3164
  function workingTreeIsDirty(repoRoot) {
2487
3165
  const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
2488
3166
  if (result.status !== 0) {
@@ -3294,6 +3972,17 @@ function parseVersionString(version) {
3294
3972
  ];
3295
3973
  }
3296
3974
 
3975
+ function compareParsedVersions(left, right) {
3976
+ if (!left || !right) return 0;
3977
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
3978
+ const leftValue = left[index] || 0;
3979
+ const rightValue = right[index] || 0;
3980
+ if (leftValue > rightValue) return 1;
3981
+ if (leftValue < rightValue) return -1;
3982
+ }
3983
+ return 0;
3984
+ }
3985
+
3297
3986
  function isNewerVersion(latest, current) {
3298
3987
  const latestParts = parseVersionString(latest);
3299
3988
  const currentParts = parseVersionString(current);
@@ -3302,11 +3991,7 @@ function isNewerVersion(latest, current) {
3302
3991
  return String(latest || '').trim() !== String(current || '').trim();
3303
3992
  }
3304
3993
 
3305
- for (let index = 0; index < latestParts.length; index += 1) {
3306
- if (latestParts[index] > currentParts[index]) return true;
3307
- if (latestParts[index] < currentParts[index]) return false;
3308
- }
3309
- return false;
3994
+ return compareParsedVersions(latestParts, currentParts) > 0;
3310
3995
  }
3311
3996
 
3312
3997
  function parseNpmVersionOutput(stdout) {
@@ -3897,6 +4582,10 @@ function runInstallInternal(options) {
3897
4582
  }
3898
4583
  const operations = [];
3899
4584
 
4585
+ if (!options.skipGitignore) {
4586
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
4587
+ }
4588
+
3900
4589
  operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3901
4590
 
3902
4591
  for (const templateFile of TEMPLATE_FILES) {
@@ -3904,9 +4593,6 @@ function runInstallInternal(options) {
3904
4593
  }
3905
4594
 
3906
4595
  operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
3907
- if (!options.skipGitignore) {
3908
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3909
- }
3910
4596
 
3911
4597
  if (!options.skipPackageJson) {
3912
4598
  operations.push(ensurePackageScripts(repoRoot, Boolean(options.dryRun), { force: Boolean(options.force) }));
@@ -3941,6 +4627,10 @@ function runFixInternal(options) {
3941
4627
  }
3942
4628
  const operations = [];
3943
4629
 
4630
+ if (!options.skipGitignore) {
4631
+ operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
4632
+ }
4633
+
3944
4634
  operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
3945
4635
 
3946
4636
  for (const templateFile of TEMPLATE_FILES) {
@@ -3948,9 +4638,6 @@ function runFixInternal(options) {
3948
4638
  }
3949
4639
 
3950
4640
  operations.push(ensureLockRegistry(repoRoot, Boolean(options.dryRun)));
3951
- if (!options.skipGitignore) {
3952
- operations.push(ensureManagedGitignore(repoRoot, Boolean(options.dryRun)));
3953
- }
3954
4641
 
3955
4642
  const lockState = lockStateOrError(repoRoot);
3956
4643
  if (!lockState.ok) {
@@ -3994,8 +4681,7 @@ function runFixInternal(options) {
3994
4681
  function runScanInternal(options) {
3995
4682
  const repoRoot = resolveRepoRoot(options.target);
3996
4683
  const guardexToggle = resolveGuardexRepoToggle(repoRoot);
3997
- const currentBranchResult = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
3998
- const branch = currentBranchResult.status === 0 ? currentBranchResult.stdout.trim() : '(unknown)';
4684
+ const branch = readBranchDisplayName(repoRoot);
3999
4685
  if (!guardexToggle.enabled) {
4000
4686
  return {
4001
4687
  repoRoot,
@@ -4421,26 +5107,118 @@ function scan(rawArgs) {
4421
5107
  }
4422
5108
 
4423
5109
  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
- });
5110
+ const options = parseDoctorArgs(rawArgs);
5111
+ const topRepoRoot = resolveRepoRoot(options.target);
5112
+ const discoveredRepos = options.recursive
5113
+ ? discoverNestedGitRepos(topRepoRoot, {
5114
+ maxDepth: options.nestedMaxDepth,
5115
+ extraSkip: options.nestedSkipDirs,
5116
+ includeSubmodules: options.includeSubmodules,
5117
+ })
5118
+ : [topRepoRoot];
5119
+
5120
+ if (discoveredRepos.length > 1) {
5121
+ if (!options.json) {
5122
+ console.log(
5123
+ `[${TOOL_NAME}] Detected ${discoveredRepos.length} git repos under ${topRepoRoot}. ` +
5124
+ `Repairing each with doctor (use --single-repo to limit to the target).`,
5125
+ );
5126
+ }
5127
+
5128
+ const repoResults = [];
5129
+ let aggregateExitCode = 0;
5130
+ for (const repoPath of discoveredRepos) {
5131
+ if (!options.json) {
5132
+ console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} ──`);
5133
+ }
5134
+
5135
+ const nestedResult = run(
5136
+ process.execPath,
5137
+ [
5138
+ path.resolve(__filename),
5139
+ 'doctor',
5140
+ '--single-repo',
5141
+ '--target',
5142
+ repoPath,
5143
+ ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
5144
+ ...(options.skipAgents ? ['--skip-agents'] : []),
5145
+ ...(options.skipPackageJson ? ['--skip-package-json'] : []),
5146
+ ...(options.skipGitignore ? ['--no-gitignore'] : []),
5147
+ ...(options.dryRun ? ['--dry-run'] : []),
5148
+ // Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
5149
+ '--no-wait-for-merge',
5150
+ ...(options.json ? ['--json'] : []),
5151
+ ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
5152
+ ],
5153
+ { cwd: topRepoRoot },
5154
+ );
5155
+ if (isSpawnFailure(nestedResult)) {
5156
+ throw nestedResult.error;
5157
+ }
5158
+
5159
+ const exitCode = typeof nestedResult.status === 'number' ? nestedResult.status : 1;
5160
+ if (exitCode !== 0 && aggregateExitCode === 0) {
5161
+ aggregateExitCode = exitCode;
5162
+ }
5163
+
5164
+ if (options.json) {
5165
+ let parsedResult = null;
5166
+ if (nestedResult.stdout) {
5167
+ try {
5168
+ parsedResult = JSON.parse(nestedResult.stdout);
5169
+ } catch {
5170
+ parsedResult = null;
5171
+ }
5172
+ }
5173
+ repoResults.push(
5174
+ parsedResult
5175
+ ? { repoRoot: repoPath, exitCode, result: parsedResult }
5176
+ : {
5177
+ repoRoot: repoPath,
5178
+ exitCode,
5179
+ stdout: nestedResult.stdout || '',
5180
+ stderr: nestedResult.stderr || '',
5181
+ },
5182
+ );
5183
+ } else {
5184
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
5185
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
5186
+ process.stdout.write('\n');
5187
+ }
5188
+ }
5189
+
5190
+ if (options.json) {
5191
+ process.stdout.write(
5192
+ JSON.stringify(
5193
+ {
5194
+ repoRoot: topRepoRoot,
5195
+ recursive: true,
5196
+ repos: repoResults,
5197
+ },
5198
+ null,
5199
+ 2,
5200
+ ) + '\n',
5201
+ );
5202
+ }
5203
+
5204
+ process.exitCode = aggregateExitCode;
5205
+ return;
5206
+ }
5207
+
5208
+ const singleRepoOptions = {
5209
+ ...options,
5210
+ target: topRepoRoot,
5211
+ };
4434
5212
 
4435
- const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false });
5213
+ const blocked = protectedBaseWriteBlock(singleRepoOptions, { requireBootstrap: false });
4436
5214
  if (blocked) {
4437
- runDoctorInSandbox(options, blocked);
5215
+ runDoctorInSandbox(singleRepoOptions, blocked);
4438
5216
  return;
4439
5217
  }
4440
5218
 
4441
- assertProtectedMainWriteAllowed(options, 'doctor');
4442
- const fixPayload = runFixInternal(options);
4443
- const scanResult = runScanInternal({ target: options.target, json: false });
5219
+ assertProtectedMainWriteAllowed(singleRepoOptions, 'doctor');
5220
+ const fixPayload = runFixInternal(singleRepoOptions);
5221
+ const scanResult = runScanInternal({ target: singleRepoOptions.target, json: false });
4444
5222
  const currentBaseBranch = currentBranchName(scanResult.repoRoot);
4445
5223
  const autoFinishSummary = scanResult.guardexEnabled === false
4446
5224
  ? {
@@ -4453,12 +5231,12 @@ function doctor(rawArgs) {
4453
5231
  }
4454
5232
  : autoFinishReadyAgentBranches(scanResult.repoRoot, {
4455
5233
  baseBranch: currentBaseBranch,
4456
- dryRun: options.dryRun,
5234
+ dryRun: singleRepoOptions.dryRun,
4457
5235
  });
4458
5236
  const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
4459
5237
  const musafe = safe;
4460
5238
 
4461
- if (options.json) {
5239
+ if (singleRepoOptions.json) {
4462
5240
  process.stdout.write(
4463
5241
  JSON.stringify(
4464
5242
  {
@@ -4469,7 +5247,7 @@ function doctor(rawArgs) {
4469
5247
  fix: {
4470
5248
  operations: fixPayload.operations,
4471
5249
  hookResult: fixPayload.hookResult,
4472
- dryRun: Boolean(options.dryRun),
5250
+ dryRun: Boolean(singleRepoOptions.dryRun),
4473
5251
  },
4474
5252
  scan: {
4475
5253
  guardexEnabled: scanResult.guardexEnabled !== false,
@@ -4997,31 +5775,24 @@ function setup(rawArgs) {
4997
5775
  console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`);
4998
5776
  }
4999
5777
 
5000
- assertProtectedMainWriteAllowed(perRepoOptions, 'setup');
5001
- const installPayload = runInstallInternal(perRepoOptions);
5002
- installPayload.operations.push(ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(perRepoOptions.dryRun)));
5003
- if (perRepoOptions.parentWorkspaceView) {
5004
- installPayload.operations.push(ensureParentWorkspaceView(installPayload.repoRoot, Boolean(perRepoOptions.dryRun)));
5778
+ const blocked = protectedBaseWriteBlock(perRepoOptions);
5779
+ if (blocked) {
5780
+ const sandboxResult = runSetupInSandbox(perRepoOptions, blocked, repoLabel);
5781
+ aggregateErrors += sandboxResult.scanResult.errors;
5782
+ aggregateWarnings += sandboxResult.scanResult.warnings;
5783
+ lastScanResult = sandboxResult.scanResult;
5784
+ continue;
5005
5785
  }
5006
- printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
5007
5786
 
5008
- const fixPayload = runFixInternal({
5009
- target: repoPath,
5010
- dryRun: perRepoOptions.dryRun,
5011
- force: perRepoOptions.force,
5012
- dropStaleLocks: true,
5013
- skipAgents: perRepoOptions.skipAgents,
5014
- skipPackageJson: perRepoOptions.skipPackageJson,
5015
- skipGitignore: perRepoOptions.skipGitignore,
5016
- });
5787
+ const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(perRepoOptions);
5788
+ printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun);
5017
5789
  printOperations(`Setup/fix${repoLabel}`, fixPayload, perRepoOptions.dryRun);
5018
5790
 
5019
5791
  if (perRepoOptions.dryRun) {
5020
5792
  continue;
5021
5793
  }
5022
5794
 
5023
- if (perRepoOptions.parentWorkspaceView) {
5024
- const parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot);
5795
+ if (parentWorkspace) {
5025
5796
  console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`);
5026
5797
  }
5027
5798
 
@@ -5042,6 +5813,7 @@ function setup(rawArgs) {
5042
5813
  } else if (autoFinishSummary.details.length > 0) {
5043
5814
  console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
5044
5815
  }
5816
+ printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
5045
5817
 
5046
5818
  aggregateErrors += scanResult.errors;
5047
5819
  aggregateWarnings += scanResult.warnings;
@@ -5101,6 +5873,156 @@ function ensureCleanWorkingTree(repoRoot) {
5101
5873
  }
5102
5874
  }
5103
5875
 
5876
+ function readReleaseRepoPackageJson(repoRoot) {
5877
+ const manifestPath = path.join(repoRoot, 'package.json');
5878
+ if (!fs.existsSync(manifestPath)) {
5879
+ throw new Error(`Release blocked: package.json missing in ${repoRoot}`);
5880
+ }
5881
+
5882
+ try {
5883
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
5884
+ } catch (error) {
5885
+ throw new Error(`Release blocked: unable to parse package.json in ${repoRoot}: ${error.message}`);
5886
+ }
5887
+ }
5888
+
5889
+ function resolveReleaseGithubRepo(repoRoot) {
5890
+ const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
5891
+ const fromManifest = inferGithubRepoSlug(
5892
+ releasePackageJson.repository &&
5893
+ (releasePackageJson.repository.url || releasePackageJson.repository),
5894
+ );
5895
+ if (fromManifest) {
5896
+ return fromManifest;
5897
+ }
5898
+
5899
+ const fromOrigin = inferGithubRepoSlug(readGitConfig(repoRoot, 'remote.origin.url'));
5900
+ if (fromOrigin) {
5901
+ return fromOrigin;
5902
+ }
5903
+
5904
+ throw new Error(
5905
+ 'Release blocked: unable to resolve GitHub repo from package.json repository URL or origin remote.',
5906
+ );
5907
+ }
5908
+
5909
+ function readRepoReadme(repoRoot) {
5910
+ const readmePath = path.join(repoRoot, 'README.md');
5911
+ if (!fs.existsSync(readmePath)) {
5912
+ throw new Error(`Release blocked: README.md missing in ${repoRoot}`);
5913
+ }
5914
+ return fs.readFileSync(readmePath, 'utf8');
5915
+ }
5916
+
5917
+ function parseReadmeReleaseEntries(readmeContent) {
5918
+ const releaseNotesIndex = String(readmeContent || '').indexOf('## Release notes');
5919
+ if (releaseNotesIndex < 0) {
5920
+ throw new Error('Release blocked: README.md is missing the "## Release notes" section');
5921
+ }
5922
+
5923
+ const releaseNotesContent = String(readmeContent || '').slice(releaseNotesIndex);
5924
+ const entries = [];
5925
+ const lines = releaseNotesContent.split(/\r?\n/);
5926
+ let currentTag = '';
5927
+ let currentLines = [];
5928
+
5929
+ function flushEntry() {
5930
+ if (!currentTag) {
5931
+ return;
5932
+ }
5933
+ const body = currentLines.join('\n').trim();
5934
+ if (body) {
5935
+ entries.push({ tag: currentTag, body, version: parseVersionString(currentTag) });
5936
+ }
5937
+ currentTag = '';
5938
+ currentLines = [];
5939
+ }
5940
+
5941
+ for (const line of lines) {
5942
+ const headingMatch = line.match(/^###\s+(v\d+\.\d+\.\d+)\s*$/);
5943
+ if (headingMatch) {
5944
+ flushEntry();
5945
+ currentTag = headingMatch[1];
5946
+ continue;
5947
+ }
5948
+
5949
+ if (!currentTag) {
5950
+ continue;
5951
+ }
5952
+
5953
+ if (/^<\/details>\s*$/.test(line) || /^##\s+/.test(line)) {
5954
+ flushEntry();
5955
+ continue;
5956
+ }
5957
+
5958
+ currentLines.push(line);
5959
+ }
5960
+
5961
+ flushEntry();
5962
+
5963
+ if (entries.length === 0) {
5964
+ throw new Error('Release blocked: README.md did not yield any versioned release-note sections');
5965
+ }
5966
+
5967
+ return entries;
5968
+ }
5969
+
5970
+ function resolvePreviousPublishedReleaseTag(repoSlug, currentTag) {
5971
+ const result = run(GH_BIN, ['release', 'list', '--repo', repoSlug, '--limit', '20'], {
5972
+ timeout: 20_000,
5973
+ });
5974
+ if (result.error) {
5975
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release list': ${result.error.message}`);
5976
+ }
5977
+ if (result.status !== 0) {
5978
+ const details = (result.stderr || result.stdout || '').trim();
5979
+ throw new Error(`Release blocked: unable to list GitHub releases.${details ? `\n${details}` : ''}`);
5980
+ }
5981
+
5982
+ const tags = String(result.stdout || '')
5983
+ .split('\n')
5984
+ .map((line) => line.split('\t')[0].trim())
5985
+ .filter(Boolean);
5986
+
5987
+ return tags.find((tag) => tag !== currentTag) || '';
5988
+ }
5989
+
5990
+ function selectReleaseEntriesForWindow(entries, currentTag, previousTag) {
5991
+ const currentVersion = parseVersionString(currentTag);
5992
+ if (!currentVersion) {
5993
+ throw new Error(`Release blocked: invalid current version tag '${currentTag}'`);
5994
+ }
5995
+ const previousVersion = previousTag ? parseVersionString(previousTag) : null;
5996
+
5997
+ const selected = entries.filter((entry) => {
5998
+ if (!entry.version) return false;
5999
+ if (compareParsedVersions(entry.version, currentVersion) > 0) return false;
6000
+ if (!previousVersion) return entry.tag === currentTag;
6001
+ return compareParsedVersions(entry.version, previousVersion) > 0;
6002
+ });
6003
+
6004
+ if (!selected.some((entry) => entry.tag === currentTag)) {
6005
+ throw new Error(`Release blocked: README.md is missing release notes for ${currentTag}`);
6006
+ }
6007
+
6008
+ return selected;
6009
+ }
6010
+
6011
+ function renderGeneratedReleaseNotes(entries, currentTag, previousTag) {
6012
+ const intro = previousTag ? `Changes since ${previousTag}.` : `Changes in ${currentTag}.`;
6013
+ const sections = entries
6014
+ .map((entry) => `### ${entry.tag}\n${entry.body}`)
6015
+ .join('\n\n');
6016
+ return `GitGuardex ${currentTag}\n\n${intro}\n\n${sections}`;
6017
+ }
6018
+
6019
+ function buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag) {
6020
+ const readme = readRepoReadme(repoRoot);
6021
+ const entries = parseReadmeReleaseEntries(readme);
6022
+ const selected = selectReleaseEntriesForWindow(entries, currentTag, previousTag);
6023
+ return renderGeneratedReleaseNotes(selected, currentTag, previousTag);
6024
+ }
6025
+
5104
6026
  function release(rawArgs) {
5105
6027
  if (rawArgs.length > 0) {
5106
6028
  throw new Error(`Unknown option: ${rawArgs[0]}`);
@@ -5116,13 +6038,74 @@ function release(rawArgs) {
5116
6038
  ensureMainBranch(repoRoot);
5117
6039
  ensureCleanWorkingTree(repoRoot);
5118
6040
 
5119
- console.log(`[${TOOL_NAME}] Releasing ${packageJson.name}@${packageJson.version} from ${repoRoot}`);
5120
- const publishResult = run(NPM_BIN, ['publish'], { cwd: repoRoot, stdio: 'inherit' });
5121
- if (publishResult.status !== 0) {
5122
- throw new Error('npm publish failed');
6041
+ if (!isCommandAvailable(GH_BIN)) {
6042
+ throw new Error(`Release blocked: '${GH_BIN}' is not available`);
6043
+ }
6044
+
6045
+ const ghAuthStatus = run(GH_BIN, ['auth', 'status'], { timeout: 20_000 });
6046
+ if (ghAuthStatus.error) {
6047
+ throw new Error(`Release blocked: unable to run '${GH_BIN} auth status': ${ghAuthStatus.error.message}`);
6048
+ }
6049
+ if (ghAuthStatus.status !== 0) {
6050
+ const details = (ghAuthStatus.stderr || ghAuthStatus.stdout || '').trim();
6051
+ throw new Error(`Release blocked: '${GH_BIN}' auth is unavailable.${details ? `\n${details}` : ''}`);
6052
+ }
6053
+
6054
+ const releasePackageJson = readReleaseRepoPackageJson(repoRoot);
6055
+ const repoSlug = resolveReleaseGithubRepo(repoRoot);
6056
+ const currentTag = `v${releasePackageJson.version}`;
6057
+ const previousTag = resolvePreviousPublishedReleaseTag(repoSlug, currentTag);
6058
+ const notes = buildReleaseNotesFromReadme(repoRoot, currentTag, previousTag);
6059
+ const headCommit = gitRun(repoRoot, ['rev-parse', 'HEAD']).stdout.trim();
6060
+
6061
+ const existingRelease = run(GH_BIN, ['release', 'view', currentTag, '--repo', repoSlug], {
6062
+ timeout: 20_000,
6063
+ });
6064
+ if (existingRelease.error) {
6065
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release view': ${existingRelease.error.message}`);
6066
+ }
6067
+
6068
+ const releaseArgs =
6069
+ existingRelease.status === 0
6070
+ ? ['release', 'edit', currentTag, '--repo', repoSlug, '--title', currentTag, '--notes', notes]
6071
+ : [
6072
+ 'release',
6073
+ 'create',
6074
+ currentTag,
6075
+ '--repo',
6076
+ repoSlug,
6077
+ '--target',
6078
+ headCommit,
6079
+ '--title',
6080
+ currentTag,
6081
+ '--notes',
6082
+ notes,
6083
+ ];
6084
+
6085
+ console.log(
6086
+ `[${TOOL_NAME}] ${existingRelease.status === 0 ? 'Updating' : 'Creating'} GitHub release ${currentTag} on ${repoSlug}`,
6087
+ );
6088
+ if (previousTag) {
6089
+ console.log(`[${TOOL_NAME}] Aggregating README release notes newer than ${previousTag}.`);
6090
+ } else {
6091
+ console.log(`[${TOOL_NAME}] No earlier published GitHub release found; using only ${currentTag}.`);
6092
+ }
6093
+
6094
+ const releaseResult = run(GH_BIN, releaseArgs, { cwd: repoRoot, timeout: 60_000 });
6095
+ if (releaseResult.error) {
6096
+ throw new Error(`Release blocked: unable to run '${GH_BIN} release': ${releaseResult.error.message}`);
6097
+ }
6098
+ if (releaseResult.status !== 0) {
6099
+ const details = (releaseResult.stderr || releaseResult.stdout || '').trim();
6100
+ throw new Error(`GitHub release command failed.${details ? `\n${details}` : ''}`);
6101
+ }
6102
+
6103
+ const releaseUrl = String(releaseResult.stdout || '').trim();
6104
+ if (releaseUrl) {
6105
+ console.log(releaseUrl);
5123
6106
  }
5124
6107
 
5125
- console.log(`[${TOOL_NAME}] ✅ Publish complete.`);
6108
+ console.log(`[${TOOL_NAME}] ✅ GitHub release ${currentTag} is synced to the README history.`);
5126
6109
  process.exitCode = 0;
5127
6110
  }
5128
6111
 
@@ -5926,6 +6909,7 @@ function main() {
5926
6909
  }
5927
6910
 
5928
6911
  if (command === '--version' || command === '-v' || command === 'version') {
6912
+ maybeSelfUpdateBeforeStatus();
5929
6913
  console.log(packageJson.version);
5930
6914
  return;
5931
6915
  }