@imdeadpool/guardex 5.0.1 → 5.0.3

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.
@@ -15,6 +15,14 @@ const GLOBAL_TOOLCHAIN_PACKAGES = [
15
15
  '@fission-ai/openspec',
16
16
  '@imdeadpool/codex-account-switcher',
17
17
  ];
18
+ const GH_BIN = process.env.MUSAFETY_GH_BIN || 'gh';
19
+ const REQUIRED_SYSTEM_TOOLS = [
20
+ {
21
+ name: 'gh',
22
+ command: GH_BIN,
23
+ installHint: 'https://cli.github.com/',
24
+ },
25
+ ];
18
26
  const MAINTAINER_RELEASE_REPO = path.resolve(
19
27
  process.env.MUSAFETY_RELEASE_REPO || '/tmp/multiagent-safety',
20
28
  );
@@ -58,6 +66,8 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
58
66
  '.githooks/pre-commit',
59
67
  'scripts/agent-branch-start.sh',
60
68
  'scripts/agent-branch-finish.sh',
69
+ 'scripts/agent-worktree-prune.sh',
70
+ 'scripts/codex-agent.sh',
61
71
  'scripts/agent-file-locks.py',
62
72
  ]);
63
73
 
@@ -88,6 +98,7 @@ const COMMAND_TYPO_ALIASES = new Map([
88
98
  ['intsall', 'install'],
89
99
  ['docter', 'doctor'],
90
100
  ['doctro', 'doctor'],
101
+ ['cleunup', 'cleanup'],
91
102
  ['scna', 'scan'],
92
103
  ]);
93
104
  const SUGGESTIBLE_COMMANDS = [
@@ -100,6 +111,7 @@ const SUGGESTIBLE_COMMANDS = [
100
111
  'copy-commands',
101
112
  'protect',
102
113
  'sync',
114
+ 'cleanup',
103
115
  'release',
104
116
  'install',
105
117
  'fix',
@@ -118,6 +130,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
118
130
  ['copy-commands', 'Print setup checklist as executable commands only'],
119
131
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
120
132
  ['sync', 'Check or sync agent branches with origin/<base>'],
133
+ ['cleanup', 'Cleanup merged agent branches/worktrees (local + remote)'],
121
134
  ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
122
135
  ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
123
136
  ['scan', 'Report safety issues and exit non-zero on findings'],
@@ -136,10 +149,12 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
136
149
  gx setup
137
150
  # alias: gx init
138
151
 
139
- - Setup detects global OMX/OpenSpec/codex-auth first.
152
+ - Setup detects global OMX/OpenSpec/codex-auth npm packages first.
140
153
  - If one is missing and setup asks for approval, reply explicitly:
141
154
  - y = run: npm i -g oh-my-codex @fission-ai/openspec @imdeadpool/codex-account-switcher (missing ones only)
142
155
  - n = skip global installs
156
+ - Setup also checks GitHub CLI (gh), required for PR/merge automation.
157
+ - If gh is missing: install it from https://cli.github.com/ and rerun gx setup.
143
158
 
144
159
  3) If setup reports warnings/errors, repair + re-check:
145
160
  gx doctor
@@ -152,6 +167,9 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
152
167
  - For every new user message/task, repeat the same cycle:
153
168
  start isolated agent branch/worktree -> claim file locks -> implement/verify ->
154
169
  finish via PR/merge cleanup with scripts/agent-branch-finish.sh.
170
+ - Finished branches stay available by default for audit/follow-up.
171
+ Remove them explicitly when done:
172
+ gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
155
173
 
156
174
  5) Optional: create OpenSpec planning workspace:
157
175
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
@@ -168,12 +186,14 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
168
186
  `;
169
187
 
170
188
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
189
+ gh --version
171
190
  gx setup
172
191
  gx doctor
173
192
  bash scripts/codex-agent.sh "task" "agent-name"
174
193
  bash scripts/agent-branch-start.sh "task" "agent-name"
175
194
  python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
176
195
  bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
196
+ gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
177
197
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
178
198
  gx protect add release staging
179
199
  gx sync --check
@@ -288,7 +308,11 @@ NOTES
288
308
  - Short alias: ${SHORT_TOOL_NAME}
289
309
  - ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup
290
310
  - ${TOOL_NAME} setup asks for Y/N approval before global installs
291
- - In initialized repos, setup/install/fix/doctor block in-place writes on protected main by default
311
+ - ${TOOL_NAME} setup checks GitHub CLI (gh) and prints install guidance if missing
312
+ - In initialized repos, setup/install/fix block in-place writes on protected main by default
313
+ - doctor auto-runs in a sandbox agent branch/worktree on protected main and tries auto-finish PR flow
314
+ - agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup
315
+ - use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too)
292
316
  - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`);
293
317
 
294
318
  if (outsideGitRepo) {
@@ -362,6 +386,10 @@ function ensureExecutable(destinationPath, relativePath, dryRun) {
362
386
  }
363
387
  }
364
388
 
389
+ function isCriticalGuardrailPath(relativePath) {
390
+ return CRITICAL_GUARDRAIL_PATHS.has(relativePath);
391
+ }
392
+
365
393
  function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
366
394
  const sourcePath = path.join(TEMPLATE_ROOT, relativeTemplatePath);
367
395
  const destinationRelativePath = toDestinationPath(relativeTemplatePath);
@@ -376,7 +404,7 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
376
404
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
377
405
  return { status: 'unchanged', file: destinationRelativePath };
378
406
  }
379
- if (!force) {
407
+ if (!force && !isCriticalGuardrailPath(destinationRelativePath)) {
380
408
  throw new Error(
381
409
  `Refusing to overwrite existing file without --force: ${destinationRelativePath}`,
382
410
  );
@@ -389,6 +417,10 @@ function copyTemplateFile(repoRoot, relativeTemplatePath, force, dryRun) {
389
417
  ensureExecutable(destinationPath, destinationRelativePath, dryRun);
390
418
  }
391
419
 
420
+ if (destinationExists && !force && isCriticalGuardrailPath(destinationRelativePath)) {
421
+ return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
422
+ }
423
+
392
424
  return { status: destinationExists ? 'overwritten' : 'created', file: destinationRelativePath };
393
425
  }
394
426
 
@@ -405,6 +437,14 @@ function ensureTemplateFilePresent(repoRoot, relativeTemplatePath, dryRun) {
405
437
  return { status: 'unchanged', file: destinationRelativePath };
406
438
  }
407
439
 
440
+ if (isCriticalGuardrailPath(destinationRelativePath)) {
441
+ if (!dryRun) {
442
+ fs.writeFileSync(destinationPath, sourceContent, 'utf8');
443
+ ensureExecutable(destinationPath, destinationRelativePath, dryRun);
444
+ }
445
+ return { status: dryRun ? 'would-repair-critical' : 'repaired-critical', file: destinationRelativePath };
446
+ }
447
+
408
448
  // In fix mode, avoid silently replacing local customizations.
409
449
  return { status: 'skipped-conflict', file: destinationRelativePath };
410
450
  }
@@ -489,7 +529,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
489
529
  'agent:codex': 'bash ./scripts/codex-agent.sh',
490
530
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
491
531
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
492
- 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh',
532
+ 'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
493
533
  'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
494
534
  'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
495
535
  'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',
@@ -671,34 +711,547 @@ function hasGuardexBootstrapFiles(repoRoot) {
671
711
  return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
672
712
  }
673
713
 
674
- function assertProtectedMainWriteAllowed(options, commandName) {
714
+ function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
675
715
  if (options.dryRun || options.allowProtectedBaseWrite) {
676
- return;
716
+ return null;
677
717
  }
678
718
 
679
719
  const repoRoot = resolveRepoRoot(options.target);
680
- if (!hasGuardexBootstrapFiles(repoRoot)) {
681
- return;
720
+ if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
721
+ return null;
682
722
  }
683
723
 
684
724
  const branch = currentBranchName(repoRoot);
685
725
  if (branch !== 'main') {
686
- return;
726
+ return null;
687
727
  }
688
728
 
689
729
  const protectedBranches = readProtectedBranches(repoRoot);
690
730
  if (!protectedBranches.includes(branch)) {
731
+ return null;
732
+ }
733
+
734
+ return {
735
+ repoRoot,
736
+ branch,
737
+ };
738
+ }
739
+
740
+ function assertProtectedMainWriteAllowed(options, commandName) {
741
+ const blocked = protectedBaseWriteBlock(options);
742
+ if (!blocked) {
691
743
  return;
692
744
  }
693
745
 
694
746
  throw new Error(
695
- `${commandName} blocked on protected branch '${branch}' in an initialized repo.\n` +
696
- `Keep local '${branch}' pull-only: start an agent branch/worktree first:\n` +
747
+ `${commandName} blocked on protected branch '${blocked.branch}' in an initialized repo.\n` +
748
+ `Keep local '${blocked.branch}' pull-only: start an agent branch/worktree first:\n` +
697
749
  ` bash scripts/agent-branch-start.sh "<task>" "codex"\n` +
698
750
  `Override once only when intentional: --allow-protected-base-write`,
699
751
  );
700
752
  }
701
753
 
754
+ function extractAgentBranchStartMetadata(output) {
755
+ const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
756
+ const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
757
+ return {
758
+ branch: branchMatch ? branchMatch[1].trim() : '',
759
+ worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
760
+ };
761
+ }
762
+
763
+ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) {
764
+ const resolvedTarget = path.resolve(targetPath);
765
+ const relativeTarget = path.relative(repoRoot, resolvedTarget);
766
+ if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
767
+ throw new Error(`doctor target must stay inside repo root when sandboxing: ${resolvedTarget}`);
768
+ }
769
+ if (!relativeTarget || relativeTarget === '.') {
770
+ return worktreePath;
771
+ }
772
+ return path.join(worktreePath, relativeTarget);
773
+ }
774
+
775
+ function buildSandboxDoctorArgs(options, sandboxTarget) {
776
+ const args = ['doctor', '--target', sandboxTarget];
777
+ if (options.dryRun) args.push('--dry-run');
778
+ if (options.skipAgents) args.push('--skip-agents');
779
+ if (options.skipPackageJson) args.push('--skip-package-json');
780
+ if (options.skipGitignore) args.push('--no-gitignore');
781
+ if (!options.dropStaleLocks) args.push('--keep-stale-locks');
782
+ if (options.json) args.push('--json');
783
+ return args;
784
+ }
785
+
786
+ function isSpawnFailure(result) {
787
+ return Boolean(result?.error) && typeof result?.status !== 'number';
788
+ }
789
+
790
+ function doctorSandboxBranchPrefix() {
791
+ const now = new Date();
792
+ const stamp = [
793
+ now.getUTCFullYear(),
794
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
795
+ String(now.getUTCDate()).padStart(2, '0'),
796
+ ].join('') + '-' + [
797
+ String(now.getUTCHours()).padStart(2, '0'),
798
+ String(now.getUTCMinutes()).padStart(2, '0'),
799
+ String(now.getUTCSeconds()).padStart(2, '0'),
800
+ ].join('');
801
+ return `agent/gx/${stamp}`;
802
+ }
803
+
804
+ function doctorSandboxWorktreePath(repoRoot, branchName) {
805
+ return path.join(repoRoot, '.omx', 'agent-worktrees', branchName.replace(/\//g, '__'));
806
+ }
807
+
808
+ function gitRefExists(repoRoot, ref) {
809
+ return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
810
+ }
811
+
812
+ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
813
+ run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
814
+ if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
815
+ return `origin/${baseBranch}`;
816
+ }
817
+ if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
818
+ return baseBranch;
819
+ }
820
+ throw new Error(`Unable to find base ref for sandbox doctor: ${baseBranch}`);
821
+ }
822
+
823
+ function startDoctorSandboxFallback(blocked) {
824
+ const branchPrefix = doctorSandboxBranchPrefix();
825
+ let selectedBranch = '';
826
+ let selectedWorktreePath = '';
827
+
828
+ for (let attempt = 0; attempt < 30; attempt += 1) {
829
+ const suffix = attempt === 0 ? 'gx-doctor' : `${attempt + 1}-gx-doctor`;
830
+ const candidateBranch = `${branchPrefix}-${suffix}`;
831
+ const candidateWorktreePath = doctorSandboxWorktreePath(blocked.repoRoot, candidateBranch);
832
+ if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
833
+ continue;
834
+ }
835
+ if (fs.existsSync(candidateWorktreePath)) {
836
+ continue;
837
+ }
838
+ selectedBranch = candidateBranch;
839
+ selectedWorktreePath = candidateWorktreePath;
840
+ break;
841
+ }
842
+
843
+ if (!selectedBranch || !selectedWorktreePath) {
844
+ throw new Error('Unable to allocate unique sandbox branch/worktree for doctor');
845
+ }
846
+
847
+ fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
848
+ const startRef = resolveDoctorSandboxStartRef(blocked.repoRoot, blocked.branch);
849
+ const addResult = run(
850
+ 'git',
851
+ ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef],
852
+ );
853
+ if (isSpawnFailure(addResult)) {
854
+ throw addResult.error;
855
+ }
856
+ if (addResult.status !== 0) {
857
+ throw new Error((addResult.stderr || addResult.stdout || 'failed to create doctor sandbox').trim());
858
+ }
859
+
860
+ return {
861
+ metadata: {
862
+ branch: selectedBranch,
863
+ worktreePath: selectedWorktreePath,
864
+ },
865
+ stdout:
866
+ `[agent-branch-start] Created branch: ${selectedBranch}\n` +
867
+ `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
868
+ stderr: addResult.stderr || '',
869
+ };
870
+ }
871
+
872
+ function startDoctorSandbox(blocked) {
873
+ const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
874
+ if (!fs.existsSync(startScript)) {
875
+ return startDoctorSandboxFallback(blocked);
876
+ }
877
+
878
+ const startResult = run('bash', [
879
+ startScript,
880
+ '--task',
881
+ `${SHORT_TOOL_NAME}-doctor`,
882
+ '--agent',
883
+ SHORT_TOOL_NAME,
884
+ '--base',
885
+ blocked.branch,
886
+ ], { cwd: blocked.repoRoot });
887
+ if (isSpawnFailure(startResult)) {
888
+ throw startResult.error;
889
+ }
890
+ if (startResult.status !== 0) {
891
+ throw new Error((startResult.stderr || startResult.stdout || 'failed to start doctor sandbox').trim());
892
+ }
893
+
894
+ const metadata = extractAgentBranchStartMetadata(startResult.stdout);
895
+ if (!metadata.worktreePath) {
896
+ throw new Error(`Failed to parse sandbox worktree from agent-branch-start output:\n${startResult.stdout}`);
897
+ }
898
+
899
+ return {
900
+ metadata,
901
+ stdout: startResult.stdout || '',
902
+ stderr: startResult.stderr || '',
903
+ };
904
+ }
905
+
906
+ function parseGitPathList(output) {
907
+ return String(output || '')
908
+ .split('\n')
909
+ .map((line) => line.trim())
910
+ .filter((line) => line && line !== LOCK_FILE_RELATIVE);
911
+ }
912
+
913
+ function collectDoctorChangedPaths(worktreePath) {
914
+ const changed = new Set();
915
+ const commands = [
916
+ ['diff', '--name-only'],
917
+ ['diff', '--cached', '--name-only'],
918
+ ['ls-files', '--others', '--exclude-standard'],
919
+ ];
920
+ for (const gitArgs of commands) {
921
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
922
+ for (const filePath of parseGitPathList(result.stdout)) {
923
+ changed.add(filePath);
924
+ }
925
+ }
926
+ return Array.from(changed);
927
+ }
928
+
929
+ function collectDoctorDeletedPaths(worktreePath) {
930
+ const deleted = new Set();
931
+ const commands = [
932
+ ['diff', '--name-only', '--diff-filter=D'],
933
+ ['diff', '--cached', '--name-only', '--diff-filter=D'],
934
+ ];
935
+ for (const gitArgs of commands) {
936
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
937
+ for (const filePath of parseGitPathList(result.stdout)) {
938
+ deleted.add(filePath);
939
+ }
940
+ }
941
+ return Array.from(deleted);
942
+ }
943
+
944
+ function claimDoctorChangedLocks(metadata) {
945
+ const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py');
946
+ if (!fs.existsSync(lockScript) || !metadata.branch) {
947
+ return {
948
+ status: 'skipped',
949
+ note: 'lock helper unavailable in sandbox',
950
+ changedCount: 0,
951
+ deletedCount: 0,
952
+ };
953
+ }
954
+
955
+ const changedPaths = collectDoctorChangedPaths(metadata.worktreePath);
956
+ const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
957
+ if (changedPaths.length > 0) {
958
+ run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], {
959
+ cwd: metadata.worktreePath,
960
+ timeout: 30_000,
961
+ });
962
+ }
963
+ if (deletedPaths.length > 0) {
964
+ run('python3', [lockScript, 'allow-delete', '--branch', metadata.branch, ...deletedPaths], {
965
+ cwd: metadata.worktreePath,
966
+ timeout: 30_000,
967
+ });
968
+ }
969
+
970
+ return {
971
+ status: 'claimed',
972
+ note: 'claimed locks for doctor auto-commit',
973
+ changedCount: changedPaths.length,
974
+ deletedCount: deletedPaths.length,
975
+ };
976
+ }
977
+
978
+ function autoCommitDoctorSandboxChanges(metadata) {
979
+ if (!metadata.worktreePath || !metadata.branch) {
980
+ return {
981
+ status: 'skipped',
982
+ note: 'missing sandbox branch metadata',
983
+ };
984
+ }
985
+
986
+ claimDoctorChangedLocks(metadata);
987
+ run('git', ['-C', metadata.worktreePath, 'add', '-A'], { timeout: 20_000 });
988
+ const staged = run(
989
+ 'git',
990
+ ['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
991
+ { timeout: 20_000 },
992
+ );
993
+ const stagedFiles = parseGitPathList(staged.stdout);
994
+ if (stagedFiles.length === 0) {
995
+ return {
996
+ status: 'no-changes',
997
+ note: 'no committable doctor changes found in sandbox',
998
+ };
999
+ }
1000
+
1001
+ const commitResult = run(
1002
+ 'git',
1003
+ ['-C', metadata.worktreePath, 'commit', '-m', 'Auto-finish: gx doctor repairs'],
1004
+ { timeout: 30_000 },
1005
+ );
1006
+ if (commitResult.status !== 0) {
1007
+ return {
1008
+ status: 'failed',
1009
+ note: 'doctor sandbox auto-commit failed',
1010
+ stdout: commitResult.stdout || '',
1011
+ stderr: commitResult.stderr || '',
1012
+ };
1013
+ }
1014
+
1015
+ return {
1016
+ status: 'committed',
1017
+ note: 'doctor sandbox repairs committed',
1018
+ commitMessage: 'Auto-finish: gx doctor repairs',
1019
+ stagedFiles,
1020
+ };
1021
+ }
1022
+
1023
+ function hasOriginRemote(repoRoot) {
1024
+ return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
1025
+ }
1026
+
1027
+ function isCommandAvailable(commandName) {
1028
+ return run('which', [commandName]).status === 0;
1029
+ }
1030
+
1031
+ function finishDoctorSandboxBranch(blocked, metadata) {
1032
+ const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
1033
+ if (!fs.existsSync(finishScript)) {
1034
+ return {
1035
+ status: 'skipped',
1036
+ note: `${path.relative(metadata.worktreePath, finishScript)} missing in sandbox`,
1037
+ };
1038
+ }
1039
+ if (!hasOriginRemote(blocked.repoRoot)) {
1040
+ return {
1041
+ status: 'skipped',
1042
+ note: 'origin remote missing; skipped auto-finish',
1043
+ };
1044
+ }
1045
+
1046
+ const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1047
+ if (!isCommandAvailable(ghBin)) {
1048
+ return {
1049
+ status: 'skipped',
1050
+ note: `'${ghBin}' not available; skipped auto-finish PR flow`,
1051
+ };
1052
+ }
1053
+ const ghAuthStatus = run(ghBin, ['auth', 'status'], { timeout: 20_000 });
1054
+ if (ghAuthStatus.status !== 0) {
1055
+ return {
1056
+ status: 'skipped',
1057
+ note: `'${ghBin}' auth unavailable; skipped auto-finish PR flow`,
1058
+ stderr: ghAuthStatus.stderr || '',
1059
+ };
1060
+ }
1061
+
1062
+ const finishResult = run(
1063
+ 'bash',
1064
+ [finishScript, '--branch', metadata.branch, '--via-pr'],
1065
+ { cwd: metadata.worktreePath, timeout: 180_000 },
1066
+ );
1067
+ if (isSpawnFailure(finishResult)) {
1068
+ return {
1069
+ status: 'failed',
1070
+ note: 'doctor sandbox finish flow errored',
1071
+ stdout: finishResult.stdout || '',
1072
+ stderr: finishResult.stderr || '',
1073
+ };
1074
+ }
1075
+ if (finishResult.status !== 0) {
1076
+ return {
1077
+ status: 'failed',
1078
+ note: 'doctor sandbox finish flow failed',
1079
+ stdout: finishResult.stdout || '',
1080
+ stderr: finishResult.stderr || '',
1081
+ };
1082
+ }
1083
+
1084
+ return {
1085
+ status: 'completed',
1086
+ note: 'doctor sandbox finish flow completed',
1087
+ stdout: finishResult.stdout || '',
1088
+ stderr: finishResult.stderr || '',
1089
+ };
1090
+ }
1091
+
1092
+ function runDoctorInSandbox(options, blocked) {
1093
+ const startResult = startDoctorSandbox(blocked);
1094
+ const metadata = startResult.metadata;
1095
+
1096
+ const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
1097
+ const nestedResult = run(
1098
+ process.execPath,
1099
+ [__filename, ...buildSandboxDoctorArgs(options, sandboxTarget)],
1100
+ { cwd: metadata.worktreePath },
1101
+ );
1102
+ if (isSpawnFailure(nestedResult)) {
1103
+ throw nestedResult.error;
1104
+ }
1105
+
1106
+ let autoCommitResult = {
1107
+ status: 'skipped',
1108
+ note: 'sandbox doctor did not complete successfully',
1109
+ };
1110
+ let finishResult = {
1111
+ status: 'skipped',
1112
+ note: 'sandbox doctor did not complete successfully',
1113
+ };
1114
+
1115
+ let lockSyncResult = {
1116
+ status: 'skipped',
1117
+ note: 'sandbox doctor did not complete successfully',
1118
+ };
1119
+ if (nestedResult.status === 0) {
1120
+ if (!options.dryRun) {
1121
+ autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
1122
+ if (autoCommitResult.status === 'committed') {
1123
+ finishResult = finishDoctorSandboxBranch(blocked, metadata);
1124
+ } else if (autoCommitResult.status === 'no-changes') {
1125
+ finishResult = {
1126
+ status: 'skipped',
1127
+ note: 'no doctor changes to auto-finish',
1128
+ };
1129
+ } else if (autoCommitResult.status !== 'failed') {
1130
+ finishResult = {
1131
+ status: 'skipped',
1132
+ note: 'auto-commit did not run',
1133
+ };
1134
+ }
1135
+ } else {
1136
+ autoCommitResult = {
1137
+ status: 'skipped',
1138
+ note: 'dry-run skips doctor sandbox auto-commit',
1139
+ };
1140
+ finishResult = {
1141
+ status: 'skipped',
1142
+ note: 'dry-run skips doctor sandbox finish flow',
1143
+ };
1144
+ }
1145
+
1146
+ const sandboxLockPath = path.join(metadata.worktreePath, LOCK_FILE_RELATIVE);
1147
+ const baseLockPath = path.join(blocked.repoRoot, LOCK_FILE_RELATIVE);
1148
+ if (!fs.existsSync(baseLockPath)) {
1149
+ lockSyncResult = {
1150
+ status: 'skipped',
1151
+ note: `${LOCK_FILE_RELATIVE} missing in protected base workspace`,
1152
+ };
1153
+ } else if (!fs.existsSync(sandboxLockPath)) {
1154
+ lockSyncResult = {
1155
+ status: 'skipped',
1156
+ note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
1157
+ };
1158
+ } else {
1159
+ const sourceContent = fs.readFileSync(sandboxLockPath, 'utf8');
1160
+ const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
1161
+ if (sourceContent === destinationContent) {
1162
+ lockSyncResult = {
1163
+ status: 'unchanged',
1164
+ note: `${LOCK_FILE_RELATIVE} already in sync`,
1165
+ };
1166
+ } else {
1167
+ fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
1168
+ fs.writeFileSync(baseLockPath, sourceContent, 'utf8');
1169
+ lockSyncResult = {
1170
+ status: 'synced',
1171
+ note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
1172
+ };
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ if (options.json) {
1178
+ if (nestedResult.stdout) {
1179
+ if (nestedResult.status === 0) {
1180
+ try {
1181
+ const parsed = JSON.parse(nestedResult.stdout);
1182
+ process.stdout.write(
1183
+ JSON.stringify(
1184
+ {
1185
+ ...parsed,
1186
+ sandboxLockSync: lockSyncResult,
1187
+ sandboxAutoCommit: autoCommitResult,
1188
+ sandboxFinish: finishResult,
1189
+ },
1190
+ null,
1191
+ 2,
1192
+ ) + '\n',
1193
+ );
1194
+ } catch {
1195
+ process.stdout.write(nestedResult.stdout);
1196
+ }
1197
+ } else {
1198
+ process.stdout.write(nestedResult.stdout);
1199
+ }
1200
+ }
1201
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
1202
+ } else {
1203
+ console.log(
1204
+ `[${TOOL_NAME}] doctor detected protected branch '${blocked.branch}'. ` +
1205
+ `Running repairs in sandbox branch '${metadata.branch || 'agent/<auto>'}'.`,
1206
+ );
1207
+ if (startResult.stdout) process.stdout.write(startResult.stdout);
1208
+ if (startResult.stderr) process.stderr.write(startResult.stderr);
1209
+ if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
1210
+ if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
1211
+ if (nestedResult.status === 0) {
1212
+ if (autoCommitResult.status === 'committed') {
1213
+ console.log(
1214
+ `[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
1215
+ );
1216
+ } else if (autoCommitResult.status === 'failed') {
1217
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
1218
+ if (autoCommitResult.stdout) process.stdout.write(autoCommitResult.stdout);
1219
+ if (autoCommitResult.stderr) process.stderr.write(autoCommitResult.stderr);
1220
+ } else {
1221
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
1222
+ }
1223
+
1224
+ if (finishResult.status === 'completed') {
1225
+ console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
1226
+ if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1227
+ if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1228
+ } else if (finishResult.status === 'failed') {
1229
+ console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
1230
+ if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1231
+ if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1232
+ } else {
1233
+ console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
1234
+ }
1235
+
1236
+ if (lockSyncResult.status === 'synced') {
1237
+ console.log(
1238
+ `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
1239
+ );
1240
+ } else if (lockSyncResult.status === 'unchanged') {
1241
+ console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1242
+ } else {
1243
+ console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ if (typeof nestedResult.status === 'number') {
1249
+ process.exitCode = nestedResult.status;
1250
+ return;
1251
+ }
1252
+ process.exitCode = 1;
1253
+ }
1254
+
702
1255
  function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
703
1256
  const remaining = [];
704
1257
  let target = defaultTarget;
@@ -1134,6 +1687,63 @@ function parseSyncArgs(rawArgs) {
1134
1687
  return options;
1135
1688
  }
1136
1689
 
1690
+ function parseCleanupArgs(rawArgs) {
1691
+ const options = {
1692
+ target: process.cwd(),
1693
+ base: '',
1694
+ branch: '',
1695
+ dryRun: false,
1696
+ forceDirty: false,
1697
+ keepRemote: false,
1698
+ };
1699
+
1700
+ for (let index = 0; index < rawArgs.length; index += 1) {
1701
+ const arg = rawArgs[index];
1702
+ if (arg === '--target') {
1703
+ const next = rawArgs[index + 1];
1704
+ if (!next) {
1705
+ throw new Error('--target requires a path value');
1706
+ }
1707
+ options.target = next;
1708
+ index += 1;
1709
+ continue;
1710
+ }
1711
+ if (arg === '--base') {
1712
+ const next = rawArgs[index + 1];
1713
+ if (!next) {
1714
+ throw new Error('--base requires a branch value');
1715
+ }
1716
+ options.base = next;
1717
+ index += 1;
1718
+ continue;
1719
+ }
1720
+ if (arg === '--branch') {
1721
+ const next = rawArgs[index + 1];
1722
+ if (!next) {
1723
+ throw new Error('--branch requires an agent branch value');
1724
+ }
1725
+ options.branch = next;
1726
+ index += 1;
1727
+ continue;
1728
+ }
1729
+ if (arg === '--dry-run') {
1730
+ options.dryRun = true;
1731
+ continue;
1732
+ }
1733
+ if (arg === '--force-dirty') {
1734
+ options.forceDirty = true;
1735
+ continue;
1736
+ }
1737
+ if (arg === '--keep-remote') {
1738
+ options.keepRemote = true;
1739
+ continue;
1740
+ }
1741
+ throw new Error(`Unknown option: ${arg}`);
1742
+ }
1743
+
1744
+ return options;
1745
+ }
1746
+
1137
1747
  function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
1138
1748
  if (strategy === 'rebase') {
1139
1749
  if (ffOnly) {
@@ -1171,6 +1781,12 @@ function isInteractiveTerminal() {
1171
1781
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1172
1782
  }
1173
1783
 
1784
+ const stdinWaitArray = new Int32Array(new SharedArrayBuffer(4));
1785
+
1786
+ function sleepSyncMs(milliseconds) {
1787
+ Atomics.wait(stdinWaitArray, 0, 0, milliseconds);
1788
+ }
1789
+
1174
1790
  function readSingleLineFromStdin() {
1175
1791
  let input = '';
1176
1792
  const buffer = Buffer.alloc(1);
@@ -1179,11 +1795,19 @@ function readSingleLineFromStdin() {
1179
1795
  let bytesRead = 0;
1180
1796
  try {
1181
1797
  bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 1);
1182
- } catch {
1798
+ } catch (error) {
1799
+ if (error && ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error.code)) {
1800
+ sleepSyncMs(15);
1801
+ continue;
1802
+ }
1183
1803
  return input;
1184
1804
  }
1185
1805
 
1186
1806
  if (bytesRead === 0) {
1807
+ if (process.stdin.isTTY) {
1808
+ sleepSyncMs(15);
1809
+ continue;
1810
+ }
1187
1811
  return input;
1188
1812
  }
1189
1813
 
@@ -1323,9 +1947,8 @@ function maybeSelfUpdateBeforeStatus() {
1323
1947
  }
1324
1948
 
1325
1949
  const shouldUpdate = interactive
1326
- ? promptYesNo(
1950
+ ? promptYesNoStrict(
1327
1951
  `Update now? (${NPM_BIN} i -g ${packageJson.name}@latest)`,
1328
- false,
1329
1952
  )
1330
1953
  : autoApproval;
1331
1954
 
@@ -1418,6 +2041,26 @@ function detectGlobalToolchainPackages() {
1418
2041
  return { ok: true, installed, missing };
1419
2042
  }
1420
2043
 
2044
+ function detectRequiredSystemTools() {
2045
+ const services = [];
2046
+ for (const tool of REQUIRED_SYSTEM_TOOLS) {
2047
+ const result = run(tool.command, ['--version']);
2048
+ const active = result.status === 0;
2049
+ const rawReason = result.error && result.error.code
2050
+ ? result.error.code
2051
+ : (result.stderr || '').trim();
2052
+ const reason = rawReason.split('\n')[0] || '';
2053
+ services.push({
2054
+ name: tool.name,
2055
+ command: tool.command,
2056
+ installHint: tool.installHint,
2057
+ status: active ? 'active' : 'inactive',
2058
+ reason,
2059
+ });
2060
+ }
2061
+ return services;
2062
+ }
2063
+
1421
2064
  function askGlobalInstallForMissing(options, missingPackages) {
1422
2065
  const approval = resolveGlobalInstallApproval(options);
1423
2066
  if (!approval.approved) {
@@ -1747,7 +2390,7 @@ function status(rawArgs) {
1747
2390
  });
1748
2391
 
1749
2392
  const toolchain = detectGlobalToolchainPackages();
1750
- const services = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
2393
+ const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
1751
2394
  if (!toolchain.ok) {
1752
2395
  return { name: pkg, status: 'unknown' };
1753
2396
  }
@@ -1756,6 +2399,14 @@ function status(rawArgs) {
1756
2399
  status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
1757
2400
  };
1758
2401
  });
2402
+ const requiredSystemTools = detectRequiredSystemTools();
2403
+ const services = [
2404
+ ...npmServices,
2405
+ ...requiredSystemTools.map((tool) => ({
2406
+ name: tool.name,
2407
+ status: tool.status,
2408
+ })),
2409
+ ];
1759
2410
 
1760
2411
  const targetPath = path.resolve(options.target);
1761
2412
  const inGitRepo = isGitRepo(targetPath);
@@ -1803,6 +2454,15 @@ function status(rawArgs) {
1803
2454
  for (const service of services) {
1804
2455
  console.log(` - ${statusDot(service.status)} ${service.name}: ${service.status}`);
1805
2456
  }
2457
+ const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
2458
+ if (missingSystemTools.length > 0) {
2459
+ const tools = missingSystemTools.map((tool) => tool.name).join(', ');
2460
+ console.log(`[${TOOL_NAME}] ⚠️ Missing required system tool(s): ${tools}`);
2461
+ for (const tool of missingSystemTools) {
2462
+ const reasonText = tool.reason ? ` (${tool.reason})` : '';
2463
+ console.log(` - install ${tool.name}: ${tool.installHint}${reasonText}`);
2464
+ }
2465
+ }
1806
2466
 
1807
2467
  if (!scanResult) {
1808
2468
  console.log(
@@ -1894,10 +2554,17 @@ function doctor(rawArgs) {
1894
2554
  allowProtectedBaseWrite: false,
1895
2555
  });
1896
2556
 
2557
+ const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false });
2558
+ if (blocked) {
2559
+ runDoctorInSandbox(options, blocked);
2560
+ return;
2561
+ }
2562
+
1897
2563
  assertProtectedMainWriteAllowed(options, 'doctor');
1898
2564
  const fixPayload = runFixInternal(options);
1899
2565
  const scanResult = runScanInternal({ target: options.target, json: false });
1900
- const musafe = scanResult.errors === 0 && scanResult.warnings === 0;
2566
+ const safe = scanResult.errors === 0 && scanResult.warnings === 0;
2567
+ const musafe = safe;
1901
2568
 
1902
2569
  if (options.json) {
1903
2570
  process.stdout.write(
@@ -1905,6 +2572,7 @@ function doctor(rawArgs) {
1905
2572
  {
1906
2573
  repoRoot: scanResult.repoRoot,
1907
2574
  branch: scanResult.branch,
2575
+ safe,
1908
2576
  musafe,
1909
2577
  fix: {
1910
2578
  operations: fixPayload.operations,
@@ -1927,11 +2595,11 @@ function doctor(rawArgs) {
1927
2595
 
1928
2596
  printOperations('Doctor/fix', fixPayload, options.dryRun);
1929
2597
  printScanResult(scanResult, false);
1930
- if (musafe) {
1931
- console.log(`[${TOOL_NAME}] ✅ Repo is correctly musafe.`);
2598
+ if (safe) {
2599
+ console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
1932
2600
  } else {
1933
2601
  console.log(
1934
- `[${TOOL_NAME}] ⚠️ Repo is not fully musafe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
2602
+ `[${TOOL_NAME}] ⚠️ Repo is not fully safe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
1935
2603
  );
1936
2604
  }
1937
2605
  setExitCodeFromScan(scanResult);
@@ -2047,7 +2715,7 @@ function setup(rawArgs) {
2047
2715
  `[${TOOL_NAME}] ✅ Global tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
2048
2716
  );
2049
2717
  } else if (globalInstallStatus.status === 'already-installed') {
2050
- console.log(`[${TOOL_NAME}] ✅ OMX/OpenSpec/codex-auth global tools already installed. Skipping.`);
2718
+ console.log(`[${TOOL_NAME}] ✅ OMX/OpenSpec/codex-auth npm global tools already installed. Skipping.`);
2051
2719
  } else if (globalInstallStatus.status === 'failed') {
2052
2720
  console.log(
2053
2721
  `[${TOOL_NAME}] ⚠️ Global install failed: ${globalInstallStatus.reason}\n` +
@@ -2060,6 +2728,18 @@ function setup(rawArgs) {
2060
2728
  `Use --yes-global-install to force or run interactively for Y/N prompt.`,
2061
2729
  );
2062
2730
  }
2731
+ const requiredSystemTools = detectRequiredSystemTools();
2732
+ const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
2733
+ if (missingSystemTools.length === 0) {
2734
+ console.log(`[${TOOL_NAME}] ✅ Required system tools available (${requiredSystemTools.map((tool) => tool.name).join(', ')}).`);
2735
+ } else {
2736
+ const names = missingSystemTools.map((tool) => tool.name).join(', ');
2737
+ console.log(`[${TOOL_NAME}] ⚠️ Missing required system tool(s): ${names}`);
2738
+ for (const tool of missingSystemTools) {
2739
+ const reasonText = tool.reason ? ` (${tool.reason})` : '';
2740
+ console.log(`[${TOOL_NAME}] Install ${tool.name}: ${tool.installHint}${reasonText}`);
2741
+ }
2742
+ }
2063
2743
 
2064
2744
  assertProtectedMainWriteAllowed(options, 'setup');
2065
2745
  const installPayload = runInstallInternal(options);
@@ -2156,6 +2836,39 @@ function copyCommands() {
2156
2836
  process.exitCode = 0;
2157
2837
  }
2158
2838
 
2839
+ function cleanup(rawArgs) {
2840
+ const options = parseCleanupArgs(rawArgs);
2841
+ const repoRoot = resolveRepoRoot(options.target);
2842
+ const pruneScript = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh');
2843
+ if (!fs.existsSync(pruneScript)) {
2844
+ throw new Error(`Missing cleanup script: ${pruneScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
2845
+ }
2846
+
2847
+ const args = [pruneScript];
2848
+ if (options.base) {
2849
+ args.push('--base', options.base);
2850
+ }
2851
+ if (options.branch) {
2852
+ args.push('--branch', options.branch);
2853
+ }
2854
+ if (options.forceDirty) {
2855
+ args.push('--force-dirty');
2856
+ }
2857
+ if (options.dryRun) {
2858
+ args.push('--dry-run');
2859
+ }
2860
+ args.push('--delete-branches');
2861
+ if (!options.keepRemote) {
2862
+ args.push('--delete-remote-branches');
2863
+ }
2864
+
2865
+ const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
2866
+ if (runResult.status !== 0) {
2867
+ throw new Error('Cleanup command failed');
2868
+ }
2869
+ process.exitCode = 0;
2870
+ }
2871
+
2159
2872
  function sync(rawArgs) {
2160
2873
  const options = parseSyncArgs(rawArgs);
2161
2874
  const repoRoot = resolveRepoRoot(options.target);
@@ -2504,6 +3217,11 @@ function main() {
2504
3217
  return;
2505
3218
  }
2506
3219
 
3220
+ if (command === 'cleanup') {
3221
+ cleanup(rest);
3222
+ return;
3223
+ }
3224
+
2507
3225
  if (command === 'release') {
2508
3226
  release(rest);
2509
3227
  return;