@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.
- package/CONTRIBUTING.md +1 -0
- package/README.md +128 -298
- package/bin/multiagent-safety.js +738 -20
- package/package.json +2 -2
- package/templates/AGENTS.multiagent-safety.md +7 -1
- package/templates/codex/skills/guardex/SKILL.md +2 -0
- package/templates/githooks/pre-commit +22 -0
- package/templates/scripts/agent-branch-finish.sh +216 -33
- package/templates/scripts/agent-branch-start.sh +40 -6
- package/templates/scripts/agent-worktree-prune.sh +57 -14
- package/templates/scripts/codex-agent.sh +318 -2
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
|
);
|
|
@@ -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
|
-
-
|
|
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':
|
|
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
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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 (
|
|
1931
|
-
console.log(`[${TOOL_NAME}] ✅ Repo is
|
|
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
|
|
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;
|