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.
Files changed (38) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +5 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  8. package/.agents/scripts/lib/config/temp-paths.js +51 -7
  9. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  10. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  11. package/.agents/scripts/lib/label-constants.js +12 -1
  12. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  13. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  14. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  15. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
  17. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  18. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  19. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  20. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  21. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  22. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  24. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  25. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  26. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  27. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  28. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  30. package/.agents/scripts/resolve-stories.js +52 -33
  31. package/.agents/scripts/single-story-confirm-merge.js +5 -7
  32. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  33. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  34. package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
  35. package/.agents/workflows/helpers/deliver-story.md +2 -2
  36. package/.agents/workflows/helpers/plan-reference.md +5 -4
  37. package/docs/CHANGELOG.md +20 -0
  38. package/package.json +1 -1
@@ -31,15 +31,16 @@
31
31
  * cardinality is deliberately not an axis (Story #4764).
32
32
  * 4. **Deliver re-derives.** `/deliver` computes the route from the fetched
33
33
  * Story body via the **same** shape function at dispatch
34
- * ({@link resolveStoryDispatchMode}) and honors it: a lite-shaped Story
35
- * executes inline no story-worker sub-agent boot, no fresh
36
- * acceptance-critic dispatch while every `single-story-close.js` gate
37
- * runs unchanged. The `route::lite` label is a **human-visible hint
38
- * only**, never the control signal: a lost label or an unread marker can
39
- * no longer misroute delivery. Ahead of the shape read sits one
40
- * shape-independent rule (Story #4736): a **single-Story run** is inline
41
- * whatever its shape, because sub-agent isolation buys nothing when
42
- * there is no concurrent sibling to isolate from.
34
+ * ({@link resolveStoryDispatchMode}) and **reports** it, while the
35
+ * dispatch *mode* answers a different question: may the engine run in the
36
+ * router's own session? Only a **single-Story run** may (Story #4736) —
37
+ * sub-agent isolation buys nothing when there is no concurrent sibling to
38
+ * isolate from. Shape cannot grant that session (Story #4829): a lite body
39
+ * makes work cheap, it does not conjure a second session for a sibling to
40
+ * run in. The `route::lite` label is a **human-visible hint only**, never
41
+ * the control signal: a lost label or an unread marker can no longer
42
+ * misroute delivery. Either way every `single-story-close.js` gate runs
43
+ * unchanged.
43
44
  *
44
45
  * The shape taxonomy is deliberately the one `review-depth.js` already
45
46
  * applies to the landed diff at close (`deriveChangeLevel` over the
@@ -864,27 +865,34 @@ function routeForReporting(body, opts) {
864
865
  /**
865
866
  * Decide how `/deliver` executes a Story.
866
867
  *
867
- * Two independent premises, checked in this order:
868
- *
869
- * 1. **Run topology (Story #4736).** A run delivering a *single* Story
870
- * executes **inline**, whatever its shape. Sub-agent isolation is
871
- * load-bearing only for CONCURRENT dispatch two workers sharing a
872
- * checkout would race on worktrees and branch refs and a one-Story run
873
- * has no sibling to race. It therefore pays the spawn premium (a boot is
874
- * a cache WRITE at full rate, where an inline continuation is a cache read
875
- * at ~10%; ~$1.43/M vs ~$1.07/M on comparable bench work) for nothing.
876
- * This is a fact about the run, not about the work, so the shape gate's
877
- * `enabled` switch — which governs *shape derivation* — does not reach it.
878
- * 2. **Shape (Story #4722 AC-4/AC-5).** For a multi-Story run, the decision
879
- * comes **from the Story body's own shape**, never from the `route::lite`
880
- * label: a lite-shaped Story executes inline; everything else a
881
- * full-shaped body, a missing/unparseable body, or the gate disabled via
882
- * `planning.complexityGate.enabled=false` dispatches as a sub-agent,
883
- * the conservative default.
884
- *
885
- * The label is read only to report hint consistency in `reasons`: with the
886
- * label absent (or its write failed) a lite-shaped Story still runs inline,
887
- * and with the label present on a full-shaped Story the shape wins.
868
+ * **`inline` names one indivisible resource: the router's own session.** Two
869
+ * Stories cannot both own it, so exactly one premise can grant it —
870
+ * **run topology (Story #4736)**: a run resolving a *single* Story executes
871
+ * inline whatever its shape, because sub-agent isolation is load-bearing only
872
+ * for CONCURRENT dispatch (two workers sharing a checkout race on worktrees and
873
+ * branch refs) and a one-Story run has no sibling to race. It therefore pays
874
+ * the spawn premium (a boot is a cache WRITE at full rate, where an inline
875
+ * continuation is a cache read at ~10%; ~$1.43/M vs ~$1.07/M on comparable
876
+ * bench work) for nothing. That is a fact about the run, not about the work, so
877
+ * the shape gate's `enabled` switch which governs *shape derivation* does
878
+ * not reach it.
879
+ *
880
+ * **Shape cannot grant it (Story #4829).** The shape read used to return
881
+ * `inline` for any lite-shaped body in a multi-Story run, inheriting no
882
+ * topology guard. Measured twice on 2026-07-29: a two-Story and a three-Story
883
+ * run came back `inline` for *every* Story while `stories-wave-tick.js`
884
+ * reported the whole set ready under a concurrency cap of five — a router
885
+ * following both signals literally runs several engines over one session and
886
+ * one checkout, the precise hazard the sub-agent path exists to prevent. Both
887
+ * runs were completed only by an operator overriding the verdict by hand, which
888
+ * is an invariant held by judgment rather than by code. So this function has
889
+ * exactly **one** `inline` exit, guarded by the topology premise; every path
890
+ * below it returns `subagent`, and the derived shape is carried on `route` for
891
+ * reporting only. A lite shape makes the work cheap — it does not conjure a
892
+ * second session for a sibling to run in.
893
+ *
894
+ * The label is read only to report hint consistency in `reasons`; it never
895
+ * routes on either premise (Story #4722 AC-4/AC-5).
888
896
  *
889
897
  * Inline execution removes model-side fan-out only — it changes **where** the
890
898
  * engine runs, never **what** runs. Every deterministic
@@ -901,7 +909,8 @@ function routeForReporting(body, opts) {
901
909
  * selectSensitivePathClassesFn?: Function,
902
910
  * }} [args] `storyCount` is the number of Stories the invoking `/deliver` run
903
911
  * resolved. Omitted (or not a positive integer) means "unknown run size",
904
- * which falls through to the shape decision never to an assumed 1.
912
+ * which cannot be shown sibling-free and therefore dispatches as a sub-agent
913
+ * — never an assumed 1.
905
914
  * @returns {{ mode: 'inline'|'subagent', reasons: string[], route: ReturnType<typeof deriveStoryShape>|null }}
906
915
  */
907
916
  export function resolveStoryDispatchMode({
@@ -920,6 +929,8 @@ export function resolveStoryDispatchMode({
920
929
  ? `the ${LITE_ROUTE_LABEL} label is present (hint only — the derived shape is the control signal)`
921
930
  : `the ${LITE_ROUTE_LABEL} label is absent (hint only — the derived shape is the control signal)`;
922
931
 
932
+ // The ONLY `inline` exit in this function, and the guard is the whole
933
+ // contract: an inline verdict must mean the engine can actually run inline.
923
934
  if (storyCount === 1) {
924
935
  return {
925
936
  mode: 'inline',
@@ -956,23 +967,17 @@ export function resolveStoryDispatchMode({
956
967
  };
957
968
  }
958
969
 
970
+ // Past the topology guard a sibling may be dispatched concurrently, so the
971
+ // router's session is not available to anyone. The shape is still derived and
972
+ // returned on `route` — ceremony and reporting read it — but it decides
973
+ // nothing here: both shapes dispatch as a sub-agent.
959
974
  const route = deriveStoryRouteFromBody(body, {
960
975
  injectedRules,
961
976
  selectSensitivePathClassesFn,
962
977
  });
963
- if (route.route === 'lite') {
964
- return {
965
- mode: 'inline',
966
- reasons: [
967
- `lite-shaped Story execute deliver-story inline; no story-worker or acceptance-critic sub-agent dispatch (close gates unchanged): ${route.reasons[0]}`,
968
- hintNote,
969
- ],
970
- route,
971
- };
972
- }
973
- return {
974
- mode: 'subagent',
975
- reasons: [`full-shaped Story — ${route.reasons[0]}`, hintNote],
976
- route,
977
- };
978
+ const shapeNote =
979
+ route.route === 'lite'
980
+ ? `lite-shaped Story in a multi-Story run — the shape is inline-eligible but the router's session is not: a concurrent sibling would have to share it, racing worktrees and branch refs; sub-agent dispatch (${route.reasons[0]})`
981
+ : `full-shaped Story — ${route.reasons[0]}`;
982
+ return { mode: 'subagent', reasons: [shapeNote, hintNote], route };
978
983
  }
@@ -317,21 +317,24 @@ export function buildStoriesEnvelope({
317
317
  const inSetDone = sorted.filter(isSatisfiedBlocker).map((s) => s.id);
318
318
  return {
319
319
  kind: 'stories',
320
- // `dispatchMode` (Story #4722): the resolver derives the per-Story
321
- // execution mode from the fetched Story BODY's own shape (the shared
322
- // shape function in `complexity-gate.js`) so `/deliver` reads one field
323
- // `inline` (lite-shaped: no story-worker / acceptance-critic sub-agent
324
- // boots) or `subagent` (everything else, the conservative default). The
325
- // `route::lite` label is a human-visible hint only, never the control
326
- // signal: a lost label cannot misroute delivery. Model-side fan-out
327
- // only; close gates are untouched.
320
+ // `dispatchMode` (Story #4722, #4736, #4829): the resolver reports the
321
+ // per-Story execution mode so `/deliver` reads one field `inline` (run
322
+ // deliver-story in the router's own session: no story-worker /
323
+ // acceptance-critic sub-agent boots) or `subagent` (the conservative
324
+ // default). Model-side fan-out only; close gates are untouched.
328
325
  //
329
- // `storyCount` (Story #4736) carries the run's topology into that same
330
- // decision: a run resolving exactly ONE Story is inline whatever its
331
- // shape, because the isolation a sub-agent buys only matters against a
332
- // concurrently-dispatched sibling. It is the resolved set size not the
333
- // undelivered remainder so the mode a caller reads for a given `--ids`
334
- // list never changes as siblings land mid-run.
326
+ // `storyCount` is the premise that decides it, and it is this call site's
327
+ // load-bearing argument: `inline` names the router's ONE session, so it is
328
+ // granted only to a run resolving exactly ONE Story, which has no
329
+ // concurrent sibling to share that session with. Passing the resolved set
330
+ // size here is therefore what makes the envelope self-consistent with the
331
+ // ready set `stories-wave-tick.js` computes from the same `dag`: a set of
332
+ // more than one can never come back with a Story claiming the session
333
+ // (Story #4829 — it previously could, whenever the body was lite-shaped).
334
+ // It is the resolved set size, NOT the undelivered remainder, so the mode
335
+ // a caller reads for a given `--ids` list never changes as siblings land
336
+ // mid-run. The `route::lite` label is a human-visible hint only, never the
337
+ // control signal.
335
338
  stories: sorted.map(({ id, title, body, url, labels, state }) => ({
336
339
  id,
337
340
  title,
@@ -0,0 +1,222 @@
1
+ /**
2
+ * review-providers/degraded-gates.js — the review's "a gate did not run"
3
+ * channel (Story #4839).
4
+ *
5
+ * ## The defect this closes
6
+ *
7
+ * Story #4699 correctly decided that a tool which **could not execute** is an
8
+ * operational degradation, not a code finding: it is routed to friction
9
+ * telemetry so severity tiers keep reflecting code findings only. What #4699
10
+ * did not add was any *other* channel, so the review's own verdict became
11
+ * unable to distinguish "lint ran and found nothing" from "lint never ran" —
12
+ * both rendered `✅ No findings` with an all-zero severity tally, and the close
13
+ * pipeline read the second as the first. A gate that reports success when it
14
+ * did not run is worse than no gate, because it is trusted.
15
+ *
16
+ * This module is that missing channel. A degradation travels **beside** the
17
+ * `Finding[]`, never inside it:
18
+ *
19
+ * - it never becomes a `Finding`, so `countBySeverity` is untouched and no
20
+ * execution failure can appear as a critical / high / medium (or even a
21
+ * suggestion) — #4699's intent survives intact;
22
+ * - it is rendered as its own section in the structured comment, and it
23
+ * suppresses the false `✅ No findings` claim;
24
+ * - it is carried on the `runCodeReview` envelope as `degraded` /
25
+ * `degradations[]`, so the close pipeline sees it too.
26
+ *
27
+ * ## Report, not block — and why
28
+ *
29
+ * A degraded gate is reported loudly and does **not** halt the close. The close
30
+ * pipeline already runs the canonical `npm run lint` as a hard
31
+ * close-validation gate *before* the review phase; the review's scoped lint is
32
+ * a second, narrower read of the same surface. Failing a merge because a
33
+ * *secondary* read of an already-gated surface could not start would block
34
+ * delivery on an operational condition the hard gate has already covered. What
35
+ * was actually broken was the silence, so the fix is to make the silence
36
+ * impossible: every surface that reads the review outcome now states the
37
+ * degradation explicitly. Escalating to a block is a one-line change here if
38
+ * the operator posture ever needs it.
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} GateDegradation
43
+ * @property {string} tool Emitter (e.g. `native-review-lint`).
44
+ * @property {string} gate Gate that degraded (e.g. `scoped-lint`).
45
+ * @property {string} surface Sub-surface that could not run (e.g. `markdownlint`).
46
+ * @property {string} reason Machine-readable reason code.
47
+ */
48
+
49
+ /**
50
+ * Pure: keep only well-formed degradation records. A misbehaving provider must
51
+ * not be able to corrupt the rendered comment or the envelope.
52
+ *
53
+ * @param {unknown} input
54
+ * @returns {GateDegradation[]}
55
+ */
56
+ export function normalizeDegradations(input) {
57
+ if (!Array.isArray(input)) return [];
58
+ const out = [];
59
+ for (const d of input) {
60
+ if (!d || typeof d !== 'object') continue;
61
+ const surface = typeof d.surface === 'string' ? d.surface : null;
62
+ const reason = typeof d.reason === 'string' ? d.reason : null;
63
+ if (surface === null || reason === null) continue;
64
+ out.push({
65
+ tool: typeof d.tool === 'string' ? d.tool : 'unknown',
66
+ gate: typeof d.gate === 'string' ? d.gate : 'unknown',
67
+ surface,
68
+ reason,
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Feature-detect a provider's degradation channel, mirroring how
76
+ * `getPromptMessages` is feature-detected. Providers predating this contract
77
+ * carry no `getDegradations`, so the empty array keeps their output byte-stable;
78
+ * a throw degrades to empty (observability must never fail a review).
79
+ *
80
+ * MUST be called **after** `runReview` — a provider records its degradations
81
+ * during the run.
82
+ *
83
+ * @param {{ getDegradations?: Function }} reviewProvider
84
+ * @param {{ warn?: Function }} [logger]
85
+ * @returns {Promise<GateDegradation[]>}
86
+ */
87
+ export async function collectProviderDegradations(reviewProvider, logger) {
88
+ if (typeof reviewProvider?.getDegradations !== 'function') return [];
89
+ try {
90
+ return normalizeDegradations(await reviewProvider.getDegradations());
91
+ } catch (err) {
92
+ logger?.warn?.(
93
+ `[code-review] getDegradations threw; treating as none. ${
94
+ err?.message ?? err
95
+ }`,
96
+ );
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Pure: one-line operator-facing summary of the degraded gates, for progress
103
+ * output and comment tallies. Empty input renders `none` so the field is always
104
+ * present — a missing degradation line must never be read as "no degradation".
105
+ *
106
+ * @param {ReadonlyArray<GateDegradation>} degradations
107
+ * @returns {string}
108
+ */
109
+ export function summarizeDegradations(degradations) {
110
+ const rows = normalizeDegradations(degradations);
111
+ if (rows.length === 0) return 'none';
112
+ return rows.map((d) => `${d.gate}/${d.surface} (${d.reason})`).join(', ');
113
+ }
114
+
115
+ /**
116
+ * Pure: the `{ degraded, degradations }` pair every outcome envelope carries, so
117
+ * a caller adds the channel by spreading one helper rather than restating the
118
+ * derivation (and cannot ship `degradations` without `degraded`).
119
+ *
120
+ * @param {unknown} degradations
121
+ * @returns {{ degraded: boolean, degradations: GateDegradation[] }}
122
+ */
123
+ export function degradationEnvelope(degradations) {
124
+ const rows = normalizeDegradations(degradations);
125
+ return { degraded: rows.length > 0, degradations: rows };
126
+ }
127
+
128
+ /**
129
+ * Merge the degraded-gate records of every inline chain entry that carries the
130
+ * channel. A provider predating the contract contributes nothing; a throw is
131
+ * logged and skipped, because a chain must never lose a "this gate did not run"
132
+ * signal *or* fail a review over reporting one.
133
+ *
134
+ * @param {ReadonlyArray<{ name: string, provider: { getDegradations?: Function } }>} entries
135
+ * @param {{ warn?: Function }} [logger]
136
+ * @returns {Promise<GateDegradation[]>}
137
+ */
138
+ export async function mergeChainDegradations(entries, logger) {
139
+ const merged = [];
140
+ for (const entry of entries) {
141
+ if (typeof entry.provider?.getDegradations !== 'function') continue;
142
+ try {
143
+ merged.push(
144
+ ...normalizeDegradations(await entry.provider.getDegradations()),
145
+ );
146
+ } catch (err) {
147
+ logger?.warn?.(
148
+ `[code-review] Inline provider "${entry.name}" getDegradations threw; skipping. ${
149
+ err?.message ?? err
150
+ }`,
151
+ );
152
+ }
153
+ }
154
+ return merged;
155
+ }
156
+
157
+ /**
158
+ * Pure: the header field naming how many gates did not run. Empty when the
159
+ * review was healthy, so a healthy body stays byte-identical to pre-#4839.
160
+ *
161
+ * @param {ReadonlyArray<GateDegradation>} degraded Already normalized.
162
+ * @returns {string[]}
163
+ */
164
+ export function renderDegradedHeaderLines(degraded) {
165
+ if (degraded.length === 0) return [];
166
+ return [`**Degraded gates**: ${degraded.length} (did not run)`];
167
+ }
168
+
169
+ /**
170
+ * Pure: the "nothing surfaced" block. This is the exact sentence the defect
171
+ * turned into a lie — with a degraded gate present, an all-zero tally is not a
172
+ * clean verdict and must not read like one.
173
+ *
174
+ * @param {ReadonlyArray<GateDegradation>} degraded Already normalized.
175
+ * @returns {string[]}
176
+ */
177
+ export function renderNoFindingsBlock(degraded) {
178
+ if (degraded.length === 0) {
179
+ return [
180
+ '### ✅ No findings',
181
+ '',
182
+ 'No issues surfaced by the review provider.',
183
+ ];
184
+ }
185
+ return [
186
+ `### ⚠️ No findings — ${degraded.length} gate(s) did not run`,
187
+ '',
188
+ 'The gates that ran surfaced no issues. This review does **not** vouch ' +
189
+ 'for the degraded surface(s) listed above.',
190
+ ];
191
+ }
192
+
193
+ /**
194
+ * Pure: render the "Degraded Gates" section of the structured comment. Returns
195
+ * an empty array when nothing degraded, so a healthy review's body stays
196
+ * byte-identical to the pre-#4839 output.
197
+ *
198
+ * @param {ReadonlyArray<GateDegradation>} degradations
199
+ * @returns {string[]} markdown lines
200
+ */
201
+ export function renderDegradedGatesSection(degradations) {
202
+ const rows = normalizeDegradations(degradations);
203
+ if (rows.length === 0) return [];
204
+ const lines = [
205
+ `### ⚠️ Degraded Gates (${rows.length})`,
206
+ '',
207
+ 'The following review gate(s) **did not run**. Their surface is',
208
+ 'unreviewed — an all-zero finding tally below does not vouch for it.',
209
+ '',
210
+ ];
211
+ for (const d of rows) {
212
+ lines.push(
213
+ `- \`${d.gate}\` → \`${d.surface}\` could not execute — ${d.reason} (emitter: \`${d.tool}\`).`,
214
+ );
215
+ }
216
+ lines.push('');
217
+ lines.push(
218
+ 'Verify with the canonical `npm run lint` before trusting this review.',
219
+ );
220
+ lines.push('');
221
+ return lines;
222
+ }
@@ -15,6 +15,13 @@
15
15
  * @typedef {import('./types.js').Severity} Severity
16
16
  */
17
17
 
18
+ import {
19
+ normalizeDegradations,
20
+ renderDegradedGatesSection,
21
+ renderDegradedHeaderLines,
22
+ renderNoFindingsBlock,
23
+ } from './degraded-gates.js';
24
+
18
25
  /**
19
26
  * Canonical severity ordering. The render output always lists the
20
27
  * severity-tier counts in this order and emits the per-finding sections
@@ -115,6 +122,12 @@ export function renderManualPromptsSection(messages) {
115
122
  * manual-prompt provider output; rendered as a trailing section when
116
123
  * non-empty.
117
124
  *
125
+ * Story #4839 — an optional `degradations` field names review gates that could
126
+ * not execute. They are **not** findings and never enter `countBySeverity`; they
127
+ * render as their own section and suppress the unqualified "no findings" claim,
128
+ * because a review that could not run a gate has not established that the
129
+ * gate's surface is clean.
130
+ *
118
131
  * @param {{
119
132
  * ticketId: number,
120
133
  * baseRef: string,
@@ -122,6 +135,7 @@ export function renderManualPromptsSection(messages) {
122
135
  * findings: ReadonlyArray<Finding>,
123
136
  * provider?: string,
124
137
  * promptMessages?: ReadonlyArray<string>,
138
+ * degradations?: ReadonlyArray<object>,
125
139
  * }} input
126
140
  * @returns {string}
127
141
  */
@@ -131,6 +145,7 @@ export function renderFindings(input) {
131
145
  const counts = countBySeverity(findings);
132
146
  const totalKnown =
133
147
  counts.critical + counts.high + counts.medium + counts.suggestion;
148
+ const degraded = normalizeDegradations(input.degradations);
134
149
 
135
150
  const providerLine = provider
136
151
  ? `**Provider**: \`${provider}\``
@@ -142,6 +157,7 @@ export function renderFindings(input) {
142
157
  `**Comparison**: \`${baseRef}\` … \`${headRef}\``,
143
158
  providerLine,
144
159
  `**Findings**: ${totalKnown}`,
160
+ ...renderDegradedHeaderLines(degraded),
145
161
  '',
146
162
  '### 📦 Severity Tier Counts',
147
163
  '',
@@ -150,12 +166,11 @@ export function renderFindings(input) {
150
166
  return `- ${meta.emoji} ${meta.label}: ${counts[sev]}`;
151
167
  }),
152
168
  '',
169
+ ...renderDegradedGatesSection(degraded),
153
170
  ];
154
171
 
155
172
  if (totalKnown === 0) {
156
- lines.push('### ✅ No findings');
157
- lines.push('');
158
- lines.push('No issues surfaced by the review provider.');
173
+ lines.push(...renderNoFindingsBlock(degraded));
159
174
  } else {
160
175
  for (const sev of SEVERITY_ORDER) {
161
176
  const tierFindings = findings.filter((f) => f && f.severity === sev);