@imdeadpool/guardex 5.0.8 → 5.0.11
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 +99 -0
- package/bin/multiagent-safety.js +980 -11
- package/package.json +3 -2
- package/templates/AGENTS.multiagent-safety.md +12 -3
- package/templates/scripts/agent-branch-finish.sh +2 -2
- package/templates/scripts/agent-branch-start.sh +109 -21
- package/templates/scripts/agent-worktree-prune.sh +102 -2
- package/templates/scripts/codex-agent.sh +91 -3
package/bin/multiagent-safety.js
CHANGED
|
@@ -78,6 +78,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
|
|
|
78
78
|
]);
|
|
79
79
|
|
|
80
80
|
const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json';
|
|
81
|
+
const AGENTS_BOTS_STATE_RELATIVE = '.omx/state/agents-bots.json';
|
|
81
82
|
const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
|
|
82
83
|
const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
|
|
83
84
|
const GITIGNORE_MARKER_START = '# multiagent-safety:START';
|
|
@@ -128,6 +129,8 @@ const SUGGESTIBLE_COMMANDS = [
|
|
|
128
129
|
'init',
|
|
129
130
|
'doctor',
|
|
130
131
|
'review',
|
|
132
|
+
'agents',
|
|
133
|
+
'finish',
|
|
131
134
|
'report',
|
|
132
135
|
'copy-prompt',
|
|
133
136
|
'copy-commands',
|
|
@@ -148,11 +151,13 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
148
151
|
['init', 'Alias of setup (bootstrap + repair guardrails in a git repo)'],
|
|
149
152
|
['doctor', 'Repair safety setup drift, then verify repo safety'],
|
|
150
153
|
['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
|
|
154
|
+
['finish', 'Auto-commit completed agent branches, then run PR finish flow'],
|
|
151
155
|
['copy-prompt', 'Print the AI-ready setup checklist'],
|
|
152
156
|
['copy-commands', 'Print setup checklist as executable commands only'],
|
|
153
157
|
['protect', 'Manage protected branches (list/add/remove/set/reset)'],
|
|
154
158
|
['sync', 'Check or sync agent branches with origin/<base>'],
|
|
155
|
-
['cleanup', 'Cleanup
|
|
159
|
+
['cleanup', 'Cleanup agent branches/worktrees (supports idle watch mode)'],
|
|
160
|
+
['agents', 'Start/stop repo-scoped review + cleanup bots'],
|
|
156
161
|
['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
|
|
157
162
|
['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
|
|
158
163
|
['scan', 'Report safety issues and exit non-zero on findings'],
|
|
@@ -163,6 +168,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
163
168
|
];
|
|
164
169
|
const AGENT_BOT_DESCRIPTIONS = [
|
|
165
170
|
['review', 'Start PR monitor + codex-agent review flow (default interval: 30s)'],
|
|
171
|
+
['agents', 'Start/stop both review and cleanup bots for this repo'],
|
|
166
172
|
];
|
|
167
173
|
|
|
168
174
|
const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-Rex for your repo) in this repository for Codex or Claude.
|
|
@@ -198,18 +204,31 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
|
|
|
198
204
|
- Finished branches stay available by default for audit/follow-up.
|
|
199
205
|
Remove them explicitly when done:
|
|
200
206
|
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
207
|
+
- To finalize all completed agent branches in one pass:
|
|
208
|
+
gx finish --all
|
|
201
209
|
|
|
202
|
-
6)
|
|
210
|
+
6) OpenSpec default change flow (core profile):
|
|
211
|
+
/opsx:propose <change-name>
|
|
212
|
+
/opsx:apply
|
|
213
|
+
/opsx:archive
|
|
214
|
+
- Full guide: docs/openspec-getting-started.md
|
|
215
|
+
|
|
216
|
+
7) Optional: enable expanded OpenSpec workflow commands:
|
|
217
|
+
openspec config profile <profile-name>
|
|
218
|
+
openspec update
|
|
219
|
+
- Expanded path: /opsx:new -> /opsx:ff or /opsx:continue -> /opsx:apply -> /opsx:verify -> /opsx:archive
|
|
220
|
+
|
|
221
|
+
8) Optional: create OpenSpec planning workspace:
|
|
203
222
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
204
223
|
|
|
205
|
-
|
|
224
|
+
9) Optional: protect extra branches:
|
|
206
225
|
gx protect add release staging
|
|
207
226
|
|
|
208
|
-
|
|
227
|
+
10) Optional: sync your current agent branch with latest base branch:
|
|
209
228
|
gx sync --check
|
|
210
229
|
gx sync
|
|
211
230
|
|
|
212
|
-
|
|
231
|
+
11) Optional (GitHub remote cleanup): enable:
|
|
213
232
|
Settings -> General -> Pull Requests -> Automatically delete head branches
|
|
214
233
|
`;
|
|
215
234
|
|
|
@@ -222,8 +241,11 @@ bash scripts/codex-agent.sh "task" "agent-name"
|
|
|
222
241
|
bash scripts/agent-branch-start.sh "task" "agent-name"
|
|
223
242
|
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
|
|
224
243
|
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
244
|
+
gx finish --all
|
|
225
245
|
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
226
246
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
247
|
+
openspec config profile <profile-name>
|
|
248
|
+
openspec update
|
|
227
249
|
gx protect add release staging
|
|
228
250
|
gx sync --check
|
|
229
251
|
gx sync
|
|
@@ -628,6 +650,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
|
|
|
628
650
|
'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
|
|
629
651
|
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
|
|
630
652
|
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
|
|
653
|
+
'agent:finish': `${SHORT_TOOL_NAME} finish --all`,
|
|
631
654
|
'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
|
|
632
655
|
'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
|
|
633
656
|
'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
|
|
@@ -1180,6 +1203,14 @@ function hasOriginRemote(repoRoot) {
|
|
|
1180
1203
|
return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
|
|
1181
1204
|
}
|
|
1182
1205
|
|
|
1206
|
+
function originRemoteLooksLikeGithub(repoRoot) {
|
|
1207
|
+
const originUrl = readGitConfig(repoRoot, 'remote.origin.url');
|
|
1208
|
+
if (!originUrl) {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
return /github\.com[:/]/i.test(originUrl);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1183
1214
|
function isCommandAvailable(commandName) {
|
|
1184
1215
|
return run('which', [commandName]).status === 0;
|
|
1185
1216
|
}
|
|
@@ -1211,6 +1242,13 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1211
1242
|
note: 'origin remote missing; skipped auto-finish',
|
|
1212
1243
|
};
|
|
1213
1244
|
}
|
|
1245
|
+
const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
|
|
1246
|
+
if (!explicitGhBin && !originRemoteLooksLikeGithub(blocked.repoRoot)) {
|
|
1247
|
+
return {
|
|
1248
|
+
status: 'skipped',
|
|
1249
|
+
note: 'origin remote is not GitHub; skipped auto-finish PR flow',
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1214
1252
|
|
|
1215
1253
|
const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
|
|
1216
1254
|
if (!isCommandAvailable(ghBin)) {
|
|
@@ -1228,10 +1266,15 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1228
1266
|
};
|
|
1229
1267
|
}
|
|
1230
1268
|
|
|
1269
|
+
const rawWaitTimeoutSeconds = Number.parseInt(process.env.MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS || '1800', 10);
|
|
1270
|
+
const waitTimeoutSeconds =
|
|
1271
|
+
Number.isFinite(rawWaitTimeoutSeconds) && rawWaitTimeoutSeconds >= 30 ? rawWaitTimeoutSeconds : 1800;
|
|
1272
|
+
const finishTimeoutMs = Math.max(180_000, (waitTimeoutSeconds + 60) * 1000);
|
|
1273
|
+
|
|
1231
1274
|
const finishResult = run(
|
|
1232
1275
|
'bash',
|
|
1233
|
-
[finishScript, '--branch', metadata.branch, '--via-pr'],
|
|
1234
|
-
{ cwd: metadata.worktreePath, timeout:
|
|
1276
|
+
[finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
|
|
1277
|
+
{ cwd: metadata.worktreePath, timeout: finishTimeoutMs },
|
|
1235
1278
|
);
|
|
1236
1279
|
if (isSpawnFailure(finishResult)) {
|
|
1237
1280
|
return {
|
|
@@ -1491,7 +1534,18 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1491
1534
|
}
|
|
1492
1535
|
|
|
1493
1536
|
if (typeof nestedResult.status === 'number') {
|
|
1494
|
-
|
|
1537
|
+
let exitCode = nestedResult.status;
|
|
1538
|
+
if (exitCode === 0 && autoCommitResult.status === 'failed') {
|
|
1539
|
+
exitCode = 1;
|
|
1540
|
+
}
|
|
1541
|
+
if (
|
|
1542
|
+
exitCode === 0 &&
|
|
1543
|
+
autoCommitResult.status === 'committed' &&
|
|
1544
|
+
(finishResult.status === 'failed' || finishResult.status === 'pending')
|
|
1545
|
+
) {
|
|
1546
|
+
exitCode = 1;
|
|
1547
|
+
}
|
|
1548
|
+
process.exitCode = exitCode;
|
|
1495
1549
|
return;
|
|
1496
1550
|
}
|
|
1497
1551
|
process.exitCode = 1;
|
|
@@ -1530,6 +1584,69 @@ function parseReviewArgs(rawArgs) {
|
|
|
1530
1584
|
};
|
|
1531
1585
|
}
|
|
1532
1586
|
|
|
1587
|
+
function parseAgentsArgs(rawArgs) {
|
|
1588
|
+
const parsed = parseTargetFlag(rawArgs, process.cwd());
|
|
1589
|
+
const [subcommandRaw = '', ...rest] = parsed.args;
|
|
1590
|
+
const subcommand = subcommandRaw || 'status';
|
|
1591
|
+
const options = {
|
|
1592
|
+
target: parsed.target,
|
|
1593
|
+
subcommand,
|
|
1594
|
+
reviewIntervalSeconds: 30,
|
|
1595
|
+
cleanupIntervalSeconds: 60,
|
|
1596
|
+
idleMinutes: 10,
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
1600
|
+
const arg = rest[index];
|
|
1601
|
+
if (arg === '--review-interval') {
|
|
1602
|
+
const next = rest[index + 1];
|
|
1603
|
+
if (!next) {
|
|
1604
|
+
throw new Error('--review-interval requires an integer seconds value');
|
|
1605
|
+
}
|
|
1606
|
+
const parsedValue = Number.parseInt(next, 10);
|
|
1607
|
+
if (!Number.isInteger(parsedValue) || parsedValue < 5) {
|
|
1608
|
+
throw new Error('--review-interval must be an integer >= 5 seconds');
|
|
1609
|
+
}
|
|
1610
|
+
options.reviewIntervalSeconds = parsedValue;
|
|
1611
|
+
index += 1;
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
if (arg === '--cleanup-interval') {
|
|
1615
|
+
const next = rest[index + 1];
|
|
1616
|
+
if (!next) {
|
|
1617
|
+
throw new Error('--cleanup-interval requires an integer seconds value');
|
|
1618
|
+
}
|
|
1619
|
+
const parsedValue = Number.parseInt(next, 10);
|
|
1620
|
+
if (!Number.isInteger(parsedValue) || parsedValue < 5) {
|
|
1621
|
+
throw new Error('--cleanup-interval must be an integer >= 5 seconds');
|
|
1622
|
+
}
|
|
1623
|
+
options.cleanupIntervalSeconds = parsedValue;
|
|
1624
|
+
index += 1;
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
if (arg === '--idle-minutes') {
|
|
1628
|
+
const next = rest[index + 1];
|
|
1629
|
+
if (!next) {
|
|
1630
|
+
throw new Error('--idle-minutes requires an integer minutes value');
|
|
1631
|
+
}
|
|
1632
|
+
const parsedValue = Number.parseInt(next, 10);
|
|
1633
|
+
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
|
|
1634
|
+
throw new Error('--idle-minutes must be an integer >= 1');
|
|
1635
|
+
}
|
|
1636
|
+
options.idleMinutes = parsedValue;
|
|
1637
|
+
index += 1;
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
if (!['start', 'stop', 'status'].includes(options.subcommand)) {
|
|
1644
|
+
throw new Error(`Unknown agents subcommand: ${options.subcommand}`);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
return options;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1533
1650
|
function parseReportArgs(rawArgs) {
|
|
1534
1651
|
const options = {
|
|
1535
1652
|
target: process.cwd(),
|
|
@@ -1914,6 +2031,12 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
|
|
|
1914
2031
|
summary.details.push('Skipped auto-finish sweep (origin remote missing).');
|
|
1915
2032
|
return summary;
|
|
1916
2033
|
}
|
|
2034
|
+
const explicitGhBin = Boolean(String(process.env.MUSAFETY_GH_BIN || '').trim());
|
|
2035
|
+
if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
|
|
2036
|
+
summary.enabled = false;
|
|
2037
|
+
summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
|
|
2038
|
+
return summary;
|
|
2039
|
+
}
|
|
1917
2040
|
|
|
1918
2041
|
const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
|
|
1919
2042
|
if (run(ghBin, ['--version']).status !== 0) {
|
|
@@ -1973,6 +2096,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
|
|
|
1973
2096
|
'--base',
|
|
1974
2097
|
baseBranch,
|
|
1975
2098
|
'--via-pr',
|
|
2099
|
+
'--wait-for-merge',
|
|
1976
2100
|
'--cleanup',
|
|
1977
2101
|
];
|
|
1978
2102
|
const finishResult = run('bash', finishArgs, { cwd: repoRoot });
|
|
@@ -2232,6 +2356,10 @@ function parseCleanupArgs(rawArgs) {
|
|
|
2232
2356
|
forceDirty: false,
|
|
2233
2357
|
keepRemote: false,
|
|
2234
2358
|
keepCleanWorktrees: false,
|
|
2359
|
+
idleMinutes: 0,
|
|
2360
|
+
watch: false,
|
|
2361
|
+
intervalSeconds: 60,
|
|
2362
|
+
once: false,
|
|
2235
2363
|
};
|
|
2236
2364
|
|
|
2237
2365
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
@@ -2279,12 +2407,426 @@ function parseCleanupArgs(rawArgs) {
|
|
|
2279
2407
|
options.keepCleanWorktrees = true;
|
|
2280
2408
|
continue;
|
|
2281
2409
|
}
|
|
2410
|
+
if (arg === '--idle-minutes') {
|
|
2411
|
+
const next = rawArgs[index + 1];
|
|
2412
|
+
if (!next) {
|
|
2413
|
+
throw new Error('--idle-minutes requires an integer value');
|
|
2414
|
+
}
|
|
2415
|
+
const parsed = Number.parseInt(next, 10);
|
|
2416
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
2417
|
+
throw new Error('--idle-minutes must be an integer >= 0');
|
|
2418
|
+
}
|
|
2419
|
+
options.idleMinutes = parsed;
|
|
2420
|
+
index += 1;
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
if (arg === '--watch') {
|
|
2424
|
+
options.watch = true;
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
if (arg === '--interval') {
|
|
2428
|
+
const next = rawArgs[index + 1];
|
|
2429
|
+
if (!next) {
|
|
2430
|
+
throw new Error('--interval requires an integer seconds value');
|
|
2431
|
+
}
|
|
2432
|
+
const parsed = Number.parseInt(next, 10);
|
|
2433
|
+
if (!Number.isInteger(parsed) || parsed < 5) {
|
|
2434
|
+
throw new Error('--interval must be an integer >= 5 seconds');
|
|
2435
|
+
}
|
|
2436
|
+
options.intervalSeconds = parsed;
|
|
2437
|
+
index += 1;
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
if (arg === '--once') {
|
|
2441
|
+
options.once = true;
|
|
2442
|
+
continue;
|
|
2443
|
+
}
|
|
2282
2444
|
throw new Error(`Unknown option: ${arg}`);
|
|
2283
2445
|
}
|
|
2284
2446
|
|
|
2447
|
+
if (options.watch && options.idleMinutes === 0) {
|
|
2448
|
+
options.idleMinutes = 10;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2285
2451
|
return options;
|
|
2286
2452
|
}
|
|
2287
2453
|
|
|
2454
|
+
function parseFinishArgs(rawArgs) {
|
|
2455
|
+
const options = {
|
|
2456
|
+
target: process.cwd(),
|
|
2457
|
+
base: '',
|
|
2458
|
+
branch: '',
|
|
2459
|
+
all: false,
|
|
2460
|
+
dryRun: false,
|
|
2461
|
+
waitForMerge: true,
|
|
2462
|
+
cleanup: true,
|
|
2463
|
+
keepRemote: false,
|
|
2464
|
+
noAutoCommit: false,
|
|
2465
|
+
failFast: false,
|
|
2466
|
+
commitMessage: '',
|
|
2467
|
+
};
|
|
2468
|
+
|
|
2469
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
2470
|
+
const arg = rawArgs[index];
|
|
2471
|
+
if (arg === '--target') {
|
|
2472
|
+
const next = rawArgs[index + 1];
|
|
2473
|
+
if (!next) {
|
|
2474
|
+
throw new Error('--target requires a path value');
|
|
2475
|
+
}
|
|
2476
|
+
options.target = next;
|
|
2477
|
+
index += 1;
|
|
2478
|
+
continue;
|
|
2479
|
+
}
|
|
2480
|
+
if (arg === '--base') {
|
|
2481
|
+
const next = rawArgs[index + 1];
|
|
2482
|
+
if (!next) {
|
|
2483
|
+
throw new Error('--base requires a branch value');
|
|
2484
|
+
}
|
|
2485
|
+
options.base = next;
|
|
2486
|
+
index += 1;
|
|
2487
|
+
continue;
|
|
2488
|
+
}
|
|
2489
|
+
if (arg === '--branch') {
|
|
2490
|
+
const next = rawArgs[index + 1];
|
|
2491
|
+
if (!next) {
|
|
2492
|
+
throw new Error('--branch requires an agent/* branch value');
|
|
2493
|
+
}
|
|
2494
|
+
options.branch = next;
|
|
2495
|
+
index += 1;
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
2498
|
+
if (arg === '--commit-message') {
|
|
2499
|
+
const next = rawArgs[index + 1];
|
|
2500
|
+
if (!next) {
|
|
2501
|
+
throw new Error('--commit-message requires a value');
|
|
2502
|
+
}
|
|
2503
|
+
options.commitMessage = next;
|
|
2504
|
+
index += 1;
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
if (arg === '--all') {
|
|
2508
|
+
options.all = true;
|
|
2509
|
+
continue;
|
|
2510
|
+
}
|
|
2511
|
+
if (arg === '--dry-run') {
|
|
2512
|
+
options.dryRun = true;
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
if (arg === '--wait-for-merge') {
|
|
2516
|
+
options.waitForMerge = true;
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
if (arg === '--no-wait-for-merge') {
|
|
2520
|
+
options.waitForMerge = false;
|
|
2521
|
+
continue;
|
|
2522
|
+
}
|
|
2523
|
+
if (arg === '--cleanup') {
|
|
2524
|
+
options.cleanup = true;
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
if (arg === '--no-cleanup') {
|
|
2528
|
+
options.cleanup = false;
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
if (arg === '--keep-remote') {
|
|
2532
|
+
options.keepRemote = true;
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
if (arg === '--no-auto-commit') {
|
|
2536
|
+
options.noAutoCommit = true;
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
if (arg === '--fail-fast') {
|
|
2540
|
+
options.failFast = true;
|
|
2541
|
+
continue;
|
|
2542
|
+
}
|
|
2543
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
if (options.branch && !options.branch.startsWith('agent/')) {
|
|
2547
|
+
throw new Error(`--branch must reference an agent/* branch (received: ${options.branch})`);
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
return options;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function listAgentWorktrees(repoRoot) {
|
|
2554
|
+
const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
|
|
2555
|
+
if (result.status !== 0) {
|
|
2556
|
+
throw new Error('Unable to list git worktrees for finish command');
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
const entries = [];
|
|
2560
|
+
let currentPath = '';
|
|
2561
|
+
let currentBranchRef = '';
|
|
2562
|
+
const lines = String(result.stdout || '').split('\n');
|
|
2563
|
+
for (const line of lines) {
|
|
2564
|
+
if (!line.trim()) {
|
|
2565
|
+
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
2566
|
+
entries.push({
|
|
2567
|
+
worktreePath: currentPath,
|
|
2568
|
+
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
currentPath = '';
|
|
2572
|
+
currentBranchRef = '';
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
if (line.startsWith('worktree ')) {
|
|
2576
|
+
currentPath = line.slice('worktree '.length).trim();
|
|
2577
|
+
continue;
|
|
2578
|
+
}
|
|
2579
|
+
if (line.startsWith('branch ')) {
|
|
2580
|
+
currentBranchRef = line.slice('branch '.length).trim();
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
if (currentPath && currentBranchRef.startsWith('refs/heads/agent/')) {
|
|
2585
|
+
entries.push({
|
|
2586
|
+
worktreePath: currentPath,
|
|
2587
|
+
branch: currentBranchRef.replace(/^refs\/heads\//, ''),
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
return entries;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
function listLocalAgentBranchesForFinish(repoRoot) {
|
|
2595
|
+
const result = gitRun(
|
|
2596
|
+
repoRoot,
|
|
2597
|
+
['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent/'],
|
|
2598
|
+
{ allowFailure: true },
|
|
2599
|
+
);
|
|
2600
|
+
if (result.status !== 0) {
|
|
2601
|
+
throw new Error('Unable to list local agent branches');
|
|
2602
|
+
}
|
|
2603
|
+
return uniquePreserveOrder(
|
|
2604
|
+
String(result.stdout || '')
|
|
2605
|
+
.split('\n')
|
|
2606
|
+
.map((line) => line.trim())
|
|
2607
|
+
.filter((line) => line.startsWith('agent/')),
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function gitQuietChangeResult(worktreePath, args) {
|
|
2612
|
+
const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
|
|
2613
|
+
if (result.status === 0) {
|
|
2614
|
+
return false;
|
|
2615
|
+
}
|
|
2616
|
+
if (result.status === 1) {
|
|
2617
|
+
return true;
|
|
2618
|
+
}
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`git ${args.join(' ')} failed in ${worktreePath}: ${(
|
|
2621
|
+
result.stderr || result.stdout || ''
|
|
2622
|
+
).trim()}`,
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function worktreeHasLocalChanges(worktreePath) {
|
|
2627
|
+
const hasUnstaged = gitQuietChangeResult(worktreePath, [
|
|
2628
|
+
'diff',
|
|
2629
|
+
'--quiet',
|
|
2630
|
+
'--',
|
|
2631
|
+
'.',
|
|
2632
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2633
|
+
]);
|
|
2634
|
+
if (hasUnstaged) {
|
|
2635
|
+
return true;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
const hasStaged = gitQuietChangeResult(worktreePath, [
|
|
2639
|
+
'diff',
|
|
2640
|
+
'--cached',
|
|
2641
|
+
'--quiet',
|
|
2642
|
+
'--',
|
|
2643
|
+
'.',
|
|
2644
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2645
|
+
]);
|
|
2646
|
+
if (hasStaged) {
|
|
2647
|
+
return true;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
const untracked = run('git', ['-C', worktreePath, 'ls-files', '--others', '--exclude-standard'], {
|
|
2651
|
+
stdio: 'pipe',
|
|
2652
|
+
});
|
|
2653
|
+
if (untracked.status !== 0) {
|
|
2654
|
+
throw new Error(`Unable to inspect untracked files in ${worktreePath}`);
|
|
2655
|
+
}
|
|
2656
|
+
return String(untracked.stdout || '').trim().length > 0;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
function gitOutputLines(worktreePath, args) {
|
|
2660
|
+
const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
|
|
2661
|
+
if (result.status !== 0) {
|
|
2662
|
+
throw new Error(
|
|
2663
|
+
`git ${args.join(' ')} failed in ${worktreePath}: ${(
|
|
2664
|
+
result.stderr || result.stdout || ''
|
|
2665
|
+
).trim()}`,
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
return String(result.stdout || '')
|
|
2669
|
+
.split('\n')
|
|
2670
|
+
.map((line) => line.trim())
|
|
2671
|
+
.filter(Boolean);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
|
|
2675
|
+
const lockScript = path.join(repoRoot, 'scripts', 'agent-file-locks.py');
|
|
2676
|
+
if (!fs.existsSync(lockScript)) {
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
const changedFiles = uniquePreserveOrder([
|
|
2681
|
+
...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
|
|
2682
|
+
...gitOutputLines(worktreePath, ['diff', '--cached', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
|
|
2683
|
+
...gitOutputLines(worktreePath, ['ls-files', '--others', '--exclude-standard']),
|
|
2684
|
+
]);
|
|
2685
|
+
|
|
2686
|
+
if (changedFiles.length > 0) {
|
|
2687
|
+
const claim = run('python3', [lockScript, 'claim', '--branch', branch, ...changedFiles], {
|
|
2688
|
+
cwd: repoRoot,
|
|
2689
|
+
stdio: 'pipe',
|
|
2690
|
+
});
|
|
2691
|
+
if (claim.status !== 0) {
|
|
2692
|
+
throw new Error(
|
|
2693
|
+
`Lock claim failed for ${branch}: ${(
|
|
2694
|
+
claim.stderr || claim.stdout || ''
|
|
2695
|
+
).trim()}`,
|
|
2696
|
+
);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
const deletedFiles = uniquePreserveOrder([
|
|
2701
|
+
...gitOutputLines(worktreePath, [
|
|
2702
|
+
'diff',
|
|
2703
|
+
'--name-only',
|
|
2704
|
+
'--diff-filter=D',
|
|
2705
|
+
'--',
|
|
2706
|
+
'.',
|
|
2707
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2708
|
+
]),
|
|
2709
|
+
...gitOutputLines(worktreePath, [
|
|
2710
|
+
'diff',
|
|
2711
|
+
'--cached',
|
|
2712
|
+
'--name-only',
|
|
2713
|
+
'--diff-filter=D',
|
|
2714
|
+
'--',
|
|
2715
|
+
'.',
|
|
2716
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2717
|
+
]),
|
|
2718
|
+
]);
|
|
2719
|
+
|
|
2720
|
+
if (deletedFiles.length > 0) {
|
|
2721
|
+
const allowDelete = run('python3', [lockScript, 'allow-delete', '--branch', branch, ...deletedFiles], {
|
|
2722
|
+
cwd: repoRoot,
|
|
2723
|
+
stdio: 'pipe',
|
|
2724
|
+
});
|
|
2725
|
+
if (allowDelete.status !== 0) {
|
|
2726
|
+
throw new Error(
|
|
2727
|
+
`Delete-lock grant failed for ${branch}: ${(
|
|
2728
|
+
allowDelete.stderr || allowDelete.stdout || ''
|
|
2729
|
+
).trim()}`,
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
function branchExists(repoRoot, branch) {
|
|
2736
|
+
const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
2737
|
+
allowFailure: true,
|
|
2738
|
+
});
|
|
2739
|
+
return result.status === 0;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) {
|
|
2743
|
+
if (explicitBase) {
|
|
2744
|
+
return explicitBase;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
const branchSpecific = readGitConfig(repoRoot, `branch.${sourceBranch}.musafetyBase`);
|
|
2748
|
+
if (branchSpecific) {
|
|
2749
|
+
return branchSpecific;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
|
|
2753
|
+
if (configured) {
|
|
2754
|
+
return configured;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
const current = gitRun(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFailure: true });
|
|
2758
|
+
const currentBranch = String(current.stdout || '').trim();
|
|
2759
|
+
if (current.status === 0 && currentBranch && currentBranch !== 'HEAD' && !currentBranch.startsWith('agent/')) {
|
|
2760
|
+
return currentBranch;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
return DEFAULT_BASE_BRANCH;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
function branchMergedIntoBase(repoRoot, branch, baseBranch) {
|
|
2767
|
+
if (!branchExists(repoRoot, baseBranch)) {
|
|
2768
|
+
return false;
|
|
2769
|
+
}
|
|
2770
|
+
const result = gitRun(repoRoot, ['merge-base', '--is-ancestor', branch, baseBranch], {
|
|
2771
|
+
allowFailure: true,
|
|
2772
|
+
});
|
|
2773
|
+
if (result.status === 0) {
|
|
2774
|
+
return true;
|
|
2775
|
+
}
|
|
2776
|
+
if (result.status === 1) {
|
|
2777
|
+
return false;
|
|
2778
|
+
}
|
|
2779
|
+
throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
|
|
2783
|
+
const hasChanges = worktreeHasLocalChanges(worktreePath);
|
|
2784
|
+
if (!hasChanges) {
|
|
2785
|
+
return { changed: false, committed: false };
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
if (options.noAutoCommit) {
|
|
2789
|
+
throw new Error(
|
|
2790
|
+
`Branch '${branch}' has local changes in ${worktreePath}. Re-run without --no-auto-commit or commit manually first.`,
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
if (options.dryRun) {
|
|
2795
|
+
return { changed: true, committed: false, dryRun: true };
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
claimLocksForAutoCommit(repoRoot, worktreePath, branch);
|
|
2799
|
+
|
|
2800
|
+
const addResult = run('git', ['-C', worktreePath, 'add', '-A'], { stdio: 'pipe' });
|
|
2801
|
+
if (addResult.status !== 0) {
|
|
2802
|
+
throw new Error(`git add failed in ${worktreePath}: ${(addResult.stderr || addResult.stdout || '').trim()}`);
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
const stagedHasChanges = gitQuietChangeResult(worktreePath, [
|
|
2806
|
+
'diff',
|
|
2807
|
+
'--cached',
|
|
2808
|
+
'--quiet',
|
|
2809
|
+
'--',
|
|
2810
|
+
'.',
|
|
2811
|
+
':(exclude).omx/state/agent-file-locks.json',
|
|
2812
|
+
]);
|
|
2813
|
+
if (!stagedHasChanges) {
|
|
2814
|
+
return { changed: true, committed: false };
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const commitMessage = options.commitMessage || `Auto-finish: ${branch}`;
|
|
2818
|
+
const commitResult = run('git', ['-C', worktreePath, 'commit', '-m', commitMessage], { stdio: 'pipe' });
|
|
2819
|
+
if (commitResult.status !== 0) {
|
|
2820
|
+
throw new Error(
|
|
2821
|
+
`Auto-commit failed on '${branch}': ${(
|
|
2822
|
+
commitResult.stderr || commitResult.stdout || ''
|
|
2823
|
+
).trim()}`,
|
|
2824
|
+
);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
return { changed: true, committed: true, message: commitMessage };
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2288
2830
|
function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
|
|
2289
2831
|
if (strategy === 'rebase') {
|
|
2290
2832
|
if (ffOnly) {
|
|
@@ -3065,6 +3607,9 @@ function install(rawArgs) {
|
|
|
3065
3607
|
printOperations('Install target', payload, options.dryRun);
|
|
3066
3608
|
|
|
3067
3609
|
if (!options.dryRun) {
|
|
3610
|
+
if (!options.skipAgents) {
|
|
3611
|
+
console.log(`[${TOOL_NAME}] AGENTS.md managed policy block is configured by install.`);
|
|
3612
|
+
}
|
|
3068
3613
|
console.log(`[${TOOL_NAME}] Installed. Next step: ${TOOL_NAME} setup`);
|
|
3069
3614
|
}
|
|
3070
3615
|
|
|
@@ -3204,6 +3749,263 @@ function review(rawArgs) {
|
|
|
3204
3749
|
process.exitCode = typeof result.status === 'number' ? result.status : 1;
|
|
3205
3750
|
}
|
|
3206
3751
|
|
|
3752
|
+
function agentsStatePathForRepo(repoRoot) {
|
|
3753
|
+
return path.join(repoRoot, AGENTS_BOTS_STATE_RELATIVE);
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
function readAgentsState(repoRoot) {
|
|
3757
|
+
const statePath = agentsStatePathForRepo(repoRoot);
|
|
3758
|
+
if (!fs.existsSync(statePath)) {
|
|
3759
|
+
return null;
|
|
3760
|
+
}
|
|
3761
|
+
try {
|
|
3762
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
3763
|
+
} catch (_error) {
|
|
3764
|
+
return null;
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
function writeAgentsState(repoRoot, state) {
|
|
3769
|
+
const statePath = agentsStatePathForRepo(repoRoot);
|
|
3770
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
3771
|
+
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
function processAlive(pid) {
|
|
3775
|
+
const normalizedPid = Number.parseInt(String(pid || ''), 10);
|
|
3776
|
+
if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
|
|
3777
|
+
return false;
|
|
3778
|
+
}
|
|
3779
|
+
try {
|
|
3780
|
+
process.kill(normalizedPid, 0);
|
|
3781
|
+
return true;
|
|
3782
|
+
} catch (_error) {
|
|
3783
|
+
return false;
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
function sleepSeconds(seconds) {
|
|
3788
|
+
const result = run('sleep', [String(seconds)]);
|
|
3789
|
+
if (isSpawnFailure(result) || result.status !== 0) {
|
|
3790
|
+
throw new Error(`sleep command failed for ${seconds}s`);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
function readProcessCommand(pid) {
|
|
3795
|
+
const result = run('ps', ['-o', 'command=', '-p', String(pid)]);
|
|
3796
|
+
if (isSpawnFailure(result) || result.status !== 0) {
|
|
3797
|
+
return '';
|
|
3798
|
+
}
|
|
3799
|
+
return String(result.stdout || '').trim();
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
function stopAgentProcessByPid(pid, expectedToken = '') {
|
|
3803
|
+
const normalizedPid = Number.parseInt(String(pid || ''), 10);
|
|
3804
|
+
if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
|
|
3805
|
+
return { status: 'invalid', pid: normalizedPid };
|
|
3806
|
+
}
|
|
3807
|
+
if (!processAlive(normalizedPid)) {
|
|
3808
|
+
return { status: 'not-running', pid: normalizedPid };
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
if (expectedToken) {
|
|
3812
|
+
const cmdline = readProcessCommand(normalizedPid);
|
|
3813
|
+
if (cmdline && !cmdline.includes(expectedToken)) {
|
|
3814
|
+
return { status: 'mismatch', pid: normalizedPid, command: cmdline };
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
try {
|
|
3819
|
+
process.kill(-normalizedPid, 'SIGTERM');
|
|
3820
|
+
} catch (_error) {
|
|
3821
|
+
try {
|
|
3822
|
+
process.kill(normalizedPid, 'SIGTERM');
|
|
3823
|
+
} catch (_err) {
|
|
3824
|
+
return { status: 'term-failed', pid: normalizedPid };
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
const deadline = Date.now() + 3_000;
|
|
3829
|
+
while (Date.now() < deadline) {
|
|
3830
|
+
if (!processAlive(normalizedPid)) {
|
|
3831
|
+
return { status: 'stopped', pid: normalizedPid };
|
|
3832
|
+
}
|
|
3833
|
+
sleepSeconds(0.1);
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
try {
|
|
3837
|
+
process.kill(-normalizedPid, 'SIGKILL');
|
|
3838
|
+
} catch (_error) {
|
|
3839
|
+
try {
|
|
3840
|
+
process.kill(normalizedPid, 'SIGKILL');
|
|
3841
|
+
} catch (_err) {
|
|
3842
|
+
return { status: 'kill-failed', pid: normalizedPid };
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
sleepSeconds(0.1);
|
|
3846
|
+
|
|
3847
|
+
return {
|
|
3848
|
+
status: processAlive(normalizedPid) ? 'kill-failed' : 'stopped',
|
|
3849
|
+
pid: normalizedPid,
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
function spawnDetachedAgentProcess({ command, args, cwd, logPath }) {
|
|
3854
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
3855
|
+
const logHandle = fs.openSync(logPath, 'a');
|
|
3856
|
+
fs.writeSync(
|
|
3857
|
+
logHandle,
|
|
3858
|
+
`[${new Date().toISOString()}] spawn: ${command} ${args.join(' ')}\n`,
|
|
3859
|
+
);
|
|
3860
|
+
const child = cp.spawn(command, args, {
|
|
3861
|
+
cwd,
|
|
3862
|
+
detached: true,
|
|
3863
|
+
stdio: ['ignore', logHandle, logHandle],
|
|
3864
|
+
env: process.env,
|
|
3865
|
+
});
|
|
3866
|
+
fs.closeSync(logHandle);
|
|
3867
|
+
if (child.error) {
|
|
3868
|
+
throw child.error;
|
|
3869
|
+
}
|
|
3870
|
+
child.unref();
|
|
3871
|
+
const pid = Number.parseInt(String(child.pid || ''), 10);
|
|
3872
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
3873
|
+
throw new Error(`Failed to spawn detached process for ${command}`);
|
|
3874
|
+
}
|
|
3875
|
+
return pid;
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
function agents(rawArgs) {
|
|
3879
|
+
const options = parseAgentsArgs(rawArgs);
|
|
3880
|
+
const repoRoot = resolveRepoRoot(options.target);
|
|
3881
|
+
const reviewScriptPath = path.join(repoRoot, 'scripts', 'review-bot-watch.sh');
|
|
3882
|
+
const pruneScriptPath = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh');
|
|
3883
|
+
const statePath = agentsStatePathForRepo(repoRoot);
|
|
3884
|
+
|
|
3885
|
+
if (options.subcommand === 'start') {
|
|
3886
|
+
if (!fs.existsSync(reviewScriptPath)) {
|
|
3887
|
+
throw new Error(
|
|
3888
|
+
`Missing review bot script: ${reviewScriptPath}\n` +
|
|
3889
|
+
`Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
|
|
3890
|
+
);
|
|
3891
|
+
}
|
|
3892
|
+
if (!fs.existsSync(pruneScriptPath)) {
|
|
3893
|
+
throw new Error(
|
|
3894
|
+
`Missing cleanup script: ${pruneScriptPath}\n` +
|
|
3895
|
+
`Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
|
|
3896
|
+
);
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
const existingState = readAgentsState(repoRoot);
|
|
3900
|
+
const existingReviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
|
|
3901
|
+
const existingCleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
|
|
3902
|
+
const reviewRunning = processAlive(existingReviewPid);
|
|
3903
|
+
const cleanupRunning = processAlive(existingCleanupPid);
|
|
3904
|
+
|
|
3905
|
+
if (reviewRunning && cleanupRunning) {
|
|
3906
|
+
console.log(
|
|
3907
|
+
`[${TOOL_NAME}] Repo agents already running (review pid=${existingReviewPid}, cleanup pid=${existingCleanupPid}).`,
|
|
3908
|
+
);
|
|
3909
|
+
process.exitCode = 0;
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
if (reviewRunning) {
|
|
3914
|
+
stopAgentProcessByPid(existingReviewPid, 'review-bot-watch.sh');
|
|
3915
|
+
}
|
|
3916
|
+
if (cleanupRunning) {
|
|
3917
|
+
stopAgentProcessByPid(existingCleanupPid, `${path.basename(__filename)} cleanup`);
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
const reviewLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-review.log');
|
|
3921
|
+
const cleanupLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-cleanup.log');
|
|
3922
|
+
const reviewPid = spawnDetachedAgentProcess({
|
|
3923
|
+
command: 'bash',
|
|
3924
|
+
args: [reviewScriptPath, '--interval', String(options.reviewIntervalSeconds)],
|
|
3925
|
+
cwd: repoRoot,
|
|
3926
|
+
logPath: reviewLogPath,
|
|
3927
|
+
});
|
|
3928
|
+
const cleanupPid = spawnDetachedAgentProcess({
|
|
3929
|
+
command: process.execPath,
|
|
3930
|
+
args: [
|
|
3931
|
+
path.resolve(__filename),
|
|
3932
|
+
'cleanup',
|
|
3933
|
+
'--target',
|
|
3934
|
+
repoRoot,
|
|
3935
|
+
'--watch',
|
|
3936
|
+
'--interval',
|
|
3937
|
+
String(options.cleanupIntervalSeconds),
|
|
3938
|
+
'--idle-minutes',
|
|
3939
|
+
String(options.idleMinutes),
|
|
3940
|
+
],
|
|
3941
|
+
cwd: repoRoot,
|
|
3942
|
+
logPath: cleanupLogPath,
|
|
3943
|
+
});
|
|
3944
|
+
|
|
3945
|
+
writeAgentsState(repoRoot, {
|
|
3946
|
+
schemaVersion: 1,
|
|
3947
|
+
repoRoot,
|
|
3948
|
+
startedAt: new Date().toISOString(),
|
|
3949
|
+
review: {
|
|
3950
|
+
pid: reviewPid,
|
|
3951
|
+
intervalSeconds: options.reviewIntervalSeconds,
|
|
3952
|
+
script: reviewScriptPath,
|
|
3953
|
+
logPath: reviewLogPath,
|
|
3954
|
+
},
|
|
3955
|
+
cleanup: {
|
|
3956
|
+
pid: cleanupPid,
|
|
3957
|
+
intervalSeconds: options.cleanupIntervalSeconds,
|
|
3958
|
+
idleMinutes: options.idleMinutes,
|
|
3959
|
+
script: path.resolve(__filename),
|
|
3960
|
+
logPath: cleanupLogPath,
|
|
3961
|
+
},
|
|
3962
|
+
});
|
|
3963
|
+
|
|
3964
|
+
console.log(
|
|
3965
|
+
`[${TOOL_NAME}] Started repo agents in ${repoRoot} (review pid=${reviewPid}, cleanup pid=${cleanupPid}).`,
|
|
3966
|
+
);
|
|
3967
|
+
console.log(`[${TOOL_NAME}] Logs: ${reviewLogPath}, ${cleanupLogPath}`);
|
|
3968
|
+
process.exitCode = 0;
|
|
3969
|
+
return;
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
if (options.subcommand === 'stop') {
|
|
3973
|
+
const existingState = readAgentsState(repoRoot);
|
|
3974
|
+
if (!existingState) {
|
|
3975
|
+
console.log(`[${TOOL_NAME}] Repo agents are not running for ${repoRoot}.`);
|
|
3976
|
+
process.exitCode = 0;
|
|
3977
|
+
return;
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
const reviewStop = stopAgentProcessByPid(existingState?.review?.pid, 'review-bot-watch.sh');
|
|
3981
|
+
const cleanupStop = stopAgentProcessByPid(existingState?.cleanup?.pid, `${path.basename(__filename)} cleanup`);
|
|
3982
|
+
|
|
3983
|
+
if (fs.existsSync(statePath)) {
|
|
3984
|
+
fs.unlinkSync(statePath);
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
console.log(
|
|
3988
|
+
`[${TOOL_NAME}] Stopped repo agents in ${repoRoot} (review=${reviewStop.status}, cleanup=${cleanupStop.status}).`,
|
|
3989
|
+
);
|
|
3990
|
+
process.exitCode = 0;
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
const existingState = readAgentsState(repoRoot);
|
|
3995
|
+
if (!existingState) {
|
|
3996
|
+
console.log(`[${TOOL_NAME}] Repo agents status: inactive (${repoRoot})`);
|
|
3997
|
+
process.exitCode = 0;
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
const reviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
|
|
4002
|
+
const cleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
|
|
4003
|
+
console.log(
|
|
4004
|
+
`[${TOOL_NAME}] Repo agents status: review=${processAlive(reviewPid) ? 'running' : 'stopped'}(pid=${reviewPid || 0}), cleanup=${processAlive(cleanupPid) ? 'running' : 'stopped'}(pid=${cleanupPid || 0})`,
|
|
4005
|
+
);
|
|
4006
|
+
process.exitCode = 0;
|
|
4007
|
+
}
|
|
4008
|
+
|
|
3207
4009
|
function report(rawArgs) {
|
|
3208
4010
|
const options = parseReportArgs(rawArgs);
|
|
3209
4011
|
const subcommand = options.subcommand || 'help';
|
|
@@ -3382,6 +4184,13 @@ function setup(rawArgs) {
|
|
|
3382
4184
|
if (scanResult.errors === 0 && scanResult.warnings === 0) {
|
|
3383
4185
|
console.log(`[${TOOL_NAME}] ✅ Setup complete.`);
|
|
3384
4186
|
console.log(`[${TOOL_NAME}] Copy AI setup prompt with: ${SHORT_TOOL_NAME} copy-prompt`);
|
|
4187
|
+
console.log(
|
|
4188
|
+
`[${TOOL_NAME}] OpenSpec core workflow: /opsx:propose -> /opsx:apply -> /opsx:archive`,
|
|
4189
|
+
);
|
|
4190
|
+
console.log(
|
|
4191
|
+
`[${TOOL_NAME}] Optional expanded OpenSpec profile: openspec config profile <profile-name> && openspec update`,
|
|
4192
|
+
);
|
|
4193
|
+
console.log(`[${TOOL_NAME}] OpenSpec guide: docs/openspec-getting-started.md`);
|
|
3385
4194
|
}
|
|
3386
4195
|
|
|
3387
4196
|
setExitCodeFromScan(scanResult);
|
|
@@ -3475,15 +4284,165 @@ function cleanup(rawArgs) {
|
|
|
3475
4284
|
if (!options.keepCleanWorktrees) {
|
|
3476
4285
|
args.push('--only-dirty-worktrees');
|
|
3477
4286
|
}
|
|
4287
|
+
if (options.idleMinutes > 0) {
|
|
4288
|
+
args.push('--idle-minutes', String(options.idleMinutes));
|
|
4289
|
+
}
|
|
3478
4290
|
args.push('--delete-branches');
|
|
3479
4291
|
if (!options.keepRemote) {
|
|
3480
4292
|
args.push('--delete-remote-branches');
|
|
3481
4293
|
}
|
|
3482
4294
|
|
|
3483
|
-
const
|
|
3484
|
-
|
|
3485
|
-
|
|
4295
|
+
const runCleanupCycle = () => {
|
|
4296
|
+
const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
|
|
4297
|
+
if (runResult.status !== 0) {
|
|
4298
|
+
throw new Error('Cleanup command failed');
|
|
4299
|
+
}
|
|
4300
|
+
};
|
|
4301
|
+
|
|
4302
|
+
if (options.watch) {
|
|
4303
|
+
let cycle = 0;
|
|
4304
|
+
while (true) {
|
|
4305
|
+
cycle += 1;
|
|
4306
|
+
console.log(
|
|
4307
|
+
`[${TOOL_NAME}] Cleanup watch cycle=${cycle} (interval=${options.intervalSeconds}s, idleMinutes=${options.idleMinutes}).`,
|
|
4308
|
+
);
|
|
4309
|
+
runCleanupCycle();
|
|
4310
|
+
if (options.once) {
|
|
4311
|
+
break;
|
|
4312
|
+
}
|
|
4313
|
+
const sleepResult = run('sleep', [String(options.intervalSeconds)], { cwd: repoRoot });
|
|
4314
|
+
if (sleepResult.status !== 0) {
|
|
4315
|
+
throw new Error(`Cleanup watch sleep failed (interval=${options.intervalSeconds}s)`);
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
process.exitCode = 0;
|
|
4319
|
+
return;
|
|
3486
4320
|
}
|
|
4321
|
+
|
|
4322
|
+
runCleanupCycle();
|
|
4323
|
+
process.exitCode = 0;
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
function finish(rawArgs) {
|
|
4327
|
+
const options = parseFinishArgs(rawArgs);
|
|
4328
|
+
const repoRoot = resolveRepoRoot(options.target);
|
|
4329
|
+
const finishScript = path.join(repoRoot, 'scripts', 'agent-branch-finish.sh');
|
|
4330
|
+
|
|
4331
|
+
if (!fs.existsSync(finishScript)) {
|
|
4332
|
+
throw new Error(`Missing finish script: ${finishScript}. Run '${SHORT_TOOL_NAME} setup' first.`);
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
const worktreeEntries = listAgentWorktrees(repoRoot);
|
|
4336
|
+
const worktreeByBranch = new Map(worktreeEntries.map((entry) => [entry.branch, entry.worktreePath]));
|
|
4337
|
+
|
|
4338
|
+
let candidateBranches = [];
|
|
4339
|
+
if (options.branch) {
|
|
4340
|
+
if (!branchExists(repoRoot, options.branch)) {
|
|
4341
|
+
throw new Error(`Local branch not found: ${options.branch}`);
|
|
4342
|
+
}
|
|
4343
|
+
candidateBranches = [options.branch];
|
|
4344
|
+
} else {
|
|
4345
|
+
candidateBranches = uniquePreserveOrder([
|
|
4346
|
+
...listLocalAgentBranchesForFinish(repoRoot),
|
|
4347
|
+
...worktreeEntries.map((entry) => entry.branch),
|
|
4348
|
+
]);
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
const candidates = [];
|
|
4352
|
+
for (const branch of candidateBranches) {
|
|
4353
|
+
const worktreePath = worktreeByBranch.get(branch) || '';
|
|
4354
|
+
const baseBranch = resolveFinishBaseBranch(repoRoot, branch, options.base);
|
|
4355
|
+
const hasChanges = worktreePath ? worktreeHasLocalChanges(worktreePath) : false;
|
|
4356
|
+
const alreadyMerged = branchMergedIntoBase(repoRoot, branch, baseBranch);
|
|
4357
|
+
if (options.all || options.branch || hasChanges || !alreadyMerged) {
|
|
4358
|
+
candidates.push({
|
|
4359
|
+
branch,
|
|
4360
|
+
baseBranch,
|
|
4361
|
+
worktreePath,
|
|
4362
|
+
hasChanges,
|
|
4363
|
+
alreadyMerged,
|
|
4364
|
+
});
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
if (candidates.length === 0) {
|
|
4369
|
+
console.log(`[${TOOL_NAME}] No pending agent branches to finish.`);
|
|
4370
|
+
process.exitCode = 0;
|
|
4371
|
+
return;
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
let succeeded = 0;
|
|
4375
|
+
let failed = 0;
|
|
4376
|
+
let autoCommitted = 0;
|
|
4377
|
+
|
|
4378
|
+
for (const candidate of candidates) {
|
|
4379
|
+
const { branch, baseBranch, worktreePath } = candidate;
|
|
4380
|
+
console.log(
|
|
4381
|
+
`[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
|
|
4382
|
+
);
|
|
4383
|
+
|
|
4384
|
+
try {
|
|
4385
|
+
let commitState = { changed: false, committed: false };
|
|
4386
|
+
if (worktreePath) {
|
|
4387
|
+
commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
if (commitState.committed) {
|
|
4391
|
+
autoCommitted += 1;
|
|
4392
|
+
console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
|
|
4393
|
+
} else if (commitState.changed && commitState.dryRun) {
|
|
4394
|
+
console.log(`[${TOOL_NAME}] [dry-run] Would auto-commit pending changes on '${branch}'.`);
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4397
|
+
const finishArgs = [
|
|
4398
|
+
finishScript,
|
|
4399
|
+
'--branch',
|
|
4400
|
+
branch,
|
|
4401
|
+
'--base',
|
|
4402
|
+
baseBranch,
|
|
4403
|
+
'--via-pr',
|
|
4404
|
+
options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
|
|
4405
|
+
options.cleanup ? '--cleanup' : '--no-cleanup',
|
|
4406
|
+
];
|
|
4407
|
+
if (options.keepRemote) {
|
|
4408
|
+
finishArgs.push('--keep-remote-branch');
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
if (options.dryRun) {
|
|
4412
|
+
console.log(`[${TOOL_NAME}] [dry-run] Would run: bash ${finishArgs.join(' ')}`);
|
|
4413
|
+
succeeded += 1;
|
|
4414
|
+
continue;
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4417
|
+
const finishResult = run('bash', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
|
|
4418
|
+
if (finishResult.stdout) {
|
|
4419
|
+
process.stdout.write(finishResult.stdout);
|
|
4420
|
+
}
|
|
4421
|
+
if (finishResult.stderr) {
|
|
4422
|
+
process.stderr.write(finishResult.stderr);
|
|
4423
|
+
}
|
|
4424
|
+
if (finishResult.status !== 0) {
|
|
4425
|
+
throw new Error(`agent-branch-finish exited with status ${finishResult.status}`);
|
|
4426
|
+
}
|
|
4427
|
+
|
|
4428
|
+
succeeded += 1;
|
|
4429
|
+
} catch (error) {
|
|
4430
|
+
failed += 1;
|
|
4431
|
+
console.error(`[${TOOL_NAME}] Finish failed for '${branch}': ${error.message}`);
|
|
4432
|
+
if (options.failFast) {
|
|
4433
|
+
break;
|
|
4434
|
+
}
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
console.log(
|
|
4439
|
+
`[${TOOL_NAME}] Finish summary: total=${candidates.length}, success=${succeeded}, failed=${failed}, autoCommitted=${autoCommitted}`,
|
|
4440
|
+
);
|
|
4441
|
+
|
|
4442
|
+
if (failed > 0) {
|
|
4443
|
+
throw new Error('finish command failed for one or more agent branches');
|
|
4444
|
+
}
|
|
4445
|
+
|
|
3487
4446
|
process.exitCode = 0;
|
|
3488
4447
|
}
|
|
3489
4448
|
|
|
@@ -3815,6 +4774,16 @@ function main() {
|
|
|
3815
4774
|
return;
|
|
3816
4775
|
}
|
|
3817
4776
|
|
|
4777
|
+
if (command === 'agents') {
|
|
4778
|
+
agents(rest);
|
|
4779
|
+
return;
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4782
|
+
if (command === 'finish') {
|
|
4783
|
+
finish(rest);
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
|
|
3818
4787
|
if (command === 'report') {
|
|
3819
4788
|
report(rest);
|
|
3820
4789
|
return;
|