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.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -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/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- 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 +13 -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 +119 -52
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- 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 +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- 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/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- 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
|
-
*
|
|
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 {{
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
-
|
|
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('**
|
|
467
|
+
lines.push('**Below threshold (not filed)**');
|
|
202
468
|
for (const item of discarded) {
|
|
203
|
-
lines.push(`- ${item.source}:
|
|
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
|
-
|
|
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
|
-
|
|
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?.(
|