mandrel 2.14.0 → 2.16.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/audit-checklists/navigability.md +1 -1
  2. package/.agents/docs/workflows.md +4 -4
  3. package/.agents/scripts/acceptance-eval.js +18 -1
  4. package/.agents/scripts/agents-bootstrap-github.js +22 -1
  5. package/.agents/scripts/apply-quality-bootstrap.js +6 -0
  6. package/.agents/scripts/audit-labels-bootstrap.js +15 -1
  7. package/.agents/scripts/audit-to-stories.js +26 -1
  8. package/.agents/scripts/boot-sweep.js +4 -1
  9. package/.agents/scripts/bootstrap.js +1 -0
  10. package/.agents/scripts/check-arch-cycles.js +20 -0
  11. package/.agents/scripts/check-baselines.js +8 -2
  12. package/.agents/scripts/check-context-budget.js +40 -5
  13. package/.agents/scripts/check-dead-exports.js +21 -0
  14. package/.agents/scripts/check-doc-links.js +12 -1
  15. package/.agents/scripts/check-lifecycle-doc-drift.js +9 -0
  16. package/.agents/scripts/check-workflow-citations.js +332 -0
  17. package/.agents/scripts/deliver-light.js +32 -3
  18. package/.agents/scripts/deliver-recover.js +4 -1
  19. package/.agents/scripts/diagnose-friction.js +17 -1
  20. package/.agents/scripts/diagnose.js +20 -14
  21. package/.agents/scripts/drain-pending-cleanup.js +20 -1
  22. package/.agents/scripts/evidence-gate.js +20 -1
  23. package/.agents/scripts/generate-config-docs.js +14 -1
  24. package/.agents/scripts/generate-lifecycle-docs.js +14 -1
  25. package/.agents/scripts/generate-workflows-doc.js +14 -1
  26. package/.agents/scripts/git-cleanup.js +32 -1
  27. package/.agents/scripts/lib/cli-usage.js +174 -0
  28. package/.agents/scripts/lib/cli-utils.js +12 -0
  29. package/.agents/scripts/lib/close-validation/process.js +61 -15
  30. package/.agents/scripts/lib/doc-tiers.js +53 -10
  31. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  32. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  33. package/.agents/scripts/lib/orchestration/plan-context.js +205 -20
  34. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +87 -13
  35. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +24 -15
  36. package/.agents/scripts/lib/workflow-closure.js +431 -0
  37. package/.agents/scripts/mandrel-update-preflight.js +9 -0
  38. package/.agents/scripts/nav-registry-diff.js +13 -0
  39. package/.agents/scripts/plan-context.js +21 -2
  40. package/.agents/scripts/plan-critics.js +10 -0
  41. package/.agents/scripts/plan-persist.js +33 -1
  42. package/.agents/scripts/plan-run-epilogue.js +12 -1
  43. package/.agents/scripts/quality-preview.js +17 -1
  44. package/.agents/scripts/resolve-doc-tiers.js +13 -0
  45. package/.agents/scripts/resolve-stories.js +1 -0
  46. package/.agents/scripts/resync-status-column.js +4 -1
  47. package/.agents/scripts/signals-view.js +11 -0
  48. package/.agents/scripts/single-story-close.js +24 -0
  49. package/.agents/scripts/single-story-confirm-merge.js +16 -0
  50. package/.agents/scripts/single-story-init.js +21 -1
  51. package/.agents/scripts/stories-wave-tick.js +1 -0
  52. package/.agents/scripts/sync-agentrc.js +16 -4
  53. package/.agents/scripts/update-ticket-state.js +23 -2
  54. package/.agents/workflows/audit-navigability.md +2 -2
  55. package/.agents/workflows/audit-to-stories.md +1 -1
  56. package/.agents/workflows/deliver.md +80 -81
  57. package/.agents/workflows/git-cleanup.md +9 -14
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -13
  59. package/.agents/workflows/helpers/audit-lens-core.md +2 -2
  60. package/.agents/workflows/helpers/code-review.md +11 -11
  61. package/.agents/workflows/helpers/deliver-digest.md +7 -8
  62. package/.agents/workflows/helpers/deliver-light.md +222 -0
  63. package/.agents/workflows/helpers/deliver-reference.md +46 -14
  64. package/.agents/workflows/helpers/deliver-story-reference.md +55 -63
  65. package/.agents/workflows/helpers/deliver-story.md +22 -22
  66. package/.agents/workflows/helpers/mandrel-sync-config.md +2 -2
  67. package/.agents/workflows/helpers/parallel-tooling.md +1 -2
  68. package/.agents/workflows/helpers/plan-reference.md +96 -17
  69. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -4
  70. package/.agents/workflows/mandrel-update.md +6 -6
  71. package/.agents/workflows/plan.md +90 -85
  72. package/.agents/workflows/prototype.md +104 -0
  73. package/docs/CHANGELOG.md +32 -0
  74. package/package.json +2 -1
  75. package/.agents/workflows/deliver-light.md +0 -148
@@ -24,10 +24,11 @@
24
24
  * reason is `full`.
25
25
  * 3. **Deterministic backstop at persist.** After authoring, the work has
26
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`).
27
+ * effort and risk — distinct change kinds, declared magnitude,
28
+ * uncertainty, deployable/migration span, and sensitive-path classes
29
+ * against {@link STORY_SHAPE_CEILINGS}. A `lite` claim whose work exceeds
30
+ * them **fails closed to `full`** (`run-plan-persist.js`). Artifact
31
+ * cardinality is deliberately not an axis (Story #4764).
31
32
  * 4. **Deliver re-derives.** `/deliver` computes the route from the fetched
32
33
  * Story body via the **same** shape function at dispatch
33
34
  * ({@link resolveStoryDispatchMode}) and honors it: a lite-shaped Story
@@ -104,35 +105,221 @@ const DEFAULT_COMPLEXITY_GATE = Object.freeze({
104
105
  export const LITE_ROUTE_LABEL = 'route::lite';
105
106
 
106
107
  /**
107
- * Shape ceilings a Story must fit for the `lite` route
108
+ * Effort/risk ceilings a Story's work must fit for the `lite` route
108
109
  * ({@link deriveStoryShape}). Framework constants, not operator knobs — a
109
- * ceiling an operator can widen past what the inline path can safely absorb
110
- * is a ceiling that fails silently. Conservative by construction: `lite` is
111
- * for genuinely trivial, mostly-additive, non-sensitive scopes.
112
- *
113
- * - `maxChanges` — total `changes[]` entries (e.g. one artifact
114
- * plus its test).
115
- * - `maxAcceptance` acceptance-criteria count; more criteria means
116
- * more contract than a trivial scope carries.
117
- * - `maxNonCreateChanges` entries whose assumption is not `creates`
118
- * (refactors-existing / deletes / exists). A lite
119
- * change is mostly additive; touching existing
120
- * surfaces is where trivial-looking work stops
121
- * being trivial.
110
+ * ceiling an operator can widen past what the inline path can safely absorb is
111
+ * a ceiling that fails silently.
112
+ *
113
+ * ## Effort and risk, never artifact cardinality (Story #4764)
114
+ *
115
+ * These ceilings used to count the declared footprint (`maxChanges: 2`,
116
+ * `maxAcceptance: 3`, `maxNonCreateChanges: 1`). Cardinality is the wrong axis
117
+ * in both directions: three identical one-line edits across three files is
118
+ * trivial work with a high count, while a 200-line rewrite of one module is a
119
+ * single change. And the count was read off a footprint the model **declares
120
+ * before doing the work** — a guess, and a gameable one — so counting it
121
+ * rejected genuinely small work (mandrel-bench's hello-world scenario is a
122
+ * server create plus a `package.json` edit plus a test create, structurally
123
+ * over the old ceilings) while admitting whatever an optimistic declaration
124
+ * under-counted.
125
+ *
126
+ * So the axes are effort, risk, and uncertainty, and the **prediction** gate
127
+ * they form is deliberately **coarse**: it rejects clearly-epic work only.
128
+ * Real enforcement belongs to the diff-derived backstop, which sees ground
129
+ * truth instead of a declaration
130
+ * ({@link module:lib/orchestration/light-suitability.checkLightDiffBackstop}).
131
+ *
132
+ * - `maxChangeKinds` — distinct change KINDS, not files. N instances of one
133
+ * mechanical edit is one kind at N sites; enumerating
134
+ * more kinds than this is a multi-capability scope.
135
+ * - `maxMagnitude` — coarse magnitude bucket, declared alongside the
136
+ * footprint: `trivial` < `moderate` < `substantial`.
137
+ * - `maxUncertainty` — is the shape determined by the request
138
+ * (`determined`), or does it still need the design
139
+ * decisions `/plan` exists to resolve
140
+ * (`needs-design`)?
141
+ * - `maxDeployables` — named deployable roots (`apps/<x>`, `packages/<x>`, …)
142
+ * the footprint spans; more than one is epic by
143
+ * construction.
144
+ *
145
+ * Two rules ride beside the ceilings and are not tunable at all: a footprint
146
+ * pairing a migration with its consumers is epic scope, and a footprint
147
+ * intersecting a sensitive-path class routes `full` however small or mechanical
148
+ * it is — the hard gate, unchanged.
122
149
  *
123
150
  * Exposed as the `ceilings` field on every {@link deriveStoryShape} decision
124
- * and exported directly (Story #4740) so the `/deliver-light` suitability gate
151
+ * and exported directly (Story #4740) so the light path's suitability gate
125
152
  * ({@link module:lib/orchestration/light-suitability}) judges a prompt's
126
- * predicted footprint against the **same** ceilings the plan-time shape
127
- * backstop applies — one source, so the light entry point and the plan path can
128
- * never disagree about what shape is trivial.
153
+ * predicted footprint against the **same** axes the plan-time shape backstop
154
+ * applies — one source, so the light entry point and the plan path can never
155
+ * disagree about what work is trivial.
129
156
  */
130
157
  export const STORY_SHAPE_CEILINGS = Object.freeze({
131
- maxChanges: 2,
132
- maxAcceptance: 3,
133
- maxNonCreateChanges: 1,
158
+ maxChangeKinds: 2,
159
+ maxMagnitude: 'moderate',
160
+ maxUncertainty: 'determined',
161
+ maxDeployables: 1,
134
162
  });
135
163
 
164
+ /** Coarse effort buckets, ascending. Anything past `maxMagnitude` routes full. */
165
+ const MAGNITUDE_SCALE = Object.freeze(['trivial', 'moderate', 'substantial']);
166
+
167
+ /** Coarse uncertainty buckets, ascending. */
168
+ const UNCERTAINTY_SCALE = Object.freeze(['determined', 'needs-design']);
169
+
170
+ /**
171
+ * Directory roots whose immediate child is a separately-deployable unit. A
172
+ * footprint spanning two of them is the "multiple deployables" epic signal.
173
+ */
174
+ const DEPLOYABLE_ROOTS = Object.freeze([
175
+ 'apps',
176
+ 'packages',
177
+ 'services',
178
+ 'functions',
179
+ 'workers',
180
+ ]);
181
+
182
+ /** Paths that are schema migrations rather than ordinary source. */
183
+ const MIGRATION_PATH_RE =
184
+ /(?:^|\/)(?:migrations?|migrate)(?:\/|$)|\.sql$|(?:^|\/)schema\.(?:prisma|rb)$/i;
185
+
186
+ /**
187
+ * Place a declared bucket on an ordered scale. **Absent** means "not declared"
188
+ * — no signal, so the coarse gate reads the supplied default rather than
189
+ * rejecting. **Present but unrecognized** is a malformed claim, which cannot be
190
+ * verified as small and therefore fails closed to the worst bucket on the
191
+ * scale.
192
+ *
193
+ * @param {unknown} value
194
+ * @param {readonly string[]} scale Ascending buckets.
195
+ * @param {string} whenAbsent Bucket to assume when nothing was declared.
196
+ * @returns {string}
197
+ */
198
+ function normalizeBucket(value, scale, whenAbsent) {
199
+ if (value === undefined || value === null || value === '') return whenAbsent;
200
+ const key = typeof value === 'string' ? value.trim().toLowerCase() : '';
201
+ return scale.includes(key) ? key : scale[scale.length - 1];
202
+ }
203
+
204
+ /**
205
+ * Resolve the distinct change KINDS in a footprint. An explicit `kinds[]`
206
+ * declaration wins; absent one, each entry's `assumption` is its kind — which
207
+ * is exactly the "N instances of one mechanical edit is one kind at N sites"
208
+ * reading, since N same-assumption entries collapse to one kind.
209
+ *
210
+ * @param {{ changes?: unknown, kinds?: unknown }} args
211
+ * @returns {string[]} Distinct kinds, in order of first appearance.
212
+ */
213
+ function resolveChangeKinds({ changes, kinds }) {
214
+ const clean = (list) =>
215
+ list
216
+ .filter((k) => typeof k === 'string' && k.trim() !== '')
217
+ .map((k) => k.trim().toLowerCase());
218
+ const declared = clean(Array.isArray(kinds) ? kinds : []);
219
+ if (declared.length > 0) return [...new Set(declared)];
220
+ const derived = (Array.isArray(changes) ? changes : []).map((entry) =>
221
+ entry && typeof entry === 'object' && typeof entry.assumption === 'string'
222
+ ? entry.assumption.trim().toLowerCase() || 'unspecified'
223
+ : 'unspecified',
224
+ );
225
+ return [...new Set(derived)];
226
+ }
227
+
228
+ /**
229
+ * Named deployable roots a footprint spans (`apps/web`, `packages/core`, …).
230
+ * The repository root itself is deliberately **not** counted: a change to one
231
+ * app plus a root-level README is one deployable, not two.
232
+ *
233
+ * @param {string[]} paths
234
+ * @returns {string[]}
235
+ */
236
+ function resolveDeployables(paths) {
237
+ const ids = new Set();
238
+ for (const p of paths) {
239
+ const segments = String(p)
240
+ .split('/')
241
+ .filter((s) => s !== '');
242
+ if (segments.length >= 3 && DEPLOYABLE_ROOTS.includes(segments[0])) {
243
+ ids.add(`${segments[0]}/${segments[1]}`);
244
+ }
245
+ }
246
+ return [...ids];
247
+ }
248
+
249
+ /**
250
+ * Does the footprint pair a schema migration with its consumers? A migration
251
+ * plus the code that reads through it is epic scope: the two have to land
252
+ * together and the ordering is the design work.
253
+ *
254
+ * @param {string[]} paths
255
+ * @returns {boolean}
256
+ */
257
+ function spansMigrationAndConsumers(paths) {
258
+ const migrations = paths.filter((p) => MIGRATION_PATH_RE.test(String(p)));
259
+ return migrations.length > 0 && migrations.length < paths.length;
260
+ }
261
+
262
+ /**
263
+ * Ordered effort/risk rules, evaluated in order; the first hit is the recorded
264
+ * reason for a `full` route. Every rule names an effort, risk, or uncertainty
265
+ * property of the work — none counts artifacts.
266
+ *
267
+ * @type {ReadonlyArray<{
268
+ * when: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => boolean,
269
+ * reason: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => string,
270
+ * }>}
271
+ */
272
+ const EFFORT_RULES = Object.freeze([
273
+ {
274
+ when: (s, c) => s.kindCount > c.maxChangeKinds,
275
+ reason: (s, c) =>
276
+ `${s.kindCount} distinct change kinds (${s.changeKinds.join(', ')}) > maxChangeKinds ${c.maxChangeKinds} — an explicit multi-capability enumeration, not one capability; full route`,
277
+ },
278
+ {
279
+ when: (s, c) =>
280
+ MAGNITUDE_SCALE.indexOf(s.magnitude) >
281
+ MAGNITUDE_SCALE.indexOf(c.maxMagnitude),
282
+ reason: (s, c) =>
283
+ `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`,
284
+ },
285
+ {
286
+ when: (s, c) =>
287
+ UNCERTAINTY_SCALE.indexOf(s.uncertainty) >
288
+ UNCERTAINTY_SCALE.indexOf(c.maxUncertainty),
289
+ reason: (s) =>
290
+ `the shape is not determined by the request (uncertainty "${s.uncertainty}") — the design decisions /plan exists to resolve are still open; full route`,
291
+ },
292
+ {
293
+ when: (s, c) => s.deployables.length > c.maxDeployables,
294
+ reason: (s, c) =>
295
+ `footprint spans ${s.deployables.length} deployables (${s.deployables.join(', ')}) > maxDeployables ${c.maxDeployables} — clearly-epic scope; full route`,
296
+ },
297
+ {
298
+ when: (s) => s.migrationSpan,
299
+ reason: () =>
300
+ 'footprint pairs a migration with its consumers — clearly-epic scope; full route',
301
+ },
302
+ {
303
+ when: (s) => s.sensitiveClasses.length > 0,
304
+ reason: (s) =>
305
+ `footprint intersects sensitive-path class(es) ${s.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
306
+ },
307
+ ]);
308
+
309
+ /**
310
+ * First effort/risk rule the shape violates, or `null` when it clears them all.
311
+ *
312
+ * @param {object} shape
313
+ * @param {typeof STORY_SHAPE_CEILINGS} ceilings
314
+ * @returns {string|null}
315
+ */
316
+ function firstEffortViolation(shape, ceilings) {
317
+ for (const rule of EFFORT_RULES) {
318
+ if (rule.when(shape, ceilings)) return rule.reason(shape, ceilings);
319
+ }
320
+ return null;
321
+ }
322
+
136
323
  /**
137
324
  * The non-negotiables the ceremony-lite path preserves (Story #4683 AC-2):
138
325
  * collapsing ceremony never means dropping the Story ticket, the PR-to-`main`
@@ -376,44 +563,98 @@ export function resolvePlannerRouteVerdict({ reason } = {}) {
376
563
  }
377
564
 
378
565
  /**
379
- * Derive the complexity route from an authored Story's **objective shape**
380
- * (Story #4722 AC-3/AC-4) the single shape function persist's backstop and
381
- * `/deliver`'s dispatch derivation both read, so the two can never disagree
382
- * about the same body.
566
+ * Assemble the effort/risk shape of a footprint the evidence
567
+ * {@link deriveStoryShape} decides on and carries on its result.
568
+ *
569
+ * @param {{
570
+ * changes: unknown[],
571
+ * paths: string[],
572
+ * acceptance?: unknown,
573
+ * kinds?: unknown,
574
+ * magnitude?: unknown,
575
+ * uncertainty?: unknown,
576
+ * sensitiveClasses: string[],
577
+ * }} args
578
+ * @returns {{
579
+ * siteCount: number,
580
+ * changeKinds: string[],
581
+ * kindCount: number,
582
+ * magnitude: string,
583
+ * uncertainty: string,
584
+ * acceptanceCount: number,
585
+ * deployables: string[],
586
+ * migrationSpan: boolean,
587
+ * sensitiveClasses: string[],
588
+ * }}
589
+ */
590
+ function buildEffortShape({
591
+ changes,
592
+ paths,
593
+ acceptance,
594
+ kinds,
595
+ magnitude,
596
+ uncertainty,
597
+ sensitiveClasses,
598
+ }) {
599
+ const changeKinds = resolveChangeKinds({ changes, kinds });
600
+ return {
601
+ siteCount: paths.length,
602
+ changeKinds,
603
+ kindCount: changeKinds.length,
604
+ magnitude: normalizeBucket(magnitude, MAGNITUDE_SCALE, 'moderate'),
605
+ uncertainty: normalizeBucket(uncertainty, UNCERTAINTY_SCALE, 'determined'),
606
+ acceptanceCount: Array.isArray(acceptance) ? acceptance.length : 0,
607
+ deployables: resolveDeployables(paths),
608
+ migrationSpan: spansMigrationAndConsumers(paths),
609
+ sensitiveClasses,
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Derive the complexity route from an authored Story's **effort and risk**
615
+ * (Story #4722 AC-3/AC-4; re-anchored off artifact cardinality by Story #4764)
616
+ * — the single shape function persist's backstop and `/deliver`'s dispatch
617
+ * derivation both read, so the two can never disagree about the same body.
383
618
  *
384
619
  * `lite` requires **every** signal to agree, against
385
620
  * {@link STORY_SHAPE_CEILINGS}:
386
621
  *
387
- * - a declared, parseable, glob-free `changes[]` footprint of at most
388
- * `maxChanges` entries, at most `maxNonCreateChanges` of which touch
389
- * existing surfaces (creates-vs-refactors mix);
390
- * - at most `maxAcceptance` acceptance criteria (and at least one a Story
391
- * with no contract cannot be judged trivial);
622
+ * - a declared, parseable, glob-free `changes[]` footprint width is not
623
+ * counted, but an unknown width cannot be judged;
624
+ * - at least one acceptance criterion (a Story with no contract cannot be
625
+ * judged trivial). The criteria are **not** capped: criterion count is
626
+ * contract detail, not effort;
627
+ * - at most `maxChangeKinds` distinct change kinds, magnitude no worse than
628
+ * `maxMagnitude`, uncertainty no worse than `maxUncertainty`, and at most
629
+ * `maxDeployables` deployable roots — plus no migration-with-consumers
630
+ * span. These are the clearly-epic rejections, and nothing finer: the
631
+ * declared footprint is a guess, so the diff-derived backstop does the real
632
+ * enforcement (see {@link STORY_SHAPE_CEILINGS});
392
633
  * - a footprint intersecting **no** sensitive-path class
393
634
  * (`deriveChangeLevel`, the taxonomy close applies to the landed diff).
394
- * Sensitivity always wins (AC-6): a sensitive footprint routes `full`,
395
- * which keeps the fresh acceptance critic via `ceremony-routing.js`.
635
+ * Sensitivity always wins (AC-6): a sensitive footprint routes `full`
636
+ * however small or mechanical, which keeps the fresh acceptance critic via
637
+ * `ceremony-routing.js`.
396
638
  *
397
- * Everything else — including an unknown/undeclared footprint or an
398
- * unreadable sensitive-path manifest — fails toward `full`. Total: never
399
- * throws.
639
+ * Everything else — an unknown/undeclared footprint, a malformed magnitude or
640
+ * uncertainty claim, or an unreadable sensitive-path manifest — fails toward
641
+ * `full`. Total: never throws.
400
642
  *
401
643
  * @param {{
402
644
  * changes?: unknown,
403
645
  * acceptance?: unknown,
646
+ * kinds?: unknown,
647
+ * magnitude?: unknown,
648
+ * uncertainty?: unknown,
404
649
  * injectedRules?: object,
405
650
  * selectSensitivePathClassesFn?: Function,
406
- * }} [args]
651
+ * }} [args] `kinds` declares the distinct change kinds explicitly (absent, each
652
+ * entry's `assumption` is its kind); `magnitude` and `uncertainty` are the
653
+ * declared coarse buckets.
407
654
  * @returns {{
408
655
  * route: ComplexityRoute,
409
656
  * reasons: string[],
410
- * shape: {
411
- * changeCount: number,
412
- * acceptanceCount: number,
413
- * createCount: number,
414
- * nonCreateCount: number,
415
- * sensitiveClasses: string[],
416
- * }|null,
657
+ * shape: ReturnType<typeof buildEffortShape>|null,
417
658
  * ceilings: typeof STORY_SHAPE_CEILINGS,
418
659
  * preserves: typeof LITE_PATH_INVARIANTS,
419
660
  * }}
@@ -421,6 +662,9 @@ export function resolvePlannerRouteVerdict({ reason } = {}) {
421
662
  export function deriveStoryShape({
422
663
  changes,
423
664
  acceptance,
665
+ kinds,
666
+ magnitude,
667
+ uncertainty,
424
668
  injectedRules,
425
669
  selectSensitivePathClassesFn,
426
670
  } = {}) {
@@ -437,7 +681,7 @@ export function deriveStoryShape({
437
681
  if (!Array.isArray(changes) || changes.length === 0) {
438
682
  return decide(
439
683
  'full',
440
- 'no changes[] declared — the footprint is unknown, so the shape cannot be judged trivial; conservative full route',
684
+ 'no changes[] declared — the footprint is unknown, so the work cannot be judged trivial; conservative full route',
441
685
  );
442
686
  }
443
687
 
@@ -451,23 +695,21 @@ export function deriveStoryShape({
451
695
  );
452
696
  }
453
697
 
454
- const acceptanceList = Array.isArray(acceptance) ? acceptance : [];
455
- const nonCreateCount = changes.filter(
456
- (entry) =>
457
- !(entry && typeof entry === 'object' && entry.assumption === 'creates'),
458
- ).length;
698
+ const paths = entries.map((e) => e.path);
459
699
  const { level, classes } = deriveChangeLevel({
460
- changedFiles: entries.map((e) => e.path),
700
+ changedFiles: paths,
461
701
  injectedRules,
462
702
  selectSensitivePathClassesFn,
463
703
  });
464
- const shape = {
465
- changeCount: changes.length,
466
- acceptanceCount: acceptanceList.length,
467
- createCount: changes.length - nonCreateCount,
468
- nonCreateCount,
704
+ const shape = buildEffortShape({
705
+ changes,
706
+ paths,
707
+ acceptance,
708
+ kinds,
709
+ magnitude,
710
+ uncertainty,
469
711
  sensitiveClasses: classes,
470
- };
712
+ });
471
713
 
472
714
  if (entries.some((e) => e.isGlob)) {
473
715
  return decide(
@@ -476,13 +718,6 @@ export function deriveStoryShape({
476
718
  shape,
477
719
  );
478
720
  }
479
- if (shape.changeCount > ceilings.maxChanges) {
480
- return decide(
481
- 'full',
482
- `changes[] declares ${shape.changeCount} entries (> maxChanges ${ceilings.maxChanges}) — not a trivial footprint; full route`,
483
- shape,
484
- );
485
- }
486
721
  if (shape.acceptanceCount === 0) {
487
722
  return decide(
488
723
  'full',
@@ -490,27 +725,10 @@ export function deriveStoryShape({
490
725
  shape,
491
726
  );
492
727
  }
493
- if (shape.acceptanceCount > ceilings.maxAcceptance) {
494
- return decide(
495
- 'full',
496
- `${shape.acceptanceCount} acceptance criteria (> maxAcceptance ${ceilings.maxAcceptance}) — more contract than a trivial scope carries; full route`,
497
- shape,
498
- );
499
- }
500
- if (shape.nonCreateCount > ceilings.maxNonCreateChanges) {
501
- return decide(
502
- 'full',
503
- `${shape.nonCreateCount} non-create change(s) (> maxNonCreateChanges ${ceilings.maxNonCreateChanges}) — a mostly-refactoring mix is not a trivial additive scope; full route`,
504
- shape,
505
- );
506
- }
507
- if (shape.sensitiveClasses.length > 0) {
508
- return decide(
509
- 'full',
510
- `footprint intersects sensitive-path class(es) ${shape.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
511
- shape,
512
- );
513
- }
728
+
729
+ const violation = firstEffortViolation(shape, ceilings);
730
+ if (violation !== null) return decide('full', violation, shape);
731
+
514
732
  if (level !== 'low') {
515
733
  // `deriveChangeLevel` degraded to its null fail-safe (unreadable
516
734
  // manifest / failed selector): there is no evidence the footprint is
@@ -524,7 +742,7 @@ export function deriveStoryShape({
524
742
 
525
743
  return decide(
526
744
  'lite',
527
- `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`,
745
+ `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`,
528
746
  shape,
529
747
  );
530
748
  }
@@ -22,7 +22,11 @@
22
22
  * {@link module:lib/orchestration/complexity-gate.STORY_SHAPE_CEILINGS})
23
23
  * **and** a ledgered model verdict carrying a recorded reason
24
24
  * ({@link resolveLedgeredVerdict}). Both must agree on `lite`; either
25
- * falling short fails closed to `full`.
25
+ * falling short fails closed to `full`. The shape axes are effort and
26
+ * risk — distinct change kinds, a coarse magnitude bucket, uncertainty,
27
+ * and epic-scope span — never artifact counts (Story #4764), so this gate
28
+ * is deliberately **coarse**: it rejects clearly-epic work, and invariant
29
+ * 3 below does the real enforcement against ground truth.
26
30
  * 2. **Over-scope stops, never silently proceeds ({@link
27
31
  * resolveLightGateOutcome}).** An over-ceiling prompt does **not**
28
32
  * hard-fail — it STOPS and asks the operator to escalate to `/plan` or
@@ -49,12 +53,13 @@ import { deriveChangeLevel } from './review-depth.js';
49
53
 
50
54
  /**
51
55
  * File-count ceiling for the **actual landed** change set the diff backstop
52
- * ({@link checkLightDiffBackstop}) enforces. The predicted-shape ceiling caps
53
- * `changes[]` at `maxChanges` (one artifact plus its test); the actual diff may
54
- * legitimately run a touch wider (a generated projection, a snapshot), but a
55
- * genuinely-light change stays small. Conservative by construction a ceiling
56
- * an operator could widen past what a single session safely absorbs is a
57
- * ceiling that fails silently, so this is a framework constant, not a knob.
56
+ * ({@link checkLightDiffBackstop}) enforces. This is the light path's **only**
57
+ * cardinality ceiling, and deliberately so (Story #4764): the predicted
58
+ * footprint is a declaration a guess, and a gameable one — so the gate that
59
+ * counts must be the one reading ground truth. A genuinely-light change stays
60
+ * small; conservative by construction, since a ceiling an operator could widen
61
+ * past what a single session safely absorbs is a ceiling that fails silently.
62
+ * A framework constant, not a knob.
58
63
  */
59
64
  export const LIGHT_DIFF_CEILINGS = Object.freeze({
60
65
  maxFiles: 4,
@@ -122,16 +127,27 @@ export function resolveLedgeredVerdict({ route, reason } = {}) {
122
127
 
123
128
  /**
124
129
  * Judge whether an operator prompt's predicted footprint is suitable for the
125
- * light path. The deterministic shape derivation and the ledgered model verdict
126
- * must **both** agree on `lite`; anything else — an over-ceiling shape, a
130
+ * light path. The deterministic effort/risk derivation and the ledgered model
131
+ * verdict must **both** agree on `lite`; anything else — clearly-epic work, a
127
132
  * sensitive-path footprint, an unledgered verdict — resolves to `full` (the
128
133
  * conservative default that routes the operator to `/plan`).
129
134
  *
135
+ * The predicted axes are declared by the caller: `predictedKinds` (the distinct
136
+ * kinds of change; absent, each entry's `assumption` is its kind, so N
137
+ * instances of one mechanical edit count once), `predictedMagnitude`
138
+ * (`trivial` | `moderate` | `substantial`), and `predictedUncertainty`
139
+ * (`determined` | `needs-design`). A malformed bucket fails closed; an absent
140
+ * one carries no signal, because a marginal footprint must not be rejected on
141
+ * counts the diff backstop is the right place to enforce.
142
+ *
130
143
  * Pure and total: never throws, never mutates its inputs.
131
144
  *
132
145
  * @param {{
133
146
  * predictedChanges?: unknown,
134
147
  * predictedAcceptance?: unknown,
148
+ * predictedKinds?: unknown,
149
+ * predictedMagnitude?: unknown,
150
+ * predictedUncertainty?: unknown,
135
151
  * verdict?: { route?: unknown, reason?: unknown },
136
152
  * injectedRules?: object,
137
153
  * selectSensitivePathClassesFn?: Function,
@@ -148,6 +164,9 @@ export function resolveLedgeredVerdict({ route, reason } = {}) {
148
164
  export function deriveLightSuitability({
149
165
  predictedChanges,
150
166
  predictedAcceptance,
167
+ predictedKinds,
168
+ predictedMagnitude,
169
+ predictedUncertainty,
151
170
  verdict,
152
171
  injectedRules,
153
172
  selectSensitivePathClassesFn,
@@ -156,6 +175,9 @@ export function deriveLightSuitability({
156
175
  const shape = deriveStoryShape({
157
176
  changes: predictedChanges,
158
177
  acceptance: predictedAcceptance,
178
+ kinds: predictedKinds,
179
+ magnitude: predictedMagnitude,
180
+ uncertainty: predictedUncertainty,
159
181
  injectedRules,
160
182
  selectSensitivePathClassesFn,
161
183
  });