mandrel 2.21.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +5 -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/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +51 -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 +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 +51 -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/story-close/phases/code-review.js +5 -1
- 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/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 +5 -4
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -21,9 +21,11 @@ import { gitSpawn } from '../git-utils.js';
|
|
|
21
21
|
import { Logger } from '../Logger.js';
|
|
22
22
|
import { composeRoutedProposals } from './retro-proposals.js';
|
|
23
23
|
import {
|
|
24
|
+
assessRollupOutcome,
|
|
24
25
|
buildFollowUpsCommentBody,
|
|
25
26
|
gatherRunFrictionSignals,
|
|
26
27
|
resolveFollowUpRepos,
|
|
28
|
+
summarizeSignalCategories,
|
|
27
29
|
} from './story-follow-ups.js';
|
|
28
30
|
import { upsertStructuredComment } from './ticketing.js';
|
|
29
31
|
|
|
@@ -550,6 +552,7 @@ async function executeFollowUpRollup({
|
|
|
550
552
|
provider,
|
|
551
553
|
config,
|
|
552
554
|
cwd,
|
|
555
|
+
graduateFn = graduateRetroProposals,
|
|
553
556
|
}) {
|
|
554
557
|
// Shared with the story-scoped gather (Story #4649): `storyId` + `details`
|
|
555
558
|
// are what the composer's recovery-netting keys on, and two hand-rolled
|
|
@@ -570,7 +573,7 @@ async function executeFollowUpRollup({
|
|
|
570
573
|
item.title = item.title.replace(/plan-run \d+/, `plan-run ${planRunId}`);
|
|
571
574
|
item.body = item.body.replace(/plan-run \d+/g, `plan-run ${planRunId}`);
|
|
572
575
|
}
|
|
573
|
-
const graduated = await
|
|
576
|
+
const graduated = await graduateFn({
|
|
574
577
|
epicId: primaryId,
|
|
575
578
|
provider,
|
|
576
579
|
config,
|
|
@@ -582,6 +585,16 @@ async function executeFollowUpRollup({
|
|
|
582
585
|
routedProposals: proposals,
|
|
583
586
|
cwd,
|
|
584
587
|
});
|
|
588
|
+
const categories = summarizeSignalCategories(signals);
|
|
589
|
+
const proposalCount = proposals.framework.length + proposals.consumer.length;
|
|
590
|
+
const outcome = assessRollupOutcome({
|
|
591
|
+
signalCount: signals.length,
|
|
592
|
+
proposalCount,
|
|
593
|
+
discardedCount: proposals.discarded.length,
|
|
594
|
+
filedCount: graduated.filed?.length ?? 0,
|
|
595
|
+
filingErrors: graduated.errors,
|
|
596
|
+
filingSkipped: graduated.skipped,
|
|
597
|
+
});
|
|
585
598
|
if (Number.isInteger(primaryId) && primaryId > 0) {
|
|
586
599
|
const body = buildFollowUpsCommentBody({
|
|
587
600
|
storyId: primaryId,
|
|
@@ -591,6 +604,10 @@ async function executeFollowUpRollup({
|
|
|
591
604
|
// render as a flagged claim ("0 signals across N Stories") rather than
|
|
592
605
|
// as "nothing to follow up".
|
|
593
606
|
storyCount: stories.length,
|
|
607
|
+
// Story #4828 — and the corpus is what lets a zero-proposal or
|
|
608
|
+
// zero-filed roll-up name what it saw instead of rendering as clean.
|
|
609
|
+
signalCount: signals.length,
|
|
610
|
+
categories,
|
|
594
611
|
}).replace(
|
|
595
612
|
`from Story #${primaryId}`,
|
|
596
613
|
`from plan-run \`${planRunId}\` (primary Story #${primaryId})`,
|
|
@@ -602,6 +619,34 @@ async function executeFollowUpRollup({
|
|
|
602
619
|
signalCount: signals.length,
|
|
603
620
|
storyCount: stories.length,
|
|
604
621
|
filed: graduated.filed?.length ?? 0,
|
|
622
|
+
// Story #4828 — everything below is what the roll-up saw and what became
|
|
623
|
+
// of it. The pre-#4828 result reported `signalCount` and `filed` and
|
|
624
|
+
// nothing in between, so nine signals routing into one proposal whose
|
|
625
|
+
// every filing attempt errored rendered as `{signalCount: 9, filed: 0,
|
|
626
|
+
// discarded: []}` — arithmetically consistent, and indistinguishable from
|
|
627
|
+
// a run with nothing to do.
|
|
628
|
+
proposalCount,
|
|
629
|
+
// The categories the corpus actually contained, so a zero-proposal
|
|
630
|
+
// roll-up names its own input rather than asserting emptiness.
|
|
631
|
+
categories,
|
|
632
|
+
filingErrors: Array.isArray(graduated.errors) ? graduated.errors : [],
|
|
633
|
+
filingSkipped: outcome.blockingSkipReasons,
|
|
634
|
+
// Signals in, nothing out — not even a below-threshold row.
|
|
635
|
+
zeroProposalSuspect: outcome.zeroProposals,
|
|
636
|
+
// Proposals cleared the threshold and the filer produced none of them.
|
|
637
|
+
unfiledProposalSuspect: outcome.unfiledProposals,
|
|
638
|
+
// Story #4824 — a roll-up that discards every candidate must still name
|
|
639
|
+
// what it discarded. Rendering that as "nothing to follow up" is how a
|
|
640
|
+
// defect recurring once per Story survived eighteen consecutive Stories.
|
|
641
|
+
// Surfaced on the step result so the CLI need not regex the comment body.
|
|
642
|
+
discarded: proposals.discarded.map((item) => ({
|
|
643
|
+
category: item.category,
|
|
644
|
+
occurrences: item.occurrences,
|
|
645
|
+
source: item.source,
|
|
646
|
+
storyCount: item.storyCount ?? null,
|
|
647
|
+
tools: item.tools ?? [],
|
|
648
|
+
fingerprint: item.fingerprint ?? null,
|
|
649
|
+
})),
|
|
605
650
|
// Story #4578 — zero signals across a multi-Story run is a claim, not a
|
|
606
651
|
// clean bill of health. Surfaced on the step result so the CLI can warn
|
|
607
652
|
// the operator without re-deriving it from the comment prose.
|
|
@@ -698,6 +743,9 @@ async function executeSiblingCoherence({ planRunId, stories, provider }) {
|
|
|
698
743
|
* @param {string} [args.cwd]
|
|
699
744
|
* @param {{ gitSpawn: Function }} [args.git] - Injection seam for tests.
|
|
700
745
|
* @param {typeof selectAudits} [args.selectAuditsFn] - Injection seam for tests.
|
|
746
|
+
* @param {typeof graduateRetroProposals} [args.graduateFn] - Injection seam so
|
|
747
|
+
* the roll-up's reporting layer can be asserted against a filer that fails
|
|
748
|
+
* (Story #4828) without spawning a real `gh`.
|
|
701
749
|
* @returns {Promise<object>}
|
|
702
750
|
*/
|
|
703
751
|
export async function runPlanRunEpilogue({
|
|
@@ -708,6 +756,7 @@ export async function runPlanRunEpilogue({
|
|
|
708
756
|
cwd = process.cwd(),
|
|
709
757
|
git = { gitSpawn },
|
|
710
758
|
selectAuditsFn = selectAudits,
|
|
759
|
+
graduateFn = graduateRetroProposals,
|
|
711
760
|
} = {}) {
|
|
712
761
|
const plan = planRunEpilogue({ planRunId, stories });
|
|
713
762
|
if (!plan.applicable) {
|
|
@@ -741,6 +790,7 @@ export async function runPlanRunEpilogue({
|
|
|
741
790
|
provider,
|
|
742
791
|
config,
|
|
743
792
|
cwd,
|
|
793
|
+
graduateFn,
|
|
744
794
|
}),
|
|
745
795
|
);
|
|
746
796
|
} else if (step.kind === 'sibling-coherence') {
|
|
@@ -28,8 +28,13 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
31
|
+
import { degradationEnvelope } from '../../review-providers/degraded-gates.js';
|
|
31
32
|
import { runStoryReviewCore } from '../../story-close/phases/review-core.js';
|
|
32
33
|
import { postStructuredComment } from '../../ticketing/state.js';
|
|
34
|
+
import {
|
|
35
|
+
buildOutcomeTally,
|
|
36
|
+
formatReviewOutcomeLines,
|
|
37
|
+
} from './review-outcome.js';
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
40
|
* Extract the numeric PR ID from a `gh pr create` URL. The CLI returns a
|
|
@@ -62,13 +67,11 @@ export function buildStoryReviewCrossRefBody({
|
|
|
62
67
|
prNumber,
|
|
63
68
|
commentUrl,
|
|
64
69
|
severity,
|
|
70
|
+
degradations,
|
|
65
71
|
}) {
|
|
66
|
-
const tally =
|
|
67
|
-
`critical:${severity.critical} · high:${severity.high} · ` +
|
|
68
|
-
`medium:${severity.medium} · suggestion:${severity.suggestion}`;
|
|
69
72
|
return (
|
|
70
73
|
`🔬 Story-scope code review posted on PR [#${prNumber}](${prUrl}): ` +
|
|
71
|
-
`[view findings](${commentUrl}) — ${
|
|
74
|
+
`[view findings](${commentUrl}) — ${buildOutcomeTally({ severity, degradations })}.`
|
|
72
75
|
);
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -116,6 +119,7 @@ async function postStoryReviewCrossRef({
|
|
|
116
119
|
prNumber,
|
|
117
120
|
commentUrl,
|
|
118
121
|
severity,
|
|
122
|
+
degradations: result.degradations,
|
|
119
123
|
});
|
|
120
124
|
try {
|
|
121
125
|
await postStructuredComment(provider, storyId, 'notification', body);
|
|
@@ -174,6 +178,8 @@ async function postStoryReviewCrossRef({
|
|
|
174
178
|
* severity?: { critical: number, high: number, medium: number, suggestion: number },
|
|
175
179
|
* posted?: boolean,
|
|
176
180
|
* postedCommentId?: number|null,
|
|
181
|
+
* degraded?: boolean,
|
|
182
|
+
* degradations?: Array<object>,
|
|
177
183
|
* crossRefPosted?: boolean,
|
|
178
184
|
* localLensReview?: object,
|
|
179
185
|
* }>}
|
|
@@ -222,10 +228,13 @@ export async function runStoryScopeReview({
|
|
|
222
228
|
medium: 0,
|
|
223
229
|
suggestion: 0,
|
|
224
230
|
};
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
231
|
+
const outcome = formatReviewOutcomeLines({
|
|
232
|
+
severity: sev,
|
|
233
|
+
degradations: result.degradations,
|
|
234
|
+
prNumber,
|
|
235
|
+
posted: result.posted,
|
|
236
|
+
});
|
|
237
|
+
for (const line of outcome) progress('REVIEW', line);
|
|
229
238
|
|
|
230
239
|
const crossRefPosted = await postStoryReviewCrossRef({
|
|
231
240
|
provider,
|
|
@@ -242,6 +251,7 @@ export async function runStoryScopeReview({
|
|
|
242
251
|
severity: sev,
|
|
243
252
|
posted: result.posted,
|
|
244
253
|
postedCommentId: result.postedCommentId ?? null,
|
|
254
|
+
...degradationEnvelope(result.degradations),
|
|
245
255
|
crossRefPosted,
|
|
246
256
|
localLensReview: result.localLensReview,
|
|
247
257
|
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* phases/review-outcome.js — operator-facing rendering of the Story-scope
|
|
3
|
+
* review outcome (Story #4839).
|
|
4
|
+
*
|
|
5
|
+
* The review phase used to report a severity tally and nothing else, which made
|
|
6
|
+
* "every gate ran and found nothing" and "a gate never ran" render identically.
|
|
7
|
+
* Both surfaces the operator actually reads — the close progress stream and the
|
|
8
|
+
* cross-reference comment on the Story — now state the degraded gates
|
|
9
|
+
* explicitly, and always state them (as `none` when healthy) so an absent line
|
|
10
|
+
* can never be mistaken for a clean gate.
|
|
11
|
+
*
|
|
12
|
+
* A degraded gate is **reported, not blocking**: the canonical `npm run lint`
|
|
13
|
+
* close-validation gate has already covered this diff before the review phase
|
|
14
|
+
* runs, so failing the merge on a secondary read of an already-gated surface
|
|
15
|
+
* would cost delivery without buying coverage. The rationale for that posture
|
|
16
|
+
* lives with the channel itself in
|
|
17
|
+
* [`review-providers/degraded-gates.js`](../../review-providers/degraded-gates.js).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { summarizeDegradations } from '../../review-providers/degraded-gates.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pure: the tally suffix shared by the progress line and the cross-reference
|
|
24
|
+
* comment — severity counts plus the degraded-gate state.
|
|
25
|
+
*
|
|
26
|
+
* @param {{ severity: { critical: number, high: number, medium: number, suggestion: number }, degradations?: unknown }} args
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function buildOutcomeTally({ severity, degradations }) {
|
|
30
|
+
return (
|
|
31
|
+
`critical:${severity.critical} · high:${severity.high} · ` +
|
|
32
|
+
`medium:${severity.medium} · suggestion:${severity.suggestion} · ` +
|
|
33
|
+
`degraded gates: ${summarizeDegradations(degradations)}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure: the progress lines announcing a completed review. Always one line
|
|
39
|
+
* naming the tally; a second, explicitly-worded line when a gate did not run so
|
|
40
|
+
* the degradation cannot be skimmed past.
|
|
41
|
+
*
|
|
42
|
+
* @param {{
|
|
43
|
+
* severity: { critical: number, high: number, medium: number, suggestion: number },
|
|
44
|
+
* degradations?: unknown,
|
|
45
|
+
* prNumber: number,
|
|
46
|
+
* posted: boolean,
|
|
47
|
+
* }} args
|
|
48
|
+
* @returns {string[]}
|
|
49
|
+
*/
|
|
50
|
+
export function formatReviewOutcomeLines({
|
|
51
|
+
severity,
|
|
52
|
+
degradations,
|
|
53
|
+
prNumber,
|
|
54
|
+
posted,
|
|
55
|
+
}) {
|
|
56
|
+
const tally = buildOutcomeTally({ severity, degradations });
|
|
57
|
+
const lines = [`Findings — ${tally}. Posted to PR #${prNumber}: ${posted}.`];
|
|
58
|
+
if (summarizeDegradations(degradations) !== 'none') {
|
|
59
|
+
lines.push(
|
|
60
|
+
'⚠️ Review ran DEGRADED — the surface(s) above were not reviewed. The close ' +
|
|
61
|
+
'is not blocked (the canonical `npm run lint` close gate already covered ' +
|
|
62
|
+
'this diff), but this review does not vouch for them.',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import { Logger } from '../../../Logger.js';
|
|
40
40
|
import { runCodeReview } from '../../code-review.js';
|
|
41
|
+
import { summarizeDegradations } from '../../review-providers/degraded-gates.js';
|
|
41
42
|
import { emitBlockedCloseResult } from '../emit-blocked.js';
|
|
42
43
|
import { runLocalLensReview } from './local-lens-review.js';
|
|
43
44
|
import { runStoryReviewCore } from './review-core.js';
|
|
@@ -86,7 +87,10 @@ function buildCodeReviewBlockedExtra({ storyId, reviewResult }) {
|
|
|
86
87
|
function formatReviewSummary(reviewResult) {
|
|
87
88
|
const { high, medium, suggestion } = resolveSeverity(reviewResult);
|
|
88
89
|
const posted = reviewResult?.posted ?? false;
|
|
89
|
-
|
|
90
|
+
// Story #4839 — `degraded` is always stated: an absent degradation line must
|
|
91
|
+
// never be the way an operator concludes that every gate ran.
|
|
92
|
+
const degraded = summarizeDegradations(reviewResult?.degradations);
|
|
93
|
+
return `Review complete — high=${high} medium=${medium} suggestion=${suggestion} degraded=${degraded} (posted=${posted}).`;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
/**
|
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
* @module lib/orchestration/story-follow-ups
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { signalsFile } from '../config/temp-paths.js';
|
|
12
13
|
import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
|
|
13
14
|
import { DEFAULT_FRAMEWORK_REPO } from '../github/framework-repo.js';
|
|
14
15
|
import { Logger } from '../Logger.js';
|
|
15
16
|
import { normalizeGatheredSignal } from '../observability/runtime-friction.js';
|
|
16
|
-
import {
|
|
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?.(
|