mandrel 2.21.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 (38) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +5 -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/lib/audit-suite/selector.js +275 -162
  8. package/.agents/scripts/lib/config/temp-paths.js +51 -7
  9. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  10. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  11. package/.agents/scripts/lib/label-constants.js +12 -1
  12. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  13. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  14. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  15. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
  17. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  18. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  19. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  20. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  21. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  22. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  24. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  25. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  26. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  27. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  28. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  30. package/.agents/scripts/resolve-stories.js +52 -33
  31. package/.agents/scripts/single-story-confirm-merge.js +5 -7
  32. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  33. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  34. package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
  35. package/.agents/workflows/helpers/deliver-story.md +2 -2
  36. package/.agents/workflows/helpers/plan-reference.md +5 -4
  37. package/docs/CHANGELOG.md +20 -0
  38. package/package.json +1 -1
@@ -21,9 +21,11 @@ import { gitSpawn } from '../git-utils.js';
21
21
  import { Logger } from '../Logger.js';
22
22
  import { composeRoutedProposals } from './retro-proposals.js';
23
23
  import {
24
+ assessRollupOutcome,
24
25
  buildFollowUpsCommentBody,
25
26
  gatherRunFrictionSignals,
26
27
  resolveFollowUpRepos,
28
+ summarizeSignalCategories,
27
29
  } from './story-follow-ups.js';
28
30
  import { upsertStructuredComment } from './ticketing.js';
29
31
 
@@ -550,6 +552,7 @@ async function executeFollowUpRollup({
550
552
  provider,
551
553
  config,
552
554
  cwd,
555
+ graduateFn = graduateRetroProposals,
553
556
  }) {
554
557
  // Shared with the story-scoped gather (Story #4649): `storyId` + `details`
555
558
  // are what the composer's recovery-netting keys on, and two hand-rolled
@@ -570,7 +573,7 @@ async function executeFollowUpRollup({
570
573
  item.title = item.title.replace(/plan-run \d+/, `plan-run ${planRunId}`);
571
574
  item.body = item.body.replace(/plan-run \d+/g, `plan-run ${planRunId}`);
572
575
  }
573
- const graduated = await graduateRetroProposals({
576
+ const graduated = await graduateFn({
574
577
  epicId: primaryId,
575
578
  provider,
576
579
  config,
@@ -582,6 +585,16 @@ async function executeFollowUpRollup({
582
585
  routedProposals: proposals,
583
586
  cwd,
584
587
  });
588
+ const categories = summarizeSignalCategories(signals);
589
+ const proposalCount = proposals.framework.length + proposals.consumer.length;
590
+ const outcome = assessRollupOutcome({
591
+ signalCount: signals.length,
592
+ proposalCount,
593
+ discardedCount: proposals.discarded.length,
594
+ filedCount: graduated.filed?.length ?? 0,
595
+ filingErrors: graduated.errors,
596
+ filingSkipped: graduated.skipped,
597
+ });
585
598
  if (Number.isInteger(primaryId) && primaryId > 0) {
586
599
  const body = buildFollowUpsCommentBody({
587
600
  storyId: primaryId,
@@ -591,6 +604,10 @@ async function executeFollowUpRollup({
591
604
  // render as a flagged claim ("0 signals across N Stories") rather than
592
605
  // as "nothing to follow up".
593
606
  storyCount: stories.length,
607
+ // Story #4828 — and the corpus is what lets a zero-proposal or
608
+ // zero-filed roll-up name what it saw instead of rendering as clean.
609
+ signalCount: signals.length,
610
+ categories,
594
611
  }).replace(
595
612
  `from Story #${primaryId}`,
596
613
  `from plan-run \`${planRunId}\` (primary Story #${primaryId})`,
@@ -602,6 +619,34 @@ async function executeFollowUpRollup({
602
619
  signalCount: signals.length,
603
620
  storyCount: stories.length,
604
621
  filed: graduated.filed?.length ?? 0,
622
+ // Story #4828 — everything below is what the roll-up saw and what became
623
+ // of it. The pre-#4828 result reported `signalCount` and `filed` and
624
+ // nothing in between, so nine signals routing into one proposal whose
625
+ // every filing attempt errored rendered as `{signalCount: 9, filed: 0,
626
+ // discarded: []}` — arithmetically consistent, and indistinguishable from
627
+ // a run with nothing to do.
628
+ proposalCount,
629
+ // The categories the corpus actually contained, so a zero-proposal
630
+ // roll-up names its own input rather than asserting emptiness.
631
+ categories,
632
+ filingErrors: Array.isArray(graduated.errors) ? graduated.errors : [],
633
+ filingSkipped: outcome.blockingSkipReasons,
634
+ // Signals in, nothing out — not even a below-threshold row.
635
+ zeroProposalSuspect: outcome.zeroProposals,
636
+ // Proposals cleared the threshold and the filer produced none of them.
637
+ unfiledProposalSuspect: outcome.unfiledProposals,
638
+ // Story #4824 — a roll-up that discards every candidate must still name
639
+ // what it discarded. Rendering that as "nothing to follow up" is how a
640
+ // defect recurring once per Story survived eighteen consecutive Stories.
641
+ // Surfaced on the step result so the CLI need not regex the comment body.
642
+ discarded: proposals.discarded.map((item) => ({
643
+ category: item.category,
644
+ occurrences: item.occurrences,
645
+ source: item.source,
646
+ storyCount: item.storyCount ?? null,
647
+ tools: item.tools ?? [],
648
+ fingerprint: item.fingerprint ?? null,
649
+ })),
605
650
  // Story #4578 — zero signals across a multi-Story run is a claim, not a
606
651
  // clean bill of health. Surfaced on the step result so the CLI can warn
607
652
  // the operator without re-deriving it from the comment prose.
@@ -698,6 +743,9 @@ async function executeSiblingCoherence({ planRunId, stories, provider }) {
698
743
  * @param {string} [args.cwd]
699
744
  * @param {{ gitSpawn: Function }} [args.git] - Injection seam for tests.
700
745
  * @param {typeof selectAudits} [args.selectAuditsFn] - Injection seam for tests.
746
+ * @param {typeof graduateRetroProposals} [args.graduateFn] - Injection seam so
747
+ * the roll-up's reporting layer can be asserted against a filer that fails
748
+ * (Story #4828) without spawning a real `gh`.
701
749
  * @returns {Promise<object>}
702
750
  */
703
751
  export async function runPlanRunEpilogue({
@@ -708,6 +756,7 @@ export async function runPlanRunEpilogue({
708
756
  cwd = process.cwd(),
709
757
  git = { gitSpawn },
710
758
  selectAuditsFn = selectAudits,
759
+ graduateFn = graduateRetroProposals,
711
760
  } = {}) {
712
761
  const plan = planRunEpilogue({ planRunId, stories });
713
762
  if (!plan.applicable) {
@@ -741,6 +790,7 @@ export async function runPlanRunEpilogue({
741
790
  provider,
742
791
  config,
743
792
  cwd,
793
+ graduateFn,
744
794
  }),
745
795
  );
746
796
  } else if (step.kind === 'sibling-coherence') {
@@ -28,8 +28,13 @@
28
28
  */
29
29
 
30
30
  import { parsePrNumberFromUrl } from '../../../github-url.js';
31
+ import { degradationEnvelope } from '../../review-providers/degraded-gates.js';
31
32
  import { runStoryReviewCore } from '../../story-close/phases/review-core.js';
32
33
  import { postStructuredComment } from '../../ticketing/state.js';
34
+ import {
35
+ buildOutcomeTally,
36
+ formatReviewOutcomeLines,
37
+ } from './review-outcome.js';
33
38
 
34
39
  /**
35
40
  * Extract the numeric PR ID from a `gh pr create` URL. The CLI returns a
@@ -62,13 +67,11 @@ export function buildStoryReviewCrossRefBody({
62
67
  prNumber,
63
68
  commentUrl,
64
69
  severity,
70
+ degradations,
65
71
  }) {
66
- const tally =
67
- `critical:${severity.critical} · high:${severity.high} · ` +
68
- `medium:${severity.medium} · suggestion:${severity.suggestion}`;
69
72
  return (
70
73
  `🔬 Story-scope code review posted on PR [#${prNumber}](${prUrl}): ` +
71
- `[view findings](${commentUrl}) — ${tally}.`
74
+ `[view findings](${commentUrl}) — ${buildOutcomeTally({ severity, degradations })}.`
72
75
  );
73
76
  }
74
77
 
@@ -116,6 +119,7 @@ async function postStoryReviewCrossRef({
116
119
  prNumber,
117
120
  commentUrl,
118
121
  severity,
122
+ degradations: result.degradations,
119
123
  });
120
124
  try {
121
125
  await postStructuredComment(provider, storyId, 'notification', body);
@@ -174,6 +178,8 @@ async function postStoryReviewCrossRef({
174
178
  * severity?: { critical: number, high: number, medium: number, suggestion: number },
175
179
  * posted?: boolean,
176
180
  * postedCommentId?: number|null,
181
+ * degraded?: boolean,
182
+ * degradations?: Array<object>,
177
183
  * crossRefPosted?: boolean,
178
184
  * localLensReview?: object,
179
185
  * }>}
@@ -222,10 +228,13 @@ export async function runStoryScopeReview({
222
228
  medium: 0,
223
229
  suggestion: 0,
224
230
  };
225
- progress(
226
- 'REVIEW',
227
- `Findings — critical:${sev.critical} high:${sev.high} medium:${sev.medium} suggestion:${sev.suggestion}. Posted to PR #${prNumber}: ${result.posted}.`,
228
- );
231
+ const outcome = formatReviewOutcomeLines({
232
+ severity: sev,
233
+ degradations: result.degradations,
234
+ prNumber,
235
+ posted: result.posted,
236
+ });
237
+ for (const line of outcome) progress('REVIEW', line);
229
238
 
230
239
  const crossRefPosted = await postStoryReviewCrossRef({
231
240
  provider,
@@ -242,6 +251,7 @@ export async function runStoryScopeReview({
242
251
  severity: sev,
243
252
  posted: result.posted,
244
253
  postedCommentId: result.postedCommentId ?? null,
254
+ ...degradationEnvelope(result.degradations),
245
255
  crossRefPosted,
246
256
  localLensReview: result.localLensReview,
247
257
  };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * phases/review-outcome.js — operator-facing rendering of the Story-scope
3
+ * review outcome (Story #4839).
4
+ *
5
+ * The review phase used to report a severity tally and nothing else, which made
6
+ * "every gate ran and found nothing" and "a gate never ran" render identically.
7
+ * Both surfaces the operator actually reads — the close progress stream and the
8
+ * cross-reference comment on the Story — now state the degraded gates
9
+ * explicitly, and always state them (as `none` when healthy) so an absent line
10
+ * can never be mistaken for a clean gate.
11
+ *
12
+ * A degraded gate is **reported, not blocking**: the canonical `npm run lint`
13
+ * close-validation gate has already covered this diff before the review phase
14
+ * runs, so failing the merge on a secondary read of an already-gated surface
15
+ * would cost delivery without buying coverage. The rationale for that posture
16
+ * lives with the channel itself in
17
+ * [`review-providers/degraded-gates.js`](../../review-providers/degraded-gates.js).
18
+ */
19
+
20
+ import { summarizeDegradations } from '../../review-providers/degraded-gates.js';
21
+
22
+ /**
23
+ * Pure: the tally suffix shared by the progress line and the cross-reference
24
+ * comment — severity counts plus the degraded-gate state.
25
+ *
26
+ * @param {{ severity: { critical: number, high: number, medium: number, suggestion: number }, degradations?: unknown }} args
27
+ * @returns {string}
28
+ */
29
+ export function buildOutcomeTally({ severity, degradations }) {
30
+ return (
31
+ `critical:${severity.critical} · high:${severity.high} · ` +
32
+ `medium:${severity.medium} · suggestion:${severity.suggestion} · ` +
33
+ `degraded gates: ${summarizeDegradations(degradations)}`
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Pure: the progress lines announcing a completed review. Always one line
39
+ * naming the tally; a second, explicitly-worded line when a gate did not run so
40
+ * the degradation cannot be skimmed past.
41
+ *
42
+ * @param {{
43
+ * severity: { critical: number, high: number, medium: number, suggestion: number },
44
+ * degradations?: unknown,
45
+ * prNumber: number,
46
+ * posted: boolean,
47
+ * }} args
48
+ * @returns {string[]}
49
+ */
50
+ export function formatReviewOutcomeLines({
51
+ severity,
52
+ degradations,
53
+ prNumber,
54
+ posted,
55
+ }) {
56
+ const tally = buildOutcomeTally({ severity, degradations });
57
+ const lines = [`Findings — ${tally}. Posted to PR #${prNumber}: ${posted}.`];
58
+ if (summarizeDegradations(degradations) !== 'none') {
59
+ lines.push(
60
+ '⚠️ Review ran DEGRADED — the surface(s) above were not reviewed. The close ' +
61
+ 'is not blocked (the canonical `npm run lint` close gate already covered ' +
62
+ 'this diff), but this review does not vouch for them.',
63
+ );
64
+ }
65
+ return lines;
66
+ }
@@ -38,6 +38,7 @@
38
38
 
39
39
  import { Logger } from '../../../Logger.js';
40
40
  import { runCodeReview } from '../../code-review.js';
41
+ import { summarizeDegradations } from '../../review-providers/degraded-gates.js';
41
42
  import { emitBlockedCloseResult } from '../emit-blocked.js';
42
43
  import { runLocalLensReview } from './local-lens-review.js';
43
44
  import { runStoryReviewCore } from './review-core.js';
@@ -86,7 +87,10 @@ function buildCodeReviewBlockedExtra({ storyId, reviewResult }) {
86
87
  function formatReviewSummary(reviewResult) {
87
88
  const { high, medium, suggestion } = resolveSeverity(reviewResult);
88
89
  const posted = reviewResult?.posted ?? false;
89
- return `Review completehigh=${high} medium=${medium} suggestion=${suggestion} (posted=${posted}).`;
90
+ // Story #4839`degraded` is always stated: an absent degradation line must
91
+ // never be the way an operator concludes that every gate ran.
92
+ const degraded = summarizeDegradations(reviewResult?.degradations);
93
+ return `Review complete — high=${high} medium=${medium} suggestion=${suggestion} degraded=${degraded} (posted=${posted}).`;
90
94
  }
91
95
 
92
96
  /**
@@ -9,11 +9,15 @@
9
9
  * @module lib/orchestration/story-follow-ups
10
10
  */
11
11
 
12
+ import { signalsFile } from '../config/temp-paths.js';
12
13
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
13
14
  import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
14
15
  import { Logger } from '../Logger.js';
15
16
  import { normalizeGatheredSignal } from '../observability/runtime-friction.js';
16
- import { forEachLine } from '../observability/signals-writer.js';
17
+ import {
18
+ forEachLine,
19
+ forEachSignalStreamLine,
20
+ } from '../observability/signals-writer.js';
17
21
  import {
18
22
  composeRoutedProposals,
19
23
  deriveUnresolvedBlockedEvents,
@@ -83,8 +87,55 @@ export async function gatherStoryFrictionSignals(storyId, config) {
83
87
  }
84
88
 
85
89
  /**
86
- * Gather friction signals across every Story in a run, for the run-scoped
87
- * roll-up.
90
+ * Identity of one physical signal row, for de-duplication.
91
+ *
92
+ * `eventId` is minted by every producer (`diagnose-friction.js` and
93
+ * `runtime-friction.js` both `crypto.randomUUID()` it), so it is the primary
94
+ * key. A row predating the field falls back to its physical `file:line`,
95
+ * which is equally stable — the same row read through two passes over the
96
+ * same tree yields the same coordinates.
97
+ *
98
+ * @param {unknown} parsed
99
+ * @param {string} file
100
+ * @param {number} lineNumber
101
+ * @returns {string}
102
+ */
103
+ function signalIdentity(parsed, file, lineNumber) {
104
+ const eventId =
105
+ parsed !== null &&
106
+ typeof parsed === 'object' &&
107
+ typeof (/** @type {Record<string, unknown>} */ (parsed).eventId) ===
108
+ 'string'
109
+ ? /** @type {string} */ (
110
+ /** @type {Record<string, unknown>} */ (parsed).eventId
111
+ ).trim()
112
+ : '';
113
+ return eventId.length > 0 ? `event:${eventId}` : `row:${file}:${lineNumber}`;
114
+ }
115
+
116
+ /**
117
+ * Gather friction signals for the run-scoped roll-up, over the **whole
118
+ * surviving recurrence window** rather than the run's own Stories
119
+ * (Story #4824).
120
+ *
121
+ * The recurrence threshold in `retro-proposals.js` is ≥ 2 occurrences, and
122
+ * the window it was measured over was one run's Story ids. A defect that
123
+ * fires exactly **once per Story** — which is what a systemic framework
124
+ * defect looks like — therefore scored 1 on every Story and was discarded as
125
+ * a singleton, forever. Eighteen consecutive Stories filed nothing.
126
+ *
127
+ * So the gather reduces over every `signals.ndjson` still present under the
128
+ * configured temp root: `<tempRoot>/standalone/stories/story-<sid>/` and
129
+ * `<tempRoot>/run-<eid>/stories/story-<sid>/`. Temp-tree auto-purge
130
+ * shortening that window is acceptable — a short window under-counts, and
131
+ * therefore fails toward *not* filing, which is the safe direction.
132
+ *
133
+ * The run's own Stories are still gathered explicitly first. The discovery
134
+ * walk resolves through the identical path helpers, so it is provably a
135
+ * superset; the explicit pass makes "never fewer signals than before" a
136
+ * property of the code rather than of an argument about path resolution.
137
+ * {@link signalIdentity} de-duplicates the overlap, so one event can never be
138
+ * counted twice and inflate a singleton into a fabricated recurrence.
88
139
  *
89
140
  * Homed beside {@link gatherStoryFrictionSignals} on purpose: the two used to
90
141
  * be independent copies of the same loop in two modules, and they drifted in
@@ -94,17 +145,39 @@ export async function gatherStoryFrictionSignals(storyId, config) {
94
145
  * Unusable ids are skipped rather than throwing — a roll-up must not fail the
95
146
  * epilogue over one malformed entry.
96
147
  *
97
- * @param {Array<number|string>} storyIds
148
+ * @param {Array<number|string>} storyIds The run's own Stories.
98
149
  * @param {object} [config]
99
150
  * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer', storyId: number, details: object }>>}
100
151
  */
101
152
  export async function gatherRunFrictionSignals(storyIds, config) {
102
153
  const signals = [];
154
+ const seen = new Set();
155
+ const take = (parsed, fallbackStoryId, identity) => {
156
+ if (seen.has(identity)) return;
157
+ seen.add(identity);
158
+ const signal = normalizeGatheredSignal(parsed, fallbackStoryId);
159
+ if (signal) signals.push(signal);
160
+ };
161
+
103
162
  for (const raw of Array.isArray(storyIds) ? storyIds : []) {
104
163
  const sid = Number(raw);
105
164
  if (!Number.isInteger(sid) || sid <= 0) continue;
106
- signals.push(...(await gatherStoryFrictionSignals(sid, config)));
165
+ const file = signalsFile(null, sid, config);
166
+ await forEachLine(
167
+ null,
168
+ sid,
169
+ (parsed, lineNumber) =>
170
+ take(parsed, sid, signalIdentity(parsed, file, lineNumber)),
171
+ config,
172
+ );
107
173
  }
174
+
175
+ await forEachSignalStreamLine(
176
+ (parsed, { storyId, file, lineNumber }) =>
177
+ take(parsed, storyId, signalIdentity(parsed, file, lineNumber)),
178
+ config,
179
+ );
180
+
108
181
  return signals;
109
182
  }
110
183
 
@@ -151,14 +224,187 @@ function renderEmptyRollupLines(storyCount) {
151
224
  ];
152
225
  }
153
226
 
227
+ /**
228
+ * Skip reasons that are a deliberate outcome rather than a broken loop. A
229
+ * roll-up whose every proposal was skipped for one of these filed nothing
230
+ * *on purpose*; anything else is the loop failing quietly.
231
+ */
232
+ const BENIGN_SKIP_REASONS = new Set([
233
+ 'already-filed',
234
+ 'toggle-disabled',
235
+ 'cross-repo-deferred',
236
+ 'cap-reached',
237
+ 'no-actionable-proposals',
238
+ ]);
239
+
240
+ /**
241
+ * Summarize a category corpus for the "name what you saw" lines. Pure.
242
+ *
243
+ * @param {Array<{ category?: string }>} signals
244
+ * @returns {Array<{ category: string, occurrences: number }>}
245
+ */
246
+ export function summarizeSignalCategories(signals) {
247
+ const counts = new Map();
248
+ for (const sig of Array.isArray(signals) ? signals : []) {
249
+ if (sig === null || typeof sig !== 'object') continue;
250
+ const category =
251
+ typeof sig.category === 'string' ? sig.category.trim() : '';
252
+ if (category.length === 0) continue;
253
+ counts.set(category, (counts.get(category) ?? 0) + 1);
254
+ }
255
+ return [...counts.entries()]
256
+ .map(([category, occurrences]) => ({ category, occurrences }))
257
+ .sort((a, b) => a.category.localeCompare(b.category));
258
+ }
259
+
260
+ /**
261
+ * Classify what a roll-up's own numbers say about it — the reporting-layer
262
+ * assertion Story #4828 adds. Pure, so the run epilogue's step result and the
263
+ * rendered comment cannot disagree about whether a roll-up succeeded.
264
+ *
265
+ * Two suspect shapes, both of which previously rendered as success:
266
+ *
267
+ * - `zeroProposals` — signals were gathered and **nothing** came out, not
268
+ * even a below-threshold row. That is the third instance of the failure
269
+ * mode Story #4578 fixed for the zero-signal case and Story #4824 for the
270
+ * all-discarded case: an all-empty routed result is silence, and a routing
271
+ * regression is exactly what it looks like.
272
+ * - `unfiledProposals` — proposals cleared the threshold and none were
273
+ * filed for a reason that is not a deliberate one. This is what actually
274
+ * happened in Story #4828: `gh issue create` rejected every call over an
275
+ * absent label, the error landed in a bucket nobody rendered, and the
276
+ * roll-up reported `filed: 0`.
277
+ *
278
+ * @param {object} args
279
+ * @param {number} args.signalCount
280
+ * @param {number} args.proposalCount framework + consumer
281
+ * @param {number} args.discardedCount
282
+ * @param {number} args.filedCount
283
+ * @param {string[]} [args.filingErrors]
284
+ * @param {Array<{ reason?: string }>} [args.filingSkipped]
285
+ * @returns {{ zeroProposals: boolean, unfiledProposals: boolean, blockingSkipReasons: string[] }}
286
+ */
287
+ export function assessRollupOutcome({
288
+ signalCount,
289
+ proposalCount,
290
+ discardedCount,
291
+ filedCount,
292
+ filingErrors = [],
293
+ filingSkipped = [],
294
+ }) {
295
+ const blockingSkipReasons = [
296
+ ...new Set(
297
+ (Array.isArray(filingSkipped) ? filingSkipped : [])
298
+ .map((entry) => (typeof entry?.reason === 'string' ? entry.reason : ''))
299
+ .filter((reason) => reason && !BENIGN_SKIP_REASONS.has(reason)),
300
+ ),
301
+ ].sort();
302
+ const errors = Array.isArray(filingErrors) ? filingErrors : [];
303
+ return {
304
+ zeroProposals:
305
+ signalCount > 0 && proposalCount === 0 && discardedCount === 0,
306
+ unfiledProposals:
307
+ proposalCount > 0 &&
308
+ filedCount === 0 &&
309
+ (errors.length > 0 || blockingSkipReasons.length > 0),
310
+ blockingSkipReasons,
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Render the "N signals in, zero proposals out" warning (Story #4828).
316
+ *
317
+ * @param {number} signalCount
318
+ * @param {Array<{ category: string, occurrences: number }>} categories
319
+ * @returns {string[]}
320
+ */
321
+ function renderZeroProposalLines(signalCount, categories) {
322
+ const named =
323
+ categories.length > 0
324
+ ? categories.map((c) => `\`${c.category}\` ×${c.occurrences}`).join(', ')
325
+ : '_none — every gathered signal carried an unusable category_';
326
+ return [
327
+ `> ⚠️ **${signalCount} friction signals gathered, 0 proposals produced — a routing outcome, not a clean run.**`,
328
+ `> Categories seen: ${named}.`,
329
+ '> Nothing cleared the actionable threshold AND nothing was recorded below',
330
+ '> it, so every signal was netted out as recovered or dropped in routing.',
331
+ '> A regression in routing renders byte-identically to this, which is why',
332
+ '> the roll-up states it rather than rendering silence.',
333
+ ];
334
+ }
335
+
336
+ /**
337
+ * Render the "proposals cleared the threshold but none were filed" warning
338
+ * (Story #4828).
339
+ *
340
+ * @param {number} proposalCount
341
+ * @param {string[]} filingErrors
342
+ * @param {string[]} blockingSkipReasons
343
+ * @returns {string[]}
344
+ */
345
+ function renderUnfiledProposalLines(
346
+ proposalCount,
347
+ filingErrors,
348
+ blockingSkipReasons,
349
+ ) {
350
+ const lines = [
351
+ `> ⚠️ **${proposalCount} actionable proposal(s) reached the filer and none were filed.**`,
352
+ '> Auto-file is on, so this is the feedback loop failing, not declining.',
353
+ ];
354
+ if (blockingSkipReasons.length > 0) {
355
+ lines.push(`> Skipped: ${blockingSkipReasons.join(', ')}.`);
356
+ }
357
+ for (const error of filingErrors) {
358
+ lines.push(`> ${error}`);
359
+ }
360
+ return lines;
361
+ }
362
+
363
+ /**
364
+ * Render one discarded (below-threshold) roll-up row (Story #4824).
365
+ *
366
+ * The pre-#4824 row was `` `category` ×N `` and nothing else. That is exactly
367
+ * how a defect firing once per Story stayed invisible for eighteen
368
+ * consecutive Stories: an operator reading "×1" cannot tell a one-off from a
369
+ * systemic defect whose window was too narrow to see it recur. The row now
370
+ * names the emitting tools, the bucket fingerprint, and the number of
371
+ * distinct Stories it spans — the cross-run count the widened recurrence
372
+ * window produces.
373
+ *
374
+ * Every added field is optional so a caller passing a hand-built proposals
375
+ * object (or an older persisted one) still renders.
376
+ *
377
+ * @param {{ category: string, occurrences: number, tools?: string[], fingerprint?: string, storyCount?: number }} item
378
+ * @returns {string}
379
+ */
380
+ function renderDiscardedItem(item) {
381
+ const parts = [`\`${item.category}\` ×${item.occurrences}`];
382
+ if (Number.isInteger(item.storyCount) && item.storyCount > 0) {
383
+ const plural = item.storyCount === 1 ? 'Story' : 'Stories';
384
+ parts.push(`across ${item.storyCount} ${plural}`);
385
+ }
386
+ if (Array.isArray(item.tools) && item.tools.length > 0) {
387
+ parts.push(`via ${item.tools.map((t) => `\`${t}\``).join(', ')}`);
388
+ }
389
+ if (typeof item.fingerprint === 'string' && item.fingerprint.length > 0) {
390
+ parts.push(`fingerprint \`${item.fingerprint}\``);
391
+ }
392
+ return parts.join(' — ');
393
+ }
394
+
154
395
  /**
155
396
  * @param {{
156
397
  * storyId: number,
157
398
  * proposals: object,
158
399
  * graduated: object,
159
400
  * storyCount?: number,
401
+ * signalCount?: number,
402
+ * categories?: Array<{ category: string, occurrences: number }>,
160
403
  * }} args - `storyCount` (default 1) is how many Stories the roll-up spans;
161
404
  * it decides whether an empty result reads as quiet or as a flagged claim.
405
+ * `signalCount` / `categories` (Story #4828) are what the roll-up actually
406
+ * gathered, so a zero-proposal or zero-filed outcome can name its own
407
+ * corpus instead of rendering as a clean run.
162
408
  * @returns {string}
163
409
  */
164
410
  export function buildFollowUpsCommentBody({
@@ -166,17 +412,37 @@ export function buildFollowUpsCommentBody({
166
412
  proposals,
167
413
  graduated,
168
414
  storyCount = 1,
415
+ signalCount = 0,
416
+ categories = [],
169
417
  }) {
170
418
  const filed = Array.isArray(graduated?.filed) ? graduated.filed : [];
171
419
  const framework = proposals?.framework ?? [];
172
420
  const consumer = proposals?.consumer ?? [];
173
421
  const discarded = proposals?.discarded ?? [];
422
+ const outcome = assessRollupOutcome({
423
+ signalCount,
424
+ proposalCount: framework.length + consumer.length,
425
+ discardedCount: discarded.length,
426
+ filedCount: filed.length,
427
+ filingErrors: graduated?.errors,
428
+ filingSkipped: graduated?.skipped,
429
+ });
174
430
  const lines = [
175
431
  '### follow-ups',
176
432
  '',
177
433
  `Actionable follow-ups captured from Story #${storyId} after merge.`,
178
434
  '',
179
435
  ];
436
+ if (outcome.unfiledProposals) {
437
+ lines.push(
438
+ ...renderUnfiledProposalLines(
439
+ framework.length + consumer.length,
440
+ Array.isArray(graduated?.errors) ? graduated.errors : [],
441
+ outcome.blockingSkipReasons,
442
+ ),
443
+ '',
444
+ );
445
+ }
180
446
  if (filed.length > 0) {
181
447
  lines.push('**Filed**');
182
448
  for (const item of filed) {
@@ -198,9 +464,9 @@ export function buildFollowUpsCommentBody({
198
464
  lines.push('');
199
465
  }
200
466
  if (discarded.length > 0) {
201
- lines.push('**Single-occurrence (not filed)**');
467
+ lines.push('**Below threshold (not filed)**');
202
468
  for (const item of discarded) {
203
- lines.push(`- ${item.source}: \`${item.category}\` ×${item.occurrences}`);
469
+ lines.push(`- ${item.source}: ${renderDiscardedItem(item)}`);
204
470
  }
205
471
  lines.push('');
206
472
  }
@@ -210,7 +476,14 @@ export function buildFollowUpsCommentBody({
210
476
  consumer.length === 0 &&
211
477
  discarded.length === 0
212
478
  ) {
213
- lines.push(...renderEmptyRollupLines(storyCount));
479
+ // Story #4828 — "no proposals" has two readings, and only one of them is
480
+ // a quiet run. Signals gathered but nothing routed is the third instance
481
+ // of the silence Stories #4578 and #4824 each fixed once.
482
+ lines.push(
483
+ ...(outcome.zeroProposals
484
+ ? renderZeroProposalLines(signalCount, categories)
485
+ : renderEmptyRollupLines(storyCount)),
486
+ );
214
487
  lines.push('');
215
488
  }
216
489
  lines.push('```json');
@@ -219,9 +492,23 @@ export function buildFollowUpsCommentBody({
219
492
  {
220
493
  storyId,
221
494
  storyCount,
495
+ // Story #4828 — the corpus the roll-up actually read. Without it a
496
+ // reader cannot tell "0 proposals because nothing recurred" from
497
+ // "0 proposals because routing broke".
498
+ signalCount,
499
+ categories,
222
500
  framework: framework.map((i) => i.category),
223
501
  consumer: consumer.map((i) => i.category),
224
- discarded: discarded.map((i) => i.category),
502
+ // Story #4824 — the machine-readable twin of the row above. A bare
503
+ // category list could not distinguish a genuine one-off from a
504
+ // recurrence the window was too narrow to see, so the count, the
505
+ // cross-Story span, and the shape fingerprint ride along.
506
+ discarded: discarded.map((i) => ({
507
+ category: i.category,
508
+ occurrences: i.occurrences,
509
+ storyCount: i.storyCount ?? null,
510
+ fingerprint: i.fingerprint ?? null,
511
+ })),
225
512
  filed: filed.map((i) => ({
226
513
  category: i.category,
227
514
  url: i.url ?? null,
@@ -234,7 +521,13 @@ export function buildFollowUpsCommentBody({
234
521
  filed.length === 0 &&
235
522
  framework.length === 0 &&
236
523
  consumer.length === 0 &&
237
- discarded.length === 0,
524
+ discarded.length === 0 &&
525
+ signalCount === 0,
526
+ // Story #4828 — the two remaining shapes that used to render as
527
+ // success. Machine-readable twins of the warning prose above.
528
+ zeroProposalSuspect: outcome.zeroProposals,
529
+ unfiledProposalSuspect: outcome.unfiledProposals,
530
+ filingErrors: Array.isArray(graduated?.errors) ? graduated.errors : [],
238
531
  },
239
532
  null,
240
533
  2,
@@ -308,6 +601,8 @@ export async function captureStoryFollowUps({
308
601
  storyId: sid,
309
602
  proposals,
310
603
  graduated,
604
+ signalCount: signals.length,
605
+ categories: summarizeSignalCategories(signals),
311
606
  });
312
607
  await upsertStructuredComment(provider, sid, FOLLOW_UPS_COMMENT_TYPE, body);
313
608
  progress?.(