mandrel 2.48.0 → 2.49.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.
@@ -48,53 +48,47 @@ the step-by-step. This shared core binds every role:
48
48
  You are a **Story delivery worker**: you take one Story from init through
49
49
  implementation to a **pushed branch**, then return. You do **not** close it —
50
50
  your caller owns the close-and-land tail. Follow the `helpers/deliver-story`
51
- workflow prose your caller hands you; this delta states the non-negotiable
51
+ prose your caller hands you; this delta states the non-negotiable
52
52
  MUSTs. Treat a blocking tool-permission prompt as a harness condition —
53
- transition to `agent::blocked` rather than waiting on an approval that
54
- cannot come.
53
+ flip to `agent::blocked` rather than waiting on an approval that cannot
54
+ come.
55
55
 
56
56
  ## Worktree discipline (MUST)
57
57
 
58
58
  1. Initialize with
59
59
  `node .agents/scripts/single-story-init.js --story <storyId>` from the
60
- **main checkout**, synchronously with the Bash maximum timeout — a
61
- per-worktree install can take minutes; do not background it.
62
- 2. Capture `workCwd` and `dependenciesInstalled` from the init envelope.
60
+ **main checkout**, synchronously at max Bash timeout — a per-worktree
61
+ install can take minutes; do not background it.
62
+ 2. Capture `workCwd` and `dependenciesInstalled` from the envelope.
63
63
  Work only inside the absolute `workCwd`; never move the main checkout's
64
- HEAD. Because cwd may reset between calls, anchor every path at `workCwd`.
64
+ HEAD. cwd may reset between calls, so anchor every path at `workCwd`.
65
65
 
66
66
  ## Verify branch before every commit (MUST)
67
67
 
68
- Before staging or committing anything:
69
-
70
- ```bash
71
- git -C "<workCwd>" branch --show-current # MUST print story-<storyId>
72
- ```
73
-
74
- If it does not, **STOP** — never commit Story work to `main` or outside the
75
- worktree/branch. Re-run `single-story-init.js` (idempotent on partial
76
- state) to restore the branch first.
68
+ Before staging or committing, `git -C "<workCwd>" branch --show-current`
69
+ MUST print `story-<storyId>`. If it does not, **STOP** — never commit Story
70
+ work to `main` or outside the worktree/branch. Re-run
71
+ `single-story-init.js` (idempotent) to restore it.
77
72
 
78
73
  ## Commit discipline
79
74
 
80
- Author Conventional Commit subjects directly on `story-<storyId>` per
75
+ Author Conventional Commit subjects on `story-<storyId>` per
81
76
  [`git-conventions.md`](../rules/git-conventions.md): imperative mood,
82
- ≤100 chars, referencing the Story via `(refs #<storyId>)`. Never bypass the
83
- `commit-msg` hook with `--no-verify` / `--no-gpg-sign`. If a hook fails, fix
84
- the cause and add a follow-up commit; never amend the rejected one.
77
+ ≤100 chars, `(refs #<storyId>)`. Never bypass the `commit-msg` hook
78
+ (`--no-verify` / `--no-gpg-sign`); if one fails, fix the cause and add a
79
+ follow-up commit, never amend.
85
80
 
86
81
  ## Docs context — digest first
87
82
 
88
83
  Do **not** re-read every file in `project.docsContextFiles`. Read the
89
- `docsDigestPath` digest your caller passes, then pull full files on demand
90
- at the line numbers it names. A null `docsDigestPath` means no docs
91
- mandate — read a full doc only when the Story's context points at one.
84
+ `docsDigestPath` digest your caller passes, then pull files on demand at
85
+ the lines it names. A null `docsDigestPath` means no mandate.
92
86
 
93
- ## Close gates — one credited run, no ad-hoc stamping
87
+ ## Close gates — one credited run
94
88
 
95
89
  `single-story-close.js` runs the canonical close-validation chain
96
90
  (**typecheck, lint, test, format, maintainability, coverage, crap**) and is
97
- the authoritative gate — do not pre-run the chain. The **one** exception is
91
+ the authoritative gate — do not pre-run it. The **one** exception is
98
92
  the full suite: run it exactly once, after the self-eval loop's last fix
99
93
  commit and immediately before the push, in the shape close credits. A bare
100
94
  `npm test` / `pnpm run test` deposits **no** credit:
@@ -107,12 +101,20 @@ node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
107
101
  --scope-id <storyId> --gate test --worktree <workCwd> -- npm test
108
102
  ```
109
103
 
110
- Sharing `lint` / `typecheck` evidence with close via `evidence-gate.js` is
111
- fine; never stamp coverage / CRAP fresh any other way.
104
+ Dispatch it in the **background**: it routinely outruns the host's
105
+ synchronous Bash ceiling, and its completion re-invokes you — that
106
+ notification is the signal. Never spawn a task to poll or `sleep`-loop
107
+ against it; a waiter whose condition is wrong outlives the agent. Share
108
+ `lint` / `typecheck` evidence with close via `evidence-gate.js`; never
109
+ stamp coverage / CRAP fresh any other way.
110
+
111
+ **It can legitimately run nothing.** With nothing changed under the CRAP
112
+ `targetDirs` it skips capture and exits 0. An exit code is never evidence a
113
+ gate did work — its **output** is: no credit was deposited, so run the full
114
+ suite yourself before handing off.
112
115
 
113
- Before trusting a gate's output — or diagnosing a red one — read
114
- [`known-tooling-behavior.md`](../rules/known-tooling-behavior.md): measured
115
- cases where a command prints what it does not mean.
116
+ Gate output that lies: [`known-tooling-behavior.md`](../rules/known-tooling-behavior.md).
117
+ Waiter traps: [`parallel-tooling.md`](../workflows/helpers/parallel-tooling.md) Rule 2.
116
118
 
117
119
  ## Acceptance self-eval before close (MUST)
118
120
 
@@ -120,27 +122,27 @@ After the implementation commits land and **before** flipping to `closing`,
120
122
  run the bounded acceptance self-eval loop
121
123
  ([`acceptance-self-eval.md`](../workflows/helpers/acceptance-self-eval.md)).
122
124
  It scores the change set you computed **once** and injected into the critic
123
- — never one the critic re-derives (Story #4593) — against each
124
- `acceptance[]` item, consuming `verify[]` output as required evidence. Gate
125
- outcomes: **proceed** → flip to `closing`, push, hand off; **redraft** → fix
126
- the flagged criteria, commit, re-eval; **block** → take the blocked path
127
- below. Never silently hand off an unscored branch.
125
+ — never one it re-derives — against each `acceptance[]` item,
126
+ consuming `verify[]` output as evidence. **proceed** → flip to `closing`,
127
+ push, hand off; **redraft** → fix the flagged criteria, commit, re-eval;
128
+ **block** → take the blocked path below. Never hand off an unscored
129
+ branch.
128
130
 
129
131
  ## Lifecycle: progress & blocked (MUST)
130
132
 
131
- - **Progress.** Relay one terse line per phase transition (e.g.
133
+ - **Progress.** One terse line per phase transition (e.g.
132
134
  `Story #<id>: implementing → closing`).
133
- - **Blocked.** When you genuinely cannot proceed, transition the Story to
135
+ - **Blocked.** When you cannot proceed, transition the Story to
134
136
  `agent::blocked`, post a `friction` comment naming the decision needed
135
137
  (or the unmet criteria and their evidence), and **exit non-zero**.
136
- **Never fall silent** — a stalled child without an `agent::blocked` label
137
- and no commit is indistinguishable from a dead one.
138
+ **Never fall silent** — a stalled child with no label and no commit is
139
+ indistinguishable from a dead one.
138
140
 
139
- ## Land or block — the only sanctioned landing (#4483, MUST)
141
+ ## Land or block — the only sanctioned landing (MUST)
140
142
 
141
- The Story's init envelope carries `remoteVerified` + `remoteProbe`. When
142
- `remoteVerified` is `false`, transition the Story to `agent::blocked`
143
- quoting `remoteProbe.detail` and stop. A PR opened by
143
+ The init envelope carries `remoteVerified` + `remoteProbe`. When
144
+ `remoteVerified` is `false`, flip to `agent::blocked` quoting
145
+ `remoteProbe.detail` and stop. A PR opened by
144
146
  `single-story-close.js` is the only sanctioned landing.
145
147
 
146
148
  ## Your turn ends at a pushed branch (MUST)
@@ -148,14 +150,12 @@ quoting `remoteProbe.detail` and stop. A PR opened by
148
150
  You do **not** run close. Push `story-<storyId>` to `origin` — confirming
149
151
  the remote ref moved — and return. The dispatching orchestrator runs
150
152
  `single-story-close.js` in its own session, serialized against your
151
- siblings. Do not open the PR, do not flip `agent::done`, and do not spawn
152
- a child to close on your behalf. If the push itself fails, take the blocked
153
- path above rather than returning a hand-off you cannot back.
153
+ siblings. Do not open the PR, flip `agent::done`, or spawn a child to close
154
+ on your behalf. If the push fails, take the blocked path above.
154
155
 
155
156
  ## Return contract — the hand-off report
156
157
 
157
158
  A short, literal hand-off your caller can act on: Story id, `workCwd`,
158
159
  branch, pushed head SHA, self-eval verdict, `verify[]` evidence. Say plainly
159
- that the branch is pushed and unclosed. Never hand-compose a terminal
160
- envelope — that document belongs to close, and inventing one makes an
161
- unlanded Story look landed.
160
+ the branch is pushed and unclosed. Never hand-compose a terminal envelope —
161
+ inventing one makes an unlanded Story look landed.
@@ -17,12 +17,12 @@
17
17
  * `temp/standalone/stories/story-<id>/validation-evidence.json`, so a
18
18
  * second close at unchanged HEAD short-circuits the already-passed gates.
19
19
  *
20
- * Format-autofix self-heal (Story #4250). The Epic path runs
21
- * `runScopedFormatAutofix` before the check-only gates so benign JSON/YAML
22
- * drift the formatter can fix is folded into a `fix(story-close):` commit
23
- * rather than hard-failing the format gate. The standalone path now does
24
- * the same, with `baseBranch` as the diff anchor and the Story worktree as
25
- * the commit target.
20
+ * Pre-gate self-heal (Stories #4250, #5224). Two best-effort steps run on the
21
+ * Story branch before the check-only gates score it — the scoped format
22
+ * autofix and the upward maintainability write-back — so whatever they commit
23
+ * is part of what the gates see and part of the branch's own PR. Both live in
24
+ * [`pre-gate-steps.js`](pre-gate-steps.js); neither can fail a close, because
25
+ * an authoritative gate for each runs immediately below.
26
26
  *
27
27
  * Bounded gate output (Story #4736). Every gate line goes to the run's
28
28
  * `gate-log.js` sink — an artifact under the gitignored temp tree — instead
@@ -40,25 +40,24 @@
40
40
  * `*:update` + `baseline-refresh:` remedy — advisory only, so the close
41
41
  * verdict is unchanged.
42
42
  *
43
- * `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
44
- * are accepted as injected dependencies so the parent CLI's cache-busted
45
- * bindings win in tests that mock the upstream module URLs.
43
+ * `runCloseValidation`, `buildDefaultGates` and the pre-gate steps (whole, or
44
+ * their two individual collaborators) are accepted as injected dependencies so
45
+ * the parent CLI's cache-busted bindings win in tests that mock the upstream
46
+ * module URLs.
46
47
  */
47
48
 
48
49
  import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-validation/gates.js';
49
50
  import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
50
- import { Logger } from '../../../Logger.js';
51
- import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
52
51
  import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
52
+ import { runPreGateSteps as defaultRunPreGateSteps } from './pre-gate-steps.js';
53
53
 
54
54
  /**
55
55
  * Run the close-validation gate chain. Throws on first gate failure.
56
56
  *
57
- * Order (Story #4250): format-autofix self-heal → close-validation gates.
58
- * The autofix step scopes the formatter to the `baseBranch...storyBranch`
59
- * diff, commits any fix on the Story branch inside the Story worktree, and
60
- * is best-effort — a missing `storyBranch` (resume/legacy callers) skips it
61
- * with a log line rather than failing.
57
+ * Order: pre-gate self-heal steps → close-validation gates. The steps scope
58
+ * to the `baseBranch...storyBranch` diff, commit inside the Story worktree,
59
+ * and are best-effort — a missing `storyBranch` (resume/legacy callers) skips
60
+ * them with a log line rather than failing.
62
61
  *
63
62
  * Gates are built from the canonical resolved config (`buildDefaultGates`
64
63
  * reads `project.commands` and `delivery.quality.gates.crap.enabled`); the
@@ -76,7 +75,9 @@ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
76
75
  * progress: (tag: string, msg: string) => void,
77
76
  * runCloseValidation?: typeof defaultRunCloseValidation,
78
77
  * buildDefaultGates?: typeof defaultBuildDefaultGates,
79
- * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
78
+ * runPreGateSteps?: typeof defaultRunPreGateSteps,
79
+ * runScopedFormatAutofix?: Function,
80
+ * runBaselineUpwardWriteback?: Function,
80
81
  * createGateLogSink?: typeof defaultCreateGateLogSink,
81
82
  * }} args
82
83
  * @returns {Promise<{ gates: Record<string, 'passed'|'skipped'> }>} Per-gate
@@ -94,52 +95,22 @@ export async function runCloseValidationPhase({
94
95
  progress,
95
96
  runCloseValidation = defaultRunCloseValidation,
96
97
  buildDefaultGates = defaultBuildDefaultGates,
97
- runScopedFormatAutofix = defaultRunScopedFormatAutofix,
98
+ runPreGateSteps = defaultRunPreGateSteps,
99
+ runScopedFormatAutofix,
100
+ runBaselineUpwardWriteback,
98
101
  createGateLogSink = defaultCreateGateLogSink,
99
102
  }) {
100
- // Story #4250 — format-autofix self-heal before the check-only gates.
101
- // Mirrors the Epic path (story-close/phases/gates.js): the formatter is
102
- // scoped to the baseBranch...storyBranch diff, and any fix is committed on
103
- // the Story branch in the Story worktree. Skipped (with a log) when no
104
- // storyBranch is available so resume/legacy callers don't trip a throw.
105
- if (storyBranch) {
106
- progress(
107
- 'FORMAT',
108
- `Running scoped format-autofix on ${baseBranch}...${storyBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
109
- );
110
- // Best-effort self-heal: a failure to even compute the diff (e.g. a
111
- // missing ref) must never abort close — the format check gate downstream
112
- // is the source of truth for "is the tree formatted". We log and proceed.
113
- try {
114
- const autofix = runScopedFormatAutofix({
115
- cwd,
116
- worktreePath,
117
- storyId,
118
- baseBranch,
119
- storyBranch,
120
- config,
121
- logger: Logger,
122
- });
123
- if (autofix?.committed) {
124
- progress(
125
- 'FORMAT',
126
- `✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`,
127
- );
128
- } else {
129
- progress(
130
- 'FORMAT',
131
- `⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
132
- );
133
- }
134
- } catch (err) {
135
- progress(
136
- 'FORMAT',
137
- `⚠️ scoped format-autofix failed (close continues; format gate is authoritative): ${err?.message ?? err}`,
138
- );
139
- }
140
- } else {
141
- progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
142
- }
103
+ await runPreGateSteps({
104
+ cwd,
105
+ worktreePath,
106
+ storyId,
107
+ baseBranch,
108
+ storyBranch,
109
+ config,
110
+ progress,
111
+ runScopedFormatAutofix,
112
+ runBaselineUpwardWriteback,
113
+ });
143
114
 
144
115
  progress(
145
116
  'VALIDATE',
@@ -0,0 +1,171 @@
1
+ /**
2
+ * phases/pre-gate-steps.js — the self-heal steps the standalone close runs on
3
+ * the Story branch *before* the check-only gate chain scores it.
4
+ *
5
+ * Two steps live here, and they share one contract that is the reason they
6
+ * share a module: each may author a commit on `story-<id>`, each must run
7
+ * ahead of the gates so its commit is part of what the gates score and part of
8
+ * the branch's own PR, and **neither may ever fail the close**. Downstream
9
+ * there is always an authoritative gate — `biome ci` for formatting,
10
+ * `check-baselines` for the ratchet — so a failure here is a missed
11
+ * opportunity to self-heal, never a verdict.
12
+ *
13
+ * 1. **Scoped format-autofix** (Story #4250) — run the formatter over the
14
+ * `baseBranch...storyBranch` diff and fold any rewrite into a
15
+ * `fix(story-close):` commit, so benign JSON/YAML drift that lint-staged
16
+ * does not glob never reaches the check-only format gate.
17
+ * 2. **Upward baseline write-back** (Story #5224) — persist the
18
+ * maintainability rows this branch improved on files it touched, as a
19
+ * `baseline-refresh:` commit, so the committed baseline stops falling
20
+ * behind the tree in the upward direction.
21
+ *
22
+ * Extracted from `close-validation.js` when the second step landed. The phase
23
+ * module's job is the gate chain, the evidence keyspace and the gate-log sink;
24
+ * carrying two multi-branch best-effort wrappers inline alongside that was the
25
+ * mass that made it the file it was. Both collaborators stay injectable — the
26
+ * parent CLI's cache-busted bindings must win in tests that mock the upstream
27
+ * module URLs — and are threaded through from the phase unchanged.
28
+ */
29
+
30
+ import { Logger } from '../../../Logger.js';
31
+ import { runBaselineUpwardWriteback as defaultRunBaselineUpwardWriteback } from '../../story-close/baseline-upward-writeback.js';
32
+ import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
33
+
34
+ /**
35
+ * Run the scoped formatter self-heal.
36
+ *
37
+ * @param {object} ctx the shared step context (see {@link runPreGateSteps})
38
+ * @returns {void}
39
+ */
40
+ function formatAutofixStep({
41
+ cwd,
42
+ worktreePath,
43
+ storyId,
44
+ baseBranch,
45
+ storyBranch,
46
+ config,
47
+ progress,
48
+ runScopedFormatAutofix,
49
+ }) {
50
+ progress(
51
+ 'FORMAT',
52
+ `Running scoped format-autofix on ${baseBranch}...${storyBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
53
+ );
54
+ const autofix = runScopedFormatAutofix({
55
+ cwd,
56
+ worktreePath,
57
+ storyId,
58
+ baseBranch,
59
+ storyBranch,
60
+ config,
61
+ logger: Logger,
62
+ });
63
+ progress(
64
+ 'FORMAT',
65
+ autofix?.committed
66
+ ? `✅ Auto-applied format fix committed as ${autofix.sha} on ${storyBranch}.`
67
+ : `⏭ No format-autofix commit (${autofix?.reason ?? 'clean'}).`,
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Run the upward maintainability write-back.
73
+ *
74
+ * @param {object} ctx the shared step context (see {@link runPreGateSteps})
75
+ * @returns {Promise<void>}
76
+ */
77
+ async function baselineWritebackStep({
78
+ cwd,
79
+ worktreePath,
80
+ storyId,
81
+ baseBranch,
82
+ storyBranch,
83
+ config,
84
+ progress,
85
+ runBaselineUpwardWriteback,
86
+ }) {
87
+ const writeback = await runBaselineUpwardWriteback({
88
+ cwd,
89
+ worktreePath,
90
+ storyId,
91
+ baseBranch,
92
+ storyBranch,
93
+ config,
94
+ logger: Logger,
95
+ });
96
+ progress(
97
+ 'BASELINE',
98
+ writeback?.committed
99
+ ? `✅ Wrote back ${writeback.improvedPaths?.length ?? 0} improved maintainability row(s) as ${writeback.sha} on ${storyBranch}.`
100
+ : `⏭ No baseline write-back (${writeback?.reason ?? 'nothing to write'}).`,
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Run one step, absorbing any throw into a progress line.
106
+ *
107
+ * The absorption is the point, not laziness about error handling: every step
108
+ * here is the *refresh* half of a loop whose *enforcement* half runs
109
+ * immediately afterwards. A step that could abort the close would convert a
110
+ * self-heal opportunity into an outage, and would do it on the path with the
111
+ * least operator attention.
112
+ *
113
+ * @param {{ tag: string, label: string, progress: Function, run: () => Promise<void>|void }} opts
114
+ * @returns {Promise<void>}
115
+ */
116
+ async function bestEffort({ tag, label, progress, run }) {
117
+ try {
118
+ await run();
119
+ } catch (err) {
120
+ progress(
121
+ tag,
122
+ `⚠️ ${label} failed (close continues; the gate chain is authoritative): ${err?.message ?? err}`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Run every pre-gate self-heal step for a standalone Story close.
129
+ *
130
+ * Both steps commit to `story-<id>`, so both are skipped — with a log line, not
131
+ * a throw — when the caller has no `storyBranch`. That is the resume/legacy
132
+ * path, which has no branch to commit onto and must not trip an exception for
133
+ * saying so.
134
+ *
135
+ * @param {{
136
+ * cwd: string,
137
+ * worktreePath: string|null,
138
+ * storyId: number,
139
+ * baseBranch: string,
140
+ * storyBranch?: string,
141
+ * config: object,
142
+ * progress: (tag: string, msg: string) => void,
143
+ * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
144
+ * runBaselineUpwardWriteback?: typeof defaultRunBaselineUpwardWriteback,
145
+ * }} args
146
+ * @returns {Promise<void>}
147
+ */
148
+ export async function runPreGateSteps({
149
+ runScopedFormatAutofix = defaultRunScopedFormatAutofix,
150
+ runBaselineUpwardWriteback = defaultRunBaselineUpwardWriteback,
151
+ ...ctx
152
+ }) {
153
+ const { storyBranch, progress } = ctx;
154
+ if (!storyBranch) {
155
+ progress('FORMAT', '⏭ Skipped scoped format-autofix (no story branch).');
156
+ progress('BASELINE', '⏭ Skipped baseline write-back (no story branch).');
157
+ return;
158
+ }
159
+ await bestEffort({
160
+ tag: 'FORMAT',
161
+ label: 'scoped format-autofix',
162
+ progress,
163
+ run: () => formatAutofixStep({ ...ctx, runScopedFormatAutofix }),
164
+ });
165
+ await bestEffort({
166
+ tag: 'BASELINE',
167
+ label: 'baseline write-back',
168
+ progress,
169
+ run: () => baselineWritebackStep({ ...ctx, runBaselineUpwardWriteback }),
170
+ });
171
+ }
@@ -0,0 +1,483 @@
1
+ /**
2
+ * baseline-upward-writeback.js — persist improved maintainability rows on the
3
+ * branch that earned them (Story #5224).
4
+ *
5
+ * The diff-scoped baseline ratchet only ever reds on a REGRESSION. A branch
6
+ * that *improves* a file it touched is therefore waved through with its
7
+ * committed row left describing the worse, older tree — and nothing on the
8
+ * per-PR path ever writes it back. The only thing that notices is the nightly
9
+ * full-scope re-score (`check-baseline-drift.js`), which had filed the same
10
+ * one-command chore seven times before this module existed.
11
+ *
12
+ * The classifier already does the hard half: `kinds/kind-factory.js#classify`
13
+ * partitions every compared row into `regressions` / `improvements` /
14
+ * `unchanged` / `additions`, and the enforcement path forwards `improvements`
15
+ * all the way to the report. Nothing persisted it. This module is that
16
+ * missing half — run from the close's `close-validation` phase, ahead of the
17
+ * gate chain, so the refreshed row lands in the branch's own PR.
18
+ *
19
+ * Shape borrowed from {@link ../story-close/format-autofix.js#runScopedFormatAutofix}:
20
+ * scope to the branch's changed-file set, fold the writes into one dedicated
21
+ * commit ahead of the gates, log the paths touched, and inject every
22
+ * git / baseline / scoring collaborator so the unit tests never spawn git
23
+ * (`.agents/rules/test-seams.md`).
24
+ *
25
+ * Four constraints bind the design, and every one of them is a "must not":
26
+ *
27
+ * 1. **Only `improvements` are written.** A regression must still fail the
28
+ * gate exactly as it does today. A write-back that could launder one
29
+ * makes the baseline actively worse than leaving it stale, so a regressed
30
+ * row is never in the written set — it is not filtered out downstream, it
31
+ * never enters.
32
+ * 2. **`maintainability` only.** CRAP's drift identity is
33
+ * `path::method@startLine`, which re-keys whenever anything above a
34
+ * method moves, so the same treatment there is churn rather than signal.
35
+ * That is exactly why the nightly watches maintainability alone.
36
+ * 3. **Changed files only — never a full-scope regeneration.** A full-scope
37
+ * write at land time would absorb unrelated drift from other branches
38
+ * into whichever PR happened to land next, and would fight the
39
+ * row-identity merge driver that exists to keep concurrent refreshes
40
+ * apart (Story #5215).
41
+ * 4. **Idempotent and silent.** No empty commit; a second close over the
42
+ * same tree finds nothing left to improve and commits nothing.
43
+ *
44
+ * The authored commit carries the `baseline-refresh:` marker
45
+ * `phases/refresh-ack.js` recognises, so the refreshed rows are vouched for
46
+ * rather than read as fresh drift, and its subject is conventional so
47
+ * commitlint accepts it.
48
+ */
49
+
50
+ import path from 'node:path';
51
+
52
+ import {
53
+ compare as compareMaintainability,
54
+ projectRow as projectMaintainabilityRow,
55
+ } from '../../baselines/kinds/maintainability.js';
56
+ import {
57
+ _internals as baselineReaderInternals,
58
+ load as loadBaselineEnvelope,
59
+ } from '../../baselines/reader.js';
60
+ import {
61
+ refreshBaseline as defaultRefreshBaseline,
62
+ resolveDefaultScorer,
63
+ } from '../../baselines/refresh-service.js';
64
+ import { getQuality } from '../../config-resolver.js';
65
+ import { gitSync as defaultGitSync } from '../../git-utils.js';
66
+ import { Logger as DefaultLogger } from '../../Logger.js';
67
+ import { currentBranch, listChangedFiles } from './format-autofix.js';
68
+
69
+ const TAG = '[baseline-writeback]';
70
+
71
+ /** The one kind this module touches. See constraint 2 in the preamble. */
72
+ const KIND = 'maintainability';
73
+
74
+ /**
75
+ * Absolute drift tolerance when the gate configures none. Mirrors
76
+ * `drift-detector.js`'s `KIND_SPECS.maintainability.defaultTolerance`, so the
77
+ * per-PR write-back and the nightly full-scope check agree on what counts as
78
+ * movement rather than float noise.
79
+ */
80
+ const DEFAULT_TOLERANCE = 0.5;
81
+
82
+ /** Files the maintainability scorer can measure at all. */
83
+ const SCORABLE = /\.(?:m?[jt]sx?)$/i;
84
+
85
+ /**
86
+ * Reasons reported before the step scored anything, so `ran: false` means
87
+ * exactly "a guard stopped this before any work happened" rather than the
88
+ * softer "nothing came of it". `no-scored-rows`, `no-improvements` and
89
+ * `unchanged` are deliberately absent: those are outcomes of a run.
90
+ */
91
+ const GUARD_REASONS = new Set([
92
+ 'gate-disabled',
93
+ 'no-changed-files',
94
+ 'wrong-branch',
95
+ 'no-baseline',
96
+ 'no-scorer',
97
+ ]);
98
+
99
+ /**
100
+ * Resolve the gate's absolute tolerance. Anything below it is float noise the
101
+ * ratchet already refuses to red on, so writing it back would be churn — and
102
+ * churn on a file every concurrent branch also touches is the one cost this
103
+ * step must not add.
104
+ *
105
+ * @param {object|undefined} gate resolved `delivery.quality.gates.maintainability`
106
+ * @returns {number}
107
+ */
108
+ function resolveTolerance(gate) {
109
+ const configured = gate?.tolerance;
110
+ if (configured?.kind === 'absolute') {
111
+ const value = Number(configured.value);
112
+ if (Number.isFinite(value)) return Math.abs(value);
113
+ }
114
+ return DEFAULT_TOLERANCE;
115
+ }
116
+
117
+ /**
118
+ * Read the maintainability gate block as DECLARED — `quality.gates[kind]`, not
119
+ * the sibling `quality[kind]` projection. The two differ in exactly the two
120
+ * fields this module reads: the projection drops `enabled` entirely and
121
+ * flattens `tolerance` to a bare number, so reading it would silently make the
122
+ * gate un-disablable and every configured tolerance unreadable. `evaluate.js`
123
+ * receives this same declared block as its `gateBlock`, which is what keeps
124
+ * the write-back's notion of "moved" identical to the gate's.
125
+ *
126
+ * Tolerates a resolver that throws (a malformed config under a tmp cwd). An
127
+ * unresolvable config reads as "framework defaults", which enable the gate —
128
+ * the same reading `projections/advisories.js#isEnabled` applies.
129
+ *
130
+ * @param {object|undefined} config
131
+ * @returns {object|undefined}
132
+ */
133
+ function resolveGate(config) {
134
+ try {
135
+ return getQuality(config)?.gates?.[KIND];
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Normalise scorer output into the on-disk row shape so scored rows and
143
+ * committed rows are directly comparable. `projectRow` is the same projection
144
+ * the writer applies, which is what makes the two sides comparable at all.
145
+ * A row the writer itself would refuse is dropped rather than compared.
146
+ *
147
+ * @param {Array<object>} rows
148
+ * @returns {Array<{ path: string, mi: number }>}
149
+ */
150
+ function projectRows(rows) {
151
+ const out = [];
152
+ for (const row of rows ?? []) {
153
+ try {
154
+ const projected = projectMaintainabilityRow(row);
155
+ if (Number.isFinite(projected.mi)) out.push(projected);
156
+ } catch {
157
+ // Unprojectable row → not evidence of an improvement.
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * Select the rows this branch has genuinely improved.
165
+ *
166
+ * The base side is deliberately narrowed to the rows the head side actually
167
+ * scored. `compare()` classifies a base row with no head row through the
168
+ * kind's `removedRowPolicy`, which for maintainability pushes an
169
+ * **improvement** ("the file is gone, so its debt is gone too"). That policy
170
+ * is correct for a full-scope compare and catastrophic for a scoped one: every
171
+ * untouched file in the repo would arrive here as an improvement and be
172
+ * rewritten from a score nobody computed. Narrowing the base to the scored
173
+ * keys means every comparison has both sides, so the removed-row arm cannot
174
+ * fire at all — and the `head === null` guard below makes that structural
175
+ * rather than incidental.
176
+ *
177
+ * `additions` — a scored file with no committed row — is likewise excluded:
178
+ * constraint 1 admits only what the classifier calls an improvement, and a new
179
+ * file's row is the ordinary refresh path's business, not this step's.
180
+ *
181
+ * Pure.
182
+ *
183
+ * @param {{ scoredRows: Array<object>, baselineRows: Array<object>, tolerance: number }} opts
184
+ * @returns {Array<{ path: string, mi: number }>} the head rows to persist
185
+ */
186
+ function selectImprovedRows({ scoredRows, baselineRows, tolerance }) {
187
+ const scoredPaths = new Set(scoredRows.map((row) => row.path));
188
+ const baseSubset = (baselineRows ?? []).filter((row) =>
189
+ scoredPaths.has(row?.path),
190
+ );
191
+ if (baseSubset.length === 0) return [];
192
+
193
+ const result = compareMaintainability(
194
+ { rows: scoredRows },
195
+ { rows: baseSubset },
196
+ );
197
+ const improved = [];
198
+ for (const entry of result?.improvements ?? []) {
199
+ const head = entry?.head;
200
+ const base = entry?.base;
201
+ if (!head || !base) continue;
202
+ if (head.mi - base.mi <= tolerance) continue;
203
+ improved.push(head);
204
+ }
205
+ return improved;
206
+ }
207
+
208
+ /**
209
+ * Render the commit body: one line per rewritten row, before → after. The body
210
+ * is the durable record of what the step touched — the `Logger` line scrolls
211
+ * out of a close transcript, this does not — and `check-baseline-drift.js`'s
212
+ * remedy text asks a `baseline-refresh:` commit to carry a non-empty body.
213
+ *
214
+ * @param {Array<{ path: string, mi: number }>} improved
215
+ * @param {Array<object>} baselineRows
216
+ * @returns {string}
217
+ */
218
+ function buildCommitBody(improved, baselineRows) {
219
+ const priorByPath = new Map(
220
+ (baselineRows ?? []).map((row) => [row?.path, row?.mi]),
221
+ );
222
+ const lines = [
223
+ 'Rows the branch improved on files it touched, written back so the',
224
+ 'committed baseline stops falling behind the tree in the upward',
225
+ 'direction. Scoped to the branch changed set; no regression is rewritten.',
226
+ '',
227
+ ];
228
+ for (const row of improved) {
229
+ const before = Number(priorByPath.get(row.path) ?? 0).toFixed(2);
230
+ lines.push(`- ${row.path}: ${before} -> ${row.mi.toFixed(2)}`);
231
+ }
232
+ return lines.join('\n');
233
+ }
234
+
235
+ /**
236
+ * Build the commit subject. Conventional (`chore(baselines): …`) so commitlint
237
+ * accepts it, carrying the `baseline-refresh:` marker as a plain substring so
238
+ * `refresh-ack.js#resolveRefreshTrigger` recognises it, and fixed-length in
239
+ * everything but the Story id so it cannot drift past commitlint's 100-char
240
+ * subject cap.
241
+ *
242
+ * @param {number|string} storyId
243
+ * @returns {string}
244
+ */
245
+ function buildCommitSubject(storyId) {
246
+ return `chore(baselines): baseline-refresh: improved maintainability rows (story #${storyId})`;
247
+ }
248
+
249
+ /**
250
+ * Stage the single baseline file and commit it. Hooks must run; never pass
251
+ * `--no-verify` (project policy).
252
+ *
253
+ * On a commit failure the written file is restored, because everything
254
+ * downstream — base-sync, the push, the gate chain's own reads — assumes the
255
+ * close left the worktree clean. A half-applied write-back that survives as an
256
+ * uncommitted edit would silently change what the gates score without ever
257
+ * reaching the PR.
258
+ *
259
+ * @param {{ cwd: string, git: Function, relPath: string, subject: string, body: string }} opts
260
+ * @returns {{ sha: string }}
261
+ */
262
+ function commitBaseline({ cwd, git, relPath, subject, body }) {
263
+ git(['add', '--', relPath], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
264
+ try {
265
+ git(['commit', '-m', subject, '-m', body], {
266
+ cwd,
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ });
269
+ } catch (err) {
270
+ git(['checkout', '--', relPath], {
271
+ cwd,
272
+ stdio: ['ignore', 'pipe', 'pipe'],
273
+ });
274
+ throw err;
275
+ }
276
+ const sha = git(['rev-parse', '--short', 'HEAD'], {
277
+ cwd,
278
+ encoding: 'utf8',
279
+ stdio: ['ignore', 'pipe', 'ignore'],
280
+ });
281
+ return { sha: String(sha ?? '').trim() };
282
+ }
283
+
284
+ /**
285
+ * Everything that must hold before the step is allowed to score anything.
286
+ * Returns a skip reason, or `null` to proceed.
287
+ *
288
+ * The branch assertion runs here — before the write, not before the commit —
289
+ * so a mis-wired `worktreePath` can never leave a modified baseline in a tree
290
+ * whose history we then refuse to touch.
291
+ *
292
+ * @param {{ gate: object|undefined, workTree: string, storyBranch: string, git: Function, changed: string[] }} ctx
293
+ * @returns {string|null}
294
+ */
295
+ function precheck({ gate, workTree, storyBranch, git, changed }) {
296
+ if (gate?.enabled === false) return 'gate-disabled';
297
+ if (changed.length === 0) return 'no-changed-files';
298
+ const onBranch = currentBranch(workTree, git);
299
+ if (onBranch !== storyBranch) return 'wrong-branch';
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Persist improved maintainability rows for the files this branch changed, and
305
+ * fold them into one `baseline-refresh:` commit on the Story branch.
306
+ *
307
+ * Every no-op is reported by name rather than silently: `gate-disabled`,
308
+ * `no-changed-files`, `wrong-branch`, `no-baseline`, `no-scored-rows`,
309
+ * `no-improvements`, `unchanged`. The caller logs the reason and proceeds —
310
+ * this step is never allowed to fail a close, because `check-baselines` is
311
+ * still the gate and this is only the refresh half of the loop.
312
+ *
313
+ * @param {{
314
+ * cwd: string,
315
+ * worktreePath?: string,
316
+ * storyId: number|string,
317
+ * baseBranch: string,
318
+ * storyBranch: string,
319
+ * config?: object,
320
+ * logger?: object,
321
+ * gitSync?: (cwd: string, ...args: string[]) => string,
322
+ * loadBaselineRows?: (opts: { cwd: string }) => Array<object>|null,
323
+ * scoreFiles?: (files: string[], opts: object) => Promise<Array<object>>|Array<object>,
324
+ * refreshBaseline?: typeof defaultRefreshBaseline,
325
+ * resolveWritePath?: (opts: { cwd: string }) => string,
326
+ * }} opts
327
+ * @returns {Promise<{
328
+ * ran: boolean,
329
+ * committed: boolean,
330
+ * sha?: string,
331
+ * improvedPaths?: string[],
332
+ * reason?: string,
333
+ * }>}
334
+ */
335
+ export async function runBaselineUpwardWriteback({
336
+ cwd,
337
+ worktreePath,
338
+ storyId,
339
+ baseBranch,
340
+ storyBranch,
341
+ config,
342
+ logger = DefaultLogger,
343
+ gitSync = defaultGitSync,
344
+ loadBaselineRows = defaultLoadBaselineRows,
345
+ scoreFiles,
346
+ refreshBaseline = defaultRefreshBaseline,
347
+ resolveWritePath = defaultResolveWritePath,
348
+ } = {}) {
349
+ if (!cwd) throw new Error('runBaselineUpwardWriteback: cwd is required');
350
+ if (!baseBranch)
351
+ throw new Error('runBaselineUpwardWriteback: baseBranch is required');
352
+ if (!storyBranch)
353
+ throw new Error('runBaselineUpwardWriteback: storyBranch is required');
354
+
355
+ const workTree = worktreePath || cwd;
356
+ // The two format-autofix helpers below take git as `(args, opts) => stdout`;
357
+ // `git-utils.gitSync` is `(cwd, ...args) => trimmed stdout` and throws on a
358
+ // non-zero exit. Adapt rather than reach for `node:child_process` directly —
359
+ // the shared surface owns the stdout ceiling, `shell: false` and error
360
+ // normalisation, and `tests/enforcement/child-process-imports.test.js`
361
+ // enforces that.
362
+ const git = (args, opts = {}) => gitSync(opts.cwd ?? workTree, ...args);
363
+ const gate = resolveGate(config);
364
+
365
+ const changed = listChangedFiles({
366
+ cwd: workTree,
367
+ baseBranch,
368
+ storyBranch,
369
+ git,
370
+ }).filter((file) => SCORABLE.test(file));
371
+
372
+ const blocked = precheck({ gate, workTree, storyBranch, git, changed });
373
+ if (blocked) return skip(logger, blocked);
374
+
375
+ const baselineRows = loadBaselineRows({ cwd: workTree });
376
+ if (!Array.isArray(baselineRows) || baselineRows.length === 0) {
377
+ return skip(logger, 'no-baseline');
378
+ }
379
+
380
+ const score = scoreFiles ?? resolveDefaultScorer(KIND, { cwd: workTree });
381
+ if (typeof score !== 'function') return skip(logger, 'no-scorer');
382
+ const scoredRows = projectRows(
383
+ await score(changed, { kind: KIND, fullScope: false, cwd: workTree }),
384
+ );
385
+ if (scoredRows.length === 0) return skip(logger, 'no-scored-rows');
386
+
387
+ const improved = selectImprovedRows({
388
+ scoredRows,
389
+ baselineRows,
390
+ tolerance: resolveTolerance(gate),
391
+ });
392
+ if (improved.length === 0) return skip(logger, 'no-improvements');
393
+
394
+ return await persist({
395
+ improved,
396
+ baselineRows,
397
+ workTree,
398
+ git,
399
+ storyId,
400
+ logger,
401
+ refreshBaseline,
402
+ resolveWritePath,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Write the selected rows through the one sanctioned write funnel and commit
408
+ * them. The already-computed rows are handed to `refreshBaseline` as its
409
+ * scorer so the files are scored exactly once — the service still owns path
410
+ * canonicalization, `mergeRows` (which preserves every out-of-scope row
411
+ * byte-for-byte), rollup, envelope stamping and the atomic write.
412
+ *
413
+ * @returns {Promise<{ ran: boolean, committed: boolean, sha?: string, improvedPaths?: string[], reason?: string }>}
414
+ */
415
+ async function persist({
416
+ improved,
417
+ baselineRows,
418
+ workTree,
419
+ git,
420
+ storyId,
421
+ logger,
422
+ refreshBaseline,
423
+ resolveWritePath,
424
+ }) {
425
+ const improvedPaths = improved.map((row) => row.path);
426
+ const writePath = resolveWritePath({ cwd: workTree });
427
+ const { wrote } = await refreshBaseline({
428
+ kind: KIND,
429
+ cwd: workTree,
430
+ writePath,
431
+ scopeFiles: improvedPaths,
432
+ scorer: () => improved,
433
+ });
434
+ // The writer short-circuits on structural equality, so `wrote: false` means
435
+ // the committed rows already carried these values — nothing to commit, and
436
+ // nothing worth a log line above debug.
437
+ if (!wrote) return skip(logger, 'unchanged');
438
+
439
+ const relPath = path.relative(workTree, writePath).split(path.sep).join('/');
440
+ const { sha } = commitBaseline({
441
+ cwd: workTree,
442
+ git,
443
+ relPath,
444
+ subject: buildCommitSubject(storyId),
445
+ body: buildCommitBody(improved, baselineRows),
446
+ });
447
+
448
+ logger.warn?.(
449
+ `${TAG} wrote back ${improvedPaths.length} improved ${KIND} row(s) ` +
450
+ `on story #${storyId}: ${improvedPaths.join(', ')}; committed as ${sha}.`,
451
+ );
452
+ return { ran: true, committed: true, sha, improvedPaths };
453
+ }
454
+
455
+ /**
456
+ * Report a no-op by name. Every skip is `info`-level: none of them is a
457
+ * problem, and the close transcript already carries one line per phase.
458
+ *
459
+ * @param {object} logger
460
+ * @param {string} reason
461
+ */
462
+ function skip(logger, reason) {
463
+ logger.info?.(`${TAG} no write-back (${reason}).`);
464
+ return { ran: !GUARD_REASONS.has(reason), committed: false, reason };
465
+ }
466
+
467
+ /**
468
+ * Default committed-row loader — the schema-validating reader, so a baseline
469
+ * this module would refuse to compare against is reported as `no-baseline`
470
+ * rather than half-read.
471
+ */
472
+ function defaultLoadBaselineRows({ cwd }) {
473
+ try {
474
+ return loadBaselineEnvelope(KIND, { cwd })?.rows ?? null;
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
479
+
480
+ /** Default on-disk location of the maintainability baseline. */
481
+ function defaultResolveWritePath({ cwd }) {
482
+ return baselineReaderInternals.resolveBaselinePath(KIND, { cwd });
483
+ }
@@ -333,10 +333,15 @@ export function runFormatAutofix({
333
333
  * `(args: string[], opts: object) => string`. A bridge adapter wraps it into
334
334
  * the `gitSpawn(cwd, ...args)` shape that `diffNameOnly` expects.
335
335
  *
336
+ * Exported since Story #5224: the sibling `baseline-upward-writeback.js` step
337
+ * scopes to the same branch changed-file set, and a second copy of this
338
+ * `(args, opts)` → `gitSpawn` bridge is exactly the kind of near-duplicate the
339
+ * duplication gate exists to refuse.
340
+ *
336
341
  * @param {{ cwd: string, baseBranch: string, storyBranch: string, git: Function }} opts
337
342
  * @returns {string[]}
338
343
  */
339
- function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
344
+ export function listChangedFiles({ cwd, baseBranch, storyBranch, git }) {
340
345
  // Bridge the (args, opts) → string interface into gitSpawn(cwd, ...args).
341
346
  const gitSpawn = (_cwd, ...args) => {
342
347
  try {
@@ -1,10 +1,9 @@
1
1
  ---
2
2
  description: >-
3
- The deliver path's one bundled framework read. Carries what
4
- every Story delivery always needs — dispatch decision, engine invariants,
5
- the change-set/ceremony incantation, the acceptance-eval gate, the credited
6
- full-suite run, and the terminal envelope contract — so the engine reads one
7
- file instead of re-reading the helper/schema set each session.
3
+ The deliver path's one bundled framework read: dispatch decision, engine
4
+ invariants, the change-set/ceremony incantation, the acceptance-eval gate,
5
+ the credited full-suite run, and the terminal envelope contract — the
6
+ engine reads one file, not the helper/schema set, each session.
8
7
  ---
9
8
 
10
9
  # Deliver digest (read once per session)
@@ -23,9 +22,9 @@ Read `stories[].dispatchMode` from the `resolve-stories.js` envelope.
23
22
  `inline` names one indivisible resource — **the router's own session** — so one
24
23
  rule produces it:
25
24
 
26
- 1. **Run topology.** A run resolving **one** Story is `inline`
27
- whatever its shape — sub-agent isolation is load-bearing only against a
28
- *concurrent* sibling racing the same checkout, and a one-Story run has none.
25
+ 1. **Run topology.** A run resolving **one** Story is `inline` whatever its
26
+ shape — sub-agent isolation only matters against a *concurrent* sibling
27
+ racing the same checkout, and a one-Story run has none.
29
28
  2. **Every other run is `subagent`.** A multi-Story run dispatches every Story
30
29
  as a sub-agent however trivial its shape. Shape still sets ceremony; the
31
30
  `route::lite` label is a human-visible hint, never the control signal.
@@ -67,8 +66,8 @@ node --input-type=module -e '
67
66
  const { level, classes } = deriveChangeLevel({ changedFiles: files });
68
67
  // resolveCeremonyForRisk({ derivedLevel, clusterIndex?, freshCriticSampleRate?,
69
68
  // ceremonyProfile? }) -> { mode, reason, sampled, profile, verdictOwner }.
70
- // derivedLevel is that level STRING. Handing it the object above matches no
71
- // tier, so it routes to the null fail-safe: a fresh critic, silently.
69
+ // derivedLevel is that level STRING — the object above matches no tier and
70
+ // routes to the null fail-safe: a fresh critic, silently.
72
71
  const ceremony = resolveCeremonyForRisk({ derivedLevel: level, clusterIndex: 0 });
73
72
  console.log(JSON.stringify({ files, level, classes, ...ceremony }));
74
73
  '
@@ -96,11 +95,8 @@ every cluster's records into a single `criteria[]` in `acceptance[]` order, one
96
95
  per acceptance item, and score that once. A gate call per cluster spends a
97
96
  round *per cluster* and races the round ledger:
98
97
 
99
- ```bash
100
- node <main-repo>/.agents/scripts/acceptance-eval.js \
101
- --story <storyId> --verdict <merged-verdict-path> \
102
- --expected-criteria <acceptance[] count>
103
- ```
98
+ `node <main-repo>/.agents/scripts/acceptance-eval.js --story <storyId>
99
+ --verdict <merged-verdict-path> --expected-criteria <acceptance[] count>`
104
100
 
105
101
  Pass `--expected-criteria` — **without it the coverage assertion is inert**, so
106
102
  an unmerged cluster verdict scores a fraction of the criteria and still reports
@@ -115,20 +111,27 @@ Per-round mechanics: [`acceptance-self-eval.md`](acceptance-self-eval.md).
115
111
  **After the self-eval loop's last fix commit, immediately before the push** —
116
112
  the credit is keyed on the tree, so any later commit invalidates it. Redraft
117
113
  rounds run scoped tests; only this final run needs credit, and a bare
118
- `npm test` / `pnpm run test` deposits **none**, so close re-runs the identical
119
- suite. Shape it by the predicate `close-validation/gates.js` uses for its test
120
- gate:
114
+ `npm test` / `pnpm run test` deposits **none**, so close re-runs it. Shape it
115
+ by what `close-validation/gates.js` runs:
121
116
 
122
117
  ```bash
123
- # CRAP gate on (default) + a `test:coverage` script — writes the stamp the
124
- # close `coverage-capture` gate reads:
118
+ # CRAP gate on (default) + a `test:coverage` script — writes close's stamp:
125
119
  node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>
126
- # otherwise — the record the close `test` gate reads. <workCwd> ABSOLUTE,
127
- # runner exactly `npm test`: both sides hash {cmd, args, cwd}.
120
+ # otherwise — the record close's `test` gate reads; <workCwd> ABSOLUTE,
121
+ # runner exactly `npm test` (both sides hash {cmd, args, cwd}):
128
122
  node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
129
123
  --scope-id <storyId> --gate test --worktree <workCwd> -- npm test
130
124
  ```
131
125
 
126
+ Dispatch it in the **background**: it outruns the host's synchronous Bash
127
+ ceiling, and its completion re-invokes you. Never spawn a task to poll or
128
+ `sleep`-loop against it ([`parallel-tooling.md`](parallel-tooling.md)
129
+ Rule 2).
130
+
131
+ Read the **output**, not the exit code: capture skips — no test run, no
132
+ credit — when nothing changed under the CRAP `targetDirs`, so run the suite
133
+ yourself before handing off.
134
+
132
135
  `verify[]` is scoped entries **plus** this one run: an entry that is itself a
133
136
  full-suite command is reported credited against the same stamp, never
134
137
  respawned.
@@ -162,7 +165,8 @@ restores live streaming.
162
165
 
163
166
  ## 7. When to leave this file
164
167
 
165
- - Unclear state / a re-run refusal → `deliver-recover.js --story <id>` (read-only).
166
- - Lease, sweep, worktree-scope detail → [`deliver-story-reference.md`](deliver-story-reference.md).
167
- - CI red after the PR opens → [`rules/ci-remediation.md`](../../rules/ci-remediation.md).
168
- - Sequencing, epilogue, checklist threading → [`deliver-reference.md`](deliver-reference.md).
168
+ Unclear state / a re-run refusal → `deliver-recover.js --story <id>`
169
+ (read-only). Lease, sweep, worktree scope; sequencing, epilogue, checklist
170
+ threading → [`deliver-story-reference.md`](deliver-story-reference.md) and
171
+ [`deliver-reference.md`](deliver-reference.md). CI red after the PR opens →
172
+ [`rules/ci-remediation.md`](../../rules/ci-remediation.md).
@@ -47,6 +47,23 @@ the full duration and blocks every other parallel opportunity.
47
47
  - **Don't poll with `sleep`:** `Monitor` returns on each stdout line. Loop
48
48
  on `until <condition>; do sleep 2; done` only when no event stream is
49
49
  available — never as a substitute for the event channel.
50
+ - **A hand-rolled waiter outlives the agent that spawned it.** Prefer the
51
+ completion notification: it is the signal, and needs no waiter at all. Two
52
+ measured failure shapes, both from one delivery run whose waiters were
53
+ still listed running nearly eight hours after their agent had finished and
54
+ its worktree had been deleted:
55
+ - An `until` guard that inverts to permanently-false the moment the run
56
+ **succeeds** — `until [ -n "$(grep -l 'Test Files' $LOG)" ] && ! grep -q
57
+ 'Test Files' $LOG` exits only while the log both has and lacks the same
58
+ marker.
59
+ - `pgrep -f <literal>` matching the waiter's **own** command line, so it
60
+ finds itself and concludes the work is still running. Forever.
61
+
62
+ The tell is a task file of **0 bytes** with no backing process. If you must
63
+ wait, hold the PID and test `kill -0 "$PID"`, or break the self-match with
64
+ a bracketed pattern (`pgrep -f "[m]y-script.js"`) — and always bound the
65
+ loop with a maximum iteration count so a wrong condition ends the wait
66
+ instead of the agent.
50
67
 
51
68
  ## Rule 3 — N parallel `Agent` calls in one turn for N independent units
52
69
 
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,19 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.49.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.48.0...mandrel-v2.49.0) (2026-09-08)
19
+
20
+
21
+ ### Added
22
+
23
+ * give story-worker a reachable long-command dispatch contract so the credited suite run stops growing hand-rolled waiters ([#5219](https://github.com/dsj1984/mandrel/issues/5219)) ([#5220](https://github.com/dsj1984/mandrel/issues/5220)) ([e2bb9eb](https://github.com/dsj1984/mandrel/commit/e2bb9eba8aaaf49ecf0d8069f36fc4438edf5e40))
24
+ * write improved maintainability rows back at land time so upward baseline drift stops accumulating ([#5224](https://github.com/dsj1984/mandrel/issues/5224)) ([#5227](https://github.com/dsj1984/mandrel/issues/5227)) ([95d2e38](https://github.com/dsj1984/mandrel/commit/95d2e38db7fe87c410dbb40d43818e31ee80a497))
25
+
26
+
27
+ ### Fixed
28
+
29
+ * tell the worker what to do when the credited full-suite command legitimately skips ([#5225](https://github.com/dsj1984/mandrel/issues/5225)) ([#5226](https://github.com/dsj1984/mandrel/issues/5226)) ([330ecaf](https://github.com/dsj1984/mandrel/commit/330ecaf6001755a515c45f69a495310d8b65643a))
30
+
18
31
  ## [2.48.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.47.0...mandrel-v2.48.0) (2026-09-08)
19
32
 
20
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.48.0",
3
+ "version": "2.49.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",