mandrel 2.58.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 (42) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/docs/SDLC.md +6 -7
  3. package/.agents/docs/quality-gates.md +1 -1
  4. package/.agents/instructions.md +2 -3
  5. package/.agents/runtime-deps.json +7 -2
  6. package/.agents/schemas/crap-baseline.schema.json +1 -1
  7. package/.agents/schemas/crap-report.schema.json +1 -1
  8. package/.agents/scripts/install-matrix-assert.js +48 -3
  9. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  10. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  11. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  12. package/.agents/scripts/lib/crap-engine.js +2 -2
  13. package/.agents/scripts/lib/crap-utils.js +21 -5
  14. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  15. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  16. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  17. package/.agents/scripts/lib/orchestration/plan-context.js +31 -25
  18. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +28 -24
  19. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
  20. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
  21. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  23. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  24. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  25. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  26. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  27. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  28. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  29. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  30. package/.agents/scripts/lib/templates/decomposer-prompts.js +21 -18
  31. package/.agents/scripts/plan-persist.js +0 -11
  32. package/.agents/skills/skills.index.json +1 -11
  33. package/.agents/workflows/audit-to-stories.md +14 -11
  34. package/.agents/workflows/helpers/plan-reference.md +18 -7
  35. package/.agents/workflows/mandrel-plan.md +14 -13
  36. package/README.md +3 -3
  37. package/docs/CHANGELOG.md +8 -0
  38. package/lib/cli/registry.js +45 -25
  39. package/package.json +7 -2
  40. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  41. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  42. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -0,0 +1,298 @@
1
+ /**
2
+ * escomplex-kernel.js — the complexity kernel's parse and dispatch layers,
3
+ * in-repo.
4
+ *
5
+ * ## Why this file exists
6
+ *
7
+ * `typhonjs-escomplex` was a thin shell around four packages that do all the
8
+ * actual work. The shell contributed two things: a parser front-end
9
+ * (`@typhonjs/babel-parser`, a ~100-LOC shim over `@babel/parser`) and a
10
+ * generic plugin bus (`typhonjs-plugin-manager`, used as a hardcoded
11
+ * two-plugin synchronous dispatcher). Nine packages of plumbing hang off
12
+ * those two, and none of it computes anything.
13
+ *
14
+ * What this does **not** do is remove `core-js@2`. Four of the retained
15
+ * metric-core packages `require('babel-runtime/core-js/*')` themselves, so it
16
+ * is load-bearing for the code that stays; a change that claimed otherwise
17
+ * would be unshippable. What it buys instead is an honest closure —
18
+ * `babel-runtime` is required by those packages and declared by none of them,
19
+ * so today it resolves only because the removed plumbing hoists it. Declaring
20
+ * it turns an accident into a contract.
21
+ *
22
+ * This module reimplements exactly those two layers over the retained metric
23
+ * core — `typhonjs-escomplex-commons`, `escomplex-plugin-metrics-module`,
24
+ * `escomplex-plugin-syntax-babylon`, `typhonjs-ast-walker` — which compute
25
+ * every score. Nothing here computes a metric; the scores come from the same
26
+ * packages as before, which is why they do not move.
27
+ *
28
+ * ## The equivalence contract
29
+ *
30
+ * Reproducing the displaced shell's *behaviour* means reproducing three
31
+ * details it never documented:
32
+ *
33
+ * 1. **The parser's fixed plugin list**, verbatim and in order, with
34
+ * `sourceType: 'unambiguous'` — see `PARSER_PLUGINS` below. The list is
35
+ * what makes a `.ts` file, a decorator or a pipeline operator parse at
36
+ * all, and `unambiguous` is what lets a CommonJS script and an ES module
37
+ * both score.
38
+ * 2. **Both plugin instances, in registration order** — syntax first, then
39
+ * metrics. The metrics plugin reads trait tables the syntax plugin put on
40
+ * the event.
41
+ * 3. **One mutable event object per dispatch**, threaded through every plugin
42
+ * in turn, with the caller reading the mutations back off it. The displaced
43
+ * bus also stamped `$$plugin_invoke_count` / `$$plugin_invoke_names` onto
44
+ * every event's data; no plugin and no report reads them, so they are not
45
+ * reproduced.
46
+ *
47
+ * Equivalence is not asserted by reasoning: `tests/lib/escomplex-kernel.test.js`
48
+ * replays a corpus captured under the displaced kernel *before* it left the
49
+ * tree (`tests/fixtures/escomplex-kernel-parity/`), because afterwards there is
50
+ * nothing left to compare against.
51
+ *
52
+ * ## The one uncontrolled input
53
+ *
54
+ * `.agents/` materializes into a consumer's repository root, so `@babel/parser`
55
+ * resolves from **their** `node_modules`. The preflight guard checks presence,
56
+ * not range, and `@babel/parser@8` is GA and rejects several names in the
57
+ * fixed plugin list. A manifest range documents the requirement; it does not
58
+ * enforce it. So the resolved major is asserted here, at load, with an error
59
+ * that names the problem — rather than surfacing as an opaque plugin-list
60
+ * syntax error partway through a scan.
61
+ */
62
+
63
+ // Every metric-core package is reached by a STATIC import specifier with an
64
+ // explicit `.js` extension. That is not style: `tests/scripts/
65
+ // runtime-deps-drift.test.js` scans for literal `import`/`require` callees, so
66
+ // a package reached through an aliased `createRequire` would be a runtime
67
+ // dependency that is neither declared nor preflighted. The extensions are
68
+ // mandatory because `typhonjs-escomplex-commons` ships an empty
69
+ // `package.json` — no `main`, no `exports` — so a deep path is the only door.
70
+ import { parse as babelParse } from '@babel/parser';
71
+ import PluginMetricsModule from 'escomplex-plugin-metrics-module/dist/PluginMetricsModule.js';
72
+ import PluginSyntaxBabylon from 'escomplex-plugin-syntax-babylon/dist/PluginSyntaxBabylon.js';
73
+ import ASTWalker from 'typhonjs-ast-walker/dist/ASTWalker.js';
74
+ import ModuleScopeControl from 'typhonjs-escomplex-commons/dist/module/report/control/ModuleScopeControl.js';
75
+ import ModuleReport from 'typhonjs-escomplex-commons/dist/module/report/ModuleReport.js';
76
+ import { install as installAstCompat } from './escomplex-ast-compat.js';
77
+ import { describeParserMajorError } from './runtime-deps/parser-major.js';
78
+
79
+ /**
80
+ * The displaced parser shim's plugin list, verbatim and in order.
81
+ *
82
+ * Order is preserved because it is cheap to preserve, not because a
83
+ * reordering is known to matter. The two entries with options
84
+ * (`decorators`, `pipelineOperator`) carry the shim's exact settings —
85
+ * `decoratorsBeforeExport: false` and the `minimal` pipeline proposal — which
86
+ * decide whether decorated classes and `|>` parse. Re-cloned per parse so a
87
+ * parser that mutated its options could not poison a later call.
88
+ */
89
+ const PARSER_PLUGINS = [
90
+ 'asyncGenerators',
91
+ 'bigInt',
92
+ 'classProperties',
93
+ 'classPrivateProperties',
94
+ 'classPrivateMethods',
95
+ ['decorators', { decoratorsBeforeExport: false }],
96
+ 'doExpressions',
97
+ 'dynamicImport',
98
+ 'exportDefaultFrom',
99
+ 'exportNamespaceFrom',
100
+ 'functionBind',
101
+ 'functionSent',
102
+ 'importMeta',
103
+ 'jsx',
104
+ 'logicalAssignment',
105
+ 'nullishCoalescingOperator',
106
+ 'numericSeparator',
107
+ 'objectRestSpread',
108
+ 'optionalCatchBinding',
109
+ 'optionalChaining',
110
+ ['pipelineOperator', { proposal: 'minimal' }],
111
+ 'throwExpressions',
112
+ 'typescript',
113
+ ];
114
+
115
+ /**
116
+ * The two plugins, in registration order: syntax populates the trait tables
117
+ * the metrics plugin then reads. Neither defines `onPluginLoad`, and the
118
+ * displaced bus was constructed without an eventbus, so there is no plugin
119
+ * lifecycle or eventbus coupling to reproduce — only this list.
120
+ */
121
+ const PLUGINS = [
122
+ ['escomplex-plugin-syntax-babylon', new PluginSyntaxBabylon()],
123
+ ['escomplex-plugin-metrics-module', new PluginMetricsModule()],
124
+ ];
125
+
126
+ // The kernel's code generator predates the Babel AST its own parser emits, so
127
+ // ordinary modern syntax aborts a WHOLE module — see `escomplex-ast-compat.js`
128
+ // for the defect and the upstream status. Installing at the kernel rather than
129
+ // at each caller makes the next scoring entrypoint correct by construction.
130
+ installAstCompat();
131
+
132
+ const parserProblem = describeParserMajorError();
133
+ if (parserProblem !== null) {
134
+ throw new Error(`[escomplex-kernel] ${parserProblem}`);
135
+ }
136
+
137
+ /**
138
+ * Run one synchronous plugin dispatch.
139
+ *
140
+ * Reproduces the displaced bus's `invokeSyncEvents` for the degenerate shape
141
+ * this kernel uses: no `copyProps` (the shell passed `void 0` at every call
142
+ * site, so the merge base was always `{}`), no eventbus, and the caller
143
+ * reading its results back off the same mutated `data` object every plugin
144
+ * saw.
145
+ *
146
+ * @param {string} method Plugin method name, e.g. `onEnterNode`.
147
+ * @param {object} passthru Properties placed on the event's `data`.
148
+ * @returns {object} The event `data`, after every plugin has mutated it.
149
+ */
150
+ function dispatch(method, passthru) {
151
+ const event = {
152
+ data: { ...passthru },
153
+ extra: undefined,
154
+ eventbus: undefined,
155
+ pluginName: undefined,
156
+ pluginOptions: undefined,
157
+ };
158
+ for (const [name, instance] of PLUGINS) {
159
+ if (typeof instance[method] !== 'function') continue;
160
+ event.pluginName = name;
161
+ instance[method](event);
162
+ }
163
+ return event.data;
164
+ }
165
+
166
+ /**
167
+ * The `ignoreKeys` a syntax trait wants withheld from the walker, if any.
168
+ *
169
+ * @param {object|undefined} syntax The trait entry for this node type.
170
+ * @param {object} node
171
+ * @param {object} parent
172
+ * @returns {string[]}
173
+ */
174
+ function traitIgnoreKeys(syntax, node, parent) {
175
+ return typeof syntax === 'object' && syntax?.ignoreKeys
176
+ ? syntax.ignoreKeys.valueOf(node, parent)
177
+ : [];
178
+ }
179
+
180
+ /**
181
+ * The new scope a syntax trait opens at this node, if any.
182
+ *
183
+ * @param {object|undefined} syntax The trait entry for this node type.
184
+ * @param {object} node
185
+ * @param {object} parent
186
+ * @returns {object|null}
187
+ */
188
+ function traitNewScope(syntax, node, parent) {
189
+ if (typeof syntax !== 'object' || !syntax?.newScope) return null;
190
+ return syntax.newScope.valueOf(node, parent) ?? null;
191
+ }
192
+
193
+ /**
194
+ * Build the walker visitor for one module traversal.
195
+ *
196
+ * Split out of {@link analyzeModule} so the enter/exit symmetry is readable
197
+ * side by side: each resolves the trait's scope, brackets the `scopeControl`
198
+ * mutation with a pre/post dispatch, and straddles it with the node dispatch
199
+ * in opposite order on the way in and out.
200
+ *
201
+ * The two event shapes are not interchangeable, and the difference is the
202
+ * displaced shell's, not a simplification available here: node events carry
203
+ * `syntaxes` and scope events do not, and a scope event names its scope
204
+ * `newScope` on the way in but `scope` on the way out.
205
+ *
206
+ * @param {{
207
+ * moduleReport: object,
208
+ * scopeControl: object,
209
+ * syntaxes: Record<string, object>,
210
+ * settings: object,
211
+ * }} context
212
+ * @returns {{enterNode: Function, exitNode: Function}}
213
+ */
214
+ function buildVisitor({ moduleReport, scopeControl, syntaxes, settings }) {
215
+ const nodeBase = { moduleReport, scopeControl, syntaxes, settings };
216
+ const scopeBase = { moduleReport, scopeControl, settings };
217
+ return {
218
+ enterNode(node, parent) {
219
+ const syntax = syntaxes[node.type];
220
+ const event = dispatch('onEnterNode', {
221
+ ...nodeBase,
222
+ ignoreKeys: traitIgnoreKeys(syntax, node, parent),
223
+ node,
224
+ parent,
225
+ });
226
+ const ignoreKeys = event !== null ? event.ignoreKeys : [];
227
+ const newScope = traitNewScope(syntax, node, parent);
228
+ if (newScope) {
229
+ const scoped = { ...scopeBase, newScope, node, parent };
230
+ dispatch('onModulePreScopeCreated', scoped);
231
+ scopeControl.createScope(newScope);
232
+ dispatch('onModulePostScopeCreated', scoped);
233
+ }
234
+ return ignoreKeys;
235
+ },
236
+ exitNode(node, parent) {
237
+ const syntax = syntaxes[node.type];
238
+ const newScope = traitNewScope(syntax, node, parent);
239
+ if (newScope) {
240
+ const scoped = { ...scopeBase, scope: newScope, node, parent };
241
+ dispatch('onModulePreScopePopped', scoped);
242
+ scopeControl.popScope(newScope);
243
+ dispatch('onModulePostScopePopped', scoped);
244
+ }
245
+ dispatch('onExitNode', { ...nodeBase, node, parent });
246
+ },
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Parse and score one module.
252
+ *
253
+ * Drop-in replacement for the displaced `escomplex.analyzeModule(source)`:
254
+ * same report object, same `finalize()` shape, same thrown errors for source
255
+ * the kernel cannot handle.
256
+ *
257
+ * @param {string} source JavaScript (or TypeScript) source text.
258
+ * @param {object} [options] Passed to the plugins' `onConfigure`, as before.
259
+ * @returns {object} The finalized module report.
260
+ * @throws {SyntaxError} Propagated from the parser, as before.
261
+ */
262
+ export function analyzeModule(source, options = {}) {
263
+ const ast = babelParse(source, {
264
+ plugins: structuredClone(PARSER_PLUGINS),
265
+ sourceType: 'unambiguous',
266
+ });
267
+
268
+ const settings = dispatch('onConfigure', { options, settings: {} }).settings;
269
+ Object.freeze(settings);
270
+ const syntaxes = dispatch('onLoadSyntax', {
271
+ settings,
272
+ syntaxes: {},
273
+ }).syntaxes;
274
+
275
+ const moduleReport = new ModuleReport(
276
+ ast.loc.start.line,
277
+ ast.loc.end.line,
278
+ settings,
279
+ );
280
+ dispatch('onModuleStart', { ast, moduleReport, syntaxes, settings });
281
+
282
+ const scopeControl = new ModuleScopeControl(moduleReport);
283
+ new ASTWalker().traverse(
284
+ ast,
285
+ buildVisitor({ moduleReport, scopeControl, syntaxes, settings }),
286
+ );
287
+
288
+ for (const phase of [
289
+ 'onModuleCalculate',
290
+ 'onModuleAverage',
291
+ 'onModulePostAverage',
292
+ 'onModuleEnd',
293
+ ]) {
294
+ dispatch(phase, { moduleReport, syntaxes, settings });
295
+ }
296
+
297
+ return moduleReport.finalize();
298
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
- import escomplex from 'typhonjs-escomplex';
3
2
  import { install as installAstCompat } from './escomplex-ast-compat.js';
3
+ import { analyzeModule } from './escomplex-kernel.js';
4
4
  import { transpileIfNeeded } from './transpile.js';
5
5
 
6
6
  /**
@@ -45,7 +45,7 @@ const UNSCORABLE = 0;
45
45
  */
46
46
  export function scoreSource(sourceCode) {
47
47
  try {
48
- const score = escomplex.analyzeModule(sourceCode)?.maintainability;
48
+ const score = analyzeModule(sourceCode)?.maintainability;
49
49
  return Number.isFinite(score)
50
50
  ? { score, unscorable: false, reason: null }
51
51
  : unscorable(`kernel returned a non-finite index (${String(score)})`);
@@ -141,7 +141,7 @@ export function scoreFile(filePath) {
141
141
  */
142
142
  export function calculateReport(sourceCode) {
143
143
  try {
144
- const result = escomplex.analyzeModule(sourceCode);
144
+ const result = analyzeModule(sourceCode);
145
145
  const methods = (result.methods ?? []).map((m) => ({
146
146
  name: m.name,
147
147
  maintainability:
@@ -31,10 +31,6 @@ import {
31
31
  renderStorySplitRules,
32
32
  ticketsModePromptField,
33
33
  } from '../templates/decomposer-prompts.js';
34
- import {
35
- renderAcceptanceSpecSystemPrompt,
36
- renderTechSpecSystemPrompt,
37
- } from '../templates/spec-author-prompts.js';
38
34
  import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
39
35
  import { buildComplexitySignals } from './complexity-gate.js';
40
36
  import { findDependencyCandidates } from './dependency-candidates.js';
@@ -329,6 +325,17 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
329
325
  title: 'Fill: short descriptive title',
330
326
  body: {
331
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.',
332
339
  spec:
333
340
  'Optional — contract and invariants only: interfaces, status ' +
334
341
  'codes, security invariants, and load-bearing constraints with ' +
@@ -340,7 +347,7 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
340
347
  non_goals: [],
341
348
  },
342
349
  acceptance: [
343
- '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',
344
351
  ],
345
352
  verify: [
346
353
  'Fill: exact command or test path — the mechanical check the acceptance item rests on',
@@ -367,25 +374,23 @@ function countEnumeratedItems(text) {
367
374
  }
368
375
 
369
376
  /**
370
- * Delta-shaped change-request verbs — the `core/scope-triage` skill's
371
- * change-request rubric routes these to `story` by default when the
372
- * 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.
373
379
  */
374
380
  const DELTA_VERB_RE =
375
381
  /\b(fix(?:es)?|tweak(?:s)?|extend(?:s)?|update(?:s)?|adjust(?:s)?|rename(?:s)?|correct(?:s)?|patch(?:es)?|bug|regression|flaky)\b/i;
376
382
 
377
383
  /**
378
- * Deterministic, CLI-applied scope-triage verdict over a raw `--seed` text
379
- * (#4496 fix 6). Embedding the verdict in the `--seed` envelope removes the
380
- * two skill Reads (`core/scope-triage` + the gate fragment's rubric pass)
381
- * 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.
382
387
  *
383
- * The heuristics anchor to the same granularity SSOT the skill anchors to —
388
+ * The heuristics anchor to the granularity SSOT —
384
389
  * `DELIVERABLE_GRANULARITY_GUIDANCE` in `ticket-validator-sizing.js` (one
385
390
  * Story = one coherent capability slice; multiple independent capabilities =
386
- * an Epic) — and to the skill's change-request delta rubric. Like the skill,
387
- * the verdict is **advisory**: being wrong in the `epic` direction is cheap,
388
- * 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.
389
394
  *
390
395
  * @param {{ seedText?: string }} args
391
396
  * @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
@@ -634,12 +639,15 @@ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
634
639
 
635
640
  /**
636
641
  * Render the authoring system prompts the collapsed pipeline's single
637
- * authoring pass consumes. The spec/acceptance prompts render from
638
- * `lib/templates/spec-author-prompts.js` (the M3/M8 handshake — envelope
639
- * authoritative from day one); the story prompt is the N=1 core from
640
- * `lib/templates/decomposer-prompts.js`, with the schedule and partition
641
- * rules a planner reads only when the default-single split policy clears
642
- * 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.
643
651
  *
644
652
  * `storyTicketsRules` is the one mode-conditional field (Story #5323): it
645
653
  * only means anything when the seed is an existing ticket, and an envelope
@@ -647,12 +655,10 @@ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
647
655
  * ticket that a `--seed` run does not have.
648
656
  *
649
657
  * @param {{ mode?: string }} [args]
650
- * @returns {{ spec: string, acceptance: string, story: string, storySplitRules: string, storyTicketsRules?: string }}
658
+ * @returns {{ story: string, storySplitRules: string, storyTicketsRules?: string }}
651
659
  */
652
660
  export function buildSystemPrompts({ mode } = {}) {
653
661
  return {
654
- spec: renderTechSpecSystemPrompt(),
655
- acceptance: renderAcceptanceSpecSystemPrompt(),
656
662
  story: renderStoryAuthorCore(),
657
663
  storySplitRules: renderStorySplitRules(),
658
664
  ...ticketsModePromptField(mode),
@@ -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`
@@ -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;
@@ -543,7 +543,6 @@ function logPersistEpilogue({
543
543
  * artifacts: {
544
544
  * stories: Array<object>,
545
545
  * techSpecContent?: string|null,
546
- * planAcceptance?: string[]|null,
547
546
  * planContextEnvelope?: object|null,
548
547
  * },
549
548
  * config?: object,
@@ -569,7 +568,6 @@ export async function runPlanPersist({
569
568
  const {
570
569
  stories: rawStories = null,
571
570
  techSpecContent = null,
572
- planAcceptance = null,
573
571
  planContextEnvelope = null,
574
572
  } = artifacts ?? {};
575
573
  const {
@@ -623,11 +621,10 @@ export async function runPlanPersist({
623
621
  epicId: opts.adoptEpicId ?? null,
624
622
  });
625
623
 
626
- // Split policy + inline Spec fold (Specs stay inline, never under docs/).
624
+ // Inline Spec fold (Specs stay inline, never under docs/).
627
625
  const seedContent = planContextEnvelope?.seed?.content ?? '';
628
626
  const { stories: assembled } = assemblePlanStories(rawStories, {
629
627
  sharedSpec: techSpecContent,
630
- planAcceptance: planAcceptance ?? undefined,
631
628
  sourceTicketIds,
632
629
  // The seed this plan was authored from: an audit sweep's Single-plan seed
633
630
  // carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
@@ -654,6 +651,31 @@ export async function runPlanPersist({
654
651
  rawFindings: validated.findings,
655
652
  });
656
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
+
657
679
  const { created, planRunLabel, planRunLabelApplied } =
658
680
  await createStoryIssues({
659
681
  provider,
@@ -666,24 +688,6 @@ export async function runPlanPersist({
666
688
  recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
667
689
 
668
690
  const primary = created[0];
669
- const waveTable = buildWaveTable(
670
- stories.map((s) => ({
671
- slug: s.slug,
672
- title: s.title,
673
- depends_on: s.depends_on,
674
- })),
675
- );
676
-
677
- // Story #5265: the table says which Stories share an order; the dispatcher
678
- // decides which of those actually run together, and it decides on the
679
- // evidence-widened footprint. Run its own predicate over the assembled
680
- // bodies — the exact artifact the tick will read back off GitHub — so the
681
- // comment names the serialisation instead of promising parallelism the
682
- // next tick refuses. `tempRoot` is threaded for the same reason the tick
683
- // threads it: the scrape must ignore this project's scratch root.
684
- const waveCollisions = predictWaveSerialisation(waveTable, stories, {
685
- tempRoot: getPaths(config).tempRoot,
686
- });
687
691
 
688
692
  // Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
689
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