@imdeadpool/guardex 5.0.2 → 5.0.4

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,15 @@ 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
+ displayName: 'GitHub (gh)',
23
+ command: GH_BIN,
24
+ installHint: 'https://cli.github.com/',
25
+ },
26
+ ];
18
27
  const MAINTAINER_RELEASE_REPO = path.resolve(
19
28
  process.env.MUSAFETY_RELEASE_REPO || '/tmp/multiagent-safety',
20
29
  );
@@ -33,11 +42,13 @@ const TEMPLATE_FILES = [
33
42
  'scripts/agent-branch-start.sh',
34
43
  'scripts/agent-branch-finish.sh',
35
44
  'scripts/codex-agent.sh',
45
+ 'scripts/review-bot-watch.sh',
36
46
  'scripts/agent-worktree-prune.sh',
37
47
  'scripts/agent-file-locks.py',
38
48
  'scripts/install-agent-git-hooks.sh',
39
49
  'scripts/openspec/init-plan-workspace.sh',
40
50
  'githooks/pre-commit',
51
+ 'githooks/pre-push',
41
52
  'codex/skills/guardex/SKILL.md',
42
53
  'claude/commands/guardex.md',
43
54
  ];
@@ -46,16 +57,19 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([
46
57
  'scripts/agent-branch-start.sh',
47
58
  'scripts/agent-branch-finish.sh',
48
59
  'scripts/codex-agent.sh',
60
+ 'scripts/review-bot-watch.sh',
49
61
  'scripts/agent-worktree-prune.sh',
50
62
  'scripts/agent-file-locks.py',
51
63
  'scripts/install-agent-git-hooks.sh',
52
64
  'scripts/openspec/init-plan-workspace.sh',
53
65
  '.githooks/pre-commit',
66
+ '.githooks/pre-push',
54
67
  ]);
55
68
 
56
69
  const CRITICAL_GUARDRAIL_PATHS = new Set([
57
70
  'AGENTS.md',
58
71
  '.githooks/pre-commit',
72
+ '.githooks/pre-push',
59
73
  'scripts/agent-branch-start.sh',
60
74
  'scripts/agent-branch-finish.sh',
61
75
  'scripts/agent-worktree-prune.sh',
@@ -71,11 +85,13 @@ const MANAGED_GITIGNORE_PATHS = [
71
85
  'scripts/agent-branch-start.sh',
72
86
  'scripts/agent-branch-finish.sh',
73
87
  'scripts/codex-agent.sh',
88
+ 'scripts/review-bot-watch.sh',
74
89
  'scripts/agent-worktree-prune.sh',
75
90
  'scripts/agent-file-locks.py',
76
91
  'scripts/install-agent-git-hooks.sh',
77
92
  'scripts/openspec/init-plan-workspace.sh',
78
93
  '.githooks/pre-commit',
94
+ '.githooks/pre-push',
79
95
  'oh-my-codex/',
80
96
  '.codex/skills/guardex/SKILL.md',
81
97
  '.claude/commands/guardex.md',
@@ -141,10 +157,12 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
141
157
  gx setup
142
158
  # alias: gx init
143
159
 
144
- - Setup detects global OMX/OpenSpec/codex-auth first.
160
+ - Setup detects global OMX/OpenSpec/codex-auth npm packages first.
145
161
  - If one is missing and setup asks for approval, reply explicitly:
146
162
  - y = run: npm i -g oh-my-codex @fission-ai/openspec @imdeadpool/codex-account-switcher (missing ones only)
147
163
  - n = skip global installs
164
+ - Setup also checks GitHub CLI (gh), required for PR/merge automation.
165
+ - If gh is missing: install it from https://cli.github.com/ and rerun gx setup.
148
166
 
149
167
  3) If setup reports warnings/errors, repair + re-check:
150
168
  gx doctor
@@ -176,6 +194,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
176
194
  `;
177
195
 
178
196
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
197
+ gh --version
179
198
  gx setup
180
199
  gx doctor
181
200
  bash scripts/codex-agent.sh "task" "agent-name"
@@ -297,8 +316,9 @@ NOTES
297
316
  - Short alias: ${SHORT_TOOL_NAME}
298
317
  - ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup
299
318
  - ${TOOL_NAME} setup asks for Y/N approval before global installs
319
+ - ${TOOL_NAME} setup checks GitHub CLI (gh) and prints install guidance if missing
300
320
  - In initialized repos, setup/install/fix block in-place writes on protected main by default
301
- - doctor auto-starts a sandbox agent branch/worktree when run on protected main
321
+ - doctor auto-runs in a sandbox agent branch/worktree on protected main and tries auto-finish PR flow
302
322
  - agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup
303
323
  - use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too)
304
324
  - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`);
@@ -515,6 +535,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
515
535
 
516
536
  const wantedScripts = {
517
537
  'agent:codex': 'bash ./scripts/codex-agent.sh',
538
+ 'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
518
539
  'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
519
540
  'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
520
541
  'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
@@ -694,18 +715,19 @@ function hasGuardexBootstrapFiles(repoRoot) {
694
715
  'AGENTS.md',
695
716
  'scripts/agent-branch-start.sh',
696
717
  '.githooks/pre-commit',
718
+ '.githooks/pre-push',
697
719
  LOCK_FILE_RELATIVE,
698
720
  ];
699
721
  return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
700
722
  }
701
723
 
702
- function protectedBaseWriteBlock(options) {
724
+ function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
703
725
  if (options.dryRun || options.allowProtectedBaseWrite) {
704
726
  return null;
705
727
  }
706
728
 
707
729
  const repoRoot = resolveRepoRoot(options.target);
708
- if (!hasGuardexBootstrapFiles(repoRoot)) {
730
+ if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
709
731
  return null;
710
732
  }
711
733
 
@@ -771,13 +793,96 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
771
793
  return args;
772
794
  }
773
795
 
774
- function runDoctorInSandbox(options, blocked) {
796
+ function isSpawnFailure(result) {
797
+ return Boolean(result?.error) && typeof result?.status !== 'number';
798
+ }
799
+
800
+ function doctorSandboxBranchPrefix() {
801
+ const now = new Date();
802
+ const stamp = [
803
+ now.getUTCFullYear(),
804
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
805
+ String(now.getUTCDate()).padStart(2, '0'),
806
+ ].join('') + '-' + [
807
+ String(now.getUTCHours()).padStart(2, '0'),
808
+ String(now.getUTCMinutes()).padStart(2, '0'),
809
+ String(now.getUTCSeconds()).padStart(2, '0'),
810
+ ].join('');
811
+ return `agent/gx/${stamp}`;
812
+ }
813
+
814
+ function doctorSandboxWorktreePath(repoRoot, branchName) {
815
+ return path.join(repoRoot, '.omx', 'agent-worktrees', branchName.replace(/\//g, '__'));
816
+ }
817
+
818
+ function gitRefExists(repoRoot, ref) {
819
+ return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
820
+ }
821
+
822
+ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) {
823
+ run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 });
824
+ if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) {
825
+ return `origin/${baseBranch}`;
826
+ }
827
+ if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) {
828
+ return baseBranch;
829
+ }
830
+ throw new Error(`Unable to find base ref for sandbox doctor: ${baseBranch}`);
831
+ }
832
+
833
+ function startDoctorSandboxFallback(blocked) {
834
+ const branchPrefix = doctorSandboxBranchPrefix();
835
+ let selectedBranch = '';
836
+ let selectedWorktreePath = '';
837
+
838
+ for (let attempt = 0; attempt < 30; attempt += 1) {
839
+ const suffix = attempt === 0 ? 'gx-doctor' : `${attempt + 1}-gx-doctor`;
840
+ const candidateBranch = `${branchPrefix}-${suffix}`;
841
+ const candidateWorktreePath = doctorSandboxWorktreePath(blocked.repoRoot, candidateBranch);
842
+ if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) {
843
+ continue;
844
+ }
845
+ if (fs.existsSync(candidateWorktreePath)) {
846
+ continue;
847
+ }
848
+ selectedBranch = candidateBranch;
849
+ selectedWorktreePath = candidateWorktreePath;
850
+ break;
851
+ }
852
+
853
+ if (!selectedBranch || !selectedWorktreePath) {
854
+ throw new Error('Unable to allocate unique sandbox branch/worktree for doctor');
855
+ }
856
+
857
+ fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true });
858
+ const startRef = resolveDoctorSandboxStartRef(blocked.repoRoot, blocked.branch);
859
+ const addResult = run(
860
+ 'git',
861
+ ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef],
862
+ );
863
+ if (isSpawnFailure(addResult)) {
864
+ throw addResult.error;
865
+ }
866
+ if (addResult.status !== 0) {
867
+ throw new Error((addResult.stderr || addResult.stdout || 'failed to create doctor sandbox').trim());
868
+ }
869
+
870
+ return {
871
+ metadata: {
872
+ branch: selectedBranch,
873
+ worktreePath: selectedWorktreePath,
874
+ },
875
+ stdout:
876
+ `[agent-branch-start] Created branch: ${selectedBranch}\n` +
877
+ `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
878
+ stderr: addResult.stderr || '',
879
+ };
880
+ }
881
+
882
+ function startDoctorSandbox(blocked) {
775
883
  const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
776
884
  if (!fs.existsSync(startScript)) {
777
- throw new Error(
778
- `doctor sandbox fallback is unavailable because '${startScript}' is missing.\n` +
779
- `Run '${SHORT_TOOL_NAME} setup --allow-protected-base-write' once to restore branch-start tooling.`,
780
- );
885
+ return startDoctorSandboxFallback(blocked);
781
886
  }
782
887
 
783
888
  const startResult = run('bash', [
@@ -789,7 +894,7 @@ function runDoctorInSandbox(options, blocked) {
789
894
  '--base',
790
895
  blocked.branch,
791
896
  ], { cwd: blocked.repoRoot });
792
- if (startResult.error) {
897
+ if (isSpawnFailure(startResult)) {
793
898
  throw startResult.error;
794
899
  }
795
900
  if (startResult.status !== 0) {
@@ -801,18 +906,308 @@ function runDoctorInSandbox(options, blocked) {
801
906
  throw new Error(`Failed to parse sandbox worktree from agent-branch-start output:\n${startResult.stdout}`);
802
907
  }
803
908
 
909
+ return {
910
+ metadata,
911
+ stdout: startResult.stdout || '',
912
+ stderr: startResult.stderr || '',
913
+ };
914
+ }
915
+
916
+ function parseGitPathList(output) {
917
+ return String(output || '')
918
+ .split('\n')
919
+ .map((line) => line.trim())
920
+ .filter((line) => line && line !== LOCK_FILE_RELATIVE);
921
+ }
922
+
923
+ function collectDoctorChangedPaths(worktreePath) {
924
+ const changed = new Set();
925
+ const commands = [
926
+ ['diff', '--name-only'],
927
+ ['diff', '--cached', '--name-only'],
928
+ ['ls-files', '--others', '--exclude-standard'],
929
+ ];
930
+ for (const gitArgs of commands) {
931
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
932
+ for (const filePath of parseGitPathList(result.stdout)) {
933
+ changed.add(filePath);
934
+ }
935
+ }
936
+ return Array.from(changed);
937
+ }
938
+
939
+ function collectDoctorDeletedPaths(worktreePath) {
940
+ const deleted = new Set();
941
+ const commands = [
942
+ ['diff', '--name-only', '--diff-filter=D'],
943
+ ['diff', '--cached', '--name-only', '--diff-filter=D'],
944
+ ];
945
+ for (const gitArgs of commands) {
946
+ const result = run('git', ['-C', worktreePath, ...gitArgs], { timeout: 20_000 });
947
+ for (const filePath of parseGitPathList(result.stdout)) {
948
+ deleted.add(filePath);
949
+ }
950
+ }
951
+ return Array.from(deleted);
952
+ }
953
+
954
+ function claimDoctorChangedLocks(metadata) {
955
+ const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py');
956
+ if (!fs.existsSync(lockScript) || !metadata.branch) {
957
+ return {
958
+ status: 'skipped',
959
+ note: 'lock helper unavailable in sandbox',
960
+ changedCount: 0,
961
+ deletedCount: 0,
962
+ };
963
+ }
964
+
965
+ const changedPaths = collectDoctorChangedPaths(metadata.worktreePath);
966
+ const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath);
967
+ if (changedPaths.length > 0) {
968
+ run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], {
969
+ cwd: metadata.worktreePath,
970
+ timeout: 30_000,
971
+ });
972
+ }
973
+ if (deletedPaths.length > 0) {
974
+ run('python3', [lockScript, 'allow-delete', '--branch', metadata.branch, ...deletedPaths], {
975
+ cwd: metadata.worktreePath,
976
+ timeout: 30_000,
977
+ });
978
+ }
979
+
980
+ return {
981
+ status: 'claimed',
982
+ note: 'claimed locks for doctor auto-commit',
983
+ changedCount: changedPaths.length,
984
+ deletedCount: deletedPaths.length,
985
+ };
986
+ }
987
+
988
+ function autoCommitDoctorSandboxChanges(metadata) {
989
+ if (!metadata.worktreePath || !metadata.branch) {
990
+ return {
991
+ status: 'skipped',
992
+ note: 'missing sandbox branch metadata',
993
+ };
994
+ }
995
+
996
+ claimDoctorChangedLocks(metadata);
997
+ run('git', ['-C', metadata.worktreePath, 'add', '-A'], { timeout: 20_000 });
998
+ const staged = run(
999
+ 'git',
1000
+ ['-C', metadata.worktreePath, 'diff', '--cached', '--name-only', '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`],
1001
+ { timeout: 20_000 },
1002
+ );
1003
+ const stagedFiles = parseGitPathList(staged.stdout);
1004
+ if (stagedFiles.length === 0) {
1005
+ return {
1006
+ status: 'no-changes',
1007
+ note: 'no committable doctor changes found in sandbox',
1008
+ };
1009
+ }
1010
+
1011
+ const commitResult = run(
1012
+ 'git',
1013
+ ['-C', metadata.worktreePath, 'commit', '-m', 'Auto-finish: gx doctor repairs'],
1014
+ { timeout: 30_000 },
1015
+ );
1016
+ if (commitResult.status !== 0) {
1017
+ return {
1018
+ status: 'failed',
1019
+ note: 'doctor sandbox auto-commit failed',
1020
+ stdout: commitResult.stdout || '',
1021
+ stderr: commitResult.stderr || '',
1022
+ };
1023
+ }
1024
+
1025
+ return {
1026
+ status: 'committed',
1027
+ note: 'doctor sandbox repairs committed',
1028
+ commitMessage: 'Auto-finish: gx doctor repairs',
1029
+ stagedFiles,
1030
+ };
1031
+ }
1032
+
1033
+ function hasOriginRemote(repoRoot) {
1034
+ return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
1035
+ }
1036
+
1037
+ function isCommandAvailable(commandName) {
1038
+ return run('which', [commandName]).status === 0;
1039
+ }
1040
+
1041
+ function finishDoctorSandboxBranch(blocked, metadata) {
1042
+ const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh');
1043
+ if (!fs.existsSync(finishScript)) {
1044
+ return {
1045
+ status: 'skipped',
1046
+ note: `${path.relative(metadata.worktreePath, finishScript)} missing in sandbox`,
1047
+ };
1048
+ }
1049
+ if (!hasOriginRemote(blocked.repoRoot)) {
1050
+ return {
1051
+ status: 'skipped',
1052
+ note: 'origin remote missing; skipped auto-finish',
1053
+ };
1054
+ }
1055
+
1056
+ const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
1057
+ if (!isCommandAvailable(ghBin)) {
1058
+ return {
1059
+ status: 'skipped',
1060
+ note: `'${ghBin}' not available; skipped auto-finish PR flow`,
1061
+ };
1062
+ }
1063
+ const ghAuthStatus = run(ghBin, ['auth', 'status'], { timeout: 20_000 });
1064
+ if (ghAuthStatus.status !== 0) {
1065
+ return {
1066
+ status: 'skipped',
1067
+ note: `'${ghBin}' auth unavailable; skipped auto-finish PR flow`,
1068
+ stderr: ghAuthStatus.stderr || '',
1069
+ };
1070
+ }
1071
+
1072
+ const finishResult = run(
1073
+ 'bash',
1074
+ [finishScript, '--branch', metadata.branch, '--via-pr'],
1075
+ { cwd: metadata.worktreePath, timeout: 180_000 },
1076
+ );
1077
+ if (isSpawnFailure(finishResult)) {
1078
+ return {
1079
+ status: 'failed',
1080
+ note: 'doctor sandbox finish flow errored',
1081
+ stdout: finishResult.stdout || '',
1082
+ stderr: finishResult.stderr || '',
1083
+ };
1084
+ }
1085
+ if (finishResult.status !== 0) {
1086
+ return {
1087
+ status: 'failed',
1088
+ note: 'doctor sandbox finish flow failed',
1089
+ stdout: finishResult.stdout || '',
1090
+ stderr: finishResult.stderr || '',
1091
+ };
1092
+ }
1093
+
1094
+ return {
1095
+ status: 'completed',
1096
+ note: 'doctor sandbox finish flow completed',
1097
+ stdout: finishResult.stdout || '',
1098
+ stderr: finishResult.stderr || '',
1099
+ };
1100
+ }
1101
+
1102
+ function runDoctorInSandbox(options, blocked) {
1103
+ const startResult = startDoctorSandbox(blocked);
1104
+ const metadata = startResult.metadata;
1105
+
804
1106
  const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
805
1107
  const nestedResult = run(
806
1108
  process.execPath,
807
1109
  [__filename, ...buildSandboxDoctorArgs(options, sandboxTarget)],
808
1110
  { cwd: metadata.worktreePath },
809
1111
  );
810
- if (nestedResult.error) {
1112
+ if (isSpawnFailure(nestedResult)) {
811
1113
  throw nestedResult.error;
812
1114
  }
813
1115
 
1116
+ let autoCommitResult = {
1117
+ status: 'skipped',
1118
+ note: 'sandbox doctor did not complete successfully',
1119
+ };
1120
+ let finishResult = {
1121
+ status: 'skipped',
1122
+ note: 'sandbox doctor did not complete successfully',
1123
+ };
1124
+
1125
+ let lockSyncResult = {
1126
+ status: 'skipped',
1127
+ note: 'sandbox doctor did not complete successfully',
1128
+ };
1129
+ if (nestedResult.status === 0) {
1130
+ if (!options.dryRun) {
1131
+ autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
1132
+ if (autoCommitResult.status === 'committed') {
1133
+ finishResult = finishDoctorSandboxBranch(blocked, metadata);
1134
+ } else if (autoCommitResult.status === 'no-changes') {
1135
+ finishResult = {
1136
+ status: 'skipped',
1137
+ note: 'no doctor changes to auto-finish',
1138
+ };
1139
+ } else if (autoCommitResult.status !== 'failed') {
1140
+ finishResult = {
1141
+ status: 'skipped',
1142
+ note: 'auto-commit did not run',
1143
+ };
1144
+ }
1145
+ } else {
1146
+ autoCommitResult = {
1147
+ status: 'skipped',
1148
+ note: 'dry-run skips doctor sandbox auto-commit',
1149
+ };
1150
+ finishResult = {
1151
+ status: 'skipped',
1152
+ note: 'dry-run skips doctor sandbox finish flow',
1153
+ };
1154
+ }
1155
+
1156
+ const sandboxLockPath = path.join(metadata.worktreePath, LOCK_FILE_RELATIVE);
1157
+ const baseLockPath = path.join(blocked.repoRoot, LOCK_FILE_RELATIVE);
1158
+ if (!fs.existsSync(baseLockPath)) {
1159
+ lockSyncResult = {
1160
+ status: 'skipped',
1161
+ note: `${LOCK_FILE_RELATIVE} missing in protected base workspace`,
1162
+ };
1163
+ } else if (!fs.existsSync(sandboxLockPath)) {
1164
+ lockSyncResult = {
1165
+ status: 'skipped',
1166
+ note: `${LOCK_FILE_RELATIVE} missing in sandbox worktree`,
1167
+ };
1168
+ } else {
1169
+ const sourceContent = fs.readFileSync(sandboxLockPath, 'utf8');
1170
+ const destinationContent = fs.readFileSync(baseLockPath, 'utf8');
1171
+ if (sourceContent === destinationContent) {
1172
+ lockSyncResult = {
1173
+ status: 'unchanged',
1174
+ note: `${LOCK_FILE_RELATIVE} already in sync`,
1175
+ };
1176
+ } else {
1177
+ fs.mkdirSync(path.dirname(baseLockPath), { recursive: true });
1178
+ fs.writeFileSync(baseLockPath, sourceContent, 'utf8');
1179
+ lockSyncResult = {
1180
+ status: 'synced',
1181
+ note: `${LOCK_FILE_RELATIVE} synced from sandbox`,
1182
+ };
1183
+ }
1184
+ }
1185
+ }
1186
+
814
1187
  if (options.json) {
815
- if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
1188
+ if (nestedResult.stdout) {
1189
+ if (nestedResult.status === 0) {
1190
+ try {
1191
+ const parsed = JSON.parse(nestedResult.stdout);
1192
+ process.stdout.write(
1193
+ JSON.stringify(
1194
+ {
1195
+ ...parsed,
1196
+ sandboxLockSync: lockSyncResult,
1197
+ sandboxAutoCommit: autoCommitResult,
1198
+ sandboxFinish: finishResult,
1199
+ },
1200
+ null,
1201
+ 2,
1202
+ ) + '\n',
1203
+ );
1204
+ } catch {
1205
+ process.stdout.write(nestedResult.stdout);
1206
+ }
1207
+ } else {
1208
+ process.stdout.write(nestedResult.stdout);
1209
+ }
1210
+ }
816
1211
  if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
817
1212
  } else {
818
1213
  console.log(
@@ -823,6 +1218,41 @@ function runDoctorInSandbox(options, blocked) {
823
1218
  if (startResult.stderr) process.stderr.write(startResult.stderr);
824
1219
  if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
825
1220
  if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
1221
+ if (nestedResult.status === 0) {
1222
+ if (autoCommitResult.status === 'committed') {
1223
+ console.log(
1224
+ `[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
1225
+ );
1226
+ } else if (autoCommitResult.status === 'failed') {
1227
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
1228
+ if (autoCommitResult.stdout) process.stdout.write(autoCommitResult.stdout);
1229
+ if (autoCommitResult.stderr) process.stderr.write(autoCommitResult.stderr);
1230
+ } else {
1231
+ console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${autoCommitResult.note}.`);
1232
+ }
1233
+
1234
+ if (finishResult.status === 'completed') {
1235
+ console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`);
1236
+ if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1237
+ if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1238
+ } else if (finishResult.status === 'failed') {
1239
+ console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
1240
+ if (finishResult.stdout) process.stdout.write(finishResult.stdout);
1241
+ if (finishResult.stderr) process.stderr.write(finishResult.stderr);
1242
+ } else {
1243
+ console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
1244
+ }
1245
+
1246
+ if (lockSyncResult.status === 'synced') {
1247
+ console.log(
1248
+ `[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
1249
+ );
1250
+ } else if (lockSyncResult.status === 'unchanged') {
1251
+ console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1252
+ } else {
1253
+ console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
1254
+ }
1255
+ }
826
1256
  }
827
1257
 
828
1258
  if (typeof nestedResult.status === 'number') {
@@ -1361,6 +1791,12 @@ function isInteractiveTerminal() {
1361
1791
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1362
1792
  }
1363
1793
 
1794
+ const stdinWaitArray = new Int32Array(new SharedArrayBuffer(4));
1795
+
1796
+ function sleepSyncMs(milliseconds) {
1797
+ Atomics.wait(stdinWaitArray, 0, 0, milliseconds);
1798
+ }
1799
+
1364
1800
  function readSingleLineFromStdin() {
1365
1801
  let input = '';
1366
1802
  const buffer = Buffer.alloc(1);
@@ -1369,11 +1805,19 @@ function readSingleLineFromStdin() {
1369
1805
  let bytesRead = 0;
1370
1806
  try {
1371
1807
  bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 1);
1372
- } catch {
1808
+ } catch (error) {
1809
+ if (error && ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error.code)) {
1810
+ sleepSyncMs(15);
1811
+ continue;
1812
+ }
1373
1813
  return input;
1374
1814
  }
1375
1815
 
1376
1816
  if (bytesRead === 0) {
1817
+ if (process.stdin.isTTY) {
1818
+ sleepSyncMs(15);
1819
+ continue;
1820
+ }
1377
1821
  return input;
1378
1822
  }
1379
1823
 
@@ -1513,9 +1957,8 @@ function maybeSelfUpdateBeforeStatus() {
1513
1957
  }
1514
1958
 
1515
1959
  const shouldUpdate = interactive
1516
- ? promptYesNo(
1960
+ ? promptYesNoStrict(
1517
1961
  `Update now? (${NPM_BIN} i -g ${packageJson.name}@latest)`,
1518
- false,
1519
1962
  )
1520
1963
  : autoApproval;
1521
1964
 
@@ -1608,6 +2051,27 @@ function detectGlobalToolchainPackages() {
1608
2051
  return { ok: true, installed, missing };
1609
2052
  }
1610
2053
 
2054
+ function detectRequiredSystemTools() {
2055
+ const services = [];
2056
+ for (const tool of REQUIRED_SYSTEM_TOOLS) {
2057
+ const result = run(tool.command, ['--version']);
2058
+ const active = result.status === 0;
2059
+ const rawReason = result.error && result.error.code
2060
+ ? result.error.code
2061
+ : (result.stderr || '').trim();
2062
+ const reason = rawReason.split('\n')[0] || '';
2063
+ services.push({
2064
+ name: tool.name,
2065
+ displayName: tool.displayName || tool.name,
2066
+ command: tool.command,
2067
+ installHint: tool.installHint,
2068
+ status: active ? 'active' : 'inactive',
2069
+ reason,
2070
+ });
2071
+ }
2072
+ return services;
2073
+ }
2074
+
1611
2075
  function askGlobalInstallForMissing(options, missingPackages) {
1612
2076
  const approval = resolveGlobalInstallApproval(options);
1613
2077
  if (!approval.approved) {
@@ -1937,7 +2401,7 @@ function status(rawArgs) {
1937
2401
  });
1938
2402
 
1939
2403
  const toolchain = detectGlobalToolchainPackages();
1940
- const services = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
2404
+ const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
1941
2405
  if (!toolchain.ok) {
1942
2406
  return { name: pkg, status: 'unknown' };
1943
2407
  }
@@ -1946,6 +2410,15 @@ function status(rawArgs) {
1946
2410
  status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
1947
2411
  };
1948
2412
  });
2413
+ const requiredSystemTools = detectRequiredSystemTools();
2414
+ const services = [
2415
+ ...npmServices,
2416
+ ...requiredSystemTools.map((tool) => ({
2417
+ name: tool.name,
2418
+ displayName: tool.displayName || tool.name,
2419
+ status: tool.status,
2420
+ })),
2421
+ ];
1949
2422
 
1950
2423
  const targetPath = path.resolve(options.target);
1951
2424
  const inGitRepo = isGitRepo(targetPath);
@@ -1991,7 +2464,19 @@ function status(rawArgs) {
1991
2464
 
1992
2465
  console.log(`[${TOOL_NAME}] Global services:`);
1993
2466
  for (const service of services) {
1994
- console.log(` - ${statusDot(service.status)} ${service.name}: ${service.status}`);
2467
+ const serviceLabel = service.displayName || service.name;
2468
+ console.log(` - ${statusDot(service.status)} ${serviceLabel}: ${service.status}`);
2469
+ }
2470
+ const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
2471
+ if (missingSystemTools.length > 0) {
2472
+ const tools = missingSystemTools
2473
+ .map((tool) => tool.displayName || tool.name)
2474
+ .join(', ');
2475
+ console.log(`[${TOOL_NAME}] ⚠️ Missing required system tool(s): ${tools}`);
2476
+ for (const tool of missingSystemTools) {
2477
+ const reasonText = tool.reason ? ` (${tool.reason})` : '';
2478
+ console.log(` - install ${tool.name}: ${tool.installHint}${reasonText}`);
2479
+ }
1995
2480
  }
1996
2481
 
1997
2482
  if (!scanResult) {
@@ -2004,6 +2489,16 @@ function status(rawArgs) {
2004
2489
 
2005
2490
  if (scanResult.errors === 0 && scanResult.warnings === 0) {
2006
2491
  console.log(`[${TOOL_NAME}] Repo safety service: ${statusDot('active')} active.`);
2492
+ } else if (scanResult.errors === 0) {
2493
+ console.log(
2494
+ `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.warnings} warning(s)).`,
2495
+ );
2496
+ console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' to review warning details.`);
2497
+ } else if (scanResult.warnings === 0) {
2498
+ console.log(
2499
+ `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s)).`,
2500
+ );
2501
+ console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' for detailed findings.`);
2007
2502
  } else {
2008
2503
  console.log(
2009
2504
  `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
@@ -2084,7 +2579,7 @@ function doctor(rawArgs) {
2084
2579
  allowProtectedBaseWrite: false,
2085
2580
  });
2086
2581
 
2087
- const blocked = protectedBaseWriteBlock(options);
2582
+ const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false });
2088
2583
  if (blocked) {
2089
2584
  runDoctorInSandbox(options, blocked);
2090
2585
  return;
@@ -2093,7 +2588,8 @@ function doctor(rawArgs) {
2093
2588
  assertProtectedMainWriteAllowed(options, 'doctor');
2094
2589
  const fixPayload = runFixInternal(options);
2095
2590
  const scanResult = runScanInternal({ target: options.target, json: false });
2096
- const musafe = scanResult.errors === 0 && scanResult.warnings === 0;
2591
+ const safe = scanResult.errors === 0 && scanResult.warnings === 0;
2592
+ const musafe = safe;
2097
2593
 
2098
2594
  if (options.json) {
2099
2595
  process.stdout.write(
@@ -2101,6 +2597,7 @@ function doctor(rawArgs) {
2101
2597
  {
2102
2598
  repoRoot: scanResult.repoRoot,
2103
2599
  branch: scanResult.branch,
2600
+ safe,
2104
2601
  musafe,
2105
2602
  fix: {
2106
2603
  operations: fixPayload.operations,
@@ -2123,11 +2620,11 @@ function doctor(rawArgs) {
2123
2620
 
2124
2621
  printOperations('Doctor/fix', fixPayload, options.dryRun);
2125
2622
  printScanResult(scanResult, false);
2126
- if (musafe) {
2127
- console.log(`[${TOOL_NAME}] ✅ Repo is correctly musafe.`);
2623
+ if (safe) {
2624
+ console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
2128
2625
  } else {
2129
2626
  console.log(
2130
- `[${TOOL_NAME}] ⚠️ Repo is not fully musafe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
2627
+ `[${TOOL_NAME}] ⚠️ Repo is not fully safe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
2131
2628
  );
2132
2629
  }
2133
2630
  setExitCodeFromScan(scanResult);
@@ -2243,7 +2740,7 @@ function setup(rawArgs) {
2243
2740
  `[${TOOL_NAME}] ✅ Global tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
2244
2741
  );
2245
2742
  } else if (globalInstallStatus.status === 'already-installed') {
2246
- console.log(`[${TOOL_NAME}] ✅ OMX/OpenSpec/codex-auth global tools already installed. Skipping.`);
2743
+ console.log(`[${TOOL_NAME}] ✅ OMX/OpenSpec/codex-auth npm global tools already installed. Skipping.`);
2247
2744
  } else if (globalInstallStatus.status === 'failed') {
2248
2745
  console.log(
2249
2746
  `[${TOOL_NAME}] ⚠️ Global install failed: ${globalInstallStatus.reason}\n` +
@@ -2256,6 +2753,18 @@ function setup(rawArgs) {
2256
2753
  `Use --yes-global-install to force or run interactively for Y/N prompt.`,
2257
2754
  );
2258
2755
  }
2756
+ const requiredSystemTools = detectRequiredSystemTools();
2757
+ const missingSystemTools = requiredSystemTools.filter((tool) => tool.status !== 'active');
2758
+ if (missingSystemTools.length === 0) {
2759
+ console.log(`[${TOOL_NAME}] ✅ Required system tools available (${requiredSystemTools.map((tool) => tool.name).join(', ')}).`);
2760
+ } else {
2761
+ const names = missingSystemTools.map((tool) => tool.name).join(', ');
2762
+ console.log(`[${TOOL_NAME}] ⚠️ Missing required system tool(s): ${names}`);
2763
+ for (const tool of missingSystemTools) {
2764
+ const reasonText = tool.reason ? ` (${tool.reason})` : '';
2765
+ console.log(`[${TOOL_NAME}] Install ${tool.name}: ${tool.installHint}${reasonText}`);
2766
+ }
2767
+ }
2259
2768
 
2260
2769
  assertProtectedMainWriteAllowed(options, 'setup');
2261
2770
  const installPayload = runInstallInternal(options);