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.
- package/.agents/README.md +6 -3
- package/.agents/docs/SDLC.md +6 -7
- package/.agents/docs/quality-gates.md +1 -1
- package/.agents/instructions.md +2 -3
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +31 -25
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +28 -24
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +21 -18
- package/.agents/scripts/plan-persist.js +0 -11
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/plan-reference.md +18 -7
- package/.agents/workflows/mandrel-plan.md +14 -13
- package/README.md +3 -3
- package/docs/CHANGELOG.md +8 -0
- package/lib/cli/registry.js +45 -25
- package/package.json +7 -2
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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/
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
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).
|
|
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
|
|
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.**
|
|
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
|
-
*
|
|
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)
|
|
217
|
-
|
|
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
|
-
####
|
|
221
|
+
#### THE COLLISION REFUSAL (persist-enforced at N>1):
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
-
|
|
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
|
|
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-
|
|
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
|
|
156
|
-
authoring step has concrete anchors),
|
|
157
|
-
|
|
158
|
-
**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
|
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,
|
|
51
|
-
`
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
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
|
|
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/`)
|
|
97
|
-
`techspec.md` (**N===1 only**, folded into `## Spec`)
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
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
|
|
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`
|
|
90
|
-
>
|
|
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
|
|