@imdeadpool/guardex 5.0.9 → 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
CHANGED
|
@@ -124,12 +124,21 @@ gx sync
|
|
|
124
124
|
# continuously monitor open PRs targeting current branch and dispatch codex-agent review/merge tasks
|
|
125
125
|
gx review --interval 30
|
|
126
126
|
|
|
127
|
+
# start both background bots for this repo (review + cleanup)
|
|
128
|
+
gx agents start
|
|
129
|
+
|
|
130
|
+
# stop both background bots for this repo
|
|
131
|
+
gx agents stop
|
|
132
|
+
|
|
127
133
|
# auto-commit finished agent branches and open/merge PR flow in one pass
|
|
128
134
|
gx finish --all
|
|
129
135
|
|
|
130
136
|
# cleanup merged agent branches and hide clean stale agent worktrees
|
|
131
137
|
gx cleanup
|
|
132
138
|
|
|
139
|
+
# run continuous stale-branch cleanup bot (default idle threshold: 10 minutes)
|
|
140
|
+
gx cleanup --watch --interval 60
|
|
141
|
+
|
|
133
142
|
# scan/report
|
|
134
143
|
gx scan
|
|
135
144
|
gx report scorecard --repo github.com/recodeecom/multiagent-safety
|
|
@@ -152,6 +161,37 @@ Useful flags:
|
|
|
152
161
|
|
|
153
162
|
Note: the monitor dispatches Codex through explicit `--task/--agent/--base` flags for compatibility with both older and newer `scripts/codex-agent.sh` argument parsing.
|
|
154
163
|
|
|
164
|
+
### Continuous stale branch cleanup bot
|
|
165
|
+
|
|
166
|
+
Use this to auto-prune idle `agent/*` worktrees created by Codex while keeping active worktrees untouched.
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
# watch cleanup loop every minute (default idle threshold is 10 minutes when --watch is enabled)
|
|
170
|
+
gx cleanup --watch --interval 60
|
|
171
|
+
|
|
172
|
+
# one-shot cleanup for branches idle at least 10 minutes
|
|
173
|
+
gx cleanup --idle-minutes 10
|
|
174
|
+
|
|
175
|
+
# run a single watch cycle (helpful for cron/CI checks)
|
|
176
|
+
gx cleanup --watch --once --interval 60
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Repo Agent Supervisor (start both bots with one command)
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
# starts review bot + cleanup bot in background for the current repo
|
|
183
|
+
gx agents start
|
|
184
|
+
|
|
185
|
+
# optional tuning
|
|
186
|
+
gx agents start --review-interval 30 --cleanup-interval 60 --idle-minutes 10
|
|
187
|
+
|
|
188
|
+
# show whether both bots are running for this repo
|
|
189
|
+
gx agents status
|
|
190
|
+
|
|
191
|
+
# stop both bots and clear repo-local state
|
|
192
|
+
gx agents stop
|
|
193
|
+
```
|
|
194
|
+
|
|
155
195
|
## Important behavior defaults
|
|
156
196
|
|
|
157
197
|
- No command defaults to `gx status`.
|
|
@@ -247,6 +287,23 @@ If you enabled global OpenSpec install during setup (`@fission-ai/openspec`), us
|
|
|
247
287
|
|
|
248
288
|
- [`docs/openspec-getting-started.md`](./docs/openspec-getting-started.md)
|
|
249
289
|
|
|
290
|
+
Default core flow:
|
|
291
|
+
|
|
292
|
+
```text
|
|
293
|
+
/opsx:propose <change-name> -> /opsx:apply -> /opsx:archive
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Optional expanded flow:
|
|
297
|
+
|
|
298
|
+
```sh
|
|
299
|
+
openspec config profile <profile-name>
|
|
300
|
+
openspec update
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```text
|
|
304
|
+
/opsx:new <change-name> -> /opsx:ff or /opsx:continue -> /opsx:apply -> /opsx:verify -> /opsx:archive
|
|
305
|
+
```
|
|
306
|
+
|
|
250
307
|
### OpenSpec in agent sub-branches
|
|
251
308
|
|
|
252
309
|
- `scripts/codex-agent.sh` enforces an OpenSpec workspace before it launches Codex in each sandbox branch/worktree.
|
|
@@ -271,6 +328,16 @@ npm pack --dry-run
|
|
|
271
328
|
|
|
272
329
|
## Release notes
|
|
273
330
|
|
|
331
|
+
### v5.0.11
|
|
332
|
+
|
|
333
|
+
- Updated the managed AGENTS contract wording to use `GX` naming and added an explicit OMX completion policy requiring commit + push + PR creation/update at task completion.
|
|
334
|
+
- Ensured `gx install` explicitly configures the managed `AGENTS.md` policy block and added regression coverage for this install-path behavior.
|
|
335
|
+
- Bumped package version from `5.0.10` to `5.0.11` for the next npm publish.
|
|
336
|
+
|
|
337
|
+
### v5.0.10
|
|
338
|
+
|
|
339
|
+
- Bumped package version from `5.0.9` to `5.0.10` for the next npm publish.
|
|
340
|
+
|
|
274
341
|
### v5.0.9
|
|
275
342
|
|
|
276
343
|
- 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`.
|
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,7 @@ const SUGGESTIBLE_COMMANDS = [
|
|
|
128
129
|
'init',
|
|
129
130
|
'doctor',
|
|
130
131
|
'review',
|
|
132
|
+
'agents',
|
|
131
133
|
'finish',
|
|
132
134
|
'report',
|
|
133
135
|
'copy-prompt',
|
|
@@ -154,7 +156,8 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
154
156
|
['copy-commands', 'Print setup checklist as executable commands only'],
|
|
155
157
|
['protect', 'Manage protected branches (list/add/remove/set/reset)'],
|
|
156
158
|
['sync', 'Check or sync agent branches with origin/<base>'],
|
|
157
|
-
['cleanup', 'Cleanup
|
|
159
|
+
['cleanup', 'Cleanup agent branches/worktrees (supports idle watch mode)'],
|
|
160
|
+
['agents', 'Start/stop repo-scoped review + cleanup bots'],
|
|
158
161
|
['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
|
|
159
162
|
['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
|
|
160
163
|
['scan', 'Report safety issues and exit non-zero on findings'],
|
|
@@ -165,6 +168,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
|
|
|
165
168
|
];
|
|
166
169
|
const AGENT_BOT_DESCRIPTIONS = [
|
|
167
170
|
['review', 'Start PR monitor + codex-agent review flow (default interval: 30s)'],
|
|
171
|
+
['agents', 'Start/stop both review and cleanup bots for this repo'],
|
|
168
172
|
];
|
|
169
173
|
|
|
170
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.
|
|
@@ -203,17 +207,28 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
|
|
|
203
207
|
- To finalize all completed agent branches in one pass:
|
|
204
208
|
gx finish --all
|
|
205
209
|
|
|
206
|
-
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:
|
|
207
222
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
208
223
|
|
|
209
|
-
|
|
224
|
+
9) Optional: protect extra branches:
|
|
210
225
|
gx protect add release staging
|
|
211
226
|
|
|
212
|
-
|
|
227
|
+
10) Optional: sync your current agent branch with latest base branch:
|
|
213
228
|
gx sync --check
|
|
214
229
|
gx sync
|
|
215
230
|
|
|
216
|
-
|
|
231
|
+
11) Optional (GitHub remote cleanup): enable:
|
|
217
232
|
Settings -> General -> Pull Requests -> Automatically delete head branches
|
|
218
233
|
`;
|
|
219
234
|
|
|
@@ -229,6 +244,8 @@ bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)
|
|
|
229
244
|
gx finish --all
|
|
230
245
|
gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
231
246
|
bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
|
|
247
|
+
openspec config profile <profile-name>
|
|
248
|
+
openspec update
|
|
232
249
|
gx protect add release staging
|
|
233
250
|
gx sync --check
|
|
234
251
|
gx sync
|
|
@@ -1567,6 +1584,69 @@ function parseReviewArgs(rawArgs) {
|
|
|
1567
1584
|
};
|
|
1568
1585
|
}
|
|
1569
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
|
+
|
|
1570
1650
|
function parseReportArgs(rawArgs) {
|
|
1571
1651
|
const options = {
|
|
1572
1652
|
target: process.cwd(),
|
|
@@ -2276,6 +2356,10 @@ function parseCleanupArgs(rawArgs) {
|
|
|
2276
2356
|
forceDirty: false,
|
|
2277
2357
|
keepRemote: false,
|
|
2278
2358
|
keepCleanWorktrees: false,
|
|
2359
|
+
idleMinutes: 0,
|
|
2360
|
+
watch: false,
|
|
2361
|
+
intervalSeconds: 60,
|
|
2362
|
+
once: false,
|
|
2279
2363
|
};
|
|
2280
2364
|
|
|
2281
2365
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
@@ -2323,9 +2407,47 @@ function parseCleanupArgs(rawArgs) {
|
|
|
2323
2407
|
options.keepCleanWorktrees = true;
|
|
2324
2408
|
continue;
|
|
2325
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
|
+
}
|
|
2326
2444
|
throw new Error(`Unknown option: ${arg}`);
|
|
2327
2445
|
}
|
|
2328
2446
|
|
|
2447
|
+
if (options.watch && options.idleMinutes === 0) {
|
|
2448
|
+
options.idleMinutes = 10;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2329
2451
|
return options;
|
|
2330
2452
|
}
|
|
2331
2453
|
|
|
@@ -3485,6 +3607,9 @@ function install(rawArgs) {
|
|
|
3485
3607
|
printOperations('Install target', payload, options.dryRun);
|
|
3486
3608
|
|
|
3487
3609
|
if (!options.dryRun) {
|
|
3610
|
+
if (!options.skipAgents) {
|
|
3611
|
+
console.log(`[${TOOL_NAME}] AGENTS.md managed policy block is configured by install.`);
|
|
3612
|
+
}
|
|
3488
3613
|
console.log(`[${TOOL_NAME}] Installed. Next step: ${TOOL_NAME} setup`);
|
|
3489
3614
|
}
|
|
3490
3615
|
|
|
@@ -3624,6 +3749,263 @@ function review(rawArgs) {
|
|
|
3624
3749
|
process.exitCode = typeof result.status === 'number' ? result.status : 1;
|
|
3625
3750
|
}
|
|
3626
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
|
+
|
|
3627
4009
|
function report(rawArgs) {
|
|
3628
4010
|
const options = parseReportArgs(rawArgs);
|
|
3629
4011
|
const subcommand = options.subcommand || 'help';
|
|
@@ -3802,6 +4184,13 @@ function setup(rawArgs) {
|
|
|
3802
4184
|
if (scanResult.errors === 0 && scanResult.warnings === 0) {
|
|
3803
4185
|
console.log(`[${TOOL_NAME}] ✅ Setup complete.`);
|
|
3804
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`);
|
|
3805
4194
|
}
|
|
3806
4195
|
|
|
3807
4196
|
setExitCodeFromScan(scanResult);
|
|
@@ -3895,15 +4284,42 @@ function cleanup(rawArgs) {
|
|
|
3895
4284
|
if (!options.keepCleanWorktrees) {
|
|
3896
4285
|
args.push('--only-dirty-worktrees');
|
|
3897
4286
|
}
|
|
4287
|
+
if (options.idleMinutes > 0) {
|
|
4288
|
+
args.push('--idle-minutes', String(options.idleMinutes));
|
|
4289
|
+
}
|
|
3898
4290
|
args.push('--delete-branches');
|
|
3899
4291
|
if (!options.keepRemote) {
|
|
3900
4292
|
args.push('--delete-remote-branches');
|
|
3901
4293
|
}
|
|
3902
4294
|
|
|
3903
|
-
const
|
|
3904
|
-
|
|
3905
|
-
|
|
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;
|
|
3906
4320
|
}
|
|
4321
|
+
|
|
4322
|
+
runCleanupCycle();
|
|
3907
4323
|
process.exitCode = 0;
|
|
3908
4324
|
}
|
|
3909
4325
|
|
|
@@ -4358,6 +4774,11 @@ function main() {
|
|
|
4358
4774
|
return;
|
|
4359
4775
|
}
|
|
4360
4776
|
|
|
4777
|
+
if (command === 'agents') {
|
|
4778
|
+
agents(rest);
|
|
4779
|
+
return;
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4361
4782
|
if (command === 'finish') {
|
|
4362
4783
|
finish(rest);
|
|
4363
4784
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imdeadpool/guardex",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.11",
|
|
4
4
|
"description": "GuardeX: the Guardian T-Rex for your repo, with hardened multi-agent git guardrails.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"preferGlobal": true,
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"agent:safety:scan": "gx scan",
|
|
30
30
|
"agent:safety:fix": "gx fix",
|
|
31
31
|
"agent:safety:doctor": "gx doctor",
|
|
32
|
-
"agent:review:watch": "bash ./scripts/review-bot-watch.sh"
|
|
32
|
+
"agent:review:watch": "bash ./scripts/review-bot-watch.sh",
|
|
33
|
+
"agent:finish": "gx finish --all"
|
|
33
34
|
},
|
|
34
35
|
"engines": {
|
|
35
36
|
"node": ">=18"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!-- multiagent-safety:START -->
|
|
2
|
-
## Multi-Agent Execution Contract (
|
|
2
|
+
## Multi-Agent Execution Contract (GX)
|
|
3
3
|
|
|
4
4
|
0. Session plan comment + read gate (required)
|
|
5
5
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
- In-place branch mode is disallowed: never switch the active local/base checkout to an agent branch.
|
|
14
14
|
- Treat the base branch (`main` or the user's current local base branch) as read-only while the agent branch is active.
|
|
15
15
|
- Agent completion defaults to `scripts/codex-agent.sh`, which auto-finishes the branch (auto-commit changed files, push/create PR, attempt merge, and pull the local base branch after merge).
|
|
16
|
+
- OMX completion policy: when a task is done, the agent must commit the task changes, push the agent branch, and create/update a PR for those changes (via `codex-agent` or `agent-branch-finish`).
|
|
16
17
|
- Auto-finish now waits for required checks/merge and then cleans merged sandbox branch/worktree by default.
|
|
17
18
|
- Use `--no-cleanup` only when you explicitly need to keep a merged sandbox for audit/debug follow-up.
|
|
18
19
|
- If codex-agent auto-finish cannot complete, immediately run `scripts/agent-branch-finish.sh --branch "<agent-branch>" --via-pr --wait-for-merge` and keep the branch open until checks/review pass.
|
|
@@ -193,6 +193,26 @@ hydrate_local_helper_in_worktree() {
|
|
|
193
193
|
echo "[agent-branch-start] Hydrated local helper in worktree: ${relative_path}"
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
hydrate_dependency_dir_symlink_in_worktree() {
|
|
197
|
+
local repo="$1"
|
|
198
|
+
local worktree="$2"
|
|
199
|
+
local relative_path="$3"
|
|
200
|
+
local source_path="${repo}/${relative_path}"
|
|
201
|
+
local target_path="${worktree}/${relative_path}"
|
|
202
|
+
|
|
203
|
+
if [[ ! -d "$source_path" ]]; then
|
|
204
|
+
return 0
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if [[ -e "$target_path" ]]; then
|
|
208
|
+
return 0
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
mkdir -p "$(dirname "$target_path")"
|
|
212
|
+
ln -s "$source_path" "$target_path"
|
|
213
|
+
echo "[agent-branch-start] Linked dependency dir in worktree: ${relative_path}"
|
|
214
|
+
}
|
|
215
|
+
|
|
196
216
|
initialize_openspec_plan_workspace() {
|
|
197
217
|
local repo="$1"
|
|
198
218
|
local worktree="$2"
|
|
@@ -341,6 +361,9 @@ if [[ -n "$auto_transfer_stash_ref" ]]; then
|
|
|
341
361
|
fi
|
|
342
362
|
|
|
343
363
|
hydrate_local_helper_in_worktree "$repo_root" "$worktree_path" "scripts/codex-agent.sh"
|
|
364
|
+
hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "node_modules"
|
|
365
|
+
hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/frontend/node_modules"
|
|
366
|
+
hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/backend/node_modules"
|
|
344
367
|
if ! initialize_openspec_plan_workspace "$repo_root" "$worktree_path" "$openspec_plan_slug"; then
|
|
345
368
|
exit 1
|
|
346
369
|
fi
|
|
@@ -9,6 +9,10 @@ DELETE_BRANCHES=0
|
|
|
9
9
|
DELETE_REMOTE_BRANCHES=0
|
|
10
10
|
ONLY_DIRTY_WORKTREES=0
|
|
11
11
|
TARGET_BRANCH=""
|
|
12
|
+
IDLE_MINUTES=0
|
|
13
|
+
NOW_EPOCH_RAW="${MUSAFETY_PRUNE_NOW_EPOCH:-}"
|
|
14
|
+
IDLE_SECONDS=0
|
|
15
|
+
NOW_EPOCH=0
|
|
12
16
|
|
|
13
17
|
if [[ -n "$BASE_BRANCH" ]]; then
|
|
14
18
|
BASE_BRANCH_EXPLICIT=1
|
|
@@ -45,9 +49,13 @@ while [[ $# -gt 0 ]]; do
|
|
|
45
49
|
TARGET_BRANCH="${2:-}"
|
|
46
50
|
shift 2
|
|
47
51
|
;;
|
|
52
|
+
--idle-minutes)
|
|
53
|
+
IDLE_MINUTES="${2:-}"
|
|
54
|
+
shift 2
|
|
55
|
+
;;
|
|
48
56
|
*)
|
|
49
57
|
echo "[agent-worktree-prune] Unknown argument: $1" >&2
|
|
50
|
-
echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch <agent/...>]" >&2
|
|
58
|
+
echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--only-dirty-worktrees] [--branch <agent/...>] [--idle-minutes <minutes>]" >&2
|
|
51
59
|
exit 1
|
|
52
60
|
;;
|
|
53
61
|
esac
|
|
@@ -103,6 +111,16 @@ if [[ -n "$TARGET_BRANCH" && "$TARGET_BRANCH" != agent/* ]]; then
|
|
|
103
111
|
exit 1
|
|
104
112
|
fi
|
|
105
113
|
|
|
114
|
+
if [[ ! "$IDLE_MINUTES" =~ ^[0-9]+$ ]]; then
|
|
115
|
+
echo "[agent-worktree-prune] --idle-minutes must be an integer >= 0." >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
if [[ -n "$NOW_EPOCH_RAW" && ! "$NOW_EPOCH_RAW" =~ ^[0-9]+$ ]]; then
|
|
120
|
+
echo "[agent-worktree-prune] MUSAFETY_PRUNE_NOW_EPOCH must be a unix timestamp integer." >&2
|
|
121
|
+
exit 1
|
|
122
|
+
fi
|
|
123
|
+
|
|
106
124
|
if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
|
|
107
125
|
BASE_BRANCH="$(resolve_base_branch)"
|
|
108
126
|
fi
|
|
@@ -117,6 +135,13 @@ if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}";
|
|
|
117
135
|
exit 1
|
|
118
136
|
fi
|
|
119
137
|
|
|
138
|
+
IDLE_SECONDS=$((IDLE_MINUTES * 60))
|
|
139
|
+
if [[ -n "$NOW_EPOCH_RAW" ]]; then
|
|
140
|
+
NOW_EPOCH="$NOW_EPOCH_RAW"
|
|
141
|
+
else
|
|
142
|
+
NOW_EPOCH="$(date +%s)"
|
|
143
|
+
fi
|
|
144
|
+
|
|
120
145
|
run_cmd() {
|
|
121
146
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
122
147
|
echo "[agent-worktree-prune] [dry-run] $*"
|
|
@@ -167,6 +192,71 @@ select_unique_worktree_path() {
|
|
|
167
192
|
printf '%s' "$candidate"
|
|
168
193
|
}
|
|
169
194
|
|
|
195
|
+
read_branch_activity_epoch() {
|
|
196
|
+
local branch="$1"
|
|
197
|
+
local wt="${2:-}"
|
|
198
|
+
local activity_epoch=""
|
|
199
|
+
|
|
200
|
+
activity_epoch="$(
|
|
201
|
+
git -C "$repo_root" reflog show --format='%ct' -n 1 "refs/heads/${branch}" 2>/dev/null \
|
|
202
|
+
| head -n 1 \
|
|
203
|
+
| tr -d '[:space:]'
|
|
204
|
+
)"
|
|
205
|
+
if [[ -z "$activity_epoch" ]]; then
|
|
206
|
+
activity_epoch="$(
|
|
207
|
+
git -C "$repo_root" log -1 --format='%ct' "$branch" 2>/dev/null \
|
|
208
|
+
| head -n 1 \
|
|
209
|
+
| tr -d '[:space:]'
|
|
210
|
+
)"
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
if [[ -n "$wt" && -d "$wt" ]]; then
|
|
214
|
+
local lock_file="${wt}/.omx/state/agent-file-locks.json"
|
|
215
|
+
if [[ -f "$lock_file" ]]; then
|
|
216
|
+
local lock_mtime=""
|
|
217
|
+
lock_mtime="$(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null || true)"
|
|
218
|
+
if [[ "$lock_mtime" =~ ^[0-9]+$ ]]; then
|
|
219
|
+
if [[ -z "$activity_epoch" || "$lock_mtime" -gt "$activity_epoch" ]]; then
|
|
220
|
+
activity_epoch="$lock_mtime"
|
|
221
|
+
fi
|
|
222
|
+
fi
|
|
223
|
+
fi
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
printf '%s' "$activity_epoch"
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
skipped_recent=0
|
|
230
|
+
|
|
231
|
+
branch_idle_gate() {
|
|
232
|
+
local branch="$1"
|
|
233
|
+
local wt="$2"
|
|
234
|
+
local reason="$3"
|
|
235
|
+
if [[ "$IDLE_SECONDS" -le 0 ]]; then
|
|
236
|
+
return 0
|
|
237
|
+
fi
|
|
238
|
+
if [[ -z "$branch" ]]; then
|
|
239
|
+
return 0
|
|
240
|
+
fi
|
|
241
|
+
|
|
242
|
+
local last_activity_epoch=""
|
|
243
|
+
last_activity_epoch="$(read_branch_activity_epoch "$branch" "$wt")"
|
|
244
|
+
if [[ ! "$last_activity_epoch" =~ ^[0-9]+$ ]]; then
|
|
245
|
+
return 0
|
|
246
|
+
fi
|
|
247
|
+
|
|
248
|
+
local idle_age=$((NOW_EPOCH - last_activity_epoch))
|
|
249
|
+
if [[ "$idle_age" -lt 0 ]]; then
|
|
250
|
+
idle_age=0
|
|
251
|
+
fi
|
|
252
|
+
if [[ "$idle_age" -lt "$IDLE_SECONDS" ]]; then
|
|
253
|
+
skipped_recent=$((skipped_recent + 1))
|
|
254
|
+
echo "[agent-worktree-prune] Skipping recent branch (${reason}): ${branch} (idle=${idle_age}s < ${IDLE_SECONDS}s)"
|
|
255
|
+
return 1
|
|
256
|
+
fi
|
|
257
|
+
return 0
|
|
258
|
+
}
|
|
259
|
+
|
|
170
260
|
relocated_foreign=0
|
|
171
261
|
skipped_foreign=0
|
|
172
262
|
|
|
@@ -273,6 +363,10 @@ process_entry() {
|
|
|
273
363
|
return
|
|
274
364
|
fi
|
|
275
365
|
|
|
366
|
+
if ! branch_idle_gate "$branch" "$wt" "$remove_reason"; then
|
|
367
|
+
return
|
|
368
|
+
fi
|
|
369
|
+
|
|
276
370
|
if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then
|
|
277
371
|
skipped_dirty=$((skipped_dirty + 1))
|
|
278
372
|
echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}"
|
|
@@ -339,6 +433,9 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
|
|
|
339
433
|
if branch_has_worktree "$branch"; then
|
|
340
434
|
continue
|
|
341
435
|
fi
|
|
436
|
+
if ! branch_idle_gate "$branch" "" "stale-merged-branch"; then
|
|
437
|
+
continue
|
|
438
|
+
fi
|
|
342
439
|
if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then
|
|
343
440
|
if run_cmd git -C "$repo_root" branch -d "$branch" >/dev/null 2>&1; then
|
|
344
441
|
removed_branches=$((removed_branches + 1))
|
|
@@ -356,7 +453,7 @@ fi
|
|
|
356
453
|
|
|
357
454
|
run_cmd git -C "$repo_root" worktree prune
|
|
358
455
|
|
|
359
|
-
echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}"
|
|
456
|
+
echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, idle_minutes=${IDLE_MINUTES}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}, skipped_recent=${skipped_recent}"
|
|
360
457
|
if [[ "$relocated_foreign" -gt 0 || "$skipped_foreign" -gt 0 ]]; then
|
|
361
458
|
echo "[agent-worktree-prune] Foreign routing: relocated=${relocated_foreign}, skipped=${skipped_foreign}"
|
|
362
459
|
fi
|
|
@@ -366,3 +463,6 @@ fi
|
|
|
366
463
|
if [[ "$skipped_dirty" -gt 0 ]]; then
|
|
367
464
|
echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2
|
|
368
465
|
fi
|
|
466
|
+
if [[ "$IDLE_SECONDS" -gt 0 && "$skipped_recent" -gt 0 ]]; then
|
|
467
|
+
echo "[agent-worktree-prune] Tip: recent branches were preserved by --idle-minutes=${IDLE_MINUTES}. Re-run later or lower the threshold." >&2
|
|
468
|
+
fi
|