mandrel 2.20.0 → 2.22.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 (46) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +15 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  9. package/.agents/scripts/lib/config/temp-paths.js +113 -7
  10. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  11. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  12. package/.agents/scripts/lib/label-constants.js +12 -1
  13. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  14. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  15. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  16. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  17. package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
  18. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  19. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  21. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  22. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  24. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  25. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  26. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  27. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  28. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  32. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  33. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  34. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  35. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  36. package/.agents/scripts/lib/temp-retention.js +23 -8
  37. package/.agents/scripts/resolve-stories.js +52 -33
  38. package/.agents/scripts/single-story-confirm-merge.js +6 -8
  39. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  40. package/.agents/workflows/helpers/deliver-light.md +45 -5
  41. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  42. package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
  43. package/.agents/workflows/helpers/deliver-story.md +8 -5
  44. package/.agents/workflows/helpers/plan-reference.md +5 -4
  45. package/docs/CHANGELOG.md +28 -0
  46. package/package.json +1 -1
@@ -31,15 +31,16 @@
31
31
  * cardinality is deliberately not an axis (Story #4764).
32
32
  * 4. **Deliver re-derives.** `/deliver` computes the route from the fetched
33
33
  * Story body via the **same** shape function at dispatch
34
- * ({@link resolveStoryDispatchMode}) and honors it: a lite-shaped Story
35
- * executes inline no story-worker sub-agent boot, no fresh
36
- * acceptance-critic dispatch while every `single-story-close.js` gate
37
- * runs unchanged. The `route::lite` label is a **human-visible hint
38
- * only**, never the control signal: a lost label or an unread marker can
39
- * no longer misroute delivery. Ahead of the shape read sits one
40
- * shape-independent rule (Story #4736): a **single-Story run** is inline
41
- * whatever its shape, because sub-agent isolation buys nothing when
42
- * there is no concurrent sibling to isolate from.
34
+ * ({@link resolveStoryDispatchMode}) and **reports** it, while the
35
+ * dispatch *mode* answers a different question: may the engine run in the
36
+ * router's own session? Only a **single-Story run** may (Story #4736) —
37
+ * sub-agent isolation buys nothing when there is no concurrent sibling to
38
+ * isolate from. Shape cannot grant that session (Story #4829): a lite body
39
+ * makes work cheap, it does not conjure a second session for a sibling to
40
+ * run in. The `route::lite` label is a **human-visible hint only**, never
41
+ * the control signal: a lost label or an unread marker can no longer
42
+ * misroute delivery. Either way every `single-story-close.js` gate runs
43
+ * unchanged.
43
44
  *
44
45
  * The shape taxonomy is deliberately the one `review-depth.js` already
45
46
  * applies to the landed diff at close (`deriveChangeLevel` over the
@@ -259,23 +260,65 @@ function spansMigrationAndConsumers(paths) {
259
260
  return migrations.length > 0 && migrations.length < paths.length;
260
261
  }
261
262
 
263
+ /**
264
+ * Stable machine-readable identifiers for every reason a shape routes `full` —
265
+ * the `code` field on a {@link deriveStoryShape} decision (Story #4815).
266
+ *
267
+ * The prose in `reasons[]` is written for a human reading a gate envelope and
268
+ * is free to be re-worded; a caller that must **branch** on *which* rule
269
+ * objected reads this code instead. That distinction is load-bearing for the
270
+ * light path's operator override
271
+ * ({@link module:lib/orchestration/light-suitability.OVERRIDABLE_SHAPE_CODES}),
272
+ * which may waive a size *prediction* but never a risk rule: keying that
273
+ * decision off reason text would make a copy-edit a security change.
274
+ *
275
+ * Split three ways, and the grouping is the contract:
276
+ *
277
+ * - **Ceiling rules** — `change-kinds`, `magnitude`, `uncertainty`,
278
+ * `deployable-span`. Coarse predictions about size, enforced for real
279
+ * against ground truth by the diff backstop.
280
+ * - **Absolute rules** — `migration-span`, `sensitive-path`. Risk, not size.
281
+ * - **Unknown-footprint rejections** — `no-changes`, `unreadable-changes`,
282
+ * `glob-footprint`, `no-acceptance`, `classification-unavailable`,
283
+ * `unparseable-body`. Nothing was judged, so there is nothing to waive.
284
+ *
285
+ * A `lite` route carries `code: null`.
286
+ */
287
+ export const SHAPE_CODES = Object.freeze({
288
+ CHANGE_KINDS: 'change-kinds',
289
+ MAGNITUDE: 'magnitude',
290
+ UNCERTAINTY: 'uncertainty',
291
+ DEPLOYABLE_SPAN: 'deployable-span',
292
+ MIGRATION_SPAN: 'migration-span',
293
+ SENSITIVE_PATH: 'sensitive-path',
294
+ NO_CHANGES: 'no-changes',
295
+ UNREADABLE_CHANGES: 'unreadable-changes',
296
+ GLOB_FOOTPRINT: 'glob-footprint',
297
+ NO_ACCEPTANCE: 'no-acceptance',
298
+ CLASSIFICATION_UNAVAILABLE: 'classification-unavailable',
299
+ UNPARSEABLE_BODY: 'unparseable-body',
300
+ });
301
+
262
302
  /**
263
303
  * Ordered effort/risk rules, evaluated in order; the first hit is the recorded
264
304
  * reason for a `full` route. Every rule names an effort, risk, or uncertainty
265
305
  * property of the work — none counts artifacts.
266
306
  *
267
307
  * @type {ReadonlyArray<{
308
+ * code: string,
268
309
  * when: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => boolean,
269
310
  * reason: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => string,
270
311
  * }>}
271
312
  */
272
313
  const EFFORT_RULES = Object.freeze([
273
314
  {
315
+ code: SHAPE_CODES.CHANGE_KINDS,
274
316
  when: (s, c) => s.kindCount > c.maxChangeKinds,
275
317
  reason: (s, c) =>
276
318
  `${s.kindCount} distinct change kinds (${s.changeKinds.join(', ')}) > maxChangeKinds ${c.maxChangeKinds} — an explicit multi-capability enumeration, not one capability; full route`,
277
319
  },
278
320
  {
321
+ code: SHAPE_CODES.MAGNITUDE,
279
322
  when: (s, c) =>
280
323
  MAGNITUDE_SCALE.indexOf(s.magnitude) >
281
324
  MAGNITUDE_SCALE.indexOf(c.maxMagnitude),
@@ -283,6 +326,7 @@ const EFFORT_RULES = Object.freeze([
283
326
  `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
327
  },
285
328
  {
329
+ code: SHAPE_CODES.UNCERTAINTY,
286
330
  when: (s, c) =>
287
331
  UNCERTAINTY_SCALE.indexOf(s.uncertainty) >
288
332
  UNCERTAINTY_SCALE.indexOf(c.maxUncertainty),
@@ -290,16 +334,19 @@ const EFFORT_RULES = Object.freeze([
290
334
  `the shape is not determined by the request (uncertainty "${s.uncertainty}") — the design decisions /plan exists to resolve are still open; full route`,
291
335
  },
292
336
  {
337
+ code: SHAPE_CODES.DEPLOYABLE_SPAN,
293
338
  when: (s, c) => s.deployables.length > c.maxDeployables,
294
339
  reason: (s, c) =>
295
340
  `footprint spans ${s.deployables.length} deployables (${s.deployables.join(', ')}) > maxDeployables ${c.maxDeployables} — clearly-epic scope; full route`,
296
341
  },
297
342
  {
343
+ code: SHAPE_CODES.MIGRATION_SPAN,
298
344
  when: (s) => s.migrationSpan,
299
345
  reason: () =>
300
346
  'footprint pairs a migration with its consumers — clearly-epic scope; full route',
301
347
  },
302
348
  {
349
+ code: SHAPE_CODES.SENSITIVE_PATH,
303
350
  when: (s) => s.sensitiveClasses.length > 0,
304
351
  reason: (s) =>
305
352
  `footprint intersects sensitive-path class(es) ${s.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
@@ -307,15 +354,18 @@ const EFFORT_RULES = Object.freeze([
307
354
  ]);
308
355
 
309
356
  /**
310
- * First effort/risk rule the shape violates, or `null` when it clears them all.
357
+ * First effort/risk rule the shape violates as a `{ code, reason }` pair, or
358
+ * `null` when it clears them all.
311
359
  *
312
360
  * @param {object} shape
313
361
  * @param {typeof STORY_SHAPE_CEILINGS} ceilings
314
- * @returns {string|null}
362
+ * @returns {{ code: string, reason: string }|null}
315
363
  */
316
364
  function firstEffortViolation(shape, ceilings) {
317
365
  for (const rule of EFFORT_RULES) {
318
- if (rule.when(shape, ceilings)) return rule.reason(shape, ceilings);
366
+ if (rule.when(shape, ceilings)) {
367
+ return { code: rule.code, reason: rule.reason(shape, ceilings) };
368
+ }
319
369
  }
320
370
  return null;
321
371
  }
@@ -654,10 +704,13 @@ function buildEffortShape({
654
704
  * @returns {{
655
705
  * route: ComplexityRoute,
656
706
  * reasons: string[],
707
+ * code: string|null,
657
708
  * shape: ReturnType<typeof buildEffortShape>|null,
658
709
  * ceilings: typeof STORY_SHAPE_CEILINGS,
659
710
  * preserves: typeof LITE_PATH_INVARIANTS,
660
- * }}
711
+ * }} `code` is the stable {@link SHAPE_CODES} identifier for the rule that
712
+ * rejected the shape (`null` on `lite`) — the field a caller branches on,
713
+ * since `reasons[]` is human prose and free to be re-worded.
661
714
  */
662
715
  export function deriveStoryShape({
663
716
  changes,
@@ -670,9 +723,10 @@ export function deriveStoryShape({
670
723
  } = {}) {
671
724
  const ceilings = STORY_SHAPE_CEILINGS;
672
725
  const preserves = LITE_PATH_INVARIANTS;
673
- const decide = (route, reason, shape = null) => ({
726
+ const decide = (route, code, reason, shape = null) => ({
674
727
  route,
675
728
  reasons: [reason],
729
+ code,
676
730
  shape,
677
731
  ceilings,
678
732
  preserves,
@@ -681,6 +735,7 @@ export function deriveStoryShape({
681
735
  if (!Array.isArray(changes) || changes.length === 0) {
682
736
  return decide(
683
737
  'full',
738
+ SHAPE_CODES.NO_CHANGES,
684
739
  'no changes[] declared — the footprint is unknown, so the work cannot be judged trivial; conservative full route',
685
740
  );
686
741
  }
@@ -691,6 +746,7 @@ export function deriveStoryShape({
691
746
  } catch (err) {
692
747
  return decide(
693
748
  'full',
749
+ SHAPE_CODES.UNREADABLE_CHANGES,
694
750
  `changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
695
751
  );
696
752
  }
@@ -714,6 +770,7 @@ export function deriveStoryShape({
714
770
  if (entries.some((e) => e.isGlob)) {
715
771
  return decide(
716
772
  'full',
773
+ SHAPE_CODES.GLOB_FOOTPRINT,
717
774
  'changes[] contains a glob path — unknown footprint width; conservative full route',
718
775
  shape,
719
776
  );
@@ -721,13 +778,16 @@ export function deriveStoryShape({
721
778
  if (shape.acceptanceCount === 0) {
722
779
  return decide(
723
780
  'full',
781
+ SHAPE_CODES.NO_ACCEPTANCE,
724
782
  'no acceptance criteria — the contract cannot be judged trivial; conservative full route',
725
783
  shape,
726
784
  );
727
785
  }
728
786
 
729
787
  const violation = firstEffortViolation(shape, ceilings);
730
- if (violation !== null) return decide('full', violation, shape);
788
+ if (violation !== null) {
789
+ return decide('full', violation.code, violation.reason, shape);
790
+ }
731
791
 
732
792
  if (level !== 'low') {
733
793
  // `deriveChangeLevel` degraded to its null fail-safe (unreadable
@@ -735,6 +795,7 @@ export function deriveStoryShape({
735
795
  // non-sensitive, and a classification failure must never buy lite.
736
796
  return decide(
737
797
  'full',
798
+ SHAPE_CODES.CLASSIFICATION_UNAVAILABLE,
738
799
  'sensitive-path classification unavailable — cannot verify the footprint is non-sensitive; conservative full route',
739
800
  shape,
740
801
  );
@@ -742,6 +803,7 @@ export function deriveStoryShape({
742
803
 
743
804
  return decide(
744
805
  'lite',
806
+ null,
745
807
  `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`,
746
808
  shape,
747
809
  );
@@ -771,6 +833,7 @@ function deriveStoryRouteFromBody(body, opts = {}) {
771
833
  reasons: [
772
834
  `Story body is unparseable (${err?.message ?? err}) — shape unknown; conservative full route`,
773
835
  ],
836
+ code: SHAPE_CODES.UNPARSEABLE_BODY,
774
837
  shape: null,
775
838
  ceilings: STORY_SHAPE_CEILINGS,
776
839
  preserves: LITE_PATH_INVARIANTS,
@@ -802,27 +865,34 @@ function routeForReporting(body, opts) {
802
865
  /**
803
866
  * Decide how `/deliver` executes a Story.
804
867
  *
805
- * Two independent premises, checked in this order:
806
- *
807
- * 1. **Run topology (Story #4736).** A run delivering a *single* Story
808
- * executes **inline**, whatever its shape. Sub-agent isolation is
809
- * load-bearing only for CONCURRENT dispatch two workers sharing a
810
- * checkout would race on worktrees and branch refs and a one-Story run
811
- * has no sibling to race. It therefore pays the spawn premium (a boot is
812
- * a cache WRITE at full rate, where an inline continuation is a cache read
813
- * at ~10%; ~$1.43/M vs ~$1.07/M on comparable bench work) for nothing.
814
- * This is a fact about the run, not about the work, so the shape gate's
815
- * `enabled` switch — which governs *shape derivation* — does not reach it.
816
- * 2. **Shape (Story #4722 AC-4/AC-5).** For a multi-Story run, the decision
817
- * comes **from the Story body's own shape**, never from the `route::lite`
818
- * label: a lite-shaped Story executes inline; everything else a
819
- * full-shaped body, a missing/unparseable body, or the gate disabled via
820
- * `planning.complexityGate.enabled=false` dispatches as a sub-agent,
821
- * the conservative default.
822
- *
823
- * The label is read only to report hint consistency in `reasons`: with the
824
- * label absent (or its write failed) a lite-shaped Story still runs inline,
825
- * and with the label present on a full-shaped Story the shape wins.
868
+ * **`inline` names one indivisible resource: the router's own session.** Two
869
+ * Stories cannot both own it, so exactly one premise can grant it —
870
+ * **run topology (Story #4736)**: a run resolving a *single* Story executes
871
+ * inline whatever its shape, because sub-agent isolation is load-bearing only
872
+ * for CONCURRENT dispatch (two workers sharing a checkout race on worktrees and
873
+ * branch refs) and a one-Story run has no sibling to race. It therefore pays
874
+ * the spawn premium (a boot is a cache WRITE at full rate, where an inline
875
+ * continuation is a cache read at ~10%; ~$1.43/M vs ~$1.07/M on comparable
876
+ * bench work) for nothing. That is a fact about the run, not about the work, so
877
+ * the shape gate's `enabled` switch which governs *shape derivation* does
878
+ * not reach it.
879
+ *
880
+ * **Shape cannot grant it (Story #4829).** The shape read used to return
881
+ * `inline` for any lite-shaped body in a multi-Story run, inheriting no
882
+ * topology guard. Measured twice on 2026-07-29: a two-Story and a three-Story
883
+ * run came back `inline` for *every* Story while `stories-wave-tick.js`
884
+ * reported the whole set ready under a concurrency cap of five — a router
885
+ * following both signals literally runs several engines over one session and
886
+ * one checkout, the precise hazard the sub-agent path exists to prevent. Both
887
+ * runs were completed only by an operator overriding the verdict by hand, which
888
+ * is an invariant held by judgment rather than by code. So this function has
889
+ * exactly **one** `inline` exit, guarded by the topology premise; every path
890
+ * below it returns `subagent`, and the derived shape is carried on `route` for
891
+ * reporting only. A lite shape makes the work cheap — it does not conjure a
892
+ * second session for a sibling to run in.
893
+ *
894
+ * The label is read only to report hint consistency in `reasons`; it never
895
+ * routes on either premise (Story #4722 AC-4/AC-5).
826
896
  *
827
897
  * Inline execution removes model-side fan-out only — it changes **where** the
828
898
  * engine runs, never **what** runs. Every deterministic
@@ -839,7 +909,8 @@ function routeForReporting(body, opts) {
839
909
  * selectSensitivePathClassesFn?: Function,
840
910
  * }} [args] `storyCount` is the number of Stories the invoking `/deliver` run
841
911
  * resolved. Omitted (or not a positive integer) means "unknown run size",
842
- * which falls through to the shape decision never to an assumed 1.
912
+ * which cannot be shown sibling-free and therefore dispatches as a sub-agent
913
+ * — never an assumed 1.
843
914
  * @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
844
915
  */
845
916
  export function resolveStoryDispatchMode({
@@ -858,6 +929,8 @@ export function resolveStoryDispatchMode({
858
929
  ? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
859
930
  : `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
860
931
 
932
+ // The ONLY `inline` exit in this function, and the guard is the whole
933
+ // contract: an inline verdict must mean the engine can actually run inline.
861
934
  if (storyCount === 1) {
862
935
  return {
863
936
  mode: 'inline',
@@ -894,23 +967,17 @@ export function resolveStoryDispatchMode({
894
967
  };
895
968
  }
896
969
 
970
+ // Past the topology guard a sibling may be dispatched concurrently, so the
971
+ // router's session is not available to anyone. The shape is still derived and
972
+ // returned on `route` — ceremony and reporting read it — but it decides
973
+ // nothing here: both shapes dispatch as a sub-agent.
897
974
  const route = deriveStoryRouteFromBody(body, {
898
975
  injectedRules,
899
976
  selectSensitivePathClassesFn,
900
977
  });
901
- if (route.route === 'lite') {
902
- return {
903
- mode: 'inline',
904
- reasons: [
905
- `lite-shaped Story execute deliver-story inline; no story-worker or acceptance-critic sub-agent dispatch (close gates unchanged): ${route.reasons[0]}`,
906
- hintNote,
907
- ],
908
- route,
909
- };
910
- }
911
- return {
912
- mode: 'subagent',
913
- reasons: [`full-shaped Story — ${route.reasons[0]}`, hintNote],
914
- route,
915
- };
978
+ const shapeNote =
979
+ route.route === 'lite'
980
+ ? `lite-shaped Story in a multi-Story run — the shape is inline-eligible but the router's session is not: a concurrent sibling would have to share it, racing worktrees and branch refs; sub-agent dispatch (${route.reasons[0]})`
981
+ : `full-shaped Story — ${route.reasons[0]}`;
982
+ return { mode: 'subagent', reasons: [shapeNote, hintNote], route };
916
983
  }