mandrel 2.47.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.
Files changed (32) hide show
  1. package/.agents/agents/story-worker.md +49 -49
  2. package/.agents/docs/configuration.md +1 -0
  3. package/.agents/docs/quality-gates.md +48 -0
  4. package/.agents/scripts/lib/baselines/kernel.js +19 -0
  5. package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
  6. package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
  7. package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
  8. package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
  9. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
  10. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
  11. package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
  12. package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
  13. package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
  14. package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
  15. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
  16. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
  17. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  18. package/.agents/scripts/lib/orchestration/epic-container.js +48 -21
  19. package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
  20. package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
  23. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
  24. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  25. package/.agents/scripts/merge-baseline.js +238 -0
  26. package/.agents/scripts/providers/github/errors.js +66 -10
  27. package/.agents/scripts/providers/github/sub-issues.js +8 -1
  28. package/.agents/workflows/helpers/deliver-digest.md +30 -26
  29. package/.agents/workflows/helpers/parallel-tooling.md +17 -0
  30. package/docs/CHANGELOG.md +25 -0
  31. package/lib/cli/registry.js +63 -0
  32. package/package.json +1 -1
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * merge-baseline.js — git merge driver for `baselines/*.json` (Story #5215).
5
+ *
6
+ * ## The failure it replaces
7
+ *
8
+ * Every baseline write stamps `generatedAt` on line 4, so two branches that
9
+ * each refresh a baseline ALWAYS differ there — even when they moved
10
+ * completely disjoint rows. Git merges JSON as text, so whether it can
11
+ * separate that hunk from the moved rows is an accident of proximity:
12
+ *
13
+ * - it cannot → a conflict on work that never overlapped (the observed
14
+ * `coverage.json` / `maintainability.json` "always conflicts" pattern);
15
+ * - it can → it splices both sides' row lines into a row set neither side
16
+ * scored, and the ratchet then guards a number no scorer produced (the
17
+ * observed `crap.json` "silently auto-merges" pattern).
18
+ *
19
+ * The quiet one is the worse one. A baseline is a set of rows keyed by
20
+ * identity plus a rollup derived from them, so this driver merges it as
21
+ * that — see `lib/baselines/merge-envelopes.js` for the semantics.
22
+ *
23
+ * ## Contract
24
+ *
25
+ * node .agents/scripts/merge-baseline.js %O %A %B %P
26
+ *
27
+ * git's merge-driver calling convention: `%O` ancestor, `%A` ours (and the
28
+ * file the driver MUST leave its result in), `%B` theirs, `%P` the real
29
+ * pathname being merged. Exit 0 merged clean, non-zero conflicted.
30
+ *
31
+ * Registered per clone (registration is per-clone, so `mandrel doctor` is
32
+ * the guard that it happened, not `mandrel sync`):
33
+ *
34
+ * .gitattributes: baselines/*.json merge=mandrel-baseline
35
+ * git config: merge.mandrel-baseline.driver
36
+ *
37
+ * ## Not every `baselines/*.json` is an envelope
38
+ *
39
+ * That glob also matches arch-cycles, cyclomatic, dead-exports, audit-ledger,
40
+ * context-budget and workflow-citations — files with their own shapes and no
41
+ * row identity. Anything whose `$schema` is not a known per-kind envelope is
42
+ * handed straight back to `git merge-file`, so registering the driver cannot
43
+ * change their behaviour.
44
+ */
45
+
46
+ import fs from 'node:fs';
47
+ import path from 'node:path';
48
+
49
+ import { assertEnvelope } from './lib/baselines/envelope.js';
50
+ import {
51
+ kindFromEnvelope,
52
+ mergeEnvelopes,
53
+ } from './lib/baselines/merge-envelopes.js';
54
+ import { writeFile as writeEnvelopeFile } from './lib/baselines/writer.js';
55
+ import { spawnChild } from './lib/child-exec.js';
56
+ import { runAsCli } from './lib/cli-utils.js';
57
+
58
+ /** Indent one row's canonical JSON to its position inside `rows`. */
59
+ function rowBlock(row) {
60
+ return JSON.stringify(row, null, 2)
61
+ .split('\n')
62
+ .map((line) => ` ${line}`)
63
+ .join('\n');
64
+ }
65
+
66
+ /**
67
+ * Wrap each conflicting row in git conflict markers, leaving every other row
68
+ * merged. Operates on the canonical text the writer already produced, so the
69
+ * non-conflicting remainder of the file is byte-identical to what a clean
70
+ * merge would have written.
71
+ *
72
+ * @param {string} text Canonical serialization of the merged envelope.
73
+ * @param {Array<object>} conflicts Row-scoped conflict records.
74
+ * @returns {string}
75
+ */
76
+ export function renderConflictMarkers(text, conflicts) {
77
+ let out = text;
78
+ for (const conflict of conflicts) {
79
+ const placed = conflict.ours ?? conflict.theirs;
80
+ if (placed === undefined) continue;
81
+ const block = rowBlock(placed);
82
+ // The row may or may not be the last element of `rows`; keep whichever
83
+ // separator follows it on both sides so the markers wrap whole lines.
84
+ const withComma = `${block},`;
85
+ const [needle, suffix] = out.includes(withComma)
86
+ ? [withComma, ',']
87
+ : [block, ''];
88
+ if (!out.includes(needle)) continue;
89
+ const ourSide =
90
+ conflict.ours === undefined
91
+ ? ''
92
+ : `${rowBlock(conflict.ours)}${suffix}\n`;
93
+ const theirSide =
94
+ conflict.theirs === undefined
95
+ ? ''
96
+ : `${rowBlock(conflict.theirs)}${suffix}\n`;
97
+ out = out.replace(
98
+ needle,
99
+ `<<<<<<< ours\n${ourSide}=======\n${theirSide}>>>>>>> theirs`.replace(
100
+ /\n$/,
101
+ '',
102
+ ),
103
+ );
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Read and parse a merge input; a missing or empty side is `null`. */
109
+ function readSide(file) {
110
+ if (!file || !fs.existsSync(file)) return null;
111
+ const raw = fs.readFileSync(file, 'utf8');
112
+ if (raw.trim() === '') return null;
113
+ try {
114
+ return JSON.parse(raw);
115
+ } catch {
116
+ return undefined; // present but unparseable — caller falls back to git
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Hand the merge back to git's own text merge. Used for every
122
+ * `baselines/*.json` that is not a known per-kind envelope, and for one that
123
+ * is too damaged to parse — in both cases the driver must not invent a
124
+ * result, and git's behaviour is exactly what the repo had before.
125
+ *
126
+ * @returns {number} git merge-file's own exit code.
127
+ */
128
+ function delegateToGit(basePath, oursPath, theirsPath) {
129
+ // `stdio: 'inherit'` so git's own conflict reporting reaches the operator
130
+ // exactly as it would have with no driver registered. `spawnChild` returns
131
+ // the RAW result deliberately: a `status` of null means the child was
132
+ // killed, and that must never be read as a clean merge.
133
+ const result = spawnChild(
134
+ 'git',
135
+ ['merge-file', oursPath, basePath, theirsPath],
136
+ { stdio: 'inherit' },
137
+ );
138
+ if (result.error) {
139
+ process.stderr.write(
140
+ `merge-baseline: could not run git merge-file: ${result.error.message}\n`,
141
+ );
142
+ return 1;
143
+ }
144
+ return result.status ?? 1;
145
+ }
146
+
147
+ /**
148
+ * @param {string[]} argv Positional arguments: %O %A %B [%P].
149
+ * @returns {number} Process exit code.
150
+ */
151
+ export function runMergeBaseline(argv) {
152
+ const [baseArg, oursArg, theirsArg, mergedPath] = argv;
153
+ if (!baseArg || !oursArg || !theirsArg) {
154
+ process.stderr.write(
155
+ 'merge-baseline: expected the git merge-driver arguments %O %A %B %P\n',
156
+ );
157
+ return 2;
158
+ }
159
+
160
+ // Git hands the driver temp filenames RELATIVE to the worktree root it
161
+ // invokes us from (`.merge_file_xxxxxx`), so every path is resolved before
162
+ // use — the shared writer refuses a relative path, and that refusal only
163
+ // shows up under a real `git merge`, never when the driver is called
164
+ // directly with absolute paths.
165
+ const [basePath, oursPath, theirsPath] = [baseArg, oursArg, theirsArg].map(
166
+ (p) => path.resolve(p),
167
+ );
168
+
169
+ const ours = readSide(oursPath);
170
+ const theirs = readSide(theirsPath);
171
+ const base = readSide(basePath);
172
+
173
+ const kind = kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
174
+ if (!kind || ours === undefined || theirs === undefined) {
175
+ return delegateToGit(basePath, oursPath, theirsPath);
176
+ }
177
+
178
+ let merged;
179
+ try {
180
+ merged = mergeEnvelopes({ base, ours, theirs, kind });
181
+ } catch (err) {
182
+ process.stderr.write(`merge-baseline: ${kind}: ${err.message}\n`);
183
+ return delegateToGit(basePath, oursPath, theirsPath);
184
+ }
185
+
186
+ const rowConflicts = merged.conflicts.filter((c) => c.scope === 'row');
187
+ const envelopeConflicts = merged.conflicts.filter(
188
+ (c) => c.scope === 'envelope',
189
+ );
190
+
191
+ // Write the canonical projection first even when conflicted: the marker
192
+ // rendering operates on exactly the bytes a clean merge would have left,
193
+ // so the merged remainder of a conflicted file is identical to it.
194
+ writeEnvelopeFile(oursPath, merged.envelope);
195
+
196
+ if (merged.conflicts.length === 0) {
197
+ assertEnvelope(merged.envelope);
198
+ return 0;
199
+ }
200
+
201
+ const label = mergedPath || oursPath;
202
+ for (const conflict of envelopeConflicts) {
203
+ process.stderr.write(
204
+ `merge-baseline: conflict ${kind} envelope key "${conflict.identity}" in ${label} — ours ${JSON.stringify(conflict.ours)}, theirs ${JSON.stringify(conflict.theirs)}\n`,
205
+ );
206
+ }
207
+ for (const conflict of rowConflicts) {
208
+ process.stderr.write(
209
+ `merge-baseline: conflict ${kind} row "${conflict.identity}" in ${label}\n`,
210
+ );
211
+ }
212
+
213
+ if (rowConflicts.length > 0) {
214
+ const text = fs.readFileSync(oursPath, 'utf8');
215
+ fs.writeFileSync(oursPath, renderConflictMarkers(text, rowConflicts));
216
+ }
217
+ return 1;
218
+ }
219
+
220
+ function main() {
221
+ return runMergeBaseline(process.argv.slice(2));
222
+ }
223
+
224
+ runAsCli(import.meta.url, main, {
225
+ source: 'merge-baseline',
226
+ propagateExitCode: true,
227
+ usage: {
228
+ invocation: 'node .agents/scripts/merge-baseline.js %O %A %B %P',
229
+ summary:
230
+ 'Git merge driver for baselines/*.json. Merges per-kind envelopes by ROW IDENTITY — disjoint refreshes merge clean, the rollup is recomputed from the merged rows, and generatedAt resolves to the later stamp instead of conflicting. A baselines file that is not a known per-kind envelope is handed back to git merge-file unchanged. Exit 0 clean, 1 conflicted.',
231
+ flags: [
232
+ ['%O', 'Merge ancestor (git supplies this).'],
233
+ ['%A', 'Our version — the driver writes its result here.'],
234
+ ['%B', 'Their version.'],
235
+ ['%P', 'Real pathname being merged; used in conflict messages.'],
236
+ ],
237
+ },
238
+ });
@@ -87,17 +87,67 @@ function matchesAny(haystack, needles) {
87
87
  return false;
88
88
  }
89
89
 
90
+ /** `gh` renders the HTTP status onto stderr as `HTTP 403: <reason>`. */
91
+ const GH_STDERR_STATUS_RE = /\bHTTP (\d{3})\b/i;
92
+
93
+ /**
94
+ * The captured `gh` stderr, or `''` when the error carries none.
95
+ *
96
+ * Its own function so {@link extractErrorFields} keeps the cyclomatic weight it
97
+ * had before stderr became a classification input — the field is read twice
98
+ * there, and inlining the guard twice is what pushed the CRAP ratchet.
99
+ *
100
+ * @param {unknown} err
101
+ * @returns {string}
102
+ */
103
+ function stderrText(err) {
104
+ return typeof err?.stderr === 'string' ? err.stderr : '';
105
+ }
106
+
90
107
  /**
91
- * Extract `{ lower, status, code }` from an error in the shape `gh-exec`
92
- * throws. Pure — exported style for unit-testability without instantiating
93
- * the provider. Defensive on shape: errors arrive as `Error` objects, plain
94
- * `{message,status,code}` bags, or non-Errors stringified into `String(err)`.
108
+ * Recover the HTTP status from a `gh`-CLI failure's stderr.
109
+ *
110
+ * The `fetch` transport sets `err.status`; the `gh` transport does not — it
111
+ * has only an exit code, and puts the status in the text it printed. Without
112
+ * this, every `gh`-path failure reached the status rules as `undefined` and a
113
+ * 403 or a 429 was indistinguishable from an unclassifiable error (Story
114
+ * #5210).
115
+ *
116
+ * @param {unknown} stderr
117
+ * @returns {number|undefined}
118
+ */
119
+ function statusFromStderr(stderr) {
120
+ if (typeof stderr !== 'string') return undefined;
121
+ const m = GH_STDERR_STATUS_RE.exec(stderr);
122
+ return m ? Number.parseInt(m[1], 10) : undefined;
123
+ }
124
+
125
+ /**
126
+ * Extract `{ lower, detail, status, code }` from an error in the shape
127
+ * `gh-exec` throws. Pure — exported style for unit-testability without
128
+ * instantiating the provider. Defensive on shape: errors arrive as `Error`
129
+ * objects, plain `{message,status,code}` bags, or non-Errors stringified into
130
+ * `String(err)`.
131
+ *
132
+ * `lower` is the message alone. `detail` is the message **plus** any captured
133
+ * `stderr`, and is what the keyword rules read: on the `gh` path the message
134
+ * is the classified summary (`gh-exec: gh exited with code 1`) and every
135
+ * actionable word — the status line, `secondary rate limit`, the missing
136
+ * GraphQL field — lives only on stderr. Matching the keyword lists against the
137
+ * message alone is what flattened a retryable 403 to `permanent` and let the
138
+ * Epic rollup treat a rate-limit burst as a settled answer (Story #5210).
95
139
  */
96
140
  export function extractErrorFields(err) {
97
141
  const message = typeof err.message === 'string' ? err.message : String(err);
142
+ const stderr = stderrText(err);
143
+ const lower = message.toLowerCase();
98
144
  return {
99
- lower: message.toLowerCase(),
100
- status: typeof err.status === 'number' ? err.status : undefined,
145
+ lower,
146
+ // Unconditional concatenation: with no stderr this is the message plus a
147
+ // trailing space, which every `includes` rule below reads identically.
148
+ detail: `${lower} ${stderr.toLowerCase()}`,
149
+ status:
150
+ typeof err.status === 'number' ? err.status : statusFromStderr(stderr),
101
151
  code: typeof err.code === 'string' ? err.code : undefined,
102
152
  };
103
153
  }
@@ -127,17 +177,23 @@ export function classifyGithubError(err) {
127
177
  // no `.status` / `.code`. Match by `err.name` to avoid a circular import
128
178
  // between this module and `lib/gh-exec.js`. Story #2860.
129
179
  if (err.name === 'GhExecTimeoutError') return 'transient';
130
- const { lower, status, code } = extractErrorFields(err);
131
- if (matchesAny(lower, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
180
+ // Every keyword rule below reads `detail` (message + stderr), never `lower`
181
+ // alone: the `gh` transport carries its reason exclusively on stderr, so a
182
+ // message-only match sees nothing but the exit code. Rule ORDER is
183
+ // load-bearing and unchanged — a secondary rate limit is delivered as HTTP
184
+ // 403, so the transient rules must stay ahead of the permission rule or it
185
+ // would bucket as 'permission' and never retry.
186
+ const { detail, status, code } = extractErrorFields(err);
187
+ if (matchesAny(detail, FEATURE_DISABLED_MESSAGES)) return 'feature-disabled';
132
188
  if (isTransientStatus(status)) return 'transient';
133
- if (isTransientByCodeOrMessage(code, lower)) return 'transient';
189
+ if (isTransientByCodeOrMessage(code, detail)) return 'transient';
134
190
  // Union with the former `transient-retry.js` predicate (Story #4298):
135
191
  // retry on network/connectivity blips the status/code checks above miss
136
192
  // (e.g. a `dial tcp ... i/o timeout` on `err.stderr` from the gh-CLI path,
137
193
  // or `ECONNREFUSED` / `ENETUNREACH`). Checked before the permission rule so
138
194
  // a transient network failure never masquerades as a permanent denial.
139
195
  if (isTransientNetworkError(err)) return 'transient';
140
- if (isPermissionSignal(status, lower)) return 'permission';
196
+ if (isPermissionSignal(status, detail)) return 'permission';
141
197
  return 'permanent';
142
198
  }
143
199
 
@@ -23,6 +23,7 @@
23
23
  * @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
24
24
  */
25
25
 
26
+ import { describeGhFailure } from '../../lib/gh-exec.js';
26
27
  import { Logger } from '../../lib/Logger.js';
27
28
  import {
28
29
  classifyGithubError as defaultClassifyGithubError,
@@ -104,8 +105,14 @@ export class SubIssueGateway {
104
105
  );
105
106
  return [];
106
107
  }
108
+ // `describeGhFailure`, not `err.message`: on the gh transport the
109
+ // message is only the classified summary (`gh exited with code 1`) and
110
+ // the actionable sentence — the HTTP status, the rate-limit notice — is
111
+ // on stderr. Three identical opaque lines are what made the Epic-rollup
112
+ // incident unreadable until the API was queried by hand (Story #5210).
107
113
  Logger.error(
108
- `[GitHubProvider] sub-issues GraphQL failed (parent #${parentId}, category=${category}): ${err.message}`,
114
+ `[GitHubProvider] sub-issues GraphQL failed (parent #${parentId}, ` +
115
+ `category=${category}): ${describeGhFailure(err)}`,
109
116
  );
110
117
  throw err;
111
118
  }
@@ -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,31 @@ 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
+
31
+ ## [2.48.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.47.0...mandrel-v2.48.0) (2026-09-08)
32
+
33
+
34
+ ### Added
35
+
36
+ * baselines: merge concurrent refreshes by row identity with a git merge driver ([#5215](https://github.com/dsj1984/mandrel/issues/5215)) ([#5216](https://github.com/dsj1984/mandrel/issues/5216)) ([bcc7057](https://github.com/dsj1984/mandrel/commit/bcc7057d8aad647ec4dae0dc859dd35865fe40a5))
37
+
38
+
39
+ ### Fixed
40
+
41
+ * never close a container Epic on a degraded child read, and stop flattening gh transport failures to permanent ([#5210](https://github.com/dsj1984/mandrel/issues/5210)) ([#5212](https://github.com/dsj1984/mandrel/issues/5212)) ([08520d7](https://github.com/dsj1984/mandrel/commit/08520d7b5ca0962f98042ca4b103cf0c295516cf))
42
+
18
43
  ## [2.47.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.46.0...mandrel-v2.47.0) (2026-09-07)
19
44
 
20
45
 
@@ -25,6 +25,11 @@ import fs from 'node:fs';
25
25
  import { createRequire } from 'node:module';
26
26
  import path from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
+ import {
29
+ BASELINE_MERGE_DRIVER_CONFIG_KEY,
30
+ BASELINE_MERGE_DRIVER_REMEDY,
31
+ declaresBaselineMergeDriver,
32
+ } from '../../.agents/scripts/lib/bootstrap/baseline-merge-driver.js';
28
33
  import {
29
34
  REQUIRED_NODE_CEILING_MAJOR,
30
35
  REQUIRED_NODE_FLOOR,
@@ -1074,6 +1079,60 @@ function runVersionCurrent({ cachePath, installedVersion, fsImpl = fs } = {}) {
1074
1079
  // Registry
1075
1080
  // ---------------------------------------------------------------------------
1076
1081
 
1082
+ /**
1083
+ * Is this clone's `baselines/*.json` merge driver actually registered?
1084
+ *
1085
+ * The two halves of that registration live in different places on purpose.
1086
+ * `.gitattributes` is tracked, so the "use the driver" half ships with the
1087
+ * repo; the driver COMMAND is per-clone `git config`, because git will not
1088
+ * execute a command chosen by whoever wrote the repository. A fresh clone
1089
+ * therefore has the first half and not the second — and git reports nothing
1090
+ * at all, it just quietly falls back to text-merging baselines, which is the
1091
+ * behaviour the driver exists to replace.
1092
+ *
1093
+ * Silent degradation is why this is a doctor check rather than a one-time
1094
+ * install step. It is scoped to repos that opted in: when `.gitattributes`
1095
+ * does not declare the driver, the check passes as skipped, so a consumer
1096
+ * who never installed the quality surface is not told to fix something they
1097
+ * did not ask for.
1098
+ *
1099
+ * @param {{cwd?: () => string, fsImpl?: typeof fs, runner?: typeof spawn}} [opts]
1100
+ * @returns {{ ok: boolean, detail: string, remedy?: string }}
1101
+ */
1102
+ export function runMergeDriver({ cwd, fsImpl = fs, runner = spawn } = {}) {
1103
+ const projectRoot = (cwd ?? (() => process.cwd()))();
1104
+ const attributesPath = path.join(projectRoot, '.gitattributes');
1105
+
1106
+ let attributes = '';
1107
+ try {
1108
+ attributes = fsImpl.readFileSync(attributesPath, 'utf8');
1109
+ } catch {
1110
+ attributes = '';
1111
+ }
1112
+ if (!declaresBaselineMergeDriver(attributes)) {
1113
+ return {
1114
+ ok: true,
1115
+ detail:
1116
+ 'skipped — .gitattributes does not route baselines/*.json through the mandrel merge driver',
1117
+ };
1118
+ }
1119
+
1120
+ const configured = runner('git', [
1121
+ 'config',
1122
+ '--get',
1123
+ BASELINE_MERGE_DRIVER_CONFIG_KEY,
1124
+ ]);
1125
+ if (configured.status === 0 && configured.stdout.trim() !== '') {
1126
+ return { ok: true, detail: configured.stdout.trim() };
1127
+ }
1128
+
1129
+ return {
1130
+ ok: false,
1131
+ detail: `${BASELINE_MERGE_DRIVER_CONFIG_KEY} is unset — baselines/*.json will fall back to git's text merge, which conflicts on the generatedAt stamp and can splice rows neither branch scored`,
1132
+ remedy: BASELINE_MERGE_DRIVER_REMEDY,
1133
+ };
1134
+ }
1135
+
1077
1136
  /**
1078
1137
  * Ordered array of doctor checks. Each entry follows the
1079
1138
  * `{ name: string, run(opts?): { ok: boolean, detail: string, remedy?: string } }` contract.
@@ -1123,6 +1182,10 @@ export const registry = [
1123
1182
  name: 'agents-drift',
1124
1183
  run: (opts) => runAgentsDrift(opts),
1125
1184
  },
1185
+ {
1186
+ name: 'merge-driver',
1187
+ run: (opts) => runMergeDriver(opts),
1188
+ },
1126
1189
  {
1127
1190
  name: 'pin-current',
1128
1191
  // Fatal, unlike version-current below (Story #4525/#4530): a pin/install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.47.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/",