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
@@ -1,99 +1,138 @@
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.
39
+ *
40
+ * The shape taxonomy is deliberately the one `review-depth.js` already
41
+ * applies to the landed diff at close (`deriveChangeLevel` over the
42
+ * `audit-rules.json` sensitive-path classes): **predicted shape at dispatch,
43
+ * actual diff at close** — one taxonomy, two read points. And sensitivity
44
+ * always wins: a small change whose footprint intersects a sensitive-path
45
+ * class routes `full`, which keeps its fresh acceptance critic
46
+ * (`ceremony-routing.js` routes a high derived level to a fresh spawn).
11
47
  *
12
48
  * ## What "lite" changes and — critically — what it never changes
13
49
  *
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.
50
+ * The lite route collapses the **advisory ceremony** only: the story-worker
51
+ * sub-agent boot and the fresh acceptance-critic spawn. It **never** relaxes
52
+ * a non-negotiable. {@link LITE_PATH_INVARIANTS} is the machine-readable
53
+ * contract that the lite path still produces a Story ticket, still lands via
54
+ * a PR to `main`, still runs every repo quality gate, and still honours
55
+ * `rules/security-baseline.md`. Those gates run in `single-story-close.js`
56
+ * regardless of route; the router cannot and does not switch them off.
57
+ *
58
+ * ## Configuration
59
+ *
60
+ * Operators tune the surface via `planning.complexityGate` in `.agentrc.json`:
61
+ *
62
+ * - `enabled` (default `true`) `false` disables lite routing
63
+ * everywhere: persist refuses lite claims and dispatch always takes the
64
+ * sub-agent path.
65
+ * - `maxArtifacts` (default `1`) — enumerated-artifact signal threshold;
66
+ * an **input signal** for the planner, no longer a deterministic router.
67
+ *
68
+ * `maxSeedWords` is **removed** (hard cutover): word count routes nothing.
65
69
  *
66
70
  * @typedef {'lite'|'full'} ComplexityRoute
67
71
  */
68
72
 
73
+ import { existsSync } from 'node:fs';
74
+ import path from 'node:path';
75
+ import {
76
+ extractChangePaths,
77
+ parse as parseStoryBody,
78
+ } from '../story-body/story-body.js';
79
+ import { deriveChangeLevel } from './review-depth.js';
80
+
69
81
  /**
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.
82
+ * Framework defaults for the complexity-routing surface. The SSOT the config
83
+ * schema mirror and the configuration reference both cite. `maxSeedWords` is
84
+ * gone: seed word count carries no routing authority (Story #4722).
73
85
  */
74
86
  const DEFAULT_COMPLEXITY_GATE = Object.freeze({
75
87
  enabled: true,
76
- maxSeedWords: 150,
77
88
  maxArtifacts: 1,
78
89
  });
79
90
 
80
91
  /**
81
- * The persisted route marker for a lite-routed Story (Story #4707).
92
+ * The persisted route marker for a lite-routed Story.
82
93
  *
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.
94
+ * **A human-visible hint only (Story #4722)** never the control signal.
95
+ * Persist still applies it so a lite cohort is filterable in the GitHub UI,
96
+ * but `/deliver` derives the route from the Story body's own shape
97
+ * ({@link resolveStoryDispatchMode}); a Story with the label whose shape
98
+ * derives `full` dispatches as a sub-agent, and a lite-shaped Story with the
99
+ * label absent (or its write failed) still executes inline.
88
100
  */
89
101
  export const LITE_ROUTE_LABEL = 'route::lite';
90
102
 
91
103
  /**
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.
104
+ * Shape ceilings a Story must fit for the `lite` route
105
+ * ({@link deriveStoryShape}). Framework constants, not operator knobs a
106
+ * ceiling an operator can widen past what the inline path can safely absorb
107
+ * is a ceiling that fails silently. Conservative by construction: `lite` is
108
+ * for genuinely trivial, mostly-additive, non-sensitive scopes.
109
+ *
110
+ * - `maxChanges` — total `changes[]` entries (e.g. one artifact
111
+ * plus its test).
112
+ * - `maxAcceptance` — acceptance-criteria count; more criteria means
113
+ * more contract than a trivial scope carries.
114
+ * - `maxNonCreateChanges` — entries whose assumption is not `creates`
115
+ * (refactors-existing / deletes / exists). A lite
116
+ * change is mostly additive; touching existing
117
+ * surfaces is where trivial-looking work stops
118
+ * being trivial.
119
+ *
120
+ * Module-private, exposed as the `ceilings` field on every
121
+ * {@link deriveStoryShape} decision — so there is no test-only export to
122
+ * leave production-dead.
123
+ */
124
+ const STORY_SHAPE_CEILINGS = Object.freeze({
125
+ maxChanges: 2,
126
+ maxAcceptance: 3,
127
+ maxNonCreateChanges: 1,
128
+ });
129
+
130
+ /**
131
+ * The non-negotiables the ceremony-lite path preserves (Story #4683 AC-2):
132
+ * collapsing ceremony never means dropping the Story ticket, the PR-to-`main`
133
+ * landing, the repo quality gates, or the security baseline. Attached
134
+ * verbatim to every route decision's `preserves` field so a downstream reader
135
+ * (or contract test) can assert the invariants held on either route.
97
136
  */
98
137
  const LITE_PATH_INVARIANTS = Object.freeze({
99
138
  storyTicket: true,
@@ -104,9 +143,8 @@ const LITE_PATH_INVARIANTS = Object.freeze({
104
143
 
105
144
  /**
106
145
  * 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`).
146
+ * framework default for anything malformed a stray `-1` or `NaN` must never
147
+ * widen the lite path (fail conservative).
110
148
  *
111
149
  * @param {unknown} value
112
150
  * @param {number} fallback
@@ -120,18 +158,22 @@ function normalizeCeiling(value, fallback) {
120
158
  }
121
159
 
122
160
  /**
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.
161
+ * Resolve the effective complexity-gate config, shallow-overlaying an
162
+ * operator `planning.complexityGate` block onto
163
+ * {@link DEFAULT_COMPLEXITY_GATE}. Accepts the full resolved config, the bare
164
+ * `planning` bag, or the bare `complexityGate` bag, mirroring the tolerant
165
+ * unwrap the other routing accessors use.
166
+ *
167
+ * Exported for persist (`run-plan-persist.js#resolveEffectiveRoute`), which
168
+ * consults `enabled` to refuse a planner lite claim when the gate is off —
169
+ * the schema's documented contract, and the same switch dispatch reads in
170
+ * {@link resolveStoryDispatchMode}, so the two read points cannot disagree
171
+ * about whether lite routing is live.
130
172
  *
131
173
  * @param {object | null | undefined} config
132
- * @returns {{ enabled: boolean, maxSeedWords: number, maxArtifacts: number }}
174
+ * @returns {{ enabled: boolean, maxArtifacts: number }}
133
175
  */
134
- function resolveComplexityGate(config) {
176
+ export function resolveComplexityGate(config) {
135
177
  const raw =
136
178
  config?.planning?.complexityGate ?? config?.complexityGate ?? config ?? {};
137
179
  const bag = raw && typeof raw === 'object' ? raw : {};
@@ -140,10 +182,6 @@ function resolveComplexityGate(config) {
140
182
  typeof bag.enabled === 'boolean'
141
183
  ? bag.enabled
142
184
  : DEFAULT_COMPLEXITY_GATE.enabled,
143
- maxSeedWords: normalizeCeiling(
144
- bag.maxSeedWords,
145
- DEFAULT_COMPLEXITY_GATE.maxSeedWords,
146
- ),
147
185
  maxArtifacts: normalizeCeiling(
148
186
  bag.maxArtifacts,
149
187
  DEFAULT_COMPLEXITY_GATE.maxArtifacts,
@@ -153,9 +191,7 @@ function resolveComplexityGate(config) {
153
191
 
154
192
  /**
155
193
  * 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.
194
+ * each enumerated line is one predicted artifact.
159
195
  *
160
196
  * @param {string} text
161
197
  * @returns {number}
@@ -167,152 +203,445 @@ function countSeedArtifacts(text) {
167
203
  .filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
168
204
  }
169
205
 
206
+ /** Cap on predicted-path extraction, to bound pathological seeds. */
207
+ const MAX_PREDICTED_PATHS = 50;
208
+
209
+ /**
210
+ * Extract path-like tokens (at least one `/` plus a dotted extension) from a
211
+ * free-form seed — the predicted footprint the sensitive-path and repo-state
212
+ * signals classify.
213
+ *
214
+ * @param {string} text
215
+ * @returns {string[]} Deduplicated, in order of first appearance.
216
+ */
217
+ function extractPredictedPaths(text) {
218
+ if (typeof text !== 'string' || text.length === 0) return [];
219
+ const re = /(?:^|[\s`'"([])((?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})/gm;
220
+ const seen = new Set();
221
+ let match = re.exec(text);
222
+ while (match !== null && seen.size < MAX_PREDICTED_PATHS) {
223
+ seen.add(match[1]);
224
+ match = re.exec(text);
225
+ }
226
+ return [...seen];
227
+ }
228
+
170
229
  /**
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`.
230
+ * Build the advisory complexity **signals** for a planning seed
231
+ * (Story #4722 AC-2). Signals, not routing: the result carries
232
+ * `routingAuthority: false` and no `route` field the planner reads these
233
+ * alongside its own judgment ({@link resolvePlannerRouteVerdict}) and the
234
+ * deterministic shape backstop validates the authored Story at persist.
174
235
  *
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`.
236
+ * - `artifactCount` — enumerated items in the seed, with the
237
+ * configured `maxArtifacts` threshold beside it
238
+ * as one input signal.
239
+ * - `riskHeuristicHits` — `planning.riskHeuristics` phrases present in
240
+ * the seed (same substring matcher the
241
+ * pre-mortem critic uses).
242
+ * - `predictedPaths` / `repoState` — path-like tokens in the seed and
243
+ * which of them exist in the repo (existing
244
+ * paths predict refactors; missing predict
245
+ * creates).
246
+ * - `sensitivePathClasses` — `audit-rules.json` sensitive-path classes the
247
+ * predicted footprint intersects (the same
248
+ * taxonomy close applies to the landed diff).
178
249
  *
179
- * @param {{ seedText?: string, config?: object }} [args]
250
+ * Total: never throws; a failed classification degrades to an empty class
251
+ * list (the honest "no signal", never a verdict).
252
+ *
253
+ * @param {{
254
+ * seedText?: string,
255
+ * config?: object,
256
+ * riskHeuristics?: string[],
257
+ * cwd?: string,
258
+ * pathExistsFn?: (absPath: string) => boolean,
259
+ * injectedRules?: object,
260
+ * selectSensitivePathClassesFn?: Function,
261
+ * }} [args]
262
+ * @returns {{
263
+ * artifactCount: number,
264
+ * maxArtifacts: number,
265
+ * riskHeuristicHits: string[],
266
+ * predictedPaths: string[],
267
+ * repoState: { existingPaths: string[], missingPaths: string[] },
268
+ * sensitivePathClasses: string[],
269
+ * gate: { enabled: boolean },
270
+ * advisory: true,
271
+ * routingAuthority: false,
272
+ * }}
273
+ */
274
+ export function buildComplexitySignals({
275
+ seedText = '',
276
+ config,
277
+ riskHeuristics = [],
278
+ cwd,
279
+ pathExistsFn = existsSync,
280
+ injectedRules,
281
+ selectSensitivePathClassesFn,
282
+ } = {}) {
283
+ const gate = resolveComplexityGate(config);
284
+ const text = typeof seedText === 'string' ? seedText : '';
285
+ const haystack = text.toLowerCase();
286
+
287
+ const riskHeuristicHits = (
288
+ Array.isArray(riskHeuristics) ? riskHeuristics : []
289
+ ).filter(
290
+ (phrase) =>
291
+ typeof phrase === 'string' &&
292
+ phrase.trim().length > 0 &&
293
+ haystack.includes(phrase.trim().toLowerCase()),
294
+ );
295
+
296
+ const predictedPaths = extractPredictedPaths(text);
297
+ const root = typeof cwd === 'string' && cwd !== '' ? cwd : process.cwd();
298
+ const existingPaths = [];
299
+ const missingPaths = [];
300
+ for (const p of predictedPaths) {
301
+ let exists = false;
302
+ try {
303
+ exists = pathExistsFn(path.resolve(root, p)) === true;
304
+ } catch {
305
+ exists = false;
306
+ }
307
+ (exists ? existingPaths : missingPaths).push(p);
308
+ }
309
+
310
+ const { classes } = deriveChangeLevel({
311
+ changedFiles: predictedPaths,
312
+ injectedRules,
313
+ selectSensitivePathClassesFn,
314
+ });
315
+
316
+ return {
317
+ artifactCount: countSeedArtifacts(text),
318
+ maxArtifacts: gate.maxArtifacts,
319
+ riskHeuristicHits,
320
+ predictedPaths,
321
+ repoState: { existingPaths, missingPaths },
322
+ sensitivePathClasses: classes,
323
+ gate: { enabled: gate.enabled },
324
+ advisory: /** @type {const} */ (true),
325
+ routingAuthority: /** @type {const} */ (false),
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Resolve the planner's authored trivial-vs-standard verdict
331
+ * (Story #4722 AC-2, generalizing the former one-way `applyPlannerDowngrade`
332
+ * seam into the verdict itself).
333
+ *
334
+ * The planner — not a word count — owns the judgment, and the contract keeps
335
+ * it auditable: `lite` **only** with a non-empty recorded reason (carried on
336
+ * `authored` and ledgered on every created Story's `story-plan-state`
337
+ * checkpoint by persist). Absent a recorded reason the conservative default
338
+ * stands: `full`, with `authored: null`. Pure and total.
339
+ *
340
+ * The verdict is a **claim**, not the decision — persist validates it against
341
+ * the authored Story's shape ({@link deriveStoryShape}) and fails closed to
342
+ * `full` when the shape exceeds the ceilings.
343
+ *
344
+ * @param {{ reason?: unknown }} [args]
180
345
  * @returns {{
181
346
  * route: ComplexityRoute,
182
347
  * reasons: string[],
183
- * threshold: { enabled: boolean, maxSeedWords: number, maxArtifacts: number },
348
+ * authored: Readonly<{ route: 'lite', reason: string }>|null,
349
+ * preserves: typeof LITE_PATH_INVARIANTS,
350
+ * }}
351
+ */
352
+ export function resolvePlannerRouteVerdict({ reason } = {}) {
353
+ const recorded = typeof reason === 'string' ? reason.trim() : '';
354
+ if (recorded === '') {
355
+ return {
356
+ route: 'full',
357
+ reasons: [
358
+ 'no authored lite verdict (no recorded reason) — standard full route',
359
+ ],
360
+ authored: null,
361
+ preserves: LITE_PATH_INVARIANTS,
362
+ };
363
+ }
364
+ return {
365
+ route: 'lite',
366
+ reasons: [`planner verdict: lite (recorded reason): ${recorded}`],
367
+ authored: Object.freeze({ route: 'lite', reason: recorded }),
368
+ preserves: LITE_PATH_INVARIANTS,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Derive the complexity route from an authored Story's **objective shape**
374
+ * (Story #4722 AC-3/AC-4) — the single shape function persist's backstop and
375
+ * `/deliver`'s dispatch derivation both read, so the two can never disagree
376
+ * about the same body.
377
+ *
378
+ * `lite` requires **every** signal to agree, against
379
+ * {@link STORY_SHAPE_CEILINGS}:
380
+ *
381
+ * - a declared, parseable, glob-free `changes[]` footprint of at most
382
+ * `maxChanges` entries, at most `maxNonCreateChanges` of which touch
383
+ * existing surfaces (creates-vs-refactors mix);
384
+ * - at most `maxAcceptance` acceptance criteria (and at least one — a Story
385
+ * with no contract cannot be judged trivial);
386
+ * - a footprint intersecting **no** sensitive-path class
387
+ * (`deriveChangeLevel`, the taxonomy close applies to the landed diff).
388
+ * Sensitivity always wins (AC-6): a sensitive footprint routes `full`,
389
+ * which keeps the fresh acceptance critic via `ceremony-routing.js`.
390
+ *
391
+ * Everything else — including an unknown/undeclared footprint or an
392
+ * unreadable sensitive-path manifest — fails toward `full`. Total: never
393
+ * throws.
394
+ *
395
+ * @param {{
396
+ * changes?: unknown,
397
+ * acceptance?: unknown,
398
+ * injectedRules?: object,
399
+ * selectSensitivePathClassesFn?: Function,
400
+ * }} [args]
401
+ * @returns {{
402
+ * route: ComplexityRoute,
403
+ * reasons: string[],
404
+ * shape: {
405
+ * changeCount: number,
406
+ * acceptanceCount: number,
407
+ * createCount: number,
408
+ * nonCreateCount: number,
409
+ * sensitiveClasses: string[],
410
+ * }|null,
411
+ * ceilings: typeof STORY_SHAPE_CEILINGS,
184
412
  * preserves: typeof LITE_PATH_INVARIANTS,
185
- * advisory: true,
186
413
  * }}
187
414
  */
188
- export function buildComplexityRouteSignal({ seedText = '', config } = {}) {
189
- const threshold = resolveComplexityGate(config);
190
- const advisory = /** @type {const} */ (true);
415
+ export function deriveStoryShape({
416
+ changes,
417
+ acceptance,
418
+ injectedRules,
419
+ selectSensitivePathClassesFn,
420
+ } = {}) {
421
+ const ceilings = STORY_SHAPE_CEILINGS;
191
422
  const preserves = LITE_PATH_INVARIANTS;
192
- const decide = (route, reason) => ({
423
+ const decide = (route, reason, shape = null) => ({
193
424
  route,
194
425
  reasons: [reason],
195
- threshold,
426
+ shape,
427
+ ceilings,
196
428
  preserves,
197
- advisory,
198
429
  });
199
430
 
200
- if (!threshold.enabled) {
431
+ if (!Array.isArray(changes) || changes.length === 0) {
201
432
  return decide(
202
433
  'full',
203
- 'complexity gate disabled (planning.complexityGate.enabled=false) — full plan/deliver ceremony',
434
+ 'no changes[] declaredthe footprint is unknown, so the shape cannot be judged trivial; conservative full route',
204
435
  );
205
436
  }
206
437
 
207
- const text = typeof seedText === 'string' ? seedText : '';
208
- const trimmed = text.trim();
209
- if (trimmed.length === 0) {
438
+ let entries;
439
+ try {
440
+ entries = extractChangePaths(changes);
441
+ } catch (err) {
210
442
  return decide(
211
443
  'full',
212
- 'empty seed triviality cannot be judged; conservative full path',
444
+ `changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
213
445
  );
214
446
  }
215
447
 
216
- const artifactCount = countSeedArtifacts(text);
217
- if (artifactCount > threshold.maxArtifacts) {
448
+ const acceptanceList = Array.isArray(acceptance) ? acceptance : [];
449
+ const nonCreateCount = changes.filter(
450
+ (entry) =>
451
+ !(entry && typeof entry === 'object' && entry.assumption === 'creates'),
452
+ ).length;
453
+ const { level, classes } = deriveChangeLevel({
454
+ changedFiles: entries.map((e) => e.path),
455
+ injectedRules,
456
+ selectSensitivePathClassesFn,
457
+ });
458
+ const shape = {
459
+ changeCount: changes.length,
460
+ acceptanceCount: acceptanceList.length,
461
+ createCount: changes.length - nonCreateCount,
462
+ nonCreateCount,
463
+ sensitiveClasses: classes,
464
+ };
465
+
466
+ if (entries.some((e) => e.isGlob)) {
218
467
  return decide(
219
468
  'full',
220
- `seed enumerates ${artifactCount} candidate artifacts (> maxArtifacts ${threshold.maxArtifacts}) multi-capability scope takes the full path`,
469
+ 'changes[] contains a glob pathunknown footprint width; conservative full route',
470
+ shape,
221
471
  );
222
472
  }
223
-
224
- const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
225
- if (wordCount > threshold.maxSeedWords) {
473
+ if (shape.changeCount > ceilings.maxChanges) {
226
474
  return decide(
227
475
  'full',
228
- `seed is ${wordCount} words (> maxSeedWords ${threshold.maxSeedWords}) — not a trivial scope; full path`,
476
+ `changes[] declares ${shape.changeCount} entries (> maxChanges ${ceilings.maxChanges}) — not a trivial footprint; full route`,
477
+ shape,
478
+ );
479
+ }
480
+ if (shape.acceptanceCount === 0) {
481
+ return decide(
482
+ 'full',
483
+ 'no acceptance criteria — the contract cannot be judged trivial; conservative full route',
484
+ shape,
485
+ );
486
+ }
487
+ if (shape.acceptanceCount > ceilings.maxAcceptance) {
488
+ return decide(
489
+ 'full',
490
+ `${shape.acceptanceCount} acceptance criteria (> maxAcceptance ${ceilings.maxAcceptance}) — more contract than a trivial scope carries; full route`,
491
+ shape,
492
+ );
493
+ }
494
+ if (shape.nonCreateCount > ceilings.maxNonCreateChanges) {
495
+ return decide(
496
+ 'full',
497
+ `${shape.nonCreateCount} non-create change(s) (> maxNonCreateChanges ${ceilings.maxNonCreateChanges}) — a mostly-refactoring mix is not a trivial additive scope; full route`,
498
+ shape,
499
+ );
500
+ }
501
+ if (shape.sensitiveClasses.length > 0) {
502
+ return decide(
503
+ 'full',
504
+ `footprint intersects sensitive-path class(es) ${shape.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
505
+ shape,
506
+ );
507
+ }
508
+ if (level !== 'low') {
509
+ // `deriveChangeLevel` degraded to its null fail-safe (unreadable
510
+ // manifest / failed selector): there is no evidence the footprint is
511
+ // non-sensitive, and a classification failure must never buy lite.
512
+ return decide(
513
+ 'full',
514
+ 'sensitive-path classification unavailable — cannot verify the footprint is non-sensitive; conservative full route',
515
+ shape,
229
516
  );
230
517
  }
231
518
 
232
519
  return decide(
233
520
  'lite',
234
- `trivial single-artifact scope (${wordCount} words ≤ ${threshold.maxSeedWords}, ${artifactCount} enumerated artifact(s) ≤ ${threshold.maxArtifacts})collapsed ceremony-lite path; non-negotiables preserved`,
521
+ `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`,
522
+ shape,
235
523
  );
236
524
  }
237
525
 
238
526
  /**
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.
527
+ * Derive the complexity route from a Story's **serialized body markdown** —
528
+ * the deliver-side entry to {@link deriveStoryShape} (`/deliver` already
529
+ * fetches the body; the route is computed from it, never from a label). An
530
+ * unparseable body degrades to `full`: unknown shape is not trivial shape.
531
+ *
532
+ * Module-private, reachable end to end through
533
+ * {@link resolveStoryDispatchMode} (which returns the derived route) — so
534
+ * there is no test-only export to leave production-dead.
535
+ *
536
+ * @param {string} body Serialized Story-body markdown.
537
+ * @param {{ injectedRules?: object, selectSensitivePathClassesFn?: Function }} [opts]
538
+ * @returns {ReturnType<typeof deriveStoryShape>}
263
539
  */
264
- export function applyPlannerDowngrade(signal, { reason } = {}) {
265
- if (!signal || typeof signal !== 'object' || signal.route !== 'full') {
266
- return signal;
540
+ function deriveStoryRouteFromBody(body, opts = {}) {
541
+ let parsed;
542
+ try {
543
+ parsed = parseStoryBody(String(body ?? '')).body;
544
+ } catch (err) {
545
+ return {
546
+ route: 'full',
547
+ reasons: [
548
+ `Story body is unparseable (${err?.message ?? err}) — shape unknown; conservative full route`,
549
+ ],
550
+ shape: null,
551
+ ceilings: STORY_SHAPE_CEILINGS,
552
+ preserves: LITE_PATH_INVARIANTS,
553
+ };
267
554
  }
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
- };
555
+ return deriveStoryShape({
556
+ changes: parsed?.changes,
557
+ acceptance: parsed?.acceptance,
558
+ injectedRules: opts.injectedRules,
559
+ selectSensitivePathClassesFn: opts.selectSensitivePathClassesFn,
560
+ });
279
561
  }
280
562
 
281
563
  /**
282
- * Decide how `/deliver` executes a Story from its persisted route marker
283
- * (Story #4707).
564
+ * Decide how `/deliver` executes a Story — **from the Story body's own
565
+ * shape**, never from the `route::lite` label (Story #4722 AC-4/AC-5).
566
+ *
567
+ * A lite-shaped Story executes **inline** in the deliver session — no
568
+ * story-worker sub-agent boot and no fresh acceptance-critic dispatch
569
+ * (sub-agent boots are the dominant deliver-phase token cost at trivial
570
+ * scope). Everything else — a full-shaped body, a missing/unparseable body,
571
+ * or the gate disabled via `planning.complexityGate.enabled=false` —
572
+ * dispatches as a sub-agent: the conservative default.
284
573
  *
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.
574
+ * The label is read only to report hint consistency in `reasons`: with the
575
+ * label absent (or its write failed) a lite-shaped Story still runs inline,
576
+ * and with the label present on a full-shaped Story the shape wins.
292
577
  *
293
578
  * 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}.
579
+ * `single-story-close.js` gate runs unchanged regardless of mode — see the
580
+ * module header's non-negotiables.
296
581
  *
297
- * @param {{ labels?: unknown }} [args]
298
- * @returns {{ mode: 'inline'|'subagent', reasons: string[] }}
582
+ * @param {{
583
+ * body?: unknown,
584
+ * labels?: unknown,
585
+ * config?: object,
586
+ * injectedRules?: object,
587
+ * selectSensitivePathClassesFn?: Function,
588
+ * }} [args]
589
+ * @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
299
590
  */
300
- export function resolveStoryDispatchMode({ labels } = {}) {
301
- const list = Array.isArray(labels)
591
+ export function resolveStoryDispatchMode({
592
+ body,
593
+ labels,
594
+ config,
595
+ injectedRules,
596
+ selectSensitivePathClassesFn,
597
+ } = {}) {
598
+ const labelList = Array.isArray(labels)
302
599
  ? labels.filter((l) => typeof l === 'string')
303
600
  : [];
304
- if (list.includes(LITE_ROUTE_LABEL)) {
601
+ const hasHint = labelList.includes(LITE_ROUTE_LABEL);
602
+ const hintNote = hasHint
603
+ ? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
604
+ : `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
605
+
606
+ const gate = resolveComplexityGate(config);
607
+ if (!gate.enabled) {
608
+ return {
609
+ mode: 'subagent',
610
+ reasons: [
611
+ 'complexity routing disabled (planning.complexityGate.enabled=false) — standard sub-agent dispatch',
612
+ ],
613
+ route: null,
614
+ };
615
+ }
616
+
617
+ if (typeof body !== 'string' || body.trim() === '') {
618
+ return {
619
+ mode: 'subagent',
620
+ reasons: [
621
+ 'no Story body to derive shape from — conservative sub-agent dispatch',
622
+ hintNote,
623
+ ],
624
+ route: null,
625
+ };
626
+ }
627
+
628
+ const route = deriveStoryRouteFromBody(body, {
629
+ injectedRules,
630
+ selectSensitivePathClassesFn,
631
+ });
632
+ if (route.route === 'lite') {
305
633
  return {
306
634
  mode: 'inline',
307
635
  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)`,
636
+ `lite-shaped Story — execute deliver-story inline; no story-worker or acceptance-critic sub-agent dispatch (close gates unchanged): ${route.reasons[0]}`,
637
+ hintNote,
309
638
  ],
639
+ route,
310
640
  };
311
641
  }
312
642
  return {
313
643
  mode: 'subagent',
314
- reasons: [
315
- `no ${LITE_ROUTE_LABEL} route marker — standard sub-agent dispatch`,
316
- ],
644
+ reasons: [`full-shaped Story — ${route.reasons[0]}`, hintNote],
645
+ route,
317
646
  };
318
647
  }