mandrel 2.57.0 → 2.59.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 (58) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/agents/story-worker.md +12 -11
  3. package/.agents/docs/SDLC.md +6 -7
  4. package/.agents/docs/quality-gates.md +1 -1
  5. package/.agents/instructions.md +2 -3
  6. package/.agents/runtime-deps.json +7 -2
  7. package/.agents/schemas/crap-baseline.schema.json +1 -1
  8. package/.agents/schemas/crap-report.schema.json +1 -1
  9. package/.agents/scripts/evidence-gate.js +17 -1
  10. package/.agents/scripts/install-matrix-assert.js +48 -3
  11. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  12. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  13. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  14. package/.agents/scripts/lib/crap-engine.js +2 -2
  15. package/.agents/scripts/lib/crap-utils.js +21 -5
  16. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  17. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  18. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  19. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  20. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  21. package/.agents/scripts/lib/orchestration/plan-context.js +41 -27
  22. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  23. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +6 -1
  24. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +14 -9
  25. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +45 -31
  26. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
  27. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
  28. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  29. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +15 -5
  30. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  32. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  33. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  34. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  35. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  36. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  37. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  38. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  39. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  40. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  41. package/.agents/scripts/lib/story-body/story-body.js +36 -2
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +73 -21
  43. package/.agents/scripts/lib/test-run-credit.js +23 -12
  44. package/.agents/scripts/plan-persist.js +0 -11
  45. package/.agents/skills/skills.index.json +1 -11
  46. package/.agents/workflows/audit-to-stories.md +14 -11
  47. package/.agents/workflows/helpers/deliver-digest.md +22 -15
  48. package/.agents/workflows/helpers/deliver-story-reference.md +31 -11
  49. package/.agents/workflows/helpers/deliver-story.md +6 -5
  50. package/.agents/workflows/helpers/plan-reference.md +53 -13
  51. package/.agents/workflows/mandrel-plan.md +19 -14
  52. package/README.md +3 -3
  53. package/docs/CHANGELOG.md +21 -0
  54. package/lib/cli/registry.js +143 -27
  55. package/package.json +7 -2
  56. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  57. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  58. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -29,11 +29,8 @@ import { parse as parseStoryBody } from '../story-body/story-body.js';
29
29
  import {
30
30
  renderStoryAuthorCore,
31
31
  renderStorySplitRules,
32
+ ticketsModePromptField,
32
33
  } from '../templates/decomposer-prompts.js';
33
- import {
34
- renderAcceptanceSpecSystemPrompt,
35
- renderTechSpecSystemPrompt,
36
- } from '../templates/spec-author-prompts.js';
37
34
  import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
38
35
  import { buildComplexitySignals } from './complexity-gate.js';
39
36
  import { findDependencyCandidates } from './dependency-candidates.js';
@@ -328,6 +325,17 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
328
325
  title: 'Fill: short descriptive title',
329
326
  body: {
330
327
  goal: 'Fill: one sentence stating why this Story exists.',
328
+ // A filled, multi-checkpoint example rather than a placeholder
329
+ // (Story #5332): `## Slicing` is how one cohesive sweep stays one
330
+ // Story, so the skeleton has to show an author what a checkpoint
331
+ // list looks like. Each line is a stage of the work — a commit
332
+ // boundary inside one session — never a restatement of an
333
+ // acceptance item, which states what is true once the Story lands.
334
+ slicing:
335
+ '1. Re-anchor the shared constant and its consumers.\n' +
336
+ '2. Move the gate ahead of the first write and arm the refusal.\n' +
337
+ '3. Delete the superseded module, its test and its flag.\n' +
338
+ '4. Regenerate the affected baselines; run the full gate chain.',
331
339
  spec:
332
340
  'Optional — contract and invariants only: interfaces, status ' +
333
341
  'codes, security invariants, and load-bearing constraints with ' +
@@ -339,7 +347,7 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
339
347
  non_goals: [],
340
348
  },
341
349
  acceptance: [
342
- 'Fill: an outcome a PR reviewer can confirm from the diff and the verify output (three to six items)',
350
+ 'Fill: an outcome a PR reviewer can confirm from the diff and the verify output — as many as the capability has, no target and no ceiling',
343
351
  ],
344
352
  verify: [
345
353
  'Fill: exact command or test path — the mechanical check the acceptance item rests on',
@@ -366,25 +374,23 @@ function countEnumeratedItems(text) {
366
374
  }
367
375
 
368
376
  /**
369
- * Delta-shaped change-request verbs — the `core/scope-triage` skill's
370
- * change-request rubric routes these to `story` by default when the
371
- * footprint stays inside Story width.
377
+ * Delta-shaped change-request verbs — a change request naming one of these
378
+ * stays a Story by default when the footprint stays inside Story width.
372
379
  */
373
380
  const DELTA_VERB_RE =
374
381
  /\b(fix(?:es)?|tweak(?:s)?|extend(?:s)?|update(?:s)?|adjust(?:s)?|rename(?:s)?|correct(?:s)?|patch(?:es)?|bug|regression|flaky)\b/i;
375
382
 
376
383
  /**
377
- * Deterministic, CLI-applied scope-triage verdict over a raw `--seed` text
378
- * (#4496 fix 6). Embedding the verdict in the `--seed` envelope removes the
379
- * two skill Reads (`core/scope-triage` + the gate fragment's rubric pass)
380
- * from the headless path; the attended path keeps the skill-based judgment.
384
+ * Deterministic, CLI-applied scope signal over a raw `--seed` text
385
+ * (#4496 fix 6). Embedding it in the `--seed` envelope keeps the headless
386
+ * path from needing a judgment pass of its own.
381
387
  *
382
- * The heuristics anchor to the same granularity SSOT the skill anchors to —
388
+ * The heuristics anchor to the granularity SSOT —
383
389
  * `DELIVERABLE_GRANULARITY_GUIDANCE` in `ticket-validator-sizing.js` (one
384
390
  * Story = one coherent capability slice; multiple independent capabilities =
385
- * an Epic) — and to the skill's change-request delta rubric. Like the skill,
386
- * the verdict is **advisory**: being wrong in the `epic` direction is cheap,
387
- * and `borderline` is a first-class output, not a forced call.
391
+ * an Epic) — and to the change-request delta rubric above. The verdict is
392
+ * **advisory**: being wrong in the `epic` direction is cheap, and
393
+ * `borderline` is a first-class output, not a forced call.
388
394
  *
389
395
  * @param {{ seedText?: string }} args
390
396
  * @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
@@ -633,21 +639,29 @@ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
633
639
 
634
640
  /**
635
641
  * Render the authoring system prompts the collapsed pipeline's single
636
- * authoring pass consumes. The spec/acceptance prompts render from
637
- * `lib/templates/spec-author-prompts.js` (the M3/M8 handshake — envelope
638
- * authoritative from day one); the story prompt is the N=1 core from
639
- * `lib/templates/decomposer-prompts.js`, with the schedule and partition
640
- * rules a planner reads only when the default-single split policy clears
641
- * carried separately as `storySplitRules` (Story #5312).
642
+ * authoring pass consumes: the N=1 core from
643
+ * `lib/templates/decomposer-prompts.js`, with the schedule rules and the
644
+ * collision refusal a planner reads only when the default-single split policy
645
+ * clears carried separately as `storySplitRules` (Story #5312).
646
+ *
647
+ * Story #5332 deleted the `spec` / `acceptance` fields with the module that
648
+ * rendered them: nothing read either, and both contradicted the current
649
+ * contract — one asserting Spec budgets that no longer exist, the other
650
+ * demanding the verify tier suffixes tickets mode now strips.
642
651
  *
643
- * @returns {{ spec: string, acceptance: string, story: string, storySplitRules: string }}
652
+ * `storyTicketsRules` is the one mode-conditional field (Story #5323): it
653
+ * only means anything when the seed is an existing ticket, and an envelope
654
+ * that carries it in every mode teaches the author to look for a source
655
+ * ticket that a `--seed` run does not have.
656
+ *
657
+ * @param {{ mode?: string }} [args]
658
+ * @returns {{ story: string, storySplitRules: string, storyTicketsRules?: string }}
644
659
  */
645
- export function buildSystemPrompts() {
660
+ export function buildSystemPrompts({ mode } = {}) {
646
661
  return {
647
- spec: renderTechSpecSystemPrompt(),
648
- acceptance: renderAcceptanceSpecSystemPrompt(),
649
662
  story: renderStoryAuthorCore(),
650
663
  storySplitRules: renderStorySplitRules(),
664
+ ...ticketsModePromptField(mode),
651
665
  };
652
666
  }
653
667
 
@@ -1007,7 +1021,7 @@ async function buildTicketsModeEnvelope({
1007
1021
  memoryPoolAdvisory: authoring.memoryPoolAdvisory,
1008
1022
  priorFeedback: authoring.priorFeedback,
1009
1023
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1010
- systemPrompts: buildSystemPrompts(),
1024
+ systemPrompts: buildSystemPrompts({ mode: 'tickets' }),
1011
1025
  planState: null,
1012
1026
  planProfile:
1013
1027
  ticketIds.length === 1 ? 'story-default' : 'story-from-tickets',
@@ -0,0 +1,107 @@
1
+ /**
2
+ * acceptance-handle-repair.js — repair-before-judging for the `AC-<n>:`
3
+ * presentation handle on authored acceptance items (Story #5323).
4
+ *
5
+ * The handle belongs to the body renderer, which numbers each checkbox from
6
+ * its position in `acceptance[]` (`story-body.js`). An author planning from
7
+ * an existing ticket reads that rendered body as a template and carries the
8
+ * handle forward, so the persisted checkbox reads `- [ ] AC-1: AC-1: …`; a
9
+ * lettered handle copied out of a hand-edited source (`AC-14a:`) misnumbers
10
+ * the rest of the list against its own text.
11
+ *
12
+ * The correction is mechanical and total — strip the handle the renderer
13
+ * will re-apply — so this module applies it and **reports** it, exactly as
14
+ * `changes-repair.js` does for the `{ path, assumption }` formality. Charging
15
+ * the author a re-draft round to paste back text the strip already produced
16
+ * buys nothing.
17
+ *
18
+ * It lives beside the validator rather than inside it for the same reason
19
+ * `changes-repair.js` does: the validator's job is to judge, and mixing a
20
+ * mutating repair pass into a module of pure collectors muddies both.
21
+ * `persist-helpers.js#validateTickets` calls this first, then the validators.
22
+ *
23
+ * @module lib/orchestration/plan-persist/acceptance-handle-repair
24
+ */
25
+
26
+ import { stripAcceptanceHandle } from '../../story-body/story-body.js';
27
+ import { renderChangeRepair } from './changes-repair.js';
28
+
29
+ /**
30
+ * Strip the handle off one surface's `acceptance[]`, recording each distinct
31
+ * strip on `repairs`.
32
+ *
33
+ * @param {object} surface An object that may carry an `acceptance` array.
34
+ * @param {string} slug
35
+ * @param {Set<string>} reported Items already reported for this ticket.
36
+ * @param {object[]} repairs Accumulator, mutated.
37
+ * @returns {void}
38
+ */
39
+ function repairSurface(surface, slug, reported, repairs) {
40
+ if (!Array.isArray(surface.acceptance)) return;
41
+ surface.acceptance = surface.acceptance.map((item) => {
42
+ const { text, stripped } = stripAcceptanceHandle(item);
43
+ if (!stripped) return item;
44
+ const from = String(item ?? '');
45
+ if (!reported.has(from)) {
46
+ reported.add(from);
47
+ repairs.push({ kind: 'acceptance-handle', slug, from, to: text });
48
+ }
49
+ return text;
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Strip the presentation `AC-<n>:` handle off every authored `acceptance[]`
55
+ * item, on both surfaces that can carry one: the ticket's top-level array
56
+ * (the machine contract) and a structured body's.
57
+ *
58
+ * A serialized **string** body needs no pass of its own — `parse()` strips
59
+ * the handle with the same grammar this does, so the two surfaces converge
60
+ * on one list whichever carried the handle, and the contract sync (which
61
+ * fails closed on a disagreement) sees them agree.
62
+ *
63
+ * Mutates `tickets` in place — the persist pipeline threads this same array
64
+ * on to assembly. Total: a non-array argument and non-Story tickets are
65
+ * no-ops.
66
+ *
67
+ * @param {object[]} tickets
68
+ * @returns {Array<{ kind: 'acceptance-handle', slug: string, from: string, to: string }>}
69
+ */
70
+ export function normalizeAcceptanceHandles(tickets) {
71
+ const repairs = [];
72
+ for (const ticket of Array.isArray(tickets) ? tickets : []) {
73
+ if (!ticket || typeof ticket !== 'object' || ticket.type !== 'story') {
74
+ continue;
75
+ }
76
+ const slug = ticket.slug ?? ticket.title ?? '<unknown>';
77
+ // A ticket normally carries the same list on both surfaces, so report
78
+ // each distinct item once — the operator reads one correction, not two.
79
+ const reported = new Set();
80
+ repairSurface(ticket, slug, reported, repairs);
81
+ const body = ticket.body;
82
+ if (body && typeof body === 'object') {
83
+ repairSurface(body, slug, reported, repairs);
84
+ }
85
+ }
86
+ return repairs;
87
+ }
88
+
89
+ /**
90
+ * Render one entry of the dry-run's mixed repair list.
91
+ *
92
+ * The list carries two kinds — a `changes[]` entry repaired by probing base,
93
+ * and an `acceptance[]` item whose handle was normalised off — and both are
94
+ * mechanical corrections the run applied on the author's behalf, so both
95
+ * belong on the one list the operator reads. This module owns the dispatch
96
+ * because it owns the newer kind: it renders its own line and delegates
97
+ * every other kind to `changes-repair.js`, so neither producer has to know
98
+ * the other's shape.
99
+ *
100
+ * @param {{ kind?: string, slug: string, from: string, to?: string }} repair
101
+ * @returns {string}
102
+ */
103
+ export function renderRepair(repair) {
104
+ if (repair.kind !== 'acceptance-handle') return renderChangeRepair(repair);
105
+ const { slug, from, to } = repair;
106
+ return `Story "${slug}": acceptance[] item "${from}" carried an AC-<n> handle — normalised to "${to}"; the body renderer numbers the checkboxes.`;
107
+ }
@@ -261,7 +261,12 @@ function repairTicket(ticket, existsAtBase) {
261
261
  }
262
262
 
263
263
  /**
264
- * Render one repair as the dry-run line the operator reads.
264
+ * Render one `changes[]` repair as the dry-run line the operator reads.
265
+ *
266
+ * The dry-run's repair list is mixed — an `acceptance[]` handle strip is
267
+ * reported on it too — but the dispatch across kinds lives in
268
+ * [`acceptance-handle-repair.js`](acceptance-handle-repair.js)`#renderRepair`,
269
+ * which delegates here for this kind. Each producer owns its own line.
265
270
  *
266
271
  * @param {{ slug: string, from: string, path: string, assumption: string, reason: string }} repair
267
272
  * @returns {string}
@@ -10,9 +10,10 @@
10
10
  * checkout on CI has no local `main`), else nothing — a shallow checkout
11
11
  * with no base at all skips the probes instead of reading every path as
12
12
  * absent.
13
- * - `validateTickets(tickets, config, opts)` — repairs the mechanical
14
- * `changes[]` formalities against the base branch, then runs the
15
- * cross-link, freshness, and task-body validators in one pass.
13
+ * - `validateTickets(tickets, config, opts)` — normalises the authored
14
+ * `acceptance[]` handles and repairs the mechanical `changes[]`
15
+ * formalities against the base branch, then runs the cross-link,
16
+ * freshness, and task-body validators in one pass.
16
17
  *
17
18
  * Story #5312 deleted the fan-out probe that lived here: the delete
18
19
  * blast-radius count never refused a real plan, and the `git grep` it paid
@@ -24,6 +25,7 @@
24
25
  import { gitSpawn } from '../../git-utils.js';
25
26
  import { validateTaskBodies } from '../task-body-validator.js';
26
27
  import { validateAndNormalizeTickets } from '../ticket-validator.js';
28
+ import { normalizeAcceptanceHandles } from './acceptance-handle-repair.js';
27
29
  import { repairChangeEntries } from './changes-repair.js';
28
30
 
29
31
  /**
@@ -167,13 +169,16 @@ function defineHidden(validated, extras) {
167
169
  export function validateTickets(tickets, config, opts = {}) {
168
170
  const baseBranch = resolveBaseBranchRef(config);
169
171
  const baseBranchRef = resolveProbeRef({ baseBranch, cwd: opts.cwd });
170
- const repairs = repairChangeEntries(tickets, {
171
- existsAtBase: makeExistsAtBase({
172
- baseBranchRef,
173
- cwd: opts.cwd,
174
- gitRunner: opts.gitRunner,
172
+ const repairs = [
173
+ ...normalizeAcceptanceHandles(tickets),
174
+ ...repairChangeEntries(tickets, {
175
+ existsAtBase: makeExistsAtBase({
176
+ baseBranchRef,
177
+ cwd: opts.cwd,
178
+ gitRunner: opts.gitRunner,
179
+ }),
175
180
  }),
176
- });
181
+ ];
177
182
  const validated = validateAndNormalizeTickets(tickets, {
178
183
  baseBranchRef: baseBranchRef ?? undefined,
179
184
  gitRunner: opts.gitRunner,
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * 1. `changes[]` repair + ticket validator + file-assumption + DAG
9
9
  * 2. Draft reachability (named soft failure, exit 3)
10
- * 3. Split-policy partition (`assertAcceptancePartition`) + spec fold
10
+ * 3. Same-wave collision refusal (`assertNoWaveCollisions`) + spec fold
11
11
  * 4. Create Story issues (`type::story` + sanitized authored labels —
12
12
  * deliberately NOT `agent::ready`), resumably via a plan fingerprint
13
13
  * 5. Upsert `story-plan-state` on every created Story; upsert `plan-summary`
@@ -62,8 +62,8 @@ import {
62
62
  conflictFindingKey,
63
63
  } from '../ticket-validator-conflicts.js';
64
64
  import { upsertStructuredComment } from '../ticketing.js';
65
+ import { renderRepair } from './acceptance-handle-repair.js';
65
66
  import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
66
- import { renderChangeRepair } from './changes-repair.js';
67
67
  import {
68
68
  resolveContainerEpic,
69
69
  resolveCrossPlanLinks,
@@ -81,7 +81,7 @@ import {
81
81
  PLAN_SUMMARY_COMMENT_TYPE,
82
82
  } from './summary.js';
83
83
  import { closeSupersededTickets } from './supersede-ops.js';
84
- import { predictWaveSerialisation } from './wave-serialisation.js';
84
+ import { assertNoWaveCollisions } from './wave-collision-gate.js';
85
85
 
86
86
  /** Checkpoint schema version written on each Story's story-plan-state. */
87
87
  const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
@@ -146,7 +146,7 @@ function enforceTicketValidation(validated) {
146
146
  );
147
147
  }
148
148
  const warnings = [
149
- ...(validated.repairs ?? []).map((repair) => renderChangeRepair(repair)),
149
+ ...(validated.repairs ?? []).map(renderRepair),
150
150
  ...(validated.warnings ?? []),
151
151
  ];
152
152
  return {
@@ -170,18 +170,28 @@ function freshnessCounts(probeRef, warnings) {
170
170
  return { stale: warnings.length, ambiguous: 0 };
171
171
  }
172
172
 
173
+ /** How each text-hygiene finding kind names itself on the warning list. */
174
+ const TEXT_HYGIENE_LABELS = {
175
+ 'open-question': 'open question in body',
176
+ 'pinned-identifier': 'pinned identifier in acceptance[]',
177
+ };
178
+
173
179
  /**
174
- * The `open-question` lint over the draft bodies (Story #5312) — an
180
+ * The advisory text-hygiene lints over the draft (Story #5312, #5323) — an
175
181
  * operator-directed question persisted into a Story a non-interactive
176
- * sub-agent executes. A warning the dry-run lists, never a refusal.
182
+ * sub-agent executes, and an acceptance item pinning an internal symbol the
183
+ * advisory `changes[]` may reshape. Warnings the dry-run lists, never
184
+ * refusals.
177
185
  *
178
186
  * @param {object[]} rawStories
179
187
  * @returns {string[]}
180
188
  */
181
- function collectOpenQuestionWarnings(rawStories) {
189
+ function collectTextHygieneWarnings(rawStories) {
182
190
  return evaluateTextHygiene({ draftStories: rawStories }).findings.map(
183
191
  (finding) =>
184
- `Story "${finding.slug}": open question in body — "${finding.evidence}". ${finding.message}`,
192
+ `Story "${finding.slug}": ${
193
+ TEXT_HYGIENE_LABELS[finding.kind] ?? finding.kind
194
+ } — "${finding.evidence}". ${finding.message}`,
185
195
  );
186
196
  }
187
197
 
@@ -533,7 +543,6 @@ function logPersistEpilogue({
533
543
  * artifacts: {
534
544
  * stories: Array<object>,
535
545
  * techSpecContent?: string|null,
536
- * planAcceptance?: string[]|null,
537
546
  * planContextEnvelope?: object|null,
538
547
  * },
539
548
  * config?: object,
@@ -559,7 +568,6 @@ export async function runPlanPersist({
559
568
  const {
560
569
  stories: rawStories = null,
561
570
  techSpecContent = null,
562
- planAcceptance = null,
563
571
  planContextEnvelope = null,
564
572
  } = artifacts ?? {};
565
573
  const {
@@ -595,7 +603,7 @@ export async function runPlanPersist({
595
603
  enforceTicketValidation(validated);
596
604
  const warnings = [
597
605
  ...validationWarnings,
598
- ...collectOpenQuestionWarnings(rawStories),
606
+ ...collectTextHygieneWarnings(rawStories),
599
607
  ];
600
608
  logWarnings(warnings);
601
609
 
@@ -613,11 +621,10 @@ export async function runPlanPersist({
613
621
  epicId: opts.adoptEpicId ?? null,
614
622
  });
615
623
 
616
- // Split policy + inline Spec fold (Specs stay inline, never under docs/).
624
+ // Inline Spec fold (Specs stay inline, never under docs/).
617
625
  const seedContent = planContextEnvelope?.seed?.content ?? '';
618
626
  const { stories: assembled } = assemblePlanStories(rawStories, {
619
627
  sharedSpec: techSpecContent,
620
- planAcceptance: planAcceptance ?? undefined,
621
628
  sourceTicketIds,
622
629
  // The seed this plan was authored from: an audit sweep's Single-plan seed
623
630
  // carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
@@ -644,6 +651,31 @@ export async function runPlanPersist({
644
651
  rawFindings: validated.findings,
645
652
  });
646
653
 
654
+ // Story #5332 — the split gate, ahead of the first create. `buildWaveTable`
655
+ // needs only `{slug, title, depends_on}` and the prediction needs only the
656
+ // assembled bodies, so both can run before anything is written; they used
657
+ // to sit *after* creation, which is why the collision table could only ever
658
+ // be a receipt for a plan already live. The same computed value is handed
659
+ // to the summary rendering below rather than recomputed, so the refusal and
660
+ // the receipt can never disagree.
661
+ const waveTable = buildWaveTable(
662
+ stories.map((s) => ({
663
+ slug: s.slug,
664
+ title: s.title,
665
+ depends_on: s.depends_on,
666
+ })),
667
+ );
668
+
669
+ // Story #5265: the table says which Stories share an order; the dispatcher
670
+ // decides which of those actually run together. Run its own predicate over
671
+ // the assembled bodies — the exact artifact the tick will read back off
672
+ // GitHub — so an N>1 draft it would serialize is refused here, and the
673
+ // summary comment names the serialisation instead of promising parallelism
674
+ // the next tick refuses. One enumeration feeds both.
675
+ const waveCollisions = assertNoWaveCollisions(waveTable, stories, {
676
+ tempRoot: getPaths(config).tempRoot,
677
+ });
678
+
647
679
  const { created, planRunLabel, planRunLabelApplied } =
648
680
  await createStoryIssues({
649
681
  provider,
@@ -656,24 +688,6 @@ export async function runPlanPersist({
656
688
  recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
657
689
 
658
690
  const primary = created[0];
659
- const waveTable = buildWaveTable(
660
- stories.map((s) => ({
661
- slug: s.slug,
662
- title: s.title,
663
- depends_on: s.depends_on,
664
- })),
665
- );
666
-
667
- // Story #5265: the table says which Stories share an order; the dispatcher
668
- // decides which of those actually run together, and it decides on the
669
- // evidence-widened footprint. Run its own predicate over the assembled
670
- // bodies — the exact artifact the tick will read back off GitHub — so the
671
- // comment names the serialisation instead of promising parallelism the
672
- // next tick refuses. `tempRoot` is threaded for the same reason the tick
673
- // threads it: the scrape must ignore this project's scratch root.
674
- const waveCollisions = predictWaveSerialisation(waveTable, stories, {
675
- tempRoot: getPaths(config).tempRoot,
676
- });
677
691
 
678
692
  // Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
679
693
  // called with `config` first, so the ledger path resolver received the
@@ -4,7 +4,7 @@
4
4
  * Under the Story collapse (`docs/roadmap.md` § Stage 3), `/mandrel-plan` persists
5
5
  * zero-or-more Story issues directly — no Epic parent, no reconciler tree,
6
6
  * no `deliveryShape` mode matrix. Default is **one Story**; N>1 is gated by
7
- * the Stage-1 split-policy validator (`assertAcceptancePartition`).
7
+ * the supersede partition check.
8
8
  *
9
9
  * Each Story body is the single executable document: Tech Spec stays inline
10
10
  * under `## Spec`, at whatever length the work needs (Story #5312 deleted
@@ -34,7 +34,6 @@ import {
34
34
  concurrentMap,
35
35
  FANOUT_CONCURRENCY,
36
36
  } from '../../util/concurrent-map.js';
37
- import { assertAcceptancePartition } from '../split-policy-validator.js';
38
37
  import {
39
38
  externalDependencyId,
40
39
  isExternalDependencyRef,
@@ -514,15 +513,18 @@ function assertSharedSpecAllowed(tickets, sharedSpec) {
514
513
 
515
514
  /**
516
515
  * Assemble markdown bodies for every Story: normalize → fold spec →
517
- * assertAcceptancePartition → assertSupersedePartition → serialize.
516
+ * assertSupersedePartition → serialize.
518
517
  *
519
- * Both partition checks run **before** any GitHub write so a mis-authored
520
- * plan never leaves Stories live against an inconsistent tracker.
518
+ * The partition check runs **before** any GitHub write so a mis-authored
519
+ * plan never leaves Stories live against an inconsistent tracker. Story #5332
520
+ * retired the acceptance partition that used to run beside it: it refused
521
+ * only byte-identical acceptance text across siblings, and the split gate is
522
+ * now `assertNoWaveCollisions` in `run-plan-persist.js`, ahead of the first
523
+ * create.
521
524
  *
522
525
  * @param {object[]} tickets
523
526
  * @param {object} [opts]
524
527
  * @param {string|null} [opts.sharedSpec]
525
- * @param {string[]} [opts.planAcceptance]
526
528
  * @param {number[]} [opts.sourceTicketIds] Ids passed to `/mandrel-plan --tickets`.
527
529
  * @returns {{ stories: Array<{ slug: string, title: string, body: string, acceptance: string[], depends_on: string[], supersedes: Array<{ id: number, note: string|null }> }> }}
528
530
  */
@@ -539,9 +541,6 @@ export function assemblePlanStories(tickets, opts = {}) {
539
541
  (ticket) => assembleOnePlanStory(ticket, opts).story,
540
542
  );
541
543
 
542
- assertAcceptancePartition(stories, {
543
- planAcceptance: opts.planAcceptance,
544
- });
545
544
  assertSupersedePartition(stories, opts.sourceTicketIds ?? []);
546
545
 
547
546
  return { stories };
@@ -16,7 +16,7 @@
16
16
  * Two halves, deliberately separated by the `createIssue` boundary:
17
17
  *
18
18
  * 1. **`assertSupersedePartition`** — a plan-time, fail-closed check that
19
- * runs *before* any GitHub write. Mirrors `assertAcceptancePartition`:
19
+ * runs *before* any GitHub write. Same fail-closed shape as the
20
20
  * every id passed to `--tickets` must be claimed by exactly one Story,
21
21
  * and no Story may claim an id that was not a source ticket. A partial
22
22
  * supersede map is a planning error, not something to paper over at
@@ -0,0 +1,107 @@
1
+ /**
2
+ * wave-collision-gate.js — the split gate (Story #5332).
3
+ *
4
+ * Kept out of `wave-serialisation.js` because it answers a different
5
+ * question. That module *predicts* what the dispatcher will do with a draft
6
+ * and renders the prediction as a receipt; this one decides whether the draft
7
+ * may be created at all, and so owns both the one enumeration the refusal and
8
+ * the receipt share and the shape reconciliation that enumeration needs.
9
+ *
10
+ * @module lib/orchestration/plan-persist/wave-collision-gate
11
+ */
12
+
13
+ import { predictWaveSerialisation } from './wave-serialisation.js';
14
+
15
+ /**
16
+ * Expose an assembled Story's declared footprint where `storyFootprint`
17
+ * looks for it.
18
+ *
19
+ * `assemblePlanStories` returns the persisted artifact — `{ slug, title,
20
+ * body, bodyObject, acceptance, depends_on, … }` — and carries the parsed
21
+ * `changes[]` inside `bodyObject`, not at the top level. `storyFootprint`
22
+ * reads `files` / `changes` / `changeset` only, so from Story #5313 (which
23
+ * retired the body scrape that had been widening the footprint out of the
24
+ * markdown) until this Story the production call saw an empty footprint for
25
+ * every assembled Story and predicted nothing — the unit fixtures passed a
26
+ * top-level `changes` and so could not show it. The prediction is now the
27
+ * split gate, and a gate that cannot see a declaration cannot fire, so the
28
+ * shapes are reconciled here rather than by widening the dispatcher's own
29
+ * predicate: the runtime's records (`resolve-stories.js`) already carry
30
+ * `changes` at the top level and pass through untouched.
31
+ *
32
+ * @param {object} story
33
+ * @returns {object} The same record, with a top-level `changes` when one can
34
+ * be resolved from its `bodyObject`.
35
+ */
36
+ function withDeclaredFootprint(story) {
37
+ if (!story || typeof story !== 'object') return story;
38
+ const declared = [story.files, story.changes, story.changeset].some(
39
+ (shape) => Array.isArray(shape) && shape.length > 0,
40
+ );
41
+ if (declared) return story;
42
+ const fromBody = story.bodyObject?.changes;
43
+ return Array.isArray(fromBody) ? { ...story, changes: fromBody } : story;
44
+ }
45
+
46
+ /**
47
+ * Render one refused pair as a report line.
48
+ *
49
+ * @param {{ wave: number, slugs: [string, string], paths: string[], source: string }} collision
50
+ * @returns {string}
51
+ */
52
+ function formatCollision({ wave, slugs, paths, source }) {
53
+ const declared = paths.map((p) => `\`${p}\``).join(', ');
54
+ return ` - wave ${wave}: "${slugs[0]}" + "${slugs[1]}" both declare ${declared} (${source})`;
55
+ }
56
+
57
+ /**
58
+ * Compute the same-wave collisions of a draft and refuse an N>1 draft that
59
+ * has any — the split gate.
60
+ *
61
+ * ADR `20260912-5312` deleted every numeric plan-time ceiling and left the
62
+ * default-single policy enforced by prose plus `assertAcceptancePartition`,
63
+ * which refused only byte-identical acceptance text across siblings — a shape
64
+ * model output does not produce. The measured result was a plan of 18 Stories
65
+ * whose own summary comment recorded 39 shared files across 14 same-wave
66
+ * Stories: the plan refuted its own parallelism claim, after persist, with
67
+ * nothing acting on it.
68
+ *
69
+ * So the gate is the dispatcher's own predicate rather than a proxy for it. A
70
+ * pair {@link predictWaveSerialisation} names is a pair
71
+ * `stories-wave-tick.js` will refuse to co-dispatch, so the split buys no
72
+ * parallelism while still paying a delivery session per Story. Two remedies,
73
+ * both the author's to take before anything is created: merge the pair into
74
+ * the one Story it already is, or order it with `depends_on` so the members
75
+ * land in different waves.
76
+ *
77
+ * **N=1 can never trip it.** A single-Story draft has no pair to score, so
78
+ * the prediction is empty by construction.
79
+ *
80
+ * The computed collisions are **returned** so the caller hands the same value
81
+ * to the plan-summary receipt instead of recomputing it — a recomputation is
82
+ * how the refusal and the receipt would come to disagree.
83
+ *
84
+ * @param {ReturnType<typeof import('./summary.js').buildWaveTable>} waveTable
85
+ * @param {Array<object>} stories The assembled Stories, in draft order.
86
+ * @param {{ tempRoot?: string }} [options] Threaded to the predicate.
87
+ * @returns {ReturnType<typeof predictWaveSerialisation>}
88
+ * @throws {Error} When an N>1 draft has at least one colliding same-wave pair.
89
+ */
90
+ export function assertNoWaveCollisions(waveTable, stories, options = {}) {
91
+ const list = Array.isArray(stories) ? stories : [];
92
+ const collisions = predictWaveSerialisation(
93
+ waveTable,
94
+ list.map(withDeclaredFootprint),
95
+ options,
96
+ );
97
+ if (list.length <= 1 || collisions.length === 0) return collisions;
98
+ throw new Error(
99
+ `[plan-persist] ${collisions.length} same-wave collision(s) — the ` +
100
+ 'dispatcher will refuse to co-dispatch these pairs, so the split buys ' +
101
+ 'no parallelism and costs a delivery session per Story:\n' +
102
+ `${collisions.map(formatCollision).join('\n')}\n` +
103
+ 'Remedy: merge each pair into the one Story it already is (its stages ' +
104
+ 'belong in `## Slicing`), or order the pair with `depends_on` so the ' +
105
+ 'members sit in different waves.',
106
+ );
107
+ }