mandrel 2.10.0 → 2.12.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/docs/configuration.md +35 -33
  2. package/.agents/rules/orchestration-error-handling.md +9 -1
  3. package/.agents/schemas/agentrc.schema.json +13 -8
  4. package/.agents/scripts/acceptance-eval.js +9 -5
  5. package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
  6. package/.agents/scripts/lib/audit-suite/selector.js +1 -26
  7. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  8. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  9. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  10. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  11. package/.agents/scripts/lib/config/quality.js +13 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  13. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  14. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  15. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +561 -184
  17. package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
  18. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +117 -60
  19. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +28 -7
  21. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  22. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
  23. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
  24. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  25. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  26. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  27. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  28. package/.agents/scripts/plan-context.js +23 -5
  29. package/.agents/scripts/resolve-stories.js +2 -0
  30. package/.agents/workflows/deliver.md +28 -28
  31. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  32. package/.agents/workflows/helpers/deliver-digest.md +126 -0
  33. package/.agents/workflows/helpers/deliver-reference.md +30 -5
  34. package/.agents/workflows/helpers/deliver-story-reference.md +38 -12
  35. package/.agents/workflows/helpers/deliver-story.md +34 -37
  36. package/.agents/workflows/helpers/plan-reference.md +79 -44
  37. package/.agents/workflows/plan.md +11 -10
  38. package/docs/CHANGELOG.md +31 -0
  39. package/lib/cli/registry.js +31 -14
  40. package/lib/migrations/index.js +2 -0
  41. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  42. package/package.json +1 -1
@@ -1,99 +1,141 @@
1
1
  /**
2
- * lib/orchestration/complexity-gate.js — plan-time ceremony-lite routing gate.
2
+ * lib/orchestration/complexity-gate.js — shape-derived complexity routing
3
+ * (Story #4722, superseding the word-count gate of Stories #4683/#4707).
3
4
  *
4
- * A **deterministic, conservative** complexity gate that routes a planning seed
5
- * onto either the full two-session plan/deliver ceremony (`full`) or a collapsed
6
- * ceremony-lite path (`lite`). It exists because the full ceremony imposes a
7
- * large fixed cost premium on genuinely trivial single-artifact scopes with no
8
- * measured quality gain (Story #4683): the bench cohort spent ~52 turns on a
9
- * hello-world scope a bare control delivered in ~6, and no path existed to opt
10
- * trivial scopes out.
5
+ * ## Route on the work, not the words
6
+ *
7
+ * The original gate routed a planning seed on its **word count**
8
+ * (`maxSeedWords`), which is the wrong proxy in both directions: a detailed
9
+ * prompt can describe trivial work, a terse one complex work. The bench
10
+ * cohort (mandrel-bench 2.10.0) observed both failure modes a lite verdict
11
+ * fired at plan time and was then lost (a swallowed label write) or ignored
12
+ * (deliver spawned a full story-worker anyway). This module now routes on the
13
+ * **objective shape of the authored work**, staged across the pipeline:
14
+ *
15
+ * 1. **Plan time — signals, not routing.** {@link buildComplexitySignals}
16
+ * emits advisory complexity *signals* (enumerated-artifact count,
17
+ * risk-heuristic hits, repo state of predicted paths, sensitive-path
18
+ * classes) carrying **no routing authority**. There is no word ceiling.
19
+ * 2. **Planner judgment, ledgered.** The planner owns the
20
+ * trivial-vs-standard verdict ({@link resolvePlannerRouteVerdict}) —
21
+ * `lite` only with a recorded reason, persisted on plan state. This
22
+ * generalizes the former one-way `applyPlannerDowngrade` seam into the
23
+ * authored verdict itself; the conservative default without a recorded
24
+ * reason is `full`.
25
+ * 3. **Deterministic backstop at persist.** After authoring, the work has
26
+ * measurable shape: {@link deriveStoryShape} reads the Story's own
27
+ * `changes[]` count, acceptance-criteria count, creates-vs-refactors
28
+ * mix, and sensitive-path classes against {@link STORY_SHAPE_CEILINGS}.
29
+ * A `lite` claim whose shape exceeds the ceilings **fails closed to
30
+ * `full`** (`run-plan-persist.js`).
31
+ * 4. **Deliver re-derives.** `/deliver` computes the route from the fetched
32
+ * Story body via the **same** shape function at dispatch
33
+ * ({@link resolveStoryDispatchMode}) and honors it: a lite-shaped Story
34
+ * executes inline — no story-worker sub-agent boot, no fresh
35
+ * acceptance-critic dispatch — while every `single-story-close.js` gate
36
+ * runs unchanged. The `route::lite` label is a **human-visible hint
37
+ * only**, never the control signal: a lost label or an unread marker can
38
+ * no longer misroute delivery. Ahead of the shape read sits one
39
+ * shape-independent rule (Story #4736): a **single-Story run** is inline
40
+ * whatever its shape, because sub-agent isolation buys nothing when
41
+ * there is no concurrent sibling to isolate from.
42
+ *
43
+ * The shape taxonomy is deliberately the one `review-depth.js` already
44
+ * applies to the landed diff at close (`deriveChangeLevel` over the
45
+ * `audit-rules.json` sensitive-path classes): **predicted shape at dispatch,
46
+ * actual diff at close** — one taxonomy, two read points. And sensitivity
47
+ * always wins: a small change whose footprint intersects a sensitive-path
48
+ * class routes `full`, which keeps its fresh acceptance critic
49
+ * (`ceremony-routing.js` routes a high derived level to a fresh spawn).
11
50
  *
12
51
  * ## What "lite" changes and — critically — what it never changes
13
52
  *
14
- * The lite route collapses the **advisory ceremony** only: the plan/deliver
15
- * session split, the fresh-context critic ceremony, and the Tech-Spec authoring
16
- * that a one-artifact scope does not earn. It **never** relaxes a non-negotiable.
17
- * {@link LITE_PATH_INVARIANTS} is the machine-readable contract that the lite
18
- * path still produces a Story ticket, still lands via a PR to `main`, still runs
19
- * every repo quality gate, and still honours `rules/security-baseline.md`. Those
20
- * gates run in `single-story-close.js` regardless of route; the gate cannot and
21
- * does not switch them off. Every `lite` decision carries this frozen object on
22
- * its `preserves` field so a downstream reader can assert the invariants held.
23
- *
24
- * ## Conservative by construction full on any doubt
25
- *
26
- * The gate is total and pure: seed text + resolved config in, decision out. It
27
- * routes `lite` **only** when every trivial-scope signal agrees; every other
28
- * case — an empty/unreadable seed, a seed above the word ceiling, a seed
29
- * enumerating more than one candidate artifact, or the gate disabled by config —
30
- * falls to `full`. Being wrong toward `full` costs a session; being wrong toward
31
- * `lite` would skip ceremony a real capability slice needs, so the tie always
32
- * breaks to `full`.
33
- *
34
- * ## Threshold + operator override
35
- *
36
- * {@link DEFAULT_COMPLEXITY_GATE} is the single source of truth for the
37
- * threshold. Operators tune it (or disable the gate entirely) via
38
- * `planning.complexityGate` in `.agentrc.json`:
39
- *
40
- * - `enabled` (default `true`) — `false` forces every seed to `full`.
41
- * - `maxSeedWords` (default `150`) — seed prose word ceiling for `lite`.
42
- * - `maxArtifacts` (default `1`) — enumerated-artifact ceiling for `lite`.
43
- *
44
- * Resolution clamps every field toward the conservative default: a malformed or
45
- * negative ceiling falls back to the framework default rather than widening the
46
- * lite path.
47
- *
48
- * ## Planner downgrade + the persisted route marker (Story #4707)
49
- *
50
- * Seed word count is a poor complexity proxy: a well-written 70-word trivial
51
- * seed is no less trivial than a terse 40-word one, which is why the ceiling
52
- * sits at 150 rather than 60. Two adjacent surfaces live here with the gate so
53
- * the whole lite-routing contract has one home:
54
- *
55
- * - {@link applyPlannerDowngrade} — the planner may downgrade a `full`
56
- * verdict to `lite` **only** with a recorded reason. The deterministic
57
- * gate itself is unchanged (it still fails toward `full`); the downgrade
58
- * is an auditable model judgment layered on top, never a silent gate
59
- * change. Absent a non-empty reason the deterministic verdict stands.
60
- * - {@link resolveStoryDispatchMode} — the deliver-side reader of the
61
- * persisted {@link LITE_ROUTE_LABEL} marker. A lite-routed Story executes
62
- * inline in the deliver session (no story-worker or acceptance-critic
63
- * sub-agent boots); everything else dispatches as before. Model-side
64
- * fan-out only — never a deterministic close gate.
53
+ * The lite route collapses the **advisory ceremony** only: the story-worker
54
+ * sub-agent boot and the fresh acceptance-critic spawn. It **never** relaxes
55
+ * a non-negotiable. {@link LITE_PATH_INVARIANTS} is the machine-readable
56
+ * contract that the lite path still produces a Story ticket, still lands via
57
+ * a PR to `main`, still runs every repo quality gate, and still honours
58
+ * `rules/security-baseline.md`. Those gates run in `single-story-close.js`
59
+ * regardless of route; the router cannot and does not switch them off.
60
+ *
61
+ * ## Configuration
62
+ *
63
+ * Operators tune the surface via `planning.complexityGate` in `.agentrc.json`:
64
+ *
65
+ * - `enabled` (default `true`) `false` disables lite routing
66
+ * everywhere: persist refuses lite claims and dispatch always takes the
67
+ * sub-agent path.
68
+ * - `maxArtifacts` (default `1`) — enumerated-artifact signal threshold;
69
+ * an **input signal** for the planner, no longer a deterministic router.
70
+ *
71
+ * `maxSeedWords` is **removed** (hard cutover): word count routes nothing.
65
72
  *
66
73
  * @typedef {'lite'|'full'} ComplexityRoute
67
74
  */
68
75
 
76
+ import { existsSync } from 'node:fs';
77
+ import path from 'node:path';
78
+ import {
79
+ extractChangePaths,
80
+ parse as parseStoryBody,
81
+ } from '../story-body/story-body.js';
82
+ import { deriveChangeLevel } from './review-depth.js';
83
+
69
84
  /**
70
- * Framework defaults for the plan-time complexity gate. The threshold SSOT
71
- * the config schema mirror and the configuration reference both cite these
72
- * numbers rather than restating divergent ones.
85
+ * Framework defaults for the complexity-routing surface. The SSOT the config
86
+ * schema mirror and the configuration reference both cite. `maxSeedWords` is
87
+ * gone: seed word count carries no routing authority (Story #4722).
73
88
  */
74
89
  const DEFAULT_COMPLEXITY_GATE = Object.freeze({
75
90
  enabled: true,
76
- maxSeedWords: 150,
77
91
  maxArtifacts: 1,
78
92
  });
79
93
 
80
94
  /**
81
- * The persisted route marker for a lite-routed Story (Story #4707).
95
+ * The persisted route marker for a lite-routed Story.
82
96
  *
83
- * Applied by plan-persist at create time and read by `/deliver` (via the
84
- * resolver envelope's `stories[].labels`) through
85
- * {@link resolveStoryDispatchMode}. A full-routed Story carries no marker —
86
- * absence is the conservative default, so an unlabelled Story always takes
87
- * the sub-agent dispatch path.
97
+ * **A human-visible hint only (Story #4722)** never the control signal.
98
+ * Persist still applies it so a lite cohort is filterable in the GitHub UI,
99
+ * but `/deliver` derives the route from the Story body's own shape
100
+ * ({@link resolveStoryDispatchMode}); a Story with the label whose shape
101
+ * derives `full` dispatches as a sub-agent, and a lite-shaped Story with the
102
+ * label absent (or its write failed) still executes inline.
88
103
  */
89
104
  export const LITE_ROUTE_LABEL = 'route::lite';
90
105
 
91
106
  /**
92
- * The non-negotiables the ceremony-lite path preserves. This is the
93
- * contract behind Story #4683 AC-2: collapsing ceremony never means dropping
94
- * the Story ticket, the PR-to-`main` landing, the repo quality gates, or the
95
- * security baseline. Attached verbatim to every `lite` decision's `preserves`
96
- * field; a downstream consumer (or contract test) asserts against it.
107
+ * Shape ceilings a Story must fit for the `lite` route
108
+ * ({@link deriveStoryShape}). Framework constants, not operator knobs a
109
+ * ceiling an operator can widen past what the inline path can safely absorb
110
+ * is a ceiling that fails silently. Conservative by construction: `lite` is
111
+ * for genuinely trivial, mostly-additive, non-sensitive scopes.
112
+ *
113
+ * - `maxChanges` — total `changes[]` entries (e.g. one artifact
114
+ * plus its test).
115
+ * - `maxAcceptance` — acceptance-criteria count; more criteria means
116
+ * more contract than a trivial scope carries.
117
+ * - `maxNonCreateChanges` — entries whose assumption is not `creates`
118
+ * (refactors-existing / deletes / exists). A lite
119
+ * change is mostly additive; touching existing
120
+ * surfaces is where trivial-looking work stops
121
+ * being trivial.
122
+ *
123
+ * Module-private, exposed as the `ceilings` field on every
124
+ * {@link deriveStoryShape} decision — so there is no test-only export to
125
+ * leave production-dead.
126
+ */
127
+ const STORY_SHAPE_CEILINGS = Object.freeze({
128
+ maxChanges: 2,
129
+ maxAcceptance: 3,
130
+ maxNonCreateChanges: 1,
131
+ });
132
+
133
+ /**
134
+ * The non-negotiables the ceremony-lite path preserves (Story #4683 AC-2):
135
+ * collapsing ceremony never means dropping the Story ticket, the PR-to-`main`
136
+ * landing, the repo quality gates, or the security baseline. Attached
137
+ * verbatim to every route decision's `preserves` field so a downstream reader
138
+ * (or contract test) can assert the invariants held on either route.
97
139
  */
98
140
  const LITE_PATH_INVARIANTS = Object.freeze({
99
141
  storyTicket: true,
@@ -104,9 +146,8 @@ const LITE_PATH_INVARIANTS = Object.freeze({
104
146
 
105
147
  /**
106
148
  * Coerce a candidate ceiling into a non-negative integer, falling back to the
107
- * framework default for anything malformed. Non-numbers, non-finite values, and
108
- * negatives all fall back — a stray `-1` or `NaN` must never widen the lite path
109
- * (the gate fails conservative, toward `full`).
149
+ * framework default for anything malformed a stray `-1` or `NaN` must never
150
+ * widen the lite path (fail conservative).
110
151
  *
111
152
  * @param {unknown} value
112
153
  * @param {number} fallback
@@ -120,18 +161,22 @@ function normalizeCeiling(value, fallback) {
120
161
  }
121
162
 
122
163
  /**
123
- * Resolve the effective complexity-gate config, shallow-overlaying an operator
124
- * `planning.complexityGate` block onto {@link DEFAULT_COMPLEXITY_GATE}. Accepts
125
- * the full resolved config, the bare `planning` bag, or the bare
126
- * `complexityGate` bag, mirroring the tolerant unwrap the other routing
127
- * accessors use. Module-private: exposed only through the resolved `threshold`
128
- * on {@link buildComplexityRouteSignal}'s output, so there is no test-only
129
- * export to leave production-dead.
164
+ * Resolve the effective complexity-gate config, shallow-overlaying an
165
+ * operator `planning.complexityGate` block onto
166
+ * {@link DEFAULT_COMPLEXITY_GATE}. Accepts the full resolved config, the bare
167
+ * `planning` bag, or the bare `complexityGate` bag, mirroring the tolerant
168
+ * unwrap the other routing accessors use.
169
+ *
170
+ * Exported for persist (`run-plan-persist.js#resolveEffectiveRoute`), which
171
+ * consults `enabled` to refuse a planner lite claim when the gate is off —
172
+ * the schema's documented contract, and the same switch dispatch reads in
173
+ * {@link resolveStoryDispatchMode}, so the two read points cannot disagree
174
+ * about whether lite routing is live.
130
175
  *
131
176
  * @param {object | null | undefined} config
132
- * @returns {{ enabled: boolean, maxSeedWords: number, maxArtifacts: number }}
177
+ * @returns {{ enabled: boolean, maxArtifacts: number }}
133
178
  */
134
- function resolveComplexityGate(config) {
179
+ export function resolveComplexityGate(config) {
135
180
  const raw =
136
181
  config?.planning?.complexityGate ?? config?.complexityGate ?? config ?? {};
137
182
  const bag = raw && typeof raw === 'object' ? raw : {};
@@ -140,10 +185,6 @@ function resolveComplexityGate(config) {
140
185
  typeof bag.enabled === 'boolean'
141
186
  ? bag.enabled
142
187
  : DEFAULT_COMPLEXITY_GATE.enabled,
143
- maxSeedWords: normalizeCeiling(
144
- bag.maxSeedWords,
145
- DEFAULT_COMPLEXITY_GATE.maxSeedWords,
146
- ),
147
188
  maxArtifacts: normalizeCeiling(
148
189
  bag.maxArtifacts,
149
190
  DEFAULT_COMPLEXITY_GATE.maxArtifacts,
@@ -153,9 +194,7 @@ function resolveComplexityGate(config) {
153
194
 
154
195
  /**
155
196
  * Count top-level enumerated items (`- `, `* `, `1. `) in a free-form seed —
156
- * the same shape the scope-triage and delivery-shape signals read as candidate
157
- * capabilities. Each enumerated line is one predicted artifact; a seed with two
158
- * or more is a multi-capability scope that must take the full path.
197
+ * each enumerated line is one predicted artifact.
159
198
  *
160
199
  * @param {string} text
161
200
  * @returns {number}
@@ -167,152 +206,490 @@ function countSeedArtifacts(text) {
167
206
  .filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
168
207
  }
169
208
 
209
+ /** Cap on predicted-path extraction, to bound pathological seeds. */
210
+ const MAX_PREDICTED_PATHS = 50;
211
+
170
212
  /**
171
- * Build the advisory complexity-route signal for a planning seed. Deterministic,
172
- * total, and conservative (see the module header): every trivial-scope signal
173
- * must agree for a `lite` decision; everything else routes `full`.
213
+ * Extract path-like tokens (at least one `/` plus a dotted extension) from a
214
+ * free-form seed the predicted footprint the sensitive-path and repo-state
215
+ * signals classify.
174
216
  *
175
- * The result is folded into the `/plan` context envelope as `complexityRoute`,
176
- * so the workflow reads one field instead of re-deriving the decision. Every
177
- * `lite` decision carries {@link LITE_PATH_INVARIANTS} on `preserves`.
217
+ * @param {string} text
218
+ * @returns {string[]} Deduplicated, in order of first appearance.
219
+ */
220
+ function extractPredictedPaths(text) {
221
+ if (typeof text !== 'string' || text.length === 0) return [];
222
+ const re = /(?:^|[\s`'"([])((?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})/gm;
223
+ const seen = new Set();
224
+ let match = re.exec(text);
225
+ while (match !== null && seen.size < MAX_PREDICTED_PATHS) {
226
+ seen.add(match[1]);
227
+ match = re.exec(text);
228
+ }
229
+ return [...seen];
230
+ }
231
+
232
+ /**
233
+ * Build the advisory complexity **signals** for a planning seed
234
+ * (Story #4722 AC-2). Signals, not routing: the result carries
235
+ * `routingAuthority: false` and no `route` field — the planner reads these
236
+ * alongside its own judgment ({@link resolvePlannerRouteVerdict}) and the
237
+ * deterministic shape backstop validates the authored Story at persist.
178
238
  *
179
- * @param {{ seedText?: string, config?: object }} [args]
239
+ * - `artifactCount` — enumerated items in the seed, with the
240
+ * configured `maxArtifacts` threshold beside it
241
+ * as one input signal.
242
+ * - `riskHeuristicHits` — `planning.riskHeuristics` phrases present in
243
+ * the seed (same substring matcher the
244
+ * pre-mortem critic uses).
245
+ * - `predictedPaths` / `repoState` — path-like tokens in the seed and
246
+ * which of them exist in the repo (existing
247
+ * paths predict refactors; missing predict
248
+ * creates).
249
+ * - `sensitivePathClasses` — `audit-rules.json` sensitive-path classes the
250
+ * predicted footprint intersects (the same
251
+ * taxonomy close applies to the landed diff).
252
+ *
253
+ * Total: never throws; a failed classification degrades to an empty class
254
+ * list (the honest "no signal", never a verdict).
255
+ *
256
+ * @param {{
257
+ * seedText?: string,
258
+ * config?: object,
259
+ * riskHeuristics?: string[],
260
+ * cwd?: string,
261
+ * pathExistsFn?: (absPath: string) => boolean,
262
+ * injectedRules?: object,
263
+ * selectSensitivePathClassesFn?: Function,
264
+ * }} [args]
265
+ * @returns {{
266
+ * artifactCount: number,
267
+ * maxArtifacts: number,
268
+ * riskHeuristicHits: string[],
269
+ * predictedPaths: string[],
270
+ * repoState: { existingPaths: string[], missingPaths: string[] },
271
+ * sensitivePathClasses: string[],
272
+ * gate: { enabled: boolean },
273
+ * advisory: true,
274
+ * routingAuthority: false,
275
+ * }}
276
+ */
277
+ export function buildComplexitySignals({
278
+ seedText = '',
279
+ config,
280
+ riskHeuristics = [],
281
+ cwd,
282
+ pathExistsFn = existsSync,
283
+ injectedRules,
284
+ selectSensitivePathClassesFn,
285
+ } = {}) {
286
+ const gate = resolveComplexityGate(config);
287
+ const text = typeof seedText === 'string' ? seedText : '';
288
+ const haystack = text.toLowerCase();
289
+
290
+ const riskHeuristicHits = (
291
+ Array.isArray(riskHeuristics) ? riskHeuristics : []
292
+ ).filter(
293
+ (phrase) =>
294
+ typeof phrase === 'string' &&
295
+ phrase.trim().length > 0 &&
296
+ haystack.includes(phrase.trim().toLowerCase()),
297
+ );
298
+
299
+ const predictedPaths = extractPredictedPaths(text);
300
+ const root = typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd();
301
+ const existingPaths = [];
302
+ const missingPaths = [];
303
+ for (const p of predictedPaths) {
304
+ let exists = false;
305
+ try {
306
+ exists = pathExistsFn(path.resolve(root, p)) === true;
307
+ } catch {
308
+ exists = false;
309
+ }
310
+ (exists ? existingPaths : missingPaths).push(p);
311
+ }
312
+
313
+ const { classes } = deriveChangeLevel({
314
+ changedFiles: predictedPaths,
315
+ injectedRules,
316
+ selectSensitivePathClassesFn,
317
+ });
318
+
319
+ return {
320
+ artifactCount: countSeedArtifacts(text),
321
+ maxArtifacts: gate.maxArtifacts,
322
+ riskHeuristicHits,
323
+ predictedPaths,
324
+ repoState: { existingPaths, missingPaths },
325
+ sensitivePathClasses: classes,
326
+ gate: { enabled: gate.enabled },
327
+ advisory: /** @type {const} */ (true),
328
+ routingAuthority: /** @type {const} */ (false),
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Resolve the planner's authored trivial-vs-standard verdict
334
+ * (Story #4722 AC-2, generalizing the former one-way `applyPlannerDowngrade`
335
+ * seam into the verdict itself).
336
+ *
337
+ * The planner — not a word count — owns the judgment, and the contract keeps
338
+ * it auditable: `lite` **only** with a non-empty recorded reason (carried on
339
+ * `authored` and ledgered on every created Story's `story-plan-state`
340
+ * checkpoint by persist). Absent a recorded reason the conservative default
341
+ * stands: `full`, with `authored: null`. Pure and total.
342
+ *
343
+ * The verdict is a **claim**, not the decision — persist validates it against
344
+ * the authored Story's shape ({@link deriveStoryShape}) and fails closed to
345
+ * `full` when the shape exceeds the ceilings.
346
+ *
347
+ * @param {{ reason?: unknown }} [args]
180
348
  * @returns {{
181
349
  * route: ComplexityRoute,
182
350
  * reasons: string[],
183
- * threshold: { enabled: boolean, maxSeedWords: number, maxArtifacts: number },
351
+ * authored: Readonly<{ route: 'lite', reason: string }>|null,
352
+ * preserves: typeof LITE_PATH_INVARIANTS,
353
+ * }}
354
+ */
355
+ export function resolvePlannerRouteVerdict({ reason } = {}) {
356
+ const recorded = typeof reason === 'string' ? reason.trim() : '';
357
+ if (recorded === '') {
358
+ return {
359
+ route: 'full',
360
+ reasons: [
361
+ 'no authored lite verdict (no recorded reason) — standard full route',
362
+ ],
363
+ authored: null,
364
+ preserves: LITE_PATH_INVARIANTS,
365
+ };
366
+ }
367
+ return {
368
+ route: 'lite',
369
+ reasons: [`planner verdict: lite (recorded reason): ${recorded}`],
370
+ authored: Object.freeze({ route: 'lite', reason: recorded }),
371
+ preserves: LITE_PATH_INVARIANTS,
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Derive the complexity route from an authored Story's **objective shape**
377
+ * (Story #4722 AC-3/AC-4) — the single shape function persist's backstop and
378
+ * `/deliver`'s dispatch derivation both read, so the two can never disagree
379
+ * about the same body.
380
+ *
381
+ * `lite` requires **every** signal to agree, against
382
+ * {@link STORY_SHAPE_CEILINGS}:
383
+ *
384
+ * - a declared, parseable, glob-free `changes[]` footprint of at most
385
+ * `maxChanges` entries, at most `maxNonCreateChanges` of which touch
386
+ * existing surfaces (creates-vs-refactors mix);
387
+ * - at most `maxAcceptance` acceptance criteria (and at least one — a Story
388
+ * with no contract cannot be judged trivial);
389
+ * - a footprint intersecting **no** sensitive-path class
390
+ * (`deriveChangeLevel`, the taxonomy close applies to the landed diff).
391
+ * Sensitivity always wins (AC-6): a sensitive footprint routes `full`,
392
+ * which keeps the fresh acceptance critic via `ceremony-routing.js`.
393
+ *
394
+ * Everything else — including an unknown/undeclared footprint or an
395
+ * unreadable sensitive-path manifest — fails toward `full`. Total: never
396
+ * throws.
397
+ *
398
+ * @param {{
399
+ * changes?: unknown,
400
+ * acceptance?: unknown,
401
+ * injectedRules?: object,
402
+ * selectSensitivePathClassesFn?: Function,
403
+ * }} [args]
404
+ * @returns {{
405
+ * route: ComplexityRoute,
406
+ * reasons: string[],
407
+ * shape: {
408
+ * changeCount: number,
409
+ * acceptanceCount: number,
410
+ * createCount: number,
411
+ * nonCreateCount: number,
412
+ * sensitiveClasses: string[],
413
+ * }|null,
414
+ * ceilings: typeof STORY_SHAPE_CEILINGS,
184
415
  * preserves: typeof LITE_PATH_INVARIANTS,
185
- * advisory: true,
186
416
  * }}
187
417
  */
188
- export function buildComplexityRouteSignal({ seedText = '', config } = {}) {
189
- const threshold = resolveComplexityGate(config);
190
- const advisory = /** @type {const} */ (true);
418
+ export function deriveStoryShape({
419
+ changes,
420
+ acceptance,
421
+ injectedRules,
422
+ selectSensitivePathClassesFn,
423
+ } = {}) {
424
+ const ceilings = STORY_SHAPE_CEILINGS;
191
425
  const preserves = LITE_PATH_INVARIANTS;
192
- const decide = (route, reason) => ({
426
+ const decide = (route, reason, shape = null) => ({
193
427
  route,
194
428
  reasons: [reason],
195
- threshold,
429
+ shape,
430
+ ceilings,
196
431
  preserves,
197
- advisory,
198
432
  });
199
433
 
200
- if (!threshold.enabled) {
434
+ if (!Array.isArray(changes) || changes.length === 0) {
201
435
  return decide(
202
436
  'full',
203
- 'complexity gate disabled (planning.complexityGate.enabled=false) — full plan/deliver ceremony',
437
+ 'no changes[] declaredthe footprint is unknown, so the shape cannot be judged trivial; conservative full route',
204
438
  );
205
439
  }
206
440
 
207
- const text = typeof seedText === 'string' ? seedText : '';
208
- const trimmed = text.trim();
209
- if (trimmed.length === 0) {
441
+ let entries;
442
+ try {
443
+ entries = extractChangePaths(changes);
444
+ } catch (err) {
210
445
  return decide(
211
446
  'full',
212
- 'empty seed triviality cannot be judged; conservative full path',
447
+ `changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
213
448
  );
214
449
  }
215
450
 
216
- const artifactCount = countSeedArtifacts(text);
217
- if (artifactCount > threshold.maxArtifacts) {
451
+ const acceptanceList = Array.isArray(acceptance) ? acceptance : [];
452
+ const nonCreateCount = changes.filter(
453
+ (entry) =>
454
+ !(entry && typeof entry === 'object' && entry.assumption === 'creates'),
455
+ ).length;
456
+ const { level, classes } = deriveChangeLevel({
457
+ changedFiles: entries.map((e) => e.path),
458
+ injectedRules,
459
+ selectSensitivePathClassesFn,
460
+ });
461
+ const shape = {
462
+ changeCount: changes.length,
463
+ acceptanceCount: acceptanceList.length,
464
+ createCount: changes.length - nonCreateCount,
465
+ nonCreateCount,
466
+ sensitiveClasses: classes,
467
+ };
468
+
469
+ if (entries.some((e) => e.isGlob)) {
218
470
  return decide(
219
471
  'full',
220
- `seed enumerates ${artifactCount} candidate artifacts (> maxArtifacts ${threshold.maxArtifacts}) multi-capability scope takes the full path`,
472
+ 'changes[] contains a glob pathunknown footprint width; conservative full route',
473
+ shape,
221
474
  );
222
475
  }
223
-
224
- const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
225
- if (wordCount > threshold.maxSeedWords) {
476
+ if (shape.changeCount > ceilings.maxChanges) {
477
+ return decide(
478
+ 'full',
479
+ `changes[] declares ${shape.changeCount} entries (> maxChanges ${ceilings.maxChanges}) — not a trivial footprint; full route`,
480
+ shape,
481
+ );
482
+ }
483
+ if (shape.acceptanceCount === 0) {
226
484
  return decide(
227
485
  'full',
228
- `seed is ${wordCount} words (> maxSeedWords ${threshold.maxSeedWords}) not a trivial scope; full path`,
486
+ 'no acceptance criteria the contract cannot be judged trivial; conservative full route',
487
+ shape,
488
+ );
489
+ }
490
+ if (shape.acceptanceCount > ceilings.maxAcceptance) {
491
+ return decide(
492
+ 'full',
493
+ `${shape.acceptanceCount} acceptance criteria (> maxAcceptance ${ceilings.maxAcceptance}) — more contract than a trivial scope carries; full route`,
494
+ shape,
495
+ );
496
+ }
497
+ if (shape.nonCreateCount > ceilings.maxNonCreateChanges) {
498
+ return decide(
499
+ 'full',
500
+ `${shape.nonCreateCount} non-create change(s) (> maxNonCreateChanges ${ceilings.maxNonCreateChanges}) — a mostly-refactoring mix is not a trivial additive scope; full route`,
501
+ shape,
502
+ );
503
+ }
504
+ if (shape.sensitiveClasses.length > 0) {
505
+ return decide(
506
+ 'full',
507
+ `footprint intersects sensitive-path class(es) ${shape.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
508
+ shape,
509
+ );
510
+ }
511
+ if (level !== 'low') {
512
+ // `deriveChangeLevel` degraded to its null fail-safe (unreadable
513
+ // manifest / failed selector): there is no evidence the footprint is
514
+ // non-sensitive, and a classification failure must never buy lite.
515
+ return decide(
516
+ 'full',
517
+ 'sensitive-path classification unavailable — cannot verify the footprint is non-sensitive; conservative full route',
518
+ shape,
229
519
  );
230
520
  }
231
521
 
232
522
  return decide(
233
523
  'lite',
234
- `trivial single-artifact scope (${wordCount} words ≤ ${threshold.maxSeedWords}, ${artifactCount} enumerated artifact(s) ≤ ${threshold.maxArtifacts})collapsed ceremony-lite path; non-negotiables preserved`,
524
+ `trivial shape: ${shape.changeCount} change(s) ≤ ${ceilings.maxChanges}, ${shape.acceptanceCount} acceptance criteria ≤ ${ceilings.maxAcceptance}, ${shape.nonCreateCount} non-create ≤ ${ceilings.maxNonCreateChanges}, no sensitive-path class inline-eligible; non-negotiables preserved`,
525
+ shape,
235
526
  );
236
527
  }
237
528
 
238
529
  /**
239
- * Apply an auditable planner downgrade to a `full` complexity verdict
240
- * (Story #4707).
241
- *
242
- * The deterministic gate is conservative by construction, and seed word count
243
- * is a poor complexity proxy — so the planner is allowed to judge a `full`
244
- * verdict down to `lite`, but **only** with a recorded reason. The contract:
245
- *
246
- * - No non-empty reason the deterministic verdict stands, unchanged. A
247
- * downgrade without a reason is indistinguishable from a silent gate
248
- * change, which is exactly what this path must never be.
249
- * - A signal that is not a `full` verdict (already `lite`, or absent) is
250
- * returned unchanged there is nothing to downgrade.
251
- * - Otherwise the returned signal routes `lite`, appends the reason to
252
- * `reasons`, and carries a frozen `downgraded: { from: 'full', reason }`
253
- * record so the judgment is ledgerable on plan state (plan-persist writes
254
- * it into every created Story's `story-plan-state` checkpoint).
255
- *
256
- * Pure and total: never mutates `signal`, never throws on malformed input.
257
- * The gate itself ({@link buildComplexityRouteSignal}) is untouched — it
258
- * still fails toward `full` on any doubt.
259
- *
260
- * @param {ReturnType<typeof buildComplexityRouteSignal>|null|undefined} signal
261
- * @param {{ reason?: unknown }} [args]
262
- * @returns {object|null|undefined} The (possibly downgraded) signal.
530
+ * Derive the complexity route from a Story's **serialized body markdown** —
531
+ * the deliver-side entry to {@link deriveStoryShape} (`/deliver` already
532
+ * fetches the body; the route is computed from it, never from a label). An
533
+ * unparseable body degrades to `full`: unknown shape is not trivial shape.
534
+ *
535
+ * Module-private, reachable end to end through
536
+ * {@link resolveStoryDispatchMode} (which returns the derived route) — so
537
+ * there is no test-only export to leave production-dead.
538
+ *
539
+ * @param {string} body Serialized Story-body markdown.
540
+ * @param {{ injectedRules?: object, selectSensitivePathClassesFn?: Function }} [opts]
541
+ * @returns {ReturnType<typeof deriveStoryShape>}
263
542
  */
264
- export function applyPlannerDowngrade(signal, { reason } = {}) {
265
- if (!signal || typeof signal !== 'object' || signal.route !== 'full') {
266
- return signal;
543
+ function deriveStoryRouteFromBody(body, opts = {}) {
544
+ let parsed;
545
+ try {
546
+ parsed = parseStoryBody(String(body ?? '')).body;
547
+ } catch (err) {
548
+ return {
549
+ route: 'full',
550
+ reasons: [
551
+ `Story body is unparseable (${err?.message ?? err}) — shape unknown; conservative full route`,
552
+ ],
553
+ shape: null,
554
+ ceilings: STORY_SHAPE_CEILINGS,
555
+ preserves: LITE_PATH_INVARIANTS,
556
+ };
267
557
  }
268
- const recorded = typeof reason === 'string' ? reason.trim() : '';
269
- if (recorded === '') return signal;
270
- return {
271
- ...signal,
272
- route: 'lite',
273
- reasons: [
274
- ...(Array.isArray(signal.reasons) ? signal.reasons : []),
275
- `planner downgrade full → lite (recorded reason): ${recorded}`,
276
- ],
277
- downgraded: Object.freeze({ from: 'full', reason: recorded }),
278
- };
558
+ return deriveStoryShape({
559
+ changes: parsed?.changes,
560
+ acceptance: parsed?.acceptance,
561
+ injectedRules: opts.injectedRules,
562
+ selectSensitivePathClassesFn: opts.selectSensitivePathClassesFn,
563
+ });
279
564
  }
280
565
 
281
566
  /**
282
- * Decide how `/deliver` executes a Story from its persisted route marker
283
- * (Story #4707).
284
- *
285
- * Reads the labels the resolver envelope already carries. A Story labelled
286
- * {@link LITE_ROUTE_LABEL} executes **inline** in the deliver session — no
287
- * story-worker sub-agent boot and no fresh acceptance-critic sub-agent
288
- * dispatch (sub-agent boots are the dominant deliver-phase token cost at
289
- * trivial scope). Every other Story — including one with missing or
290
- * malformed labels — dispatches as a sub-agent: absence of the marker is the
291
- * conservative default, mirroring the gate's fail-toward-`full` posture.
292
- *
293
- * Inline execution removes model-side fan-out only. Every deterministic
294
- * `single-story-close.js` gate (validation, security baseline, PR-to-`main`)
295
- * runs unchanged regardless of mode — see {@link LITE_PATH_INVARIANTS}.
296
- *
297
- * @param {{ labels?: unknown }} [args]
298
- * @returns {{ mode: 'inline'|'subagent', reasons: string[] }}
567
+ * Best-effort route derivation for reporting, when the *mode* is already
568
+ * pinned by run topology and only `route` remains to be filled in. A body
569
+ * that will not parse yields `null` rather than throwing — the caller is not
570
+ * asking the shape to decide anything.
571
+ *
572
+ * @param {unknown} body
573
+ * @param {{ injectedRules?: object, selectSensitivePathClassesFn?: Function }} opts
574
+ * @returns {ReturnType<typeof deriveStoryShape>|null}
299
575
  */
300
- export function resolveStoryDispatchMode({ labels } = {}) {
301
- const list = Array.isArray(labels)
576
+ function routeForReporting(body, opts) {
577
+ if (typeof body !== 'string' || body.trim() === '') return null;
578
+ return deriveStoryRouteFromBody(body, opts);
579
+ }
580
+
581
+ /**
582
+ * Decide how `/deliver` executes a Story.
583
+ *
584
+ * Two independent premises, checked in this order:
585
+ *
586
+ * 1. **Run topology (Story #4736).** A run delivering a *single* Story
587
+ * executes **inline**, whatever its shape. Sub-agent isolation is
588
+ * load-bearing only for CONCURRENT dispatch — two workers sharing a
589
+ * checkout would race on worktrees and branch refs — and a one-Story run
590
+ * has no sibling to race. It therefore pays the spawn premium (a boot is
591
+ * a cache WRITE at full rate, where an inline continuation is a cache read
592
+ * at ~10%; ~$1.43/M vs ~$1.07/M on comparable bench work) for nothing.
593
+ * This is a fact about the run, not about the work, so the shape gate's
594
+ * `enabled` switch — which governs *shape derivation* — does not reach it.
595
+ * 2. **Shape (Story #4722 AC-4/AC-5).** For a multi-Story run, the decision
596
+ * comes **from the Story body's own shape**, never from the `route::lite`
597
+ * label: a lite-shaped Story executes inline; everything else — a
598
+ * full-shaped body, a missing/unparseable body, or the gate disabled via
599
+ * `planning.complexityGate.enabled=false` — dispatches as a sub-agent,
600
+ * the conservative default.
601
+ *
602
+ * The label is read only to report hint consistency in `reasons`: with the
603
+ * label absent (or its write failed) a lite-shaped Story still runs inline,
604
+ * and with the label present on a full-shaped Story the shape wins.
605
+ *
606
+ * Inline execution removes model-side fan-out only — it changes **where** the
607
+ * engine runs, never **what** runs. Every deterministic
608
+ * `single-story-close.js` gate, the PR to `main`, and the
609
+ * `story-deliver-terminal` envelope are identical in both modes; see the
610
+ * module header's non-negotiables.
611
+ *
612
+ * @param {{
613
+ * body?: unknown,
614
+ * labels?: unknown,
615
+ * config?: object,
616
+ * storyCount?: unknown,
617
+ * injectedRules?: object,
618
+ * selectSensitivePathClassesFn?: Function,
619
+ * }} [args] `storyCount` is the number of Stories the invoking `/deliver` run
620
+ * resolved. Omitted (or not a positive integer) means "unknown run size",
621
+ * which falls through to the shape decision — never to an assumed 1.
622
+ * @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
623
+ */
624
+ export function resolveStoryDispatchMode({
625
+ body,
626
+ labels,
627
+ config,
628
+ storyCount,
629
+ injectedRules,
630
+ selectSensitivePathClassesFn,
631
+ } = {}) {
632
+ const labelList = Array.isArray(labels)
302
633
  ? labels.filter((l) => typeof l === 'string')
303
634
  : [];
304
- if (list.includes(LITE_ROUTE_LABEL)) {
635
+ const hasHint = labelList.includes(LITE_ROUTE_LABEL);
636
+ const hintNote = hasHint
637
+ ? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
638
+ : `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
639
+
640
+ if (storyCount === 1) {
641
+ return {
642
+ mode: 'inline',
643
+ reasons: [
644
+ 'single-Story run — execute deliver-story inline; sub-agent isolation is load-bearing only for concurrent dispatch, and a one-Story run has no sibling to race (close gates, PR, and terminal envelope unchanged)',
645
+ hintNote,
646
+ ],
647
+ route: routeForReporting(body, {
648
+ injectedRules,
649
+ selectSensitivePathClassesFn,
650
+ }),
651
+ };
652
+ }
653
+
654
+ const gate = resolveComplexityGate(config);
655
+ if (!gate.enabled) {
656
+ return {
657
+ mode: 'subagent',
658
+ reasons: [
659
+ 'complexity routing disabled (planning.complexityGate.enabled=false) — standard sub-agent dispatch',
660
+ ],
661
+ route: null,
662
+ };
663
+ }
664
+
665
+ if (typeof body !== 'string' || body.trim() === '') {
666
+ return {
667
+ mode: 'subagent',
668
+ reasons: [
669
+ 'no Story body to derive shape from — conservative sub-agent dispatch',
670
+ hintNote,
671
+ ],
672
+ route: null,
673
+ };
674
+ }
675
+
676
+ const route = deriveStoryRouteFromBody(body, {
677
+ injectedRules,
678
+ selectSensitivePathClassesFn,
679
+ });
680
+ if (route.route === 'lite') {
305
681
  return {
306
682
  mode: 'inline',
307
683
  reasons: [
308
- `Story carries the ${LITE_ROUTE_LABEL} route marker — execute deliver-story inline; no story-worker or acceptance-critic sub-agent dispatch (close gates unchanged)`,
684
+ `lite-shaped Story — execute deliver-story inline; no story-worker or acceptance-critic sub-agent dispatch (close gates unchanged): ${route.reasons[0]}`,
685
+ hintNote,
309
686
  ],
687
+ route,
310
688
  };
311
689
  }
312
690
  return {
313
691
  mode: 'subagent',
314
- reasons: [
315
- `no ${LITE_ROUTE_LABEL} route marker — standard sub-agent dispatch`,
316
- ],
692
+ reasons: [`full-shaped Story — ${route.reasons[0]}`, hintNote],
693
+ route,
317
694
  };
318
695
  }