mandrel 2.56.0 → 2.57.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 (106) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -34
  3. package/.agents/docs/agentrc-reference.json +0 -30
  4. package/.agents/docs/configuration.md +8 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/schemas/agentrc.schema.json +9 -185
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  10. package/.agents/scripts/acceptance-eval.js +107 -17
  11. package/.agents/scripts/ceremony-derive.js +191 -0
  12. package/.agents/scripts/check-context-budget.js +28 -33
  13. package/.agents/scripts/check-cyclomatic.js +4 -3
  14. package/.agents/scripts/deliver-light.js +31 -94
  15. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  16. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  17. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  18. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  19. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  20. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  21. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  22. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  23. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  24. package/.agents/scripts/lib/config/explain.js +0 -19
  25. package/.agents/scripts/lib/config/limits.js +18 -78
  26. package/.agents/scripts/lib/config/quality.js +6 -3
  27. package/.agents/scripts/lib/config/runners.js +3 -2
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  29. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  30. package/.agents/scripts/lib/config-settings-schema.js +16 -143
  31. package/.agents/scripts/lib/crap-engine.js +35 -4
  32. package/.agents/scripts/lib/crap-utils.js +17 -1
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  34. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  35. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  36. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  37. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  38. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  39. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  40. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  41. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  42. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  43. package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
  44. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  45. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
  47. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +118 -297
  49. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  50. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  51. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  52. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
  53. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  56. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  57. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  58. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  59. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  60. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  61. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  62. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  63. package/.agents/scripts/lib/story-body/story-body.js +17 -237
  64. package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
  65. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  66. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  67. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  68. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  69. package/.agents/scripts/lib/test-run-credit.js +266 -0
  70. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  71. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  72. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  73. package/.agents/scripts/plan-context.js +7 -9
  74. package/.agents/scripts/plan-critics.js +28 -54
  75. package/.agents/scripts/plan-persist.js +25 -68
  76. package/.agents/scripts/quality-preview.js +51 -0
  77. package/.agents/scripts/run-tests.js +12 -0
  78. package/.agents/scripts/stories-wave-tick.js +23 -45
  79. package/.agents/scripts/test-isolate.js +13 -180
  80. package/.agents/scripts/update-coverage-baseline.js +25 -70
  81. package/.agents/scripts/update-crap-baseline.js +19 -123
  82. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  83. package/.agents/workflows/audit-clean-code.md +4 -3
  84. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  85. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  86. package/.agents/workflows/helpers/code-review.md +2 -3
  87. package/.agents/workflows/helpers/deliver-digest.md +41 -57
  88. package/.agents/workflows/helpers/deliver-light.md +40 -105
  89. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  90. package/.agents/workflows/helpers/deliver-story-reference.md +37 -58
  91. package/.agents/workflows/helpers/deliver-story.md +9 -13
  92. package/.agents/workflows/helpers/plan-reference.md +132 -219
  93. package/.agents/workflows/mandrel-plan.md +27 -40
  94. package/.agents/workflows/memory-consolidate.md +9 -13
  95. package/docs/CHANGELOG.md +23 -0
  96. package/lib/migrations/index.js +4 -0
  97. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  98. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  99. package/package.json +1 -1
  100. package/.agents/scripts/lib/framework-version.js +0 -39
  101. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  102. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  103. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  104. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  105. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  106. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -4,80 +4,42 @@
4
4
  *
5
5
  * Split out of `ready-set.js` (Story #5044), which is the *scheduling* kernel:
6
6
  * eligibility, capacity, admission order. Deciding whether two Stories collide
7
- * is a separate question with its own rules — what counts as a declaration,
8
- * what counts as evidence, what a glob means, and which text is edit intent
9
- * rather than machine-generated noise — and it had grown large enough inside
10
- * the scheduler to obscure both.
7
+ * is a separate question with its own rules — what counts as a declaration and
8
+ * what a glob means — and it had grown large enough inside the scheduler to
9
+ * obscure both.
10
+ *
11
+ * ## The footprint is the declaration (Story #5313)
12
+ *
13
+ * Between Story #4875 and Story #5313 the footprint compared here was the
14
+ * declared `changes[]` **widened** by every repo-relative path scraped out of
15
+ * the Story's title, spec and body, on the theory that a declaration is a
16
+ * lower bound. Measured against real cohorts the widening manufactured far
17
+ * more serialisation than it prevented: audit provenance footers, markdown
18
+ * citations, `## Verify` gate commands and `## Non-Goals` prose all read as
19
+ * edit intent, and three narrowing passes (#5044, #5265) were spent teaching
20
+ * the scrape what a path is not. The delivery diet removes the scrape: two
21
+ * Stories collide only when **both declare** a path (or one declares a glob),
22
+ * so the `scraped-overlap` class, the per-path attribution and the field
23
+ * labels are gone with it. What a Story edits beyond its declaration is the
24
+ * close-time merge's business, not a dispatch guess.
11
25
  *
12
26
  * The layer has exactly one job: given two Story records, say whether their
13
- * file footprints intersect, name the paths, and say whether a declaration or
14
- * the text scrape produced the answer. It reads nothing and mutates nothing.
27
+ * declared footprints intersect and name the paths. It reads nothing and
28
+ * mutates nothing.
15
29
  *
16
30
  * @module lib/wave-runner/footprint
17
31
  */
18
32
 
19
33
  /**
20
- * Why two footprints collided.
21
- *
22
- * `declared-overlap` — at least one colliding path was **declared** by both
23
- * Stories (or is a declared glob). This is the guard doing its intended job:
24
- * two Stories that both list `baselines/maintainability.json` really do have to
25
- * be serialized, and no amount of scrape-narrowing should change that.
26
- *
27
- * `scraped-overlap` — every colliding path reached the comparison through the
28
- * evidence widening rather than a declaration. Still a real signal (Story
29
- * #4875 exists because declarations are systematically a lower bound), but it
30
- * is the class where a false positive is possible, so it is the one an operator
31
- * should be able to see and — via `footprintGuard: 'advisory'` — choose not to
32
- * enforce.
34
+ * Why two footprints collided. Since Story #5313 there is exactly one class:
35
+ * a path both Stories declared (or a declared glob). The value is kept on the
36
+ * envelope so a consumer keyed on `source` does not have to learn a new
37
+ * vocabulary, and so a future second class has a home.
33
38
  */
34
39
  export const OVERLAP_SOURCES = Object.freeze({
35
40
  DECLARED: 'declared-overlap',
36
- SCRAPED: 'scraped-overlap',
37
41
  });
38
42
 
39
- /**
40
- * Repo-relative file paths as they appear in Story prose: at least one `/`
41
- * separator and a short file extension. Deliberately narrow — a token has to
42
- * look like a real path before it can widen a footprint and withhold a Story.
43
- */
44
- const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
45
-
46
- /**
47
- * The **machine-generated audit provenance footers** — and nothing else.
48
- *
49
- * `/audit-to-stories` and `plan-persist` stamp `<!-- audit-fingerprints: … -->`
50
- * and `<!-- audit-semantic-keys: … -->` onto a Story body as dedup identity.
51
- * A semantic key is `area␟primaryFile`, and that `␟` (U+241F) separator is
52
- * outside {@link PROSE_PATH_RE}'s character class, so the `primaryFile` half
53
- * matches as a standalone path token. `plan-persist` carries the **sweep-wide
54
- * union** of those footers onto every sibling of an audit-derived plan, so
55
- * every pair of that plan shared path-shaped tokens neither Story would edit —
56
- * measured at 10/10 colliding pairs, 0/10 once these blocks are ignored
57
- * (issue #5040).
58
- *
59
- * **This is surgical on purpose: a blanket HTML-comment strip would be wrong.**
60
- * `.agents/instructions.md` § 7 puts a complexity decomposition's numbered
61
- * sub-steps inside a `<!-- DECOMPOSITION -->` block, and the paths a sub-step
62
- * names are exactly the edit intent this layer exists to read. Only the two
63
- * provenance markers below are removed; every other comment stays evidence.
64
- */
65
- const PROVENANCE_FOOTER_RE =
66
- /<!--\s*audit-(?:fingerprints|semantic-keys)\s*:[\s\S]*?-->/g;
67
-
68
- /**
69
- * The URL interior of a markdown inline link (`](…)`), with an optional title.
70
- * A link target is a *citation* — "see [the spec](docs/architecture.md)" — not
71
- * a declaration that this Story will edit that file, and generated bodies cite
72
- * the same source report from every sibling. The link **text** is left intact:
73
- * a human writing "the caller in [`bin/mandrel.js`](bin/mandrel.js)" is naming
74
- * an edit target in the prose half, and that half still counts.
75
- */
76
- const MARKDOWN_LINK_URL_RE = /\]\(\s*[^)\s]*(?:\s+"[^"]*")?\s*\)/g;
77
-
78
- /** Default gitignored scratch root when no `project.paths.tempRoot` is threaded. */
79
- const DEFAULT_TEMP_ROOT = 'temp';
80
-
81
43
  /**
82
44
  * Extract a Story's declared file footprint as a normalized set of path
83
45
  * strings. Accepts the three footprint shapes a Story record can carry:
@@ -125,236 +87,6 @@ function isGlobPath(path) {
125
87
  return path.includes('*') || path.includes('?') || path.includes('{');
126
88
  }
127
89
 
128
- /**
129
- * Is this path inside the gitignored temp root?
130
- *
131
- * `project.paths.tempRoot` is scratch space by contract
132
- * ([`.agents/instructions.md`](../../../instructions.md) § 6): nothing under it
133
- * is ever committed, so it can never be a delivery write target, and two
134
- * Stories naming the same `temp/audits/audit-<lens>-results.md` source report
135
- * are not racing anything. The match is rooted, not a substring test, so a real
136
- * deliverable like `lib/temperature.js` is untouched.
137
- *
138
- * @param {string} path
139
- * @param {string} tempRoot
140
- * @returns {boolean}
141
- */
142
- function isUnderTempRoot(path, tempRoot) {
143
- if (!tempRoot) return false;
144
- const normalized = path.replace(/^\.\//, '');
145
- return normalized === tempRoot || normalized.startsWith(`${tempRoot}/`);
146
- }
147
-
148
- /**
149
- * Scrape file paths a Story's **text** mentions but its `changes[]` never
150
- * declared (Story #4875), **narrowed to text that expresses edit intent**
151
- * (Story #5044).
152
- *
153
- * The declared footprint is a planner's *prediction*, and it is systematically
154
- * a lower bound: a Story's `## Spec` names the module it must also touch, its
155
- * acceptance criteria name the caller that must be updated, and none of that
156
- * reaches `changes[]`. The overlap guard exists to stop two Stories racing the
157
- * same file, so trusting the declaration outright means the guard is blind to
158
- * precisely the collisions nobody predicted.
159
- *
160
- * But the converse failure is just as real: a path-shaped token that no human
161
- * wrote as intent manufactures a collision, and a manufactured collision
162
- * serializes a run that had no reason to be serial. Three token sources are
163
- * therefore excluded before the scrape, each because it is *structurally*
164
- * incapable of naming an edit target:
165
- *
166
- * 1. **Audit provenance footers** ({@link PROVENANCE_FOOTER_RE}) — machine-
167
- * stamped dedup identity, unioned sweep-wide across siblings.
168
- * 2. **Markdown-link URLs** ({@link MARKDOWN_LINK_URL_RE}) — citations.
169
- * 3. **Paths under the temp root** ({@link isUnderTempRoot}) — gitignored
170
- * scratch, never a write target.
171
- *
172
- * Evidence is only ever **added** to the declaration — nothing here can shrink
173
- * a declared footprint, so `changes[]` remains a lower bound (Story #4875) and
174
- * narrowing the scrape can never co-dispatch a pair the declared comparison
175
- * would have caught.
176
- *
177
- * Each path is returned **with the field it was scraped from** (Story #5265).
178
- * "This pair collided on a path neither declared" is only half an
179
- * explanation: the operator's next question is always *where did that path
180
- * come from*, and until they can answer it they cannot tell an unpredicted
181
- * edit target from a citation the guard read as one. The measured case is a
182
- * gate script every Story merely **runs** in `verify[]` — attribution turns
183
- * that from an unexplained serialisation into a one-glance verdict.
184
- *
185
- * @param {object} story
186
- * @param {object} [options]
187
- * @param {string} [options.tempRoot='temp'] Resolved `project.paths.tempRoot`.
188
- * @returns {Map<string, Set<string>>} Path → the field label(s) that named it.
189
- */
190
- function storyEvidencePaths(story, { tempRoot = DEFAULT_TEMP_ROOT } = {}) {
191
- const out = new Map();
192
- for (const [field, text] of attributedSegments(story)) {
193
- for (const [token] of text.matchAll(PROSE_PATH_RE)) {
194
- if (isUnderTempRoot(token, tempRoot)) continue;
195
- const fields = out.get(token);
196
- if (fields) fields.add(field);
197
- else out.set(token, new Set([field]));
198
- }
199
- }
200
- return out;
201
- }
202
-
203
- /**
204
- * A markdown section heading in a serialized Story body — the attribution
205
- * grain (Story #5265).
206
- *
207
- * `body` alone would be a true but useless label: a Story body is the whole
208
- * document, so every scraped path would report the same field. The section is
209
- * where the distinction actually lives — a path under `## Changes` is a
210
- * declaration restated, one under `## Verify` is a command line, one under
211
- * `## Non-Goals` is explicitly *not* an edit target.
212
- */
213
- const BODY_SECTION_RE = /^#{2,6}[ \t]+(\S.*?)[ \t]*$/gm;
214
-
215
- /**
216
- * Strip the two token sources that are structurally incapable of naming an
217
- * edit target. Applied to the whole field **before** segmentation, so the
218
- * scanned text is byte-identical to what the pre-attribution scrape read and
219
- * a stripped footer can never be mistaken for a section boundary.
220
- *
221
- * @param {string} text
222
- * @returns {string}
223
- */
224
- function stripNonIntentTokens(text) {
225
- return text
226
- .replace(PROVENANCE_FOOTER_RE, ' ')
227
- .replace(MARKDOWN_LINK_URL_RE, ']()');
228
- }
229
-
230
- /**
231
- * Split a Story body into `[label, text]` segments at its `##` headings.
232
- *
233
- * The heading line stays with the section it opens rather than being consumed
234
- * as a delimiter: a heading can itself name a path, and dropping that text
235
- * would *narrow* the footprint — the one direction this layer must never move
236
- * (Story #4875 / #5265 AC-8). Text before the first heading keeps the bare
237
- * `body` label.
238
- *
239
- * @param {string} body
240
- * @returns {Array<[string, string]>}
241
- */
242
- function bodySegments(body) {
243
- const out = [];
244
- let cursor = 0;
245
- let label = 'body';
246
- for (const match of body.matchAll(BODY_SECTION_RE)) {
247
- if (match.index > cursor)
248
- out.push([label, body.slice(cursor, match.index)]);
249
- label = `body:${match[1].trim()}`;
250
- cursor = match.index;
251
- }
252
- out.push([label, body.slice(cursor)]);
253
- return out;
254
- }
255
-
256
- /**
257
- * Every scannable `[fieldLabel, text]` pair on a Story record: `title` and
258
- * `spec` whole, `body` split by section.
259
- *
260
- * @param {object} story
261
- * @returns {Array<[string, string]>}
262
- */
263
- function attributedSegments(story) {
264
- const out = [];
265
- if (typeof story?.title === 'string') {
266
- out.push(['title', stripNonIntentTokens(story.title)]);
267
- }
268
- if (typeof story?.body === 'string') {
269
- out.push(...bodySegments(stripNonIntentTokens(story.body)));
270
- }
271
- if (typeof story?.spec === 'string') {
272
- out.push(['spec', stripNonIntentTokens(story.spec)]);
273
- }
274
- return out;
275
- }
276
-
277
- /**
278
- * A Story's declared footprint **and** the evidence-widened one.
279
- *
280
- * Both are returned because they answer different questions. `widened` decides
281
- * *whether* two Stories collide; `declared` decides *how to describe* the
282
- * collision — a path both Stories declared is intended serialization (two
283
- * Stories that really do rewrite the same generated baseline), while one only
284
- * the scrape produced may be an artifact of how a body was worded. An operator
285
- * reading an unfilled slot needs to tell those apart.
286
- *
287
- * `evidence` carries the third answer (Story #5265): *which field* produced
288
- * each scraped path, so a collision can say where the token was written
289
- * rather than only that nobody declared it.
290
- *
291
- * @param {object} story
292
- * @param {object} [options]
293
- * @returns {{ declared: Set<string>, widened: Set<string>, evidence: Map<string, Set<string>> }}
294
- */
295
- function storyFootprints(story, options) {
296
- const declared = storyFootprint(story);
297
- const evidence = storyEvidencePaths(story, options);
298
- const widened = new Set(declared);
299
- for (const path of evidence.keys()) widened.add(path);
300
- return { declared, widened, evidence };
301
- }
302
-
303
- /**
304
- * The field labels that scraped `path` on one side — empty when that side
305
- * **declared** it, because a declaration is not evidence and reporting the
306
- * prose restatement of a declared path would read as if the scrape had caused
307
- * the collision.
308
- *
309
- * @param {{ declared: Set<string>, evidence: Map<string, Set<string>> }} side
310
- * @param {string} path
311
- * @returns {string[]}
312
- */
313
- function scrapedFields(side, path) {
314
- if (side.declared.has(path)) return [];
315
- return [...(side.evidence.get(path) ?? [])];
316
- }
317
-
318
- /**
319
- * Per-path provenance for one colliding path (Story #5265): whether both
320
- * sides declared it, and — when at least one side did not — the field labels
321
- * the scrape found it in, unioned across the two sides and sorted.
322
- *
323
- * @param {object} fa
324
- * @param {object} fb
325
- * @param {string} path
326
- * @param {boolean} declared
327
- * @returns {{ path: string, declared: boolean, fields: string[] }}
328
- */
329
- function attributePath(fa, fb, path, declared) {
330
- const fields = new Set([
331
- ...scrapedFields(fa, path),
332
- ...scrapedFields(fb, path),
333
- ]);
334
- return { path, declared, fields: [...fields].sort() };
335
- }
336
-
337
- /**
338
- * Record one colliding path, remembering whether **any** occurrence of it was
339
- * declaration-backed.
340
- *
341
- * `declared` is tracked per path rather than per pair because the two hit kinds
342
- * qualify differently: a shared **concrete** path counts as declared only when
343
- * both sides declared it, whereas a **glob** names no file to share and counts
344
- * as declared when its own side declared it. The scraper cannot emit a glob —
345
- * prose globs are narrative ("everything under `.agents/**`") and never match
346
- * {@link PROSE_PATH_RE} — so a glob hit is essentially always declared width
347
- * failing safe, and labelling it `scraped` would make advisory mode read as if
348
- * the text widening had caused it.
349
- *
350
- * @param {Map<string, boolean>} hits
351
- * @param {string} path
352
- * @param {boolean} declared
353
- */
354
- function recordHit(hits, path, declared) {
355
- hits.set(path, (hits.get(path) ?? false) || declared);
356
- }
357
-
358
90
  /**
359
91
  * Collect the glob paths on one side. A glob is unknown width, and unknown
360
92
  * width is not no width: within a beat it collides with everything, because
@@ -362,24 +94,23 @@ function recordHit(hits, path, declared) {
362
94
  * `.agents/scripts/lib/**` alongside one declaring a file underneath it
363
95
  * (Story #4539/#4540).
364
96
  *
365
- * @param {Map<string, boolean>} hits
366
- * @param {{ declared: Set<string>, widened: Set<string> }} side
97
+ * @param {Set<string>} hits
98
+ * @param {Set<string>} side
367
99
  */
368
- function recordGlobs(hits, side) {
369
- for (const path of side.widened) {
370
- if (isGlobPath(path)) recordHit(hits, path, side.declared.has(path));
100
+ function collectGlobs(hits, side) {
101
+ for (const path of side) {
102
+ if (isGlobPath(path)) hits.add(path);
371
103
  }
372
104
  }
373
105
 
374
106
  /**
375
- * The colliding paths between two Stories' widened footprints, tagged with
376
- * whether a declaration produced the collision — or `null` when they do not
377
- * collide.
107
+ * The colliding paths between two Stories' declared footprints — or `null`
108
+ * when they do not collide.
378
109
  *
379
110
  * **An empty footprint means "no known overlap"**, so this short-circuits to
380
111
  * `null` on one. That is permissive by necessity: a Story with no declared
381
- * footprint and no path evidence in its text carries no information, and
382
- * withholding on absence would serialize every run.
112
+ * footprint carries no information, and withholding on absence would
113
+ * serialize every run.
383
114
  *
384
115
  * `concreteOnly` selects between the two guards' deliberately different
385
116
  * treatment of unknown width (Story #4960). The beat-local guard counts a glob
@@ -389,71 +120,30 @@ function recordGlobs(hits, side) {
389
120
  * run for hours — and `resolve-stories.js` substitutes an UNKNOWN sentinel for
390
121
  * any body it cannot parse, so one malformed Story would make a run serial.
391
122
  *
392
- * `attribution` (Story #5265) reports the same `paths`, one entry each, with
393
- * the provenance a consumer needs to explain the withhold: `declared` says
394
- * whether both sides named the path in `changes[]`, and `fields` names the
395
- * field label(s) the scrape read it from otherwise (`title`, `spec`, or
396
- * `body:<section>`). It is strictly additive — `paths` and `source` are
397
- * unchanged, so no pair that collided before collides differently now.
123
+ * The second options parameter is accepted for call-site compatibility with
124
+ * the retired evidence-scrape options (`tempRoot`); only `concreteOnly` is
125
+ * read.
398
126
  *
399
127
  * @param {object} a
400
128
  * @param {object} b
401
129
  * @param {object} [options]
402
130
  * @param {boolean} [options.concreteOnly=false] Skip glob paths on both sides.
403
- * @param {string} [options.tempRoot]
404
- * @returns {{ paths: string[], source: string, attribution: Array<{ path: string, declared: boolean, fields: string[] }> }|null}
131
+ * @returns {{ paths: string[], source: string }|null}
405
132
  */
406
- export function detectCollision(
407
- a,
408
- b,
409
- { concreteOnly = false, ...evidence } = {},
410
- ) {
411
- const fa = storyFootprints(a, evidence);
412
- if (fa.widened.size === 0) return null;
413
- const fb = storyFootprints(b, evidence);
414
- if (fb.widened.size === 0) return null;
133
+ export function detectCollision(a, b, { concreteOnly = false } = {}) {
134
+ const fa = storyFootprint(a);
135
+ if (fa.size === 0) return null;
136
+ const fb = storyFootprint(b);
137
+ if (fb.size === 0) return null;
415
138
 
416
- const hits = new Map();
417
- for (const path of fa.widened) {
418
- if (!isGlobPath(path) && fb.widened.has(path)) {
419
- recordHit(hits, path, fa.declared.has(path) && fb.declared.has(path));
420
- }
139
+ const hits = new Set();
140
+ for (const path of fa) {
141
+ if (!isGlobPath(path) && fb.has(path)) hits.add(path);
421
142
  }
422
143
  if (!concreteOnly) {
423
- recordGlobs(hits, fa);
424
- recordGlobs(hits, fb);
144
+ collectGlobs(hits, fa);
145
+ collectGlobs(hits, fb);
425
146
  }
426
147
  if (hits.size === 0) return null;
427
- const paths = [...hits.keys()].sort();
428
- return {
429
- paths,
430
- source: [...hits.values()].some(Boolean)
431
- ? OVERLAP_SOURCES.DECLARED
432
- : OVERLAP_SOURCES.SCRAPED,
433
- attribution: paths.map((path) =>
434
- attributePath(fa, fb, path, hits.get(path)),
435
- ),
436
- };
437
- }
438
-
439
- /**
440
- * Render one collision's scraped-path provenance as a single operator-facing
441
- * clause, or `''` when every colliding path was declared by both sides.
442
- *
443
- * Shared by every report that names a withhold so the tick's envelope note
444
- * and plan-persist's predicted-serialisation table read identically — the two
445
- * surfaces describe the same computation and an operator comparing them
446
- * should not have to translate (Story #5265).
447
- *
448
- * @param {Array<{ path: string, declared: boolean, fields: string[] }>} attribution
449
- * @returns {string}
450
- */
451
- export function renderScrapeAttribution(attribution) {
452
- const scraped = (Array.isArray(attribution) ? attribution : []).filter(
453
- (entry) => Array.isArray(entry?.fields) && entry.fields.length > 0,
454
- );
455
- if (scraped.length === 0) return '';
456
- return scraped
457
- .map((entry) => `${entry.path} ← ${entry.fields.join(', ')}`)
458
- .join('; ');
148
+ return { paths: [...hits].sort(), source: OVERLAP_SOURCES.DECLARED };
459
149
  }
@@ -296,12 +296,13 @@ function reservesConcretePath(held, candidate, options = {}) {
296
296
  * the guard *detects* — every would-be withhold is still computed and
297
297
  * returned in `footprintWithholds` with `enforced: false` — so turning it on
298
298
  * trades serialization for throughput without going blind (Story #5044).
299
- * @param {string} [args.tempRoot] Resolved `project.paths.tempRoot`, threaded
300
- * so the evidence scrape can ignore gitignored scratch paths.
299
+ * @param {string} [args.tempRoot] Resolved `project.paths.tempRoot`. Accepted
300
+ * for call-site compatibility; the evidence scrape that read it was retired
301
+ * in Story #5313 and the footprint is the declared `changes[]` alone.
301
302
  * @returns {{
302
303
  * selected: StoryRecord[],
303
304
  * withheldByInFlight: Array<{id: number, blockedBy: number}>,
304
- * footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], attribution: object[], enforced: boolean}>,
305
+ * footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], enforced: boolean}>,
305
306
  * guardMode: 'enforce'|'advisory'
306
307
  * }}
307
308
  * `selected` is the dispatch set: a subset of `stories`, ascending by id,
@@ -309,8 +310,8 @@ function reservesConcretePath(held, candidate, options = {}) {
309
310
  * names each eligible Story a reservation held back and the in-flight
310
311
  * Story that holds it. `footprintWithholds` is the **complete** ledger —
311
312
  * beat-local skips as well as cross-beat reservations, each with the
312
- * colliding paths and its `declared-overlap` / `scraped-overlap` source — so
313
- * no withheld dispatch is unexplained (Story #5044).
313
+ * colliding paths and its `declared-overlap` source — so no withheld
314
+ * dispatch is unexplained (Story #5044).
314
315
  */
315
316
  export function planReadySet({
316
317
  stories,
@@ -23,14 +23,18 @@
23
23
  * totalMethods: number,
24
24
  * } }
25
25
  *
26
- * A truly unrecoverable per-file failure (read error, transpile null)
27
- * surfaces as `{ ok: true, result: { relPath, rows: null, ... } }` so
28
- * the host loop drops the file and increments its own counter — never
29
- * aborts the whole scan.
26
+ * A truly unrecoverable per-file failure (read error, transpile null, or a
27
+ * source the kernel cannot parse) surfaces as
28
+ * `{ ok: true, result: { relPath, rows: null, ... } }` so the host loop drops
29
+ * the file and increments its own counter — never aborts the whole scan.
30
30
  */
31
31
 
32
32
  import { parentPort } from 'node:worker_threads';
33
- import { calculateCrapForSource, finalizeMethodRows } from '../crap-engine.js';
33
+ import {
34
+ calculateCrapForSource,
35
+ finalizeMethodRows,
36
+ UNSCORABLE,
37
+ } from '../crap-engine.js';
34
38
  import { prepareSourceForScoring } from '../transpile.js';
35
39
  import { serveWorkerMessages } from './serve-worker-messages.js';
36
40
 
@@ -60,7 +64,7 @@ import { serveWorkerMessages } from './serve-worker-messages.js';
60
64
  * readFile?: (abs: string) => string,
61
65
  * transpile?: (abs: string, source: string, opts?: object) => unknown,
62
66
  * prepare?: (abs: string, deps: object) => object,
63
- * calculateCrap?: (source: string, entry: object|null, mapLine: Function|null) => Array<object>,
67
+ * calculateCrap?: (source: string, entry: object|null, mapLine: Function|null) => Array<object>|null,
64
68
  * }} [deps]
65
69
  * @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
66
70
  */
@@ -87,25 +91,12 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
87
91
  // `item.coverageEntry` may be explicitly `null` when the file has no
88
92
  // coverage, or `undefined` when the caller did not supply it (treat as null).
89
93
  const entry = item.coverageEntry ?? null;
90
- if (requireCoverage && entry === null) {
91
- return {
92
- kind: 'reply',
93
- message: {
94
- ok: true,
95
- result: {
96
- relPath,
97
- skippedFileNoCoverage: true,
98
- rows: [],
99
- skippedMethodsNoCoverage: 0,
100
- hasCoverageEntry: false,
101
- resolvedMethods: 0,
102
- totalMethods: 0,
103
- },
104
- },
105
- };
106
- }
107
94
 
108
- const dropped = (error) => ({
95
+ // Every non-error outcome answers in the same envelope; each branch below
96
+ // overrides only the fields its own verdict changes. Spelling the seven
97
+ // result fields out per branch is what let the drop shape and the success
98
+ // shape drift apart in the first place.
99
+ const reply = (result) => ({
109
100
  kind: 'reply',
110
101
  message: {
111
102
  ok: true,
@@ -117,11 +108,17 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
117
108
  hasCoverageEntry: entry !== null,
118
109
  resolvedMethods: 0,
119
110
  totalMethods: 0,
120
- ...(error ? { error } : {}),
111
+ ...result,
121
112
  },
122
113
  },
123
114
  });
124
115
 
116
+ if (requireCoverage && entry === null) {
117
+ return reply({ skippedFileNoCoverage: true, rows: [] });
118
+ }
119
+
120
+ const dropped = (error) => reply(error ? { error } : {});
121
+
125
122
  // TS/TSX -> transpile-then-analyze, carrying the source map. The coverage
126
123
  // lookup above used the ORIGINAL source path (vitest's coverage-final.json
127
124
  // keys on the .ts file, not transpiled output) and the per-method join
@@ -143,23 +140,17 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
143
140
  err && typeof err.message === 'string' ? err.message : String(err),
144
141
  );
145
142
  }
143
+ // Story #5311: the kernel answers `UNSCORABLE` — not `[]` — for a source it
144
+ // could not parse. Collapsing the two here is what made this drop path
145
+ // unreachable for the whole parse-failure class: the file reached the host
146
+ // as a successfully-scored file with no methods, and its baseline rows
147
+ // vanished without a counter moving. `rows: null` is the serial path's
148
+ // `parseError` verdict in the shape the host loop already drops on.
149
+ if (methodRows === UNSCORABLE) return dropped(null);
146
150
 
147
- const finalized = finalizeMethodRows(methodRows, {
148
- requireCoverage,
149
- coverageAvailable,
150
- });
151
- return {
152
- kind: 'reply',
153
- message: {
154
- ok: true,
155
- result: {
156
- relPath,
157
- skippedFileNoCoverage: false,
158
- hasCoverageEntry: entry !== null,
159
- ...finalized,
160
- },
161
- },
162
- };
151
+ return reply(
152
+ finalizeMethodRows(methodRows, { requireCoverage, coverageAvailable }),
153
+ );
163
154
  }
164
155
 
165
156
  serveWorkerMessages(parentPort, (msg) => handleCrapWorkerMessage(msg, null));
@@ -164,23 +164,21 @@ export async function emitPlanContext({
164
164
  bytes: Buffer.byteLength(json, 'utf8'),
165
165
  sourceTickets: (envelope.sourceTickets ?? []).map((t) => t.id),
166
166
  duplicates: (envelope.duplicates ?? []).length,
167
- // Advisory only (Story #4722): signals, no route — the planner owns
168
- // the trivial-vs-standard verdict and persist validates it by shape.
169
- // The nested `deliverLightSuggestion` is the recorded plan-side routing
170
- // handshake (Story #4741 AC-6) and `uiSurface` the recorded /prototype
171
- // offer — advisory, never an automatic reroute. Both ride the digest
172
- // because with `--out` the digest is the only thing the planner reads.
167
+ // Advisory only: signals, no route. `uiSurface` is the recorded
168
+ // /prototype offer — advisory, never an automatic reroute. It rides the
169
+ // digest because with `--out` the digest is the only thing the planner
170
+ // reads.
173
171
  complexitySignals: envelope.complexitySignals
174
172
  ? {
175
173
  artifactCount: envelope.complexitySignals.artifactCount,
176
- riskHeuristicHits: envelope.complexitySignals.riskHeuristicHits,
177
174
  sensitivePathClasses:
178
175
  envelope.complexitySignals.sensitivePathClasses,
179
- deliverLightSuggestion:
180
- envelope.complexitySignals.deliverLightSuggestion ?? null,
181
176
  uiSurface: envelope.complexitySignals.uiSurface ?? null,
182
177
  }
183
178
  : null,
179
+ // Story #5312: an envelope over the planner-context ceiling is written
180
+ // truncated, and the digest names what was cut.
181
+ truncated: envelope.truncated ?? null,
184
182
  amends: envelope.amends ? { id: envelope.amends.id } : null,
185
183
  };
186
184
  stdout.write(`${JSON.stringify(digest)}\n`);