mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  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/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -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
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The contract every test-running entrypoint shares: the `node --test` flag
3
+ * set, and the per-tier preflight.
4
+ *
5
+ * ## Why this is a module and not two literals (Story #4936)
6
+ *
7
+ * The repository has two full-tier runners — `run-tests.js` (`npm test`) and
8
+ * `run-coverage.js` (`npm run test:coverage`, the *required* CI job). When
9
+ * they disagree about a `node --test` flag, they disagree about whether a
10
+ * test can execute at all: `--experimental-test-module-mocks` decides whether
11
+ * `t.mock.module` works, so a divergence makes a suite pass under one runner
12
+ * and fail under the other with no source defect. `FULL_TIER_GLOBS` in
13
+ * [`test-tiers.js`](test-tiers.js) already solved the sibling problem for
14
+ * *which files* run; this module is the same fix for *how they are run*.
15
+ * Both runners import from here; neither restates a literal.
16
+ *
17
+ * ## The preflight is invoked, never hooked
18
+ *
19
+ * `.npmrc` sets `ignore-scripts=true` as deliberate defence against malicious
20
+ * postinstall hooks (CWE-1357). That setting is correct and stays — but it
21
+ * suppresses every `pre*` / `post*` lifecycle script for `npm run` as well as
22
+ * for installs, so a `pretest` entry in `package.json` never fires. The
23
+ * repository carried `pretest`, `pretest:quick` and `pretest:integration`
24
+ * entries that therefore ran for no tier at all, and Story #4922 worked
25
+ * around it for the coverage tier alone by having CI name `pretest:coverage`
26
+ * explicitly. `runTierPreflight` closes the gap for every tier by moving the
27
+ * invocation into the runners, where it executes under `npm test`,
28
+ * `npm run test:coverage`, and a bare `node .agents/scripts/run-tests.js`
29
+ * alike — no lifecycle hook involved.
30
+ */
31
+
32
+ import { spawnSync } from 'node:child_process';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import process from 'node:process';
36
+
37
+ /** Minimum and maximum bounds for `--test-concurrency`. */
38
+ export const TEST_CONCURRENCY_MIN = 1;
39
+ export const TEST_CONCURRENCY_MAX = 16;
40
+
41
+ /**
42
+ * Resolve the `--test-concurrency` value for the current host.
43
+ *
44
+ * Uses `os.availableParallelism()` (Node ≥18.14 / ≥20.0) clamped to the
45
+ * range `[TEST_CONCURRENCY_MIN, TEST_CONCURRENCY_MAX]`. The `parallelism`
46
+ * parameter is injected in tests so the clamping logic is verifiable
47
+ * without touching the OS.
48
+ *
49
+ * @param {number} [parallelism] - defaults to `os.availableParallelism()`
50
+ * @returns {number}
51
+ */
52
+ export function resolveTestConcurrency(
53
+ parallelism = os.availableParallelism(),
54
+ ) {
55
+ return Math.min(
56
+ TEST_CONCURRENCY_MAX,
57
+ Math.max(TEST_CONCURRENCY_MIN, parallelism),
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Fixed `node --test` flags applied to every spawn of every runner.
63
+ *
64
+ * `--test-concurrency` is derived at startup from the host's available
65
+ * parallelism so the value suits the machine running the suite rather than
66
+ * being pinned to the historical constant of 8.
67
+ *
68
+ * This is the **single** declaration. A flag added here reaches both runners;
69
+ * a flag added to one runner's argv builder instead is caught by the
70
+ * flag-set equality assertion in `tests/scripts/run-coverage.test.js`.
71
+ */
72
+ export const TEST_RUNNER_FLAGS = Object.freeze([
73
+ '--experimental-test-module-mocks',
74
+ '--test',
75
+ `--test-concurrency=${resolveTestConcurrency()}`,
76
+ ]);
77
+
78
+ /**
79
+ * Preflight scripts per tier, repo-relative. These are the definitions that
80
+ * used to live in `package.json` as inert `pretest*` entries.
81
+ *
82
+ * `full` carries the skills validator on top of the state-probe wrapper
83
+ * because the full tier is the release-shaped run (and the surface the
84
+ * coverage tier measures); `quick` and `integration` run the state probe
85
+ * only, matching the tiers' historical `pretest:quick` / `pretest:integration`
86
+ * definitions. The coverage runner runs the full tier, so it shares `full`.
87
+ *
88
+ * Deliberately not exported: a second importer would be a second place to
89
+ * read the tier→preflight mapping from, and tests assert the mapping through
90
+ * the spawns `runTierPreflight` actually issues — which is the thing that
91
+ * has to be true.
92
+ */
93
+ const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
94
+ full: Object.freeze([
95
+ '.agents/scripts/test-wrapper.js',
96
+ '.agents/scripts/validate-skills.js',
97
+ ]),
98
+ quick: Object.freeze(['.agents/scripts/test-wrapper.js']),
99
+ integration: Object.freeze(['.agents/scripts/test-wrapper.js']),
100
+ });
101
+
102
+ /**
103
+ * Run the preflight for a tier, in order, stopping at the first failure.
104
+ *
105
+ * Mirrors npm's own `pre<script>` semantics — a failed preflight aborts
106
+ * before the test runner is spawned and propagates its exit code (the
107
+ * wrapper reserves 2 for "preflight refused") — except that it actually
108
+ * executes, which the npm hook does not under `ignore-scripts=true`.
109
+ *
110
+ * @param {object} [opts]
111
+ * @param {'full' | 'quick' | 'integration'} [opts.tier]
112
+ * @param {string} [opts.repoRoot] Absolute repository root.
113
+ * @param {typeof spawnSync} [opts.spawn] Injected in tests.
114
+ * @param {string} [opts.execPath] Node binary to spawn; injected in tests.
115
+ * @returns {number} 0 when every preflight script passed, else the first
116
+ * non-zero exit code.
117
+ */
118
+ export function runTierPreflight({
119
+ tier = 'full',
120
+ repoRoot = process.cwd(),
121
+ spawn = spawnSync,
122
+ execPath = process.execPath,
123
+ } = {}) {
124
+ for (const script of TIER_PREFLIGHT_SCRIPTS[tier] ?? []) {
125
+ const run = spawn(execPath, [path.join(repoRoot, script)], {
126
+ cwd: repoRoot,
127
+ stdio: 'inherit',
128
+ });
129
+ if (run.error) throw run.error;
130
+ const status = run.status ?? 1;
131
+ if (status !== 0) return status;
132
+ }
133
+ return 0;
134
+ }
@@ -35,7 +35,9 @@ const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
35
35
  * same way (Story #4195). Without each root here, both the quick /
36
36
  * integration walk and the full-tier glob set miss the colocated tests,
37
37
  * leaving that coverage dark in `npm test`. The matching full-tier globs
38
- * live in `FULL_TIER_GLOBS`.
38
+ * live in the exported `FULL_TIER_GLOBS` — every full-tier runner
39
+ * (`run-tests.js`, `run-coverage.js`) MUST consume that constant rather than
40
+ * restate a glob literal, or a runner silently walks a narrower surface.
39
41
  */
40
42
  const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
41
43
 
@@ -44,8 +46,15 @@ const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
44
46
  * The `tests` glob is a flat recursive sweep; the `lib` and `.agents/scripts`
45
47
  * globs are scoped to `__tests__` subtrees so they only match colocated
46
48
  * tests, never the shipped source modules themselves.
49
+ *
50
+ * Exported because the full tier has two runners, not one: `run-tests.js`
51
+ * (via `listTestFilesForTier`) and `run-coverage.js`. Story #4922 — the
52
+ * coverage runner used to restate `tests/**` on its own, so the 47 colocated
53
+ * `__tests__` files ran under `npm test` but were absent from the measured
54
+ * surface, leaving the coverage and CRAP numbers computed over code the
55
+ * measuring run never executed. Consume this constant; never restate a glob.
47
56
  */
48
- const FULL_TIER_GLOBS = [
57
+ export const FULL_TIER_GLOBS = [
49
58
  'tests/**/*.test.js',
50
59
  'lib/**/__tests__/**/*.test.js',
51
60
  '.agents/scripts/**/__tests__/**/*.test.js',
@@ -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,23 @@
13
13
  * and would leak otherwise.
14
14
  */
15
15
 
16
+ /**
17
+ * The one bound every independent-write fan-out over the GitHub API uses
18
+ * (Story #4952 raised those loops off serial; Story #4961 made this the single
19
+ * owner of the number they share). Imported by the `/plan` context gathers,
20
+ * the persist checkpoint fan-out, the `agent::ready` flips and the supersede
21
+ * close loop, so re-tuning the policy is one edit rather than six.
22
+ *
23
+ * **Why bounded and not unbounded.** Every unit in those fan-outs is its own
24
+ * API round-trip, so an unbounded map over an N-Story plan dispatches N writes
25
+ * at once — and GitHub answers a burst with a secondary rate limit rather than
26
+ * with throughput. The goal is overlapping unrelated waits, not saturating the
27
+ * API, and four is enough to collapse the latency the serial loops paid while
28
+ * staying well under the burst threshold. Callers whose ordering is
29
+ * load-bearing (`createStoryIssues`) stay serial instead of importing this.
30
+ */
31
+ export const FANOUT_CONCURRENCY = 4;
32
+
16
33
  /**
17
34
  * @template T, R
18
35
  * @param {ReadonlyArray<T>} items
@@ -0,0 +1,103 @@
1
+ /**
2
+ * parse-id-list — expand a Story-id list that may contain dash ranges.
3
+ *
4
+ * Operators name a contiguous span of Stories the way they read one — as a
5
+ * range: `/deliver 4922 - 4926`. Enumerating it by hand is the kind of
6
+ * transcription step that silently drops or invents an id, so the range is a
7
+ * first-class shape of every delivery id list rather than something the host
8
+ * expands from prose.
9
+ *
10
+ * Accepted tokens, comma-separated:
11
+ * - a single id, with an optional `#` — `4922`, `#4922`
12
+ * - an inclusive range — `4922-4926`, `4922 - 4926`, `#4922-#4926`
13
+ * (hyphen-minus, en dash, or em dash; whitespace around it is fine)
14
+ *
15
+ * Everything else is a hard error, never a silent drop: a wrong id list
16
+ * co-dispatches against the wrong graph, so it must fail where it is typed.
17
+ * Two range-specific guards exist for the same reason — a backwards range is
18
+ * refused rather than expanded to nothing, and a span above `MAX_RANGE_SPAN`
19
+ * is refused rather than resolving thousands of issues off a typo.
20
+ */
21
+
22
+ /**
23
+ * Inclusive-span ceiling for a single range token. Generous against any real
24
+ * plan run (a handful of Stories) and tight enough that `1-4926` is caught as
25
+ * the typo it is rather than fanning out into a live resolution sweep.
26
+ *
27
+ * Deliberately module-private: the cap is a published contract
28
+ * (`helpers/deliver-reference.md` § Ranges), so a test that imported it could
29
+ * not notice the number silently moving out from under the doc.
30
+ */
31
+ const MAX_RANGE_SPAN = 50;
32
+
33
+ /** Hyphen-minus, en dash, em dash — whichever the operator's keyboard emits. */
34
+ const DASH = '[-–—]';
35
+ const SINGLE_RE = /^#?(\d+)$/;
36
+ const RANGE_RE = new RegExp(`^#?(\\d+)\\s*${DASH}\\s*#?(\\d+)$`);
37
+
38
+ /**
39
+ * Parse a comma-separated Story-id list, expanding any `A-B` range token.
40
+ *
41
+ * Absent or empty input is not an error here — it yields an empty list, and
42
+ * the caller decides whether that is a usage error (`--ids`) or a legitimate
43
+ * empty set (`--done`).
44
+ *
45
+ * @param {string|undefined|null} raw
46
+ * @param {object} [options]
47
+ * @param {string} [options.flag] Flag name, for the error message.
48
+ * @param {string} [options.prefix] Message prefix, for the caller's log tag.
49
+ * @param {number} [options.maxSpan] Inclusive-span ceiling per range token.
50
+ * @returns {{ ids: number[]|null, error: string|null }}
51
+ */
52
+ export function expandIdList(raw, options = {}) {
53
+ const { flag = '--ids', prefix = '', maxSpan = MAX_RANGE_SPAN } = options;
54
+ const fail = (message) => ({ ids: null, error: `${prefix}${message}` });
55
+
56
+ const ids = [];
57
+ const seen = new Set();
58
+ const push = (n) => {
59
+ if (seen.has(n)) return;
60
+ seen.add(n);
61
+ ids.push(n);
62
+ };
63
+
64
+ for (const token of String(raw ?? '').split(',')) {
65
+ const trimmed = token.trim();
66
+ if (trimmed === '') continue;
67
+
68
+ const range = RANGE_RE.exec(trimmed);
69
+ if (range) {
70
+ const start = Number(range[1]);
71
+ const end = Number(range[2]);
72
+ if (start <= 0 || end <= 0) {
73
+ return fail(
74
+ `${flag} range "${trimmed}" must use positive issue numbers.`,
75
+ );
76
+ }
77
+ if (end < start) {
78
+ return fail(
79
+ `${flag} range "${trimmed}" runs backwards — write it low-to-high (e.g. 4922-4926).`,
80
+ );
81
+ }
82
+ const span = end - start + 1;
83
+ if (span > maxSpan) {
84
+ return fail(
85
+ `${flag} range "${trimmed}" spans ${span} ids, above the ${maxSpan}-id cap. Narrow it, or list the ids.`,
86
+ );
87
+ }
88
+ for (let n = start; n <= end; n++) push(n);
89
+ continue;
90
+ }
91
+
92
+ const single = SINGLE_RE.exec(trimmed);
93
+ const n = single ? Number(single[1]) : Number.NaN;
94
+ if (!Number.isInteger(n) || n <= 0) {
95
+ return fail(
96
+ `${flag} must be a comma-separated list of positive issue numbers or A-B ranges (got "${trimmed}").`,
97
+ );
98
+ }
99
+ push(n);
100
+ }
101
+
102
+ return { ids, error: null };
103
+ }
@@ -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
  }