mandrel 2.58.0 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/.agents/README.md +17 -12
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +12 -13
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +9 -5
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +5 -7
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/runtime-deps.json +7 -2
  13. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  14. package/.agents/schemas/agentrc.schema.json +6 -11
  15. package/.agents/schemas/crap-baseline.schema.json +1 -1
  16. package/.agents/schemas/crap-report.schema.json +1 -1
  17. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  18. package/.agents/scripts/README.md +11 -1
  19. package/.agents/scripts/acceptance-eval.js +25 -27
  20. package/.agents/scripts/ceremony-derive.js +15 -10
  21. package/.agents/scripts/check-context-budget.js +148 -228
  22. package/.agents/scripts/check-schema-references.js +5 -3
  23. package/.agents/scripts/check-workflow-citations.js +33 -147
  24. package/.agents/scripts/coverage-capture.js +7 -4
  25. package/.agents/scripts/deliver-light.js +41 -100
  26. package/.agents/scripts/deliver-run.js +631 -0
  27. package/.agents/scripts/file-ci-gap.js +59 -11
  28. package/.agents/scripts/install-matrix-assert.js +48 -3
  29. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  30. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  31. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  32. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  33. package/.agents/scripts/lib/changed-files.js +30 -0
  34. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  35. package/.agents/scripts/lib/config/explain.js +1 -3
  36. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  37. package/.agents/scripts/lib/config-resolver.js +1 -0
  38. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  39. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  40. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  41. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  42. package/.agents/scripts/lib/crap-engine.js +2 -2
  43. package/.agents/scripts/lib/crap-utils.js +21 -5
  44. package/.agents/scripts/lib/doc-tiers.js +4 -2
  45. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  46. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  47. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  48. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  49. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  50. package/.agents/scripts/lib/gh-exec.js +160 -0
  51. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  52. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  53. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  54. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  55. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  57. package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
  58. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  64. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  65. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  66. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  67. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  68. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  69. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  70. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  71. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  72. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  73. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  74. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  75. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  76. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  77. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  78. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  79. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  80. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  81. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  82. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  83. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  84. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  85. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  86. package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
  87. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  88. package/.agents/scripts/merge-baseline.js +4 -5
  89. package/.agents/scripts/plan-context.js +117 -28
  90. package/.agents/scripts/plan-persist.js +79 -39
  91. package/.agents/scripts/plan-run-epilogue.js +11 -8
  92. package/.agents/scripts/pr-watch-with-update.js +9 -2
  93. package/.agents/scripts/run-verify.js +13 -6
  94. package/.agents/scripts/single-story-init.js +7 -57
  95. package/.agents/scripts/stories-wave-tick.js +160 -26
  96. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  97. package/.agents/skills/skills.index.json +2 -12
  98. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  99. package/.agents/workflows/audit-to-stories.md +14 -11
  100. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  101. package/.agents/workflows/helpers/code-review.md +4 -2
  102. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  103. package/.agents/workflows/helpers/deliver-light.md +92 -101
  104. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  105. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  106. package/.agents/workflows/helpers/deliver-story.md +17 -18
  107. package/.agents/workflows/helpers/plan-reference.md +82 -60
  108. package/.agents/workflows/mandrel-deliver.md +47 -31
  109. package/.agents/workflows/mandrel-plan.md +32 -30
  110. package/.agents/workflows/mandrel-update.md +36 -21
  111. package/README.md +3 -3
  112. package/docs/CHANGELOG.md +43 -0
  113. package/lib/cli/registry.js +45 -25
  114. package/lib/cli/update.js +376 -17
  115. package/lib/migrations/index.js +2 -0
  116. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  117. package/package.json +8 -2
  118. package/.agents/schemas/model-attribution.schema.json +0 -53
  119. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  120. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  121. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  122. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
  123. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  124. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -6,8 +6,7 @@
6
6
  * tip vs base (deterministic `selectAudits` — host walks lenses).
7
7
  * 2. Rolls up friction follow-ups across every Story in the run and
8
8
  * files/posts them on the primary Story.
9
- * 3. Checks sibling Spec/acceptance coherence across Story bodies.
10
- * 4. Reports what the per-Story land tails left the run's container Epics
9
+ * 3. Reports what the per-Story land tails left the run's container Epics
11
10
  * in — closed, or still open (Story #5139; read-only since #5280).
12
11
  *
13
12
  * There is no inert planner-only path: `planRunEpilogue` enumerates steps
@@ -27,6 +26,7 @@ import {
27
26
  assessRollupOutcome,
28
27
  buildFollowUpsCommentBody,
29
28
  gatherRunFrictionSignals,
29
+ publishFollowUpsRollup,
30
30
  resolveFollowUpRepos,
31
31
  summarizeSignalCategories,
32
32
  } from './story-follow-ups.js';
@@ -34,12 +34,23 @@ import { upsertStructuredComment } from './ticketing.js';
34
34
 
35
35
  /**
36
36
  * Canonical epilogue step kinds, in execution order.
37
- * @type {readonly ['audit-roster', 'follow-up-rollup', 'sibling-coherence', 'epic-close']}
37
+ *
38
+ * A fourth step — a cross-Story spec/acceptance coherence check — was removed
39
+ * in Story #5341: it re-read every Story body to post a comment nothing read
40
+ * back, on the one run shape (N>1) where that re-read costs the most. ADR
41
+ * `20260917-5341` carries the reasoning.
42
+ *
43
+ * `audit-roster` is **opt-in** since Story #5343 (`--audit-roster`): it is
44
+ * the one step that asks the host to spend a sub-agent per selected lens,
45
+ * and a run whose operator did not ask for an audit sweep paid that cost —
46
+ * plus a `plan-run-audit-roster` comment nobody acted on — on every N>1 run.
47
+ * The other two steps are reporting and stay on by default.
48
+ *
49
+ * @type {readonly ['audit-roster', 'follow-up-rollup', 'epic-close']}
38
50
  */
39
51
  export const RUN_EPILOGUE_STEP_KINDS = Object.freeze([
40
52
  'audit-roster',
41
53
  'follow-up-rollup',
42
- 'sibling-coherence',
43
54
  'epic-close',
44
55
  ]);
45
56
 
@@ -159,9 +170,16 @@ function normalizeStoryIds(stories) {
159
170
  * @param {object} args
160
171
  * @param {string} args.planRunId
161
172
  * @param {Array<string|number|{ id?: string|number, slug?: string }>} args.stories
173
+ * @param {boolean} [args.auditRoster] Opt into the cross-Story audit roster
174
+ * (Story #5343). Default `false`: the roster spawns auditors and posts a
175
+ * comment the operator has to act on, so it runs only when asked for.
162
176
  * @returns {object}
163
177
  */
164
- export function planRunEpilogue({ planRunId, stories } = {}) {
178
+ export function planRunEpilogue({
179
+ planRunId,
180
+ stories,
181
+ auditRoster = false,
182
+ } = {}) {
165
183
  const ids = normalizeStoryIds(stories);
166
184
  const runId =
167
185
  typeof planRunId === 'string' && planRunId.trim() !== ''
@@ -189,21 +207,20 @@ export function planRunEpilogue({ planRunId, stories } = {}) {
189
207
  `adhoc-${[...ids].sort((a, b) => Number(a) - Number(b)).join('-')}`;
190
208
 
191
209
  const steps = [
192
- {
193
- kind: 'audit-roster',
194
- description: `Select cross-Story audit lenses for run ${effectiveRunId}`,
195
- stories: ids,
196
- },
210
+ ...(auditRoster
211
+ ? [
212
+ {
213
+ kind: 'audit-roster',
214
+ description: `Select cross-Story audit lenses for run ${effectiveRunId}`,
215
+ stories: ids,
216
+ },
217
+ ]
218
+ : []),
197
219
  {
198
220
  kind: 'follow-up-rollup',
199
221
  description: `Friction follow-up roll-up for run ${effectiveRunId}`,
200
222
  stories: ids,
201
223
  },
202
- {
203
- kind: 'sibling-coherence',
204
- description: `Sibling-coherence check across the ${ids.length} Story specs of run ${effectiveRunId}`,
205
- stories: ids,
206
- },
207
224
  {
208
225
  kind: 'epic-close',
209
226
  description: `Report the container Epic state the land tails of run ${effectiveRunId} left behind`,
@@ -526,48 +543,24 @@ function resolveBaseRef(config) {
526
543
  return `origin/${branch}`;
527
544
  }
528
545
 
529
- async function executeAuditRoster({
546
+ /**
547
+ * Render the `plan-run-audit-roster` comment body.
548
+ *
549
+ * Split out of `executeAuditRoster` in Story #5341: the executor's job is to
550
+ * resolve the diff and select the lenses, and a hundred lines of rendering
551
+ * between those two facts and the result is what hid them.
552
+ *
553
+ * @param {object} args
554
+ * @returns {string}
555
+ */
556
+ function renderAuditRosterBody({
530
557
  planRunId,
531
558
  stories,
532
- cwd,
533
- provider,
534
- config,
535
- git,
536
- selectAuditsFn,
559
+ diff,
560
+ lensGrounding,
561
+ selectedAudits,
537
562
  }) {
538
- const primaryId = Number(stories[0]);
539
- const diff = resolveCombinedDiff({
540
- stories,
541
- cwd,
542
- baseRef: resolveBaseRef(config),
543
- git,
544
- });
545
- // Hand `selectAudits` the change set we just resolved — never a git range for
546
- // it to re-derive. This function runs in the main checkout *after* the run's
547
- // Stories merged, so every range it could name (`main...HEAD`) is empty by
548
- // construction; asking for one is how the roster came to select lenses from
549
- // zero files while printing the correct file list beside them (Story #4571).
550
- const lensGrounding = diff.resolved ? 'diff' : 'keyword-only';
551
- let selectedAudits = [];
552
- if (Number.isInteger(primaryId) && primaryId > 0) {
553
- const selected = await selectAuditsFn({
554
- ticketId: primaryId,
555
- gate: 'gate3',
556
- provider,
557
- changedFiles: diff.resolved ? diff.changedFiles : [],
558
- });
559
- selectedAudits = Array.isArray(selected?.selectedAudits)
560
- ? selected.selectedAudits
561
- : Array.isArray(selected)
562
- ? selected
563
- : [];
564
- }
565
- if (!diff.resolved) {
566
- Logger.warn(
567
- `[run-epilogue] plan-run ${planRunId}: combined landed diff unavailable — ${diff.reason}`,
568
- );
569
- }
570
- const body = [
563
+ return [
571
564
  '### plan-run-audit-roster',
572
565
  '',
573
566
  `Cross-Story audit roster for plan-run \`${planRunId}\`.`,
@@ -613,6 +606,56 @@ async function executeAuditRoster({
613
606
  ),
614
607
  '```',
615
608
  ].join('\n');
609
+ }
610
+
611
+ async function executeAuditRoster({
612
+ planRunId,
613
+ stories,
614
+ cwd,
615
+ provider,
616
+ config,
617
+ git,
618
+ selectAuditsFn,
619
+ }) {
620
+ const primaryId = Number(stories[0]);
621
+ const diff = resolveCombinedDiff({
622
+ stories,
623
+ cwd,
624
+ baseRef: resolveBaseRef(config),
625
+ git,
626
+ });
627
+ // Hand `selectAudits` the change set we just resolved — never a git range for
628
+ // it to re-derive. This function runs in the main checkout *after* the run's
629
+ // Stories merged, so every range it could name (`main...HEAD`) is empty by
630
+ // construction; asking for one is how the roster came to select lenses from
631
+ // zero files while printing the correct file list beside them (Story #4571).
632
+ const lensGrounding = diff.resolved ? 'diff' : 'keyword-only';
633
+ let selectedAudits = [];
634
+ if (Number.isInteger(primaryId) && primaryId > 0) {
635
+ const selected = await selectAuditsFn({
636
+ ticketId: primaryId,
637
+ gate: 'gate3',
638
+ provider,
639
+ changedFiles: diff.resolved ? diff.changedFiles : [],
640
+ });
641
+ selectedAudits = Array.isArray(selected?.selectedAudits)
642
+ ? selected.selectedAudits
643
+ : Array.isArray(selected)
644
+ ? selected
645
+ : [];
646
+ }
647
+ if (!diff.resolved) {
648
+ Logger.warn(
649
+ `[run-epilogue] plan-run ${planRunId}: combined landed diff unavailable — ${diff.reason}`,
650
+ );
651
+ }
652
+ const body = renderAuditRosterBody({
653
+ planRunId,
654
+ stories,
655
+ diff,
656
+ lensGrounding,
657
+ selectedAudits,
658
+ });
616
659
  if (Number.isInteger(primaryId) && primaryId > 0) {
617
660
  await upsertStructuredComment(
618
661
  provider,
@@ -644,31 +687,29 @@ async function executeAuditRoster({
644
687
  };
645
688
  }
646
689
 
647
- async function executeFollowUpRollup({
690
+ /**
691
+ * Compose the run-scope routed proposals.
692
+ *
693
+ * Story #4850 — `runToken` and `anchorStoryIds` are INPUTS. This used to
694
+ * compose with the primary Story's numeric id standing in for the run and then
695
+ * rewrite the rendered title/body by regex over a `plan-run \d+` substring,
696
+ * which meant the composer's own wording could not be changed without silently
697
+ * breaking the patch. `anchorStoryIds` is what lets the composer tell a corpus
698
+ * confined to this run from one spanning the whole surviving window, so it
699
+ * never titles the latter as if it were the former.
700
+ *
701
+ * @param {object} args
702
+ * @returns {object}
703
+ */
704
+ function composeRunProposals({
705
+ primaryId,
648
706
  planRunId,
649
707
  stories,
650
- provider,
708
+ signals,
651
709
  config,
652
- cwd,
653
- graduateFn = graduateRetroProposals,
654
710
  }) {
655
- // Shared with the story-scoped gather (Story #4649): `storyId` + `details`
656
- // are what the composer's recovery-netting keys on, and two hand-rolled
657
- // copies of this loop are how they got dropped in the first place.
658
- const { signals, window: frictionWindow } = await gatherRunFrictionSignals(
659
- stories,
660
- config,
661
- );
662
711
  const repos = resolveFollowUpRepos(config);
663
- const primaryId = Number(stories[0]);
664
- // Story #4850 — `runToken` and `anchorStoryIds` are INPUTS. This used to
665
- // compose with the primary Story's numeric id standing in for the run and
666
- // then rewrite the rendered title/body by regex over a `plan-run \d+`
667
- // substring, which meant the composer's own wording could not be changed
668
- // without silently breaking the patch. `anchorStoryIds` is what lets the
669
- // composer tell a corpus confined to this run from one spanning the whole
670
- // surviving window, so it never titles the latter as if it were the former.
671
- const proposals = composeRoutedProposals({
712
+ return composeRoutedProposals({
672
713
  anchorId: Number.isInteger(primaryId) ? primaryId : 1,
673
714
  anchorKind: 'run',
674
715
  runToken: String(planRunId ?? ''),
@@ -678,7 +719,24 @@ async function executeFollowUpRollup({
678
719
  signals,
679
720
  unresolvedBlockedEvents: [],
680
721
  });
681
- const graduated = await graduateFn({
722
+ }
723
+
724
+ /**
725
+ * Hand the run-scope proposals to the graduator (injectable for tests).
726
+ *
727
+ * @param {object} args
728
+ * @returns {Promise<object>}
729
+ */
730
+ function fileRunProposals({
731
+ primaryId,
732
+ proposals,
733
+ provider,
734
+ config,
735
+ cwd,
736
+ graduateFn,
737
+ }) {
738
+ const repos = resolveFollowUpRepos(config);
739
+ return graduateFn({
682
740
  epicId: primaryId,
683
741
  provider,
684
742
  config,
@@ -690,6 +748,39 @@ async function executeFollowUpRollup({
690
748
  routedProposals: proposals,
691
749
  cwd,
692
750
  });
751
+ }
752
+
753
+ async function executeFollowUpRollup({
754
+ planRunId,
755
+ stories,
756
+ provider,
757
+ config,
758
+ cwd,
759
+ graduateFn = graduateRetroProposals,
760
+ }) {
761
+ // Shared with the story-scoped gather (Story #4649): `storyId` + `details`
762
+ // are what the composer's recovery-netting keys on, and two hand-rolled
763
+ // copies of this loop are how they got dropped in the first place.
764
+ const { signals, window: frictionWindow } = await gatherRunFrictionSignals(
765
+ stories,
766
+ config,
767
+ );
768
+ const primaryId = Number(stories[0]);
769
+ const proposals = composeRunProposals({
770
+ primaryId,
771
+ planRunId,
772
+ stories,
773
+ signals,
774
+ config,
775
+ });
776
+ const graduated = await fileRunProposals({
777
+ primaryId,
778
+ proposals,
779
+ provider,
780
+ config,
781
+ cwd,
782
+ graduateFn,
783
+ });
693
784
  const categories = summarizeSignalCategories(signals);
694
785
  const proposalCount = proposals.framework.length + proposals.consumer.length;
695
786
  const outcome = assessRollupOutcome({
@@ -700,29 +791,98 @@ async function executeFollowUpRollup({
700
791
  filingErrors: graduated.errors,
701
792
  filingSkipped: graduated.skipped,
702
793
  });
703
- if (Number.isInteger(primaryId) && primaryId > 0) {
704
- const body = buildFollowUpsCommentBody({
705
- storyId: primaryId,
706
- proposals,
707
- graduated,
708
- // Story #4578 — the run's Story count is what lets an empty roll-up
709
- // render as a flagged claim ("0 signals across N Stories") rather than
710
- // as "nothing to follow up".
711
- storyCount: stories.length,
712
- // Story #4828 — and the corpus is what lets a zero-proposal or
713
- // zero-filed roll-up name what it saw instead of rendering as clean.
714
- signalCount: signals.length,
715
- categories,
716
- }).replace(
717
- `from Story #${primaryId}`,
718
- `from plan-run \`${planRunId}\` (primary Story #${primaryId})`,
719
- );
720
- await upsertStructuredComment(provider, primaryId, 'follow-ups', body);
721
- }
794
+ await publishRunRollup({
795
+ primaryId,
796
+ planRunId,
797
+ provider,
798
+ config,
799
+ proposals,
800
+ graduated,
801
+ storyCount: stories.length,
802
+ signalCount: signals.length,
803
+ categories,
804
+ filedCount: graduated.filed?.length ?? 0,
805
+ });
806
+ return buildRollupStepResult({
807
+ signals,
808
+ storyCount: stories.length,
809
+ graduated,
810
+ proposals,
811
+ proposalCount,
812
+ categories,
813
+ outcome,
814
+ frictionWindow,
815
+ });
816
+ }
817
+
818
+ /**
819
+ * Render the run-scope roll-up and publish it — the one place that decides
820
+ * where it goes.
821
+ *
822
+ * @param {object} args
823
+ * @returns {Promise<void>}
824
+ */
825
+ async function publishRunRollup({
826
+ primaryId,
827
+ planRunId,
828
+ provider,
829
+ config,
830
+ proposals,
831
+ graduated,
832
+ storyCount,
833
+ signalCount,
834
+ categories,
835
+ filedCount,
836
+ }) {
837
+ if (!Number.isInteger(primaryId) || primaryId <= 0) return;
838
+ const body = buildFollowUpsCommentBody({
839
+ storyId: primaryId,
840
+ proposals,
841
+ graduated,
842
+ // Story #4578 — the run's Story count is what lets an empty roll-up
843
+ // render as a flagged claim ("0 signals across N Stories") rather than
844
+ // as "nothing to follow up".
845
+ storyCount,
846
+ // Story #4828 — and the corpus is what lets a zero-proposal or
847
+ // zero-filed roll-up name what it saw instead of rendering as clean.
848
+ signalCount,
849
+ categories,
850
+ }).replace(
851
+ `from Story #${primaryId}`,
852
+ `from plan-run \`${planRunId}\` (primary Story #${primaryId})`,
853
+ );
854
+ await publishFollowUpsRollup({
855
+ anchorId: primaryId,
856
+ body,
857
+ filedCount,
858
+ provider,
859
+ config,
860
+ });
861
+ }
862
+
863
+ /**
864
+ * Assemble the `follow-up-rollup` step result. Split out of
865
+ * `executeFollowUpRollup` in Story #5341: the literal had grown to carry
866
+ * every suspicion the roll-up can raise, and the reporting shape is what
867
+ * callers read, not how it was gathered.
868
+ *
869
+ * @param {object} args
870
+ * @returns {object}
871
+ */
872
+ function buildRollupStepResult({
873
+ signals,
874
+ storyCount,
875
+ graduated,
876
+ proposals,
877
+ proposalCount,
878
+ categories,
879
+ outcome,
880
+ frictionWindow,
881
+ }) {
722
882
  return {
723
883
  kind: 'follow-up-rollup',
724
884
  signalCount: signals.length,
725
- storyCount: stories.length,
885
+ storyCount,
726
886
  filed: graduated.filed?.length ?? 0,
727
887
  // Story #4850 — the recurrence window the gather actually applied, and what
728
888
  // it dropped. `signalCount` alone cannot distinguish "the window is bounded
@@ -760,87 +920,10 @@ async function executeFollowUpRollup({
760
920
  // Story #4578 — zero signals across a multi-Story run is a claim, not a
761
921
  // clean bill of health. Surfaced on the step result so the CLI can warn
762
922
  // the operator without re-deriving it from the comment prose.
763
- emptyRollupSuspect: signals.length === 0 && stories.length > 1,
923
+ emptyRollupSuspect: signals.length === 0 && storyCount > 1,
764
924
  };
765
925
  }
766
926
 
767
- function extractSection(body, heading) {
768
- if (typeof body !== 'string') return '';
769
- const re = new RegExp(
770
- `(?:^|\\n)## ${heading}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`,
771
- 'i',
772
- );
773
- const match = body.match(re);
774
- return match ? match[1].trim() : '';
775
- }
776
-
777
- async function executeSiblingCoherence({ planRunId, stories, provider }) {
778
- const findings = [];
779
- const bodies = [];
780
- for (const raw of stories) {
781
- const sid = Number(raw);
782
- if (!Number.isInteger(sid) || sid <= 0) continue;
783
- const ticket = await provider.getTicket(sid);
784
- bodies.push({
785
- id: sid,
786
- title: ticket?.title ?? '',
787
- acceptance: extractSection(ticket?.body ?? '', 'Acceptance'),
788
- spec: extractSection(ticket?.body ?? '', 'Spec'),
789
- });
790
- }
791
- const withAcceptance = bodies.filter((b) => b.acceptance.length > 0);
792
- if (withAcceptance.length > 0 && withAcceptance.length < bodies.length) {
793
- const missing = bodies
794
- .filter((b) => b.acceptance.length === 0)
795
- .map((b) => `#${b.id}`);
796
- findings.push(
797
- `Stories missing ## Acceptance while siblings declare ACs: ${missing.join(', ')}`,
798
- );
799
- }
800
- // Detect identical non-empty Spec blobs (likely copy-paste drift).
801
- const specMap = new Map();
802
- for (const b of bodies) {
803
- if (!b.spec) continue;
804
- const key = b.spec.replace(/\s+/g, ' ').slice(0, 400);
805
- if (!specMap.has(key)) specMap.set(key, []);
806
- specMap.get(key).push(b.id);
807
- }
808
- for (const ids of specMap.values()) {
809
- if (ids.length > 1) {
810
- findings.push(
811
- `Duplicate ## Spec prose across Stories ${ids.map((id) => `#${id}`).join(', ')} — split or dedupe.`,
812
- );
813
- }
814
- }
815
- const primaryId = Number(stories[0]);
816
- const body = [
817
- '### plan-run-sibling-coherence',
818
- '',
819
- `Sibling-coherence check for plan-run \`${planRunId}\`.`,
820
- '',
821
- findings.length === 0
822
- ? '_No coherence findings._'
823
- : findings.map((f) => `- ${f}`).join('\n'),
824
- '',
825
- '```json',
826
- JSON.stringify(
827
- { planRunId, stories: stories.map(Number), findings },
828
- null,
829
- 2,
830
- ),
831
- '```',
832
- ].join('\n');
833
- if (Number.isInteger(primaryId) && primaryId > 0) {
834
- await upsertStructuredComment(
835
- provider,
836
- primaryId,
837
- 'plan-run-sibling-coherence',
838
- body,
839
- );
840
- }
841
- return { kind: 'sibling-coherence', findings };
842
- }
843
-
844
927
  /**
845
928
  * Execute the per-run epilogue. Throws only on programmer misuse; step
846
929
  * failures are collected into `errors[]`.
@@ -856,6 +939,8 @@ async function executeSiblingCoherence({ planRunId, stories, provider }) {
856
939
  * @param {typeof graduateRetroProposals} [args.graduateFn] - Injection seam so
857
940
  * the roll-up's reporting layer can be asserted against a filer that fails
858
941
  * (Story #4828) without spawning a real `gh`.
942
+ * @param {boolean} [args.auditRoster] Opt into the audit-roster step
943
+ * (Story #5343); default `false`.
859
944
  * @returns {Promise<object>}
860
945
  */
861
946
  export async function runPlanRunEpilogue({
@@ -867,8 +952,9 @@ export async function runPlanRunEpilogue({
867
952
  git = { gitSpawn },
868
953
  selectAuditsFn = selectAudits,
869
954
  graduateFn = graduateRetroProposals,
955
+ auditRoster = false,
870
956
  } = {}) {
871
- const plan = planRunEpilogue({ planRunId, stories });
957
+ const plan = planRunEpilogue({ planRunId, stories, auditRoster });
872
958
  if (!plan.applicable) {
873
959
  return { ...plan, results: [], errors: [] };
874
960
  }
@@ -903,14 +989,6 @@ export async function runPlanRunEpilogue({
903
989
  graduateFn,
904
990
  }),
905
991
  );
906
- } else if (step.kind === 'sibling-coherence') {
907
- results.push(
908
- await executeSiblingCoherence({
909
- planRunId: plan.planRunId,
910
- stories: plan.stories,
911
- provider,
912
- }),
913
- );
914
992
  } else if (step.kind === 'epic-close') {
915
993
  results.push(
916
994
  await executeEpicClose({ stories: plan.stories, provider }),