@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`.
@@ -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 merged agent branches/worktrees (local + remote)'],
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) Optional: create OpenSpec planning workspace:
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
- 7) Optional: protect extra branches:
224
+ 9) Optional: protect extra branches:
210
225
  gx protect add release staging
211
226
 
212
- 8) Optional: sync your current agent branch with latest base branch:
227
+ 10) Optional: sync your current agent branch with latest base branch:
213
228
  gx sync --check
214
229
  gx sync
215
230
 
216
- 9) Optional (GitHub remote cleanup): enable:
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 runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
3904
- if (runResult.status !== 0) {
3905
- throw new Error('Cleanup command failed');
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.9",
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 (multiagent-safety)
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