@imdeadpool/guardex 5.0.9 → 5.0.12

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
@@ -1,8 +1,8 @@
1
1
  # GuardeX — Guardian T-Rex for your repo
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/%40imdeadpool%2Fguardex?color=cb3837&logo=npm)](https://www.npmjs.com/package/@imdeadpool/guardex)
4
- [![CI](https://github.com/recodeecom/multiagent-safety/actions/workflows/ci.yml/badge.svg)](https://github.com/recodeecom/multiagent-safety/actions/workflows/ci.yml)
5
- [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/recodeecom/multiagent-safety/badge)](https://securityscorecards.dev/viewer/?uri=github.com/recodeecom/multiagent-safety)
4
+ [![CI](https://github.com/recodeee/guardex/actions/workflows/ci.yml/badge.svg)](https://github.com/recodeee/guardex/actions/workflows/ci.yml)
5
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/recodeee/guardex/badge)](https://securityscorecards.dev/viewer/?uri=github.com/recodeee/guardex)
6
6
 
7
7
  GuardeX is a safety layer for parallel Codex/agent work in git repos.
8
8
 
@@ -17,7 +17,7 @@ Progress became **de-progressive**: more activity, less real forward movement.
17
17
 
18
18
  GuardeX exists to stop that loop.
19
19
 
20
- ![Multi-agent dashboard example](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/dashboard-multi-agent.png)
20
+ ![Multi-agent dashboard example](https://raw.githubusercontent.com/recodeee/guardex/main/docs/images/dashboard-multi-agent.png)
21
21
 
22
22
  ```mermaid
23
23
  flowchart LR
@@ -81,23 +81,23 @@ gx finish --all
81
81
 
82
82
  ### Setup status
83
83
 
84
- ![gx setup behavior screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/setup-success.svg)
84
+ ![gx setup behavior screenshot](https://raw.githubusercontent.com/recodeee/guardex/main/docs/images/setup-success.svg)
85
85
 
86
86
  ### Service logs/status
87
87
 
88
- ![gx status logs screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/status-tools-logs.svg)
88
+ ![gx status logs screenshot](https://raw.githubusercontent.com/recodeee/guardex/main/docs/images/status-tools-logs.svg)
89
89
 
90
90
  ### Branch/worktree start protocol
91
91
 
92
- ![gx branch start protocol screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-branch-start.svg)
92
+ ![gx branch start protocol screenshot](https://raw.githubusercontent.com/recodeee/guardex/main/docs/images/workflow-branch-start.svg)
93
93
 
94
94
  ### Lock + delete guard protocol
95
95
 
96
- ![gx lock and delete guard screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-lock-guard.svg)
96
+ ![gx lock and delete guard screenshot](https://raw.githubusercontent.com/recodeee/guardex/main/docs/images/workflow-lock-guard.svg)
97
97
 
98
98
  ### Real VS Code Source Control layout (exact screenshot)
99
99
 
100
- ![Real VS Code Source Control layout](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/workflow-vscode-source-control-exact.png)
100
+ ![Real VS Code Source Control layout](https://raw.githubusercontent.com/recodeee/guardex/main/docs/images/workflow-vscode-source-control-exact.png)
101
101
 
102
102
  ## Copy-paste: common commands
103
103
 
@@ -124,15 +124,24 @@ 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
- gx report scorecard --repo github.com/recodeecom/multiagent-safety
144
+ gx report scorecard --repo github.com/recodeee/guardex
136
145
  ```
137
146
 
138
147
  ### Continuous Codex PR monitor (local codex-auth session)
@@ -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`.
@@ -201,6 +241,40 @@ gh --version
201
241
  gh auth status
202
242
  ```
203
243
 
244
+ ## Optional GitHub Apps: fork sync + PR review
245
+
246
+ ### Pull app (Probot fork sync)
247
+
248
+ GuardeX setup now installs a starter file at `.github/pull.yml.example`.
249
+
250
+ To enable fork auto-sync:
251
+
252
+ ```sh
253
+ cp .github/pull.yml.example .github/pull.yml
254
+ ```
255
+
256
+ Then edit `.github/pull.yml`:
257
+
258
+ - set `rules[].base` to your fork branch (`main`, `master`, or `dev`)
259
+ - set `rules[].upstream` to `<upstream-owner>:<branch>`
260
+
261
+ Install the app: <https://github.com/apps/pull>
262
+ Validate config: `https://pull.git.ci/check/<owner>/<repo>`
263
+
264
+ ### CR-GPT code review app
265
+
266
+ Install app: <https://github.com/apps/cr-gpt>
267
+
268
+ `gx setup` also installs `.github/workflows/cr.yml` (GitHub Actions review workflow).
269
+
270
+ Then in your repo:
271
+
272
+ 1. `Settings -> Secrets and variables -> Actions`
273
+ 2. open `Variables`
274
+ 3. add `OPENAI_API_KEY`
275
+
276
+ After that, the app reviews new and updated pull requests automatically.
277
+
204
278
  ## Companion dependency: `codex-auth` account switcher
205
279
 
206
280
  For multi-identity Codex workflows, GuardeX pairs with
@@ -236,6 +310,8 @@ scripts/openspec/init-plan-workspace.sh
236
310
  .githooks/pre-push
237
311
  .codex/skills/guardex/SKILL.md
238
312
  .claude/commands/guardex.md
313
+ .github/pull.yml.example
314
+ .github/workflows/cr.yml
239
315
  .omx/state/agent-file-locks.json
240
316
  ```
241
317
 
@@ -247,6 +323,23 @@ If you enabled global OpenSpec install during setup (`@fission-ai/openspec`), us
247
323
 
248
324
  - [`docs/openspec-getting-started.md`](./docs/openspec-getting-started.md)
249
325
 
326
+ Default core flow:
327
+
328
+ ```text
329
+ /opsx:propose <change-name> -> /opsx:apply -> /opsx:archive
330
+ ```
331
+
332
+ Optional expanded flow:
333
+
334
+ ```sh
335
+ openspec config profile <profile-name>
336
+ openspec update
337
+ ```
338
+
339
+ ```text
340
+ /opsx:new <change-name> -> /opsx:ff or /opsx:continue -> /opsx:apply -> /opsx:verify -> /opsx:archive
341
+ ```
342
+
250
343
  ### OpenSpec in agent sub-branches
251
344
 
252
345
  - `scripts/codex-agent.sh` enforces an OpenSpec workspace before it launches Codex in each sandbox branch/worktree.
@@ -271,6 +364,21 @@ npm pack --dry-run
271
364
 
272
365
  ## Release notes
273
366
 
367
+ ### v5.0.12
368
+
369
+ - Bumped package version from `5.0.11` to `5.0.12` for the next npm publish.
370
+ - Updated repository metadata and README links to the renamed GitHub repository (`recodeee/guardex`).
371
+
372
+ ### v5.0.11
373
+
374
+ - 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.
375
+ - Ensured `gx install` explicitly configures the managed `AGENTS.md` policy block and added regression coverage for this install-path behavior.
376
+ - Bumped package version from `5.0.10` to `5.0.11` for the next npm publish.
377
+
378
+ ### v5.0.10
379
+
380
+ - Bumped package version from `5.0.9` to `5.0.10` for the next npm publish.
381
+
274
382
  ### v5.0.9
275
383
 
276
384
  - 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`.
@@ -50,7 +50,10 @@ const TEMPLATE_FILES = [
50
50
  'githooks/pre-commit',
51
51
  'githooks/pre-push',
52
52
  'codex/skills/guardex/SKILL.md',
53
+ 'codex/skills/guardex-merge-skills-to-dev/SKILL.md',
53
54
  'claude/commands/guardex.md',
55
+ 'github/pull.yml.example',
56
+ 'github/workflows/cr.yml',
54
57
  ];
55
58
 
56
59
  const EXECUTABLE_RELATIVE_PATHS = new Set([
@@ -78,6 +81,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([
78
81
  ]);
79
82
 
80
83
  const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json';
84
+ const AGENTS_BOTS_STATE_RELATIVE = '.omx/state/agents-bots.json';
81
85
  const AGENTS_MARKER_START = '<!-- multiagent-safety:START -->';
82
86
  const AGENTS_MARKER_END = '<!-- multiagent-safety:END -->';
83
87
  const GITIGNORE_MARKER_START = '# multiagent-safety:START';
@@ -96,6 +100,7 @@ const MANAGED_GITIGNORE_PATHS = [
96
100
  '.githooks/pre-push',
97
101
  'oh-my-codex/',
98
102
  '.codex/skills/guardex/SKILL.md',
103
+ '.codex/skills/guardex-merge-skills-to-dev/SKILL.md',
99
104
  '.claude/commands/guardex.md',
100
105
  LOCK_FILE_RELATIVE,
101
106
  ];
@@ -128,6 +133,7 @@ const SUGGESTIBLE_COMMANDS = [
128
133
  'init',
129
134
  'doctor',
130
135
  'review',
136
+ 'agents',
131
137
  'finish',
132
138
  'report',
133
139
  'copy-prompt',
@@ -154,7 +160,8 @@ const CLI_COMMAND_DESCRIPTIONS = [
154
160
  ['copy-commands', 'Print setup checklist as executable commands only'],
155
161
  ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
156
162
  ['sync', 'Check or sync agent branches with origin/<base>'],
157
- ['cleanup', 'Cleanup merged agent branches/worktrees (local + remote)'],
163
+ ['cleanup', 'Cleanup agent branches/worktrees (supports idle watch mode)'],
164
+ ['agents', 'Start/stop repo-scoped review + cleanup bots'],
158
165
  ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'],
159
166
  ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'],
160
167
  ['scan', 'Report safety issues and exit non-zero on findings'],
@@ -165,6 +172,7 @@ const CLI_COMMAND_DESCRIPTIONS = [
165
172
  ];
166
173
  const AGENT_BOT_DESCRIPTIONS = [
167
174
  ['review', 'Start PR monitor + codex-agent review flow (default interval: 30s)'],
175
+ ['agents', 'Start/stop both review and cleanup bots for this repo'],
168
176
  ];
169
177
 
170
178
  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,18 +211,48 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R
203
211
  - To finalize all completed agent branches in one pass:
204
212
  gx finish --all
205
213
 
206
- 6) Optional: create OpenSpec planning workspace:
214
+ 6) OpenSpec default change flow (core profile):
215
+ /opsx:propose <change-name>
216
+ /opsx:apply
217
+ /opsx:archive
218
+ - Full guide: docs/openspec-getting-started.md
219
+
220
+ 7) Optional: enable expanded OpenSpec workflow commands:
221
+ openspec config profile <profile-name>
222
+ openspec update
223
+ - Expanded path: /opsx:new -> /opsx:ff or /opsx:continue -> /opsx:apply -> /opsx:verify -> /opsx:archive
224
+
225
+ 8) Optional: create OpenSpec planning workspace:
207
226
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
208
227
 
209
- 7) Optional: protect extra branches:
228
+ 9) Optional: protect extra branches:
210
229
  gx protect add release staging
211
230
 
212
- 8) Optional: sync your current agent branch with latest base branch:
231
+ 10) Optional: sync your current agent branch with latest base branch:
213
232
  gx sync --check
214
233
  gx sync
215
234
 
216
- 9) Optional (GitHub remote cleanup): enable:
235
+ 11) Optional (GitHub remote cleanup): enable:
217
236
  Settings -> General -> Pull Requests -> Automatically delete head branches
237
+
238
+ 12) Optional (fork sync with Pull app):
239
+ cp .github/pull.yml.example .github/pull.yml
240
+ # then edit .github/pull.yml:
241
+ # - set rules[].base to your fork branch (main/master/dev)
242
+ # - set rules[].upstream to upstream-owner:branch
243
+ # install app: https://github.com/apps/pull
244
+ # validate config: https://pull.git.ci/check/<owner>/<repo>
245
+
246
+ 13) Optional (PR review bot with cr-gpt GitHub App):
247
+ - install app: https://github.com/apps/cr-gpt
248
+ - in GitHub repo Settings -> Secrets and variables -> Actions -> Variables:
249
+ add OPENAI_API_KEY (your API key)
250
+ - the app reviews new/updated pull requests automatically
251
+
252
+ 14) Optional: test PR review action workflow
253
+ - gx setup installs .github/workflows/cr.yml
254
+ - open or update a PR
255
+ - check Actions -> "Code Review" run logs + PR timeline comments
218
256
  `;
219
257
 
220
258
  const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex
@@ -229,9 +267,12 @@ bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)
229
267
  gx finish --all
230
268
  gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)"
231
269
  bash scripts/openspec/init-plan-workspace.sh "<plan-slug>"
270
+ openspec config profile <profile-name>
271
+ openspec update
232
272
  gx protect add release staging
233
273
  gx sync --check
234
274
  gx sync
275
+ cp .github/pull.yml.example .github/pull.yml
235
276
  `;
236
277
 
237
278
  const SCORECARD_RISK_BY_CHECK = {
@@ -435,6 +476,9 @@ function toDestinationPath(relativeTemplatePath) {
435
476
  if (relativeTemplatePath.startsWith('claude/')) {
436
477
  return `.${relativeTemplatePath}`;
437
478
  }
479
+ if (relativeTemplatePath.startsWith('github/')) {
480
+ return `.${relativeTemplatePath}`;
481
+ }
438
482
  throw new Error(`Unsupported template path: ${relativeTemplatePath}`);
439
483
  }
440
484
 
@@ -1567,6 +1611,69 @@ function parseReviewArgs(rawArgs) {
1567
1611
  };
1568
1612
  }
1569
1613
 
1614
+ function parseAgentsArgs(rawArgs) {
1615
+ const parsed = parseTargetFlag(rawArgs, process.cwd());
1616
+ const [subcommandRaw = '', ...rest] = parsed.args;
1617
+ const subcommand = subcommandRaw || 'status';
1618
+ const options = {
1619
+ target: parsed.target,
1620
+ subcommand,
1621
+ reviewIntervalSeconds: 30,
1622
+ cleanupIntervalSeconds: 60,
1623
+ idleMinutes: 10,
1624
+ };
1625
+
1626
+ for (let index = 0; index < rest.length; index += 1) {
1627
+ const arg = rest[index];
1628
+ if (arg === '--review-interval') {
1629
+ const next = rest[index + 1];
1630
+ if (!next) {
1631
+ throw new Error('--review-interval requires an integer seconds value');
1632
+ }
1633
+ const parsedValue = Number.parseInt(next, 10);
1634
+ if (!Number.isInteger(parsedValue) || parsedValue < 5) {
1635
+ throw new Error('--review-interval must be an integer >= 5 seconds');
1636
+ }
1637
+ options.reviewIntervalSeconds = parsedValue;
1638
+ index += 1;
1639
+ continue;
1640
+ }
1641
+ if (arg === '--cleanup-interval') {
1642
+ const next = rest[index + 1];
1643
+ if (!next) {
1644
+ throw new Error('--cleanup-interval requires an integer seconds value');
1645
+ }
1646
+ const parsedValue = Number.parseInt(next, 10);
1647
+ if (!Number.isInteger(parsedValue) || parsedValue < 5) {
1648
+ throw new Error('--cleanup-interval must be an integer >= 5 seconds');
1649
+ }
1650
+ options.cleanupIntervalSeconds = parsedValue;
1651
+ index += 1;
1652
+ continue;
1653
+ }
1654
+ if (arg === '--idle-minutes') {
1655
+ const next = rest[index + 1];
1656
+ if (!next) {
1657
+ throw new Error('--idle-minutes requires an integer minutes value');
1658
+ }
1659
+ const parsedValue = Number.parseInt(next, 10);
1660
+ if (!Number.isInteger(parsedValue) || parsedValue < 1) {
1661
+ throw new Error('--idle-minutes must be an integer >= 1');
1662
+ }
1663
+ options.idleMinutes = parsedValue;
1664
+ index += 1;
1665
+ continue;
1666
+ }
1667
+ throw new Error(`Unknown option: ${arg}`);
1668
+ }
1669
+
1670
+ if (!['start', 'stop', 'status'].includes(options.subcommand)) {
1671
+ throw new Error(`Unknown agents subcommand: ${options.subcommand}`);
1672
+ }
1673
+
1674
+ return options;
1675
+ }
1676
+
1570
1677
  function parseReportArgs(rawArgs) {
1571
1678
  const options = {
1572
1679
  target: process.cwd(),
@@ -2276,6 +2383,10 @@ function parseCleanupArgs(rawArgs) {
2276
2383
  forceDirty: false,
2277
2384
  keepRemote: false,
2278
2385
  keepCleanWorktrees: false,
2386
+ idleMinutes: 0,
2387
+ watch: false,
2388
+ intervalSeconds: 60,
2389
+ once: false,
2279
2390
  };
2280
2391
 
2281
2392
  for (let index = 0; index < rawArgs.length; index += 1) {
@@ -2323,9 +2434,47 @@ function parseCleanupArgs(rawArgs) {
2323
2434
  options.keepCleanWorktrees = true;
2324
2435
  continue;
2325
2436
  }
2437
+ if (arg === '--idle-minutes') {
2438
+ const next = rawArgs[index + 1];
2439
+ if (!next) {
2440
+ throw new Error('--idle-minutes requires an integer value');
2441
+ }
2442
+ const parsed = Number.parseInt(next, 10);
2443
+ if (!Number.isInteger(parsed) || parsed < 0) {
2444
+ throw new Error('--idle-minutes must be an integer >= 0');
2445
+ }
2446
+ options.idleMinutes = parsed;
2447
+ index += 1;
2448
+ continue;
2449
+ }
2450
+ if (arg === '--watch') {
2451
+ options.watch = true;
2452
+ continue;
2453
+ }
2454
+ if (arg === '--interval') {
2455
+ const next = rawArgs[index + 1];
2456
+ if (!next) {
2457
+ throw new Error('--interval requires an integer seconds value');
2458
+ }
2459
+ const parsed = Number.parseInt(next, 10);
2460
+ if (!Number.isInteger(parsed) || parsed < 5) {
2461
+ throw new Error('--interval must be an integer >= 5 seconds');
2462
+ }
2463
+ options.intervalSeconds = parsed;
2464
+ index += 1;
2465
+ continue;
2466
+ }
2467
+ if (arg === '--once') {
2468
+ options.once = true;
2469
+ continue;
2470
+ }
2326
2471
  throw new Error(`Unknown option: ${arg}`);
2327
2472
  }
2328
2473
 
2474
+ if (options.watch && options.idleMinutes === 0) {
2475
+ options.idleMinutes = 10;
2476
+ }
2477
+
2329
2478
  return options;
2330
2479
  }
2331
2480
 
@@ -3485,6 +3634,9 @@ function install(rawArgs) {
3485
3634
  printOperations('Install target', payload, options.dryRun);
3486
3635
 
3487
3636
  if (!options.dryRun) {
3637
+ if (!options.skipAgents) {
3638
+ console.log(`[${TOOL_NAME}] AGENTS.md managed policy block is configured by install.`);
3639
+ }
3488
3640
  console.log(`[${TOOL_NAME}] Installed. Next step: ${TOOL_NAME} setup`);
3489
3641
  }
3490
3642
 
@@ -3624,6 +3776,263 @@ function review(rawArgs) {
3624
3776
  process.exitCode = typeof result.status === 'number' ? result.status : 1;
3625
3777
  }
3626
3778
 
3779
+ function agentsStatePathForRepo(repoRoot) {
3780
+ return path.join(repoRoot, AGENTS_BOTS_STATE_RELATIVE);
3781
+ }
3782
+
3783
+ function readAgentsState(repoRoot) {
3784
+ const statePath = agentsStatePathForRepo(repoRoot);
3785
+ if (!fs.existsSync(statePath)) {
3786
+ return null;
3787
+ }
3788
+ try {
3789
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
3790
+ } catch (_error) {
3791
+ return null;
3792
+ }
3793
+ }
3794
+
3795
+ function writeAgentsState(repoRoot, state) {
3796
+ const statePath = agentsStatePathForRepo(repoRoot);
3797
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
3798
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
3799
+ }
3800
+
3801
+ function processAlive(pid) {
3802
+ const normalizedPid = Number.parseInt(String(pid || ''), 10);
3803
+ if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
3804
+ return false;
3805
+ }
3806
+ try {
3807
+ process.kill(normalizedPid, 0);
3808
+ return true;
3809
+ } catch (_error) {
3810
+ return false;
3811
+ }
3812
+ }
3813
+
3814
+ function sleepSeconds(seconds) {
3815
+ const result = run('sleep', [String(seconds)]);
3816
+ if (isSpawnFailure(result) || result.status !== 0) {
3817
+ throw new Error(`sleep command failed for ${seconds}s`);
3818
+ }
3819
+ }
3820
+
3821
+ function readProcessCommand(pid) {
3822
+ const result = run('ps', ['-o', 'command=', '-p', String(pid)]);
3823
+ if (isSpawnFailure(result) || result.status !== 0) {
3824
+ return '';
3825
+ }
3826
+ return String(result.stdout || '').trim();
3827
+ }
3828
+
3829
+ function stopAgentProcessByPid(pid, expectedToken = '') {
3830
+ const normalizedPid = Number.parseInt(String(pid || ''), 10);
3831
+ if (!Number.isInteger(normalizedPid) || normalizedPid <= 0) {
3832
+ return { status: 'invalid', pid: normalizedPid };
3833
+ }
3834
+ if (!processAlive(normalizedPid)) {
3835
+ return { status: 'not-running', pid: normalizedPid };
3836
+ }
3837
+
3838
+ if (expectedToken) {
3839
+ const cmdline = readProcessCommand(normalizedPid);
3840
+ if (cmdline && !cmdline.includes(expectedToken)) {
3841
+ return { status: 'mismatch', pid: normalizedPid, command: cmdline };
3842
+ }
3843
+ }
3844
+
3845
+ try {
3846
+ process.kill(-normalizedPid, 'SIGTERM');
3847
+ } catch (_error) {
3848
+ try {
3849
+ process.kill(normalizedPid, 'SIGTERM');
3850
+ } catch (_err) {
3851
+ return { status: 'term-failed', pid: normalizedPid };
3852
+ }
3853
+ }
3854
+
3855
+ const deadline = Date.now() + 3_000;
3856
+ while (Date.now() < deadline) {
3857
+ if (!processAlive(normalizedPid)) {
3858
+ return { status: 'stopped', pid: normalizedPid };
3859
+ }
3860
+ sleepSeconds(0.1);
3861
+ }
3862
+
3863
+ try {
3864
+ process.kill(-normalizedPid, 'SIGKILL');
3865
+ } catch (_error) {
3866
+ try {
3867
+ process.kill(normalizedPid, 'SIGKILL');
3868
+ } catch (_err) {
3869
+ return { status: 'kill-failed', pid: normalizedPid };
3870
+ }
3871
+ }
3872
+ sleepSeconds(0.1);
3873
+
3874
+ return {
3875
+ status: processAlive(normalizedPid) ? 'kill-failed' : 'stopped',
3876
+ pid: normalizedPid,
3877
+ };
3878
+ }
3879
+
3880
+ function spawnDetachedAgentProcess({ command, args, cwd, logPath }) {
3881
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
3882
+ const logHandle = fs.openSync(logPath, 'a');
3883
+ fs.writeSync(
3884
+ logHandle,
3885
+ `[${new Date().toISOString()}] spawn: ${command} ${args.join(' ')}\n`,
3886
+ );
3887
+ const child = cp.spawn(command, args, {
3888
+ cwd,
3889
+ detached: true,
3890
+ stdio: ['ignore', logHandle, logHandle],
3891
+ env: process.env,
3892
+ });
3893
+ fs.closeSync(logHandle);
3894
+ if (child.error) {
3895
+ throw child.error;
3896
+ }
3897
+ child.unref();
3898
+ const pid = Number.parseInt(String(child.pid || ''), 10);
3899
+ if (!Number.isInteger(pid) || pid <= 0) {
3900
+ throw new Error(`Failed to spawn detached process for ${command}`);
3901
+ }
3902
+ return pid;
3903
+ }
3904
+
3905
+ function agents(rawArgs) {
3906
+ const options = parseAgentsArgs(rawArgs);
3907
+ const repoRoot = resolveRepoRoot(options.target);
3908
+ const reviewScriptPath = path.join(repoRoot, 'scripts', 'review-bot-watch.sh');
3909
+ const pruneScriptPath = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh');
3910
+ const statePath = agentsStatePathForRepo(repoRoot);
3911
+
3912
+ if (options.subcommand === 'start') {
3913
+ if (!fs.existsSync(reviewScriptPath)) {
3914
+ throw new Error(
3915
+ `Missing review bot script: ${reviewScriptPath}\n` +
3916
+ `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
3917
+ );
3918
+ }
3919
+ if (!fs.existsSync(pruneScriptPath)) {
3920
+ throw new Error(
3921
+ `Missing cleanup script: ${pruneScriptPath}\n` +
3922
+ `Run '${SHORT_TOOL_NAME} setup --target ${repoRoot}' then '${SHORT_TOOL_NAME} doctor --target ${repoRoot}'.`,
3923
+ );
3924
+ }
3925
+
3926
+ const existingState = readAgentsState(repoRoot);
3927
+ const existingReviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
3928
+ const existingCleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
3929
+ const reviewRunning = processAlive(existingReviewPid);
3930
+ const cleanupRunning = processAlive(existingCleanupPid);
3931
+
3932
+ if (reviewRunning && cleanupRunning) {
3933
+ console.log(
3934
+ `[${TOOL_NAME}] Repo agents already running (review pid=${existingReviewPid}, cleanup pid=${existingCleanupPid}).`,
3935
+ );
3936
+ process.exitCode = 0;
3937
+ return;
3938
+ }
3939
+
3940
+ if (reviewRunning) {
3941
+ stopAgentProcessByPid(existingReviewPid, 'review-bot-watch.sh');
3942
+ }
3943
+ if (cleanupRunning) {
3944
+ stopAgentProcessByPid(existingCleanupPid, `${path.basename(__filename)} cleanup`);
3945
+ }
3946
+
3947
+ const reviewLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-review.log');
3948
+ const cleanupLogPath = path.join(repoRoot, '.omx', 'logs', 'agent-cleanup.log');
3949
+ const reviewPid = spawnDetachedAgentProcess({
3950
+ command: 'bash',
3951
+ args: [reviewScriptPath, '--interval', String(options.reviewIntervalSeconds)],
3952
+ cwd: repoRoot,
3953
+ logPath: reviewLogPath,
3954
+ });
3955
+ const cleanupPid = spawnDetachedAgentProcess({
3956
+ command: process.execPath,
3957
+ args: [
3958
+ path.resolve(__filename),
3959
+ 'cleanup',
3960
+ '--target',
3961
+ repoRoot,
3962
+ '--watch',
3963
+ '--interval',
3964
+ String(options.cleanupIntervalSeconds),
3965
+ '--idle-minutes',
3966
+ String(options.idleMinutes),
3967
+ ],
3968
+ cwd: repoRoot,
3969
+ logPath: cleanupLogPath,
3970
+ });
3971
+
3972
+ writeAgentsState(repoRoot, {
3973
+ schemaVersion: 1,
3974
+ repoRoot,
3975
+ startedAt: new Date().toISOString(),
3976
+ review: {
3977
+ pid: reviewPid,
3978
+ intervalSeconds: options.reviewIntervalSeconds,
3979
+ script: reviewScriptPath,
3980
+ logPath: reviewLogPath,
3981
+ },
3982
+ cleanup: {
3983
+ pid: cleanupPid,
3984
+ intervalSeconds: options.cleanupIntervalSeconds,
3985
+ idleMinutes: options.idleMinutes,
3986
+ script: path.resolve(__filename),
3987
+ logPath: cleanupLogPath,
3988
+ },
3989
+ });
3990
+
3991
+ console.log(
3992
+ `[${TOOL_NAME}] Started repo agents in ${repoRoot} (review pid=${reviewPid}, cleanup pid=${cleanupPid}).`,
3993
+ );
3994
+ console.log(`[${TOOL_NAME}] Logs: ${reviewLogPath}, ${cleanupLogPath}`);
3995
+ process.exitCode = 0;
3996
+ return;
3997
+ }
3998
+
3999
+ if (options.subcommand === 'stop') {
4000
+ const existingState = readAgentsState(repoRoot);
4001
+ if (!existingState) {
4002
+ console.log(`[${TOOL_NAME}] Repo agents are not running for ${repoRoot}.`);
4003
+ process.exitCode = 0;
4004
+ return;
4005
+ }
4006
+
4007
+ const reviewStop = stopAgentProcessByPid(existingState?.review?.pid, 'review-bot-watch.sh');
4008
+ const cleanupStop = stopAgentProcessByPid(existingState?.cleanup?.pid, `${path.basename(__filename)} cleanup`);
4009
+
4010
+ if (fs.existsSync(statePath)) {
4011
+ fs.unlinkSync(statePath);
4012
+ }
4013
+
4014
+ console.log(
4015
+ `[${TOOL_NAME}] Stopped repo agents in ${repoRoot} (review=${reviewStop.status}, cleanup=${cleanupStop.status}).`,
4016
+ );
4017
+ process.exitCode = 0;
4018
+ return;
4019
+ }
4020
+
4021
+ const existingState = readAgentsState(repoRoot);
4022
+ if (!existingState) {
4023
+ console.log(`[${TOOL_NAME}] Repo agents status: inactive (${repoRoot})`);
4024
+ process.exitCode = 0;
4025
+ return;
4026
+ }
4027
+
4028
+ const reviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
4029
+ const cleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
4030
+ console.log(
4031
+ `[${TOOL_NAME}] Repo agents status: review=${processAlive(reviewPid) ? 'running' : 'stopped'}(pid=${reviewPid || 0}), cleanup=${processAlive(cleanupPid) ? 'running' : 'stopped'}(pid=${cleanupPid || 0})`,
4032
+ );
4033
+ process.exitCode = 0;
4034
+ }
4035
+
3627
4036
  function report(rawArgs) {
3628
4037
  const options = parseReportArgs(rawArgs);
3629
4038
  const subcommand = options.subcommand || 'help';
@@ -3802,6 +4211,13 @@ function setup(rawArgs) {
3802
4211
  if (scanResult.errors === 0 && scanResult.warnings === 0) {
3803
4212
  console.log(`[${TOOL_NAME}] ✅ Setup complete.`);
3804
4213
  console.log(`[${TOOL_NAME}] Copy AI setup prompt with: ${SHORT_TOOL_NAME} copy-prompt`);
4214
+ console.log(
4215
+ `[${TOOL_NAME}] OpenSpec core workflow: /opsx:propose -> /opsx:apply -> /opsx:archive`,
4216
+ );
4217
+ console.log(
4218
+ `[${TOOL_NAME}] Optional expanded OpenSpec profile: openspec config profile <profile-name> && openspec update`,
4219
+ );
4220
+ console.log(`[${TOOL_NAME}] OpenSpec guide: docs/openspec-getting-started.md`);
3805
4221
  }
3806
4222
 
3807
4223
  setExitCodeFromScan(scanResult);
@@ -3895,15 +4311,42 @@ function cleanup(rawArgs) {
3895
4311
  if (!options.keepCleanWorktrees) {
3896
4312
  args.push('--only-dirty-worktrees');
3897
4313
  }
4314
+ if (options.idleMinutes > 0) {
4315
+ args.push('--idle-minutes', String(options.idleMinutes));
4316
+ }
3898
4317
  args.push('--delete-branches');
3899
4318
  if (!options.keepRemote) {
3900
4319
  args.push('--delete-remote-branches');
3901
4320
  }
3902
4321
 
3903
- const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
3904
- if (runResult.status !== 0) {
3905
- throw new Error('Cleanup command failed');
4322
+ const runCleanupCycle = () => {
4323
+ const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' });
4324
+ if (runResult.status !== 0) {
4325
+ throw new Error('Cleanup command failed');
4326
+ }
4327
+ };
4328
+
4329
+ if (options.watch) {
4330
+ let cycle = 0;
4331
+ while (true) {
4332
+ cycle += 1;
4333
+ console.log(
4334
+ `[${TOOL_NAME}] Cleanup watch cycle=${cycle} (interval=${options.intervalSeconds}s, idleMinutes=${options.idleMinutes}).`,
4335
+ );
4336
+ runCleanupCycle();
4337
+ if (options.once) {
4338
+ break;
4339
+ }
4340
+ const sleepResult = run('sleep', [String(options.intervalSeconds)], { cwd: repoRoot });
4341
+ if (sleepResult.status !== 0) {
4342
+ throw new Error(`Cleanup watch sleep failed (interval=${options.intervalSeconds}s)`);
4343
+ }
4344
+ }
4345
+ process.exitCode = 0;
4346
+ return;
3906
4347
  }
4348
+
4349
+ runCleanupCycle();
3907
4350
  process.exitCode = 0;
3908
4351
  }
3909
4352
 
@@ -4358,6 +4801,11 @@ function main() {
4358
4801
  return;
4359
4802
  }
4360
4803
 
4804
+ if (command === 'agents') {
4805
+ agents(rest);
4806
+ return;
4807
+ }
4808
+
4361
4809
  if (command === 'finish') {
4362
4810
  finish(rest);
4363
4811
  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.12",
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"
@@ -53,12 +54,12 @@
53
54
  "author": "recodeecom",
54
55
  "repository": {
55
56
  "type": "git",
56
- "url": "git+https://github.com/recodeecom/multiagent-safety.git"
57
+ "url": "git+https://github.com/recodeee/guardex.git"
57
58
  },
58
59
  "bugs": {
59
- "url": "https://github.com/recodeecom/multiagent-safety/issues"
60
+ "url": "https://github.com/recodeee/guardex/issues"
60
61
  },
61
- "homepage": "https://github.com/recodeecom/multiagent-safety#readme",
62
+ "homepage": "https://github.com/recodeee/guardex#readme",
62
63
  "funding": "https://github.com/sponsors/recodeecom",
63
64
  "publishConfig": {
64
65
  "access": "public"
@@ -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,12 +13,13 @@
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.
19
20
  - If merge/rebase conflicts block auto-finish, run a conflict-resolution review pass in that sandbox branch, then rerun `agent-branch-finish.sh --via-pr` until merged.
20
21
  - Completion is not valid until these are true: commit exists on the agent branch, branch is pushed to `origin`, and PR/merge status is produced by `agent-branch-finish.sh` or `codex-agent`.
21
- - For every new task, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch; otherwise create a fresh one from the current local base snapshot with `scripts/agent-branch-start.sh`.
22
+ - For every new task, including follow-up work in the same chat/session, if an assigned agent sub-branch/worktree is already open, continue in that sub-branch; otherwise create a fresh one from the current local base snapshot with `scripts/agent-branch-start.sh`.
22
23
  - Never implement directly on the local/base branch checkout; keep it unchanged and perform all edits in the agent sub-branch/worktree.
23
24
  - If the change publishes or bumps a version, the same change must also update release notes/changelog entries.
24
25
 
@@ -38,4 +38,52 @@ gx scan
38
38
  - For one-command Codex sandbox startup, use `bash scripts/codex-agent.sh "<task>" "<agent-name>"`.
39
39
  - `scripts/codex-agent.sh` auto-syncs the sandbox branch against base before each task and auto-finishes merge/PR flow after Codex exits.
40
40
  - Auto-finish keeps the branch/worktree by default; remove merged branches explicitly with `gx cleanup` (or `gx cleanup --branch "<agent-branch>"`).
41
+ - For skill-file-only merges into the local base branch (`dev` by default), use `$guardex-merge-skills-to-dev`.
41
42
  - Do not bypass protected branch safeguards unless explicitly required.
43
+
44
+ ## Bulk merge runbook (changed agent branches)
45
+
46
+ Use this when a repo has many `agent/*` branches/worktrees with pending changes and you need them merged into the base branch quickly.
47
+
48
+ 1. Confirm base and guardrails are healthy:
49
+
50
+ ```sh
51
+ git status --short --branch
52
+ git pull --ff-only origin "$(git config --get multiagent.baseBranch || echo dev)"
53
+ gx scan
54
+ ```
55
+
56
+ 2. Run bulk finish first:
57
+
58
+ ```sh
59
+ gx finish --all
60
+ ```
61
+
62
+ 3. If a branch fails with `already used by worktree` or stale rebase hints, clear the stale state in that worktree, then retry targeted finish:
63
+
64
+ ```sh
65
+ git -C "<worktree>" rebase --abort || true
66
+ gx finish --branch "<agent-branch>" --base "$(git config --get multiagent.baseBranch || echo dev)" --no-wait-for-merge --cleanup
67
+ ```
68
+
69
+ 4. If `gh pr merge` exits non-zero due local branch deletion but PR is already merged, treat it as merged and verify with:
70
+
71
+ ```sh
72
+ gh pr view "<pr-number>" --json state,mergedAt,url
73
+ ```
74
+
75
+ 5. If a branch is still ahead of base with no open PR, create and merge a follow-up PR manually:
76
+
77
+ ```sh
78
+ gh pr create --base "<base-branch>" --head "<agent-branch>" --title "Auto-finish: <agent-branch>" --body "Follow-up merge for pending branch commits."
79
+ gh pr merge "<pr-number>" --squash --delete-branch
80
+ ```
81
+
82
+ 6. Final verification:
83
+
84
+ ```sh
85
+ gh pr list --state open --search "head:agent/ base:<base-branch>"
86
+ git pull --ff-only origin "<base-branch>"
87
+ gx cleanup
88
+ gx scan
89
+ ```
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: guardex-merge-skills-to-dev
3
+ description: "Use when you need to merge SKILL.md updates from agent branches/worktrees into the local base branch (default: dev) with the multiagent-safety flow."
4
+ ---
5
+
6
+ # GuardeX Merge Skills to dev
7
+
8
+ Use this skill when you only want to promote Codex skill file updates into the base branch (normally `dev`) without editing the visible base checkout directly.
9
+
10
+ ## What this merges
11
+
12
+ - `.codex/skills/**/SKILL.md`
13
+ - `templates/codex/skills/**/SKILL.md`
14
+
15
+ ## Merge runbook (safe path)
16
+
17
+ 1. Resolve the base branch:
18
+
19
+ ```sh
20
+ BASE_BRANCH="$(git config --get multiagent.baseBranch || echo dev)"
21
+ echo "$BASE_BRANCH"
22
+ ```
23
+
24
+ 2. Start a dedicated integration sandbox from base:
25
+
26
+ ```sh
27
+ bash scripts/agent-branch-start.sh "merge-skill-files-to-${BASE_BRANCH}" "skill-merge" "$BASE_BRANCH"
28
+ ```
29
+
30
+ 3. Enter the sandbox worktree printed by the command above.
31
+
32
+ 4. Pull only skill files from each source agent branch:
33
+
34
+ ```sh
35
+ SOURCE_BRANCH="<agent-branch>"
36
+ git checkout "$SOURCE_BRANCH" -- ':(glob).codex/skills/**/SKILL.md' ':(glob)templates/codex/skills/**/SKILL.md'
37
+ ```
38
+
39
+ 5. Verify scope before commit:
40
+
41
+ ```sh
42
+ git status --short
43
+ git diff --name-only
44
+ ```
45
+
46
+ 6. Commit and merge back to base using guardex finish flow:
47
+
48
+ ```sh
49
+ git add .codex/skills templates/codex/skills
50
+ git commit -m "Merge skill file updates into ${BASE_BRANCH}"
51
+ bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" --base "$BASE_BRANCH" --via-pr --wait-for-merge --cleanup
52
+ ```
53
+
54
+ ## Notes
55
+
56
+ - If a source branch has non-skill changes, this runbook keeps them out of the merge.
57
+ - If merge conflicts occur, resolve only within the skill files, then rerun `agent-branch-finish.sh`.
58
+ - Do not commit directly on `dev`/`main`; always merge through an agent branch/worktree.
@@ -0,0 +1,6 @@
1
+ version: "1"
2
+ rules:
3
+ - base: main
4
+ upstream: upstream-owner:main
5
+ mergeMethod: hardreset
6
+ mergeUnstable: true
@@ -0,0 +1,21 @@
1
+ name: Code Review
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, reopened, synchronize]
6
+
7
+ permissions:
8
+ contents: read
9
+ pull-requests: write
10
+
11
+ jobs:
12
+ review:
13
+ if: ${{ secrets.OPENAI_API_KEY != '' }}
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: anc95/ChatGPT-CodeReview@main
17
+ env:
18
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
20
+ OPENAI_API_ENDPOINT: https://api.openai.com/v1
21
+ MODEL: gpt-4o-mini
@@ -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