mandrel 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.agents/docs/configuration.md +35 -33
  2. package/.agents/schemas/agentrc.schema.json +13 -8
  3. package/.agents/scripts/acceptance-eval.js +9 -5
  4. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  5. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  6. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  7. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  8. package/.agents/scripts/lib/config/quality.js +13 -0
  9. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  10. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  11. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  12. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  13. package/.agents/scripts/lib/orchestration/complexity-gate.js +509 -180
  14. package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
  15. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +111 -60
  16. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
  17. package/.agents/scripts/lib/orchestration/resolve-stories.js +11 -7
  18. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  19. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  20. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  21. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  23. package/.agents/scripts/plan-context.js +23 -5
  24. package/.agents/scripts/resolve-stories.js +2 -0
  25. package/.agents/workflows/deliver.md +2 -0
  26. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  27. package/.agents/workflows/helpers/deliver-reference.md +9 -5
  28. package/.agents/workflows/helpers/deliver-story-reference.md +32 -12
  29. package/.agents/workflows/helpers/deliver-story.md +4 -3
  30. package/.agents/workflows/helpers/plan-reference.md +79 -44
  31. package/.agents/workflows/plan.md +11 -10
  32. package/docs/CHANGELOG.md +19 -0
  33. package/lib/cli/registry.js +31 -14
  34. package/lib/migrations/index.js +2 -0
  35. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  36. package/package.json +1 -1
@@ -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,105 @@ 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
+ * }} args
305
+ * @returns {{
306
+ * route: 'lite'|'full',
307
+ * reasons: string[],
308
+ * authored: { route: 'lite', reason: string },
309
+ * shape: Array<{ slug: string, route: string, reasons: string[], shape: object|null }>,
310
+ * }|null} `null` when the planner authored no verdict (nothing to persist).
289
311
  */
290
312
  function resolveEffectiveRoute({
291
- planContextEnvelope = null,
313
+ stories,
292
314
  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;
315
+ config = {},
316
+ }) {
317
+ const verdict = resolvePlannerRouteVerdict({ reason: routeDowngradeReason });
318
+ if (verdict.route !== 'lite') return null;
319
+
320
+ // The schema's documented contract: with the gate disabled
321
+ // (`planning.complexityGate.enabled=false`), persist refuses lite claims —
322
+ // the same switch dispatch reads (`resolveStoryDispatchMode` falls back to
323
+ // sub-agent), so neither read point can honor a lite claim the operator
324
+ // has switched off. The refusal is ledgered like any other, keeping the
325
+ // judgment auditable.
326
+ if (!resolveComplexityGate(config).enabled) {
327
+ return {
328
+ route: 'full',
329
+ reasons: [
330
+ 'planner lite verdict refused: complexity routing is disabled ' +
331
+ '(planning.complexityGate.enabled=false)',
332
+ ],
333
+ authored: verdict.authored,
334
+ shape: [],
335
+ };
308
336
  }
309
- const effective = applyPlannerDowngrade(verdict, {
310
- reason: routeDowngradeReason,
337
+
338
+ const perStory = (Array.isArray(stories) ? stories : []).map((story) => {
339
+ const derived = deriveStoryShape({
340
+ changes: story.bodyObject?.changes,
341
+ acceptance: story.acceptance,
342
+ });
343
+ return {
344
+ slug: story.slug,
345
+ route: derived.route,
346
+ reasons: derived.reasons,
347
+ shape: derived.shape,
348
+ };
311
349
  });
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
- );
350
+ const offenders = perStory.filter((entry) => entry.route !== 'lite');
351
+ if (offenders.length > 0) {
352
+ return {
353
+ route: 'full',
354
+ reasons: [
355
+ `planner lite verdict refused: ${offenders.length} of ${perStory.length} ` +
356
+ 'Story(ies) exceed the lite shape ceilings failing closed to full',
357
+ ...offenders.map((entry) => `${entry.slug}: ${entry.reasons[0]}`),
358
+ ],
359
+ authored: verdict.authored,
360
+ shape: perStory,
361
+ };
321
362
  }
322
363
  return {
323
- route: effective.route === 'lite' ? 'lite' : 'full',
324
- reasons: Array.isArray(effective.reasons) ? effective.reasons : [],
325
- downgraded: effective.downgraded ?? null,
364
+ route: 'lite',
365
+ reasons: [
366
+ ...verdict.reasons,
367
+ 'shape backstop: every authored Story fits the lite shape ceilings',
368
+ ],
369
+ authored: verdict.authored,
370
+ shape: perStory,
326
371
  };
327
372
  }
328
373
 
@@ -424,7 +469,6 @@ export async function runPlanPersist({
424
469
  stories: rawStories = null,
425
470
  techSpecContent = null,
426
471
  planAcceptance = null,
427
- planContextEnvelope = null,
428
472
  } = artifacts ?? {};
429
473
  const {
430
474
  forceReview = false,
@@ -513,22 +557,28 @@ export async function runPlanPersist({
513
557
  sourceTicketIds,
514
558
  });
515
559
 
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.
560
+ // Effective complexity route (Story #4722): the planner's authored lite
561
+ // verdict (recorded reason), validated against every assembled Story's own
562
+ // shape a claim exceeding the shape ceilings fails closed to full. Lite
563
+ // persists the `route::lite` HINT label + a checkpoint route block; a
564
+ // refused claim ledgers the refusal (no label); no claim persists nothing.
520
565
  const route = resolveEffectiveRoute({
521
- planContextEnvelope,
566
+ stories,
522
567
  routeDowngradeReason,
568
+ config,
523
569
  });
524
570
  const isLiteRoute = route?.route === 'lite';
525
571
  if (isLiteRoute) {
526
572
  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)'),
573
+ `[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
574
+ `created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
575
+ `(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
576
+ 'the route from each Story body — the label is never the control signal.',
577
+ );
578
+ } else if (route) {
579
+ Logger.warn(
580
+ `[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
581
+ '(no route hint label).',
532
582
  );
533
583
  }
534
584
 
@@ -586,10 +636,11 @@ export async function runPlanPersist({
586
636
  id: createdStory.id,
587
637
  })),
588
638
  },
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 } : {}),
639
+ // Ledger the authored route verdict the recorded reason and the
640
+ // per-Story shape evidence, including a shape-refused claim on plan
641
+ // state (Story #4722). No authored verdict writes no block: absence
642
+ // is the standard full path.
643
+ ...(route ? { route } : {}),
593
644
  });
594
645
  }
595
646
  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
 
@@ -307,23 +307,27 @@ export function buildStoriesEnvelope({
307
307
  nativeEdges = new Map(),
308
308
  foreignDone = [],
309
309
  warn,
310
+ config,
310
311
  }) {
311
312
  const sorted = [...stories].sort((a, b) => a.id - b.id);
312
313
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
313
314
  return {
314
315
  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 }) => ({
316
+ // `dispatchMode` (Story #4722): the resolver derives the per-Story
317
+ // execution mode from the fetched Story BODY's own shape (the shared
318
+ // shape function in `complexity-gate.js`) so `/deliver` reads one field —
319
+ // `inline` (lite-shaped: no story-worker / acceptance-critic sub-agent
320
+ // boots) or `subagent` (everything else, the conservative default). The
321
+ // `route::lite` label is a human-visible hint only, never the control
322
+ // signal: a lost label cannot misroute delivery. Model-side fan-out
323
+ // only; close gates are untouched.
324
+ stories: sorted.map(({ id, title, body, url, labels, state }) => ({
321
325
  id,
322
326
  title,
323
327
  url,
324
328
  labels,
325
329
  state,
326
- dispatchMode: resolveStoryDispatchMode({ labels }).mode,
330
+ dispatchMode: resolveStoryDispatchMode({ body, labels, config }).mode,
327
331
  })),
328
332
  dag: storiesToDag(sorted, nativeEdges, warn),
329
333
  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
@@ -0,0 +1,78 @@
1
+ /**
2
+ * lib/orchestration/spec-budget.js — the soft `## Spec` word-budget pass
3
+ * (Story #4723), extracted from `ticket-validator.js` so the advisory
4
+ * length nudge lives beside neither the hard validators nor their error
5
+ * channel: everything here is `'soft'` by construction and can never fail
6
+ * a persist.
7
+ */
8
+
9
+ import { parse as parseStoryBody } from '../story-body/story-body.js';
10
+
11
+ /**
12
+ * Soft advisory word budget for a Story's inline `## Spec` (Story #4723).
13
+ * ~250 words is the #4707 contract-level-prose target: interfaces,
14
+ * invariants, and load-bearing constraints — not route-by-route behavior
15
+ * narration. Distinct from the hard ~1500-token fail-closed ceiling in
16
+ * `spec-spill.js`: this budget only warns; it never fails the persist.
17
+ */
18
+ export const SPEC_SOFT_WORD_BUDGET = 250;
19
+
20
+ /**
21
+ * Resolve a Story's Spec prose across both authoring shapes: the canonical
22
+ * serialized string body (parsed; `## Spec` text block) and the
23
+ * pre-serialize structured object body (`body.spec`). Returns `''` when
24
+ * absent — or when a string body does not parse: this pass is advisory, so
25
+ * an unreadable body contributes no finding here and is left to the hard
26
+ * parse gate (`assertStoryBodiesParse`) to reject.
27
+ *
28
+ * @param {object} story
29
+ * @returns {string}
30
+ */
31
+ function resolveSpecText(story) {
32
+ const body = story?.body;
33
+ if (typeof body === 'string' && body.trim().length > 0) {
34
+ let spec;
35
+ try {
36
+ spec = parseStoryBody(body).body.spec;
37
+ } catch {
38
+ return '';
39
+ }
40
+ return typeof spec === 'string' ? spec : '';
41
+ }
42
+ if (body !== null && typeof body === 'object') {
43
+ return typeof body?.spec === 'string' ? body.spec : '';
44
+ }
45
+ return '';
46
+ }
47
+
48
+ /**
49
+ * Advisory `## Spec` length pass (Story #4723). Emits one `'soft'` finding
50
+ * per Story whose Spec prose exceeds {@link SPEC_SOFT_WORD_BUDGET} words,
51
+ * nudging the author toward contract-level prose (#4707). Soft only — the
52
+ * findings never reach the validator's `errors[]` channel, so an
53
+ * over-budget Spec never fails the persist.
54
+ *
55
+ * @param {{ stories: object[] }} opts
56
+ * @returns {object[]} Zero or more `spec-word-budget` findings.
57
+ */
58
+ export function computeSpecBudgetFindings({ stories }) {
59
+ const findings = [];
60
+ for (const story of stories ?? []) {
61
+ const words = resolveSpecText(story).split(/\s+/).filter(Boolean).length;
62
+ if (words <= SPEC_SOFT_WORD_BUDGET) continue;
63
+ findings.push({
64
+ kind: 'spec-word-budget',
65
+ severity: 'soft',
66
+ ticketSlug: story.slug ?? '<unknown>',
67
+ words,
68
+ budget: SPEC_SOFT_WORD_BUDGET,
69
+ message:
70
+ `Story "${story.slug ?? '<unknown>'}" ## Spec is ~${words} words ` +
71
+ `(soft budget ${SPEC_SOFT_WORD_BUDGET}). Prefer contract-level prose ` +
72
+ '(interfaces, invariants, load-bearing constraints with their why) ' +
73
+ 'over per-file behavior narration — advisory only; the persist ' +
74
+ 'proceeds.',
75
+ });
76
+ }
77
+ return findings;
78
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * lib/orchestration/story-body-gate.js — the Story-body parse gate
3
+ * (Story #4541), extracted from `ticket-validator.js`: the one place a
4
+ * serialized Story body is parsed with parse failures translated into the
5
+ * validator's operator-legible `ValidationError` shape. Both the gate that
6
+ * refuses a plan up front (`assertStoryBodiesParse`) and the per-call
7
+ * translating parser (`parseStoryBodyOrThrow`) the downstream validators
8
+ * lean on live here.
9
+ */
10
+
11
+ import { ValidationError } from '../errors/index.js';
12
+ import {
13
+ parse as parseStoryBody,
14
+ StoryBodyParseError,
15
+ } from '../story-body/story-body.js';
16
+
17
+ /**
18
+ * Parse a Story's serialized markdown body, translating a
19
+ * `StoryBodyParseError` into a `ValidationError` that names the offending
20
+ * **section** and **entry** (Story #4541).
21
+ *
22
+ * `StoryBodyParseError` already carries `field` (the section the parser was
23
+ * reading) and `raw` (the entry text that failed); this lifts both into an
24
+ * operator-legible message and a structured `violation` payload so an
25
+ * authoring loop can point at the exact bullet instead of re-deriving it
26
+ * from a downstream freshness miss.
27
+ *
28
+ * @param {object} story Story whose `body` is a non-empty markdown string.
29
+ * @returns {object} The structured body.
30
+ * @throws {ValidationError} `code: 'story-body-unparseable'`.
31
+ */
32
+ export function parseStoryBodyOrThrow(story) {
33
+ try {
34
+ return parseStoryBody(story.body).body;
35
+ } catch (err) {
36
+ if (!(err instanceof StoryBodyParseError)) throw err;
37
+ const slug = story.slug ?? '<unknown>';
38
+ const section = err.field ?? 'body';
39
+ const entry = err.raw ?? null;
40
+ const entryLine = entry === null ? '' : `\n entry: ${entry}`;
41
+ const violation = { slug, section, entry, reason: err.message };
42
+ const error = new ValidationError(
43
+ `Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
44
+ `the ## ${section} section could not be read: ${err.message}` +
45
+ `${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
46
+ 'not a stale path reference.',
47
+ { violations: [violation] },
48
+ );
49
+ error.code = 'story-body-unparseable';
50
+ error.violations = [violation];
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Refuse the plan when any Story's serialized body cannot be parsed, before
57
+ * either git-probe gate runs (Story #4541). Ordering matters: the freshness
58
+ * gate consults `body.changes` for its net-new whitelist, so an unparseable
59
+ * body used to reach the operator as a freshness miss naming declared paths.
60
+ *
61
+ * @param {{ tickets: object[] }} opts
62
+ * @throws {ValidationError} `code: 'story-body-unparseable'` on the first
63
+ * offending Story.
64
+ */
65
+ export function assertStoryBodiesParse({ tickets }) {
66
+ for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
67
+ if (typeof story.body !== 'string' || story.body.trim().length === 0) {
68
+ continue;
69
+ }
70
+ parseStoryBodyOrThrow(story);
71
+ }
72
+ }
@@ -829,6 +829,12 @@ export function renderHardConflictError(finding) {
829
829
  if (finding.kind === 'missing-bdd-scaffold') {
830
830
  return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
831
831
  }
832
+ // Findings from other passes (sizing, spec-word-budget) carry their own
833
+ // message — render it rather than a shape-blind generic line, so the soft
834
+ // surface (`surfaceSoftConflictFindings`) stays legible for every kind.
835
+ if (typeof finding.message === 'string' && finding.message.length > 0) {
836
+ return finding.message;
837
+ }
832
838
  return `Conflict finding ${finding.kind} on path "${finding.path ?? '<unknown>'}".`;
833
839
  }
834
840