mandrel 2.9.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 (75) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +39 -25
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +36 -9
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +10 -6
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  17. package/.agents/scripts/deliver-recover.js +23 -6
  18. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  19. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  20. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  21. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  22. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  23. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  24. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  25. package/.agents/scripts/lib/config/quality.js +13 -0
  26. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  27. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  28. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  29. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  30. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  31. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  32. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  33. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  34. package/.agents/scripts/lib/orchestration/complexity-gate.js +533 -93
  35. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  36. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  37. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  38. package/.agents/scripts/lib/orchestration/plan-context.js +119 -3
  39. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  40. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  41. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  42. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +153 -2
  43. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +221 -14
  44. package/.agents/scripts/lib/orchestration/resolve-stories.js +12 -1
  45. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  46. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  48. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  49. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  50. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  51. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  52. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  53. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  54. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  55. package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
  56. package/.agents/scripts/lib/test-env.js +65 -0
  57. package/.agents/scripts/plan-context.js +84 -9
  58. package/.agents/scripts/plan-critics.js +115 -3
  59. package/.agents/scripts/plan-persist.js +11 -1
  60. package/.agents/scripts/plan-run-epilogue.js +1 -1
  61. package/.agents/scripts/resolve-stories.js +2 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +65 -5
  63. package/.agents/scripts/stories-wave-tick.js +1 -1
  64. package/.agents/workflows/deliver.md +88 -230
  65. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  66. package/.agents/workflows/helpers/deliver-reference.md +171 -0
  67. package/.agents/workflows/helpers/deliver-story-reference.md +223 -0
  68. package/.agents/workflows/helpers/deliver-story.md +115 -432
  69. package/.agents/workflows/helpers/plan-reference.md +246 -0
  70. package/.agents/workflows/plan.md +108 -304
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +31 -14
  73. package/lib/migrations/index.js +2 -0
  74. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  75. package/package.json +1 -1
@@ -1,70 +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
11
6
  *
12
- * ## What "lite" changes and critically what it never changes
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.
13
39
  *
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.
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).
23
47
  *
24
- * ## Conservative by constructionfull on any doubt
48
+ * ## What "lite" changes and critically what it never changes
25
49
  *
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`.
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.
33
57
  *
34
- * ## Threshold + operator override
58
+ * ## Configuration
35
59
  *
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`:
60
+ * Operators tune the surface via `planning.complexityGate` in `.agentrc.json`:
39
61
  *
40
- * - `enabled` (default `true`) — `false` forces every seed to `full`.
41
- * - `maxSeedWords` (default `60`) — seed prose word ceiling for `lite`.
42
- * - `maxArtifacts` (default `1`) — enumerated-artifact ceiling for `lite`.
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.
43
67
  *
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.
68
+ * `maxSeedWords` is **removed** (hard cutover): word count routes nothing.
47
69
  *
48
70
  * @typedef {'lite'|'full'} ComplexityRoute
49
71
  */
50
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
+
51
81
  /**
52
- * Framework defaults for the plan-time complexity gate. The threshold SSOT
53
- * the config schema mirror and the configuration reference both cite these
54
- * 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).
55
85
  */
56
86
  const DEFAULT_COMPLEXITY_GATE = Object.freeze({
57
87
  enabled: true,
58
- maxSeedWords: 60,
59
88
  maxArtifacts: 1,
60
89
  });
61
90
 
62
91
  /**
63
- * The non-negotiables the ceremony-lite path preserves. This is the
64
- * contract behind Story #4683 AC-2: collapsing ceremony never means dropping
65
- * the Story ticket, the PR-to-`main` landing, the repo quality gates, or the
66
- * security baseline. Attached verbatim to every `lite` decision's `preserves`
67
- * field; a downstream consumer (or contract test) asserts against it.
92
+ * The persisted route marker for a lite-routed Story.
93
+ *
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.
100
+ */
101
+ export const LITE_ROUTE_LABEL = 'route::lite';
102
+
103
+ /**
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.
68
136
  */
69
137
  const LITE_PATH_INVARIANTS = Object.freeze({
70
138
  storyTicket: true,
@@ -75,9 +143,8 @@ const LITE_PATH_INVARIANTS = Object.freeze({
75
143
 
76
144
  /**
77
145
  * Coerce a candidate ceiling into a non-negative integer, falling back to the
78
- * framework default for anything malformed. Non-numbers, non-finite values, and
79
- * negatives all fall back — a stray `-1` or `NaN` must never widen the lite path
80
- * (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).
81
148
  *
82
149
  * @param {unknown} value
83
150
  * @param {number} fallback
@@ -91,18 +158,22 @@ function normalizeCeiling(value, fallback) {
91
158
  }
92
159
 
93
160
  /**
94
- * Resolve the effective complexity-gate config, shallow-overlaying an operator
95
- * `planning.complexityGate` block onto {@link DEFAULT_COMPLEXITY_GATE}. Accepts
96
- * the full resolved config, the bare `planning` bag, or the bare
97
- * `complexityGate` bag, mirroring the tolerant unwrap the other routing
98
- * accessors use. Module-private: exposed only through the resolved `threshold`
99
- * on {@link buildComplexityRouteSignal}'s output, so there is no test-only
100
- * 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.
101
172
  *
102
173
  * @param {object | null | undefined} config
103
- * @returns {{ enabled: boolean, maxSeedWords: number, maxArtifacts: number }}
174
+ * @returns {{ enabled: boolean, maxArtifacts: number }}
104
175
  */
105
- function resolveComplexityGate(config) {
176
+ export function resolveComplexityGate(config) {
106
177
  const raw =
107
178
  config?.planning?.complexityGate ?? config?.complexityGate ?? config ?? {};
108
179
  const bag = raw && typeof raw === 'object' ? raw : {};
@@ -111,10 +182,6 @@ function resolveComplexityGate(config) {
111
182
  typeof bag.enabled === 'boolean'
112
183
  ? bag.enabled
113
184
  : DEFAULT_COMPLEXITY_GATE.enabled,
114
- maxSeedWords: normalizeCeiling(
115
- bag.maxSeedWords,
116
- DEFAULT_COMPLEXITY_GATE.maxSeedWords,
117
- ),
118
185
  maxArtifacts: normalizeCeiling(
119
186
  bag.maxArtifacts,
120
187
  DEFAULT_COMPLEXITY_GATE.maxArtifacts,
@@ -124,9 +191,7 @@ function resolveComplexityGate(config) {
124
191
 
125
192
  /**
126
193
  * Count top-level enumerated items (`- `, `* `, `1. `) in a free-form seed —
127
- * the same shape the scope-triage and delivery-shape signals read as candidate
128
- * capabilities. Each enumerated line is one predicted artifact; a seed with two
129
- * or more is a multi-capability scope that must take the full path.
194
+ * each enumerated line is one predicted artifact.
130
195
  *
131
196
  * @param {string} text
132
197
  * @returns {number}
@@ -138,70 +203,445 @@ function countSeedArtifacts(text) {
138
203
  .filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
139
204
  }
140
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
+
141
229
  /**
142
- * Build the advisory complexity-route signal for a planning seed. Deterministic,
143
- * total, and conservative (see the module header): every trivial-scope signal
144
- * 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.
145
235
  *
146
- * The result is folded into the `/plan` context envelope as `complexityRoute`,
147
- * so the workflow reads one field instead of re-deriving the decision. Every
148
- * `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).
149
249
  *
150
- * @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]
151
345
  * @returns {{
152
346
  * route: ComplexityRoute,
153
347
  * reasons: string[],
154
- * threshold: { enabled: boolean, maxSeedWords: number, maxArtifacts: number },
348
+ * authored: Readonly<{ route: 'lite', reason: string }>|null,
155
349
  * preserves: typeof LITE_PATH_INVARIANTS,
156
- * advisory: true,
157
350
  * }}
158
351
  */
159
- export function buildComplexityRouteSignal({ seedText = '', config } = {}) {
160
- const threshold = resolveComplexityGate(config);
161
- const advisory = /** @type {const} */ (true);
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,
412
+ * preserves: typeof LITE_PATH_INVARIANTS,
413
+ * }}
414
+ */
415
+ export function deriveStoryShape({
416
+ changes,
417
+ acceptance,
418
+ injectedRules,
419
+ selectSensitivePathClassesFn,
420
+ } = {}) {
421
+ const ceilings = STORY_SHAPE_CEILINGS;
162
422
  const preserves = LITE_PATH_INVARIANTS;
163
- const decide = (route, reason) => ({
423
+ const decide = (route, reason, shape = null) => ({
164
424
  route,
165
425
  reasons: [reason],
166
- threshold,
426
+ shape,
427
+ ceilings,
167
428
  preserves,
168
- advisory,
169
429
  });
170
430
 
171
- if (!threshold.enabled) {
431
+ if (!Array.isArray(changes) || changes.length === 0) {
172
432
  return decide(
173
433
  'full',
174
- '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',
175
435
  );
176
436
  }
177
437
 
178
- const text = typeof seedText === 'string' ? seedText : '';
179
- const trimmed = text.trim();
180
- if (trimmed.length === 0) {
438
+ let entries;
439
+ try {
440
+ entries = extractChangePaths(changes);
441
+ } catch (err) {
181
442
  return decide(
182
443
  'full',
183
- 'empty seed triviality cannot be judged; conservative full path',
444
+ `changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
184
445
  );
185
446
  }
186
447
 
187
- const artifactCount = countSeedArtifacts(text);
188
- 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)) {
189
467
  return decide(
190
468
  'full',
191
- `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,
192
471
  );
193
472
  }
194
-
195
- const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
196
- if (wordCount > threshold.maxSeedWords) {
473
+ if (shape.changeCount > ceilings.maxChanges) {
474
+ return decide(
475
+ 'full',
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.
197
512
  return decide(
198
513
  'full',
199
- `seed is ${wordCount} words (> maxSeedWords ${threshold.maxSeedWords}) not a trivial scope; full path`,
514
+ 'sensitive-path classification unavailable cannot verify the footprint is non-sensitive; conservative full route',
515
+ shape,
200
516
  );
201
517
  }
202
518
 
203
519
  return decide(
204
520
  'lite',
205
- `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,
206
523
  );
207
524
  }
525
+
526
+ /**
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>}
539
+ */
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
+ };
554
+ }
555
+ return deriveStoryShape({
556
+ changes: parsed?.changes,
557
+ acceptance: parsed?.acceptance,
558
+ injectedRules: opts.injectedRules,
559
+ selectSensitivePathClassesFn: opts.selectSensitivePathClassesFn,
560
+ });
561
+ }
562
+
563
+ /**
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.
573
+ *
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.
577
+ *
578
+ * Inline execution removes model-side fan-out only. Every deterministic
579
+ * `single-story-close.js` gate runs unchanged regardless of mode — see the
580
+ * module header's non-negotiables.
581
+ *
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 }}
590
+ */
591
+ export function resolveStoryDispatchMode({
592
+ body,
593
+ labels,
594
+ config,
595
+ injectedRules,
596
+ selectSensitivePathClassesFn,
597
+ } = {}) {
598
+ const labelList = Array.isArray(labels)
599
+ ? labels.filter((l) => typeof l === 'string')
600
+ : [];
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') {
633
+ return {
634
+ mode: 'inline',
635
+ reasons: [
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,
638
+ ],
639
+ route,
640
+ };
641
+ }
642
+ return {
643
+ mode: 'subagent',
644
+ reasons: [`full-shaped Story — ${route.reasons[0]}`, hintNote],
645
+ route,
646
+ };
647
+ }