mandrel 2.10.0 → 2.11.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 (36) hide show
  1. package/.agents/docs/configuration.md +35 -33
  2. package/.agents/schemas/agentrc.schema.json +13 -8
  3. package/.agents/scripts/acceptance-eval.js +9 -5
  4. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  5. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  6. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  7. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  8. package/.agents/scripts/lib/config/quality.js +13 -0
  9. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  10. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  11. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  12. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  13. package/.agents/scripts/lib/orchestration/complexity-gate.js +509 -180
  14. package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
  15. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +111 -60
  16. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
  17. package/.agents/scripts/lib/orchestration/resolve-stories.js +11 -7
  18. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  19. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  20. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  21. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  23. package/.agents/scripts/plan-context.js +23 -5
  24. package/.agents/scripts/resolve-stories.js +2 -0
  25. package/.agents/workflows/deliver.md +2 -0
  26. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  27. package/.agents/workflows/helpers/deliver-reference.md +9 -5
  28. package/.agents/workflows/helpers/deliver-story-reference.md +32 -12
  29. package/.agents/workflows/helpers/deliver-story.md +4 -3
  30. package/.agents/workflows/helpers/plan-reference.md +79 -44
  31. package/.agents/workflows/plan.md +11 -10
  32. package/docs/CHANGELOG.md +19 -0
  33. package/lib/cli/registry.js +31 -14
  34. package/lib/migrations/index.js +2 -0
  35. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  36. package/package.json +1 -1
@@ -3,11 +3,12 @@ import { detectCycle } from '../Graph.js';
3
3
  import { gitSpawn } from '../git-utils.js';
4
4
 
5
5
  import { Logger } from '../Logger.js';
6
- import {
7
- parse as parseStoryBody,
8
- StoryBodyParseError,
9
- } from '../story-body/story-body.js';
10
6
  import { validateStoryFileAssumptions } from './file-assumptions.js';
7
+ import { computeSpecBudgetFindings } from './spec-budget.js';
8
+ import {
9
+ assertStoryBodiesParse,
10
+ parseStoryBodyOrThrow,
11
+ } from './story-body-gate.js';
11
12
  import {
12
13
  computeConflictFindings,
13
14
  renderHardConflictError,
@@ -46,63 +47,6 @@ function collectPathsFromText(text, paths) {
46
47
  }
47
48
  }
48
49
 
49
- /**
50
- * Parse a Story's serialized markdown body, translating a
51
- * `StoryBodyParseError` into a `ValidationError` that names the offending
52
- * **section** and **entry** (Story #4541).
53
- *
54
- * `StoryBodyParseError` already carries `field` (the section the parser was
55
- * reading) and `raw` (the entry text that failed); this lifts both into an
56
- * operator-legible message and a structured `violation` payload so an
57
- * authoring loop can point at the exact bullet instead of re-deriving it
58
- * from a downstream freshness miss.
59
- *
60
- * @param {object} story Story whose `body` is a non-empty markdown string.
61
- * @returns {object} The structured body.
62
- * @throws {ValidationError} `code: 'story-body-unparseable'`.
63
- */
64
- function parseStoryBodyOrThrow(story) {
65
- try {
66
- return parseStoryBody(story.body).body;
67
- } catch (err) {
68
- if (!(err instanceof StoryBodyParseError)) throw err;
69
- const slug = story.slug ?? '<unknown>';
70
- const section = err.field ?? 'body';
71
- const entry = err.raw ?? null;
72
- const entryLine = entry === null ? '' : `\n entry: ${entry}`;
73
- const violation = { slug, section, entry, reason: err.message };
74
- const error = new ValidationError(
75
- `Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
76
- `the ## ${section} section could not be read: ${err.message}` +
77
- `${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
78
- 'not a stale path reference.',
79
- { violations: [violation] },
80
- );
81
- error.code = 'story-body-unparseable';
82
- error.violations = [violation];
83
- throw error;
84
- }
85
- }
86
-
87
- /**
88
- * Refuse the plan when any Story's serialized body cannot be parsed, before
89
- * either git-probe gate runs (Story #4541). Ordering matters: the freshness
90
- * gate consults `body.changes` for its net-new whitelist, so an unparseable
91
- * body used to reach the operator as a freshness miss naming declared paths.
92
- *
93
- * @param {{ tickets: object[] }} opts
94
- * @throws {ValidationError} `code: 'story-body-unparseable'` on the first
95
- * offending Story.
96
- */
97
- function assertStoryBodiesParse({ tickets }) {
98
- for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
99
- if (typeof story.body !== 'string' || story.body.trim().length === 0) {
100
- continue;
101
- }
102
- parseStoryBodyOrThrow(story);
103
- }
104
- }
105
-
106
50
  /**
107
51
  * Resolve every acceptance line a Story declares, across both authoring
108
52
  * shapes (Story #4541).
@@ -721,7 +665,19 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
721
665
  stories,
722
666
  policy: opts.conflictPolicy,
723
667
  });
724
- const findings = [...sizingFindings, ...conflictFindings];
668
+ // Advisory `## Spec` word-budget pass (Story #4723) — soft findings only,
669
+ // surfaced as warnings here and via the persist soft-finding channel;
670
+ // never promoted to `errors[]`, so an over-budget Spec cannot fail the
671
+ // persist. Runs after `assertStoryBodiesParse`, so string bodies parse.
672
+ const specBudgetFindings = computeSpecBudgetFindings({ stories });
673
+ for (const finding of specBudgetFindings) {
674
+ Logger.warn(`[ticket-validator] spec-word-budget: ${finding.message}`);
675
+ }
676
+ const findings = [
677
+ ...sizingFindings,
678
+ ...conflictFindings,
679
+ ...specBudgetFindings,
680
+ ];
725
681
  const CONFLICT_KINDS = new Set([
726
682
  'shared-editor',
727
683
  'implicit-cross-story-dep',
@@ -124,7 +124,7 @@ export async function emitPlanContext({
124
124
  // later turn. When it is captured to disk anyway, stdout carries a
125
125
  // compact digest naming the artifact instead of the payload itself.
126
126
  await writeEnvelopeFile(outPath, json);
127
- await writeStoriesTemplateFile(outPath);
127
+ await writeStoriesTemplateFile(outPath, envelope);
128
128
  const resolved = path.resolve(outPath);
129
129
  const digest = {
130
130
  digest: 'plan-context',
@@ -137,7 +137,16 @@ export async function emitPlanContext({
137
137
  bytes: Buffer.byteLength(json, 'utf8'),
138
138
  sourceTickets: (envelope.sourceTickets ?? []).map((t) => t.id),
139
139
  duplicates: (envelope.duplicates ?? []).length,
140
- complexityRoute: envelope.complexityRoute?.route ?? null,
140
+ // Advisory only (Story #4722): signals, no route the planner owns
141
+ // the trivial-vs-standard verdict and persist validates it by shape.
142
+ complexitySignals: envelope.complexitySignals
143
+ ? {
144
+ artifactCount: envelope.complexitySignals.artifactCount,
145
+ riskHeuristicHits: envelope.complexitySignals.riskHeuristicHits,
146
+ sensitivePathClasses:
147
+ envelope.complexitySignals.sensitivePathClasses,
148
+ }
149
+ : null,
141
150
  };
142
151
  stdout.write(`${JSON.stringify(digest)}\n`);
143
152
  } else {
@@ -177,18 +186,27 @@ async function writeEnvelopeFile(outPath, json) {
177
186
  * requires reading `story-body.js` source. Written whenever `--out` is
178
187
  * passed, and throwing on failure for the same reason the envelope write
179
188
  * does: a silently missing template re-opens the format-discovery loop it
180
- * exists to close.
189
+ * exists to close. The envelope's advisory `complexitySignals` are threaded
190
+ * through so the skeleton's `changes[]` arrive pre-resolved to
191
+ * creates-vs-refactors against the repo snapshot (Story #4723).
181
192
  *
182
193
  * @param {string} outPath The envelope `--out` path; the template lands in
183
194
  * the same directory as {@link STORIES_TEMPLATE_FILENAME}.
195
+ * @param {object} [envelope] The emitted plan-context envelope.
184
196
  */
185
- async function writeStoriesTemplateFile(outPath) {
197
+ async function writeStoriesTemplateFile(outPath, envelope = {}) {
186
198
  const resolved = path.resolve(
187
199
  path.dirname(path.resolve(outPath)),
188
200
  STORIES_TEMPLATE_FILENAME,
189
201
  );
190
202
  try {
191
- await writeFile(resolved, renderStoriesTemplate(), 'utf8');
203
+ await writeFile(
204
+ resolved,
205
+ renderStoriesTemplate({
206
+ complexitySignals: envelope?.complexitySignals ?? null,
207
+ }),
208
+ 'utf8',
209
+ );
192
210
  } catch (err) {
193
211
  throw new Error(
194
212
  `[plan-context] cannot write stories template to ${resolved}: ${err.message}`,
@@ -209,6 +209,7 @@ async function main() {
209
209
  stories,
210
210
  nativeEdges,
211
211
  warn: (m) => Logger.warn(m),
212
+ config,
212
213
  });
213
214
  const foreignDone = await resolveForeignDone({
214
215
  provider,
@@ -220,6 +221,7 @@ async function main() {
220
221
  nativeEdges,
221
222
  foreignDone,
222
223
  warn: () => {},
224
+ config,
223
225
  });
224
226
 
225
227
  process.stdout.write(
@@ -25,6 +25,8 @@ blocker resolved against its real issue state). You never hand it a graph, and
25
25
  there is no batch label — which is what lets you deliver Stories **across plan
26
26
  runs and over time**. The `plan-run::<id>` grouping label is filter metadata
27
27
  only — never a resolution input (there is no `--run` or `--dep` axis).
28
+ Per-Story routes are **body-derived** too (#4722); `route::lite` is a hint
29
+ only.
28
30
 
29
31
  ## Inputs
30
32
 
@@ -30,10 +30,19 @@ mid-delivery, and evaluates the actual work product.
30
30
 
31
31
  ## Per round
32
32
 
33
- 1. **Eval pass (fresh context, independent of the author).** Run a **separate
34
- critic pass** a fresh-context sub-agent (`Agent` tool), *not* a
35
- continuation of your implementing turn so the evaluator does not grade its
36
- own homework.
33
+ 1. **Eval pass one verdict-owner per cluster (Story #4723).** Exactly
34
+ **one** pass authors each cluster's verdict: the **fresh-context critic**
35
+ when the ceremony routing below resolves `fresh` (a sub-agent via the
36
+ `Agent` tool, *not* a continuation of your implementing turn — the
37
+ evaluator does not grade its own homework), or the **inline self-eval**
38
+ when it resolves `inline`. The resolved decision names the owner
39
+ explicitly (`verdictOwner: 'fresh-critic' | 'inline-self-eval'` from
40
+ `resolveCeremonyForRisk`). **Never run both**, and never run a
41
+ preliminary self-assessment pass before dispatching the fresh critic —
42
+ the redundant pre-pass buys no measurable quality and roughly triples
43
+ the acceptance-block cost. Step 2's gate is the deterministic **scorer**
44
+ of the one authored verdict, not a second (or third) pass over the
45
+ criteria.
37
46
 
38
47
  > **Sub-agent type + derived-level ceremony (Epic #4478, M7-B).** When
39
48
  > `delivery.routing.roleScopedAgents` is enabled (the **default**), dispatch
@@ -129,7 +138,9 @@ mid-delivery, and evaluates the actual work product.
129
138
  one `{ index, criterion, verdict: met|partial|unmet, evidence,
130
139
  verifyEvidence[] }` record per acceptance item.
131
140
  2. **Decide.** Run the gate against the verdict (the caller's Step 1a names the
132
- exact invocation — omit `--epic`):
141
+ exact invocation — omit `--epic`). The gate **scores the single verdict
142
+ the round's owner authored** — schema validation, round cap, decision —
143
+ and never re-scores the criteria itself (Story #4723):
133
144
 
134
145
  ```bash
135
146
  node <main-repo>/.agents/scripts/acceptance-eval.js \
@@ -51,19 +51,23 @@ probe logs a warning and leans on init's lease refusal alone.
51
51
 
52
52
  ## Dispatch mechanics (role-scoped by default)
53
53
 
54
- **Lite-routed Stories execute inline (Story #4707).** Before spawning anything,
54
+ **Lite-shaped Stories execute inline (Story #4722).** Before spawning anything,
55
55
  read the Story's `dispatchMode` from the resolver envelope
56
56
  (`stories[].dispatchMode`, derived by `resolveStoryDispatchMode` in
57
- `lib/orchestration/complexity-gate.js` from the persisted `route::lite`
58
- marker): a Story with `dispatchMode: "inline"` executes
57
+ `lib/orchestration/complexity-gate.js` **from the fetched Story body's own
58
+ shape** `changes[]` count, acceptance count, creates-vs-refactors mix, and
59
+ sensitive-path classes; the `route::lite` label is a human-visible hint only,
60
+ never the control signal, so a lost or never-written label cannot misroute
61
+ delivery): a Story with `dispatchMode: "inline"` executes
59
62
  [`deliver-story.md`](deliver-story.md) **inline in this session** — no
60
63
  `story-worker` sub-agent boot and no fresh acceptance-critic sub-agents
61
64
  (sub-agent boots are the dominant deliver-phase token cost at trivial scope) —
62
65
  threading the same `docsDigestPath` / `checklistPath` / change-set discipline
63
66
  as a spawned worker. Inline removes model-side fan-out only: every
64
67
  `single-story-close.js` gate, the PR to `main`, and the terminal envelope are
65
- identical. A Story without the marker (or with unreadable labels) takes the
66
- standard sub-agent path.
68
+ identical. Everything else a full-shaped body, a missing/unparseable body,
69
+ or a footprint intersecting a sensitive-path class (sensitivity wins and
70
+ keeps the fresh acceptance critic) — takes the standard sub-agent path.
67
71
 
68
72
  **Dispatch each `ready` Story (role-scoped by default).** When
69
73
  `delivery.routing.roleScopedAgents` is enabled (the **default**) and the host
@@ -125,8 +125,7 @@ The v2 engine's trait table:
125
125
  | Spec / slices | Folded `## Spec` + optional `## Slicing` checkpoints in-session |
126
126
  | Ceremony | Per-Story, routed off the derived change level via `ceremony-routing.js` |
127
127
 
128
- **Ceremony-lite Stories still land through this engine unchanged (Story #4683).** A Story that `/plan` routed onto the ceremony-lite path (its
129
- `complexityRoute.route === "lite"`) collapses only the *advisory* plan/deliver
128
+ **Ceremony-lite Stories still land through this engine unchanged (Story #4683).** A lite-routed Story collapses only the *advisory* plan/deliver
130
129
  ceremony — the fresh-critic / Tech-Spec authoring a one-artifact scope does
131
130
  not earn. It does **not** get a cheaper landing: the close-validation gates
132
131
  (lint / test / format / coverage / CRAP / maintainability), the PR to `main`,
@@ -134,15 +133,22 @@ and the `rules/security-baseline.md` MUSTs all run exactly as for a
134
133
  full-ceremony Story. The lite route's `preserves` field is the machine-readable
135
134
  record of those non-negotiables; there is no lite-specific gate bypass.
136
135
 
137
- **The lite route persists to delivery as the `route::lite` label (Story #4707).** Persist stamps every Story of a lite-routed plan with that marker
138
- (and ledgers the route including any audited planner-downgrade reason on
139
- its `story-plan-state` checkpoint); a full-routed Story carries no marker.
140
- `/deliver` reads the label via `resolveStoryDispatchMode`
141
- (`lib/orchestration/complexity-gate.js`) and executes a lite Story
142
- **inline in the deliver session** no `story-worker` sub-agent boot, and
143
- the Step 1a acceptance self-eval runs its critics **inline** (no
136
+ **Deliver derives the route from the Story body's shape (Story #4722).**
137
+ Persist stamps a lite cohort's Stories with the `route::lite` label as a
138
+ *human-visible hint only* (and ledgers the authored verdict — recorded
139
+ reason plus per-Story shape evidence — on the `story-plan-state`
140
+ checkpoint); the label is never the control signal. `/deliver` computes the
141
+ route from the fetched Story body via `resolveStoryDispatchMode`
142
+ (`lib/orchestration/complexity-gate.js`) the same shape taxonomy
143
+ `deriveChangeLevel` applies to the landed diff at close: `changes[]` count,
144
+ acceptance count, creates-vs-refactors mix, sensitive-path classes. A
145
+ lite-shaped Story executes **inline in the deliver session** — even when the
146
+ label is absent or its write failed — with no `story-worker` sub-agent boot,
147
+ and the Step 1a acceptance self-eval runs its critics **inline** (no
144
148
  fresh-context acceptance-critic sub-agent dispatch; sub-agent boots are the
145
- dominant deliver-phase token cost at trivial scope). Inline execution
149
+ dominant deliver-phase token cost at trivial scope). A footprint
150
+ intersecting a sensitive-path class derives `full` — sensitivity wins, and
151
+ the Story keeps its fresh acceptance critic. Inline execution
146
152
  changes the isolation only: the engine, every script gate, and the
147
153
  terminal envelope are byte-identical either way.
148
154
 
@@ -175,6 +181,20 @@ directly.
175
181
 
176
182
  ### Step 1a — self-eval mechanics
177
183
 
184
+ **One verdict-owner per cluster (Story #4723).** The ceremony routing's
185
+ resolved decision names each cluster's single verdict owner
186
+ (`verdictOwner: 'fresh-critic' | 'inline-self-eval'` from
187
+ `resolveCeremonyForRisk`): the fresh maker-blind critic when sensitivity
188
+ routes the cluster `fresh`, the contract-identical inline self-eval when it
189
+ routes `inline`. Exactly one pass authors the verdict — never both, and
190
+ never a preliminary self-assessment pass before dispatching the fresh
191
+ critic (the redundant pre-pass buys no measurable quality and roughly
192
+ triples the acceptance-block cost). `acceptance-eval.js` is the
193
+ deterministic **scorer** of that one authored verdict — schema validation,
194
+ round cap, proceed / redraft / block — not an independent additional pass
195
+ over the criteria. The M4-B floor holds: one verdict per cluster, the
196
+ cluster count owned by `acceptance-clusters.js` alone.
197
+
178
198
  **Critic evidence-share (Story #4250).** When the critic runs a `verify[]`
179
199
  command that is byte-identical to a close gate (`lint` / `typecheck`), it
180
200
  records the pass into the Story evidence keyspace via `--standalone` so
@@ -227,8 +247,8 @@ Resolve fresh-vs-inline acceptance critics per AC-cluster with
227
247
  floor forces `fresh`). Review depth reads the same derived level via
228
248
  `review-depth.js` inside close, so the two decisions cannot disagree.
229
249
 
230
- **Lite-route override (Story #4707).** When the Story carries the
231
- `route::lite` marker (`resolveStoryDispatchMode` → `inline`), run every
250
+ **Lite-route override (Story #4722).** When the Story's body derives the
251
+ lite shape (`resolveStoryDispatchMode` → `inline`), run every
232
252
  acceptance critic **inline** — do not spawn fresh-context critic sub-agents
233
253
  regardless of what the profile would otherwise resolve. The self-eval rigor
234
254
  (scoring each `acceptance[]` item against the one computed change set, with
@@ -14,8 +14,9 @@ description:
14
14
 
15
15
  ## Overview
16
16
 
17
- The **one** delivery engine in v2 — every Story (`route::lite` runs
18
- inline with inline critics; engine, gates, envelope byte-identical):
17
+ The **one** delivery engine in v2 — every Story (a lite-**shaped** Story
18
+ runs inline with inline critics, #4722; engine, gates, envelope
19
+ byte-identical):
19
20
 
20
21
  ```text
21
22
  single-story-init.js → implement + commits → derived-level ceremony
@@ -74,7 +75,7 @@ One branch, one PR to `main`, commits against the inline `acceptance[]` /
74
75
  ### Step 1a — Bounded acceptance self-eval loop (**required**)
75
76
 
76
77
  Follow the single-homed include
77
- [`acceptance-self-eval.md`](acceptance-self-eval.md) (fresh-context critic,
78
+ [`acceptance-self-eval.md`](acceptance-self-eval.md) (single verdict-owner,
78
79
  `verify[]`-as-evidence, proceed / redraft / block). Gate invocation (omit
79
80
  `--epic`):
80
81
 
@@ -1,52 +1,87 @@
1
1
  # /plan — on-demand reference appendix
2
2
 
3
3
  > **Applies when:** you are executing [`/plan`](../plan.md) and hit one of the
4
- > situations below — the ceremony-lite route, `--tickets` supersede authoring,
5
- > critic dispatch detail, a failed persist, or source-id resolution. The spine
6
- > stays resident; this file is read on demand.
7
-
8
- ## Ceremony-lite complexity gate (`complexityRoute`)
9
-
10
- The envelope's `complexityRoute` field is a **deterministic, conservative**
11
- plan-time gate (Story #4683) that routes a genuinely trivial single-artifact
12
- seed onto a collapsed path so it stops paying the full two-session
13
- plan/deliver ceremony that measurably buys no quality at that size:
14
-
15
- - **`route: "lite"`** a trivial scope (seed ≤ `maxSeedWords` words **and** ≤
16
- `maxArtifacts` enumerated items). Collapse the ceremony: author **one minimal
17
- Story** and skip the fresh-critic / Tech-Spec ceremony a one-artifact scope
18
- does not earn. The lite route is **not** licence to drop a non-negotiable —
19
- its `preserves` field enumerates exactly what still holds: the Story ticket,
20
- the PR-to-`main` landing, every repo quality gate, and the security baseline.
21
- Those gates still run in `single-story-close.js` regardless of route.
22
- - **`route: "full"`** everything else. The gate fails toward `full` on any
23
- doubt (empty seed, over the word ceiling, a multi-capability enumeration, or
24
- the gate disabled via `planning.complexityGate.enabled=false`), so a real
25
- capability slice never loses ceremony. Author normally under the split policy.
26
-
27
- **Planner downgrade (audited, Story #4707).** Seed word count is a poor
28
- complexity proxy, so a `full` verdict you judge genuinely trivial (one
29
- artifact, one obvious change) may be downgraded to `lite` but **only** by
30
- passing `--route-downgrade-reason "<why>"` to persist. The reason is recorded
31
- on every created Story's `story-plan-state` checkpoint, making the judgment
32
- auditable; without a recorded reason the deterministic verdict stands, and the
33
- gate itself is unchanged (it still fails toward `full`).
34
-
35
- **The route persists with the Story (Story #4707).** Persist labels every
36
- Story of a lite-routed plan with the **`route::lite`** marker and ledgers the
37
- route (including any downgrade reason) on its `story-plan-state` checkpoint;
38
- a full-routed Story carries no marker. `/deliver` reads the marker to execute
39
- a lite Story **inline** — no story-worker or acceptance-critic sub-agent
40
- boots while every `single-story-close.js` gate runs unchanged. The
41
- `route::*` axis is runtime-derived: hand-authored `route::*` entries in
42
- `labels[]` are dropped by persist.
43
-
44
- The threshold and its override knob (`planning.complexityGate.{enabled,
45
- maxSeedWords, maxArtifacts}`) are documented in
46
- [`.agents/docs/configuration.md`](../../docs/configuration.md) under
47
- `### planning`; the defaults live on `DEFAULT_COMPLEXITY_GATE` in
4
+ > situations below — shape-derived complexity routing, `--tickets` supersede
5
+ > authoring, critic dispatch detail, a failed persist, or source-id
6
+ > resolution. The spine stays resident; this file is read on demand.
7
+
8
+ ## Shape-derived complexity routing (`complexitySignals`)
9
+
10
+ Complexity routes on the **objective shape of the authored work**, never on
11
+ seed word count (Story #4722 a detailed prompt can describe trivial work, a
12
+ terse one complex work; `maxSeedWords` is removed). The pipeline stages the
13
+ decision:
14
+
15
+ - **Signals, not routing.** The envelope's `complexitySignals` field is
16
+ advisory only (`routingAuthority: false`): enumerated-artifact count (with
17
+ the configured `maxArtifacts` threshold beside it as one input),
18
+ `planning.riskHeuristics` phrases present in the seed, the repo state of
19
+ predicted paths (existing paths predict refactors; missing predict
20
+ creates), and the `audit-rules.json` sensitive-path classes the predicted
21
+ footprint intersects.
22
+ - **You author the verdict.** Judge the signals: a genuinely trivial scope
23
+ (small additive footprint, no risk hits, no sensitive class) earns a `lite`
24
+ claim via `plan-persist.js --route-downgrade-reason "<why>"`. The reason is
25
+ recorded on every created Story's `story-plan-state` checkpoint, making the
26
+ judgment auditable; without a recorded reason the conservative default
27
+ (`full`) stands.
28
+ - **Persist backstops the claim deterministically.** After authoring, the
29
+ work has measurable shape, so persist validates the `lite` claim against
30
+ each Story's own shape `changes[]` count, acceptance-criteria count,
31
+ creates-vs-refactors mix, glob-free footprint, and sensitive-path classes,
32
+ against the framework `STORY_SHAPE_CEILINGS` and **fails closed to
33
+ `full`** when any Story exceeds them (the refusal is ledgered on the
34
+ checkpoint too). The lite route is **not** licence to drop a
35
+ non-negotiable every decision's `preserves` field enumerates what still
36
+ holds: the Story ticket, the PR-to-`main` landing, every repo quality gate,
37
+ and the security baseline. Those gates run in `single-story-close.js`
38
+ regardless of route.
39
+
40
+ **The label is a hint; deliver re-derives (Story #4722).** Persist labels a
41
+ lite cohort's Stories with **`route::lite`** as a *human-visible hint only*
42
+ `/deliver` computes the route from each fetched Story body via the same shape
43
+ function at dispatch, so neither a lost label nor an unread marker can
44
+ misroute delivery: a lite-shaped Story executes **inline** (no story-worker
45
+ or acceptance-critic sub-agent boots) even with the label absent, and a
46
+ sensitive-footprint Story routes `full` and keeps its fresh critic even with
47
+ the label present. The `route::*` axis stays runtime-derived: hand-authored
48
+ `route::*` entries in `labels[]` are dropped by persist.
49
+
50
+ The knobs (`planning.complexityGate.{enabled, maxArtifacts}`) are documented
51
+ in [`.agents/docs/configuration.md`](../../docs/configuration.md) under
52
+ `### planning`; the defaults live on `DEFAULT_COMPLEXITY_GATE` and the shape
53
+ ceilings on `STORY_SHAPE_CEILINGS` in
48
54
  [`lib/orchestration/complexity-gate.js`](../../scripts/lib/orchestration/complexity-gate.js).
49
55
 
56
+ ## Correct-by-construction authoring template (Story #4723)
57
+
58
+ `plan-context.js --out` writes `stories.template.json` as a
59
+ **correct-by-construction** skeleton, built from the same repo snapshot the
60
+ `complexitySignals` probed:
61
+
62
+ - **`verify[]` placeholders already end with a valid `(tier)` tag.** Keep
63
+ every filled entry's trailing tag one of `(unit)` / `(contract)` /
64
+ `(e2e)` / `(validate)` (or use the `manual:<reason>` escape) — a tierless
65
+ entry is exactly the mechanical persist round-trip the template exists to
66
+ prevent.
67
+ - **`changes[]` arrive pre-resolved to creates-vs-refactors.** Every path
68
+ the seed predicted is probed against the repo: an existing path is
69
+ emitted with `assumption: "refactors-existing"`, a missing one with
70
+ `assumption: "creates"`. Trust the pre-resolved assumption — verify
71
+ against the repo before overriding one (authoring `creates` for a file
72
+ that exists at base is a validator rejection). The persist gates stay
73
+ authoritative: they probe the base branch ref, not the working tree.
74
+ - **Keep `## Spec` near contract-level prose.** Persist emits an
75
+ **advisory** warning past ~250 words (`SPEC_SOFT_WORD_BUDGET`) — it never
76
+ fails the persist, but it is the nudge toward the #4707 contract-level
77
+ Spec (interfaces, invariants, load-bearing constraints; no per-file
78
+ behavior narration). The hard fail-closed ceiling (~1500 tokens,
79
+ `spec-spill.js`) is unchanged.
80
+
81
+ A faithfully-filled skeleton — placeholders replaced, pre-resolved entries
82
+ kept, tags valid — passes the persist ticket validators with no
83
+ round-trip.
84
+
50
85
  ## Tickets mode — authoring `supersedes[]`
51
86
 
52
87
  In `--tickets` mode each Story carries a top-level `supersedes` array claiming
@@ -32,7 +32,7 @@ Epic/Story router, no scope-triage `epic|story` verdict:
32
32
  | `--tickets <ids>` | Issue ids to analyze; closed as superseded at persist. |
33
33
  | `--no-close-superseded` | Keep the source issues open — no supersede comment, no close. |
34
34
  | `--force-review` | STOP at gate #2 for operator review — the only review gate (Story #4542). |
35
- | `--route-downgrade-reason "<text>"` | Audited `full`→`lite` downgrade (Story #4707), ledgered per Story. |
35
+ | `--route-downgrade-reason "<text>"` | Authored `lite` verdict + reason (Story #4722), ledgered per Story; shape-validated, fails closed to `full`. |
36
36
  | `--allow-over-budget` | Permit a plan exceeding `maxTickets`. |
37
37
  | `--yes` | Non-interactive: auto-proceed gate #1 and gate #2 HITL waits. |
38
38
  | `--dry-run` | Author + validate without GitHub writes; run as a pre-pass. |
@@ -66,11 +66,11 @@ authoring skeleton step 2 starts from.
66
66
 
67
67
  The envelope carries docs context, codebase snapshot, the story-author
68
68
  prompt, `sourceTickets[]`, `duplicates[]` (open **Stories** overlapping the
69
- seed — never Epics), and the `complexityRoute` signal:
70
- `"lite"` (trivial single-artifact scope author one minimal Story, skip
71
- fresh-critic / Tech-Spec ceremony; every close gate still runs) or `"full"`
72
- (everything else; fails toward `full` on any doubt). Detail:
73
- [`helpers/plan-reference.md` § Ceremony-lite gate](helpers/plan-reference.md).
69
+ seed — never Epics), and advisory `complexitySignals` (**no routing
70
+ authority**, Story #4722). A genuinely trivial scope earns
71
+ `--route-downgrade-reason "<why>"` at persist shape-validated, failing
72
+ closed to `full`. Detail:
73
+ [`helpers/plan-reference.md` § Shape-derived routing](helpers/plan-reference.md).
74
74
  Under `--yes`, do not ask free-form operator questions — unresolved
75
75
  unknowns land in Key Assumptions.
76
76
 
@@ -79,8 +79,9 @@ duplicate-candidate review. Under `--yes`, auto-proceed.
79
79
 
80
80
  ### 2. Author
81
81
 
82
- **One-shot authoring (Story #4707).** Start from `stories.template.json`
83
- (or the skeleton below); author `stories.json` in one pass. `body` is a
82
+ **One-shot authoring (Story #4707).** Start from `stories.template.json`;
83
+ author `stories.json` in one pass. Entries are pre-resolved (#4723); keep
84
+ tiers/assumptions valid. `body` is a
84
85
  markdown string **or** a structured object; persist parses either,
85
86
  serializes the canonical markdown, and syncs the top-level `acceptance[]` /
86
87
  `verify[]` into the body — never dual-author those lists.
@@ -182,7 +183,7 @@ persist, re-run the same command; never hand-delete issues.
182
183
 
183
184
  - [`/deliver`](deliver.md) — delivery entry point.
184
185
  - [`/audit-to-stories`](audit-to-stories.md) — audit findings → plan seed.
185
- - [`helpers/plan-reference.md`](helpers/plan-reference.md) — ceremony-lite,
186
- supersede, critic, and persist-resume detail.
186
+ - [`helpers/plan-reference.md`](helpers/plan-reference.md) — on-demand
187
+ detail.
187
188
  - [`core/scope-triage`](../skills/core/scope-triage/SKILL.md) — optional
188
189
  split-advisory notes only (no routing verdict).
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.11.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.10.0...mandrel-v2.11.0) (2026-07-23)
6
+
7
+
8
+ ### Added
9
+
10
+ * **migration:** strip retired planning.complexityGate.maxSeedWords on consumer upgrade ([#4729](https://github.com/dsj1984/mandrel/issues/4729)) ([fd75b54](https://github.com/dsj1984/mandrel/commit/fd75b54b0f7390a3d562a25e90cbe6c5911b4f66))
11
+ * route on the work, not the words — shape-derived complexity routing honored end-to-end (refs [#4722](https://github.com/dsj1984/mandrel/issues/4722)) ([#4725](https://github.com/dsj1984/mandrel/issues/4725)) ([f166ca1](https://github.com/dsj1984/mandrel/commit/f166ca14785bad97657fdda0f41e282e47db2ea3))
12
+ * single-owner acceptance verification + plan-template hardening ([#4723](https://github.com/dsj1984/mandrel/issues/4723)) ([#4728](https://github.com/dsj1984/mandrel/issues/4728)) ([954f55a](https://github.com/dsj1984/mandrel/commit/954f55aedda3199d1fdd48b11884a9513012e2c4))
13
+
14
+
15
+ ### Fixed
16
+
17
+ * **doctor:** include .agents/local/workflows/ in the commands-in-sync expected set (refs [#4724](https://github.com/dsj1984/mandrel/issues/4724)) ([#4726](https://github.com/dsj1984/mandrel/issues/4726)) ([b96dcd4](https://github.com/dsj1984/mandrel/commit/b96dcd446879a9da28e8d86c1a493c2bae62d2c8))
18
+
19
+
20
+ ### Changed
21
+
22
+ * **orchestration:** extract spec-budget and story-body parse gate from ticket-validator ([#4730](https://github.com/dsj1984/mandrel/issues/4730)) ([fb0c3c3](https://github.com/dsj1984/mandrel/commit/fb0c3c31795f6178b2ba740b2fb00760921e1178))
23
+
5
24
  ## [2.10.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.9.0...mandrel-v2.10.0) (2026-07-23)
6
25
 
7
26
 
@@ -225,10 +225,20 @@ function runGhAuth({ runner = spawn, env = process.env } = {}) {
225
225
  // ---------------------------------------------------------------------------
226
226
 
227
227
  /**
228
- * Dry-run the sync-claude-commands logic: compare `.agents/workflows/*.md`
229
- * sources to the generated flat command tree `.claude/commands/*.md`
230
- * destinations and report parity (the projection is a flat `/<name>` command
231
- * surface; the #3576 plugin projection was reverted).
228
+ * Dry-run the sync-claude-commands logic: compare the union of the two
229
+ * projection sources `.agents/workflows/*.md` (the installed payload) and
230
+ * `.agents/local/workflows/*.md` (consumer-authored, prune-exempt, projected
231
+ * since 1.75.0 / #4244) to the generated flat command tree
232
+ * `.claude/commands/*.md` destinations and report parity (the projection is
233
+ * a flat `/<name>` command surface; the #3576 plugin projection was
234
+ * reverted). A dest command backed only by a local workflow is in sync, not
235
+ * stale (#4721).
236
+ *
237
+ * Union semantics mirror the sync script's payload-wins shadowing (`byRel`):
238
+ * a basename projects iff at least one source's copy is non-excluded — an
239
+ * excluded payload copy never enters `byRel`, so it does not shadow a
240
+ * projectable local copy. Filtering each source dir independently and
241
+ * unioning the surviving basenames reproduces that exactly.
232
242
  *
233
243
  * Resolution anchor (Story #3588): the root defaults to `process.cwd()` —
234
244
  * the consumer project directory where `mandrel sync` materializes both
@@ -272,28 +282,35 @@ function runCommandsInSync({ projectRoot, cwd, readDir, readFile } = {}) {
272
282
  }
273
283
  });
274
284
 
275
- const srcDir = path.join(root, '.agents', 'workflows');
285
+ const srcDirs = [
286
+ path.join(root, '.agents', 'workflows'),
287
+ path.join(root, '.agents', 'local', 'workflows'),
288
+ ];
276
289
  const destDir = path.join(root, '.claude', 'commands');
277
290
 
278
291
  // Only top-level .md files are synced (helpers/ subdirectory excluded by
279
292
  // the sync script — they are path-included modules, not slash commands).
280
293
  // Workflows whose frontmatter carries `command: false` (#4482) opt out of
281
- // projection and must not count toward the expected command set.
282
- const sources = listDir(srcDir)
283
- .filter((f) => !f.startsWith('.'))
284
- .filter((f) => {
294
+ // projection and must not count toward the expected command set. An absent
295
+ // local dir degrades to [] via the listDir catch, so payload-only
296
+ // consumers are unchanged.
297
+ const expected = new Set();
298
+ for (const srcDir of srcDirs) {
299
+ for (const f of listDir(srcDir)) {
300
+ if (f.startsWith('.')) continue;
285
301
  const content = readSource(path.join(srcDir, f));
286
- return content == null || !isCommandExcluded(content);
287
- })
288
- .sort();
302
+ if (content != null && isCommandExcluded(content)) continue;
303
+ expected.add(f);
304
+ }
305
+ }
306
+ const sources = [...expected].sort();
289
307
  const dests = listDir(destDir)
290
308
  .filter((f) => !f.startsWith('.'))
291
309
  .sort();
292
310
 
293
- const srcSet = new Set(sources);
294
311
  const dstSet = new Set(dests);
295
312
  const missing = sources.filter((f) => !dstSet.has(f));
296
- const extra = dests.filter((f) => !srcSet.has(f));
313
+ const extra = dests.filter((f) => !expected.has(f));
297
314
 
298
315
  if (missing.length === 0 && extra.length === 0) {
299
316
  return { ok: true, detail: `${sources.length} commands up to date` };