mandrel 2.21.0 → 2.23.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 (44) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +5 -0
  3. package/.agents/docs/configuration.md +1 -0
  4. package/.agents/instructions.md +14 -17
  5. package/.agents/rules/git-conventions.md +1 -1
  6. package/.agents/rules/known-tooling-behavior.md +114 -0
  7. package/.agents/schemas/agentrc.schema.json +6 -0
  8. package/.agents/scripts/check-context-budget.js +134 -2
  9. package/.agents/scripts/diagnose-friction.js +95 -4
  10. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  11. package/.agents/scripts/lib/config/temp-paths.js +51 -7
  12. package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
  13. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  14. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  15. package/.agents/scripts/lib/label-constants.js +12 -1
  16. package/.agents/scripts/lib/observability/runtime-friction.js +41 -1
  17. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  18. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  19. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  20. package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
  21. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  22. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  24. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  25. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  26. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  27. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  28. package/.agents/scripts/lib/orchestration/run-epilogue.js +69 -7
  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/story-close/phases/code-review.js +5 -1
  32. package/.agents/scripts/lib/orchestration/story-follow-ups.js +380 -13
  33. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  34. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  35. package/.agents/scripts/resolve-stories.js +52 -33
  36. package/.agents/scripts/single-story-confirm-merge.js +5 -7
  37. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  38. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  39. package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
  40. package/.agents/workflows/helpers/deliver-story.md +2 -2
  41. package/.agents/workflows/helpers/plan-reference.md +45 -4
  42. package/.agents/workflows/plan.md +21 -16
  43. package/docs/CHANGELOG.md +34 -0
  44. package/package.json +1 -1
@@ -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,
@@ -22,6 +26,33 @@ import { upsertStructuredComment } from './ticketing.js';
22
26
 
23
27
  export const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
24
28
 
29
+ /** Milliseconds in one day — the unit `frictionWindowDays` is expressed in. */
30
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
31
+
32
+ /** Window bound applied when `frictionWindowDays` is unset. */
33
+ const DEFAULT_FRICTION_WINDOW_DAYS = 30;
34
+
35
+ /**
36
+ * How many days back the run-scope recurrence window reaches (Story #4850).
37
+ *
38
+ * Defaults to 30 rather than to "unbounded": the widened cross-run window
39
+ * exists to let a once-per-Story defect reach the ≥ 2 threshold, and 30 days is
40
+ * long enough for that while short enough that a defect fixed last month stops
41
+ * re-routing. An absent, non-integer, or sub-1 value takes the default — the
42
+ * runtime AJV in `config-settings-schema-delivery.js` rejects those at load, so
43
+ * reaching this fallback means the config never went through the validator.
44
+ *
45
+ * @param {object} [config]
46
+ * @returns {number}
47
+ */
48
+ function resolveFrictionWindowDays(config) {
49
+ const raw = config?.delivery?.feedbackLoop?.frictionWindowDays;
50
+ const days = Number(raw);
51
+ return Number.isInteger(days) && days >= 1
52
+ ? days
53
+ : DEFAULT_FRICTION_WINDOW_DAYS;
54
+ }
55
+
25
56
  /**
26
57
  * @param {object} [config]
27
58
  * @returns {{ frameworkRepo: string, consumerRepo: string, currentRepo: { owner: string, repo: string } }}
@@ -83,8 +114,55 @@ export async function gatherStoryFrictionSignals(storyId, config) {
83
114
  }
84
115
 
85
116
  /**
86
- * Gather friction signals across every Story in a run, for the run-scoped
87
- * roll-up.
117
+ * Identity of one physical signal row, for de-duplication.
118
+ *
119
+ * `eventId` is minted by every producer (`diagnose-friction.js` and
120
+ * `runtime-friction.js` both `crypto.randomUUID()` it), so it is the primary
121
+ * key. A row predating the field falls back to its physical `file:line`,
122
+ * which is equally stable — the same row read through two passes over the
123
+ * same tree yields the same coordinates.
124
+ *
125
+ * @param {unknown} parsed
126
+ * @param {string} file
127
+ * @param {number} lineNumber
128
+ * @returns {string}
129
+ */
130
+ function signalIdentity(parsed, file, lineNumber) {
131
+ const eventId =
132
+ parsed !== null &&
133
+ typeof parsed === 'object' &&
134
+ typeof (/** @type {Record<string, unknown>} */ (parsed).eventId) ===
135
+ 'string'
136
+ ? /** @type {string} */ (
137
+ /** @type {Record<string, unknown>} */ (parsed).eventId
138
+ ).trim()
139
+ : '';
140
+ return eventId.length > 0 ? `event:${eventId}` : `row:${file}:${lineNumber}`;
141
+ }
142
+
143
+ /**
144
+ * Gather friction signals for the run-scoped roll-up, over the **whole
145
+ * surviving recurrence window** rather than the run's own Stories
146
+ * (Story #4824).
147
+ *
148
+ * The recurrence threshold in `retro-proposals.js` is ≥ 2 occurrences, and
149
+ * the window it was measured over was one run's Story ids. A defect that
150
+ * fires exactly **once per Story** — which is what a systemic framework
151
+ * defect looks like — therefore scored 1 on every Story and was discarded as
152
+ * a singleton, forever. Eighteen consecutive Stories filed nothing.
153
+ *
154
+ * So the gather reduces over every `signals.ndjson` still present under the
155
+ * configured temp root: `<tempRoot>/standalone/stories/story-<sid>/` and
156
+ * `<tempRoot>/run-<eid>/stories/story-<sid>/`. Temp-tree auto-purge
157
+ * shortening that window is acceptable — a short window under-counts, and
158
+ * therefore fails toward *not* filing, which is the safe direction.
159
+ *
160
+ * The run's own Stories are still gathered explicitly first. The discovery
161
+ * walk resolves through the identical path helpers, so it is provably a
162
+ * superset; the explicit pass makes "never fewer signals than before" a
163
+ * property of the code rather than of an argument about path resolution.
164
+ * {@link signalIdentity} de-duplicates the overlap, so one event can never be
165
+ * counted twice and inflate a singleton into a fabricated recurrence.
88
166
  *
89
167
  * Homed beside {@link gatherStoryFrictionSignals} on purpose: the two used to
90
168
  * be independent copies of the same loop in two modules, and they drifted in
@@ -94,18 +172,85 @@ export async function gatherStoryFrictionSignals(storyId, config) {
94
172
  * Unusable ids are skipped rather than throwing — a roll-up must not fail the
95
173
  * epilogue over one malformed entry.
96
174
  *
97
- * @param {Array<number|string>} storyIds
175
+ * **Bounded by age, not by run (Story #4850).** Widening the window to the
176
+ * whole surviving temp tree also made it unbounded in *time*: a defect fixed
177
+ * weeks ago kept its occurrences on disk and kept re-routing forever, burying
178
+ * a genuine new regression underneath a historical ledger. Rows older than
179
+ * `delivery.feedbackLoop.frictionWindowDays` (default 30) are excluded, as are
180
+ * rows carrying no `ts` a `Date` can read — excluding an undateable row is the
181
+ * direction that fails toward under-counting, and under-counting fails toward
182
+ * not filing. Both exclusions are **counted and reported**, so a caller can
183
+ * tell a bounded window from an unbounded one without reading prose.
184
+ *
185
+ * A recovery marker is written after the incident it cancels, so a marker can
186
+ * never fall outside a window its incident is inside — the netting cannot be
187
+ * broken by the age floor.
188
+ *
189
+ * @param {Array<number|string>} storyIds The run's own Stories.
98
190
  * @param {object} [config]
99
- * @returns {Promise<Array<{ category: string, source: 'framework'|'consumer', storyId: number, details: object }>>}
191
+ * @param {{ now?: number }} [clock] Injected epoch-ms seam so a test can pin
192
+ * the window without touching the system clock.
193
+ * @returns {Promise<{
194
+ * signals: Array<{ category: string, source: 'framework'|'consumer', storyId: number, ts: string|null, details: object }>,
195
+ * window: { days: number, cutoff: string, excludedStale: number, excludedUnparseable: number },
196
+ * }>}
100
197
  */
101
- export async function gatherRunFrictionSignals(storyIds, config) {
198
+ export async function gatherRunFrictionSignals(
199
+ storyIds,
200
+ config,
201
+ { now = Date.now() } = {},
202
+ ) {
203
+ const days = resolveFrictionWindowDays(config);
204
+ const cutoffMs = now - days * MS_PER_DAY;
102
205
  const signals = [];
206
+ const seen = new Set();
207
+ let excludedStale = 0;
208
+ let excludedUnparseable = 0;
209
+ const take = (parsed, fallbackStoryId, identity) => {
210
+ if (seen.has(identity)) return;
211
+ seen.add(identity);
212
+ const signal = normalizeGatheredSignal(parsed, fallbackStoryId);
213
+ if (!signal) return;
214
+ const ms = signal.ts === null ? Number.NaN : Date.parse(signal.ts);
215
+ if (!Number.isFinite(ms)) {
216
+ excludedUnparseable += 1;
217
+ return;
218
+ }
219
+ if (ms < cutoffMs) {
220
+ excludedStale += 1;
221
+ return;
222
+ }
223
+ signals.push(signal);
224
+ };
225
+
103
226
  for (const raw of Array.isArray(storyIds) ? storyIds : []) {
104
227
  const sid = Number(raw);
105
228
  if (!Number.isInteger(sid) || sid <= 0) continue;
106
- signals.push(...(await gatherStoryFrictionSignals(sid, config)));
229
+ const file = signalsFile(null, sid, config);
230
+ await forEachLine(
231
+ null,
232
+ sid,
233
+ (parsed, lineNumber) =>
234
+ take(parsed, sid, signalIdentity(parsed, file, lineNumber)),
235
+ config,
236
+ );
107
237
  }
108
- return signals;
238
+
239
+ await forEachSignalStreamLine(
240
+ (parsed, { storyId, file, lineNumber }) =>
241
+ take(parsed, storyId, signalIdentity(parsed, file, lineNumber)),
242
+ config,
243
+ );
244
+
245
+ return {
246
+ signals,
247
+ window: {
248
+ days,
249
+ cutoff: new Date(cutoffMs).toISOString(),
250
+ excludedStale,
251
+ excludedUnparseable,
252
+ },
253
+ };
109
254
  }
110
255
 
111
256
  /**
@@ -151,14 +296,187 @@ function renderEmptyRollupLines(storyCount) {
151
296
  ];
152
297
  }
153
298
 
299
+ /**
300
+ * Skip reasons that are a deliberate outcome rather than a broken loop. A
301
+ * roll-up whose every proposal was skipped for one of these filed nothing
302
+ * *on purpose*; anything else is the loop failing quietly.
303
+ */
304
+ const BENIGN_SKIP_REASONS = new Set([
305
+ 'already-filed',
306
+ 'toggle-disabled',
307
+ 'cross-repo-deferred',
308
+ 'cap-reached',
309
+ 'no-actionable-proposals',
310
+ ]);
311
+
312
+ /**
313
+ * Summarize a category corpus for the "name what you saw" lines. Pure.
314
+ *
315
+ * @param {Array<{ category?: string }>} signals
316
+ * @returns {Array<{ category: string, occurrences: number }>}
317
+ */
318
+ export function summarizeSignalCategories(signals) {
319
+ const counts = new Map();
320
+ for (const sig of Array.isArray(signals) ? signals : []) {
321
+ if (sig === null || typeof sig !== 'object') continue;
322
+ const category =
323
+ typeof sig.category === 'string' ? sig.category.trim() : '';
324
+ if (category.length === 0) continue;
325
+ counts.set(category, (counts.get(category) ?? 0) + 1);
326
+ }
327
+ return [...counts.entries()]
328
+ .map(([category, occurrences]) => ({ category, occurrences }))
329
+ .sort((a, b) => a.category.localeCompare(b.category));
330
+ }
331
+
332
+ /**
333
+ * Classify what a roll-up's own numbers say about it — the reporting-layer
334
+ * assertion Story #4828 adds. Pure, so the run epilogue's step result and the
335
+ * rendered comment cannot disagree about whether a roll-up succeeded.
336
+ *
337
+ * Two suspect shapes, both of which previously rendered as success:
338
+ *
339
+ * - `zeroProposals` — signals were gathered and **nothing** came out, not
340
+ * even a below-threshold row. That is the third instance of the failure
341
+ * mode Story #4578 fixed for the zero-signal case and Story #4824 for the
342
+ * all-discarded case: an all-empty routed result is silence, and a routing
343
+ * regression is exactly what it looks like.
344
+ * - `unfiledProposals` — proposals cleared the threshold and none were
345
+ * filed for a reason that is not a deliberate one. This is what actually
346
+ * happened in Story #4828: `gh issue create` rejected every call over an
347
+ * absent label, the error landed in a bucket nobody rendered, and the
348
+ * roll-up reported `filed: 0`.
349
+ *
350
+ * @param {object} args
351
+ * @param {number} args.signalCount
352
+ * @param {number} args.proposalCount framework + consumer
353
+ * @param {number} args.discardedCount
354
+ * @param {number} args.filedCount
355
+ * @param {string[]} [args.filingErrors]
356
+ * @param {Array<{ reason?: string }>} [args.filingSkipped]
357
+ * @returns {{ zeroProposals: boolean, unfiledProposals: boolean, blockingSkipReasons: string[] }}
358
+ */
359
+ export function assessRollupOutcome({
360
+ signalCount,
361
+ proposalCount,
362
+ discardedCount,
363
+ filedCount,
364
+ filingErrors = [],
365
+ filingSkipped = [],
366
+ }) {
367
+ const blockingSkipReasons = [
368
+ ...new Set(
369
+ (Array.isArray(filingSkipped) ? filingSkipped : [])
370
+ .map((entry) => (typeof entry?.reason === 'string' ? entry.reason : ''))
371
+ .filter((reason) => reason && !BENIGN_SKIP_REASONS.has(reason)),
372
+ ),
373
+ ].sort();
374
+ const errors = Array.isArray(filingErrors) ? filingErrors : [];
375
+ return {
376
+ zeroProposals:
377
+ signalCount > 0 && proposalCount === 0 && discardedCount === 0,
378
+ unfiledProposals:
379
+ proposalCount > 0 &&
380
+ filedCount === 0 &&
381
+ (errors.length > 0 || blockingSkipReasons.length > 0),
382
+ blockingSkipReasons,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Render the "N signals in, zero proposals out" warning (Story #4828).
388
+ *
389
+ * @param {number} signalCount
390
+ * @param {Array<{ category: string, occurrences: number }>} categories
391
+ * @returns {string[]}
392
+ */
393
+ function renderZeroProposalLines(signalCount, categories) {
394
+ const named =
395
+ categories.length > 0
396
+ ? categories.map((c) => `\`${c.category}\` ×${c.occurrences}`).join(', ')
397
+ : '_none — every gathered signal carried an unusable category_';
398
+ return [
399
+ `> ⚠️ **${signalCount} friction signals gathered, 0 proposals produced — a routing outcome, not a clean run.**`,
400
+ `> Categories seen: ${named}.`,
401
+ '> Nothing cleared the actionable threshold AND nothing was recorded below',
402
+ '> it, so every signal was netted out as recovered or dropped in routing.',
403
+ '> A regression in routing renders byte-identically to this, which is why',
404
+ '> the roll-up states it rather than rendering silence.',
405
+ ];
406
+ }
407
+
408
+ /**
409
+ * Render the "proposals cleared the threshold but none were filed" warning
410
+ * (Story #4828).
411
+ *
412
+ * @param {number} proposalCount
413
+ * @param {string[]} filingErrors
414
+ * @param {string[]} blockingSkipReasons
415
+ * @returns {string[]}
416
+ */
417
+ function renderUnfiledProposalLines(
418
+ proposalCount,
419
+ filingErrors,
420
+ blockingSkipReasons,
421
+ ) {
422
+ const lines = [
423
+ `> ⚠️ **${proposalCount} actionable proposal(s) reached the filer and none were filed.**`,
424
+ '> Auto-file is on, so this is the feedback loop failing, not declining.',
425
+ ];
426
+ if (blockingSkipReasons.length > 0) {
427
+ lines.push(`> Skipped: ${blockingSkipReasons.join(', ')}.`);
428
+ }
429
+ for (const error of filingErrors) {
430
+ lines.push(`> ${error}`);
431
+ }
432
+ return lines;
433
+ }
434
+
435
+ /**
436
+ * Render one discarded (below-threshold) roll-up row (Story #4824).
437
+ *
438
+ * The pre-#4824 row was `` `category` ×N `` and nothing else. That is exactly
439
+ * how a defect firing once per Story stayed invisible for eighteen
440
+ * consecutive Stories: an operator reading "×1" cannot tell a one-off from a
441
+ * systemic defect whose window was too narrow to see it recur. The row now
442
+ * names the emitting tools, the bucket fingerprint, and the number of
443
+ * distinct Stories it spans — the cross-run count the widened recurrence
444
+ * window produces.
445
+ *
446
+ * Every added field is optional so a caller passing a hand-built proposals
447
+ * object (or an older persisted one) still renders.
448
+ *
449
+ * @param {{ category: string, occurrences: number, tools?: string[], fingerprint?: string, storyCount?: number }} item
450
+ * @returns {string}
451
+ */
452
+ function renderDiscardedItem(item) {
453
+ const parts = [`\`${item.category}\` ×${item.occurrences}`];
454
+ if (Number.isInteger(item.storyCount) && item.storyCount > 0) {
455
+ const plural = item.storyCount === 1 ? 'Story' : 'Stories';
456
+ parts.push(`across ${item.storyCount} ${plural}`);
457
+ }
458
+ if (Array.isArray(item.tools) && item.tools.length > 0) {
459
+ parts.push(`via ${item.tools.map((t) => `\`${t}\``).join(', ')}`);
460
+ }
461
+ if (typeof item.fingerprint === 'string' && item.fingerprint.length > 0) {
462
+ parts.push(`fingerprint \`${item.fingerprint}\``);
463
+ }
464
+ return parts.join(' — ');
465
+ }
466
+
154
467
  /**
155
468
  * @param {{
156
469
  * storyId: number,
157
470
  * proposals: object,
158
471
  * graduated: object,
159
472
  * storyCount?: number,
473
+ * signalCount?: number,
474
+ * categories?: Array<{ category: string, occurrences: number }>,
160
475
  * }} args - `storyCount` (default 1) is how many Stories the roll-up spans;
161
476
  * it decides whether an empty result reads as quiet or as a flagged claim.
477
+ * `signalCount` / `categories` (Story #4828) are what the roll-up actually
478
+ * gathered, so a zero-proposal or zero-filed outcome can name its own
479
+ * corpus instead of rendering as a clean run.
162
480
  * @returns {string}
163
481
  */
164
482
  export function buildFollowUpsCommentBody({
@@ -166,17 +484,37 @@ export function buildFollowUpsCommentBody({
166
484
  proposals,
167
485
  graduated,
168
486
  storyCount = 1,
487
+ signalCount = 0,
488
+ categories = [],
169
489
  }) {
170
490
  const filed = Array.isArray(graduated?.filed) ? graduated.filed : [];
171
491
  const framework = proposals?.framework ?? [];
172
492
  const consumer = proposals?.consumer ?? [];
173
493
  const discarded = proposals?.discarded ?? [];
494
+ const outcome = assessRollupOutcome({
495
+ signalCount,
496
+ proposalCount: framework.length + consumer.length,
497
+ discardedCount: discarded.length,
498
+ filedCount: filed.length,
499
+ filingErrors: graduated?.errors,
500
+ filingSkipped: graduated?.skipped,
501
+ });
174
502
  const lines = [
175
503
  '### follow-ups',
176
504
  '',
177
505
  `Actionable follow-ups captured from Story #${storyId} after merge.`,
178
506
  '',
179
507
  ];
508
+ if (outcome.unfiledProposals) {
509
+ lines.push(
510
+ ...renderUnfiledProposalLines(
511
+ framework.length + consumer.length,
512
+ Array.isArray(graduated?.errors) ? graduated.errors : [],
513
+ outcome.blockingSkipReasons,
514
+ ),
515
+ '',
516
+ );
517
+ }
180
518
  if (filed.length > 0) {
181
519
  lines.push('**Filed**');
182
520
  for (const item of filed) {
@@ -198,9 +536,9 @@ export function buildFollowUpsCommentBody({
198
536
  lines.push('');
199
537
  }
200
538
  if (discarded.length > 0) {
201
- lines.push('**Single-occurrence (not filed)**');
539
+ lines.push('**Below threshold (not filed)**');
202
540
  for (const item of discarded) {
203
- lines.push(`- ${item.source}: \`${item.category}\` ×${item.occurrences}`);
541
+ lines.push(`- ${item.source}: ${renderDiscardedItem(item)}`);
204
542
  }
205
543
  lines.push('');
206
544
  }
@@ -210,7 +548,14 @@ export function buildFollowUpsCommentBody({
210
548
  consumer.length === 0 &&
211
549
  discarded.length === 0
212
550
  ) {
213
- lines.push(...renderEmptyRollupLines(storyCount));
551
+ // Story #4828 — "no proposals" has two readings, and only one of them is
552
+ // a quiet run. Signals gathered but nothing routed is the third instance
553
+ // of the silence Stories #4578 and #4824 each fixed once.
554
+ lines.push(
555
+ ...(outcome.zeroProposals
556
+ ? renderZeroProposalLines(signalCount, categories)
557
+ : renderEmptyRollupLines(storyCount)),
558
+ );
214
559
  lines.push('');
215
560
  }
216
561
  lines.push('```json');
@@ -219,9 +564,23 @@ export function buildFollowUpsCommentBody({
219
564
  {
220
565
  storyId,
221
566
  storyCount,
567
+ // Story #4828 — the corpus the roll-up actually read. Without it a
568
+ // reader cannot tell "0 proposals because nothing recurred" from
569
+ // "0 proposals because routing broke".
570
+ signalCount,
571
+ categories,
222
572
  framework: framework.map((i) => i.category),
223
573
  consumer: consumer.map((i) => i.category),
224
- discarded: discarded.map((i) => i.category),
574
+ // Story #4824 — the machine-readable twin of the row above. A bare
575
+ // category list could not distinguish a genuine one-off from a
576
+ // recurrence the window was too narrow to see, so the count, the
577
+ // cross-Story span, and the shape fingerprint ride along.
578
+ discarded: discarded.map((i) => ({
579
+ category: i.category,
580
+ occurrences: i.occurrences,
581
+ storyCount: i.storyCount ?? null,
582
+ fingerprint: i.fingerprint ?? null,
583
+ })),
225
584
  filed: filed.map((i) => ({
226
585
  category: i.category,
227
586
  url: i.url ?? null,
@@ -234,7 +593,13 @@ export function buildFollowUpsCommentBody({
234
593
  filed.length === 0 &&
235
594
  framework.length === 0 &&
236
595
  consumer.length === 0 &&
237
- discarded.length === 0,
596
+ discarded.length === 0 &&
597
+ signalCount === 0,
598
+ // Story #4828 — the two remaining shapes that used to render as
599
+ // success. Machine-readable twins of the warning prose above.
600
+ zeroProposalSuspect: outcome.zeroProposals,
601
+ unfiledProposalSuspect: outcome.unfiledProposals,
602
+ filingErrors: Array.isArray(graduated?.errors) ? graduated.errors : [],
238
603
  },
239
604
  null,
240
605
  2,
@@ -308,6 +673,8 @@ export async function captureStoryFollowUps({
308
673
  storyId: sid,
309
674
  proposals,
310
675
  graduated,
676
+ signalCount: signals.length,
677
+ categories: summarizeSignalCategories(signals),
311
678
  });
312
679
  await upsertStructuredComment(provider, sid, FOLLOW_UPS_COMMENT_TYPE, body);
313
680
  progress?.(