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
@@ -28,6 +28,10 @@
28
28
  * pre-#4543 pipeline collapsed by treating budget exhaustion as a block.
29
29
  */
30
30
 
31
+ import nodeFs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { storyTerminalEnvelopePath } from '../config/temp-paths.js';
34
+ import { resolveConfig } from '../config-resolver.js';
31
35
  import { validateTerminalEnvelope } from './story-deliver-terminal-schema.js';
32
36
 
33
37
  // Re-exported so the schema split stays an implementation detail: every
@@ -327,7 +331,95 @@ export const TERMINAL_BEGIN_MARKER = '--- STORY DELIVER TERMINAL ---';
327
331
  export const TERMINAL_END_MARKER = '--- END TERMINAL ---';
328
332
 
329
333
  /**
330
- * Write a terminal envelope to stdout, between its markers.
334
+ * Resolve the repo config, or `undefined` when it cannot be read.
335
+ *
336
+ * An unreadable `.agentrc.json` must not cost the run its envelope copy: the
337
+ * path helpers fall back to the framework-default temp root, which is still a
338
+ * far better outcome than no artifact at all.
339
+ *
340
+ * @param {typeof resolveConfig} resolveConfigImpl
341
+ * @returns {object|undefined}
342
+ */
343
+ function tolerantConfig(resolveConfigImpl) {
344
+ try {
345
+ return resolveConfigImpl();
346
+ } catch {
347
+ return undefined;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Persist a terminal envelope beside its Story's gate log (Story #4816).
353
+ *
354
+ * Stdout is a channel with exactly one reader — the turn that launched the
355
+ * close — and that reader is not always still listening. A `story-worker`
356
+ * that reports progress and ends its turn while the close it started is
357
+ * mid-gate-chain is behaving reasonably, but the envelope it never relayed is
358
+ * gone: the router then has to reconstruct the Story's state from labels, and
359
+ * `deliver-recover.js` used to answer that reconstruction with
360
+ * "Implementation never finished" — false, and its re-init suggestion can put
361
+ * a second close on one PR. Observed four times across three workers in one
362
+ * consumer run. The file is the second channel, and it outlives the turn.
363
+ *
364
+ * **Best-effort by construction.** Every failure path returns `null`: a
365
+ * delivery must never turn a landed PR into a crash because a temp directory
366
+ * was unwritable. The stdout envelope above is the contract; this is a copy.
367
+ *
368
+ * **Atomic by construction.** The payload is written to a pid-scoped
369
+ * temporary name and renamed into place, because the reader that matters most
370
+ * is a router polling during a live close — a half-written file would hand it
371
+ * a parse error at exactly the moment it is trying to avoid guessing.
372
+ *
373
+ * A null `storyId` (the `escalated` terminal, which by construction never
374
+ * authored a Story) has nowhere to be filed and writes nothing.
375
+ *
376
+ * `config` is optional and resolved lazily when absent. Two of the emit sites
377
+ * are `catch` blocks that crashed before any config was resolved, and those
378
+ * are precisely the runs whose envelope is most worth keeping — so the temp
379
+ * root is looked up here rather than left at the framework default, which
380
+ * would file the artifact where a consumer's router never looks (and where
381
+ * the retention purge would never reap it).
382
+ *
383
+ * @param {object} envelope A validated terminal envelope.
384
+ * @param {{
385
+ * config?: object,
386
+ * fsImpl?: typeof nodeFs,
387
+ * resolveConfigImpl?: typeof resolveConfig,
388
+ * }} [deps]
389
+ * @returns {string|null} The path written, or `null` when nothing was.
390
+ */
391
+ export function persistTerminalEnvelope(
392
+ envelope,
393
+ { config, fsImpl = nodeFs, resolveConfigImpl = resolveConfig } = {},
394
+ ) {
395
+ const storyId = envelope?.storyId;
396
+ if (!Number.isInteger(storyId) || storyId <= 0) return null;
397
+ let tmpPath = null;
398
+ try {
399
+ const resolved = config ?? tolerantConfig(resolveConfigImpl);
400
+ const target = storyTerminalEnvelopePath(storyId, resolved);
401
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
402
+ tmpPath = `${target}.${process.pid}.tmp`;
403
+ fsImpl.writeFileSync(tmpPath, `${JSON.stringify(envelope)}\n`, 'utf8');
404
+ fsImpl.renameSync(tmpPath, target);
405
+ return target;
406
+ } catch {
407
+ // A rename that never ran leaves the scratch file behind; drop it rather
408
+ // than accumulating one per failed close.
409
+ if (tmpPath) {
410
+ try {
411
+ fsImpl.rmSync(tmpPath, { force: true });
412
+ } catch {
413
+ // Nothing left to try — this whole path is already best-effort.
414
+ }
415
+ }
416
+ return null;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Write a terminal envelope to stdout, between its markers, and persist a
422
+ * copy to disk.
331
423
  *
332
424
  * **Deliberately not `Logger.info`.** The envelope is this CLI's
333
425
  * machine-readable contract — every invocation emits exactly ONE, and a
@@ -340,16 +432,37 @@ export const TERMINAL_END_MARKER = '--- END TERMINAL ---';
340
432
  *
341
433
  * Single home for the marker format so the four emit sites (the runner's
342
434
  * terminal, the close CLI's failed-terminal catch, and both confirm-CLI
343
- * paths) cannot drift apart.
435
+ * paths) cannot drift apart — and, since Story #4816, the single home for the
436
+ * on-disk copy too, so no emit path can persist and another forget.
437
+ * {@link persistTerminalEnvelope} runs **first**: a caller that has read the
438
+ * markers off stdout can then rely on the file already being there.
344
439
  *
345
440
  * @param {object} envelope
346
- * @param {{ write?: (s: string) => void }} [opts] `write` is a test seam.
441
+ * @param {{
442
+ * write?: (s: string) => void,
443
+ * config?: object,
444
+ * persist?: typeof persistTerminalEnvelope,
445
+ * }} [opts] `write` and `persist` are test seams; `config` resolves the
446
+ * artifact's temp root and is threaded from whichever emit site holds one.
347
447
  * @returns {void}
348
448
  */
349
449
  export function emitTerminalEnvelope(
350
450
  envelope,
351
- { write = (s) => process.stdout.write(s) } = {},
451
+ {
452
+ write = (s) => process.stdout.write(s),
453
+ config,
454
+ persist = persistTerminalEnvelope,
455
+ } = {},
352
456
  ) {
457
+ // Belt and braces around a copy: `persistTerminalEnvelope` already swallows
458
+ // its own failures, but the stdout envelope is the CONTRACT and the disk
459
+ // copy is a convenience. Nothing in the secondary path — including a future
460
+ // injected `persist` — may cost the caller the primary one.
461
+ try {
462
+ persist(envelope, { config });
463
+ } catch {
464
+ // Intentionally silent: see above.
465
+ }
353
466
  // Story #4685 — compact (not 2-space pretty) JSON. The envelope is a
354
467
  // machine contract callers recover with `JSON.parse`, so pretty-printing
355
468
  // only adds turn-resident bytes without helping any consumer.
@@ -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?.(