mandrel 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.agents/docs/configuration.md +35 -33
  2. package/.agents/rules/orchestration-error-handling.md +9 -1
  3. package/.agents/schemas/agentrc.schema.json +13 -8
  4. package/.agents/scripts/acceptance-eval.js +9 -5
  5. package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
  6. package/.agents/scripts/lib/audit-suite/selector.js +1 -26
  7. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  8. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  9. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  10. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  11. package/.agents/scripts/lib/config/quality.js +13 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  13. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  14. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  15. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +561 -184
  17. package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
  18. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +117 -60
  19. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +28 -7
  21. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  22. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
  23. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
  24. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  25. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  26. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  27. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  28. package/.agents/scripts/plan-context.js +23 -5
  29. package/.agents/scripts/resolve-stories.js +2 -0
  30. package/.agents/workflows/deliver.md +28 -28
  31. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  32. package/.agents/workflows/helpers/deliver-digest.md +126 -0
  33. package/.agents/workflows/helpers/deliver-reference.md +30 -5
  34. package/.agents/workflows/helpers/deliver-story-reference.md +38 -12
  35. package/.agents/workflows/helpers/deliver-story.md +34 -37
  36. package/.agents/workflows/helpers/plan-reference.md +79 -44
  37. package/.agents/workflows/plan.md +11 -10
  38. package/docs/CHANGELOG.md +31 -0
  39. package/lib/cli/registry.js +31 -14
  40. package/lib/migrations/index.js +2 -0
  41. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  42. package/package.json +1 -1
@@ -26,7 +26,7 @@ import {
26
26
  renderTechSpecSystemPrompt,
27
27
  } from '../templates/spec-author-prompts.js';
28
28
  import { concurrentMap } from '../util/concurrent-map.js';
29
- import { buildComplexityRouteSignal } from './complexity-gate.js';
29
+ import { buildComplexitySignals } from './complexity-gate.js';
30
30
  import { parseDeliverySlicingTable } from './consolidation-precondition.js';
31
31
  import { buildDocsDigest } from './docs-digest.js';
32
32
  import { buildAuthoringContext } from './planning/authoring-context.js';
@@ -144,6 +144,41 @@ export const TICKET_SCHEMA_DESCRIPTOR = Object.freeze({
144
144
  */
145
145
  export const STORIES_TEMPLATE_FILENAME = 'stories.template.json';
146
146
 
147
+ /**
148
+ * Build the template's `changes[]` entries from the envelope's advisory
149
+ * complexity signals (Story #4723). Each seed-predicted path is
150
+ * pre-resolved to its creates-vs-refactors assumption against the repo
151
+ * snapshot the signals already probed: a path present in the repo is a
152
+ * `refactors-existing`, a missing one is a `creates`. Order follows
153
+ * `predictedPaths` (first appearance in the seed). Falls back to the
154
+ * single instructive placeholder entry when the seed predicted no paths.
155
+ *
156
+ * @param {{
157
+ * predictedPaths?: string[],
158
+ * repoState?: { existingPaths?: string[], missingPaths?: string[] },
159
+ * }|null|undefined} complexitySignals
160
+ * @returns {Array<{ path: string, assumption: string }>}
161
+ */
162
+ function buildTemplateChanges(complexitySignals) {
163
+ const predicted = Array.isArray(complexitySignals?.predictedPaths)
164
+ ? complexitySignals.predictedPaths.filter(
165
+ (p) => typeof p === 'string' && p.length > 0,
166
+ )
167
+ : [];
168
+ if (predicted.length === 0) {
169
+ return [{ path: 'path/to/file.ext', assumption: 'refactors-existing' }];
170
+ }
171
+ const existing = new Set(
172
+ Array.isArray(complexitySignals?.repoState?.existingPaths)
173
+ ? complexitySignals.repoState.existingPaths
174
+ : [],
175
+ );
176
+ return predicted.map((path) => ({
177
+ path,
178
+ assumption: existing.has(path) ? 'refactors-existing' : 'creates',
179
+ }));
180
+ }
181
+
147
182
  /**
148
183
  * Render the ready-to-fill `stories.json` authoring template (Story #4707).
149
184
  *
@@ -158,12 +193,23 @@ export const STORIES_TEMPLATE_FILENAME = 'stories.template.json';
158
193
  * / `verify[]` live at the ticket's top level — the machine contract persist
159
194
  * syncs into the body.
160
195
  *
196
+ * Correct-by-construction skeleton (Story #4723): the emitted `verify[]`
197
+ * placeholder already ends with a valid `(tier)` tag (swap `(unit)` for
198
+ * `(contract)` / `(e2e)` / `(validate)` where appropriate), and when the
199
+ * envelope's `complexitySignals` predicted a footprint the `changes[]`
200
+ * entries arrive pre-resolved to creates-vs-refactors against the repo
201
+ * snapshot — a faithfully-filled skeleton passes the persist ticket
202
+ * validators without a mechanical round-trip. The persist gates stay
203
+ * authoritative (they probe the base branch ref, not the working tree).
204
+ *
161
205
  * Pure and deterministic; the output is valid JSON (parseable as-is), with
162
206
  * instructive placeholder values rather than comments.
163
207
  *
208
+ * @param {{ complexitySignals?: object|null }} [opts] Envelope signals to
209
+ * pre-resolve the skeleton against; omit for the bare placeholder shape.
164
210
  * @returns {string} Pretty-printed JSON template content.
165
211
  */
166
- export function renderStoriesTemplate() {
212
+ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
167
213
  const template = [
168
214
  {
169
215
  slug: 'fill-hyphen-case-slug',
@@ -176,11 +222,9 @@ export function renderStoriesTemplate() {
176
222
  'codes, security invariants, and load-bearing constraints with ' +
177
223
  'their why. Implementation choices belong to the deliverer unless ' +
178
224
  'load-bearing. No per-file behavior paragraphs, no current-state ' +
179
- 'narration. Delete this field when acceptance[] carries the whole ' +
180
- 'contract.',
181
- changes: [
182
- { path: 'path/to/file.ext', assumption: 'refactors-existing' },
183
- ],
225
+ 'narration. Keep it under ~250 words (soft advisory budget). ' +
226
+ 'Delete this field when acceptance[] carries the whole contract.',
227
+ changes: buildTemplateChanges(complexitySignals),
184
228
  non_goals: [],
185
229
  reason_to_exist:
186
230
  'Fill: the single coherent reason this Story exists (one sentence).',
@@ -188,7 +232,9 @@ export function renderStoriesTemplate() {
188
232
  acceptance: [
189
233
  'Fill: a testable, observable criterion (a command exits 0, a file exists, a test matches)',
190
234
  ],
191
- verify: ['Fill: exact command or test path (unit|contract|e2e|validate)'],
235
+ verify: [
236
+ 'Fill: exact command or test path — keep the trailing tier tag valid: unit, contract, e2e, or validate (unit)',
237
+ ],
192
238
  depends_on: [],
193
239
  },
194
240
  ];
@@ -540,7 +586,15 @@ async function buildSeedFileModeEnvelope({
540
586
  return {
541
587
  mode: modeLabel,
542
588
  seed: { path: seedFilePath ?? null, content },
543
- complexityRoute: buildComplexityRouteSignal({ seedText: content, config }),
589
+ // Advisory complexity signals only (Story #4722): no route, no routing
590
+ // authority. The planner authors the trivial-vs-standard verdict; persist
591
+ // validates a lite claim against the authored Story's shape.
592
+ complexitySignals: buildComplexitySignals({
593
+ seedText: content,
594
+ config,
595
+ riskHeuristics: heuristics,
596
+ cwd,
597
+ }),
544
598
  duplicates,
545
599
  docsContext,
546
600
  codebaseSnapshot: authoring.codebaseSnapshot,
@@ -694,7 +748,12 @@ async function buildTicketsModeEnvelope({
694
748
  mode: 'tickets',
695
749
  sourceTickets,
696
750
  seed: { text: seed, path: null },
697
- complexityRoute: buildComplexityRouteSignal({ seedText: seed, config }),
751
+ complexitySignals: buildComplexitySignals({
752
+ seedText: seed,
753
+ config,
754
+ riskHeuristics: heuristics,
755
+ cwd,
756
+ }),
698
757
  duplicates,
699
758
  docsContext,
700
759
  codebaseSnapshot: authoring.codebaseSnapshot,
@@ -44,7 +44,12 @@ import { anchorTempRoot, tempRootFrom } from '../../config/temp-paths.js';
44
44
  import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
45
45
  import { gitSpawn } from '../../git-utils.js';
46
46
  import { Logger } from '../../Logger.js';
47
- import { applyPlannerDowngrade, LITE_ROUTE_LABEL } from '../complexity-gate.js';
47
+ import {
48
+ deriveStoryShape,
49
+ LITE_ROUTE_LABEL,
50
+ resolveComplexityGate,
51
+ resolvePlannerRouteVerdict,
52
+ } from '../complexity-gate.js';
48
53
  import {
49
54
  appendCriticSkip,
50
55
  readPlanMetrics,
@@ -264,65 +269,108 @@ async function renderRunScopedPlanMetricsLine({
264
269
 
265
270
  /**
266
271
  * Resolve the plan's **effective** complexity route for persist
267
- * (Story #4707).
272
+ * (Story #4722, superseding the envelope-verdict model of Story #4707).
273
+ *
274
+ * Two staged inputs, no word count anywhere:
268
275
  *
269
- * The deterministic verdict rides in on the captured plan-context envelope's
270
- * `complexityRoute`. Layered on top is the audited planner downgrade: a
271
- * `full` verdict downgrades to `lite` **only** when the operator/planner
272
- * passed `--route-downgrade-reason` with a non-empty reason
273
- * (`applyPlannerDowngrade`absent a recorded reason the deterministic
274
- * verdict stands, and the gate itself still fails toward `full`).
276
+ * 1. **The planner's authored verdict** `--route-downgrade-reason` is the
277
+ * lite claim's recorded reason (`resolvePlannerRouteVerdict`). No
278
+ * recorded reason means no claim: the plan persists as standard `full`
279
+ * and nothing is ledgered (`null`).
280
+ * 2. **The deterministic shape backstop** — a lite claim is validated
281
+ * against every assembled Story's own shape (`deriveStoryShape` over its
282
+ * `changes[]`, acceptance count, creates-vs-refactors mix, and
283
+ * sensitive-path classes). Any Story exceeding the ceilings **fails the
284
+ * claim closed to `full`** — the honest gate: after authoring, the work
285
+ * has measurable shape, so complexity is read from the work, not guessed
286
+ * from the seed.
275
287
  *
276
288
  * The resolved route decides whether the created Stories carry the
277
- * {@link LITE_ROUTE_LABEL} marker and a `route` block on their
278
- * `story-plan-state` checkpoint the persisted, ledgered record `/deliver`
279
- * reads. A full route persists **no** marker and no checkpoint block:
280
- * absence is the conservative default.
289
+ * {@link LITE_ROUTE_LABEL} **hint** (never the control signal `/deliver`
290
+ * re-derives the route from the Story body's shape) and the `route` block
291
+ * ledgered on their `story-plan-state` checkpoint, including the authored
292
+ * verdict, its recorded reason, and the per-Story shape evidence. A refused
293
+ * claim is ledgered too (route `full` with the refusal reasons), so the
294
+ * judgment stays auditable either way.
281
295
  *
282
296
  * Module-private: reachable end to end through {@link runPlanPersist}
283
297
  * (whose result reports the resolved route), so there is no test-only
284
298
  * export to leave production-dead.
285
299
  *
286
- * @param {{ planContextEnvelope?: object|null, routeDowngradeReason?: string|null }} args
287
- * @returns {{ route: 'lite'|'full', reasons: string[], downgraded: { from: 'full', reason: string }|null }|null}
288
- * `null` when no envelope carried a verdict (nothing to persist).
300
+ * @param {{
301
+ * stories: ReturnType<typeof assemblePlanStories>['stories'],
302
+ * routeDowngradeReason?: string|null,
303
+ * config?: object,
304
+ * injectedRules?: object,
305
+ * }} args
306
+ * @returns {{
307
+ * route: 'lite'|'full',
308
+ * reasons: string[],
309
+ * authored: { route: 'lite', reason: string },
310
+ * shape: Array<{ slug: string, route: string, reasons: string[], shape: object|null }>,
311
+ * }|null} `null` when the planner authored no verdict (nothing to persist).
289
312
  */
290
313
  function resolveEffectiveRoute({
291
- planContextEnvelope = null,
314
+ stories,
292
315
  routeDowngradeReason = null,
293
- } = {}) {
294
- const verdict = planContextEnvelope?.complexityRoute ?? null;
295
- if (!verdict || typeof verdict !== 'object') {
296
- if (
297
- typeof routeDowngradeReason === 'string' &&
298
- routeDowngradeReason.trim() !== ''
299
- ) {
300
- Logger.warn(
301
- '[plan-persist] --route-downgrade-reason was passed but no captured ' +
302
- 'plan-context envelope carries a complexityRoute verdict there ' +
303
- 'is no full verdict to downgrade, so the plan persists as full ' +
304
- '(no route marker).',
305
- );
306
- }
307
- return null;
316
+ config = {},
317
+ injectedRules,
318
+ }) {
319
+ const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
320
+ if (verdict.route !== 'lite') return null;
321
+
322
+ // The schema's documented contract: with the gate disabled
323
+ // (`planning.complexityGate.enabled=false`), persist refuses lite claims —
324
+ // the same switch dispatch reads (`resolveStoryDispatchMode` falls back to
325
+ // sub-agent), so neither read point can honor a lite claim the operator
326
+ // has switched off. The refusal is ledgered like any other, keeping the
327
+ // judgment auditable.
328
+ if (!resolveComplexityGate(config).enabled) {
329
+ return {
330
+ route: 'full',
331
+ reasons: [
332
+ 'planner lite verdict refused: complexity routing is disabled ' +
333
+ '(planning.complexityGate.enabled=false)',
334
+ ],
335
+ authored: verdict.authored,
336
+ shape: [],
337
+ };
308
338
  }
309
- const effective = applyPlannerDowngrade(verdict, {
310
- reason: routeDowngradeReason,
339
+
340
+ const perStory = (Array.isArray(stories) ? stories : []).map((story) => {
341
+ const derived = deriveStoryShape({
342
+ changes: story.bodyObject?.changes,
343
+ acceptance: story.acceptance,
344
+ injectedRules,
345
+ });
346
+ return {
347
+ slug: story.slug,
348
+ route: derived.route,
349
+ reasons: derived.reasons,
350
+ shape: derived.shape,
351
+ };
311
352
  });
312
- if (
313
- typeof routeDowngradeReason === 'string' &&
314
- routeDowngradeReason.trim() !== '' &&
315
- effective.downgraded == null
316
- ) {
317
- Logger.warn(
318
- '[plan-persist] --route-downgrade-reason had no effect: the envelope ' +
319
- `verdict is already "${verdict.route}" — nothing to downgrade.`,
320
- );
353
+ const offenders = perStory.filter((entry) => entry.route !== 'lite');
354
+ if (offenders.length > 0) {
355
+ return {
356
+ route: 'full',
357
+ reasons: [
358
+ `planner lite verdict refused: ${offenders.length} of ${perStory.length} ` +
359
+ 'Story(ies) exceed the lite shape ceilings failing closed to full',
360
+ ...offenders.map((entry) => `${entry.slug}: ${entry.reasons[0]}`),
361
+ ],
362
+ authored: verdict.authored,
363
+ shape: perStory,
364
+ };
321
365
  }
322
366
  return {
323
- route: effective.route === 'lite' ? 'lite' : 'full',
324
- reasons: Array.isArray(effective.reasons) ? effective.reasons : [],
325
- downgraded: effective.downgraded ?? null,
367
+ route: 'lite',
368
+ reasons: [
369
+ ...verdict.reasons,
370
+ 'shape backstop: every authored Story fits the lite shape ceilings',
371
+ ],
372
+ authored: verdict.authored,
373
+ shape: perStory,
326
374
  };
327
375
  }
328
376
 
@@ -410,6 +458,7 @@ export async function reapStalePlanDirs({
410
458
  * sourceTicketOrigin?: 'flag'|'envelope'|'none',
411
459
  * closeSuperseded?: boolean,
412
460
  * routeDowngradeReason?: string|null,
461
+ * injectedRules?: object,
413
462
  * },
414
463
  * }} input
415
464
  */
@@ -424,7 +473,6 @@ export async function runPlanPersist({
424
473
  stories: rawStories = null,
425
474
  techSpecContent = null,
426
475
  planAcceptance = null,
427
- planContextEnvelope = null,
428
476
  } = artifacts ?? {};
429
477
  const {
430
478
  forceReview = false,
@@ -439,6 +487,7 @@ export async function runPlanPersist({
439
487
  sourceTicketOrigin = 'none',
440
488
  closeSuperseded = true,
441
489
  routeDowngradeReason = null,
490
+ injectedRules = undefined,
442
491
  } = opts;
443
492
 
444
493
  // Boundary for the plan-metrics summary below: everything this invocation
@@ -513,22 +562,29 @@ export async function runPlanPersist({
513
562
  sourceTicketIds,
514
563
  });
515
564
 
516
- // Effective complexity route (Story #4707): envelope verdict, plus the
517
- // audited planner downgrade when --route-downgrade-reason was recorded.
518
- // Lite persists the `route::lite` marker + a checkpoint block; full
519
- // persists nothing.
565
+ // Effective complexity route (Story #4722): the planner's authored lite
566
+ // verdict (recorded reason), validated against every assembled Story's own
567
+ // shape a claim exceeding the shape ceilings fails closed to full. Lite
568
+ // persists the `route::lite` HINT label + a checkpoint route block; a
569
+ // refused claim ledgers the refusal (no label); no claim persists nothing.
520
570
  const route = resolveEffectiveRoute({
521
- planContextEnvelope,
571
+ stories,
522
572
  routeDowngradeReason,
573
+ config,
574
+ injectedRules,
523
575
  });
524
576
  const isLiteRoute = route?.route === 'lite';
525
577
  if (isLiteRoute) {
526
578
  Logger.info(
527
- `[plan-persist] ceremony-lite route: created Stories carry the ` +
528
- `${LITE_ROUTE_LABEL} marker` +
529
- (route.downgraded
530
- ? ` (planner downgrade, recorded reason: ${route.downgraded.reason})`
531
- : ' (deterministic gate verdict)'),
579
+ `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
580
+ `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
581
+ `(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
582
+ 'the route from each Story body — the label is never the control signal.',
583
+ );
584
+ } else if (route) {
585
+ Logger.warn(
586
+ `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
587
+ '(no route hint label).',
532
588
  );
533
589
  }
534
590
 
@@ -586,10 +642,11 @@ export async function runPlanPersist({
586
642
  id: createdStory.id,
587
643
  })),
588
644
  },
589
- // Ledger the lite route — including any planner downgrade and its
590
- // recorded reason on plan state (Story #4707). A full route writes
591
- // no block: absence of the marker is the full path.
592
- ...(isLiteRoute ? { route } : {}),
645
+ // Ledger the authored route verdict the recorded reason and the
646
+ // per-Story shape evidence, including a shape-refused claim on plan
647
+ // state (Story #4722). No authored verdict writes no block: absence
648
+ // is the standard full path.
649
+ ...(route ? { route } : {}),
593
650
  });
594
651
  }
595
652
  await upsertStructuredComment(
@@ -46,7 +46,11 @@ export const PLAN_RUN_LABEL_PREFIX = 'plan-run::';
46
46
  /** Stable color for the cohort grouping label (`ensureLabels`). */
47
47
  const PLAN_RUN_LABEL_COLOR = '#C5DEF5';
48
48
 
49
- /** Stable color for the `route::lite` ceremony-route marker (Story #4707). */
49
+ /**
50
+ * Stable color for the `route::lite` ceremony-route hint (Story #4707;
51
+ * hint-only since Story #4722 — `/deliver` re-derives the route from the
52
+ * Story body's shape).
53
+ */
50
54
  const LITE_ROUTE_LABEL_COLOR = '#D4C5F9';
51
55
 
52
56
  /** Length of the derived plan-run id (hex chars). */
@@ -694,9 +698,10 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
694
698
  * route, and an opaque derived label never exists yet.
695
699
  *
696
700
  * **Non-fatal by design**, matching the native-blocked_by mirroring posture:
697
- * neither label is load-bearing for correctness (grouping is cosmetic; a
698
- * missing route marker degrades a lite Story to the standard safer —
699
- * sub-agent dispatch), so it is never a reason to fail persist. On an ensure
701
+ * neither label is load-bearing for correctness (grouping is cosmetic, and
702
+ * the route label is a human-visible hint only `/deliver` re-derives the
703
+ * route from the Story body's shape, Story #4722), so it is never a reason
704
+ * to fail persist. On an ensure
700
705
  * failure (throw, or the label reported `missing` by the post-loop
701
706
  * reconcile) the create loop proceeds **without** the label — applying an
702
707
  * unensured label could fail the issue create itself, and the Stories matter
@@ -782,15 +787,16 @@ async function ensurePersistLabel({
782
787
  * every id is known (Story #4544), so plan-created order stops depending on
783
788
  * prose. That pass is non-fatal — see `mirrorNativeDependencyEdges`.
784
789
  *
785
- * **A lite-routed cohort carries the `route::lite` marker** (Story #4707).
786
- * When the caller resolves the plan's effective complexity route to `lite`
787
- * (envelope verdict, or an audited planner downgrade), it passes the marker
788
- * via `opts.routeLabel` and every created Story carries it the persisted,
789
- * `/deliver`-readable form of the route (`resolveStoryDispatchMode`). A
790
- * full-routed plan passes nothing and its Stories carry **no** route marker.
791
- * Like the cohort label, the ensure is non-fatal: a Story created without
792
- * the marker degrades to the standard sub-agent dispatch, never to a skipped
793
- * gate.
790
+ * **A lite-routed cohort carries the `route::lite` hint** (Story #4707,
791
+ * hint-only since Story #4722). When the caller resolves the plan's
792
+ * effective complexity route to `lite` (the planner's recorded verdict,
793
+ * upheld by the shape backstop), it passes the label via `opts.routeLabel`
794
+ * and every created Story carries it — a **human-visible hint only**, never
795
+ * the control signal: `/deliver` re-derives the route from each Story body's
796
+ * own shape (`resolveStoryDispatchMode`), so a lost or failed label write
797
+ * cannot misroute delivery. A full-routed plan passes nothing and its
798
+ * Stories carry **no** route label. Like the cohort label, the ensure is
799
+ * non-fatal — the label is cosmetic either way.
794
800
  *
795
801
  * @param {object} args
796
802
  * @param {object} args.provider
@@ -857,8 +863,8 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
857
863
  label: routeLabel,
858
864
  color: LITE_ROUTE_LABEL_COLOR,
859
865
  description:
860
- 'Ceremony-lite route marker: /deliver executes this Story inline ' +
861
- '(no sub-agent fan-out); every close gate runs unchanged.',
866
+ 'Ceremony-lite hint (human-visible only): /deliver re-derives the ' +
867
+ 'route from the Story body shape; every close gate runs unchanged.',
862
868
  role: 'route-marker',
863
869
  }));
864
870
 
@@ -300,6 +300,9 @@ export async function readNativeBlockedBy({
300
300
  * @param {Map<number, number[]>} nativeEdges
301
301
  * @param {number[]} foreignDone Ids outside the set already satisfied.
302
302
  * @param {(msg: string) => void} [warn]
303
+ * @param {object} [injectedRules] Test seam forwarded to the shape
304
+ * derivation — skips the `audit-rules.json` disk read. Production callers
305
+ * omit it (the real manifest, memoized per process, is the default).
303
306
  * @returns {{ kind: string, stories: object[], dag: object[], done: number[] }}
304
307
  */
305
308
  export function buildStoriesEnvelope({
@@ -307,23 +310,41 @@ export function buildStoriesEnvelope({
307
310
  nativeEdges = new Map(),
308
311
  foreignDone = [],
309
312
  warn,
313
+ config,
314
+ injectedRules,
310
315
  }) {
311
316
  const sorted = [...stories].sort((a, b) => a.id - b.id);
312
317
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
313
318
  return {
314
319
  kind: 'stories',
315
- // `dispatchMode` (Story #4707): the resolver derives the per-Story
316
- // execution mode from the persisted `route::lite` marker so `/deliver`
317
- // reads one field `inline` (lite: no story-worker / acceptance-critic
318
- // sub-agent boots) or `subagent` (everything else, the conservative
319
- // default). Model-side fan-out only; close gates are untouched.
320
- stories: sorted.map(({ id, title, url, labels, state }) => ({
320
+ // `dispatchMode` (Story #4722): the resolver derives the per-Story
321
+ // execution mode from the fetched Story BODY's own shape (the shared
322
+ // shape function in `complexity-gate.js`) so `/deliver` reads one field —
323
+ // `inline` (lite-shaped: no story-worker / acceptance-critic sub-agent
324
+ // boots) or `subagent` (everything else, the conservative default). The
325
+ // `route::lite` label is a human-visible hint only, never the control
326
+ // signal: a lost label cannot misroute delivery. Model-side fan-out
327
+ // only; close gates are untouched.
328
+ //
329
+ // `storyCount` (Story #4736) carries the run's topology into that same
330
+ // decision: a run resolving exactly ONE Story is inline whatever its
331
+ // shape, because the isolation a sub-agent buys only matters against a
332
+ // concurrently-dispatched sibling. It is the resolved set size — not the
333
+ // undelivered remainder — so the mode a caller reads for a given `--ids`
334
+ // list never changes as siblings land mid-run.
335
+ stories: sorted.map(({ id, title, body, url, labels, state }) => ({
321
336
  id,
322
337
  title,
323
338
  url,
324
339
  labels,
325
340
  state,
326
- dispatchMode: resolveStoryDispatchMode({ labels }).mode,
341
+ dispatchMode: resolveStoryDispatchMode({
342
+ body,
343
+ labels,
344
+ config,
345
+ storyCount: sorted.length,
346
+ injectedRules,
347
+ }).mode,
327
348
  })),
328
349
  dag: storiesToDag(sorted, nativeEdges, warn),
329
350
  done: [...new Set([...inSetDone, ...foreignDone])].sort((a, b) => a - b),
@@ -74,10 +74,15 @@ export const DEFAULT_DIFF_WIDTH = Object.freeze({
74
74
  * changed-file set intersect any sensitive-path class registered in
75
75
  * `audit-rules.json`?
76
76
  *
77
- * This is the **single source** of the derived level — both the review depth
78
- * ({@link resolveDepth}) and the acceptance-critic fresh-vs-inline routing
79
- * (`ceremony-routing.js#resolveCeremonyForRisk`) consume what this returns, so
80
- * the two ceremony decisions can never disagree about how risky a change is.
77
+ * This is the **single source** of the derived level — the review depth
78
+ * ({@link resolveDepth}), the acceptance-critic fresh-vs-inline routing
79
+ * (`ceremony-routing.js#resolveCeremonyForRisk`), and the dispatch-side
80
+ * complexity routing (`complexity-gate.js#deriveStoryShape`, Story #4722)
81
+ * all consume what this returns, so no ceremony decision can disagree about
82
+ * how risky a change is. Dispatch reads the **predicted** shape (the Story's
83
+ * declared `changes[]` footprint) and close reads the **actual** diff — one
84
+ * taxonomy, two read points, which is what keeps a lite-shaped Story whose
85
+ * footprint touches a sensitive path on the full route with its fresh critic.
81
86
  *
82
87
  * Returns `null` — the fail-safe "no derivable signal" level — when the change
83
88
  * set is empty/unknown or the manifest cannot be read. Both downstream