mandrel 2.66.0 → 2.68.0

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.
Files changed (85) hide show
  1. package/.agents/agents/acceptance-critic.md +2 -2
  2. package/.agents/agents/story-worker.md +15 -11
  3. package/.agents/docs/agentrc-reference.json +5 -2
  4. package/.agents/docs/configuration.md +38 -2
  5. package/.agents/docs/workflows.md +4 -2
  6. package/.agents/instructions.md +2 -1
  7. package/.agents/rules/git-conventions-reference.md +5 -5
  8. package/.agents/rules/git-conventions.md +1 -1
  9. package/.agents/schemas/agentrc.schema.json +20 -2
  10. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  11. package/.agents/schemas/validation-evidence.schema.json +3 -1
  12. package/.agents/scripts/boot-sweep.js +97 -9
  13. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  14. package/.agents/scripts/clean-temp.js +54 -0
  15. package/.agents/scripts/clean-worktrees.js +593 -0
  16. package/.agents/scripts/coverage-capture.js +65 -9
  17. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  18. package/.agents/scripts/evidence-gate.js +106 -8
  19. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  20. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  21. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  22. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  23. package/.agents/scripts/lib/child-exec.js +39 -1
  24. package/.agents/scripts/lib/clean-temp.js +440 -0
  25. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  26. package/.agents/scripts/lib/close-validation/process.js +23 -24
  27. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  28. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  29. package/.agents/scripts/lib/config/quality.js +7 -1
  30. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
  32. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  33. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  34. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  35. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  36. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  37. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  38. package/.agents/scripts/lib/coverage-capture.js +121 -81
  39. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  40. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/source-classifier.js +4 -1
  43. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  44. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  45. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  46. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  47. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  48. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  49. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  50. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  51. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  53. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  54. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  55. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  56. package/.agents/scripts/lib/process-group.js +1 -1
  57. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  58. package/.agents/scripts/lib/supervised-suite.js +247 -0
  59. package/.agents/scripts/lib/temp-removal.js +110 -0
  60. package/.agents/scripts/lib/temp-retention.js +122 -73
  61. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  62. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  63. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  64. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  65. package/.agents/scripts/quality-preview.js +112 -14
  66. package/.agents/scripts/single-story-init.js +120 -17
  67. package/.agents/scripts/stories-wave-tick.js +47 -0
  68. package/.agents/scripts/story-review-compute.js +207 -0
  69. package/.agents/scripts/update-coverage-baseline.js +15 -10
  70. package/.agents/scripts/update-crap-baseline.js +12 -2
  71. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  72. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  73. package/.agents/workflows/clean-temp.md +67 -0
  74. package/.agents/workflows/clean-worktrees.md +63 -0
  75. package/.agents/workflows/git-deliver.md +1 -1
  76. package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
  77. package/.agents/workflows/helpers/code-review.md +7 -5
  78. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  79. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  80. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  81. package/.agents/workflows/helpers/deliver-story.md +2 -1
  82. package/docs/CHANGELOG.md +39 -0
  83. package/lib/cli/registry.js +125 -18
  84. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  85. package/package.json +1 -1
@@ -98,8 +98,8 @@ For each acceptance item:
98
98
 
99
99
  ## Verdict schema (MUST)
100
100
 
101
- Write **one** verdict file under `temp/` (e.g.
102
- `temp/acceptance-verdict-<storyId>-r<round>.json`) conforming to
101
+ Write **one** verdict file under `temp/scratch/story-<storyId>/` (e.g.
102
+ `temp/scratch/story-<storyId>/acceptance-verdict-r<round>.json`) conforming to
103
103
  [`acceptance-eval-verdict.schema.json`](../schemas/acceptance-eval-verdict.schema.json):
104
104
  one `criteria[]` record per acceptance item, in acceptance-array order, with
105
105
  `index` being the criterion's position in that array.
@@ -87,14 +87,16 @@ names. A null digest path means no docs mandate.
87
87
 
88
88
  `single-story-close.js` runs the canonical close-validation chain
89
89
  (**typecheck, lint, test, format, maintainability, coverage, crap**) and is
90
- the authoritative gate — do not pre-run it. The **one** exception is the
91
- full suite: after the self-eval loop's last fix commit, run it once in
90
+ the authoritative gate — do not pre-run it. Two exceptions, both run in
92
91
  `<workCwd>` exactly as
93
- [`deliver-digest.md`](../workflows/helpers/deliver-digest.md) § 5 states it —
94
- that section is the rule's only home, so read the invocation there rather
95
- than from a copy here.
96
-
97
- If the suite outruns the host's sync Bash ceiling, dispatch it in the
92
+ [`deliver-digest.md`](../workflows/helpers/deliver-digest.md) § 5 states them:
93
+ the **blocking** lint + quality-preview preflight, then the one credited run
94
+ (the coverage capture or the test depositor, as § 5 picks), once after the
95
+ self-eval loop's last fix commit, then the `--seat-missing` baseline seat
96
+ before push. That section is their only home, so read the invocations there
97
+ rather than from a copy here.
98
+
99
+ If the run outruns the host's sync Bash ceiling, dispatch it in the
98
100
  **background**: its completion re-invokes you. Never spawn a task to poll or
99
101
  `sleep`-loop against it; a waiter with a wrong condition outlives the agent.
100
102
  An exit code is never evidence a gate did work — its **output** is. Redraft
@@ -142,11 +144,13 @@ the remote ref moved — then return: a turn that ends unpushed reads as
142
144
  unfinished work. The orchestrator runs
143
145
  `single-story-close.js` in its own session, serialized against your
144
146
  siblings. Do not open the PR, flip `agent::done`, or spawn a child to
145
- close for you. If the push fails, take the blocked path above.
147
+ close for you. If the push fails, take the blocked path above. After
148
+ the push, compute the held review and fix a CRITICAL before returning
149
+ ([`deliver-reference.md`](../workflows/helpers/deliver-reference.md) § Held review).
146
150
 
147
151
  ## Return contract — the hand-off report
148
152
 
149
153
  A short, literal hand-off your caller can act on: Story id, `workCwd`,
150
- branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say the
151
- branch is pushed and unclosed. Never hand-compose a terminal envelope —
152
- inventing one makes an unlanded Story look landed.
154
+ branch, pushed head SHA, self-eval verdict, `verify[]` evidence, review
155
+ tally. Say the branch is pushed and unclosed. Never hand-compose a
156
+ terminal envelope — inventing one makes an unlanded Story look landed.
@@ -87,7 +87,8 @@
87
87
  "orchestrationLogs": true,
88
88
  "validationEvidence": true,
89
89
  "auditResults": true,
90
- "planDirs": true
90
+ "planDirs": true,
91
+ "scratch": true
91
92
  }
92
93
  },
93
94
  "deliverRunner": {
@@ -124,7 +125,9 @@
124
125
  "functions": 90
125
126
  }
126
127
  },
127
- "coveragePath": "coverage/coverage-final.json"
128
+ "coveragePath": "coverage/coverage-final.json",
129
+ "captureScope": "full",
130
+ "timeoutMs": 600000
128
131
  },
129
132
  "crap": {
130
133
  "enabled": true,
@@ -136,16 +136,17 @@ Everything `/mandrel-deliver` and `single-story-close` consume: worktree isolati
136
136
  | Key | Required | Type | Default | Description |
137
137
  | --- | --- | --- | --- | --- |
138
138
  | `execution` | No | `object` | — | Serialization of the full-suite spawns delivery drives. |
139
- | `execution.fullSuiteLock` | No | `boolean` | `true` | Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable. |
139
+ | `execution.fullSuiteLock` | No | `boolean` | `true` | Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. The lock queues, it never overlaps: a wait that expires with a live holder spawns nothing and exits 75 (resumable), naming the holder. A dead or non-heartbeating holder is taken over, and a broken lockfile proceeds unserialized, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable. |
140
140
  | `docsFreshness` | No | `object` | — | Documentation-freshness scope: the files a change of consequence is expected to touch. Read by the audit-documentation lens to seed its target set; no delivery gate enforces it. |
141
141
  | `docsFreshness.paths` | No | `array<string>` | `["README.md"]` | Repo-relative documentation paths the audit-documentation lens adds to its target set. |
142
- | `tempRetention` | No | `object` | — | Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path. |
142
+ | `tempRetention` | No | `object` | — | Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so unrecognized files under tempRoot are reported with their size and left alone (`/clean-temp` is the operator path for them). signals.ndjson is never purged by any path. |
143
143
  | `tempRetention.enabled` | No | `boolean` | `true` | Master switch. Default true — reclaiming a landed Story's gate transcripts and validation evidence is the behaviour, and this knob turns it off. When false every purge path is a reported no-op. |
144
144
  | `tempRetention.classes` | No | `object` | — | Per-class opt-out. Each defaults to true; set one false to keep that family while the rest are purged. |
145
145
  | `tempRetention.classes.orchestrationLogs` | No | `boolean` | `true` | <tempRoot>/orchestration/*.log — close gate transcripts and terse-result detail dumps. |
146
146
  | `tempRetention.classes.validationEvidence` | No | `boolean` | `true` | Per-Story validation-evidence.json, lifecycle.ndjson, and manifest.md under the standalone and per-run story trees. |
147
147
  | `tempRetention.classes.auditResults` | No | `boolean` | `true` | <tempRoot>/audits/ — audit lens reports. |
148
148
  | `tempRetention.classes.planDirs` | No | `boolean` | `true` | <tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. Age-floored only; the current run is always excluded. |
149
+ | `tempRetention.classes.scratch` | No | `boolean` | `true` | <tempRoot>/scratch/ — agent-authored scratch. `scratch/story-<id>/` is purged when that Story lands; any other `scratch/` entry is age-floored. |
149
150
  | `deliverRunner` | No | `object` | — | Bounded-concurrency knob for the /mandrel-deliver fan-out. |
150
151
  | `deliverRunner.concurrencyCap` | No | `integer` | `3` | Maximum ready Stories dispatched by /mandrel-deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff. |
151
152
  | `deliverRunner.footprintGuard` | No | `"enforce"` \| `"advisory"` | `"enforce"` | How a file-footprint collision affects dispatch. 'enforce' (default, and the behaviour to keep unless you have a reason) withholds a Story whose footprint races a peer admitted this beat or one still in flight — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground that moved since planning) that no depends_on edge can carry. 'advisory' still DETECTS every collision and reports each would-be withhold in the tick envelope, but lets dispatch follow the declared depends_on edges alone — a deliberate throughput trade for a run whose ordering is fully declared. See stories-wave-tick.js and helpers/deliver-reference.md. |
@@ -168,6 +169,8 @@ Everything `/mandrel-deliver` and `single-story-close` consume: worktree isolati
168
169
  | `quality.gates.coverage.floors` | No | `object<map>` | `{"*":{"lines":90,"branches":85,"functions":90}}` | Workspace-keyed absolute floors: `{ "<workspace>": { "<metric>": number } }`. `"*"` is the project-wide catch-all; the metric keyset is open so per-rollup keys flow through without each gate enumerating them. Floors are absolute — unlike `tolerance`, they are enforced regardless of the baseline. |
169
170
  | `quality.gates.coverage.components` | No | `object<map>` | — | Per-gate component map — component name to the glob list whose files roll up under it. Defaults to `{ "*": ["**"] }` at the resolver layer. |
170
171
  | `quality.gates.coverage.coveragePath` | No | `string` | `"coverage/coverage-final.json"` | Repo-relative path to the Istanbul `coverage-final.json` the capture step writes and the gate reads. |
172
+ | `quality.gates.coverage.captureScope` | No | `"full"` \| `"affected"` | `"full"` | What coverage-capture runs. `full` (default) runs `npm run test:coverage`. `affected` runs the consumer-owned `npm run test:coverage:affected` with the base ref in `MANDREL_COVERAGE_BASE_REF`, merges its rows over the prior artifact and stamps it `affected`; baseline rows the scoped run did not measure are treated as unmeasured, never removed. Falls back to `full` with a warning when the script is absent. Meant for consumers whose CI already enforces coverage on the full suite. |
173
+ | `quality.gates.coverage.timeoutMs` | No | `integer` | `600000` | Kill bound (ms) for one full-suite run — the coverage capture, the close-validation full-suite gate and the full-suite lock-wait budget all read it. The clock starts at spawn, never while queued on the host lock; a suite that signals `MANDREL_SUITE_READY_FILE` gets a fresh bound for its test phase, so worst-case wall is lock wait + 2 × timeoutMs. On expiry the run exits 124 so callers can tell a hang from a failure. |
171
174
  | `quality.gates.crap` | No | `object` | — | CRAP (Change Risk Anti-Pattern) ratchet — per-method cyclomatic complexity joined against per-method coverage. |
172
175
  | `quality.gates.crap.enabled` | No | `boolean` | `true` | When false, the checker exits 0 with a skip line and the gate is reported as `skipped`, never omitted. |
173
176
  | `quality.gates.crap.baselinePath` | No | `string` | `"baselines/crap.json"` | Repo-root-relative path to the gate's committed baseline artifact. |
@@ -364,6 +367,39 @@ A config still carrying the retired key is a hard validation failure; the
364
367
  Extend the list-valued gate keys with the deep-merge extender form (see
365
368
  [How to extend](#how-to-extend)).
366
369
 
370
+ #### `delivery.quality.gates.coverage.captureScope` — delta refresh
371
+
372
+ Under `captureScope: "affected"`, a capture stamp records the commit it
373
+ measured. When close's base-sync merges `main` into the Story branch, the
374
+ next capture re-measures only the merged commits' tests instead of the
375
+ Story's whole affected scope: it runs `npm run test:coverage:affected` with
376
+ `MANDREL_COVERAGE_BASE_REF` set to the **stamped commit**, drops the prior
377
+ rows of the delta's files, merges the new rows over the prior artifact and
378
+ stamps the merged tree fresh. It holds the full-suite lock like any capture,
379
+ and a red delta refresh fails the capture — it never falls back to a wider
380
+ run.
381
+
382
+ A delta refresh runs only when **every** condition holds; otherwise the
383
+ capture behaves exactly as before (the Story's affected scope, or the full
384
+ suite when the script is absent):
385
+
386
+ - the stamp is stale by content digest, and records a `commit` (a stamp
387
+ written before this field existed never qualifies) that is an ancestor of
388
+ `HEAD`;
389
+ - `captureScope` is `affected` and the `test:coverage:affected` script
390
+ exists;
391
+ - the worktree is clean, and the delta (`git diff --name-only <commit> HEAD`)
392
+ is non-empty and shares no path with the Story's change set;
393
+ - the delta touches no coverage-determining config: `package.json`, a
394
+ lockfile, `tsconfig*.json`, or a vitest / jest / c8 / nyc config;
395
+ - a prior artifact exists to merge over.
396
+
397
+ Any git error or unresolvable input fails closed to the ordinary capture.
398
+ Every freshness log line names the stamp's scope, the required scope and the
399
+ verdict (`fresh`, `stale`, `scope-mismatch`, `missing`, or `delta-refresh`
400
+ with the delta's file count), so an unexpected full re-run is explainable
401
+ from the log alone. `captureScope: "full"` is unaffected.
402
+
367
403
  #### `delivery.worktreeIsolation` — node_modules strategies
368
404
 
369
405
  When `enabled: true`, each Story runs in its own worktree under
@@ -32,7 +32,7 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
32
32
  fails when it drifts from the on-disk workflow set. To change a command’s
33
33
  description, edit the workflow file’s front-matter and regenerate.
34
34
 
35
- ## Commands (29)
35
+ ## Commands (31)
36
36
 
37
37
  | Command | Description |
38
38
  | --- | --- |
@@ -55,7 +55,9 @@ description, edit the workflow file’s front-matter and regenerate.
55
55
  | `/audit-sre` | "Audit production-readiness for a release candidate: SLOs, observability, runbooks, error budgets, and rollback paths." |
56
56
  | `/audit-to-stories` | Convert findings produced by the audit-\* workflows into actionable GitHub Stories. Reads temp/audits/audit-\*-results.md, groups findings cross-audit, deduplicates against existing Issues by fingerprint, and either chains into /mandrel-plan --seed-file or opens standalone Stories. |
57
57
  | `/audit-ux-ui` | Audit UX/UI consistency and design system adherence |
58
- | `/git-cleanup` | Tidy the local checkout in four phases: fast-forward `main`, prune stale remote-tracking refs, sweep merged branches (squash-aware), and triage `git stash` entries — each step gated by operator confirmation. |
58
+ | `/clean-git` | Tidy the local checkout in four phases: fast-forward `main`, prune stale remote-tracking refs, sweep merged branches (squash-aware), and triage `git stash` entries — each step gated by operator confirmation. |
59
+ | `/clean-temp` | Clear the temp-tree backlog the land-time purge cannot attribute: sort every top-level entry under the project's tempRoot into framework, closed-issue, aged and kept buckets, preview by default, and delete only confirmed buckets. |
60
+ | `/clean-worktrees` | Reclaim disk from dead worktrees: list every worktree of this project as a removal candidate (closed Story, merged branch, orphaned directory, detached HEAD) or as kept with a reason, then remove candidates only on `--execute`. |
59
61
  | `/git-deliver` | Single ad-hoc delivery command for working-tree changes. Detects the git setup and escalates to the right terminal step — commit only, commit + push, or commit + push + open a PR with native auto-merge — picking the default from observable state and letting flags pin any level explicitly. Replaces the retired git-commit-all, git-push, and git-pr-all trio. |
60
62
  | `/mandrel-deliver` | Unified delivery entry point. Takes Story ids or a plain-language prompt, derives which path the work belongs on, and lands it via the single deliver-story engine — story-<id> → PR → main. |
61
63
  | `/mandrel-plan` | Unified planning entry point. Interrogate → author → persist. Emits one Story by default; splits into N>1 only under the default-single split policy. |
@@ -181,7 +181,8 @@ never delivered): `/mandrel-plan` offers one above 2 Stories and
181
181
 
182
182
  All temporary files, scratch scripts, and intermediate outputs MUST
183
183
  live in the gitignored workspace-root `/temp/` directory — do NOT commit
184
- anything under it.
184
+ anything under it. Put ad-hoc scratch in `temp/scratch/story-<id>/` (or
185
+ `temp/scratch/` with no Story), the layout the temp purge reaps.
185
186
 
186
187
  ---
187
188
 
@@ -96,13 +96,13 @@ signature is worth naming:
96
96
 
97
97
  **Invariant (stated in the core): the delivering flow owns tidying the local
98
98
  checkout — reaping its own merged refs and fast-forwarding the base branch.
99
- `/git-cleanup` is a recovery tool, not a routine chore.** The outcome every
99
+ `/clean-git` is a recovery tool, not a routine chore.** The outcome every
100
100
  delivering flow (`/mandrel-deliver`, `/git-deliver`) guarantees, with the mechanics
101
- owned by `boot-sweep.js` / `git-cleanup.js`:
101
+ owned by `boot-sweep.js` / `clean-git.js`:
102
102
 
103
103
  - **`main` is fast-forwarded** by the flow itself in its cleanup phase, so the
104
104
  next init seeds from a current base. No workflow ends by telling the operator
105
- to run `/git-cleanup` to catch up.
105
+ to run `/clean-git` to catch up.
106
106
  - **Merged local refs are reaped** at the next workflow boot's protected sweep
107
107
  (`boot-sweep.js`) — every local branch whose PR is already merged, skipping
108
108
  any candidate with unpushed work, a dirty worktree, or a still-open parent
@@ -112,9 +112,9 @@ owned by `boot-sweep.js` / `git-cleanup.js`:
112
112
  weaker content-equivalence signal (`detectedBy: 'content-merged'` — content
113
113
  already landed in the base by another route, with no merged PR or git
114
114
  ancestry of its own) is **never** reaped by the boot sweep; it is surfaced
115
- under `contentMerged` for the operator to send to `/git-cleanup` for a
115
+ under `contentMerged` for the operator to send to `/clean-git` for a
116
116
  confirmed, eyeballed reap.
117
- - **`/git-cleanup` is recovery, not routine.** Run it by hand only for a state
117
+ - **`/clean-git` is recovery, not routine.** Run it by hand only for a state
118
118
  the automated hygiene does not cover — triaging stashes, reaping across
119
119
  non-standard namespaces, or `--remote` pruning after a force-push. Reaching
120
120
  for it after every routine delivery signals the owning flow's hygiene step
@@ -56,7 +56,7 @@ subject referencing the Story via `(refs #<storyId>)` — see
56
56
  **The delivering flow owns tidying the local checkout** — it
57
57
  fast-forwards the base branch itself and reaps its own merged refs on
58
58
  the next workflow boot (the `boot-sweep.js` protected sweep).
59
- `/git-cleanup` is a recovery tool, not a routine chore — never end a
59
+ `/clean-git` is a recovery tool, not a routine chore — never end a
60
60
  workflow by telling the operator to run it. Scope rules and the
61
61
  shared-checkout contention guard:
62
62
  [`git-conventions-reference.md` § Local checkout hygiene](git-conventions-reference.md).
@@ -359,7 +359,7 @@
359
359
  "properties": {
360
360
  "fullSuiteLock": {
361
361
  "type": "boolean",
362
- "description": "Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.",
362
+ "description": "Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. The lock queues, it never overlaps: a wait that expires with a live holder spawns nothing and exits 75 (resumable), naming the holder. A dead or non-heartbeating holder is taken over, and a broken lockfile proceeds unserialized, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.",
363
363
  "default": true
364
364
  }
365
365
  },
@@ -386,7 +386,7 @@
386
386
  },
387
387
  "tempRetention": {
388
388
  "type": "object",
389
- "description": "Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path.",
389
+ "description": "Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so unrecognized files under tempRoot are reported with their size and left alone (`/clean-temp` is the operator path for them). signals.ndjson is never purged by any path.",
390
390
  "properties": {
391
391
  "enabled": {
392
392
  "type": "boolean",
@@ -416,6 +416,11 @@
416
416
  "type": "boolean",
417
417
  "description": "<tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. Age-floored only; the current run is always excluded.",
418
418
  "default": true
419
+ },
420
+ "scratch": {
421
+ "type": "boolean",
422
+ "description": "<tempRoot>/scratch/ — agent-authored scratch. `scratch/story-<id>/` is purged when that Story lands; any other `scratch/` entry is age-floored.",
423
+ "default": true
419
424
  }
420
425
  },
421
426
  "additionalProperties": false
@@ -595,6 +600,19 @@
595
600
  "minLength": 1,
596
601
  "description": "Repo-relative path to the Istanbul `coverage-final.json` the capture step writes and the gate reads.",
597
602
  "default": "coverage/coverage-final.json"
603
+ },
604
+ "captureScope": {
605
+ "type": "string",
606
+ "enum": ["full", "affected"],
607
+ "description": "What coverage-capture runs. `full` (default) runs `npm run test:coverage`. `affected` runs the consumer-owned `npm run test:coverage:affected` with the base ref in `MANDREL_COVERAGE_BASE_REF`, merges its rows over the prior artifact and stamps it `affected`; baseline rows the scoped run did not measure are treated as unmeasured, never removed. Falls back to `full` with a warning when the script is absent. Meant for consumers whose CI already enforces coverage on the full suite.",
608
+ "default": "full"
609
+ },
610
+ "timeoutMs": {
611
+ "type": "integer",
612
+ "minimum": 60000,
613
+ "maximum": 7200000,
614
+ "description": "Kill bound (ms) for one full-suite run — the coverage capture, the close-validation full-suite gate and the full-suite lock-wait budget all read it. The clock starts at spawn, never while queued on the host lock; a suite that signals `MANDREL_SUITE_READY_FILE` gets a fresh bound for its test phase, so worst-case wall is lock wait + 2 × timeoutMs. On expiry the run exits 124 so callers can tell a hang from a failure.",
615
+ "default": 600000
598
616
  }
599
617
  },
600
618
  "additionalProperties": false
@@ -200,7 +200,29 @@
200
200
  "required": ["waitedSeconds", "expired"],
201
201
  "properties": {
202
202
  "waitedSeconds": { "type": "number", "minimum": 0 },
203
- "expired": { "type": "boolean" }
203
+ "expired": { "type": "boolean" },
204
+ "holder": {
205
+ "type": "object",
206
+ "description": "Story #5485 — the live holder an expired wait gave up on, read from the lockfile: its owner id, pid and lock age. Each field is null when the lockfile did not say.",
207
+ "required": ["ownerId", "pid", "ageSeconds"],
208
+ "properties": {
209
+ "ownerId": { "type": ["string", "null"] },
210
+ "pid": { "type": ["integer", "null"], "minimum": 1 },
211
+ "ageSeconds": { "type": ["number", "null"], "minimum": 0 }
212
+ },
213
+ "additionalProperties": false
214
+ }
215
+ },
216
+ "additionalProperties": false
217
+ },
218
+ "suiteTimings": {
219
+ "type": ["object", "null"],
220
+ "description": "Story #5485 — the close's one full-suite run split into three separate figures: lockWaitMs (queued on the host full-suite lock), hostWaitMs (spawn until the suite signalled MANDREL_SUITE_READY_FILE; null when it did not use the handshake) and testRunMs (the test phase the coverage.timeoutMs kill bound applies to). null when no full suite ran in this close.",
221
+ "required": ["lockWaitMs", "hostWaitMs", "testRunMs"],
222
+ "properties": {
223
+ "lockWaitMs": { "type": "number", "minimum": 0 },
224
+ "hostWaitMs": { "type": ["number", "null"], "minimum": 0 },
225
+ "testRunMs": { "type": "number", "minimum": 0 }
204
226
  },
205
227
  "additionalProperties": false
206
228
  },
@@ -41,10 +41,12 @@
41
41
  "check-baselines-independent",
42
42
  "check-baselines-coverage",
43
43
  "quality-preview",
44
+ "quality-preview-mi",
45
+ "quality-preview-crap",
44
46
  "check-maintainability",
45
47
  "check-crap"
46
48
  ],
47
- "description": "Stable gate identifier. Closed enum — additions require a schema bump. Must be a superset of every gate name buildDefaultGates() can emit (lib/close-validation/gates.js); tests/close-validation-gates-enum.test.js pins that gate-list ⊆ enum invariant. `coverage-capture` and `check-baselines` are the real close-validation gates (Story #4697); `check-baselines-independent` / `check-baselines-coverage` are the split pair the gate registers as when its enabled-kind set resolves (Story #5172), with `check-baselines` kept as both the unsplit fail-closed fallback and the historical name; `quality-preview` replays the pre-push CRAP-scope preview at close, registered beside `coverage-capture` (Story #5378); `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
49
+ "description": "Stable gate identifier. Closed enum — additions require a schema bump. Must be a superset of every gate name buildDefaultGates() can emit (lib/close-validation/gates.js); tests/close-validation-gates-enum.test.js pins that gate-list ⊆ enum invariant. `coverage-capture` and `check-baselines` are the real close-validation gates (Story #4697); `check-baselines-independent` / `check-baselines-coverage` are the split pair the gate registers as when its enabled-kind set resolves (Story #5172), with `check-baselines` kept as both the unsplit fail-closed fallback and the historical name; `quality-preview-mi` / `quality-preview-crap` replay the pre-push quality preview at close as its two halves — the MI half in the parallel partition, the CRAP half serial behind `coverage-capture` (Story #5471) — with `quality-preview` kept as the historical name of the unsplit gate (Story #5378); `check-maintainability` / `check-crap` are the retired per-kind gates (Story #2210) kept so historical evidence records still validate."
48
50
  },
49
51
  "commitSha": {
50
52
  "type": "string",
@@ -3,13 +3,18 @@
3
3
 
4
4
  /**
5
5
  * boot-sweep.js — non-interactive *protected* merged-branch sweep over
6
- * `sweepMergedBranches` (flags: see HELP). Unlike `git-cleanup --branches` it
6
+ * `sweepMergedBranches` (flags: see HELP). Unlike `clean-git --branches` it
7
7
  * always skips a branch with unpushed work, a dirty worktree or an open
8
8
  * parent Story. Best-effort: failures land in the envelope, exit is always 0.
9
9
  *
10
+ * After the branch sweep it runs the closed-Story worktree sweep
11
+ * (`sweepStaleStoryWorktrees`) under the same lock: `.worktrees/story-<id>`
12
+ * trees whose Story is closed or `agent::done` are removed; an open Story's
13
+ * tree and the tree this process runs from are never touched.
14
+ *
10
15
  * `content-merged` branches (merge-tree equivalence — no merge check ever
11
16
  * validated their exact diff) are never reaped here, only reported for
12
- * `/git-cleanup`.
17
+ * `/clean-git`.
13
18
  */
14
19
 
15
20
  import path from 'node:path';
@@ -18,9 +23,13 @@ import { parseArgs } from 'node:util';
18
23
  import { runAsCli } from './lib/cli-utils.js';
19
24
  import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
20
25
  import { Logger } from './lib/Logger.js';
26
+ import { sweepStaleStoryWorktrees } from './lib/orchestration/plan-runner/worktree-sweep.js';
21
27
  import { createProvider } from './lib/provider-factory.js';
22
28
  import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
23
- import { resolveSweepLockPath } from './lib/single-story-sweep/sweep-lock.js';
29
+ import {
30
+ acquireSweepLock,
31
+ resolveSweepLockPath,
32
+ } from './lib/single-story-sweep/sweep-lock.js';
24
33
  import { sweepMergedBranches } from './lib/single-story-sweep.js';
25
34
  import { sweepTempRetention } from './lib/temp-retention.js';
26
35
 
@@ -46,10 +55,13 @@ Runs the protected merged-branch boot sweep non-interactively: reaps every
46
55
  local branch whose PR is MERGED and whose HEAD matches the merged headRefOid,
47
56
  skipping any candidate the protection partition flags (unpushed work, dirty
48
57
  worktree, still-open parent Story), then fast-forwards the base branch.
58
+ Then removes every .worktrees/story-<id> tree whose Story is closed or
59
+ agent::done (never an open Story's, never the tree this process runs from);
60
+ the outcome lands under "worktreeSweep".
49
61
  Branches detected only via the weaker content-equivalence signal
50
62
  (detectedBy: 'content-merged') are never reaped here — they are reported
51
63
  under "contentMerged" (and a routing hint in the summary line) for the
52
- operator to send to /git-cleanup.
64
+ operator to send to /clean-git.
53
65
 
54
66
  Options:
55
67
  --include <glob> Branch glob to sweep (repeatable). Default: story-*
@@ -60,6 +72,63 @@ Options:
60
72
  --json Emit the result envelope as JSON.
61
73
  `;
62
74
 
75
+ /**
76
+ * The closed-Story worktree sweep under the shared sweep lock; never throws.
77
+ * A contended lock skips it — the holder's next boot picks the trees up.
78
+ * Shared with `single-story-init.js`, whose boot never reaches
79
+ * {@link runBootSweep}: one sweep, one lock, two callers. `keepPaths` joins
80
+ * the sweep's running-tree guard — init passes the tree it is about to
81
+ * work in, so the Story being initialized is never removed.
82
+ *
83
+ * @param {{
84
+ * root: string,
85
+ * provider: object,
86
+ * lockPath: string,
87
+ * lockTimeoutMs: number,
88
+ * sweepFn?: Function,
89
+ * acquireLockFn?: Function,
90
+ * logger: object,
91
+ * logTag?: string,
92
+ * keepPaths?: string[],
93
+ * }} args
94
+ * @returns {Promise<object>} `{ ok, reaped, skipped, reason?, error? }`.
95
+ */
96
+ export async function runWorktreeSweep({
97
+ root,
98
+ provider,
99
+ lockPath,
100
+ lockTimeoutMs,
101
+ sweepFn = sweepStaleStoryWorktrees,
102
+ acquireLockFn = acquireSweepLock,
103
+ logger,
104
+ logTag = '[boot-sweep]',
105
+ keepPaths = [],
106
+ }) {
107
+ const lock = acquireLockFn({ lockPath, timeoutMs: lockTimeoutMs });
108
+ if (!lock.acquired) {
109
+ return { ok: true, reason: `lock-${lock.reason}`, reaped: [], skipped: [] };
110
+ }
111
+ try {
112
+ const result = await sweepFn({
113
+ provider,
114
+ repoRoot: root,
115
+ runningPaths: keepPaths,
116
+ logger: {
117
+ info: (m) => logger.info?.(`${logTag} ${m}`),
118
+ warn: (m) => logger.warn?.(`${logTag} ${m}`),
119
+ error: (m) => logger.warn?.(`${logTag} ${m}`),
120
+ },
121
+ });
122
+ return { ok: true, ...result };
123
+ } catch (err) {
124
+ const msg = err?.message ?? String(err);
125
+ logger.warn?.(`${logTag} worktree sweep threw (host continues): ${msg}`);
126
+ return { ok: false, error: msg, reaped: [], skipped: [] };
127
+ } finally {
128
+ lock.release();
129
+ }
130
+ }
131
+
63
132
  /**
64
133
  * Run the protected boot sweep; never throws.
65
134
  *
@@ -73,11 +142,13 @@ Options:
73
142
  * injectedConfig?: object,
74
143
  * injectedProvider?: object,
75
144
  * injectedSweep?: Function,
145
+ * worktreeSweepFn?: Function,
146
+ * acquireLockFn?: Function,
76
147
  * purgeFn?: Function,
77
148
  * logger?: { info?: Function, warn?: Function },
78
149
  * }} [args]
79
150
  * @returns {Promise<object>} the {@link sweepMergedBranches} envelope plus
80
- * `tempPurge`.
151
+ * `worktreeSweep` and `tempPurge`.
81
152
  */
82
153
  export async function runBootSweep({
83
154
  cwd,
@@ -89,6 +160,8 @@ export async function runBootSweep({
89
160
  injectedConfig,
90
161
  injectedProvider,
91
162
  injectedSweep,
163
+ worktreeSweepFn = sweepStaleStoryWorktrees,
164
+ acquireLockFn = acquireSweepLock,
92
165
  purgeFn = sweepTempRetention,
93
166
  logger = Logger,
94
167
  } = {}) {
@@ -130,6 +203,16 @@ export async function runBootSweep({
130
203
  lockTimeoutMs,
131
204
  });
132
205
 
206
+ const worktreeSweep = await runWorktreeSweep({
207
+ root,
208
+ provider,
209
+ lockPath,
210
+ lockTimeoutMs,
211
+ sweepFn: worktreeSweepFn,
212
+ acquireLockFn,
213
+ logger,
214
+ });
215
+
133
216
  // Temp-retention catch-up: reaped branches are confirmed merges, so their
134
217
  // artifacts are spent; the age floor collects the rest.
135
218
  const purge = await purgeFn({
@@ -138,7 +221,7 @@ export async function runBootSweep({
138
221
  label: 'boot-sweep',
139
222
  logger,
140
223
  });
141
- return { ...result, tempPurge: purge };
224
+ return { ...result, worktreeSweep, tempPurge: purge };
142
225
  } catch (err) {
143
226
  const msg = err?.message ?? String(err);
144
227
  logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
@@ -157,7 +240,7 @@ export async function runBootSweep({
157
240
  }
158
241
 
159
242
  /**
160
- * One-line summary; a nonzero `contentMerged` count adds a `/git-cleanup` hint.
243
+ * One-line summary; a nonzero `contentMerged` count adds a `/clean-git` hint.
161
244
  *
162
245
  * @param {{ localDeleted: number, remoteDeleted: number, protected?: Array, contentMerged?: Array }} result
163
246
  * @returns {string}
@@ -165,11 +248,16 @@ export async function runBootSweep({
165
248
  export function buildSummaryLine(result) {
166
249
  const protectedCount = result.protected?.length ?? 0;
167
250
  const contentMergedCount = result.contentMerged?.length ?? 0;
251
+ const worktreesReaped = result.worktreeSweep?.reaped?.length ?? 0;
252
+ const worktreeSuffix =
253
+ worktreesReaped > 0
254
+ ? `; removed ${worktreesReaped} closed-Story worktree(s)`
255
+ : '';
168
256
  const contentMergedSuffix =
169
257
  contentMergedCount > 0
170
- ? `; ${contentMergedCount} content-merged branch(es) left for /git-cleanup`
258
+ ? `; ${contentMergedCount} content-merged branch(es) left for /clean-git`
171
259
  : '';
172
- return `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}${contentMergedSuffix}.`;
260
+ return `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}${worktreeSuffix}${contentMergedSuffix}.`;
173
261
  }
174
262
 
175
263
  /**
@@ -100,10 +100,10 @@ async function main() {
100
100
  }
101
101
 
102
102
  runAsCli(import.meta.url, main, {
103
- source: 'git-cleanup',
103
+ source: 'clean-git',
104
104
  usage: {
105
105
  invocation:
106
- 'node .agents/scripts/git-cleanup.js [--execute] [--yes] [--json] [phase flags] [filters]',
106
+ 'node .agents/scripts/clean-git.js [--execute] [--yes] [--json] [phase flags] [filters]',
107
107
  summary:
108
108
  'Tidy the local checkout in four phases — fast-forward the base branch, prune stale remote refs, reap merged branches, triage stashes. Dry-run unless --execute.',
109
109
  flags: [
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /* node:coverage ignore file -- thin CLI shell; the engine is lib/clean-temp.js */
3
+
4
+ /**
5
+ * `/clean-temp` — preview (default) or delete spent entries under the
6
+ * project's tempRoot. Thin shell over `lib/clean-temp.js`.
7
+ *
8
+ * Exit codes: 0 clean or dry-run, 1 refused (tempRoot outside the project
9
+ * root) or a deletion failed.
10
+ */
11
+
12
+ import { runCleanTemp } from './lib/clean-temp.js';
13
+ import { runAsCli } from './lib/cli-utils.js';
14
+ import { resolveConfig } from './lib/config-resolver.js';
15
+ import { promptYesNo } from './lib/orchestration/git-cleanup/phases/prompts.js';
16
+ import { createProvider } from './lib/provider-factory.js';
17
+
18
+ /* node:coverage ignore next */
19
+ async function main() {
20
+ const { exitCode } = await runCleanTemp({
21
+ argv: process.argv.slice(2),
22
+ cwd: process.cwd(),
23
+ loadConfig: (projectRoot) => resolveConfig({ cwd: projectRoot }),
24
+ getProvider: createProvider,
25
+ confirm: promptYesNo,
26
+ write: (text) => process.stdout.write(text),
27
+ writeErr: (text) => process.stderr.write(text),
28
+ });
29
+ return exitCode;
30
+ }
31
+
32
+ runAsCli(import.meta.url, main, {
33
+ source: 'clean-temp',
34
+ propagateExitCode: true,
35
+ usage: {
36
+ invocation:
37
+ 'node .agents/scripts/clean-temp.js [--execute] [--yes] [--json] [--cwd <path>]',
38
+ summary:
39
+ "Sort every top-level entry under the project's tempRoot into framework / closed-issue / aged / kept buckets. Dry-run unless --execute.",
40
+ flags: [
41
+ ['--execute', 'Delete the confirmed buckets (default is a dry run).'],
42
+ [
43
+ '--yes',
44
+ 'Skip the per-bucket prompts; deletes framework and closed-issue only — aged is never deleted unattended.',
45
+ ],
46
+ ['--json', 'Emit the result envelope as JSON.'],
47
+ ['--cwd <path>', 'Project directory (default: process cwd).'],
48
+ ],
49
+ notes: [
50
+ 'Refuses (exit 1) when the resolved tempRoot is not inside the project root.',
51
+ 'qa/, cache/, *.lock and signals.ndjson (at any depth) are never deleted.',
52
+ ],
53
+ },
54
+ });