@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.
- package/CONTRIBUTING.md +1 -0
- package/README.md +129 -311
- package/bin/multiagent-safety.js +506 -22
- package/package.json +2 -2
- package/templates/AGENTS.multiagent-safety.md +5 -3
- package/templates/scripts/agent-branch-finish.sh +141 -8
- package/templates/scripts/agent-branch-start.sh +40 -6
- package/templates/scripts/codex-agent.sh +21 -4
package/bin/multiagent-safety.js
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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 (
|
|
2127
|
-
console.log(`[${TOOL_NAME}] ✅ Repo is
|
|
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
|
|
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);
|