@imdeadpool/guardex 5.0.7 → 5.0.9
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/README.md +50 -4
- package/bin/multiagent-safety.js +989 -14
- package/package.json +1 -1
- package/templates/AGENTS.multiagent-safety.md +11 -2
- package/templates/githooks/pre-commit +20 -6
- package/templates/githooks/pre-push +3 -3
- package/templates/scripts/agent-branch-finish.sh +2 -2
- package/templates/scripts/agent-branch-start.sh +92 -60
- package/templates/scripts/codex-agent.sh +234 -5
package/bin/multiagent-safety.js
CHANGED
|
@@ -83,6 +83,7 @@ const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
|
|
|
83
83
|
const GITIGNORE_MARKER_START = '# multiagent-safety:START';
|
|
84
84
|
const GITIGNORE_MARKER_END = '# multiagent-safety:END';
|
|
85
85
|
const MANAGED_GITIGNORE_PATHS = [
|
|
86
|
+
'.omx/',
|
|
86
87
|
'scripts/agent-branch-start.sh',
|
|
87
88
|
'scripts/agent-branch-finish.sh',
|
|
88
89
|
'scripts/codex-agent.sh',
|
|
@@ -98,6 +99,17 @@ const MANAGED_GITIGNORE_PATHS = [
|
|
|
98
99
|
'.claude/commands/guardex.md',
|
|
99
100
|
LOCK_FILE_RELATIVE,
|
|
100
101
|
];
|
|
102
|
+
const OMX_SCAFFOLD_DIRECTORIES = [
|
|
103
|
+
'.omx',
|
|
104
|
+
'.omx/state',
|
|
105
|
+
'.omx/logs',
|
|
106
|
+
'.omx/plans',
|
|
107
|
+
'.omx/agent-worktrees',
|
|
108
|
+
];
|
|
109
|
+
const OMX_SCAFFOLD_FILES = new Map([
|
|
110
|
+
['.omx/notepad.md', '\n\n## WORKING MEMORY\n'],
|
|
111
|
+
['.omx/project-memory.json', '{}\n'],
|
|
112
|
+
]);
|
|
101
113
|
const COMMAND_TYPO_ALIASES = new Map([
|
|
102
114
|
['relaese', 'release'],
|
|
103
115
|
['realaese', 'release'],
|
|
@@ -115,6 +127,8 @@ const SUGGESTIBLE_COMMANDS = [
|
|
|
115
127
|
'setup',
|
|
116
128
|
'init',
|
|
117
129
|
'doctor',
|
|
130
|
+
'review',
|
|
131
|
+
'finish',
|
|
118
132
|
'report',
|
|
119
133
|
'copy-prompt',
|
|
120
134
|
'copy-commands',
|
|
@@ -135,6 +149,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
135
149
|
['init', 'Alias of setup (bootstrap + repair guardrails in a git repo)'],
|
|
136
150
|
['doctor', 'Repair safety setup drift, then verify repo safety'],
|
|
137
151
|
['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
|
|
152
|
+
['finish', 'Auto-commit completed agent branches, then run PR finish flow'],
|
|
138
153
|
['copy-prompt', 'Print the AI-ready setup checklist'],
|
|
139
154
|
['copy-commands', 'Print setup checklist as executable commands only'],
|
|
140
155
|
['protect', 'Manage protected branches (list/add/remove/set/reset)'],
|
|
@@ -149,8 +164,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
149
164
|
['version', 'Print GuardeX version'],
|
|
150
165
|
];
|
|
151
166
|
const AGENT_BOT_DESCRIPTIONS = [
|
|
152
|
-
['review', '
|
|
153
|
-
['start', 'bash scripts/review-bot-watch.sh --interval 30'],
|
|
167
|
+
['review', 'Start PR monitor + codex-agent review flow (default interval: 30s)'],
|
|
154
168
|
];
|
|
155
169
|
|
|
156
170
|
const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-Rex for your repo) in this repository for Codex or Claude.
|
|
@@ -172,7 +186,10 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
|
|
|
172
186
|
3) If setup reports warnings/errors, repair + re-check:
|
|
173
187
|
gx doctor
|
|
174
188
|
|
|
175
|
-
4)
|
|
189
|
+
4) Optional: start continuous PR monitor from this repo:
|
|
190
|
+
gx review --interval 30
|
|
191
|
+
|
|
192
|
+
5) Confirm next safe agent workflow commands:
|
|
176
193
|
bash scripts/codex-agent.sh "task" "agent-name"
|
|
177
194
|
bash scripts/agent-branch-start.sh "task" "agent-name"
|
|
178
195
|
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
|
|
@@ -183,18 +200,20 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
|
|
|
183
200
|
- Finished branches stay available by default for audit/follow-up.
|
|
184
201
|
Remove them explicitly when done:
|
|
185
202
|
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
203
|
+
- To finalize all completed agent branches in one pass:
|
|
204
|
+
gx finish --all
|
|
186
205
|
|
|
187
|
-
|
|
206
|
+
6) Optional: create OpenSpec planning workspace:
|
|
188
207
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
189
208
|
|
|
190
|
-
|
|
209
|
+
7) Optional: protect extra branches:
|
|
191
210
|
gx protect add release staging
|
|
192
211
|
|
|
193
|
-
|
|
212
|
+
8) Optional: sync your current agent branch with latest base branch:
|
|
194
213
|
gx sync --check
|
|
195
214
|
gx sync
|
|
196
215
|
|
|
197
|
-
|
|
216
|
+
9) Optional (GitHub remote cleanup): enable:
|
|
198
217
|
Settings -> General -> Pull Requests -> Automatically delete head branches
|
|
199
218
|
`;
|
|
200
219
|
|
|
@@ -202,10 +221,12 @@ const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
|
|
|
202
221
|
gh --version
|
|
203
222
|
gx setup
|
|
204
223
|
gx doctor
|
|
224
|
+
gx review --interval 30
|
|
205
225
|
bash scripts/codex-agent.sh "task" "agent-name"
|
|
206
226
|
bash scripts/agent-branch-start.sh "task" "agent-name"
|
|
207
227
|
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
|
|
208
228
|
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
229
|
+
gx finish --all
|
|
209
230
|
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
210
231
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
211
232
|
gx protect add release staging
|
|
@@ -349,7 +370,9 @@ NOTES
|
|
|
349
370
|
- ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup
|
|
350
371
|
- ${TOOL_NAME} setup asks for Y/N approval before global installs
|
|
351
372
|
- ${TOOL_NAME} setup checks GitHub CLI (gh) and prints install guidance if missing
|
|
373
|
+
- For other repos: ${SHORT_TOOL_NAME} setup --target <repo-path> then ${SHORT_TOOL_NAME} doctor --target <repo-path>
|
|
352
374
|
- In initialized repos, setup/install/fix block in-place writes on protected main by default
|
|
375
|
+
- setup/doctor auto-finish clean pending agent/* branches via PR flow into the current local base branch
|
|
353
376
|
- doctor auto-runs in a sandbox agent branch/worktree on protected main and tries auto-finish PR flow
|
|
354
377
|
- agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup
|
|
355
378
|
- use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too)
|
|
@@ -359,7 +382,8 @@ NOTES
|
|
|
359
382
|
console.log(`
|
|
360
383
|
[${TOOL_NAME}] No git repository detected in current directory.
|
|
361
384
|
[${TOOL_NAME}] Start from a repo root, or pass an explicit target:
|
|
362
|
-
${TOOL_NAME} setup --target <path-to-git-repo
|
|
385
|
+
${TOOL_NAME} setup --target <path-to-git-repo>
|
|
386
|
+
${TOOL_NAME} doctor --target <path-to-git-repo>`);
|
|
363
387
|
}
|
|
364
388
|
}
|
|
365
389
|
|
|
@@ -502,6 +526,45 @@ function lockFilePath(repoRoot) {
|
|
|
502
526
|
return path.join(repoRoot, LOCK_FILE_RELATIVE);
|
|
503
527
|
}
|
|
504
528
|
|
|
529
|
+
function ensureOmxScaffold(repoRoot, dryRun) {
|
|
530
|
+
const operations = [];
|
|
531
|
+
|
|
532
|
+
for (const relativeDir of OMX_SCAFFOLD_DIRECTORIES) {
|
|
533
|
+
const absoluteDir = path.join(repoRoot, relativeDir);
|
|
534
|
+
if (fs.existsSync(absoluteDir)) {
|
|
535
|
+
if (!fs.statSync(absoluteDir).isDirectory()) {
|
|
536
|
+
throw new Error(`Expected directory at ${relativeDir} but found a file.`);
|
|
537
|
+
}
|
|
538
|
+
operations.push({ status: 'unchanged', file: relativeDir });
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!dryRun) {
|
|
543
|
+
fs.mkdirSync(absoluteDir, { recursive: true });
|
|
544
|
+
}
|
|
545
|
+
operations.push({ status: 'created', file: relativeDir });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
for (const [relativeFile, defaultContent] of OMX_SCAFFOLD_FILES.entries()) {
|
|
549
|
+
const absoluteFile = path.join(repoRoot, relativeFile);
|
|
550
|
+
if (fs.existsSync(absoluteFile)) {
|
|
551
|
+
if (!fs.statSync(absoluteFile).isFile()) {
|
|
552
|
+
throw new Error(`Expected file at ${relativeFile} but found a directory.`);
|
|
553
|
+
}
|
|
554
|
+
operations.push({ status: 'unchanged', file: relativeFile });
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!dryRun) {
|
|
559
|
+
fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
|
|
560
|
+
fs.writeFileSync(absoluteFile, defaultContent, 'utf8');
|
|
561
|
+
}
|
|
562
|
+
operations.push({ status: 'created', file: relativeFile });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return operations;
|
|
566
|
+
}
|
|
567
|
+
|
|
505
568
|
function ensureLockRegistry(repoRoot, dryRun) {
|
|
506
569
|
const absolutePath = lockFilePath(repoRoot);
|
|
507
570
|
if (fs.existsSync(absolutePath)) {
|
|
@@ -570,6 +633,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
|
|
|
570
633
|
'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
|
|
571
634
|
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
|
|
572
635
|
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
|
|
636
|
+
'agent:finish': `${SHORT_TOOL_NAME} finish --all`,
|
|
573
637
|
'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
|
|
574
638
|
'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
|
|
575
639
|
'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
|
|
@@ -844,6 +908,33 @@ function isSpawnFailure(result) {
|
|
|
844
908
|
return Boolean(result?.error) && typeof result?.status !== 'number';
|
|
845
909
|
}
|
|
846
910
|
|
|
911
|
+
function ensureRepoBranch(repoRoot, branch) {
|
|
912
|
+
const current = currentBranchName(repoRoot);
|
|
913
|
+
if (current === branch) {
|
|
914
|
+
return { ok: true, changed: false };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const checkoutResult = run('git', ['-C', repoRoot, 'checkout', branch], { timeout: 20_000 });
|
|
918
|
+
if (isSpawnFailure(checkoutResult)) {
|
|
919
|
+
return {
|
|
920
|
+
ok: false,
|
|
921
|
+
changed: false,
|
|
922
|
+
stdout: checkoutResult.stdout || '',
|
|
923
|
+
stderr: checkoutResult.stderr || '',
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (checkoutResult.status !== 0) {
|
|
927
|
+
return {
|
|
928
|
+
ok: false,
|
|
929
|
+
changed: false,
|
|
930
|
+
stdout: checkoutResult.stdout || '',
|
|
931
|
+
stderr: checkoutResult.stderr || '',
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return { ok: true, changed: true };
|
|
936
|
+
}
|
|
937
|
+
|
|
847
938
|
function doctorSandboxBranchPrefix() {
|
|
848
939
|
const now = new Date();
|
|
849
940
|
const stamp = [
|
|
@@ -945,12 +1036,26 @@ function startDoctorSandbox(blocked) {
|
|
|
945
1036
|
throw startResult.error;
|
|
946
1037
|
}
|
|
947
1038
|
if (startResult.status !== 0) {
|
|
948
|
-
|
|
1039
|
+
return startDoctorSandboxFallback(blocked);
|
|
949
1040
|
}
|
|
950
1041
|
|
|
951
1042
|
const metadata = extractAgentBranchStartMetadata(startResult.stdout);
|
|
952
|
-
|
|
953
|
-
|
|
1043
|
+
const currentBranch = currentBranchName(blocked.repoRoot);
|
|
1044
|
+
const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : '';
|
|
1045
|
+
const repoRootPath = path.resolve(blocked.repoRoot);
|
|
1046
|
+
const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath;
|
|
1047
|
+
const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch;
|
|
1048
|
+
|
|
1049
|
+
if (!hasSafeWorktree || branchChanged) {
|
|
1050
|
+
const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch);
|
|
1051
|
+
if (!restoreResult.ok) {
|
|
1052
|
+
const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim();
|
|
1053
|
+
throw new Error(
|
|
1054
|
+
`doctor sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` +
|
|
1055
|
+
(detail ? `\n${detail}` : ''),
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
return startDoctorSandboxFallback(blocked);
|
|
954
1059
|
}
|
|
955
1060
|
|
|
956
1061
|
return {
|
|
@@ -1081,6 +1186,14 @@ function hasOriginRemote(repoRoot) {
|
|
|
1081
1186
|
return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
|
|
1082
1187
|
}
|
|
1083
1188
|
|
|
1189
|
+
function originRemoteLooksLikeGithub(repoRoot) {
|
|
1190
|
+
const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
|
|
1191
|
+
if (!originUrl) {
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
return /github\.com[:/]/i.test(originUrl);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1084
1197
|
function isCommandAvailable(commandName) {
|
|
1085
1198
|
return run('which', [commandName]).status === 0;
|
|
1086
1199
|
}
|
|
@@ -1112,6 +1225,13 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1112
1225
|
note: 'origin remote missing; skipped auto-finish',
|
|
1113
1226
|
};
|
|
1114
1227
|
}
|
|
1228
|
+
const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
|
|
1229
|
+
if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
|
|
1230
|
+
return {
|
|
1231
|
+
status: 'skipped',
|
|
1232
|
+
note: 'origin remote is not GitHub; skipped auto-finish PR flow',
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1115
1235
|
|
|
1116
1236
|
const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
|
|
1117
1237
|
if (!isCommandAvailable(ghBin)) {
|
|
@@ -1129,10 +1249,15 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1129
1249
|
};
|
|
1130
1250
|
}
|
|
1131
1251
|
|
|
1252
|
+
const rawWaitTimeoutSeconds = Number.parseInt(process.env.MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
|
|
1253
|
+
const waitTimeoutSeconds =
|
|
1254
|
+
Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
|
|
1255
|
+
const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
|
|
1256
|
+
|
|
1132
1257
|
const finishResult = run(
|
|
1133
1258
|
'bash',
|
|
1134
|
-
[finishScript, '--branch', metadata.branch, '--via-pr'],
|
|
1135
|
-
{ cwd: metadata.worktreePath, timeout:
|
|
1259
|
+
[finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
|
|
1260
|
+
{ cwd: metadata.worktreePath, timeout: finishTimeoutMs },
|
|
1136
1261
|
);
|
|
1137
1262
|
if (isSpawnFailure(finishResult)) {
|
|
1138
1263
|
return {
|
|
@@ -1197,7 +1322,35 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1197
1322
|
status: 'skipped',
|
|
1198
1323
|
note: 'sandbox doctor did not complete successfully',
|
|
1199
1324
|
};
|
|
1325
|
+
let postSandboxAutoFinishSummary = {
|
|
1326
|
+
enabled: false,
|
|
1327
|
+
attempted: 0,
|
|
1328
|
+
completed: 0,
|
|
1329
|
+
skipped: 0,
|
|
1330
|
+
failed: 0,
|
|
1331
|
+
details: ['Skipped auto-finish sweep (sandbox doctor did not complete successfully).'],
|
|
1332
|
+
};
|
|
1333
|
+
let omxScaffoldSyncResult = {
|
|
1334
|
+
status: 'skipped',
|
|
1335
|
+
note: 'sandbox doctor did not complete successfully',
|
|
1336
|
+
};
|
|
1200
1337
|
if (nestedResult.status === 0) {
|
|
1338
|
+
const omxScaffoldOps = ensureOmxScaffold(blocked.repoRoot, Boolean(options.dryRun));
|
|
1339
|
+
const changedOmxPaths = omxScaffoldOps.filter((operation) => operation.status !== 'unchanged');
|
|
1340
|
+
if (changedOmxPaths.length === 0) {
|
|
1341
|
+
omxScaffoldSyncResult = {
|
|
1342
|
+
status: 'unchanged',
|
|
1343
|
+
note: '.omx scaffold already in sync',
|
|
1344
|
+
operations: omxScaffoldOps,
|
|
1345
|
+
};
|
|
1346
|
+
} else {
|
|
1347
|
+
omxScaffoldSyncResult = {
|
|
1348
|
+
status: options.dryRun ? 'would-sync' : 'synced',
|
|
1349
|
+
note: `${options.dryRun ? 'would sync' : 'synced'} ${changedOmxPaths.length} .omx path(s)`,
|
|
1350
|
+
operations: omxScaffoldOps,
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1201
1354
|
if (!options.dryRun) {
|
|
1202
1355
|
autoCommitResult = autoCommitDoctorSandboxChanges(metadata);
|
|
1203
1356
|
if (autoCommitResult.status === 'committed') {
|
|
@@ -1253,6 +1406,12 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1253
1406
|
};
|
|
1254
1407
|
}
|
|
1255
1408
|
}
|
|
1409
|
+
|
|
1410
|
+
postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
|
|
1411
|
+
baseBranch: blocked.branch,
|
|
1412
|
+
dryRun: options.dryRun,
|
|
1413
|
+
excludeBranches: [metadata.branch],
|
|
1414
|
+
});
|
|
1256
1415
|
}
|
|
1257
1416
|
|
|
1258
1417
|
if (options.json) {
|
|
@@ -1264,9 +1423,11 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1264
1423
|
JSON.stringify(
|
|
1265
1424
|
{
|
|
1266
1425
|
...parsed,
|
|
1426
|
+
sandboxOmxScaffoldSync: omxScaffoldSyncResult,
|
|
1267
1427
|
sandboxLockSync: lockSyncResult,
|
|
1268
1428
|
sandboxAutoCommit: autoCommitResult,
|
|
1269
1429
|
sandboxFinish: finishResult,
|
|
1430
|
+
autoFinish: postSandboxAutoFinishSummary,
|
|
1270
1431
|
},
|
|
1271
1432
|
null,
|
|
1272
1433
|
2,
|
|
@@ -1332,11 +1493,42 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1332
1493
|
} else {
|
|
1333
1494
|
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${lockSyncResult.note}.`);
|
|
1334
1495
|
}
|
|
1496
|
+
|
|
1497
|
+
if (postSandboxAutoFinishSummary.enabled) {
|
|
1498
|
+
console.log(
|
|
1499
|
+
`[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
|
|
1500
|
+
);
|
|
1501
|
+
for (const detail of postSandboxAutoFinishSummary.details) {
|
|
1502
|
+
console.log(`[${TOOL_NAME}] ${detail}`);
|
|
1503
|
+
}
|
|
1504
|
+
} else if (postSandboxAutoFinishSummary.details.length > 0) {
|
|
1505
|
+
console.log(`[${TOOL_NAME}] ${postSandboxAutoFinishSummary.details[0]}`);
|
|
1506
|
+
}
|
|
1507
|
+
if (omxScaffoldSyncResult.status === 'synced') {
|
|
1508
|
+
console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
|
|
1509
|
+
} else if (omxScaffoldSyncResult.status === 'unchanged') {
|
|
1510
|
+
console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
|
|
1511
|
+
} else if (omxScaffoldSyncResult.status === 'would-sync') {
|
|
1512
|
+
console.log(`[${TOOL_NAME}] Dry run: would sync .omx scaffold back to protected branch workspace.`);
|
|
1513
|
+
} else {
|
|
1514
|
+
console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${omxScaffoldSyncResult.note}.`);
|
|
1515
|
+
}
|
|
1335
1516
|
}
|
|
1336
1517
|
}
|
|
1337
1518
|
|
|
1338
1519
|
if (typeof nestedResult.status === 'number') {
|
|
1339
|
-
|
|
1520
|
+
let exitCode = nestedResult.status;
|
|
1521
|
+
if (exitCode === 0 && autoCommitResult.status === 'failed') {
|
|
1522
|
+
exitCode = 1;
|
|
1523
|
+
}
|
|
1524
|
+
if (
|
|
1525
|
+
exitCode === 0 &&
|
|
1526
|
+
autoCommitResult.status === 'committed' &&
|
|
1527
|
+
(finishResult.status === 'failed' || finishResult.status === 'pending')
|
|
1528
|
+
) {
|
|
1529
|
+
exitCode = 1;
|
|
1530
|
+
}
|
|
1531
|
+
process.exitCode = exitCode;
|
|
1340
1532
|
return;
|
|
1341
1533
|
}
|
|
1342
1534
|
process.exitCode = 1;
|
|
@@ -1363,6 +1555,18 @@ function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) {
|
|
|
1363
1555
|
return { target, args: remaining };
|
|
1364
1556
|
}
|
|
1365
1557
|
|
|
1558
|
+
function parseReviewArgs(rawArgs) {
|
|
1559
|
+
const parsed = parseTargetFlag(rawArgs, process.cwd());
|
|
1560
|
+
const passthroughArgs = [...parsed.args];
|
|
1561
|
+
if (passthroughArgs[0] === 'start') {
|
|
1562
|
+
passthroughArgs.shift();
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
target: parsed.target,
|
|
1566
|
+
passthroughArgs,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1366
1570
|
function parseReportArgs(rawArgs) {
|
|
1367
1571
|
const options = {
|
|
1368
1572
|
target: process.cwd(),
|
|
@@ -1628,6 +1832,210 @@ function listLocalUserBranches(repoRoot) {
|
|
|
1628
1832
|
return [branchName];
|
|
1629
1833
|
}
|
|
1630
1834
|
|
|
1835
|
+
function listLocalAgentBranches(repoRoot) {
|
|
1836
|
+
const result = gitRun(
|
|
1837
|
+
repoRoot,
|
|
1838
|
+
['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
|
|
1839
|
+
{ allowFailure: true },
|
|
1840
|
+
);
|
|
1841
|
+
if (result.status !== 0) {
|
|
1842
|
+
return [];
|
|
1843
|
+
}
|
|
1844
|
+
return uniquePreserveOrder(
|
|
1845
|
+
String(result.stdout || '')
|
|
1846
|
+
.split('\n')
|
|
1847
|
+
.map((item) => item.trim())
|
|
1848
|
+
.filter(Boolean),
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function mapWorktreePathsByBranch(repoRoot) {
|
|
1853
|
+
const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
|
|
1854
|
+
const map = new Map();
|
|
1855
|
+
if (result.status !== 0) {
|
|
1856
|
+
return map;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const lines = String(result.stdout || '').split('\n');
|
|
1860
|
+
let currentWorktree = '';
|
|
1861
|
+
for (const line of lines) {
|
|
1862
|
+
if (line.startsWith('worktree ')) {
|
|
1863
|
+
currentWorktree = line.slice('worktree '.length).trim();
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (line.startsWith('branch refs/heads/')) {
|
|
1867
|
+
const branchName = line.slice('branch refs/heads/'.length).trim();
|
|
1868
|
+
if (currentWorktree && branchName) {
|
|
1869
|
+
map.set(branchName, currentWorktree);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
return map;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function hasSignificantWorkingTreeChanges(worktreePath) {
|
|
1877
|
+
const result = run('git', ['-C', worktreePath, 'status', '--porcelain']);
|
|
1878
|
+
if (result.status !== 0) {
|
|
1879
|
+
return true;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const lines = String(result.stdout || '')
|
|
1883
|
+
.split('\n')
|
|
1884
|
+
.map((line) => line.trimEnd())
|
|
1885
|
+
.filter((line) => line.length > 0);
|
|
1886
|
+
|
|
1887
|
+
for (const line of lines) {
|
|
1888
|
+
const pathPart = (line.length > 3 ? line.slice(3) : '').trim();
|
|
1889
|
+
if (!pathPart) continue;
|
|
1890
|
+
if (pathPart === LOCK_FILE_RELATIVE) continue;
|
|
1891
|
+
if (pathPart.startsWith(`${LOCK_FILE_RELATIVE} -> `)) continue;
|
|
1892
|
+
if (pathPart.endsWith(` -> ${LOCK_FILE_RELATIVE}`)) continue;
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
return false;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function autoFinishReadyAgentBranches(repoRoot, options = {}) {
|
|
1899
|
+
const baseBranch = String(options.baseBranch || '').trim();
|
|
1900
|
+
const dryRun = Boolean(options.dryRun);
|
|
1901
|
+
const excludedBranches = new Set(
|
|
1902
|
+
Array.isArray(options.excludeBranches)
|
|
1903
|
+
? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
|
|
1904
|
+
: [],
|
|
1905
|
+
);
|
|
1906
|
+
|
|
1907
|
+
const summary = {
|
|
1908
|
+
enabled: true,
|
|
1909
|
+
baseBranch,
|
|
1910
|
+
attempted: 0,
|
|
1911
|
+
completed: 0,
|
|
1912
|
+
skipped: 0,
|
|
1913
|
+
failed: 0,
|
|
1914
|
+
details: [],
|
|
1915
|
+
};
|
|
1916
|
+
|
|
1917
|
+
if (!baseBranch || baseBranch === 'HEAD' || baseBranch.startsWith('agent/')) {
|
|
1918
|
+
summary.enabled = false;
|
|
1919
|
+
summary.details.push('Skipped auto-finish sweep (base branch is missing or not a non-agent local branch).');
|
|
1920
|
+
return summary;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (String(process.env.MUSAFETY_DOCTOR_SANDBOX || '') === '1') {
|
|
1924
|
+
summary.enabled = false;
|
|
1925
|
+
summary.details.push('Skipped auto-finish sweep inside doctor sandbox pass.');
|
|
1926
|
+
return summary;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (String(process.env.MUSAFETY_SKIP_AUTO_FINISH_READY_BRANCHES || '') === '1') {
|
|
1930
|
+
summary.enabled = false;
|
|
1931
|
+
summary.details.push('Skipped auto-finish sweep (MUSAFETY_SKIP_AUTO_FINISH_READY_BRANCHES=1).');
|
|
1932
|
+
return summary;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (dryRun) {
|
|
1936
|
+
summary.enabled = false;
|
|
1937
|
+
summary.details.push('Skipped auto-finish sweep in dry-run mode.');
|
|
1938
|
+
return summary;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh');
|
|
1942
|
+
if (!fs.existsSync(finishScript)) {
|
|
1943
|
+
summary.enabled = false;
|
|
1944
|
+
summary.details.push(`Skipped auto-finish sweep (missing ${path.relative(repoRoot, finishScript)}).`);
|
|
1945
|
+
return summary;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const hasOrigin = gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
|
|
1949
|
+
if (!hasOrigin) {
|
|
1950
|
+
summary.enabled = false;
|
|
1951
|
+
summary.details.push('Skipped auto-finish sweep (origin remote missing).');
|
|
1952
|
+
return summary;
|
|
1953
|
+
}
|
|
1954
|
+
const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
|
|
1955
|
+
if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
|
|
1956
|
+
summary.enabled = false;
|
|
1957
|
+
summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
|
|
1958
|
+
return summary;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
|
|
1962
|
+
if (run(ghBin, ['--version']).status !== 0) {
|
|
1963
|
+
summary.enabled = false;
|
|
1964
|
+
summary.details.push(`Skipped auto-finish sweep (${ghBin} not available).`);
|
|
1965
|
+
return summary;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const branchWorktrees = mapWorktreePathsByBranch(repoRoot);
|
|
1969
|
+
const agentBranches = listLocalAgentBranches(repoRoot);
|
|
1970
|
+
if (agentBranches.length === 0) {
|
|
1971
|
+
summary.enabled = false;
|
|
1972
|
+
summary.details.push('No local agent branches found for auto-finish sweep.');
|
|
1973
|
+
return summary;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
for (const branch of agentBranches) {
|
|
1977
|
+
if (excludedBranches.has(branch)) {
|
|
1978
|
+
summary.skipped += 1;
|
|
1979
|
+
summary.details.push(`[skip] ${branch}: excluded from this auto-finish sweep.`);
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
if (branch === baseBranch) {
|
|
1984
|
+
summary.skipped += 1;
|
|
1985
|
+
summary.details.push(`[skip] ${branch}: source branch equals base branch.`);
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
let counts;
|
|
1990
|
+
try {
|
|
1991
|
+
counts = aheadBehind(repoRoot, branch, baseBranch);
|
|
1992
|
+
} catch (error) {
|
|
1993
|
+
summary.failed += 1;
|
|
1994
|
+
summary.details.push(`[fail] ${branch}: unable to compute ahead/behind (${error.message}).`);
|
|
1995
|
+
continue;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
if (counts.ahead <= 0) {
|
|
1999
|
+
summary.skipped += 1;
|
|
2000
|
+
summary.details.push(`[skip] ${branch}: already merged into ${baseBranch}.`);
|
|
2001
|
+
continue;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
const branchWorktree = branchWorktrees.get(branch) || '';
|
|
2005
|
+
if (branchWorktree && hasSignificantWorkingTreeChanges(branchWorktree)) {
|
|
2006
|
+
summary.skipped += 1;
|
|
2007
|
+
summary.details.push(`[skip] ${branch}: dirty worktree (${branchWorktree}).`);
|
|
2008
|
+
continue;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
summary.attempted += 1;
|
|
2012
|
+
const finishArgs = [
|
|
2013
|
+
finishScript,
|
|
2014
|
+
'--branch',
|
|
2015
|
+
branch,
|
|
2016
|
+
'--base',
|
|
2017
|
+
baseBranch,
|
|
2018
|
+
'--via-pr',
|
|
2019
|
+
'--wait-for-merge',
|
|
2020
|
+
'--cleanup',
|
|
2021
|
+
];
|
|
2022
|
+
const finishResult = run('bash', finishArgs, { cwd: repoRoot });
|
|
2023
|
+
const combinedOutput = [finishResult.stdout || '', finishResult.stderr || ''].join('\n').trim();
|
|
2024
|
+
|
|
2025
|
+
if (finishResult.status === 0) {
|
|
2026
|
+
summary.completed += 1;
|
|
2027
|
+
summary.details.push(`[done] ${branch}: auto-finish completed.`);
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
summary.failed += 1;
|
|
2032
|
+
const tail = combinedOutput ? ` ${combinedOutput.split('\n').slice(-2).join(' | ')}` : '';
|
|
2033
|
+
summary.details.push(`[fail] ${branch}: auto-finish failed.${tail}`);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
return summary;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1631
2039
|
function ensureSetupProtectedBranches(repoRoot, dryRun) {
|
|
1632
2040
|
const localUserBranches = listLocalUserBranches(repoRoot);
|
|
1633
2041
|
if (localUserBranches.length === 0) {
|
|
@@ -1921,6 +2329,382 @@ function parseCleanupArgs(rawArgs) {
|
|
|
1921
2329
|
return options;
|
|
1922
2330
|
}
|
|
1923
2331
|
|
|
2332
|
+
function parseFinishArgs(rawArgs) {
|
|
2333
|
+
const options = {
|
|
2334
|
+
target: process.cwd(),
|
|
2335
|
+
base: '',
|
|
2336
|
+
branch: '',
|
|
2337
|
+
all: false,
|
|
2338
|
+
dryRun: false,
|
|
2339
|
+
waitForMerge: true,
|
|
2340
|
+
cleanup: true,
|
|
2341
|
+
keepRemote: false,
|
|
2342
|
+
noAutoCommit: false,
|
|
2343
|
+
failFast: false,
|
|
2344
|
+
commitMessage: '',
|
|
2345
|
+
};
|
|
2346
|
+
|
|
2347
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
2348
|
+
const arg = rawArgs[index];
|
|
2349
|
+
if (arg === '--target') {
|
|
2350
|
+
const next = rawArgs[index + 1];
|
|
2351
|
+
if (!next) {
|
|
2352
|
+
throw new Error('--target requires a path value');
|
|
2353
|
+
}
|
|
2354
|
+
options.target = next;
|
|
2355
|
+
index += 1;
|
|
2356
|
+
continue;
|
|
2357
|
+
}
|
|
2358
|
+
if (arg === '--base') {
|
|
2359
|
+
const next = rawArgs[index + 1];
|
|
2360
|
+
if (!next) {
|
|
2361
|
+
throw new Error('--base requires a branch value');
|
|
2362
|
+
}
|
|
2363
|
+
options.base = next;
|
|
2364
|
+
index += 1;
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2367
|
+
if (arg === '--branch') {
|
|
2368
|
+
const next = rawArgs[index + 1];
|
|
2369
|
+
if (!next) {
|
|
2370
|
+
throw new Error('--branch requires an agent/* branch value');
|
|
2371
|
+
}
|
|
2372
|
+
options.branch = next;
|
|
2373
|
+
index += 1;
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
if (arg === '--commit-message') {
|
|
2377
|
+
const next = rawArgs[index + 1];
|
|
2378
|
+
if (!next) {
|
|
2379
|
+
throw new Error('--commit-message requires a value');
|
|
2380
|
+
}
|
|
2381
|
+
options.commitMessage = next;
|
|
2382
|
+
index += 1;
|
|
2383
|
+
continue;
|
|
2384
|
+
}
|
|
2385
|
+
if (arg === '--all') {
|
|
2386
|
+
options.all = true;
|
|
2387
|
+
continue;
|
|
2388
|
+
}
|
|
2389
|
+
if (arg === '--dry-run') {
|
|
2390
|
+
options.dryRun = true;
|
|
2391
|
+
continue;
|
|
2392
|
+
}
|
|
2393
|
+
if (arg === '--wait-for-merge') {
|
|
2394
|
+
options.waitForMerge = true;
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
if (arg === '--no-wait-for-merge') {
|
|
2398
|
+
options.waitForMerge = false;
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
if (arg === '--cleanup') {
|
|
2402
|
+
options.cleanup = true;
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
if (arg === '--no-cleanup') {
|
|
2406
|
+
options.cleanup = false;
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
if (arg === '--keep-remote') {
|
|
2410
|
+
options.keepRemote = true;
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
if (arg === '--no-auto-commit') {
|
|
2414
|
+
options.noAutoCommit = true;
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
if (arg === '--fail-fast') {
|
|
2418
|
+
options.failFast = true;
|
|
2419
|
+
continue;
|
|
2420
|
+
}
|
|
2421
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
if (options.branch && !options.branch.startsWith('agent/')) {
|
|
2425
|
+
throw new Error(`--branch must reference an agent/* branch (received: ${options.branch})`);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
return options;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function listAgentWorktrees(repoRoot) {
|
|
2432
|
+
const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
|
|
2433
|
+
if (result.status !== 0) {
|
|
2434
|
+
throw new Error('Unable to list git worktrees for finish command');
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
const entries = [];
|
|
2438
|
+
let currentPath = '';
|
|
2439
|
+
let currentBranchRef = '';
|
|
2440
|
+
const lines = String(result.stdout || '').split('\n');
|
|
2441
|
+
for (const line of lines) {
|
|
2442
|
+
if (!line.trim()) {
|
|
2443
|
+
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
2444
|
+
entries.push({
|
|
2445
|
+
worktreePath: currentPath,
|
|
2446
|
+
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
currentPath = '';
|
|
2450
|
+
currentBranchRef = '';
|
|
2451
|
+
continue;
|
|
2452
|
+
}
|
|
2453
|
+
if (line.startsWith('worktree ')) {
|
|
2454
|
+
currentPath = line.slice('worktree '.length).trim();
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
if (line.startsWith('branch ')) {
|
|
2458
|
+
currentBranchRef = line.slice('branch '.length).trim();
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
2463
|
+
entries.push({
|
|
2464
|
+
worktreePath: currentPath,
|
|
2465
|
+
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
return entries;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function listLocalAgentBranchesForFinish(repoRoot) {
|
|
2473
|
+
const result = gitRun(
|
|
2474
|
+
repoRoot,
|
|
2475
|
+
['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
|
|
2476
|
+
{ allowFailure: true },
|
|
2477
|
+
);
|
|
2478
|
+
if (result.status !== 0) {
|
|
2479
|
+
throw new Error('Unable to list local agent branches');
|
|
2480
|
+
}
|
|
2481
|
+
return uniquePreserveOrder(
|
|
2482
|
+
String(result.stdout || '')
|
|
2483
|
+
.split('\n')
|
|
2484
|
+
.map((line) => line.trim())
|
|
2485
|
+
.filter((line) => line.startsWith('agent/')),
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
function gitQuietChangeResult(worktreePath, args) {
|
|
2490
|
+
const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
|
|
2491
|
+
if (result.status === 0) {
|
|
2492
|
+
return false;
|
|
2493
|
+
}
|
|
2494
|
+
if (result.status === 1) {
|
|
2495
|
+
return true;
|
|
2496
|
+
}
|
|
2497
|
+
throw new Error(
|
|
2498
|
+
`git ${args.join(' ')} failed in ${worktreePath}: ${(
|
|
2499
|
+
result.stderr || result.stdout || ''
|
|
2500
|
+
).trim()}`,
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function worktreeHasLocalChanges(worktreePath) {
|
|
2505
|
+
const hasUnstaged = gitQuietChangeResult(worktreePath, [
|
|
2506
|
+
'diff',
|
|
2507
|
+
'--quiet',
|
|
2508
|
+
'--',
|
|
2509
|
+
'.',
|
|
2510
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2511
|
+
]);
|
|
2512
|
+
if (hasUnstaged) {
|
|
2513
|
+
return true;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
const hasStaged = gitQuietChangeResult(worktreePath, [
|
|
2517
|
+
'diff',
|
|
2518
|
+
'--cached',
|
|
2519
|
+
'--quiet',
|
|
2520
|
+
'--',
|
|
2521
|
+
'.',
|
|
2522
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2523
|
+
]);
|
|
2524
|
+
if (hasStaged) {
|
|
2525
|
+
return true;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
|
|
2529
|
+
stdio: 'pipe',
|
|
2530
|
+
});
|
|
2531
|
+
if (untracked.status !== 0) {
|
|
2532
|
+
throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
|
|
2533
|
+
}
|
|
2534
|
+
return String(untracked.stdout || '').trim().length > 0;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function gitOutputLines(worktreePath, args) {
|
|
2538
|
+
const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
|
|
2539
|
+
if (result.status !== 0) {
|
|
2540
|
+
throw new Error(
|
|
2541
|
+
`git ${args.join(' ')} failed in ${worktreePath}: ${(
|
|
2542
|
+
result.stderr || result.stdout || ''
|
|
2543
|
+
).trim()}`,
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
return String(result.stdout || '')
|
|
2547
|
+
.split('\n')
|
|
2548
|
+
.map((line) => line.trim())
|
|
2549
|
+
.filter(Boolean);
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
|
|
2553
|
+
const lockScript = path.join(repoRoot, 'scripts', 'agent-file-locks.py');
|
|
2554
|
+
if (!fs.existsSync(lockScript)) {
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const changedFiles = uniquePreserveOrder([
|
|
2559
|
+
...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
|
|
2560
|
+
...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
|
|
2561
|
+
...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
|
|
2562
|
+
]);
|
|
2563
|
+
|
|
2564
|
+
if (changedFiles.length > 0) {
|
|
2565
|
+
const claim = run('python3', [lockScript, 'claim', '--branch', branch, ...changedFiles], {
|
|
2566
|
+
cwd: repoRoot,
|
|
2567
|
+
stdio: 'pipe',
|
|
2568
|
+
});
|
|
2569
|
+
if (claim.status !== 0) {
|
|
2570
|
+
throw new Error(
|
|
2571
|
+
`Lock claim failed for ${branch}: ${(
|
|
2572
|
+
claim.stderr || claim.stdout || ''
|
|
2573
|
+
).trim()}`,
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
const deletedFiles = uniquePreserveOrder([
|
|
2579
|
+
...gitOutputLines(worktreePath, [
|
|
2580
|
+
'diff',
|
|
2581
|
+
'--name-only',
|
|
2582
|
+
'--diff-filter=D',
|
|
2583
|
+
'--',
|
|
2584
|
+
'.',
|
|
2585
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2586
|
+
]),
|
|
2587
|
+
...gitOutputLines(worktreePath, [
|
|
2588
|
+
'diff',
|
|
2589
|
+
'--cached',
|
|
2590
|
+
'--name-only',
|
|
2591
|
+
'--diff-filter=D',
|
|
2592
|
+
'--',
|
|
2593
|
+
'.',
|
|
2594
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2595
|
+
]),
|
|
2596
|
+
]);
|
|
2597
|
+
|
|
2598
|
+
if (deletedFiles.length > 0) {
|
|
2599
|
+
const allowDelete = run('python3', [lockScript, 'allow-delete', '--branch', branch, ...deletedFiles], {
|
|
2600
|
+
cwd: repoRoot,
|
|
2601
|
+
stdio: 'pipe',
|
|
2602
|
+
});
|
|
2603
|
+
if (allowDelete.status !== 0) {
|
|
2604
|
+
throw new Error(
|
|
2605
|
+
`Delete-lock grant failed for ${branch}: ${(
|
|
2606
|
+
allowDelete.stderr || allowDelete.stdout || ''
|
|
2607
|
+
).trim()}`,
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function branchExists(repoRoot, branch) {
|
|
2614
|
+
const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
2615
|
+
allowFailure: true,
|
|
2616
|
+
});
|
|
2617
|
+
return result.status === 0;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) {
|
|
2621
|
+
if (explicitBase) {
|
|
2622
|
+
return explicitBase;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
const branchSpecific = readGitConfig(repoRoot, `branch.${sourceBranch}.musafetyBase`);
|
|
2626
|
+
if (branchSpecific) {
|
|
2627
|
+
return branchSpecific;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
|
|
2631
|
+
if (configured) {
|
|
2632
|
+
return configured;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
const current = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
|
|
2636
|
+
const currentBranch = String(current.stdout || '').trim();
|
|
2637
|
+
if (current.status === 0 && currentBranch && currentBranch !== 'HEAD' && !currentBranch.startsWith('agent/')) {
|
|
2638
|
+
return currentBranch;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
return DEFAULT_BASE_BRANCH;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
function branchMergedIntoBase(repoRoot, branch, baseBranch) {
|
|
2645
|
+
if (!branchExists(repoRoot, baseBranch)) {
|
|
2646
|
+
return false;
|
|
2647
|
+
}
|
|
2648
|
+
const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
|
|
2649
|
+
allowFailure: true,
|
|
2650
|
+
});
|
|
2651
|
+
if (result.status === 0) {
|
|
2652
|
+
return true;
|
|
2653
|
+
}
|
|
2654
|
+
if (result.status === 1) {
|
|
2655
|
+
return false;
|
|
2656
|
+
}
|
|
2657
|
+
throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
|
|
2661
|
+
const hasChanges = worktreeHasLocalChanges(worktreePath);
|
|
2662
|
+
if (!hasChanges) {
|
|
2663
|
+
return { changed: false, committed: false };
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
if (options.noAutoCommit) {
|
|
2667
|
+
throw new Error(
|
|
2668
|
+
`Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
|
|
2669
|
+
);
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (options.dryRun) {
|
|
2673
|
+
return { changed: true, committed: false, dryRun: true };
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
claimLocksForAutoCommit(repoRoot, worktreePath, branch);
|
|
2677
|
+
|
|
2678
|
+
const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
|
|
2679
|
+
if (addResult.status !== 0) {
|
|
2680
|
+
throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const stagedHasChanges = gitQuietChangeResult(worktreePath, [
|
|
2684
|
+
'diff',
|
|
2685
|
+
'--cached',
|
|
2686
|
+
'--quiet',
|
|
2687
|
+
'--',
|
|
2688
|
+
'.',
|
|
2689
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2690
|
+
]);
|
|
2691
|
+
if (!stagedHasChanges) {
|
|
2692
|
+
return { changed: true, committed: false };
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
const commitMessage = options.commitMessage || `Auto-finish: ${branch}`;
|
|
2696
|
+
const commitResult = run('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { stdio: 'pipe' });
|
|
2697
|
+
if (commitResult.status !== 0) {
|
|
2698
|
+
throw new Error(
|
|
2699
|
+
`Auto-commit failed on '${branch}': ${(
|
|
2700
|
+
commitResult.stderr || commitResult.stdout || ''
|
|
2701
|
+
).trim()}`,
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
return { changed: true, committed: true, message: commitMessage };
|
|
2706
|
+
}
|
|
2707
|
+
|
|
1924
2708
|
function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
|
|
1925
2709
|
if (strategy === 'rebase') {
|
|
1926
2710
|
if (ffOnly) {
|
|
@@ -2325,6 +3109,8 @@ function runInstallInternal(options) {
|
|
|
2325
3109
|
const repoRoot = resolveRepoRoot(options.target);
|
|
2326
3110
|
const operations = [];
|
|
2327
3111
|
|
|
3112
|
+
operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
|
|
3113
|
+
|
|
2328
3114
|
for (const templateFile of TEMPLATE_FILES) {
|
|
2329
3115
|
operations.push(copyTemplateFile(repoRoot, templateFile, Boolean(options.force), Boolean(options.dryRun)));
|
|
2330
3116
|
}
|
|
@@ -2351,6 +3137,8 @@ function runFixInternal(options) {
|
|
|
2351
3137
|
const repoRoot = resolveRepoRoot(options.target);
|
|
2352
3138
|
const operations = [];
|
|
2353
3139
|
|
|
3140
|
+
operations.push(...ensureOmxScaffold(repoRoot, Boolean(options.dryRun)));
|
|
3141
|
+
|
|
2354
3142
|
for (const templateFile of TEMPLATE_FILES) {
|
|
2355
3143
|
operations.push(ensureTemplateFilePresent(repoRoot, templateFile, Boolean(options.dryRun)));
|
|
2356
3144
|
}
|
|
@@ -2404,6 +3192,8 @@ function runScanInternal(options) {
|
|
|
2404
3192
|
const findings = [];
|
|
2405
3193
|
|
|
2406
3194
|
const requiredPaths = [
|
|
3195
|
+
...OMX_SCAFFOLD_DIRECTORIES,
|
|
3196
|
+
...Array.from(OMX_SCAFFOLD_FILES.keys()),
|
|
2407
3197
|
...TEMPLATE_FILES.map((entry) => toDestinationPath(entry)),
|
|
2408
3198
|
LOCK_FILE_RELATIVE,
|
|
2409
3199
|
];
|
|
@@ -2755,6 +3545,11 @@ function doctor(rawArgs) {
|
|
|
2755
3545
|
assertProtectedMainWriteAllowed(options, 'doctor');
|
|
2756
3546
|
const fixPayload = runFixInternal(options);
|
|
2757
3547
|
const scanResult = runScanInternal({ target: options.target, json: false });
|
|
3548
|
+
const currentBaseBranch = currentBranchName(scanResult.repoRoot);
|
|
3549
|
+
const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
3550
|
+
baseBranch: currentBaseBranch,
|
|
3551
|
+
dryRun: options.dryRun,
|
|
3552
|
+
});
|
|
2758
3553
|
const safe = scanResult.errors === 0 && scanResult.warnings === 0;
|
|
2759
3554
|
const musafe = safe;
|
|
2760
3555
|
|
|
@@ -2776,6 +3571,7 @@ function doctor(rawArgs) {
|
|
|
2776
3571
|
warnings: scanResult.warnings,
|
|
2777
3572
|
findings: scanResult.findings,
|
|
2778
3573
|
},
|
|
3574
|
+
autoFinish: autoFinishSummary,
|
|
2779
3575
|
},
|
|
2780
3576
|
null,
|
|
2781
3577
|
2,
|
|
@@ -2787,6 +3583,16 @@ function doctor(rawArgs) {
|
|
|
2787
3583
|
|
|
2788
3584
|
printOperations('Doctor/fix', fixPayload, options.dryRun);
|
|
2789
3585
|
printScanResult(scanResult, false);
|
|
3586
|
+
if (autoFinishSummary.enabled) {
|
|
3587
|
+
console.log(
|
|
3588
|
+
`[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
|
|
3589
|
+
);
|
|
3590
|
+
for (const detail of autoFinishSummary.details) {
|
|
3591
|
+
console.log(`[${TOOL_NAME}] ${detail}`);
|
|
3592
|
+
}
|
|
3593
|
+
} else if (autoFinishSummary.details.length > 0) {
|
|
3594
|
+
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
|
|
3595
|
+
}
|
|
2790
3596
|
if (safe) {
|
|
2791
3597
|
console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
|
|
2792
3598
|
} else {
|
|
@@ -2797,6 +3603,27 @@ function doctor(rawArgs) {
|
|
|
2797
3603
|
setExitCodeFromScan(scanResult);
|
|
2798
3604
|
}
|
|
2799
3605
|
|
|
3606
|
+
function review(rawArgs) {
|
|
3607
|
+
const options = parseReviewArgs(rawArgs);
|
|
3608
|
+
const repoRoot = resolveRepoRoot(options.target);
|
|
3609
|
+
const reviewScriptPath = path.join(repoRoot, 'scripts', 'review-bot-watch.sh');
|
|
3610
|
+
if (!fs.existsSync(reviewScriptPath)) {
|
|
3611
|
+
throw new Error(
|
|
3612
|
+
`Missing review bot script: ${reviewScriptPath}\n` +
|
|
3613
|
+
`Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
|
|
3614
|
+
);
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
const result = run('bash', [reviewScriptPath, ...options.passthroughArgs], { cwd: repoRoot });
|
|
3618
|
+
if (isSpawnFailure(result)) {
|
|
3619
|
+
throw result.error;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
3623
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
3624
|
+
process.exitCode = typeof result.status === 'number' ? result.status : 1;
|
|
3625
|
+
}
|
|
3626
|
+
|
|
2800
3627
|
function report(rawArgs) {
|
|
2801
3628
|
const options = parseReportArgs(rawArgs);
|
|
2802
3629
|
const subcommand = options.subcommand || 'help';
|
|
@@ -2955,7 +3782,22 @@ function setup(rawArgs) {
|
|
|
2955
3782
|
}
|
|
2956
3783
|
|
|
2957
3784
|
const scanResult = runScanInternal({ target: options.target, json: false });
|
|
3785
|
+
const currentBaseBranch = currentBranchName(scanResult.repoRoot);
|
|
3786
|
+
const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, {
|
|
3787
|
+
baseBranch: currentBaseBranch,
|
|
3788
|
+
dryRun: options.dryRun,
|
|
3789
|
+
});
|
|
2958
3790
|
printScanResult(scanResult, false);
|
|
3791
|
+
if (autoFinishSummary.enabled) {
|
|
3792
|
+
console.log(
|
|
3793
|
+
`[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
|
|
3794
|
+
);
|
|
3795
|
+
for (const detail of autoFinishSummary.details) {
|
|
3796
|
+
console.log(`[${TOOL_NAME}] ${detail}`);
|
|
3797
|
+
}
|
|
3798
|
+
} else if (autoFinishSummary.details.length > 0) {
|
|
3799
|
+
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
|
|
3800
|
+
}
|
|
2959
3801
|
|
|
2960
3802
|
if (scanResult.errors === 0 && scanResult.warnings === 0) {
|
|
2961
3803
|
console.log(`[${TOOL_NAME}] ✅ Setup complete.`);
|
|
@@ -3065,6 +3907,129 @@ function cleanup(rawArgs) {
|
|
|
3065
3907
|
process.exitCode = 0;
|
|
3066
3908
|
}
|
|
3067
3909
|
|
|
3910
|
+
function finish(rawArgs) {
|
|
3911
|
+
const options = parseFinishArgs(rawArgs);
|
|
3912
|
+
const repoRoot = resolveRepoRoot(options.target);
|
|
3913
|
+
const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh');
|
|
3914
|
+
|
|
3915
|
+
if (!fs.existsSync(finishScript)) {
|
|
3916
|
+
throw new Error(`Missing finish script: ${finishScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
const worktreeEntries = listAgentWorktrees(repoRoot);
|
|
3920
|
+
const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath]));
|
|
3921
|
+
|
|
3922
|
+
let candidateBranches = [];
|
|
3923
|
+
if (options.branch) {
|
|
3924
|
+
if (!branchExists(repoRoot, options.branch)) {
|
|
3925
|
+
throw new Error(`Local branch not found: ${options.branch}`);
|
|
3926
|
+
}
|
|
3927
|
+
candidateBranches = [options.branch];
|
|
3928
|
+
} else {
|
|
3929
|
+
candidateBranches = uniquePreserveOrder([
|
|
3930
|
+
...listLocalAgentBranchesForFinish(repoRoot),
|
|
3931
|
+
...worktreeEntries.map((entry) => entry.branch),
|
|
3932
|
+
]);
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
const candidates = [];
|
|
3936
|
+
for (const branch of candidateBranches) {
|
|
3937
|
+
const worktreePath = worktreeByBranch.get(branch) || '';
|
|
3938
|
+
const baseBranch = resolveFinishBaseBranch(repoRoot, branch, options.base);
|
|
3939
|
+
const hasChanges = worktreePath ? worktreeHasLocalChanges(worktreePath) : false;
|
|
3940
|
+
const alreadyMerged = branchMergedIntoBase(repoRoot, branch, baseBranch);
|
|
3941
|
+
if (options.all || options.branch || hasChanges || !alreadyMerged) {
|
|
3942
|
+
candidates.push({
|
|
3943
|
+
branch,
|
|
3944
|
+
baseBranch,
|
|
3945
|
+
worktreePath,
|
|
3946
|
+
hasChanges,
|
|
3947
|
+
alreadyMerged,
|
|
3948
|
+
});
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
if (candidates.length === 0) {
|
|
3953
|
+
console.log(`[${TOOL_NAME}] No pending agent branches to finish.`);
|
|
3954
|
+
process.exitCode = 0;
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
let succeeded = 0;
|
|
3959
|
+
let failed = 0;
|
|
3960
|
+
let autoCommitted = 0;
|
|
3961
|
+
|
|
3962
|
+
for (const candidate of candidates) {
|
|
3963
|
+
const { branch, baseBranch, worktreePath } = candidate;
|
|
3964
|
+
console.log(
|
|
3965
|
+
`[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
|
|
3966
|
+
);
|
|
3967
|
+
|
|
3968
|
+
try {
|
|
3969
|
+
let commitState = { changed: false, committed: false };
|
|
3970
|
+
if (worktreePath) {
|
|
3971
|
+
commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
if (commitState.committed) {
|
|
3975
|
+
autoCommitted += 1;
|
|
3976
|
+
console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
|
|
3977
|
+
} else if (commitState.changed && commitState.dryRun) {
|
|
3978
|
+
console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
const finishArgs = [
|
|
3982
|
+
finishScript,
|
|
3983
|
+
'--branch',
|
|
3984
|
+
branch,
|
|
3985
|
+
'--base',
|
|
3986
|
+
baseBranch,
|
|
3987
|
+
'--via-pr',
|
|
3988
|
+
options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
|
|
3989
|
+
options.cleanup ? '--cleanup' : '--no-cleanup',
|
|
3990
|
+
];
|
|
3991
|
+
if (options.keepRemote) {
|
|
3992
|
+
finishArgs.push('--keep-remote-branch');
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
if (options.dryRun) {
|
|
3996
|
+
console.log(`[${TOOL_NAME}] [dry-run] Would run: bash ${finishArgs.join(' ')}`);
|
|
3997
|
+
succeeded += 1;
|
|
3998
|
+
continue;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
const finishResult = run('bash', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
|
|
4002
|
+
if (finishResult.stdout) {
|
|
4003
|
+
process.stdout.write(finishResult.stdout);
|
|
4004
|
+
}
|
|
4005
|
+
if (finishResult.stderr) {
|
|
4006
|
+
process.stderr.write(finishResult.stderr);
|
|
4007
|
+
}
|
|
4008
|
+
if (finishResult.status !== 0) {
|
|
4009
|
+
throw new Error(`agent-branch-finish exited with status ${finishResult.status}`);
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
succeeded += 1;
|
|
4013
|
+
} catch (error) {
|
|
4014
|
+
failed += 1;
|
|
4015
|
+
console.error(`[${TOOL_NAME}] Finish failed for '${branch}': ${error.message}`);
|
|
4016
|
+
if (options.failFast) {
|
|
4017
|
+
break;
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
console.log(
|
|
4023
|
+
`[${TOOL_NAME}] Finish summary: total=${candidates.length}, success=${succeeded}, failed=${failed}, autoCommitted=${autoCommitted}`,
|
|
4024
|
+
);
|
|
4025
|
+
|
|
4026
|
+
if (failed > 0) {
|
|
4027
|
+
throw new Error('finish command failed for one or more agent branches');
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
process.exitCode = 0;
|
|
4031
|
+
}
|
|
4032
|
+
|
|
3068
4033
|
function sync(rawArgs) {
|
|
3069
4034
|
const options = parseSyncArgs(rawArgs);
|
|
3070
4035
|
const repoRoot = resolveRepoRoot(options.target);
|
|
@@ -3388,6 +4353,16 @@ function main() {
|
|
|
3388
4353
|
return;
|
|
3389
4354
|
}
|
|
3390
4355
|
|
|
4356
|
+
if (command === 'review') {
|
|
4357
|
+
review(rest);
|
|
4358
|
+
return;
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
if (command === 'finish') {
|
|
4362
|
+
finish(rest);
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
3391
4366
|
if (command === 'report') {
|
|
3392
4367
|
report(rest);
|
|
3393
4368
|
return;
|