mandrel 2.59.0 → 2.60.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -10,13 +10,15 @@
10
10
  * the `audit-rules.json` sensitive-path classes the footprint intersects.
11
11
  * They ground the authoring template's `changes[]` skeleton and the
12
12
  * `/prototype` offer; they route nothing.
13
- * 2. **Story shape ({@link deriveStoryShape}).** The light path
14
- * (`deliver-light`) reads a predicted footprint's effort and risk —
15
- * distinct change kinds, declared magnitude, uncertainty,
16
- * deployable/migration span, sensitive-path classes — against
17
- * {@link STORY_SHAPE_CEILINGS} to decide whether a prompt may skip the
18
- * Story-authoring ceremony. Artifact cardinality is deliberately not an
19
- * axis (Story #4764).
13
+ * 2. **Story risk ({@link deriveStoryShape}).** The light path
14
+ * (`deliver-light`) reads a predicted footprint's **risk** — a migration
15
+ * paired with its consumers, and the `audit-rules.json` sensitive-path
16
+ * classes the footprint intersects — to decide whether a prompt may skip
17
+ * the Story-authoring ceremony. Effort is no longer an axis: Story #4764
18
+ * removed the artifact counts, and Story #5344 removed the declared
19
+ * effort ceilings that replaced them, because a prediction is a
20
+ * declaration and size is enforced against ground truth by the light
21
+ * path's diff backstop.
20
22
  * 3. **Dispatch mode ({@link resolveStoryDispatchMode}).** `/mandrel-deliver`
21
23
  * answers a different question from the route: may the engine run in the
22
24
  * router's own session? Only a **single-Story run** may (Story #4736).
@@ -25,21 +27,23 @@
25
27
  * (`--route-downgrade-reason`), the persist-time shape backstop that
26
28
  * validated it, the `route::lite` hint label, and the
27
29
  * `planning.complexityGate` knobs. Persist no longer routes; every Story
28
- * lands through the same engine and the same close gates, and the shape
29
- * ceilings below are read only where a shape is actually decided on.
30
+ * lands through the same engine and the same close gates.
30
31
  *
31
- * The shape taxonomy is deliberately the one `review-depth.js` already
32
+ * The risk taxonomy is deliberately the one `review-depth.js` already
32
33
  * applies to the landed diff at close (`deriveChangeLevel` over the
33
- * `audit-rules.json` sensitive-path classes): **predicted shape at dispatch,
34
- * actual diff at close** — one taxonomy, two read points. Sensitivity always
35
- * wins: a small change whose footprint intersects a sensitive-path class
36
- * routes `full`, which keeps its fresh acceptance critic.
37
- *
38
- * {@link LITE_PATH_INVARIANTS} is the machine-readable contract that the
39
- * light path still produces a Story ticket, still lands via a PR to `main`,
40
- * still runs every repo quality gate, and still honours
41
- * `rules/security-baseline.md`. Those gates run in `single-story-close.js`
42
- * regardless of route; the router cannot and does not switch them off.
34
+ * `audit-rules.json` sensitive-path classes): **predicted footprint at
35
+ * dispatch, actual diff at close** — one taxonomy, two read points.
36
+ * Sensitivity always wins: a small change whose footprint intersects a
37
+ * sensitive-path class routes `full`, which keeps its deep code review
38
+ * (`review-depth.js`).
39
+ *
40
+ * The light path still produces a Story ticket, still lands via a PR to
41
+ * `main`, still runs every repo quality gate, and still honours
42
+ * `rules/security-baseline.md`. That is a property of
43
+ * `single-story-close.js`, which runs those gates regardless of route — the
44
+ * router cannot and does not switch them off. Story #5366 deleted the frozen
45
+ * `preserves` payload that used to restate it on every decision: nothing read
46
+ * it, and a claim attached to a decision is not the thing that enforces it.
43
47
  *
44
48
  * @typedef {'lite'|'full'} ComplexityRoute
45
49
  */
@@ -50,147 +54,32 @@ import { extractChangePaths } from '../story-body/story-body.js';
50
54
  import { deriveChangeLevel } from './review-depth.js';
51
55
 
52
56
  /**
53
- * Effort/risk ceilings a Story's work must fit for the `lite` route
54
- * ({@link deriveStoryShape}). Framework constants, not operator knobs — a
55
- * ceiling an operator can widen past what the inline path can safely absorb is
56
- * a ceiling that fails silently.
57
- *
58
- * ## Effort and risk, never artifact cardinality (Story #4764)
59
- *
60
- * These ceilings used to count the declared footprint (`maxChanges: 2`,
61
- * `maxAcceptance: 3`, `maxNonCreateChanges: 1`). Cardinality is the wrong axis
62
- * in both directions: three identical one-line edits across three files is
63
- * trivial work with a high count, while a 200-line rewrite of one module is a
64
- * single change. And the count was read off a footprint the model **declares
65
- * before doing the work** — a guess, and a gameable one — so counting it
66
- * rejected genuinely small work (mandrel-bench's hello-world scenario is a
67
- * server create plus a `package.json` edit plus a test create, structurally
68
- * over the old ceilings) while admitting whatever an optimistic declaration
69
- * under-counted.
70
- *
71
- * So the axes are effort, risk, and uncertainty, and the **prediction** gate
72
- * they form is deliberately **coarse**: it rejects clearly-epic work only.
73
- * Real enforcement belongs to the diff-derived backstop, which sees ground
74
- * truth instead of a declaration
57
+ * ## Why no predicted-effort ceilings live here any more (Story #5344)
58
+ *
59
+ * This module used to export a frozen shape-ceilings constant — declared kinds,
60
+ * a magnitude bucket, an uncertainty bucket, and a deployable span — which the
61
+ * light path judged a prompt's predicted footprint against. Two rounds of
62
+ * evidence retired them. Story #4764 had already removed the artifact counts
63
+ * (`maxChanges`, `maxAcceptance`) because cardinality is the wrong axis in both
64
+ * directions. Story #5313 then demoted the remaining four axes from a gate to a
65
+ * `warnings[]` entry, at which point they decided nothing at all: every one was
66
+ * SELF-DECLARED by the same agent asking to proceed, and an axis a caller sets
67
+ * and no one verifies is not a measurement. Story #5344 deleted them.
68
+ *
69
+ * What survives is what reads something other than the caller's own claim: the
70
+ * two absolute risk rules below (a migration paired with its consumers, and a
71
+ * footprint intersecting a registered sensitive-path class), derived from the
72
+ * predicted PATHS, and the diff-derived backstop that measures the actual
73
+ * change set
75
74
  * ({@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}).
76
- *
77
- * - `maxChangeKinds` — distinct change KINDS, not files. N instances of one
78
- * mechanical edit is one kind at N sites; enumerating
79
- * more kinds than this is a multi-capability scope.
80
- * - `maxMagnitude` — coarse magnitude bucket, declared alongside the
81
- * footprint: `trivial` < `moderate` < `substantial`.
82
- * - `maxUncertainty` — is the shape determined by the request
83
- * (`determined`), or does it still need the design
84
- * decisions `/mandrel-plan` exists to resolve
85
- * (`needs-design`)?
86
- * - `maxDeployables` — named deployable roots (`apps/<x>`, `packages/<x>`, …)
87
- * the footprint spans; more than one is epic by
88
- * construction.
89
- *
90
- * Two rules ride beside the ceilings and are not tunable at all: a footprint
91
- * pairing a migration with its consumers is epic scope, and a footprint
92
- * intersecting a sensitive-path class routes `full` however small or mechanical
93
- * it is — the hard gate, unchanged.
94
- *
95
- * Exposed as the `ceilings` field on every {@link deriveStoryShape} decision
96
- * and exported directly (Story #4740) so the light path's suitability gate
97
- * ({@link module:lib/orchestration/light-suitability}) judges a prompt's
98
- * predicted footprint against the **same** axes the plan-time shape backstop
99
- * applies — one source, so the light entry point and the plan path can never
100
- * disagree about what work is trivial.
101
- */
102
- export const STORY_SHAPE_CEILINGS = Object.freeze({
103
- maxChangeKinds: 2,
104
- maxMagnitude: 'moderate',
105
- maxUncertainty: 'determined',
106
- maxDeployables: 1,
107
- });
108
-
109
- /** Coarse effort buckets, ascending. Anything past `maxMagnitude` routes full. */
110
- const MAGNITUDE_SCALE = Object.freeze(['trivial', 'moderate', 'substantial']);
111
-
112
- /** Coarse uncertainty buckets, ascending. */
113
- const UNCERTAINTY_SCALE = Object.freeze(['determined', 'needs-design']);
114
-
115
- /**
116
- * Directory roots whose immediate child is a separately-deployable unit. A
117
- * footprint spanning two of them is the "multiple deployables" epic signal.
75
+ * The backstop's `LIGHT_DIFF_CEILINGS` are now the only size block on the
76
+ * light path, and they are the only ceilings measured against ground truth.
118
77
  */
119
- const DEPLOYABLE_ROOTS = Object.freeze([
120
- 'apps',
121
- 'packages',
122
- 'services',
123
- 'functions',
124
- 'workers',
125
- ]);
126
78
 
127
79
  /** Paths that are schema migrations rather than ordinary source. */
128
80
  const MIGRATION_PATH_RE =
129
81
  /(?:^|\/)(?:migrations?|migrate)(?:\/|$)|\.sql$|(?:^|\/)schema\.(?:prisma|rb)$/i;
130
82
 
131
- /**
132
- * Place a declared bucket on an ordered scale. **Absent** means "not declared"
133
- * — no signal, so the coarse gate reads the supplied default rather than
134
- * rejecting. **Present but unrecognized** is a malformed claim, which cannot be
135
- * verified as small and therefore fails closed to the worst bucket on the
136
- * scale.
137
- *
138
- * @param {unknown} value
139
- * @param {readonly string[]} scale Ascending buckets.
140
- * @param {string} whenAbsent Bucket to assume when nothing was declared.
141
- * @returns {string}
142
- */
143
- function normalizeBucket(value, scale, whenAbsent) {
144
- if (value === undefined || value === null || value === '') return whenAbsent;
145
- const key = typeof value === 'string' ? value.trim().toLowerCase() : '';
146
- return scale.includes(key) ? key : scale[scale.length - 1];
147
- }
148
-
149
- /**
150
- * Resolve the distinct change KINDS in a footprint. An explicit `kinds[]`
151
- * declaration wins; absent one, each entry's `assumption` is its kind — which
152
- * is exactly the "N instances of one mechanical edit is one kind at N sites"
153
- * reading, since N same-assumption entries collapse to one kind.
154
- *
155
- * @param {{ changes?: unknown, kinds?: unknown }} args
156
- * @returns {string[]} Distinct kinds, in order of first appearance.
157
- */
158
- function resolveChangeKinds({ changes, kinds }) {
159
- const clean = (list) =>
160
- list
161
- .filter((k) => typeof k === 'string' && k.trim() !== '')
162
- .map((k) => k.trim().toLowerCase());
163
- const declared = clean(Array.isArray(kinds) ? kinds : []);
164
- if (declared.length > 0) return [...new Set(declared)];
165
- const derived = (Array.isArray(changes) ? changes : []).map((entry) =>
166
- entry && typeof entry === 'object' && typeof entry.assumption === 'string'
167
- ? entry.assumption.trim().toLowerCase() || 'unspecified'
168
- : 'unspecified',
169
- );
170
- return [...new Set(derived)];
171
- }
172
-
173
- /**
174
- * Named deployable roots a footprint spans (`apps/web`, `packages/core`, …).
175
- * The repository root itself is deliberately **not** counted: a change to one
176
- * app plus a root-level README is one deployable, not two.
177
- *
178
- * @param {string[]} paths
179
- * @returns {string[]}
180
- */
181
- function resolveDeployables(paths) {
182
- const ids = new Set();
183
- for (const p of paths) {
184
- const segments = String(p)
185
- .split('/')
186
- .filter((s) => s !== '');
187
- if (segments.length >= 3 && DEPLOYABLE_ROOTS.includes(segments[0])) {
188
- ids.add(`${segments[0]}/${segments[1]}`);
189
- }
190
- }
191
- return [...ids];
192
- }
193
-
194
83
  /**
195
84
  * Does the footprint pair a schema migration with its consumers? A migration
196
85
  * plus the code that reads through it is epic scope: the two have to land
@@ -205,83 +94,52 @@ function spansMigrationAndConsumers(paths) {
205
94
  }
206
95
 
207
96
  /**
208
- * Stable machine-readable identifiers for every reason a shape routes `full` —
209
- * the `code` field on a {@link deriveStoryShape} decision (Story #4815).
97
+ * Stable machine-readable identifiers for every reason a footprint routes
98
+ * `full` — the `code` field on a {@link deriveStoryShape} decision
99
+ * (Story #4815).
210
100
  *
211
101
  * The prose in `reasons[]` is written for a human reading a gate envelope and
212
102
  * is free to be re-worded; a caller that must **branch** on *which* rule
213
- * objected reads this code instead. That distinction is load-bearing for the
214
- * light path's operator override
215
- * ({@link module:lib/orchestration/light-suitability.OVERRIDABLE_SHAPE_CODES}),
216
- * which may waive a size *prediction* but never a risk rule: keying that
217
- * decision off reason text would make a copy-edit a security change.
218
- *
219
- * Split three ways, and the grouping is the contract:
220
- *
221
- * - **Ceiling rules** — `change-kinds`, `magnitude`, `uncertainty`,
222
- * `deployable-span`. Coarse predictions about size, enforced for real
223
- * against ground truth by the diff backstop.
224
- * - **Absolute rules** — `migration-span`, `sensitive-path`. Risk, not size.
103
+ * objected reads this code instead — keying that decision off reason text
104
+ * would make a copy-edit a routing change.
105
+ *
106
+ * Split two ways since Story #5344 deleted the ceiling rules
107
+ * (`change-kinds`, `magnitude`, `uncertainty`, `deployable-span`), and the
108
+ * grouping is the contract:
109
+ *
110
+ * - **Absolute rules** — `migration-span`, `sensitive-path`. Risk, not size,
111
+ * and derived from the predicted PATHS rather than a self-declared bucket.
112
+ * No re-slicing satisfies one.
225
113
  * - **Unknown-footprint rejections** — `no-changes`, `unreadable-changes`,
226
- * `glob-footprint`, `no-acceptance`, `classification-unavailable`.
227
- * Nothing was judged, so there is nothing to waive.
114
+ * `glob-footprint`, `classification-unavailable`. Nothing was judged, so
115
+ * there is nothing to appeal. Story #5366 deleted `no-acceptance` with
116
+ * the `--acceptance` flag that was its only source: the flag clamped to a
117
+ * floor of one, so the zero-check behind this code could never fire.
228
118
  *
229
119
  * A `lite` route carries `code: null`.
230
120
  */
231
121
  export const SHAPE_CODES = Object.freeze({
232
- CHANGE_KINDS: 'change-kinds',
233
- MAGNITUDE: 'magnitude',
234
- UNCERTAINTY: 'uncertainty',
235
- DEPLOYABLE_SPAN: 'deployable-span',
236
122
  MIGRATION_SPAN: 'migration-span',
237
123
  SENSITIVE_PATH: 'sensitive-path',
238
124
  NO_CHANGES: 'no-changes',
239
125
  UNREADABLE_CHANGES: 'unreadable-changes',
240
126
  GLOB_FOOTPRINT: 'glob-footprint',
241
- NO_ACCEPTANCE: 'no-acceptance',
242
127
  CLASSIFICATION_UNAVAILABLE: 'classification-unavailable',
243
128
  });
244
129
 
245
130
  /**
246
- * Ordered effort/risk rules, evaluated in order; the first hit is the recorded
247
- * reason for a `full` route. Every rule names an effort, risk, or uncertainty
248
- * property of the work — none counts artifacts.
131
+ * Ordered **absolute risk** rules, evaluated in order; the first hit is the
132
+ * recorded reason for a `full` route. Neither reads a bucket the caller
133
+ * declared about itself — both are derived from the predicted paths, which is
134
+ * exactly why they survived the Story #5344 deletion of the effort ceilings.
249
135
  *
250
136
  * @type {ReadonlyArray<{
251
137
  * code: string,
252
- * when: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => boolean,
253
- * reason: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => string,
138
+ * when: (shape: object) => boolean,
139
+ * reason: (shape: object) => string,
254
140
  * }>}
255
141
  */
256
- const EFFORT_RULES = Object.freeze([
257
- {
258
- code: SHAPE_CODES.CHANGE_KINDS,
259
- when: (s, c) => s.kindCount > c.maxChangeKinds,
260
- reason: (s, c) =>
261
- `${s.kindCount} distinct change kinds (${s.changeKinds.join(', ')}) > maxChangeKinds ${c.maxChangeKinds} — an explicit multi-capability enumeration, not one capability; full route`,
262
- },
263
- {
264
- code: SHAPE_CODES.MAGNITUDE,
265
- when: (s, c) =>
266
- MAGNITUDE_SCALE.indexOf(s.magnitude) >
267
- MAGNITUDE_SCALE.indexOf(c.maxMagnitude),
268
- reason: (s, c) =>
269
- `declared magnitude "${s.magnitude}" > maxMagnitude "${c.maxMagnitude}" — a substantial rewrite is effort a single inline pass should not absorb, however few files it touches; full route`,
270
- },
271
- {
272
- code: SHAPE_CODES.UNCERTAINTY,
273
- when: (s, c) =>
274
- UNCERTAINTY_SCALE.indexOf(s.uncertainty) >
275
- UNCERTAINTY_SCALE.indexOf(c.maxUncertainty),
276
- reason: (s) =>
277
- `the shape is not determined by the request (uncertainty "${s.uncertainty}") — the design decisions /mandrel-plan exists to resolve are still open; full route`,
278
- },
279
- {
280
- code: SHAPE_CODES.DEPLOYABLE_SPAN,
281
- when: (s, c) => s.deployables.length > c.maxDeployables,
282
- reason: (s, c) =>
283
- `footprint spans ${s.deployables.length} deployables (${s.deployables.join(', ')}) > maxDeployables ${c.maxDeployables} — clearly-epic scope; full route`,
284
- },
142
+ const RISK_RULES = Object.freeze([
285
143
  {
286
144
  code: SHAPE_CODES.MIGRATION_SPAN,
287
145
  when: (s) => s.migrationSpan,
@@ -292,41 +150,26 @@ const EFFORT_RULES = Object.freeze([
292
150
  code: SHAPE_CODES.SENSITIVE_PATH,
293
151
  when: (s) => s.sensitiveClasses.length > 0,
294
152
  reason: (s) =>
295
- `footprint intersects sensitive-path class(es) ${s.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
153
+ `footprint intersects sensitive-path class(es) ${s.sensitiveClasses.join(', ')} — sensitivity wins over a small footprint; full route (deep review retained)`,
296
154
  },
297
155
  ]);
298
156
 
299
157
  /**
300
- * First effort/risk rule the shape violates as a `{ code, reason }` pair, or
301
- * `null` when it clears them all.
158
+ * First absolute risk rule the footprint violates as a `{ code, reason }`
159
+ * pair, or `null` when it clears them all.
302
160
  *
303
161
  * @param {object} shape
304
- * @param {typeof STORY_SHAPE_CEILINGS} ceilings
305
162
  * @returns {{ code: string, reason: string }|null}
306
163
  */
307
- function firstEffortViolation(shape, ceilings) {
308
- for (const rule of EFFORT_RULES) {
309
- if (rule.when(shape, ceilings)) {
310
- return { code: rule.code, reason: rule.reason(shape, ceilings) };
164
+ function firstRiskViolation(shape) {
165
+ for (const rule of RISK_RULES) {
166
+ if (rule.when(shape)) {
167
+ return { code: rule.code, reason: rule.reason(shape) };
311
168
  }
312
169
  }
313
170
  return null;
314
171
  }
315
172
 
316
- /**
317
- * The non-negotiables the ceremony-lite path preserves (Story #4683 AC-2):
318
- * collapsing ceremony never means dropping the Story ticket, the PR-to-`main`
319
- * landing, the repo quality gates, or the security baseline. Attached
320
- * verbatim to every route decision's `preserves` field so a downstream reader
321
- * (or contract test) can assert the invariants held on either route.
322
- */
323
- const LITE_PATH_INVARIANTS = Object.freeze({
324
- storyTicket: true,
325
- prToMain: true,
326
- repoGates: true,
327
- securityBaseline: true,
328
- });
329
-
330
173
  /**
331
174
  * Count top-level enumerated items (`- `, `* `, `1. `) in a free-form seed —
332
175
  * each enumerated line is one predicted artifact.
@@ -441,143 +284,150 @@ export function buildComplexitySignals({
441
284
  }
442
285
 
443
286
  /**
444
- * Assemble the effort/risk shape of a footprint — the evidence
287
+ * Assemble one decision object. Module-level rather than a closure inside
288
+ * {@link deriveStoryShape}, so each rejection family below can build its own
289
+ * verdict through one shape.
290
+ *
291
+ * @param {'lite'|'full'} route
292
+ * @param {string|null} code
293
+ * @param {string} reason
294
+ * @param {object|null} [shape]
295
+ * @returns {object}
296
+ */
297
+ function decide(route, code, reason, shape = null) {
298
+ return { route, reasons: [reason], code, shape };
299
+ }
300
+
301
+ /**
302
+ * Read the declared footprint into path entries, or the rejection that stands
303
+ * in for one when it cannot be read at all — the first of the three rejection
304
+ * families {@link SHAPE_CODES} groups. Nothing has been judged at this point,
305
+ * so neither rejection carries a shape.
306
+ *
307
+ * @param {unknown} changes
308
+ * @returns {{ entries: Array<{ path: string, isGlob?: boolean }>|null, rejection: object|null }}
309
+ */
310
+ function readFootprintEntries(changes) {
311
+ if (!Array.isArray(changes) || changes.length === 0) {
312
+ return {
313
+ entries: null,
314
+ rejection: decide(
315
+ 'full',
316
+ SHAPE_CODES.NO_CHANGES,
317
+ 'no changes[] declared — the footprint is unknown, so the work cannot be judged trivial; conservative full route',
318
+ ),
319
+ };
320
+ }
321
+ try {
322
+ return { entries: extractChangePaths(changes), rejection: null };
323
+ } catch (err) {
324
+ return {
325
+ entries: null,
326
+ rejection: decide(
327
+ 'full',
328
+ SHAPE_CODES.UNREADABLE_CHANGES,
329
+ `changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
330
+ ),
331
+ };
332
+ }
333
+ }
334
+
335
+ /**
336
+ * The one rejection a footprint that WAS read can still earn before any risk
337
+ * rule is reached: an unknowable width (a glob). Returns `null` when the
338
+ * footprint is judgeable.
339
+ *
340
+ * It used to have a sibling — a zero-length acceptance list — which Story
341
+ * #5366 removed along with the `--acceptance` flag that fed it. The flag
342
+ * clamped its own value to a floor of one, so the branch was unreachable from
343
+ * the only caller in the tree.
344
+ *
345
+ * @param {Array<{ isGlob?: boolean }>} entries
346
+ * @param {object} shape
347
+ * @returns {object|null}
348
+ */
349
+ function unjudgeableFootprintRejection(entries, shape) {
350
+ if (entries.some((e) => e.isGlob)) {
351
+ return decide(
352
+ 'full',
353
+ SHAPE_CODES.GLOB_FOOTPRINT,
354
+ 'changes[] contains a glob path — unknown footprint width; conservative full route',
355
+ shape,
356
+ );
357
+ }
358
+ return null;
359
+ }
360
+
361
+ /**
362
+ * Assemble the risk shape of a footprint — the evidence
445
363
  * {@link deriveStoryShape} decides on and carries on its result.
446
364
  *
447
365
  * @param {{
448
- * changes: unknown[],
449
366
  * paths: string[],
450
- * acceptance?: unknown,
451
- * kinds?: unknown,
452
- * magnitude?: unknown,
453
- * uncertainty?: unknown,
454
367
  * sensitiveClasses: string[],
455
368
  * }} args
456
369
  * @returns {{
457
370
  * siteCount: number,
458
- * changeKinds: string[],
459
- * kindCount: number,
460
- * magnitude: string,
461
- * uncertainty: string,
462
- * acceptanceCount: number,
463
- * deployables: string[],
464
371
  * migrationSpan: boolean,
465
372
  * sensitiveClasses: string[],
466
373
  * }}
467
374
  */
468
- function buildEffortShape({
469
- changes,
470
- paths,
471
- acceptance,
472
- kinds,
473
- magnitude,
474
- uncertainty,
475
- sensitiveClasses,
476
- }) {
477
- const changeKinds = resolveChangeKinds({ changes, kinds });
375
+ function buildRiskShape({ paths, sensitiveClasses }) {
478
376
  return {
479
377
  siteCount: paths.length,
480
- changeKinds,
481
- kindCount: changeKinds.length,
482
- magnitude: normalizeBucket(magnitude, MAGNITUDE_SCALE, 'moderate'),
483
- uncertainty: normalizeBucket(uncertainty, UNCERTAINTY_SCALE, 'determined'),
484
- acceptanceCount: Array.isArray(acceptance) ? acceptance.length : 0,
485
- deployables: resolveDeployables(paths),
486
378
  migrationSpan: spansMigrationAndConsumers(paths),
487
379
  sensitiveClasses,
488
380
  };
489
381
  }
490
382
 
491
383
  /**
492
- * Derive the complexity route from an authored Story's **effort and risk**
493
- * (Story #4722 AC-3/AC-4; re-anchored off artifact cardinality by Story #4764)
494
- * — the single shape function persist's backstop and `/mandrel-deliver`'s dispatch
495
- * derivation both read, so the two can never disagree about the same body.
384
+ * Derive the complexity route from a footprint's **risk** (Story #4722
385
+ * AC-3/AC-4; re-anchored off artifact cardinality by Story #4764; the declared
386
+ * effort ceilings deleted by Story #5344) — the single function the light
387
+ * path's suitability gate reads, so prediction-time and close-time can never
388
+ * disagree about what is sensitive.
496
389
  *
497
- * `lite` requires **every** signal to agree, against
498
- * {@link STORY_SHAPE_CEILINGS}:
390
+ * `lite` requires:
499
391
  *
500
392
  * - a declared, parseable, glob-free `changes[]` footprint — width is not
501
- * counted, but an unknown width cannot be judged;
502
- * - at least one acceptance criterion (a Story with no contract cannot be
503
- * judged trivial). The criteria are **not** capped: criterion count is
504
- * contract detail, not effort;
505
- * - at most `maxChangeKinds` distinct change kinds, magnitude no worse than
506
- * `maxMagnitude`, uncertainty no worse than `maxUncertainty`, and at most
507
- * `maxDeployables` deployable roots — plus no migration-with-consumers
508
- * span. These are the clearly-epic rejections, and nothing finer: the
509
- * declared footprint is a guess, so the diff-derived backstop does the real
510
- * enforcement (see {@link STORY_SHAPE_CEILINGS});
393
+ * counted, but an unknown footprint cannot be classified for risk;
394
+ * - no migration-with-consumers span;
511
395
  * - a footprint intersecting **no** sensitive-path class
512
396
  * (`deriveChangeLevel`, the taxonomy close applies to the landed diff).
513
397
  * Sensitivity always wins (AC-6): a sensitive footprint routes `full`
514
- * however small or mechanical, which keeps the fresh acceptance critic via
515
- * `ceremony-routing.js`.
398
+ * however small or mechanical, which keeps the deep code review via
399
+ * `review-depth.js#resolveDepth`. Since Story #5343 it does NOT also buy a
400
+ * fresh acceptance critic — that owner follows the ceremony profile.
401
+ *
402
+ * **Size is not a rule here.** It was, and Story #5344 removed it: every size
403
+ * axis was a bucket the caller declared about its own request, and the light
404
+ * path's diff backstop measures the real change set afterwards. What is left
405
+ * reads the predicted paths, which is evidence.
516
406
  *
517
- * Everything else — an unknown/undeclared footprint, a malformed magnitude or
518
- * uncertainty claim, or an unreadable sensitive-path manifest — fails toward
519
- * `full`. Total: never throws.
407
+ * Everything else — an unknown/undeclared footprint or an unreadable
408
+ * sensitive-path manifest — fails toward `full`. Total: never throws.
520
409
  *
521
410
  * @param {{
522
411
  * changes?: unknown,
523
- * acceptance?: unknown,
524
- * kinds?: unknown,
525
- * magnitude?: unknown,
526
- * uncertainty?: unknown,
527
412
  * injectedRules?: object,
528
413
  * selectSensitivePathClassesFn?: Function,
529
- * }} [args] `kinds` declares the distinct change kinds explicitly (absent, each
530
- * entry's `assumption` is its kind); `magnitude` and `uncertainty` are the
531
- * declared coarse buckets.
414
+ * }} [args]
532
415
  * @returns {{
533
416
  * route: ComplexityRoute,
534
417
  * reasons: string[],
535
418
  * code: string|null,
536
- * shape: ReturnType<typeof buildEffortShape>|null,
537
- * ceilings: typeof STORY_SHAPE_CEILINGS,
538
- * preserves: typeof LITE_PATH_INVARIANTS,
419
+ * shape: ReturnType<typeof buildRiskShape>|null,
539
420
  * }} `code` is the stable {@link SHAPE_CODES} identifier for the rule that
540
- * rejected the shape (`null` on `lite`) — the field a caller branches on,
541
- * since `reasons[]` is human prose and free to be re-worded.
421
+ * rejected the footprint (`null` on `lite`) — the field a caller branches
422
+ * on, since `reasons[]` is human prose and free to be re-worded.
542
423
  */
543
424
  export function deriveStoryShape({
544
425
  changes,
545
- acceptance,
546
- kinds,
547
- magnitude,
548
- uncertainty,
549
426
  injectedRules,
550
427
  selectSensitivePathClassesFn,
551
428
  } = {}) {
552
- const ceilings = STORY_SHAPE_CEILINGS;
553
- const preserves = LITE_PATH_INVARIANTS;
554
- const decide = (route, code, reason, shape = null) => ({
555
- route,
556
- reasons: [reason],
557
- code,
558
- shape,
559
- ceilings,
560
- preserves,
561
- });
562
-
563
- if (!Array.isArray(changes) || changes.length === 0) {
564
- return decide(
565
- 'full',
566
- SHAPE_CODES.NO_CHANGES,
567
- 'no changes[] declared — the footprint is unknown, so the work cannot be judged trivial; conservative full route',
568
- );
569
- }
570
-
571
- let entries;
572
- try {
573
- entries = extractChangePaths(changes);
574
- } catch (err) {
575
- return decide(
576
- 'full',
577
- SHAPE_CODES.UNREADABLE_CHANGES,
578
- `changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
579
- );
580
- }
429
+ const { entries, rejection } = readFootprintEntries(changes);
430
+ if (rejection !== null) return rejection;
581
431
 
582
432
  const paths = entries.map((e) => e.path);
583
433
  const { level, classes } = deriveChangeLevel({
@@ -585,34 +435,12 @@ export function deriveStoryShape({
585
435
  injectedRules,
586
436
  selectSensitivePathClassesFn,
587
437
  });
588
- const shape = buildEffortShape({
589
- changes,
590
- paths,
591
- acceptance,
592
- kinds,
593
- magnitude,
594
- uncertainty,
595
- sensitiveClasses: classes,
596
- });
438
+ const shape = buildRiskShape({ paths, sensitiveClasses: classes });
597
439
 
598
- if (entries.some((e) => e.isGlob)) {
599
- return decide(
600
- 'full',
601
- SHAPE_CODES.GLOB_FOOTPRINT,
602
- 'changes[] contains a glob path — unknown footprint width; conservative full route',
603
- shape,
604
- );
605
- }
606
- if (shape.acceptanceCount === 0) {
607
- return decide(
608
- 'full',
609
- SHAPE_CODES.NO_ACCEPTANCE,
610
- 'no acceptance criteria — the contract cannot be judged trivial; conservative full route',
611
- shape,
612
- );
613
- }
440
+ const unjudgeable = unjudgeableFootprintRejection(entries, shape);
441
+ if (unjudgeable !== null) return unjudgeable;
614
442
 
615
- const violation = firstEffortViolation(shape, ceilings);
443
+ const violation = firstRiskViolation(shape);
616
444
  if (violation !== null) {
617
445
  return decide('full', violation.code, violation.reason, shape);
618
446
  }
@@ -632,7 +460,7 @@ export function deriveStoryShape({
632
460
  return decide(
633
461
  'lite',
634
462
  null,
635
- `trivial shape: ${shape.kindCount} change kind(s) (${shape.changeKinds.join(', ')}) ≤ ${ceilings.maxChangeKinds} across ${shape.siteCount} site(s), magnitude ${shape.magnitude} ≤ ${ceilings.maxMagnitude}, shape ${shape.uncertainty}, no epic-scope span, no sensitive-path class — inline-eligible; non-negotiables preserved`,
463
+ `no absolute risk rule fires across ${shape.siteCount} predicted path(s): no migration-with-consumers span, no sensitive-path class — inline-eligible; size is bounded by the diff backstop`,
636
464
  shape,
637
465
  );
638
466
  }