mandrel 2.23.0 → 2.25.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 (101) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  15. package/.agents/scripts/acceptance-eval.js +35 -9
  16. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  17. package/.agents/scripts/audit-to-stories.js +33 -3
  18. package/.agents/scripts/check-context-budget.js +95 -14
  19. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  20. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  21. package/.agents/scripts/deliver-light.js +22 -45
  22. package/.agents/scripts/deliver-recover.js +13 -0
  23. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
  24. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  25. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  26. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  27. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  28. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  29. package/.agents/scripts/lib/baseline-loader.js +0 -0
  30. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  31. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  32. package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
  33. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  34. package/.agents/scripts/lib/baselines/reader.js +10 -0
  35. package/.agents/scripts/lib/baselines/writer.js +7 -3
  36. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  37. package/.agents/scripts/lib/crap-engine.js +97 -19
  38. package/.agents/scripts/lib/crap-utils.js +73 -13
  39. package/.agents/scripts/lib/env-loader.js +46 -16
  40. package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
  41. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  42. package/.agents/scripts/lib/findings/severity.js +80 -2
  43. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  44. package/.agents/scripts/lib/label-constants.js +17 -0
  45. package/.agents/scripts/lib/maintainability-engine.js +83 -11
  46. package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
  47. package/.agents/scripts/lib/maintainability-utils.js +14 -5
  48. package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
  49. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  50. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  51. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  52. package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
  53. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  54. package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
  55. package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
  57. package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
  58. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  59. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  60. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  61. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  63. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  64. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
  65. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  66. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
  67. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  68. package/.agents/scripts/lib/story-plan.js +137 -42
  69. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  70. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  71. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  72. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  73. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  74. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  75. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  76. package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
  77. package/.agents/scripts/pr-watch-with-update.js +568 -213
  78. package/.agents/scripts/quality-preview.js +25 -0
  79. package/.agents/scripts/run-tests.js +10 -0
  80. package/.agents/scripts/single-story-close.js +6 -0
  81. package/.agents/scripts/single-story-init.js +7 -0
  82. package/.agents/scripts/stories-wave-tick.js +95 -10
  83. package/.agents/scripts/story-plan.js +65 -9
  84. package/.agents/scripts/update-crap-baseline.js +42 -4
  85. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  86. package/.agents/templates/single-story-body.md +16 -8
  87. package/.agents/workflows/audit-architecture.md +69 -13
  88. package/.agents/workflows/audit-quality.md +26 -0
  89. package/.agents/workflows/audit-to-stories.md +30 -4
  90. package/.agents/workflows/deliver.md +81 -95
  91. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  92. package/.agents/workflows/helpers/deliver-light.md +21 -4
  93. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  94. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  95. package/.agents/workflows/helpers/deliver-story.md +104 -119
  96. package/.agents/workflows/helpers/plan-reference.md +34 -0
  97. package/.agents/workflows/plan.md +60 -76
  98. package/docs/CHANGELOG.md +38 -0
  99. package/lib/cli/update.js +14 -11
  100. package/lib/cli/version-check.js +9 -1
  101. package/package.json +1 -1
@@ -9,16 +9,24 @@
9
9
  * - DEFAULT_REFINE_THRESHOLD — seed length below which refinement
10
10
  * is auto-suggested.
11
11
  * - REQUIRED_SECTIONS — canonical section headings the body
12
- * must carry to be accepted by
13
- * /single-story-deliver.
12
+ * must carry itself. Story #4874: the
13
+ * `## Acceptance` / `## Verify` sections
14
+ * are deliberately NOT among them —
15
+ * those lists are the ticket's top-level
16
+ * machine contract and persist
17
+ * synthesizes their sections, exactly as
18
+ * the story-author prompt instructs.
14
19
  * - rankDuplicateCandidates({ seed, openStories, maxResults })
15
20
  * — Jaccard-overlap ranking of open
16
21
  * Stories whose titles fuzzy-match
17
22
  * the seed.
18
- * - validateStoryBody(body) — schema-light shape check:
19
- * required sections present, no
20
- * `Epic:` reference, AC checklist
21
- * non-empty.
23
+ * - validateStoryBody(body, contract)
24
+ * schema-light shape check against the
25
+ * canonical Story-body contract: `##
26
+ * Goal` / `## Changes` present, no
27
+ * `Epic:` reference, and an
28
+ * acceptance/verify contract resolvable
29
+ * from the top-level arrays or the body.
22
30
  * - buildContextEnvelope(opts) — assemble the context envelope the
23
31
  * host LLM consumes.
24
32
  */
@@ -26,21 +34,25 @@
26
34
  import { readFile } from 'node:fs/promises';
27
35
  import path from 'node:path';
28
36
  import { overlapScore, tokenize } from './duplicate-search.js';
37
+ import { parse as parseStoryBody } from './story-body/story-body.js';
29
38
 
30
39
  export const DEFAULT_REFINE_THRESHOLD = 200;
31
40
  export const DEFAULT_DUPLICATE_MAX_RESULTS = 5;
32
41
  export const DEFAULT_DUPLICATE_MIN_SCORE = 0.15;
33
42
 
34
- export const REQUIRED_SECTIONS = [
35
- 'Context',
36
- 'Acceptance Criteria',
37
- 'Out of Scope',
38
- 'Notes',
39
- ];
43
+ /**
44
+ * The sections the authored body must carry itself. Story #4874 reconciled
45
+ * this list with the story-author prompt: the prompt tells the author to
46
+ * write `acceptance[]` / `verify[]` **once** at the ticket's top level and
47
+ * omit the matching body sections, so demanding those sections here made a
48
+ * prompt-faithful body unpersistable and cost a re-author round.
49
+ */
50
+ export const REQUIRED_SECTIONS = ['Goal', 'Changes'];
51
+
52
+ /** The contract lists persist synthesizes into the body from the top level. */
53
+ const CONTRACT_FIELDS = /** @type {const} */ (['acceptance', 'verify']);
40
54
 
41
55
  const EPIC_REF_PATTERN = /^\s*Epic:\s*#\d+/m;
42
- const AC_HEADING_PATTERN = /^##\s+Acceptance Criteria\s*$/m;
43
- const CHECKLIST_PATTERN = /^\s*-\s*\[\s?\]/m;
44
56
 
45
57
  /**
46
58
  * Rank open Stories by title-overlap with the seed. Reuses the same
@@ -115,54 +127,137 @@ export function shouldRefine({
115
127
  }
116
128
 
117
129
  /**
118
- * Schema-light validator for a standalone-Story body. Used by the persist
119
- * path *and* by tests asserting `--dry-run` output stability. The check
120
- * is deliberately tolerant of authoring whitespace: it asserts the
121
- * canonical headings are present, that no `Epic: #N` reference leaks
122
- * (the standalone contract), and that the Acceptance Criteria section
123
- * carries at least one unchecked checklist item.
130
+ * Collect the violations for one contract list (`acceptance` / `verify`).
131
+ *
132
+ * The list may be authored at the ticket's top level (the shape the
133
+ * story-author prompt prescribes) or written into the body as a section —
134
+ * either satisfies the contract, and the top level is preferred because
135
+ * persist synthesizes the section from it. Only two shapes are violations:
136
+ * the list is nowhere, or it is in both places and the two disagree (fail
137
+ * closed rather than guess which is authoritative, mirroring
138
+ * `plan-persist`'s `syncContractFieldFromTopLevel`).
139
+ *
140
+ * @param {'acceptance'|'verify'} field
141
+ * @param {unknown} bodyList The parsed body's section entries.
142
+ * @param {unknown} topLevel The ticket's top-level array.
143
+ * @returns {string[]}
144
+ */
145
+ function collectContractErrors(field, bodyList, topLevel) {
146
+ const inBody = (Array.isArray(bodyList) ? bodyList : []).map(String);
147
+ const inTicket = (Array.isArray(topLevel) ? topLevel : []).map(String);
148
+ if (inBody.length === 0 && inTicket.length === 0) {
149
+ return [
150
+ `${field} must list at least one entry — author it as the ticket's top-level ${field}[] array; persist synthesizes the "## ${field[0].toUpperCase()}${field.slice(1)}" section from it`,
151
+ ];
152
+ }
153
+ if (
154
+ inBody.length > 0 &&
155
+ inTicket.length > 0 &&
156
+ (inBody.length !== inTicket.length ||
157
+ inBody.some((v, i) => v !== inTicket[i]))
158
+ ) {
159
+ return [
160
+ `${field} disagrees between the body section and the top-level ${field}[] array — author it once, at the top level`,
161
+ ];
162
+ }
163
+ return [];
164
+ }
165
+
166
+ /**
167
+ * Schema-light validator for a standalone-Story body, stated against the
168
+ * same authoring shape the story-author prompt prescribes (Story #4874).
169
+ *
170
+ * It parses the body with the canonical Story-body parser and asserts:
171
+ * a non-empty `## Goal`, at least one `## Changes` entry, no leaking
172
+ * `Epic: #N` reference (the standalone contract), and an acceptance +
173
+ * verify contract resolvable from the ticket's top-level arrays **or** the
174
+ * body's own sections. It deliberately does not demand the `## Acceptance`
175
+ * / `## Verify` sections of the author — persist synthesizes them.
124
176
  *
125
177
  * @param {string} body
178
+ * @param {{ acceptance?: string[], verify?: string[] }} [contract]
179
+ * The ticket's top-level contract arrays, when the caller has them.
126
180
  * @returns {{ ok:boolean, errors:string[] }}
127
181
  */
128
- export function validateStoryBody(body) {
129
- const errors = [];
182
+ export function validateStoryBody(body, contract = {}) {
130
183
  if (typeof body !== 'string' || body.trim().length === 0) {
131
184
  return { ok: false, errors: ['body is empty'] };
132
185
  }
133
186
 
134
- for (const section of REQUIRED_SECTIONS) {
135
- const pattern = new RegExp(`^##\\s+${escapeRegex(section)}\\s*$`, 'm');
136
- if (!pattern.test(body)) {
137
- errors.push(`missing required section: "## ${section}"`);
138
- }
187
+ let parsed;
188
+ try {
189
+ parsed = parseStoryBody(body).body;
190
+ } catch (err) {
191
+ return {
192
+ ok: false,
193
+ errors: [`body is not a parseable Story body: ${err.message}`],
194
+ };
139
195
  }
140
196
 
197
+ const errors = [];
198
+ if (typeof parsed.goal !== 'string' || parsed.goal.trim() === '') {
199
+ errors.push('missing required section: "## Goal"');
200
+ }
201
+ if (!Array.isArray(parsed.changes) || parsed.changes.length === 0) {
202
+ errors.push('missing required section: "## Changes"');
203
+ }
141
204
  if (EPIC_REF_PATTERN.test(body)) {
142
205
  errors.push(
143
206
  'body contains an "Epic: #N" reference — standalone Stories must not link to an Epic',
144
207
  );
145
208
  }
146
-
147
- // AC checklist non-empty: extract from "## Acceptance Criteria" to the
148
- // next "## " heading (or EOF) and assert at least one checklist item.
149
- const acStart = body.search(AC_HEADING_PATTERN);
150
- if (acStart !== -1) {
151
- const rest = body.slice(acStart);
152
- const nextHeading = rest.slice(1).search(/^##\s+/m);
153
- const acBlock = nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1);
154
- if (!CHECKLIST_PATTERN.test(acBlock)) {
155
- errors.push(
156
- 'Acceptance Criteria section has no unchecked checklist items (`- [ ] ...`)',
157
- );
158
- }
209
+ for (const field of CONTRACT_FIELDS) {
210
+ errors.push(
211
+ ...collectContractErrors(field, parsed[field], contract?.[field]),
212
+ );
159
213
  }
160
214
 
161
215
  return { ok: errors.length === 0, errors };
162
216
  }
163
217
 
164
- function escapeRegex(s) {
165
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
218
+ /**
219
+ * Render the canonical `## Acceptance` / `## Verify` sections into a body
220
+ * that omits them, from the ticket's top-level arrays (Story #4874).
221
+ *
222
+ * This is the synthesis the story-author prompt promises: the author writes
223
+ * each list once at top level, and the persisted GitHub issue still reads as
224
+ * a complete executable document. A body that already carries a section is
225
+ * returned untouched — `validateStoryBody` has already refused the case
226
+ * where the two disagree.
227
+ *
228
+ * @param {string} body
229
+ * @param {{ acceptance?: string[], verify?: string[] }} [contract]
230
+ * @returns {string}
231
+ */
232
+ export function synthesizeContractSections(body, contract = {}) {
233
+ let parsed;
234
+ try {
235
+ parsed = parseStoryBody(body).body;
236
+ } catch {
237
+ return body;
238
+ }
239
+ const blocks = [];
240
+ if (
241
+ (parsed.acceptance ?? []).length === 0 &&
242
+ Array.isArray(contract.acceptance) &&
243
+ contract.acceptance.length > 0
244
+ ) {
245
+ const items = contract.acceptance
246
+ .map((a, i) => `- [ ] AC-${i + 1}: ${a}`)
247
+ .join('\n');
248
+ blocks.push(`## Acceptance\n${items}`);
249
+ }
250
+ if (
251
+ (parsed.verify ?? []).length === 0 &&
252
+ Array.isArray(contract.verify) &&
253
+ contract.verify.length > 0
254
+ ) {
255
+ blocks.push(
256
+ `## Verify\n${contract.verify.map((v) => `- ${v}`).join('\n')}`,
257
+ );
258
+ }
259
+ if (blocks.length === 0) return body;
260
+ return `${body.trimEnd()}\n\n${blocks.join('\n\n')}\n`;
166
261
  }
167
262
 
168
263
  /**
@@ -113,9 +113,9 @@ You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown
113
113
 
114
114
  The \`acceptance[]\` and \`verify[]\` arrays live at the **top level** of the Story ticket object — that is the machine contract the validator reads. Author each list **once, at top level**, and **omit** the \`## Acceptance\` / \`## Verify\` sections from the authored \`body\` string: persist syncs the top-level arrays into those sections so the GitHub issue stays a complete executable document. The validator resolves both fields from the top level, so an omitted section is the expected shape, not a violation.
115
115
 
116
- If you do write those sections into the \`body\` string anyway, they must mirror the top-level arrays **item for item** — persist fails closed on a disagreement rather than guessing which list is authoritative. Do **not** invent a second criteria list inside \`## Spec\`, and do not author a separate Acceptance Spec / PRD artifact.
116
+ Never hand-mirror either list into the \`body\` there is no step that asks you to. Persist fails closed on a body section that disagrees with its top-level array rather than guessing which list is authoritative, so writing one anyway only adds a way to be wrong. Do **not** invent a second criteria list inside \`## Spec\`, and do not author a separate Acceptance Spec / PRD artifact.
117
117
 
118
- The serialized \`body\` string renders these markdown sections (in order):
118
+ The **persisted** \`body\` renders these markdown sections (in order) — you author every one of them except \`## Acceptance\` / \`## Verify\`, which persist synthesizes from the top-level arrays:
119
119
 
120
120
  ## Goal
121
121
  <one sentence — why this Story exists>
@@ -130,11 +130,11 @@ The serialized \`body\` string renders these markdown sections (in order):
130
130
  - {"path": "<file path>", "assumption": "creates" | "refactors-existing" | "deletes"}
131
131
  - ...
132
132
 
133
- ## Acceptance
133
+ ## Acceptance <-- synthesized by persist from acceptance[]; do not author
134
134
  - [ ] <testable, observable criterion>
135
135
  - ...
136
136
 
137
- ## Verify
137
+ ## Verify <-- synthesized by persist from verify[]; do not author
138
138
  - <exact command or test path> (<tier>)
139
139
  - ...
140
140
 
@@ -235,7 +235,7 @@ export function sliceTicketBodyForDelivery(body) {
235
235
  // survive verbatim.
236
236
  const techLoc = locate(working, 'techSpec');
237
237
  let techRegion = null;
238
- const PLACEHOLDER = 'MANDREL_TECH_SPEC_PLACEHOLDER';
238
+ const PLACEHOLDER = '\u0000MANDREL_TECH_SPEC_PLACEHOLDER\u0000';
239
239
  if (techLoc) {
240
240
  const { end } = descriptor('techSpec');
241
241
  techRegion = working.slice(techLoc.startIdx, techLoc.endIdx + end.length);
@@ -13,6 +13,13 @@
13
13
  * `sleep` is exported as the cancellable delay primitive used internally;
14
14
  * callers that already have their own cadence (e.g. a continuous ticker)
15
15
  * can import it directly.
16
+ *
17
+ * `sleepFn` is the inter-tick delay seam (Story #4873). A caller that already
18
+ * owns a delay seam of its own — the close-and-land merge wait, whose suite
19
+ * must never actually sleep out a 30s poll interval — passes it here so
20
+ * adopting this primitive does not cost it that seam. It is called as
21
+ * `sleepFn(intervalMs, signal)`; a one-argument stub simply ignores the
22
+ * signal, and the default is the cancellable {@link sleep} below.
16
23
  */
17
24
 
18
25
  /**
@@ -23,11 +30,20 @@
23
30
  * timeoutMs?: number,
24
31
  * signal?: AbortSignal,
25
32
  * logger?: { warn?: Function },
33
+ * sleepFn?: (ms: number, signal?: AbortSignal) => Promise<void>,
26
34
  * }} opts
27
35
  * @returns {Promise<any | undefined>}
28
36
  */
29
37
  export async function pollUntil(opts) {
30
- const { fn, predicate, intervalMs, timeoutMs, signal, logger } = opts;
38
+ const {
39
+ fn,
40
+ predicate,
41
+ intervalMs,
42
+ timeoutMs,
43
+ signal,
44
+ logger,
45
+ sleepFn = sleep,
46
+ } = opts;
31
47
  if (typeof fn !== 'function') throw new TypeError('pollUntil: fn required');
32
48
  if (typeof predicate !== 'function') {
33
49
  throw new TypeError('pollUntil: predicate required');
@@ -52,7 +68,7 @@ export async function pollUntil(opts) {
52
68
  if (deadline !== null && Date.now() >= deadline) {
53
69
  throw new Error(`pollUntil: timed out after ${timeoutMs}ms`);
54
70
  }
55
- await sleep(intervalMs, signal);
71
+ await sleepFn(intervalMs, signal);
56
72
  }
57
73
  return undefined;
58
74
  }
@@ -246,7 +246,7 @@ export function createProbeContext({
246
246
  * caller tracked in-flight itself; probe mode must put them back.
247
247
  *
248
248
  * @returns {Promise<{
249
- * nodes: Array<{id: number, dependsOn: number[], files: string[], labels: string[]}>,
249
+ * nodes: Array<{id: number, dependsOn: number[], files: string[], body: string, labels: string[]}>,
250
250
  * doneIds: Set<number>,
251
251
  * inFlight: number,
252
252
  * blockedIds: number[],
@@ -282,6 +282,11 @@ export async function probeLiveState({
282
282
  });
283
283
 
284
284
  const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
285
+ // Bodies ride along with the node so the co-dispatch guard can widen a
286
+ // declared footprint from the paths a Story's own text names (Story #4875).
287
+ // They are consumed in-process by `selectReadySet` and never serialized into
288
+ // the beat envelope, which stays a list of ids.
289
+ const bodyById = new Map(stories.map((s) => [s.id, s.body ?? '']));
285
290
  const inFlightIds = deriveInFlightIds(stories, dispatched);
286
291
  // A Story another operator's lease holds occupies a (global) dispatch slot
287
292
  // just like an in-flight one: fold it into the in-flight set so it is both
@@ -292,6 +297,7 @@ export async function probeLiveState({
292
297
  return {
293
298
  nodes: envelope.dag.map((node) => ({
294
299
  ...node,
300
+ body: bodyById.get(node.id) ?? '',
295
301
  labels: projectInFlightLabels(
296
302
  labelsById.get(node.id) ?? [],
297
303
  inFlightIds.has(node.id),
@@ -26,10 +26,14 @@
26
26
  * (`agent::done` OR closed issue) so a Story closed manually through
27
27
  * the GitHub UI is recognised as done.
28
28
  * - `storiesOverlap(a, b)` — the file-overlap co-dispatch guard: true
29
- * when two Stories' declared file footprints intersect. Two Stories
30
- * that would touch the same file MUST NOT be dispatched onto parallel
29
+ * when two Stories' file footprints intersect. Two Stories that would
30
+ * touch the same file MUST NOT be dispatched onto parallel
31
31
  * `story-<id>` branches in the same beat (they would race the same
32
- * path and produce a merge conflict at close).
32
+ * path and produce a merge conflict at close). The comparison runs over
33
+ * the **widened** footprint (`storyWidenedFootprint`) — a declared
34
+ * `changes[]` is treated as a lower bound and widened from the paths the
35
+ * Story's own text names, because a guard that trusts a prediction cannot
36
+ * prevent the collision nobody predicted (Story #4875).
33
37
  * - `selectReadySet({ stories, doneIds, inFlight, globalCap })` — the
34
38
  * scheduler. Returns the deterministic, overlap-free set of ready
35
39
  * Stories, capped at `globalCap − inFlight`.
@@ -168,6 +172,59 @@ function isGlobPath(path) {
168
172
  return path.includes('*') || path.includes('?') || path.includes('{');
169
173
  }
170
174
 
175
+ /**
176
+ * Repo-relative file paths as they appear in Story prose: at least one `/`
177
+ * separator and a short file extension. Deliberately narrow — a token has to
178
+ * look like a real path before it can widen a footprint and withhold a Story.
179
+ */
180
+ const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
181
+
182
+ /**
183
+ * Scrape file paths a Story's **text** mentions but its `changes[]` never
184
+ * declared (Story #4875).
185
+ *
186
+ * The declared footprint is a planner's *prediction*, and it is systematically
187
+ * a lower bound: a Story's `## Spec` names the module it must also touch, its
188
+ * acceptance criteria name the caller that must be updated, and none of that
189
+ * reaches `changes[]`. The overlap guard exists to stop two Stories racing the
190
+ * same file, so trusting the declaration outright means the guard is blind to
191
+ * precisely the collisions nobody predicted.
192
+ *
193
+ * Evidence is only ever **added** — nothing here can shrink a declared
194
+ * footprint, so widening can withhold a Story for a beat but can never
195
+ * co-dispatch one the declared comparison would have caught.
196
+ *
197
+ * @param {StoryRecord} story
198
+ * @returns {Set<string>}
199
+ */
200
+ function storyEvidencePaths(story) {
201
+ const out = new Set();
202
+ for (const field of [story?.title, story?.body, story?.spec]) {
203
+ if (typeof field !== 'string' || field === '') continue;
204
+ for (const match of field.matchAll(PROSE_PATH_RE)) {
205
+ const trimmed = match[0].trim();
206
+ if (trimmed) out.add(trimmed);
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * A Story's footprint **widened from observable evidence** — the set the
214
+ * co-dispatch guard actually compares (Story #4875).
215
+ *
216
+ * `declared ∪ scraped-from-prose`. See {@link storyEvidencePaths} for why the
217
+ * declaration is treated as a lower bound rather than the answer.
218
+ *
219
+ * @param {StoryRecord} story
220
+ * @returns {Set<string>}
221
+ */
222
+ function storyWidenedFootprint(story) {
223
+ const out = storyFootprint(story);
224
+ for (const path of storyEvidencePaths(story)) out.add(path);
225
+ return out;
226
+ }
227
+
171
228
  /**
172
229
  * File-overlap co-dispatch guard. Returns `true` when two Stories' declared
173
230
  * file footprints intersect — meaning they would race the same file if
@@ -175,12 +232,19 @@ function isGlobPath(path) {
175
232
  * Stories that overlap MUST NOT both appear in one dispatch set; one is
176
233
  * withheld until the other clears.
177
234
  *
235
+ * Comparison runs over the **widened** footprint
236
+ * ({@link storyWidenedFootprint}), not the declaration: a declared `changes[]`
237
+ * is a lower bound on what a Story will touch, and two Stories whose real edits
238
+ * collide were being co-dispatched whenever the collision was not predicted
239
+ * (Story #4875).
240
+ *
178
241
  * Two deliberate asymmetries:
179
242
  *
180
- * - **An empty footprint means "no known overlap"** → `false`. A Story that
181
- * declares no files is never withheld. This is permissive by necessity:
182
- * an undeclared footprint carries no information, and withholding on
183
- * absence would serialize every run.
243
+ * - **An empty footprint means "no known overlap"** → `false`. A Story with
244
+ * no declared footprint and no path evidence in its text is never
245
+ * withheld. This is permissive by necessity: a genuinely unknown footprint
246
+ * carries no information, and withholding on absence would serialize every
247
+ * run.
184
248
  * - **A glob footprint overlaps EVERYTHING** → `true` (Story #4539/#4540).
185
249
  * Comparison is exact-string, so a Story declaring
186
250
  * `.agents/scripts/lib/**` would not match another declaring
@@ -193,9 +257,9 @@ function isGlobPath(path) {
193
257
  * @returns {boolean}
194
258
  */
195
259
  export function storiesOverlap(a, b) {
196
- const fa = storyFootprint(a);
260
+ const fa = storyWidenedFootprint(a);
197
261
  if (fa.size === 0) return false;
198
- const fb = storyFootprint(b);
262
+ const fb = storyWidenedFootprint(b);
199
263
  if (fb.size === 0) return false;
200
264
  for (const path of fa) {
201
265
  if (isGlobPath(path)) return true;
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * Message contract — see lib/cpu-pool.js:
26
26
  * IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
27
- * coverageEntry: object | null } }
27
+ * coverageAvailable?: boolean, coverageEntry: object | null } }
28
28
  * { exit: true }
29
29
  * OUT : { ok: true, result: {
30
30
  * relPath,
@@ -88,7 +88,7 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
88
88
  },
89
89
  };
90
90
  }
91
- const { abs, relPath, requireCoverage } = item;
91
+ const { abs, relPath, requireCoverage, coverageAvailable = true } = item;
92
92
  const prepare = deps.prepare ?? prepareSourceForScoring;
93
93
  const analyze = deps.analyze ?? analyzeOnce;
94
94
 
@@ -150,7 +150,10 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
150
150
  }
151
151
 
152
152
  const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
153
- finalizeMethodRows(rawCrapRows, { requireCoverage });
153
+ finalizeMethodRows(rawCrapRows, {
154
+ requireCoverage,
155
+ coverageAvailable,
156
+ });
154
157
  return reply({
155
158
  miScore,
156
159
  crapRows: rows,
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Message contract — see lib/cpu-pool.js:
13
13
  * IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
14
- * coverageEntry: object | null } }
14
+ * coverageAvailable?: boolean, coverageEntry: object | null } }
15
15
  * { exit: true }
16
16
  * OUT : { ok: true, result: {
17
17
  * relPath,
@@ -80,7 +80,7 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
80
80
  },
81
81
  };
82
82
  }
83
- const { abs, relPath, requireCoverage } = item;
83
+ const { abs, relPath, requireCoverage, coverageAvailable = true } = item;
84
84
 
85
85
  // Coverage entry is pre-resolved on the host and attached to the item.
86
86
  // `item.coverageEntry` may be explicitly `null` when the file has no
@@ -143,7 +143,10 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
143
143
  );
144
144
  }
145
145
 
146
- const finalized = finalizeMethodRows(methodRows, { requireCoverage });
146
+ const finalized = finalizeMethodRows(methodRows, {
147
+ requireCoverage,
148
+ coverageAvailable,
149
+ });
147
150
  return {
148
151
  kind: 'reply',
149
152
  message: {
@@ -7,16 +7,20 @@
7
7
  * Message contract — see lib/cpu-pool.js:
8
8
  * IN : { item: string } — absolute file path to score
9
9
  * { exit: true } — drain & terminate
10
- * OUT : { ok: true, result: { filePath, score: number | null } }
10
+ * OUT : { ok: true, result: { filePath, score, unscorable?, reason? } }
11
11
  *
12
12
  * `score` is `null` only when the file genuinely cannot be read (ENOENT
13
- * or other I/O error). Parse failures inside escomplex still resolve to
14
- * `0` to preserve byte-for-byte parity with the pre-pool serial path
15
- * (calculateForSource swallows parse errors and returns 0).
13
+ * or other I/O error).
14
+ *
15
+ * A file the kernel cannot analyse comes back as `unscorable: true` with the
16
+ * kernel's own `reason`, rather than as a bare `0`. The `0` is still carried in
17
+ * `score` for wire compatibility, but it is no longer the only signal — the
18
+ * point of the flag is that the caller can *report* the file instead of
19
+ * silently dropping it (see `maintainability-engine.js`'s `UNSCORABLE`).
16
20
  */
17
21
 
18
22
  import { parentPort } from 'node:worker_threads';
19
- import { calculateForFile } from '../maintainability-engine.js';
23
+ import { scoreFile } from '../maintainability-engine.js';
20
24
 
21
25
  /**
22
26
  * Pure handler for a single inbound worker message. Exported so unit
@@ -24,7 +28,7 @@ import { calculateForFile } from '../maintainability-engine.js';
24
28
  * without spawning a real `Worker` thread.
25
29
  *
26
30
  * @param {unknown} msg
27
- * @param {{ score?: (filePath: string) => number | null }} [deps]
31
+ * @param {{ score?: (filePath: string) => { score: number, unscorable: boolean, reason: string|null } }} [deps]
28
32
  * @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
29
33
  */
30
34
  export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
@@ -40,12 +44,13 @@ export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
40
44
  };
41
45
  }
42
46
  const filePath = msg.item;
43
- const scoreFn = deps.score ?? calculateForFile;
47
+ const scoreFn = deps.score ?? scoreFile;
44
48
  try {
45
- const score = scoreFn(filePath);
49
+ // `scoreFn` returns `{ score, unscorable, reason }` — spread so the flag
50
+ // and its reason reach the pool caller intact.
46
51
  return {
47
52
  kind: 'reply',
48
- message: { ok: true, result: { filePath, score } },
53
+ message: { ok: true, result: { filePath, ...scoreFn(filePath) } },
49
54
  };
50
55
  } catch (err) {
51
56
  // I/O or other unexpected error — surface as a per-item null score