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,110 @@
1
+ /**
2
+ * runtime-deps/parser-major — which `@babel/parser` major the complexity
3
+ * kernel can actually parse with, and how to say so when it is wrong.
4
+ *
5
+ * Its own module because two very different callers need the same answer and
6
+ * neither should drag the other in: the kernel asserts it at load, and
7
+ * `mandrel doctor` reports it to a consumer. Importing the kernel into doctor
8
+ * to ask one version question would pull the whole metric core and install the
9
+ * AST compatibility patch as a side effect.
10
+ *
11
+ * `.agents/` materializes into the consumer's repository root, so
12
+ * `@babel/parser` resolves from *their* `node_modules`. A range in
13
+ * `runtime-deps.json` documents the requirement; it cannot enforce it. This is
14
+ * the enforcement.
15
+ *
16
+ * @module lib/runtime-deps/parser-major
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+
22
+ /**
23
+ * The only `@babel/parser` major the kernel supports.
24
+ *
25
+ * Module-local, with `describeParserMajorError` as the single public door:
26
+ * every caller wants the verdict and the remedy, not the number.
27
+ *
28
+ * 8.x removed several plugin names from the kernel's fixed list (they became
29
+ * default syntax), so it does not merely warn — it throws on the plugin list
30
+ * itself. Adopting it is a deliberate change with a baseline recut attached,
31
+ * not something to absorb from a consumer's resolution.
32
+ */
33
+ const SUPPORTED_PARSER_MAJOR = 7;
34
+
35
+ /** Package whose resolved major gates the kernel. */
36
+ const PARSER_PACKAGE = '@babel/parser';
37
+
38
+ /** Memoised resolved parser version: `undefined` unread, `null` unresolvable. */
39
+ let parserVersion;
40
+
41
+ /**
42
+ * Read the resolved `@babel/parser` version from its own manifest.
43
+ *
44
+ * The package exports no version, so its `package.json` is the only source.
45
+ * This is the one non-static resolution in the file and it deliberately
46
+ * targets a manifest rather than code: `@babel/parser` itself is reached by a
47
+ * static import above, so it is declared and preflighted like every other
48
+ * dependency.
49
+ *
50
+ * @returns {string|null} The resolved version, or `null` when the manifest
51
+ * cannot be read (a layout that hides `package.json` behind `exports`, say).
52
+ */
53
+ function resolveParserVersion() {
54
+ if (parserVersion !== undefined) return parserVersion;
55
+ try {
56
+ const require = createRequire(import.meta.url);
57
+ const manifest = require.resolve(`${PARSER_PACKAGE}/package.json`);
58
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf-8'));
59
+ parserVersion = typeof parsed?.version === 'string' ? parsed.version : null;
60
+ } catch {
61
+ parserVersion = null;
62
+ }
63
+ return parserVersion;
64
+ }
65
+
66
+ /**
67
+ * The resolved parser's major version.
68
+ *
69
+ * @returns {number|null} `null` when the version could not be resolved or
70
+ * does not lead with an integer.
71
+ */
72
+ function resolveParserMajor() {
73
+ const version = resolveParserVersion();
74
+ if (version === null) return null;
75
+ const major = Number.parseInt(version, 10);
76
+ return Number.isInteger(major) ? major : null;
77
+ }
78
+
79
+ /**
80
+ * Describe the resolved-parser problem, if there is one.
81
+ *
82
+ * Single-sourced so the load-time assertion below and the preflight guard
83
+ * (`runtime-deps/ensure-installed.js`) emit the *same* named, actionable
84
+ * message — the point of AC-5 is that a consumer never meets this as a
85
+ * plugin-list syntax error mid-scan.
86
+ *
87
+ * An unresolvable version is **not** a problem: a consumer layout that hides
88
+ * the manifest still resolves the parser itself, and refusing to score would
89
+ * be a worse answer than scoring with an unverified parser. Only a
90
+ * *known-wrong* major is reported.
91
+ *
92
+ * @param {{major?: number|null, version?: string|null}} [resolved] Overrides
93
+ * the resolved parser, so the message a consumer on an unsupported major
94
+ * would read is assertable without installing one.
95
+ * @returns {string|null} The message, or `null` when the resolved parser is
96
+ * supported (or its version is unknowable).
97
+ */
98
+ export function describeParserMajorError(resolved = {}) {
99
+ const { major = resolveParserMajor(), version = resolveParserVersion() } =
100
+ resolved;
101
+ if (major === null || major === SUPPORTED_PARSER_MAJOR) return null;
102
+ return (
103
+ `unsupported ${PARSER_PACKAGE} major: resolved ${version}, ` +
104
+ `the complexity kernel requires ${SUPPORTED_PARSER_MAJOR}.x. It parses ` +
105
+ `with a fixed plugin list that later majors reject, so CRAP and ` +
106
+ `maintainability scoring would fail mid-scan with an opaque plugin-list ` +
107
+ `error. Declare "${PARSER_PACKAGE}": "^${SUPPORTED_PARSER_MAJOR}" in ` +
108
+ `your package.json (see .agents/runtime-deps.json).`
109
+ );
110
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
- * runtime-deps/preflight — pure helpers for the dependency-presence check.
2
+ * runtime-deps/preflight — pure helpers for the dependency-presence check's
3
+ * *messaging* half.
3
4
  *
4
- * These functions hold no side effects so they are unit-testable in
5
- * isolation: `checkRuntimeDeps` takes an injected `resolve` seam,
6
- * `detectPackageManager` takes an injected `exists` seam, and
5
+ * The check itself moved to `dep-resolution.js`, which owns resolving a
6
+ * declared dependency, judging its major, and explaining a mismatch. What is
7
+ * left here holds no side effects and stays unit-testable in isolation:
8
+ * `detectPackageManager` takes an injected `exists` seam and
7
9
  * `formatMissingDepsMessage` is a pure string builder. The side-effecting
8
10
  * guard that wires them to the real process lives in `ensure-installed.js`.
9
11
  *
@@ -14,27 +16,6 @@
14
16
  import fs from 'node:fs';
15
17
  import { detectPackageManager as detectPm } from '../detect-package-manager.js';
16
18
 
17
- /**
18
- * Resolve each required package via the injected `resolve` seam and collect
19
- * the ones that fail. `resolve` is typically `require.resolve` bound to the
20
- * framework module location; it throws `MODULE_NOT_FOUND` when a package is
21
- * absent from the resolvable `node_modules`.
22
- *
23
- * @param {{ required: string[], resolve: (specifier: string) => string }} opts
24
- * @returns {{ ok: boolean, missing: string[] }}
25
- */
26
- export function checkRuntimeDeps({ required, resolve }) {
27
- const missing = [];
28
- for (const dep of required) {
29
- try {
30
- resolve(dep);
31
- } catch {
32
- missing.push(dep);
33
- }
34
- }
35
- return { ok: missing.length === 0, missing };
36
- }
37
-
38
19
  /**
39
20
  * Detect the consumer's package manager from lockfile presence so the
40
21
  * remediation message names the right install command. Defaults to `npm`.
@@ -71,6 +71,51 @@ const STATIC_FROM =
71
71
  const SIDE_EFFECT = /^\s*import\s*['"]([^'"]+)['"]/gm;
72
72
  // `require(...)` and dynamic `import(...)` may appear mid-expression.
73
73
  const CALL_FORM = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
74
+ // Names bound to a `createRequire(...)` result, e.g.
75
+ // `const fromReader = createRequire(x)`. Such a binding is a require function
76
+ // under a different name, so calls through it are real runtime imports that
77
+ // `CALL_FORM` cannot see — it matches the literal callees `require`/`import`.
78
+ // A module reached only that way would be an undeclared, unpreflighted
79
+ // dependency that this scanner reported as absent.
80
+ const REQUIRE_ALIAS =
81
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*createRequire\s*\(/g;
82
+
83
+ /**
84
+ * Build a matcher for calls through `createRequire`-bound identifiers.
85
+ *
86
+ * Returns `null` when the source binds none, so the common case adds no pass.
87
+ * Aliases named `require` need no entry — `CALL_FORM` already covers them.
88
+ *
89
+ * @param {string} cleaned Comment-stripped source.
90
+ * @returns {RegExp|null}
91
+ */
92
+ function aliasedRequireMatcher(cleaned) {
93
+ REQUIRE_ALIAS.lastIndex = 0;
94
+ const names = new Set();
95
+ let m = REQUIRE_ALIAS.exec(cleaned);
96
+ while (m !== null) {
97
+ if (m[1] !== 'require') names.add(m[1]);
98
+ m = REQUIRE_ALIAS.exec(cleaned);
99
+ }
100
+ if (names.size === 0) return null;
101
+ const alternation = [...names]
102
+ .map((n) => n.replace(/[$]/g, '\\$$'))
103
+ .join('|');
104
+ return new RegExp(`\\b(?:${alternation})\\s*\\(\\s*['"]([^'"]+)['"]`, 'g');
105
+ }
106
+
107
+ /**
108
+ * The specifier patterns to run over one source: the three fixed forms, plus
109
+ * an alias matcher when the source binds a `createRequire` result.
110
+ *
111
+ * @param {string} cleaned Comment-stripped source.
112
+ * @returns {RegExp[]}
113
+ */
114
+ function specifierMatchers(cleaned) {
115
+ const aliased = aliasedRequireMatcher(cleaned);
116
+ if (!aliased) return [STATIC_FROM, SIDE_EFFECT, CALL_FORM];
117
+ return [STATIC_FROM, SIDE_EFFECT, CALL_FORM, aliased];
118
+ }
74
119
 
75
120
  /**
76
121
  * Extract the set of third-party top-level package names imported by a
@@ -82,7 +127,7 @@ const CALL_FORM = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
82
127
  export function extractThirdPartyImports(source) {
83
128
  const found = new Set();
84
129
  const cleaned = stripJsComments(source);
85
- for (const re of [STATIC_FROM, SIDE_EFFECT, CALL_FORM]) {
130
+ for (const re of specifierMatchers(cleaned)) {
86
131
  re.lastIndex = 0;
87
132
  let match = re.exec(cleaned);
88
133
  while (match !== null) {
@@ -41,7 +41,7 @@ export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
41
41
 
42
42
  /**
43
43
  * A skill id is the tier-relative path naming a skill — e.g.
44
- * `core/scope-triage` or `stack/qa/playwright`. It is the value that
44
+ * `core/test-first` or `stack/qa/playwright`. It is the value that
45
45
  * appears in `skills.index.json` minus the root prefix, and the value a
46
46
  * `qa.environments.*.signInSeam.skill` seam carries.
47
47
  *
@@ -24,10 +24,11 @@ import { BODY_FORMAT_LINTS } from '../story-body/body-format-lints.js';
24
24
  * no verify-tier suffix — every one of those either scored a shape the
25
25
  * authoring model already judges or prescribed a proxy that became the
26
26
  * goal.
27
- * - **The N>1 rules** ({@link renderStorySplitRules}) — the schedule and
28
- * partition rules that only mean anything once a draft has siblings:
29
- * every Story must earn its slot in the wave schedule, and every
30
- * acceptance criterion belongs to exactly one Story.
27
+ * - **The N>1 rules** ({@link renderStorySplitRules}) — the schedule rules
28
+ * that only mean anything once a draft has siblings: every Story must
29
+ * earn its slot in the wave schedule, and no same-wave pair may collide
30
+ * on a declared path (Story #5332 replaced the acceptance partition with
31
+ * the dispatcher's own collision predicate, armed as a refusal).
31
32
  * - **The tickets-mode rules** ({@link ticketsModePromptField}, Story
32
33
  * #5323) — what to re-derive rather than carry when the seed is an
33
34
  * existing ticket whose body is already in Story shape.
@@ -135,9 +136,9 @@ The **persisted** \`body\` renders these markdown sections (in order) — you au
135
136
 
136
137
  - **goal** (in body string): One sentence stating WHY this Story exists.
137
138
  - **spec** (optional, in body string as \`## Spec\`): The technical approach at the altitude the SPEC PROSE CONTRACT below fixes — contract and invariants, never implementation narration. Write as much as the work needs and no more; persist keeps Specs inline at any length and never writes them under \`docs/\`.
138
- - **slicing** (optional): Ordered intra-session checkpoints for one Story, one line each. Not a fan-out table and not a duplicate of Acceptance.
139
+ - **slicing** (optional): Ordered intra-session checkpoints for one Story, one line each. A checkpoint is a **stage of the work** — a commit boundary the deliverer passes through inside one session, stated as the step it performs. An acceptance item is a **state of the codebase** a PR reviewer confirms once the Story has landed. The same Story therefore carries both: the checkpoints say in what order it is built, \`acceptance[]\` says what must then be true. Never a fan-out table, never a second acceptance list, and never sibling tickets — a broad sweep with many stages is still one Story, sliced here.
139
140
  - **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. **Name the files the deliverer authors, and omit generated artifacts** — quality baselines, generated test indexes, migration journals, lockfiles and the like are regenerated by the work itself, the refresh is a close-gate concern, and declaring one needlessly reserves a footprint that serializes sibling Stories at dispatch. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals. Persist probes every path against the base branch and repairs a plain-string bullet or a trailing parenthetical into the object form for you; a \`creates\` on an existing path or a \`refactors-existing\` on an absent one is a dry-run warning, and only a \`deletes\` naming an absent path is refused.
140
- - **acceptance** (top-level array on the ticket object): Each item is an **outcome a PR reviewer can confirm from the diff and the verify output** — what is true of the codebase once the Story lands, stated at the altitude of the capability (a command that now exits 0 against a named input, a behavior a named test now asserts, a config that now fails validation on a retired key, a document that now records a decision). Aim for **three to six** items: fewer than three usually means the outcome is under-specified; more than six usually means acceptance is re-listing the footprint or the mechanical checks that belong in \`verify[]\`. Push grep-shaped probes, file-exists checks and exit-code tests down into \`verify[]\`; never pin an internal helper name or a private file path into an acceptance item the advisory \`changes[]\` is free to reshape. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec".
141
+ - **acceptance** (top-level array on the ticket object): Each item is an **outcome a PR reviewer can confirm from the diff and the verify output** — what is true of the codebase once the Story lands, stated at the altitude of the capability (a command that now exits 0 against a named input, a behavior a named test now asserts, a config that now fails validation on a retired key, a document that now records a decision). State as many outcomes as the capability has and no more — the list has no target, floor or ceiling, and a long one is never a reason to split the Story. Push grep-shaped probes, file-exists checks and exit-code tests down into \`verify[]\`; never pin an internal helper name or a private file path into an acceptance item the advisory \`changes[]\` is free to reshape. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec".
141
142
  - **verify** (top-level array on the ticket object): The **mechanical checks** — exact commands or test paths the deliverer runs and the acceptance critic consumes as evidence: \`node --test tests/x.test.js\`, \`npm run lint\`, \`npm run validate\`, a scoped grep. Every acceptance item should be confirmable from at least one verify entry's output plus the diff. Stories with zero verify entries fail validation.
142
143
  - **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it, and the dry-run warns on every one it finds. Triage each unknown by who can resolve it: an AFK-shaped unknown (a fact in docs, a third-party API surface, observable repo behavior) MUST be resolved by your own research before authoring — never restated as an assumption; only a HITL-shaped unknown (a genuine product or architecture call the operator owns) may be restated as a declarative Key Assumption the agent can act on, stating the default chosen (a decision-made-by-default).
143
144
  - **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
@@ -171,13 +172,13 @@ ${advisoryCaveat}
171
172
 
172
173
  **Decompose at deliverable granularity, not module/task level.** ${granularityDefinition}
173
174
 
174
- The only sizing question is **cohesion**: *is this one coherent change with one reason to exist?* There is no ceiling on a Story's footprint, Spec length or acceptance count — a broad contract cutover is one Story when every changed site changes for the same reason. Frontier models one-shot capability-sized work in a single pass; do not fragment a coherent capability into dependent slices to stay "small", and do not pad a Story with adjacent work to look "complete".
175
+ The only sizing question is **cohesion**: *is this one coherent change with one reason to exist?* There is no target, floor or ceiling on a Story's footprint, Spec length or acceptance count, and a long acceptance list is a description of a broad capability, never a reason to split. A broad contract cutover is one Story when every changed site changes for the same reason. Frontier models one-shot capability-sized work in a single pass; do not fragment a coherent capability into dependent slices to stay "small", and do not pad a Story with adjacent work to look "complete".
175
176
 
176
177
  ${envelopeFloor}
177
178
 
178
- - **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories — or two Stories that should be one.
179
+ - **One Story = one coherent change with one reason to exist.**
180
+ - **A remediation sweep over one subsystem is one Story.** A batch of findings in the same subsystem shares one reason to exist — the subsystem is wrong — so it arrives as one Story whose \`## Slicing\` checkpoints carry the stages, not as one Story per finding.
179
181
  - ${singleConsumerRule}
180
- - **Split independent, parallelizable work** into sibling Stories — but only when the pieces genuinely have separate reasons to exist.
181
182
 
182
183
  #### UI / TESTID INVARIANCE (per CLAUDE.md safety rule):
183
184
 
@@ -201,7 +202,7 @@ IMPORTANT DEPENDENCY RULE: Story-to-Story dependencies are expressed via \`depen
201
202
  /**
202
203
  * The rules that only apply once a draft has more than one Story: the
203
204
  * delivery-schedule simulation that makes each Story earn its slot, and the
204
- * acceptance partition persist enforces at N>1.
205
+ * same-wave collision refusal persist enforces at N>1.
205
206
  *
206
207
  * @returns {string}
207
208
  */
@@ -213,16 +214,18 @@ You are splitting past the default-single policy, so simulate the delivery sched
213
214
  1. **Build the wave schedule.** A Story runs only after every \`depends_on\` completes, and two Stories that name the same file in \`changes[]\` cannot run in the same wave (the scheduler serializes file-overlapping Stories even when no \`depends_on\` edge links them).
214
215
  2. **Every Story must earn its slot** by at least one of:
215
216
  - **(a) parallelism** — it actually runs concurrently with a sibling in the schedule you just built ("logically independent" does not count; *schedule*-independent does);
216
- - **(b) risk isolation** — it isolates a consumer-facing behavior change or high-risk cutover into its own reviewable, revertable unit;
217
- - **(c) cohesion break** — merged into its neighbor it would no longer be one coherent change with one reason to exist.
218
- 3. **A dependent link with none of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains: N Stories that deliver no faster than one Story pay N delivery sessions (branch, PR, review, CI) for nothing.
217
+ - **(b) cohesion break** — merged into its neighbor it would no longer be one coherent change with one reason to exist.
218
+ 3. **A dependent link with neither of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains: N Stories that deliver no faster than one Story pay N delivery sessions (branch, PR, review, CI) for nothing.
219
219
  4. **When one file appears in the \`changes[]\` of most of your Stories, the slicing axis cuts across a shared seam** — merge the Stories that co-edit it, or re-slice along the seam so each Story owns its files.
220
220
 
221
- #### ACCEPTANCE PARTITION (persist-enforced at N>1):
221
+ #### THE COLLISION REFUSAL (persist-enforced at N>1):
222
222
 
223
- - Every acceptance criterion of the plan belongs to **exactly one** Story — no criterion is shared, and none is dropped. Persist refuses a draft whose criteria overlap or leave a plan-level criterion unclaimed.
224
- - Each Story carries its **own** \`## Spec\`; a shared \`techspec.md\` cannot be folded into N>1 Stories.
225
- - Express ordering with \`depends_on\` (a sibling slug, or \`#<id>\` for an open Story from an earlier plan). A Story whose \`verify[]\` runs against a file a sibling creates MUST \`depends_on\` that sibling, so the file exists when verification runs.`;
223
+ Persist runs the **dispatcher's own** collision predicate pairwise over your draft, before it creates a single issue, and **refuses** the plan when any two same-wave Stories collide — both declaring a path in \`changes[]\`, or one declaring a glob that covers the other's path. Such a pair cannot be co-dispatched, so the split buys no parallelism and costs a delivery session. Two remedies, both yours to choose at authoring time:
224
+
225
+ - **Merge the pair** into the one Story they already are, with \`## Slicing\` checkpoints for the stages; or
226
+ - **Order them** with \`depends_on\` so they sit in different waves, when they genuinely have separate reasons to exist.
227
+
228
+ Each Story carries its **own** \`## Spec\`; a shared \`techspec.md\` cannot be folded into N>1 Stories. Express ordering with \`depends_on\` (a sibling slug, or \`#<id>\` for an open Story from an earlier plan). A Story whose \`verify[]\` runs against a file a sibling creates MUST \`depends_on\` that sibling, so the file exists when verification runs.`;
226
229
  }
227
230
 
228
231
  /**
@@ -244,7 +247,7 @@ function renderStoryTicketsRules() {
244
247
 
245
248
  You are planning from one or more existing tickets. Read them for **what the work is** — the problem, the constraints, the commands that verify it — and re-derive everything else. Specifically:
246
249
 
247
- 1. **Re-derive \`acceptance[]\` from the goal.** Do not copy the source's \`## Acceptance\` list, and never carry its \`AC-<n>:\` handles — the body renderer numbers the checkboxes itself, so a copied handle renders doubled. A source ticket carrying fifteen criteria is telling you its acceptance was over-specified, not that yours must be: state the three to six outcomes a PR reviewer can confirm, and let the rest fall to \`verify[]\`.
250
+ 1. **Re-derive \`acceptance[]\` from the goal.** Do not copy the source's \`## Acceptance\` list, and never carry its \`AC-<n>:\` handles — the body renderer numbers the checkboxes itself, so a copied handle renders doubled. A source ticket carrying fifteen criteria is telling you its acceptance was over-specified, not that yours must be: state the outcomes a PR reviewer can confirm, and let the mechanical checks fall to \`verify[]\`.
248
251
  2. **A mechanical check is a \`verify[]\` command, not an acceptance item.** "Baselines refreshed", "lint exits 0", "the generated index is regenerated", "the quality gate passes" are commands the deliverer runs and the critic reads as evidence. Carrying them as acceptance items inflates the binding contract with work every close already gates.
249
252
  3. **Read the source's \`verify[]\` for the commands it names, not for its shape.** Take the test paths and scripts; drop any trailing tier suffix (\`(unit)\`, \`(contract)\`, \`(e2e)\`, \`(validate)\`) and any \`manual:<reason>\` escape — a verify entry is a bare command.
250
253
  4. **Re-derive the footprint against the tree as it is now.** The source ticket's \`## Changes\` predicted a repository that has since moved; probe the paths you cite and omit the generated artifacts it listed.
@@ -29,7 +29,6 @@
29
29
  * --plan-context <file> Optional explicit path to the `plan-context.js`
30
30
  * envelope. Its `sourceTickets[]` is what makes
31
31
  * `--tickets` superseding work without a flag
32
- * --plan-acceptance <file> Optional JSON string[] for partition coverage
33
32
  * --source-tickets <ids> Explicit OVERRIDE of the envelope-derived source
34
33
  * ids, for hand-driven runs. Each id must be
35
34
  * claimed by exactly one Story's `supersedes[]`;
@@ -106,7 +105,6 @@ const CLI_OPTIONS = {
106
105
  'tech-spec': { type: 'string' },
107
106
  'plan-dir': { type: 'string' },
108
107
  'plan-context': { type: 'string' },
109
- 'plan-acceptance': { type: 'string' },
110
108
  'source-tickets': { type: 'string' },
111
109
  'close-superseded': { type: 'boolean', default: true },
112
110
  'no-close-superseded': { type: 'boolean', default: false },
@@ -121,7 +119,6 @@ const CLI_OPTIONS = {
121
119
  const USAGE =
122
120
  'Usage: plan-persist.js --stories <file> ' +
123
121
  '[--tech-spec <file>] [--plan-dir <dir>] [--plan-context <file>] ' +
124
- '[--plan-acceptance <file>] ' +
125
122
  '[--source-tickets <ids>] [--no-close-superseded] ' +
126
123
  '[--dry-run] [--chain-on-clean] [--force-review] ' +
127
124
  '[--epic-title <text> --epic-goal <text> | --epic <id>]';
@@ -159,9 +156,6 @@ export function resolveInputPaths(values) {
159
156
  techSpecPath: values['tech-spec']
160
157
  ? path.resolve(values['tech-spec'])
161
158
  : null,
162
- planAcceptancePath: values['plan-acceptance']
163
- ? path.resolve(values['plan-acceptance'])
164
- : null,
165
159
  planDir,
166
160
  planContextPath: resolvePlanContextPath(values['plan-context'], planDir),
167
161
  };
@@ -172,9 +166,6 @@ async function loadArtifacts(paths) {
172
166
  const techSpecContent = paths.techSpecPath
173
167
  ? await readOptional(paths.techSpecPath, { required: true })
174
168
  : null;
175
- const planAcceptance = paths.planAcceptancePath
176
- ? await readJsonFile(paths.planAcceptancePath, 'plan-acceptance')
177
- : null;
178
169
  const planContextEnvelope = await loadPlanContextEnvelope(
179
170
  paths.planContextPath,
180
171
  );
@@ -182,7 +173,6 @@ async function loadArtifacts(paths) {
182
173
  return {
183
174
  stories,
184
175
  techSpecContent,
185
- planAcceptance,
186
176
  planContextEnvelope,
187
177
  };
188
178
  }
@@ -501,7 +491,6 @@ runAsCli(import.meta.url, main, {
501
491
  '--plan-context <file>',
502
492
  'The plan-context envelope this draft was authored against.',
503
493
  ],
504
- ['--plan-acceptance <file>', 'Acceptance artifact to attach.'],
505
494
  ['--source-tickets <ids>', 'Ticket ids this plan supersedes.'],
506
495
  ['--dry-run', 'Validate and report; create nothing.'],
507
496
  ['--chain-on-clean', 'Persist immediately when the dry run is clean.'],
@@ -1,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-09-06T12:59:23.069Z",
2
+ "generatedAt": "2026-09-14T11:57:26.498Z",
3
3
  "generator": "generate-skills-index.js@1",
4
4
  "skills": [
5
5
  {
@@ -52,16 +52,6 @@
52
52
  "allowedTools": null,
53
53
  "vendor": null
54
54
  },
55
- {
56
- "name": "scope-triage",
57
- "tier": "core",
58
- "category": "core",
59
- "path": ".agents/skills/core/scope-triage/SKILL.md",
60
- "description": "Optional split-advisory for `/mandrel-plan`. Under v2 there is no epic|story routing verdict — `/mandrel-plan` always authors Stories. Use this skill only when judging whether a draft should stay one Story or legitimately split (near-zero overlap or an architectural seam).",
61
- "policyCapsuleBullets": 5,
62
- "allowedTools": null,
63
- "vendor": null
64
- },
65
55
  {
66
56
  "name": "security-and-hardening",
67
57
  "tier": "core",
@@ -152,15 +152,17 @@ node .agents/scripts/audit-to-stories.js --emit-plan-seed \
152
152
 
153
153
  The seed renders the canonical one-pager sections — Problem Statement,
154
154
  Recommended Direction, Key Assumptions (with links to every source
155
- report), MVP Scope (the M proposed Stories), Key Files (so `/mandrel-plan`'s
156
- authoring step has concrete anchors), Grouping, Not Doing.
157
-
158
- **Grouping is the container-Epic directive.** Above 2 proposed Stories the
159
- seed instructs `/mandrel-plan` to group them under one Epic — a sweep is the
160
- clearest case for a container, since every Story shares a provenance and an
161
- operator usually delivers them together. It is a directive in the text, not an
162
- automatic write: Phase 4 above is where an operator declines it. Below the
163
- threshold the section says so and asks for nothing.
155
+ report), MVP Scope (**the findings, flat**), Key Files (so `/mandrel-plan`'s
156
+ authoring step has concrete anchors), Not Doing.
157
+
158
+ **The seed states findings, not a partition.** MVP Scope used
159
+ to render one numbered bullet per group beneath a `## Grouping` container
160
+ directive — a plan the seed had already decided, at the grouping grain, before
161
+ `/mandrel-plan` read a word of it. N now reaches the planner **undecided**: it
162
+ applies its own cohesion judgment, and container grouping is `/mandrel-plan`'s
163
+ Gate #3 call at persist, where N is known. The grouping still drives the
164
+ **standalone-Stories** path (Phase 5b), which needs one issue payload per
165
+ group.
164
166
 
165
167
  Chain into the existing planning entrypoint:
166
168
 
@@ -172,9 +174,10 @@ Chain into the existing planning entrypoint:
172
174
  then runs its author → persist path, as documented in its workflow.
173
175
 
174
176
  **Dedup provenance is carried mechanically — do not hand-copy it.** The seed's
175
- MVP Scope bullets carry each group's `audit-fingerprints` and
177
+ MVP Scope section carries each group's `audit-fingerprints` and
176
178
  `audit-semantic-keys` footers as HTML comments (invisible in the rendered
177
- one-pager). `plan-persist` harvests them out of the seed on the
179
+ one-pager, and per-group even though the visible list is flat — they are the
180
+ identity the next sweep matches on). `plan-persist` harvests them out of the seed on the
178
181
  `plan-context.json` envelope and appends them to **every** Story body it
179
182
  persists, via `carryProvenanceFooters`
180
183
  ([`lib/findings/route-finding.js`](../scripts/lib/findings/route-finding.js)).
@@ -47,10 +47,20 @@ The spine's two escape hatches from N=1 are narrow on purpose:
47
47
  sitting unverifiable behind the other.
48
48
 
49
49
  Everything else is one Story with `## Slicing` checkpoints. When N>1 does
50
- apply, **every acceptance criterion belongs to exactly one Story** —
51
- `assertAcceptancePartition` refuses a split whose criteria repeat across
52
- siblings, because a verbatim-shared criterion is the signature of coupled work
53
- cut in half rather than genuinely separable work.
50
+ apply, the split has to survive the **same-wave collision refusal**: persist
51
+ runs the dispatcher's own `detectCollision` pairwise over the draft, ahead of
52
+ the first `createIssue`, and refuses any pair of same-wave siblings that both
53
+ declare a path (or one of which declares a covering glob) — naming the pair,
54
+ the paths and the two remedies (merge them, or order them with `depends_on`).
55
+ Such a pair cannot be co-dispatched, so the split buys no parallelism and
56
+ costs a delivery session per Story. It replaced the acceptance partition,
57
+ which refused only byte-identical acceptance text across siblings — a shape
58
+ model output does not produce — so it never fired on the fragmentation it was
59
+ meant to catch. **N=1 can never trip the refusal.**
60
+
61
+ A draft of more than one Story also stops at **Gate #2** for operator
62
+ approval, `--force-review` or not: a split always earns eyes. `--yes`
63
+ auto-proceeds, as at every other gate.
54
64
 
55
65
  ## Unknown triage — AFK vs HITL
56
66
 
@@ -249,8 +259,9 @@ The conflict passes run **twice**: once over the raw `stories.json` payload
249
259
  The second pass is not belt-and-braces. The canonical authoring shape carries
250
260
  `acceptance[]` / `verify[]` at the ticket's top level and assembly folds them
251
261
  into the body, so the passes that scan `body.acceptance` / `body.verify`
252
- (`implicit-cross-story-dep`, `missing-bdd-scaffold`) saw two empty arrays on
253
- the real payload and emitted nothing. Both passes complete before the first
262
+ saw two empty arrays on the real payload and emitted nothing; the two
263
+ substring-match advisories that depended on it are retired, leaving
264
+ `shared-editor` as the one conflict kind. Both passes complete before the first
254
265
  `createIssue`, so a refusal still costs no writes.
255
266
 
256
267
  `shared-editor` findings are rendered into the posted `plan-summary` comment,
@@ -308,7 +319,7 @@ template-only prose.
308
319
  ### Supersede-map partition
309
320
 
310
321
  `plan-persist` refuses a partial supersede map **before** it creates any
311
- Story (mirroring `assertAcceptancePartition`): every id passed to
322
+ Story, the same fail-closed shape as the collision refusal: every id passed to
312
323
  `--tickets` must be claimed by **exactly one** Story, and no Story may
313
324
  claim an id that was not a source ticket. With N>1 the mapping is not
314
325
  total by default — an authored map is the only thing that can say
@@ -11,7 +11,7 @@ description:
11
11
 
12
12
  ## Inputs
13
13
 
14
- Single planning path — there is no Epic/Story router, no scope-triage
14
+ Single planning path — there is no Epic/Story router, no split-triage
15
15
  `epic|story` verdict (Gate #3's container groups, never routes). **Derive the
16
16
  mode from what the operator typed, announce it, act**:
17
17
 
@@ -93,12 +93,11 @@ included. One rescue: a **never-tracked** one normalises to `creates`. Fields:
93
93
  [ref](helpers/plan-reference.md).
94
94
 
95
95
  Artifacts under `temp/plan-<slug>/`: `stories.json` (**length 1 by default**;
96
- a Spec is as long as the work needs, inline, never under `docs/`); optional
97
- `techspec.md` (**N===1 only**, folded into `## Spec`) and
98
- `acceptance-manifest.json` (N>1 — `--plan-acceptance`). Use the envelope
96
+ a Spec is as long as the work needs, inline, never under `docs/`) and optional
97
+ `techspec.md` (**N===1 only**, folded into `## Spec`). Use the envelope
99
98
  `systemPrompts.story`; split only under the policy above, and when you do,
100
- read `systemPrompts.storySplitRules` too — it carries the schedule and
101
- partition rules the core omits. In **tickets mode** also read
99
+ read `systemPrompts.storySplitRules` too — it carries the schedule rules and
100
+ the same-wave collision refusal the core omits. In **tickets mode** also read
102
101
  `systemPrompts.storyTicketsRules`: the source ticket is evidence, not a
103
102
  template — re-derive `acceptance[]` rather than carrying its list, handles
104
103
  and tier suffixes forward.
@@ -112,15 +111,20 @@ The maker-blind **pre-mortem** critic is not a step of this spine: run
112
111
 
113
112
  ### 3. Persist
114
113
 
115
- **Gate #2** — STOP for approval before persist **only** when the operator asked
116
- to review (`--force-review`). Under `--yes`, auto-proceed.
114
+ **Gate #2** — STOP for approval before persist when the draft carries **more
115
+ than one Story** (a split always earns operator eyes, `--force-review` or
116
+ not), or when the operator asked to review (`--force-review`).
117
+ Under `--yes`, auto-proceed.
117
118
 
118
119
  **Gate #3 — adopt, else create.** Offer the top `epicCandidates[]` Epic at
119
120
  **any N** (`--epic <id>`); else, at **N>2**, a new container (`--epic-title` /
120
121
  `--epic-goal`). Never unasked ([ref](helpers/plan-reference.md)).
121
122
 
122
123
  Run persist `--dry-run` **first** — same command, writes suppressed; every gate
123
- runs before the first `createIssue`, and the run **lists its warnings**
124
+ runs before the first `createIssue` — including the **same-wave collision
125
+ refusal**, which rejects an N>1 draft whose siblings declare a common path
126
+ (merge them, or order them with `depends_on`) — and the run **lists its
127
+ warnings**
124
128
  (a `creates` / `refactors-existing` the base branch disagrees with, a goal or
125
129
  acceptance path absent at base, an open question in a body) and the
126
130
  `changes[]` repairs it applied ([list](helpers/plan-reference.md)). Read
@@ -130,7 +134,6 @@ them; they never stop the persist:
130
134
  node .agents/scripts/plan-persist.js \
131
135
  --stories temp/plan-<slug>/stories.json \
132
136
  --plan-dir temp/plan-<slug> \
133
- [--plan-acceptance temp/plan-<slug>/acceptance-manifest.json] \
134
137
  [--tech-spec temp/plan-<slug>/techspec.md] \
135
138
  [--source-tickets 123,456] \
136
139
  [--epic <id> | --epic-title "<name>" --epic-goal "<one paragraph>"]
@@ -158,6 +161,4 @@ also comments on and closes each source id ([ref](helpers/plan-reference.md)).
158
161
  ## See also
159
162
 
160
163
  [`/mandrel-deliver`](mandrel-deliver.md), [`/audit-to-stories`](audit-to-stories.md),
161
- [`helpers/plan-reference.md`](helpers/plan-reference.md) (on-demand detail),
162
- [`core/scope-triage`](../skills/core/scope-triage/SKILL.md) — optional
163
- split-advisory notes only (no routing verdict).
164
+ [`helpers/plan-reference.md`](helpers/plan-reference.md) (on-demand detail).
package/README.md CHANGED
@@ -86,9 +86,9 @@ time to confirm the install is healthy.
86
86
  >
87
87
  > Prefer a surgical alternative? Replace `shamefully-hoist` with a scoped
88
88
  > `public-hoist-pattern[]=` line per package listed in
89
- > `.agents/runtime-deps.json` (`ajv`, `ajv-formats`, `js-yaml`, `minimatch`,
90
- > `picomatch`, `typhonjs-escomplex`). If `mandrel doctor`
91
- > reports `runtime-deps missing: …`, this is the fix.
89
+ > `.agents/runtime-deps.json` — read the file rather than copying a list from
90
+ > here, since the complexity kernel's closure is several packages. If
91
+ > `mandrel doctor` reports `runtime-deps missing: …`, this is the fix.
92
92
 
93
93
  `bootstrap.js` is interactive on a TTY and auto-accepts the
94
94
  owner/repo/base branch/operator handle it can infer from your local
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,14 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.59.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.58.0...mandrel-v2.59.0) (2026-09-14)
19
+
20
+
21
+ ### Added
22
+
23
+ * planning stops fragmenting cohesive work: the acceptance band goes, the dispatcher's own collision predicate becomes the split gate, and the audit seed stops pre-cutting the partition ([#5332](https://github.com/dsj1984/mandrel/issues/5332)) ([#5334](https://github.com/dsj1984/mandrel/issues/5334)) ([e6408e4](https://github.com/dsj1984/mandrel/commit/e6408e44d3f9a7fcbdf8c9a7df4a1b68fdffd88b))
24
+ * replace the complexity kernel's parse and dispatch layers and declare the runtime closure it leaves behind, so the framework's dependency guards describe what actually loads ([#5336](https://github.com/dsj1984/mandrel/issues/5336)) ([#5337](https://github.com/dsj1984/mandrel/issues/5337)) ([821c4f5](https://github.com/dsj1984/mandrel/commit/821c4f55fe3fe9b91b83a4ef9fe9ddee5da9db8f))
25
+
18
26
  ## [2.58.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.57.0...mandrel-v2.58.0) (2026-09-12)
19
27
 
20
28