@imdeadpool/guardex 5.0.8 → 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 +32 -0
- package/bin/multiagent-safety.js +551 -3
- package/package.json +1 -1
- package/templates/AGENTS.multiagent-safety.md +10 -2
- package/templates/scripts/agent-branch-finish.sh +2 -2
- package/templates/scripts/agent-branch-start.sh +86 -21
- package/templates/scripts/codex-agent.sh +91 -3
package/README.md
CHANGED
|
@@ -71,6 +71,12 @@ gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
|
71
71
|
If you use `scripts/codex-agent.sh`, the finish flow is auto-run after the Codex session exits.
|
|
72
72
|
It auto-commits sandbox changes, retries once after syncing if the branch moved behind base during the run, then pushes/opens PR merge flow against the current base branch.
|
|
73
73
|
|
|
74
|
+
If you run Codex in multiple existing agent worktrees directly (for example from VS Code Source Control), finalize all completed branches with:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
gx finish --all
|
|
78
|
+
```
|
|
79
|
+
|
|
74
80
|
## Visual workflow
|
|
75
81
|
|
|
76
82
|
### Setup status
|
|
@@ -89,6 +95,10 @@ It auto-commits sandbox changes, retries once after syncing if the branch moved
|
|
|
89
95
|
|
|
90
96
|

|
|
91
97
|
|
|
98
|
+
### Real VS Code Source Control layout (exact screenshot)
|
|
99
|
+
|
|
100
|
+

|
|
101
|
+
|
|
92
102
|
## Copy-paste: common commands
|
|
93
103
|
|
|
94
104
|
```sh
|
|
@@ -114,6 +124,9 @@ gx sync
|
|
|
114
124
|
# continuously monitor open PRs targeting current branch and dispatch codex-agent review/merge tasks
|
|
115
125
|
gx review --interval 30
|
|
116
126
|
|
|
127
|
+
# auto-commit finished agent branches and open/merge PR flow in one pass
|
|
128
|
+
gx finish --all
|
|
129
|
+
|
|
117
130
|
# cleanup merged agent branches and hide clean stale agent worktrees
|
|
118
131
|
gx cleanup
|
|
119
132
|
|
|
@@ -228,6 +241,19 @@ scripts/openspec/init-plan-workspace.sh
|
|
|
228
241
|
|
|
229
242
|
If `package.json` exists, setup also adds `agent:*` helper scripts.
|
|
230
243
|
|
|
244
|
+
## OpenSpec quick start after `gx setup`
|
|
245
|
+
|
|
246
|
+
If you enabled global OpenSpec install during setup (`@fission-ai/openspec`), use the full guide here:
|
|
247
|
+
|
|
248
|
+
- [`docs/openspec-getting-started.md`](./docs/openspec-getting-started.md)
|
|
249
|
+
|
|
250
|
+
### OpenSpec in agent sub-branches
|
|
251
|
+
|
|
252
|
+
- `scripts/codex-agent.sh` enforces an OpenSpec workspace before it launches Codex in each sandbox branch/worktree.
|
|
253
|
+
- `scripts/agent-branch-start.sh` can also scaffold `openspec/plan/<agent-branch-slug>/` when you set `MUSAFETY_OPENSPEC_AUTO_INIT=true`.
|
|
254
|
+
- Set `MUSAFETY_OPENSPEC_AUTO_INIT=false` (default for `agent-branch-start`) to skip branch-start auto-bootstrap.
|
|
255
|
+
- Set `MUSAFETY_OPENSPEC_PLAN_SLUG=<kebab-case-slug>` to force a specific plan workspace name.
|
|
256
|
+
|
|
231
257
|
## Security and maintenance posture
|
|
232
258
|
|
|
233
259
|
- CI matrix on Node 18/20/22 (`npm test`, `node --check`, `npm pack --dry-run`)
|
|
@@ -245,6 +271,12 @@ npm pack --dry-run
|
|
|
245
271
|
|
|
246
272
|
## Release notes
|
|
247
273
|
|
|
274
|
+
### v5.0.9
|
|
275
|
+
|
|
276
|
+
- Enforced OpenSpec workspace bootstrap for sandbox agent execution: `scripts/codex-agent.sh` now initializes `openspec/plan/<agent-branch-slug>/` before launching Codex, and `scripts/agent-branch-start.sh` supports `MUSAFETY_OPENSPEC_AUTO_INIT` plus `MUSAFETY_OPENSPEC_PLAN_SLUG`.
|
|
277
|
+
- Tightened doctor auto-finish correctness: sandbox finish now waits for merge and exits non-zero if the PR closes without merge, so repair flows are not reported as complete when policy blocks merge.
|
|
278
|
+
- Updated package version from `5.0.8` to `5.0.9` for the next npm publish.
|
|
279
|
+
|
|
248
280
|
### v5.0.8
|
|
249
281
|
|
|
250
282
|
- Fixed `bin/multiagent-safety.js` syntax regressions in the doctor sandbox flow (`Unexpected identifier` / `Unexpected end of input`) that were breaking CLI execution and CI tests.
|
package/bin/multiagent-safety.js
CHANGED
|
@@ -128,6 +128,7 @@ const SUGGESTIBLE_COMMANDS = [
|
|
|
128
128
|
'init',
|
|
129
129
|
'doctor',
|
|
130
130
|
'review',
|
|
131
|
+
'finish',
|
|
131
132
|
'report',
|
|
132
133
|
'copy-prompt',
|
|
133
134
|
'copy-commands',
|
|
@@ -148,6 +149,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
148
149
|
['init', 'Alias of setup (bootstrap + repair guardrails in a git repo)'],
|
|
149
150
|
['doctor', 'Repair safety setup drift, then verify repo safety'],
|
|
150
151
|
['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'],
|
|
152
|
+
['finish', 'Auto-commit completed agent branches, then run PR finish flow'],
|
|
151
153
|
['copy-prompt', 'Print the AI-ready setup checklist'],
|
|
152
154
|
['copy-commands', 'Print setup checklist as executable commands only'],
|
|
153
155
|
['protect', 'Manage protected branches (list/add/remove/set/reset)'],
|
|
@@ -198,6 +200,8 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
|
|
|
198
200
|
- Finished branches stay available by default for audit/follow-up.
|
|
199
201
|
Remove them explicitly when done:
|
|
200
202
|
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
203
|
+
- To finalize all completed agent branches in one pass:
|
|
204
|
+
gx finish --all
|
|
201
205
|
|
|
202
206
|
6) Optional: create OpenSpec planning workspace:
|
|
203
207
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
@@ -222,6 +226,7 @@ bash scripts/codex-agent.sh "task" "agent-name"
|
|
|
222
226
|
bash scripts/agent-branch-start.sh "task" "agent-name"
|
|
223
227
|
python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...>
|
|
224
228
|
bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
229
|
+
gx finish --all
|
|
225
230
|
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
226
231
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
227
232
|
gx protect add release staging
|
|
@@ -628,6 +633,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
|
|
|
628
633
|
'agent:review:watch': 'bash ./scripts/review-bot-watch.sh',
|
|
629
634
|
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
|
|
630
635
|
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
|
|
636
|
+
'agent:finish': `${SHORT_TOOL_NAME} finish --all`,
|
|
631
637
|
'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`,
|
|
632
638
|
'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
|
|
633
639
|
'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
|
|
@@ -1180,6 +1186,14 @@ function hasOriginRemote(repoRoot) {
|
|
|
1180
1186
|
return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0;
|
|
1181
1187
|
}
|
|
1182
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
|
+
|
|
1183
1197
|
function isCommandAvailable(commandName) {
|
|
1184
1198
|
return run('which', [commandName]).status === 0;
|
|
1185
1199
|
}
|
|
@@ -1211,6 +1225,13 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1211
1225
|
note: 'origin remote missing; skipped auto-finish',
|
|
1212
1226
|
};
|
|
1213
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
|
+
}
|
|
1214
1235
|
|
|
1215
1236
|
const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
|
|
1216
1237
|
if (!isCommandAvailable(ghBin)) {
|
|
@@ -1228,10 +1249,15 @@ function finishDoctorSandboxBranch(blocked, metadata) {
|
|
|
1228
1249
|
};
|
|
1229
1250
|
}
|
|
1230
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
|
+
|
|
1231
1257
|
const finishResult = run(
|
|
1232
1258
|
'bash',
|
|
1233
|
-
[finishScript, '--branch', metadata.branch, '--via-pr'],
|
|
1234
|
-
{ cwd: metadata.worktreePath, timeout:
|
|
1259
|
+
[finishScript, '--branch', metadata.branch, '--via-pr', '--wait-for-merge'],
|
|
1260
|
+
{ cwd: metadata.worktreePath, timeout: finishTimeoutMs },
|
|
1235
1261
|
);
|
|
1236
1262
|
if (isSpawnFailure(finishResult)) {
|
|
1237
1263
|
return {
|
|
@@ -1491,7 +1517,18 @@ function runDoctorInSandbox(options, blocked) {
|
|
|
1491
1517
|
}
|
|
1492
1518
|
|
|
1493
1519
|
if (typeof nestedResult.status === 'number') {
|
|
1494
|
-
|
|
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;
|
|
1495
1532
|
return;
|
|
1496
1533
|
}
|
|
1497
1534
|
process.exitCode = 1;
|
|
@@ -1914,6 +1951,12 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
|
|
|
1914
1951
|
summary.details.push('Skipped auto-finish sweep (origin remote missing).');
|
|
1915
1952
|
return summary;
|
|
1916
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
|
+
}
|
|
1917
1960
|
|
|
1918
1961
|
const ghBin = process.env.MUSAFETY_GH_BIN || 'gh';
|
|
1919
1962
|
if (run(ghBin, ['--version']).status !== 0) {
|
|
@@ -1973,6 +2016,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
|
|
|
1973
2016
|
'--base',
|
|
1974
2017
|
baseBranch,
|
|
1975
2018
|
'--via-pr',
|
|
2019
|
+
'--wait-for-merge',
|
|
1976
2020
|
'--cleanup',
|
|
1977
2021
|
];
|
|
1978
2022
|
const finishResult = run('bash', finishArgs, { cwd: repoRoot });
|
|
@@ -2285,6 +2329,382 @@ function parseCleanupArgs(rawArgs) {
|
|
|
2285
2329
|
return options;
|
|
2286
2330
|
}
|
|
2287
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
|
+
|
|
2288
2708
|
function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
|
|
2289
2709
|
if (strategy === 'rebase') {
|
|
2290
2710
|
if (ffOnly) {
|
|
@@ -3487,6 +3907,129 @@ function cleanup(rawArgs) {
|
|
|
3487
3907
|
process.exitCode = 0;
|
|
3488
3908
|
}
|
|
3489
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
|
+
|
|
3490
4033
|
function sync(rawArgs) {
|
|
3491
4034
|
const options = parseSyncArgs(rawArgs);
|
|
3492
4035
|
const repoRoot = resolveRepoRoot(options.target);
|
|
@@ -3815,6 +4358,11 @@ function main() {
|
|
|
3815
4358
|
return;
|
|
3816
4359
|
}
|
|
3817
4360
|
|
|
4361
|
+
if (command === 'finish') {
|
|
4362
|
+
finish(rest);
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
3818
4366
|
if (command === 'report') {
|
|
3819
4367
|
report(rest);
|
|
3820
4368
|
return;
|
package/package.json
CHANGED
|
@@ -45,9 +45,17 @@
|
|
|
45
45
|
- Verification commands + results
|
|
46
46
|
- Risks / follow-ups
|
|
47
47
|
|
|
48
|
-
## OpenSpec Plan Workspace (
|
|
48
|
+
## OpenSpec Plan Workspace (required for agent sub-branch changes)
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
OMX Codex execution flows must use OpenSpec. `scripts/codex-agent.sh` bootstraps a
|
|
51
|
+
per-branch plan workspace automatically under:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
openspec/plan/<agent-branch-slug>/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For manual `scripts/agent-branch-start.sh` usage, enable auto-bootstrap with
|
|
58
|
+
`MUSAFETY_OPENSPEC_AUTO_INIT=true` or scaffold manually before implementation:
|
|
51
59
|
|
|
52
60
|
```bash
|
|
53
61
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
@@ -338,7 +338,7 @@ is_local_branch_delete_error() {
|
|
|
338
338
|
|
|
339
339
|
read_pr_state() {
|
|
340
340
|
local state_line
|
|
341
|
-
state_line="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] |
|
|
341
|
+
state_line="$("$GH_BIN" pr view "$SOURCE_BRANCH" --json state,mergedAt,url --jq '[.state, (.mergedAt // ""), (.url // "")] | join("\u001f")' 2>/dev/null || true)"
|
|
342
342
|
if [[ -z "$state_line" ]]; then
|
|
343
343
|
return 1
|
|
344
344
|
fi
|
|
@@ -346,7 +346,7 @@ read_pr_state() {
|
|
|
346
346
|
local parsed_state=""
|
|
347
347
|
local parsed_merged_at=""
|
|
348
348
|
local parsed_url=""
|
|
349
|
-
IFS=$'\
|
|
349
|
+
IFS=$'\x1f' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line"
|
|
350
350
|
PR_STATE="$parsed_state"
|
|
351
351
|
PR_MERGED_AT="$parsed_merged_at"
|
|
352
352
|
if [[ -n "$parsed_url" ]]; then
|
|
@@ -6,6 +6,8 @@ AGENT_NAME="agent"
|
|
|
6
6
|
BASE_BRANCH=""
|
|
7
7
|
BASE_BRANCH_EXPLICIT=0
|
|
8
8
|
WORKTREE_ROOT_REL=".omx/agent-worktrees"
|
|
9
|
+
OPENSPEC_AUTO_INIT_RAW="${MUSAFETY_OPENSPEC_AUTO_INIT:-false}"
|
|
10
|
+
OPENSPEC_PLAN_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_PLAN_SLUG:-}"
|
|
9
11
|
POSITIONAL_ARGS=()
|
|
10
12
|
|
|
11
13
|
while [[ $# -gt 0 ]]; do
|
|
@@ -82,6 +84,31 @@ sanitize_slug() {
|
|
|
82
84
|
printf '%s' "$slug"
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
normalize_bool() {
|
|
88
|
+
local raw="${1:-}"
|
|
89
|
+
local fallback="${2:-0}"
|
|
90
|
+
local lowered
|
|
91
|
+
lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
|
|
92
|
+
case "$lowered" in
|
|
93
|
+
1|true|yes|on) printf '1' ;;
|
|
94
|
+
0|false|no|off) printf '0' ;;
|
|
95
|
+
'') printf '%s' "$fallback" ;;
|
|
96
|
+
*) printf '%s' "$fallback" ;;
|
|
97
|
+
esac
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")"
|
|
101
|
+
|
|
102
|
+
resolve_openspec_plan_slug() {
|
|
103
|
+
local branch_name="$1"
|
|
104
|
+
local task_slug="$2"
|
|
105
|
+
if [[ -n "$OPENSPEC_PLAN_SLUG_OVERRIDE" ]]; then
|
|
106
|
+
sanitize_slug "$OPENSPEC_PLAN_SLUG_OVERRIDE" "$task_slug"
|
|
107
|
+
return 0
|
|
108
|
+
fi
|
|
109
|
+
sanitize_slug "${branch_name//\//-}" "$task_slug"
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
resolve_active_codex_snapshot_name() {
|
|
86
113
|
local override="${MUSAFETY_CODEX_AUTH_SNAPSHOT:-}"
|
|
87
114
|
if [[ -n "$override" ]]; then
|
|
@@ -114,17 +141,6 @@ has_local_changes() {
|
|
|
114
141
|
return 1
|
|
115
142
|
}
|
|
116
143
|
|
|
117
|
-
has_tracked_changes() {
|
|
118
|
-
local root="$1"
|
|
119
|
-
if ! git -C "$root" diff --quiet; then
|
|
120
|
-
return 0
|
|
121
|
-
fi
|
|
122
|
-
if ! git -C "$root" diff --cached --quiet; then
|
|
123
|
-
return 0
|
|
124
|
-
fi
|
|
125
|
-
return 1
|
|
126
|
-
}
|
|
127
|
-
|
|
128
144
|
resolve_protected_branches() {
|
|
129
145
|
local root="$1"
|
|
130
146
|
local raw
|
|
@@ -177,6 +193,43 @@ hydrate_local_helper_in_worktree() {
|
|
|
177
193
|
echo "[agent-branch-start] Hydrated local helper in worktree: ${relative_path}"
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
initialize_openspec_plan_workspace() {
|
|
197
|
+
local repo="$1"
|
|
198
|
+
local worktree="$2"
|
|
199
|
+
local plan_slug="$3"
|
|
200
|
+
|
|
201
|
+
hydrate_local_helper_in_worktree "$repo" "$worktree" "scripts/openspec/init-plan-workspace.sh"
|
|
202
|
+
|
|
203
|
+
if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then
|
|
204
|
+
return 0
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
local openspec_script="${worktree}/scripts/openspec/init-plan-workspace.sh"
|
|
208
|
+
if [[ ! -f "$openspec_script" ]]; then
|
|
209
|
+
echo "[agent-branch-start] OpenSpec init script is missing in sandbox worktree." >&2
|
|
210
|
+
echo "[agent-branch-start] Run 'gx setup --target \"$repo\"' to repair templates, then retry." >&2
|
|
211
|
+
return 1
|
|
212
|
+
fi
|
|
213
|
+
if [[ ! -x "$openspec_script" ]]; then
|
|
214
|
+
chmod +x "$openspec_script" 2>/dev/null || true
|
|
215
|
+
fi
|
|
216
|
+
|
|
217
|
+
local init_output=""
|
|
218
|
+
if ! init_output="$(
|
|
219
|
+
cd "$worktree"
|
|
220
|
+
bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1
|
|
221
|
+
)"; then
|
|
222
|
+
printf '%s\n' "$init_output" >&2
|
|
223
|
+
echo "[agent-branch-start] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2
|
|
224
|
+
return 1
|
|
225
|
+
fi
|
|
226
|
+
|
|
227
|
+
if [[ -n "$init_output" ]]; then
|
|
228
|
+
printf '%s\n' "$init_output"
|
|
229
|
+
fi
|
|
230
|
+
echo "[agent-branch-start] OpenSpec plan workspace: ${worktree}/openspec/plan/${plan_slug}"
|
|
231
|
+
}
|
|
232
|
+
|
|
180
233
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
181
234
|
echo "[agent-branch-start] Not inside a git repository." >&2
|
|
182
235
|
exit 1
|
|
@@ -190,12 +243,15 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
|
|
|
190
243
|
fi
|
|
191
244
|
|
|
192
245
|
if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
246
|
+
current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
247
|
+
protected_branches_raw="$(resolve_protected_branches "$repo_root")"
|
|
248
|
+
if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_branch_name "$current_branch" "$protected_branches_raw"; then
|
|
249
|
+
BASE_BRANCH="$current_branch"
|
|
196
250
|
else
|
|
197
|
-
|
|
198
|
-
if [[ -n "$
|
|
251
|
+
configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
|
|
252
|
+
if [[ -n "$configured_base" ]]; then
|
|
253
|
+
BASE_BRANCH="$configured_base"
|
|
254
|
+
elif [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then
|
|
199
255
|
BASE_BRANCH="$current_branch"
|
|
200
256
|
else
|
|
201
257
|
BASE_BRANCH="dev"
|
|
@@ -235,6 +291,7 @@ done
|
|
|
235
291
|
worktree_root="${repo_root}/${WORKTREE_ROOT_REL}"
|
|
236
292
|
mkdir -p "$worktree_root"
|
|
237
293
|
worktree_path="${worktree_root}/${branch_name//\//__}"
|
|
294
|
+
openspec_plan_slug="$(resolve_openspec_plan_slug "$branch_name" "$task_slug")"
|
|
238
295
|
|
|
239
296
|
if [[ -e "$worktree_path" ]]; then
|
|
240
297
|
echo "[agent-branch-start] Worktree path already exists: ${worktree_path}" >&2
|
|
@@ -243,10 +300,11 @@ fi
|
|
|
243
300
|
|
|
244
301
|
auto_transfer_stash_ref=""
|
|
245
302
|
auto_transfer_message=""
|
|
303
|
+
auto_transfer_source_branch=""
|
|
246
304
|
current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
247
305
|
protected_branches_raw="$(resolve_protected_branches "$repo_root")"
|
|
248
|
-
if [[ "$current_branch"
|
|
249
|
-
if
|
|
306
|
+
if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_branch_name "$current_branch" "$protected_branches_raw"; then
|
|
307
|
+
if has_local_changes "$repo_root"; then
|
|
250
308
|
auto_transfer_message="musafety-auto-transfer-${timestamp}-${agent_slug}-${task_slug}"
|
|
251
309
|
if git -C "$repo_root" stash push --include-untracked --message "$auto_transfer_message" >/dev/null 2>&1; then
|
|
252
310
|
auto_transfer_stash_ref="$(
|
|
@@ -254,7 +312,8 @@ if [[ "$current_branch" == "$BASE_BRANCH" ]] && is_protected_branch_name "$BASE_
|
|
|
254
312
|
| awk -v msg="$auto_transfer_message" '$0 ~ msg { ref=$1; sub(/:$/, "", ref); print ref; exit }'
|
|
255
313
|
)"
|
|
256
314
|
if [[ -n "$auto_transfer_stash_ref" ]]; then
|
|
257
|
-
|
|
315
|
+
auto_transfer_source_branch="$current_branch"
|
|
316
|
+
echo "[agent-branch-start] Detected local changes on protected branch '${current_branch}'. Moving them to '${branch_name}'..."
|
|
258
317
|
fi
|
|
259
318
|
fi
|
|
260
319
|
fi
|
|
@@ -270,19 +329,25 @@ fi
|
|
|
270
329
|
if [[ -n "$auto_transfer_stash_ref" ]]; then
|
|
271
330
|
if git -C "$worktree_path" stash apply "$auto_transfer_stash_ref" >/dev/null 2>&1; then
|
|
272
331
|
git -C "$repo_root" stash drop "$auto_transfer_stash_ref" >/dev/null 2>&1 || true
|
|
273
|
-
|
|
332
|
+
transfer_label="${auto_transfer_source_branch:-$BASE_BRANCH}"
|
|
333
|
+
echo "[agent-branch-start] Moved local changes from '${transfer_label}' into '${branch_name}'."
|
|
274
334
|
else
|
|
275
335
|
echo "[agent-branch-start] Failed to auto-apply moved changes in new worktree." >&2
|
|
276
|
-
|
|
336
|
+
transfer_label="${auto_transfer_source_branch:-$BASE_BRANCH}"
|
|
337
|
+
echo "[agent-branch-start] Changes are preserved in ${auto_transfer_stash_ref} on ${transfer_label}." >&2
|
|
277
338
|
echo "[agent-branch-start] Apply manually with: git -C \"$worktree_path\" stash apply \"${auto_transfer_stash_ref}\"" >&2
|
|
278
339
|
exit 1
|
|
279
340
|
fi
|
|
280
341
|
fi
|
|
281
342
|
|
|
282
343
|
hydrate_local_helper_in_worktree "$repo_root" "$worktree_path" "scripts/codex-agent.sh"
|
|
344
|
+
if ! initialize_openspec_plan_workspace "$repo_root" "$worktree_path" "$openspec_plan_slug"; then
|
|
345
|
+
exit 1
|
|
346
|
+
fi
|
|
283
347
|
|
|
284
348
|
echo "[agent-branch-start] Created branch: ${branch_name}"
|
|
285
349
|
echo "[agent-branch-start] Worktree: ${worktree_path}"
|
|
350
|
+
echo "[agent-branch-start] OpenSpec plan: openspec/plan/${openspec_plan_slug}"
|
|
286
351
|
echo "[agent-branch-start] Next steps:"
|
|
287
352
|
echo " cd \"${worktree_path}\""
|
|
288
353
|
echo " python3 scripts/agent-file-locks.py claim --branch \"${branch_name}\" <file...>"
|
|
@@ -10,6 +10,8 @@ AUTO_FINISH_RAW="${MUSAFETY_CODEX_AUTO_FINISH:-true}"
|
|
|
10
10
|
AUTO_REVIEW_ON_CONFLICT_RAW="${MUSAFETY_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}"
|
|
11
11
|
AUTO_CLEANUP_RAW="${MUSAFETY_CODEX_AUTO_CLEANUP:-true}"
|
|
12
12
|
AUTO_WAIT_FOR_MERGE_RAW="${MUSAFETY_CODEX_WAIT_FOR_MERGE:-true}"
|
|
13
|
+
OPENSPEC_AUTO_INIT_RAW="${MUSAFETY_OPENSPEC_AUTO_INIT:-true}"
|
|
14
|
+
OPENSPEC_PLAN_SLUG_OVERRIDE="${MUSAFETY_OPENSPEC_PLAN_SLUG:-}"
|
|
13
15
|
|
|
14
16
|
normalize_bool() {
|
|
15
17
|
local raw="${1:-}"
|
|
@@ -28,6 +30,7 @@ AUTO_FINISH="$(normalize_bool "$AUTO_FINISH_RAW" "1")"
|
|
|
28
30
|
AUTO_REVIEW_ON_CONFLICT="$(normalize_bool "$AUTO_REVIEW_ON_CONFLICT_RAW" "1")"
|
|
29
31
|
AUTO_CLEANUP="$(normalize_bool "$AUTO_CLEANUP_RAW" "1")"
|
|
30
32
|
AUTO_WAIT_FOR_MERGE="$(normalize_bool "$AUTO_WAIT_FOR_MERGE_RAW" "1")"
|
|
33
|
+
OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")"
|
|
31
34
|
|
|
32
35
|
if [[ -n "$BASE_BRANCH" ]]; then
|
|
33
36
|
BASE_BRANCH_EXPLICIT=1
|
|
@@ -136,6 +139,46 @@ sanitize_slug() {
|
|
|
136
139
|
printf '%s' "$slug"
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
resolve_openspec_plan_slug() {
|
|
143
|
+
local branch_name="$1"
|
|
144
|
+
local task_slug
|
|
145
|
+
task_slug="$(sanitize_slug "$TASK_NAME" "task")"
|
|
146
|
+
if [[ -n "$OPENSPEC_PLAN_SLUG_OVERRIDE" ]]; then
|
|
147
|
+
sanitize_slug "$OPENSPEC_PLAN_SLUG_OVERRIDE" "$task_slug"
|
|
148
|
+
return 0
|
|
149
|
+
fi
|
|
150
|
+
sanitize_slug "${branch_name//\//-}" "$task_slug"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
hydrate_local_helper_in_worktree() {
|
|
154
|
+
local worktree="$1"
|
|
155
|
+
local relative_path="$2"
|
|
156
|
+
local worktree_target="${worktree}/${relative_path}"
|
|
157
|
+
local source_path=""
|
|
158
|
+
|
|
159
|
+
if [[ -e "$worktree_target" ]]; then
|
|
160
|
+
return 0
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
if [[ -f "${repo_root}/${relative_path}" ]]; then
|
|
164
|
+
source_path="${repo_root}/${relative_path}"
|
|
165
|
+
elif [[ -f "${repo_root}/templates/${relative_path}" ]]; then
|
|
166
|
+
source_path="${repo_root}/templates/${relative_path}"
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
if [[ -z "$source_path" ]]; then
|
|
170
|
+
return 0
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
mkdir -p "$(dirname "$worktree_target")"
|
|
174
|
+
cp "$source_path" "$worktree_target"
|
|
175
|
+
if [[ -x "$source_path" ]]; then
|
|
176
|
+
chmod +x "$worktree_target"
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
echo "[codex-agent] Hydrated local helper in sandbox: ${relative_path}"
|
|
180
|
+
}
|
|
181
|
+
|
|
139
182
|
resolve_start_base_branch() {
|
|
140
183
|
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then
|
|
141
184
|
printf '%s' "$BASE_BRANCH"
|
|
@@ -239,7 +282,7 @@ initial_repo_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/nu
|
|
|
239
282
|
start_output=""
|
|
240
283
|
start_status=0
|
|
241
284
|
set +e
|
|
242
|
-
start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1)"
|
|
285
|
+
start_output="$(MUSAFETY_OPENSPEC_AUTO_INIT=0 bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1)"
|
|
243
286
|
start_status=$?
|
|
244
287
|
set -e
|
|
245
288
|
|
|
@@ -363,6 +406,43 @@ sync_worktree_with_base() {
|
|
|
363
406
|
return 0
|
|
364
407
|
}
|
|
365
408
|
|
|
409
|
+
ensure_openspec_plan_workspace() {
|
|
410
|
+
local wt="$1"
|
|
411
|
+
local branch="$2"
|
|
412
|
+
|
|
413
|
+
if [[ "$OPENSPEC_AUTO_INIT" -ne 1 ]]; then
|
|
414
|
+
return 0
|
|
415
|
+
fi
|
|
416
|
+
|
|
417
|
+
hydrate_local_helper_in_worktree "$wt" "scripts/openspec/init-plan-workspace.sh"
|
|
418
|
+
|
|
419
|
+
local openspec_script="${wt}/scripts/openspec/init-plan-workspace.sh"
|
|
420
|
+
if [[ ! -f "$openspec_script" ]]; then
|
|
421
|
+
echo "[codex-agent] Missing OpenSpec init script in sandbox: ${openspec_script}" >&2
|
|
422
|
+
echo "[codex-agent] Run 'gx setup --target ${repo_root}' and retry." >&2
|
|
423
|
+
return 1
|
|
424
|
+
fi
|
|
425
|
+
if [[ ! -x "$openspec_script" ]]; then
|
|
426
|
+
chmod +x "$openspec_script" 2>/dev/null || true
|
|
427
|
+
fi
|
|
428
|
+
|
|
429
|
+
local plan_slug
|
|
430
|
+
plan_slug="$(resolve_openspec_plan_slug "$branch")"
|
|
431
|
+
local init_output=""
|
|
432
|
+
if ! init_output="$(
|
|
433
|
+
cd "$wt"
|
|
434
|
+
bash "scripts/openspec/init-plan-workspace.sh" "$plan_slug" 2>&1
|
|
435
|
+
)"; then
|
|
436
|
+
printf '%s\n' "$init_output" >&2
|
|
437
|
+
echo "[codex-agent] OpenSpec workspace initialization failed for plan '${plan_slug}'." >&2
|
|
438
|
+
return 1
|
|
439
|
+
fi
|
|
440
|
+
if [[ -n "$init_output" ]]; then
|
|
441
|
+
printf '%s\n' "$init_output"
|
|
442
|
+
fi
|
|
443
|
+
echo "[codex-agent] OpenSpec plan workspace: ${wt}/openspec/plan/${plan_slug}"
|
|
444
|
+
}
|
|
445
|
+
|
|
366
446
|
worktree_has_changes() {
|
|
367
447
|
local wt="$1"
|
|
368
448
|
if ! git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then
|
|
@@ -579,6 +659,16 @@ if ! sync_worktree_with_base "$worktree_path"; then
|
|
|
579
659
|
exit 1
|
|
580
660
|
fi
|
|
581
661
|
|
|
662
|
+
worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
663
|
+
if [[ -z "$worktree_branch" || "$worktree_branch" == "HEAD" ]]; then
|
|
664
|
+
echo "[codex-agent] Could not determine sandbox branch for worktree: $worktree_path" >&2
|
|
665
|
+
exit 1
|
|
666
|
+
fi
|
|
667
|
+
|
|
668
|
+
if ! ensure_openspec_plan_workspace "$worktree_path" "$worktree_branch"; then
|
|
669
|
+
exit 1
|
|
670
|
+
fi
|
|
671
|
+
|
|
582
672
|
echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path"
|
|
583
673
|
cd "$worktree_path"
|
|
584
674
|
set +e
|
|
@@ -590,8 +680,6 @@ cd "$repo_root"
|
|
|
590
680
|
final_exit="$codex_exit"
|
|
591
681
|
auto_finish_completed=0
|
|
592
682
|
|
|
593
|
-
worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
594
|
-
|
|
595
683
|
if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then
|
|
596
684
|
if [[ "$AUTO_WAIT_FOR_MERGE" -eq 1 && "$AUTO_CLEANUP" -eq 1 ]]; then
|
|
597
685
|
echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> wait for merge -> cleanup."
|