@imdeadpool/guardex 5.0.2 → 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
  );
@@ -141,10 +149,12 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
141
149
  gx setup
142
150
  # alias: gx init
143
151
 
144
- - Setup detects global OMX/OpenSpec/codex-auth first.
152
+ - Setup detects global OMX/OpenSpec/codex-auth npm packages first.
145
153
  - If one is missing and setup asks for approval, reply explicitly:
146
154
  - y = run: npm i -g oh-my-codex @fission-ai/openspec @imdeadpool/codex-account-switcher (missing ones only)
147
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.
148
158
 
149
159
  3) If setup reports warnings/errors, repair + re-check:
150
160
  gx doctor
@@ -176,6 +186,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
176
186
  `;
177
187
 
178
188
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
189
+ gh --version
179
190
  gx setup
180
191
  gx doctor
181
192
  bash scripts/codex-agent.sh "task" "agent-name"
@@ -297,8 +308,9 @@ NOTES
297
308
  - Short alias: ${SHORT_TOOL_NAME}
298
309
  - ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup
299
310
  - ${TOOL_NAME} setup asks for Y/N approval before global installs
311
+ - ${TOOL_NAME} setup checks GitHub CLI (gh) and prints install guidance if missing
300
312
  - 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
313
+ - doctor auto-runs in a sandbox agent branch/worktree on protected main and tries auto-finish PR flow
302
314
  - agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup
303
315
  - use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too)
304
316
  - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`);
@@ -699,13 +711,13 @@ function hasGuardexBootstrapFiles(repoRoot) {
699
711
  return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
700
712
  }
701
713
 
702
- function protectedBaseWriteBlock(options) {
714
+ function protectedBaseWriteBlock(options, { requireBootstrap = true } = {}) {
703
715
  if (options.dryRun || options.allowProtectedBaseWrite) {
704
716
  return null;
705
717
  }
706
718
 
707
719
  const repoRoot = resolveRepoRoot(options.target);
708
- if (!hasGuardexBootstrapFiles(repoRoot)) {
720
+ if (requireBootstrap && !hasGuardexBootstrapFiles(repoRoot)) {
709
721
  return null;
710
722
  }
711
723
 
@@ -771,13 +783,96 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
771
783
  return args;
772
784
  }
773
785
 
774
- function runDoctorInSandbox(options, blocked) {
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) {
775
873
  const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh');
776
874
  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
- );
875
+ return startDoctorSandboxFallback(blocked);
781
876
  }
782
877
 
783
878
  const startResult = run('bash', [
@@ -789,7 +884,7 @@ function runDoctorInSandbox(options, blocked) {
789
884
  '--base',
790
885
  blocked.branch,
791
886
  ], { cwd: blocked.repoRoot });
792
- if (startResult.error) {
887
+ if (isSpawnFailure(startResult)) {
793
888
  throw startResult.error;
794
889
  }
795
890
  if (startResult.status !== 0) {
@@ -801,18 +896,308 @@ function runDoctorInSandbox(options, blocked) {
801
896
  throw new Error(`Failed to parse sandbox worktree from agent-branch-start output:\n${startResult.stdout}`);
802
897
  }
803
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
+
804
1096
  const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target);
805
1097
  const nestedResult = run(
806
1098
  process.execPath,
807
1099
  [__filename, ...buildSandboxDoctorArgs(options, sandboxTarget)],
808
1100
  { cwd: metadata.worktreePath },
809
1101
  );
810
- if (nestedResult.error) {
1102
+ if (isSpawnFailure(nestedResult)) {
811
1103
  throw nestedResult.error;
812
1104
  }
813
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
+
814
1177
  if (options.json) {
815
- if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
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
+ }
816
1201
  if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
817
1202
  } else {
818
1203
  console.log(
@@ -823,6 +1208,41 @@ function runDoctorInSandbox(options, blocked) {
823
1208
  if (startResult.stderr) process.stderr.write(startResult.stderr);
824
1209
  if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
825
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
+ }
826
1246
  }
827
1247
 
828
1248
  if (typeof nestedResult.status === 'number') {
@@ -1361,6 +1781,12 @@ function isInteractiveTerminal() {
1361
1781
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1362
1782
  }
1363
1783
 
1784
+ const stdinWaitArray = new Int32Array(new SharedArrayBuffer(4));
1785
+
1786
+ function sleepSyncMs(milliseconds) {
1787
+ Atomics.wait(stdinWaitArray, 0, 0, milliseconds);
1788
+ }
1789
+
1364
1790
  function readSingleLineFromStdin() {
1365
1791
  let input = '';
1366
1792
  const buffer = Buffer.alloc(1);
@@ -1369,11 +1795,19 @@ function readSingleLineFromStdin() {
1369
1795
  let bytesRead = 0;
1370
1796
  try {
1371
1797
  bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 1);
1372
- } catch {
1798
+ } catch (error) {
1799
+ if (error && ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error.code)) {
1800
+ sleepSyncMs(15);
1801
+ continue;
1802
+ }
1373
1803
  return input;
1374
1804
  }
1375
1805
 
1376
1806
  if (bytesRead === 0) {
1807
+ if (process.stdin.isTTY) {
1808
+ sleepSyncMs(15);
1809
+ continue;
1810
+ }
1377
1811
  return input;
1378
1812
  }
1379
1813
 
@@ -1513,9 +1947,8 @@ function maybeSelfUpdateBeforeStatus() {
1513
1947
  }
1514
1948
 
1515
1949
  const shouldUpdate = interactive
1516
- ? promptYesNo(
1950
+ ? promptYesNoStrict(
1517
1951
  `Update now? (${NPM_BIN} i -g ${packageJson.name}@latest)`,
1518
- false,
1519
1952
  )
1520
1953
  : autoApproval;
1521
1954
 
@@ -1608,6 +2041,26 @@ function detectGlobalToolchainPackages() {
1608
2041
  return { ok: true, installed, missing };
1609
2042
  }
1610
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
+
1611
2064
  function askGlobalInstallForMissing(options, missingPackages) {
1612
2065
  const approval = resolveGlobalInstallApproval(options);
1613
2066
  if (!approval.approved) {
@@ -1937,7 +2390,7 @@ function status(rawArgs) {
1937
2390
  });
1938
2391
 
1939
2392
  const toolchain = detectGlobalToolchainPackages();
1940
- const services = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
2393
+ const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
1941
2394
  if (!toolchain.ok) {
1942
2395
  return { name: pkg, status: 'unknown' };
1943
2396
  }
@@ -1946,6 +2399,14 @@ function status(rawArgs) {
1946
2399
  status: toolchain.installed.includes(pkg) ? 'active' : 'inactive',
1947
2400
  };
1948
2401
  });
2402
+ const requiredSystemTools = detectRequiredSystemTools();
2403
+ const services = [
2404
+ ...npmServices,
2405
+ ...requiredSystemTools.map((tool) => ({
2406
+ name: tool.name,
2407
+ status: tool.status,
2408
+ })),
2409
+ ];
1949
2410
 
1950
2411
  const targetPath = path.resolve(options.target);
1951
2412
  const inGitRepo = isGitRepo(targetPath);
@@ -1993,6 +2454,15 @@ function status(rawArgs) {
1993
2454
  for (const service of services) {
1994
2455
  console.log(` - ${statusDot(service.status)} ${service.name}: ${service.status}`);
1995
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
+ }
1996
2466
 
1997
2467
  if (!scanResult) {
1998
2468
  console.log(
@@ -2084,7 +2554,7 @@ function doctor(rawArgs) {
2084
2554
  allowProtectedBaseWrite: false,
2085
2555
  });
2086
2556
 
2087
- const blocked = protectedBaseWriteBlock(options);
2557
+ const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false });
2088
2558
  if (blocked) {
2089
2559
  runDoctorInSandbox(options, blocked);
2090
2560
  return;
@@ -2093,7 +2563,8 @@ function doctor(rawArgs) {
2093
2563
  assertProtectedMainWriteAllowed(options, 'doctor');
2094
2564
  const fixPayload = runFixInternal(options);
2095
2565
  const scanResult = runScanInternal({ target: options.target, json: false });
2096
- const musafe = scanResult.errors === 0 && scanResult.warnings === 0;
2566
+ const safe = scanResult.errors === 0 && scanResult.warnings === 0;
2567
+ const musafe = safe;
2097
2568
 
2098
2569
  if (options.json) {
2099
2570
  process.stdout.write(
@@ -2101,6 +2572,7 @@ function doctor(rawArgs) {
2101
2572
  {
2102
2573
  repoRoot: scanResult.repoRoot,
2103
2574
  branch: scanResult.branch,
2575
+ safe,
2104
2576
  musafe,
2105
2577
  fix: {
2106
2578
  operations: fixPayload.operations,
@@ -2123,11 +2595,11 @@ function doctor(rawArgs) {
2123
2595
 
2124
2596
  printOperations('Doctor/fix', fixPayload, options.dryRun);
2125
2597
  printScanResult(scanResult, false);
2126
- if (musafe) {
2127
- console.log(`[${TOOL_NAME}] ✅ Repo is correctly musafe.`);
2598
+ if (safe) {
2599
+ console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
2128
2600
  } else {
2129
2601
  console.log(
2130
- `[${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)).`,
2131
2603
  );
2132
2604
  }
2133
2605
  setExitCodeFromScan(scanResult);
@@ -2243,7 +2715,7 @@ function setup(rawArgs) {
2243
2715
  `[${TOOL_NAME}] ✅ Global tools installed (${(globalInstallStatus.packages || []).join(', ')}).`,
2244
2716
  );
2245
2717
  } else if (globalInstallStatus.status === 'already-installed') {
2246
- 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.`);
2247
2719
  } else if (globalInstallStatus.status === 'failed') {
2248
2720
  console.log(
2249
2721
  `[${TOOL_NAME}] ⚠️ Global install failed: ${globalInstallStatus.reason}\n` +
@@ -2256,6 +2728,18 @@ function setup(rawArgs) {
2256
2728
  `Use --yes-global-install to force or run interactively for Y/N prompt.`,
2257
2729
  );
2258
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
+ }
2259
2743
 
2260
2744
  assertProtectedMainWriteAllowed(options, 'setup');
2261
2745
  const installPayload = runInstallInternal(options);