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.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +5 -0
- package/.agents/docs/configuration.md +1 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/diagnose-friction.js +95 -4
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +51 -7
- package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +41 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +69 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +380 -13
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +5 -7
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
- package/.agents/workflows/helpers/deliver-story.md +2 -2
- package/.agents/workflows/helpers/plan-reference.md +45 -4
- package/.agents/workflows/plan.md +21 -16
- package/docs/CHANGELOG.md +34 -0
- 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 {
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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('**
|
|
539
|
+
lines.push('**Below threshold (not filed)**');
|
|
202
540
|
for (const item of discarded) {
|
|
203
|
-
lines.push(`- ${item.source}:
|
|
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
|
-
|
|
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
|
-
|
|
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?.(
|