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.
Files changed (46) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +15 -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/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  9. package/.agents/scripts/lib/config/temp-paths.js +113 -7
  10. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  11. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  12. package/.agents/scripts/lib/label-constants.js +12 -1
  13. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  14. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  15. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  16. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  17. package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
  18. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  19. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  21. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  22. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  24. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  25. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  26. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  27. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  28. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  32. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  33. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  34. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  35. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  36. package/.agents/scripts/lib/temp-retention.js +23 -8
  37. package/.agents/scripts/resolve-stories.js +52 -33
  38. package/.agents/scripts/single-story-confirm-merge.js +6 -8
  39. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  40. package/.agents/workflows/helpers/deliver-light.md +45 -5
  41. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  42. package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
  43. package/.agents/workflows/helpers/deliver-story.md +8 -5
  44. package/.agents/workflows/helpers/plan-reference.md +5 -4
  45. package/docs/CHANGELOG.md +28 -0
  46. package/package.json +1 -1
@@ -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);
@@ -40,7 +40,6 @@
40
40
  * @typedef {import('./types.js').ReviewProvider} ReviewProvider
41
41
  */
42
42
 
43
- import { spawnSync } from 'node:child_process';
44
43
  import path from 'node:path';
45
44
  import { POOL_SERIAL_THRESHOLD, runOnPool } from '../../cpu-pool.js';
46
45
  import { gitSpawn } from '../../git-utils.js';
@@ -54,6 +53,20 @@ import {
54
53
  } from '../../observability/runtime-friction.js';
55
54
  import { PROJECT_ROOT } from '../../project-root.js';
56
55
  import { transpileIfNeeded } from '../../transpile.js';
56
+ import {
57
+ parseLintOutput,
58
+ partitionFilesForLint,
59
+ runScopedLint,
60
+ } from './scoped-lint.js';
61
+
62
+ /**
63
+ * The scoped-lint surface lives in [`scoped-lint.js`](scoped-lint.js), which
64
+ * owns runner resolution, per-surface classification, and the merge. Story
65
+ * #4839 moved it there while fixing the three invocation defects that made this
66
+ * gate fail open on ~78% of deliveries; the module docstring there carries the
67
+ * diagnosis. The three names stay part of this provider's published lint seam.
68
+ */
69
+ export { parseLintOutput, partitionFilesForLint, runScopedLint };
57
70
 
58
71
  /** Worker entry that scores one file into a full maintainability report. */
59
72
  const MAINTAINABILITY_REPORT_WORKER_URL = new URL(
@@ -74,72 +87,6 @@ export const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
74
87
 
75
88
  const JS_MAINTAINABILITY_EXTS = new Set(['.js', '.mjs', '.cjs']);
76
89
 
77
- /**
78
- * Parse stdout/stderr from a lint runner to estimate error vs warning counts.
79
- *
80
- * Handles the two runners composing `npm run lint` in this project:
81
- * - Biome: emits "Found N error(s)." and "Found N warning(s)." lines.
82
- * - markdownlint: emits one diagnostic per issue, plus a trailing
83
- * "Summary: N error(s)" line.
84
- *
85
- * Severity classification: when the runner exits non-zero but its output
86
- * matches neither known reporter format, the result is "could not classify" —
87
- * `executionFailed: true` so callers can degrade the gate to a suggestion +
88
- * skipped marker rather than mislabelling an environment problem as high risk.
89
- *
90
- * Exported for testing.
91
- *
92
- * @param {{ status: number, stdout: string, stderr: string }} result
93
- * @returns {{ errors: number, warnings: number, parsed: boolean, executionFailed: boolean }}
94
- */
95
- export function parseLintOutput(result) {
96
- const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
97
-
98
- let errors = 0;
99
- let warnings = 0;
100
- let parsed = false;
101
-
102
- const errMatches = combined.matchAll(/Found\s+(\d+)\s+error/gi);
103
- for (const m of errMatches) {
104
- errors += Number(m[1]);
105
- parsed = true;
106
- }
107
- const warnMatches = combined.matchAll(/Found\s+(\d+)\s+warning/gi);
108
- for (const m of warnMatches) {
109
- warnings += Number(m[1]);
110
- parsed = true;
111
- }
112
-
113
- const mdSummary = combined.match(/Summary:\s+(\d+)\s+error/i);
114
- if (mdSummary) {
115
- errors += Number(mdSummary[1]);
116
- parsed = true;
117
- }
118
-
119
- const executionFailed = !parsed && result.status !== 0;
120
-
121
- return { errors, warnings, parsed, executionFailed };
122
- }
123
-
124
- /**
125
- * Pure: split changed paths into the file lists each lint runner consumes.
126
- *
127
- * Exported for testing.
128
- *
129
- * @param {string[]} changedFiles
130
- * @returns {{ code: string[], md: string[] }}
131
- */
132
- export function partitionFilesForLint(changedFiles) {
133
- const CODE = /\.(js|mjs|cjs|jsx|ts|tsx|json|jsonc)$/i;
134
- const code = [];
135
- const md = [];
136
- for (const f of changedFiles) {
137
- if (CODE.test(f)) code.push(f);
138
- else if (/\.md$/i.test(f)) md.push(f);
139
- }
140
- return { code, md };
141
- }
142
-
143
90
  /**
144
91
  * Read a changed file's content as it exists at `headRef` via
145
92
  * `git show <headRef>:<relPath>`, rather than reading the on-disk copy at
@@ -197,61 +144,6 @@ export function scoreSourceReport(source, relPath) {
197
144
  return calculateReport(prepared);
198
145
  }
199
146
 
200
- function spawnLintRunner(bin, args, cwd) {
201
- const result = spawnSync('npx', ['--no', bin, ...args], {
202
- cwd,
203
- encoding: 'utf-8',
204
- shell: process.platform === 'win32',
205
- });
206
- return {
207
- status: result.status ?? 1,
208
- stdout: result.stdout ?? '',
209
- stderr: result.stderr ?? '',
210
- };
211
- }
212
-
213
- /**
214
- * Run lint scoped to the changed surface only. Returns a normalized summary
215
- * compatible with `parseLintOutput` plus a `skipped` flag set when there is
216
- * no JS or markdown file in the changed set (nothing to lint).
217
- *
218
- * @param {string[]} changedFiles
219
- * @param {string} cwd
220
- * @param {(bin: string, args: string[], cwd: string) => { status: number, stdout: string, stderr: string }} [runnerFn]
221
- * @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only', executionFailed?: boolean }}
222
- */
223
- export function runScopedLint(changedFiles, cwd, runnerFn = spawnLintRunner) {
224
- const { code, md } = partitionFilesForLint(changedFiles);
225
- if (code.length === 0 && md.length === 0) {
226
- return {
227
- errors: 0,
228
- warnings: 0,
229
- parsed: false,
230
- skipped: true,
231
- mode: 'changed-only',
232
- };
233
- }
234
-
235
- const runs = [];
236
- if (code.length > 0) runs.push(runnerFn('biome', ['lint', ...code], cwd));
237
- if (md.length > 0) {
238
- runs.push(
239
- runnerFn('markdownlint', [...md, '--ignore', 'node_modules'], cwd),
240
- );
241
- }
242
-
243
- let status = 0;
244
- let stdout = '';
245
- let stderr = '';
246
- for (const r of runs) {
247
- if ((r.status ?? 1) > status) status = r.status ?? 1;
248
- stdout += r.stdout ?? '';
249
- stderr += r.stderr ?? '';
250
- }
251
- const summary = parseLintOutput({ status, stdout, stderr });
252
- return { ...summary, skipped: false, mode: 'changed-only' };
253
- }
254
-
255
147
  /**
256
148
  * Pure: classify a single file's maintainability report into a row + optional
257
149
  * Finding-shaped entries. `reportFn` is the thunk that produces the file's
@@ -523,6 +415,8 @@ async function runLintPhase({
523
415
  parsed: false,
524
416
  skipped: true,
525
417
  mode: 'off',
418
+ executionFailed: false,
419
+ degradations: [],
526
420
  };
527
421
  }
528
422
  logger?.info?.(
@@ -531,6 +425,35 @@ async function runLintPhase({
531
425
  return runScopedLintFn(changedFiles, PROJECT_ROOT);
532
426
  }
533
427
 
428
+ /**
429
+ * Pure: turn an `executionFailed` lint summary into the degradation records the
430
+ * review outcome carries beside its findings (Story #4839).
431
+ *
432
+ * A summary from `runScopedLint` names each failed surface; an injected or
433
+ * legacy summary that sets only `executionFailed` degrades to one record for
434
+ * the gate as a whole, so the outcome is never silent about a gate that did not
435
+ * run just because the summary predates the per-surface contract.
436
+ *
437
+ * @param {{ executionFailed?: boolean, degradations?: Array<{ surface: string, reason: string }> }} lintSummary
438
+ * @returns {Array<{ tool: string, gate: string, surface: string, reason: string }>}
439
+ */
440
+ function buildLintDegradations(lintSummary) {
441
+ if (!lintSummary.executionFailed) return [];
442
+ const rows = Array.isArray(lintSummary.degradations)
443
+ ? lintSummary.degradations
444
+ : [];
445
+ const surfaces =
446
+ rows.length > 0
447
+ ? rows
448
+ : [{ surface: 'scoped-lint', reason: 'unparseable-output' }];
449
+ return surfaces.map((row) => ({
450
+ tool: 'native-review-lint',
451
+ gate: 'scoped-lint',
452
+ surface: row.surface,
453
+ reason: row.reason,
454
+ }));
455
+ }
456
+
534
457
  /**
535
458
  * Build a `ReviewProvider` instance backed by the native in-process pipeline.
536
459
  *
@@ -560,12 +483,33 @@ export function createNativeProvider(deps = {}) {
560
483
  scopeLint = 'changed-only',
561
484
  } = deps;
562
485
 
486
+ /**
487
+ * Degradations recorded by the most recent `runReview`. Read through
488
+ * `getDegradations()` after the run, mirroring how `getPromptMessages` is
489
+ * feature-detected by the orchestrator — findings and degradations travel
490
+ * side by side, so an unexecutable tool never has to become a `Finding` to
491
+ * be visible (Story #4699's intent; Story #4839's fix).
492
+ *
493
+ * @type {Array<{ tool: string, gate: string, surface: string, reason: string }>}
494
+ */
495
+ let recordedDegradations = [];
496
+
563
497
  return {
498
+ /**
499
+ * Gate degradations from the last `runReview`. Never a `Finding`, so
500
+ * severity counts stay code-findings-only.
501
+ *
502
+ * @returns {Array<{ tool: string, gate: string, surface: string, reason: string }>}
503
+ */
504
+ getDegradations() {
505
+ return recordedDegradations;
506
+ },
564
507
  /**
565
508
  * @param {ReviewInput} input
566
509
  * @returns {Promise<Finding[]>}
567
510
  */
568
511
  async runReview(input) {
512
+ recordedDegradations = [];
569
513
  const { scope, ticketId, baseRef, headRef } = input ?? {};
570
514
  if (!baseRef || !headRef) {
571
515
  throw new TypeError(
@@ -623,8 +567,19 @@ export function createNativeProvider(deps = {}) {
623
567
  // Story #4699 — a tool that could not execute is an operational
624
568
  // degradation, not a code finding. Route it to friction telemetry
625
569
  // (best-effort) so severity counts reflect code findings only.
570
+ //
571
+ // Story #4839 — telemetry alone left the review's own verdict unable to
572
+ // distinguish "lint ran and found nothing" from "lint never ran", so
573
+ // the same degradation is also recorded on the outcome channel. It is
574
+ // still never a `Finding`: the friction emission below is unchanged and
575
+ // severity counts remain code-findings-only.
576
+ recordedDegradations = buildLintDegradations(lintSummary);
626
577
  logger?.warn?.(
627
- '[native-review] Lint runner could not execute — recorded as friction telemetry, no finding emitted. Verify with the canonical `npm run lint` before merging.',
578
+ `[native-review] Lint runner could not execute (${recordedDegradations
579
+ .map((d) => `${d.surface}: ${d.reason}`)
580
+ .join(
581
+ '; ',
582
+ )}) — reported as a degraded gate on the review outcome and recorded as friction telemetry; no finding emitted. Verify with the canonical \`npm run lint\` before merging.`,
628
583
  );
629
584
  try {
630
585
  await emitToolDegradationFn({
@@ -646,9 +601,10 @@ export function createNativeProvider(deps = {}) {
646
601
 
647
602
  // Canonical ordering: critical (maintainability) first, then high
648
603
  // (lint errors), then medium (size/volume warnings), then suggestion
649
- // (lint warnings / executionFailed). The renderer re-bucketizes by
650
- // severity tier, so this order only matters for stability of fixture
651
- // outputs.
604
+ // (lint warnings). An execution failure contributes to none of these
605
+ // tiers it travels on the degradation channel. The renderer
606
+ // re-bucketizes by severity tier, so this order only matters for
607
+ // stability of fixture outputs.
652
608
  return [
653
609
  ...results.criticalFindings,
654
610
  ...lintFindings.filter((f) => f.severity === 'high'),
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import { createCodexProviderForRegistry } from './codex.js';
38
+ import { mergeChainDegradations } from './degraded-gates.js';
38
39
  import { createNativeProviderForRegistry } from './native.js';
39
40
  import { createSecurityReviewProviderForRegistry } from './security-review.js';
40
41
  import { createUltrareviewProviderForRegistry } from './ultrareview.js';
@@ -288,6 +289,15 @@ export function createChainProvider(chain, opts = {}) {
288
289
  }
289
290
  return merged;
290
291
  },
292
+ /**
293
+ * Degraded gates across the inline chain (Story #4839). Called after
294
+ * `runReview`.
295
+ *
296
+ * @returns {Promise<Array<object>>}
297
+ */
298
+ async getDegradations() {
299
+ return mergeChainDegradations(chain.inline, logger);
300
+ },
291
301
  /**
292
302
  * @param {ReviewInput} input
293
303
  * @returns {Promise<string[]>}