mandrel 2.24.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 (88) 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/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. package/package.json +1 -1
@@ -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: {