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,300 @@
1
+ /**
2
+ * review-providers/scoped-lint.js — the scoped-lint surface of the native
3
+ * review provider (extracted from `native.js` by Story #4839).
4
+ *
5
+ * ## Why this module exists
6
+ *
7
+ * The scoped-lint gate reported `executionFailed` — and therefore emitted zero
8
+ * findings while the review reported clean — on 18 of 23 Beestera/swarm-os
9
+ * Stories carrying friction (78%) and on 5 mandrel Stories. Measured
10
+ * 2026-07-29, the cause was **not** environmental and **not** a parse failure.
11
+ * Three defects in how the runners were invoked and reconciled produced the
12
+ * same symptom:
13
+ *
14
+ * 1. **The markdown runner was never resolvable.** The provider spawned
15
+ * `npx --no markdownlint`, but the binary this project (and the consumer)
16
+ * installs is `markdownlint-cli2` — `markdownlint-cli2` is the package and
17
+ * the bin name; a bare `markdownlint` bin does not exist. `npx --no` with an
18
+ * unresolvable bin exits 1 printing `could not determine executable to run`
19
+ * and nothing else, so the summary parsed nothing and the gate degraded.
20
+ * The parser was already written for **cli2's** `Summary: N error(s)` line,
21
+ * so the invocation and the parser had never agreed. The `--ignore
22
+ * node_modules` flag was likewise `markdownlint-cli` (v1) syntax, which
23
+ * cli2 does not accept. Fix: resolve the runner from what is actually
24
+ * installed and pass each candidate its own argument shape.
25
+ *
26
+ * 2. **One runner's failure poisoned the other's verdict.** The two runs were
27
+ * folded into a *single* `parseLintOutput` call over concatenated output and
28
+ * the maximum exit status. So the unresolvable markdown runner's exit 1
29
+ * became the verdict for biome too: any change set containing at least one
30
+ * `.md` file degraded the whole gate whenever biome itself had nothing to
31
+ * report — i.e. exactly the clean case the gate exists to confirm. Fix:
32
+ * classify each surface independently and merge structurally.
33
+ *
34
+ * 3. **Biome's "nothing in scope" exit was read as a failure.** `biome lint`
35
+ * exits 1 with `No files were processed in the specified paths.` when every
36
+ * supplied path is excluded by `biome.json` (`temp/`, `dist/`,
37
+ * `.worktrees/`, anything in the VCS ignore file). That is an empty scope,
38
+ * not a runner that could not execute. Fix: recognise the sentinel.
39
+ *
40
+ * ## What a degraded surface now produces
41
+ *
42
+ * `runScopedLint` still reports `executionFailed` — the friction-telemetry
43
+ * emission in `native.js` is deliberately unchanged (Story #4699 routed an
44
+ * unexecutable tool to telemetry so severity tiers reflect code findings only,
45
+ * and that intent stands). It additionally reports a `degradations[]` array
46
+ * naming **which** surface could not run and **why**, so the review outcome can
47
+ * say "this gate did not run" instead of silently reading clean.
48
+ */
49
+
50
+ import { spawnSync } from 'node:child_process';
51
+ import { existsSync } from 'node:fs';
52
+ import path from 'node:path';
53
+
54
+ /** Paths these extensions land on the biome (code) runner. */
55
+ const CODE_EXTENSIONS = /\.(js|mjs|cjs|jsx|ts|tsx|json|jsonc)$/i;
56
+
57
+ /** npx's message when the requested bin cannot be resolved. */
58
+ const NPX_UNRESOLVABLE = /could not determine executable to run/i;
59
+
60
+ /** Biome's exit-1 message when every supplied path is config-excluded. */
61
+ const BIOME_EMPTY_SCOPE = /No files were processed in the specified paths/i;
62
+
63
+ /**
64
+ * Markdown runners in preference order, each with the argument shape *it*
65
+ * accepts. `markdownlint-cli2` takes bare paths/globs and rejects `--ignore`;
66
+ * `markdownlint` (cli v1) takes `--ignore`. Explicit changed-file paths are
67
+ * passed either way, so the v1 ignore flag is belt-and-braces only.
68
+ */
69
+ const MARKDOWN_RUNNERS = Object.freeze([
70
+ Object.freeze({ bin: 'markdownlint-cli2', extraArgs: Object.freeze([]) }),
71
+ Object.freeze({
72
+ bin: 'markdownlint',
73
+ extraArgs: Object.freeze(['--ignore', 'node_modules']),
74
+ }),
75
+ ]);
76
+
77
+ /** Reason codes carried on a degradation record. */
78
+ const DEGRADATION_REASONS = Object.freeze({
79
+ RUNNER_NOT_INSTALLED: 'runner-not-installed',
80
+ RUNNER_NOT_RESOLVABLE: 'runner-not-resolvable',
81
+ UNPARSEABLE_OUTPUT: 'unparseable-output',
82
+ });
83
+
84
+ /**
85
+ * Spawn one lint runner through `npx --no` (never install on the fly).
86
+ *
87
+ * @param {string} bin
88
+ * @param {string[]} args
89
+ * @param {string} cwd
90
+ * @returns {{ status: number, stdout: string, stderr: string }}
91
+ */
92
+ function spawnLintRunner(bin, args, cwd) {
93
+ const result = spawnSync('npx', ['--no', bin, ...args], {
94
+ cwd,
95
+ encoding: 'utf-8',
96
+ shell: process.platform === 'win32',
97
+ });
98
+ return {
99
+ status: result.status ?? 1,
100
+ stdout: result.stdout ?? '',
101
+ stderr: result.stderr ?? '',
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Pure-ish: pick the first markdown runner whose bin is actually installed
107
+ * under `<cwd>/node_modules/.bin`. Returns `null` when none is — an honest
108
+ * "this surface has no runner" that the caller reports rather than silently
109
+ * folding into a generic parse failure.
110
+ *
111
+ * The disk probe (rather than "spawn and see") is what makes the failure
112
+ * *nameable*: `npx --no <missing-bin>` yields only a generic npm error, which
113
+ * is precisely how the defect hid for months.
114
+ *
115
+ * Not exported: it is reachable — and asserted — through {@link runScopedLint},
116
+ * whose `existsFn` seam drives every resolution branch.
117
+ *
118
+ * @param {string} cwd
119
+ * @param {(p: string) => boolean} [existsFn] Injected for testing.
120
+ * @returns {{ bin: string, extraArgs: ReadonlyArray<string> }|null}
121
+ */
122
+ function resolveMarkdownRunner(cwd, existsFn = existsSync) {
123
+ for (const candidate of MARKDOWN_RUNNERS) {
124
+ const base = path.join(cwd, 'node_modules', '.bin', candidate.bin);
125
+ if (existsFn(base)) return candidate;
126
+ if (
127
+ process.platform === 'win32' &&
128
+ (existsFn(`${base}.cmd`) || existsFn(`${base}.ps1`))
129
+ ) {
130
+ return candidate;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Pure: split changed paths into the file lists each lint runner consumes.
138
+ *
139
+ * @param {string[]} changedFiles
140
+ * @returns {{ code: string[], md: string[] }}
141
+ */
142
+ export function partitionFilesForLint(changedFiles) {
143
+ const code = [];
144
+ const md = [];
145
+ for (const f of changedFiles) {
146
+ if (CODE_EXTENSIONS.test(f)) code.push(f);
147
+ else if (/\.md$/i.test(f)) md.push(f);
148
+ }
149
+ return { code, md };
150
+ }
151
+
152
+ /**
153
+ * Pure: classify **one** runner's result into a summary.
154
+ *
155
+ * Handles the reporter formats composing `npm run lint` here:
156
+ * - Biome: `Found N error(s).` / `Found N warning(s).`
157
+ * - markdownlint-cli2: a trailing `Summary: N error(s)` line.
158
+ *
159
+ * A non-zero exit whose output matches no known reporter format is "could not
160
+ * classify" → `executionFailed: true`, with `reason` naming what was actually
161
+ * observed. Biome's empty-scope exit is recognised separately as
162
+ * `emptyScope` — nothing to lint is not a broken runner.
163
+ *
164
+ * @param {{ status?: number, stdout?: string, stderr?: string }} result
165
+ * @returns {{ errors: number, warnings: number, parsed: boolean, executionFailed: boolean, emptyScope: boolean, reason: string|null }}
166
+ */
167
+ export function parseLintOutput(result) {
168
+ const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
169
+
170
+ let errors = 0;
171
+ let warnings = 0;
172
+ let parsed = false;
173
+
174
+ for (const m of combined.matchAll(/Found\s+(\d+)\s+error/gi)) {
175
+ errors += Number(m[1]);
176
+ parsed = true;
177
+ }
178
+ for (const m of combined.matchAll(/Found\s+(\d+)\s+warning/gi)) {
179
+ warnings += Number(m[1]);
180
+ parsed = true;
181
+ }
182
+ const mdSummary = combined.match(/Summary:\s+(\d+)\s+error/i);
183
+ if (mdSummary) {
184
+ errors += Number(mdSummary[1]);
185
+ parsed = true;
186
+ }
187
+
188
+ const failedExit = !parsed && (result.status ?? 0) !== 0;
189
+ const emptyScope = failedExit && BIOME_EMPTY_SCOPE.test(combined);
190
+ const executionFailed = failedExit && !emptyScope;
191
+ const reason = executionFailed
192
+ ? NPX_UNRESOLVABLE.test(combined)
193
+ ? DEGRADATION_REASONS.RUNNER_NOT_RESOLVABLE
194
+ : DEGRADATION_REASONS.UNPARSEABLE_OUTPUT
195
+ : null;
196
+
197
+ return { errors, warnings, parsed, executionFailed, emptyScope, reason };
198
+ }
199
+
200
+ /**
201
+ * Pure: merge per-surface summaries into the gate's single summary. Counts add;
202
+ * `executionFailed` is the OR across surfaces; each failed surface contributes
203
+ * one degradation record naming itself. Merging *summaries* rather than raw
204
+ * output is what stops one runner's failure from becoming the other's verdict.
205
+ *
206
+ * @param {Array<{ surface: string, summary: ReturnType<typeof parseLintOutput> }>} surfaces
207
+ */
208
+ function mergeSurfaceSummaries(surfaces) {
209
+ let errors = 0;
210
+ let warnings = 0;
211
+ let parsed = false;
212
+ let executionFailed = false;
213
+ const degradations = [];
214
+
215
+ for (const { surface, summary } of surfaces) {
216
+ errors += summary.errors;
217
+ warnings += summary.warnings;
218
+ if (summary.parsed) parsed = true;
219
+ if (summary.executionFailed) {
220
+ executionFailed = true;
221
+ degradations.push({
222
+ surface,
223
+ reason: summary.reason ?? DEGRADATION_REASONS.UNPARSEABLE_OUTPUT,
224
+ });
225
+ }
226
+ }
227
+
228
+ return {
229
+ errors,
230
+ warnings,
231
+ parsed,
232
+ executionFailed,
233
+ skipped: false,
234
+ mode: 'changed-only',
235
+ degradations,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Run lint scoped to the changed surface only.
241
+ *
242
+ * @param {string[]} changedFiles
243
+ * @param {string} cwd
244
+ * @param {typeof spawnLintRunner} [runnerFn]
245
+ * @param {{ existsFn?: (p: string) => boolean }} [deps] Test seam for runner resolution.
246
+ * @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only'|'off', executionFailed: boolean, degradations: Array<{ surface: string, reason: string }> }}
247
+ */
248
+ export function runScopedLint(
249
+ changedFiles,
250
+ cwd,
251
+ runnerFn = spawnLintRunner,
252
+ deps = {},
253
+ ) {
254
+ const { existsFn = existsSync } = deps;
255
+ const { code, md } = partitionFilesForLint(changedFiles);
256
+ if (code.length === 0 && md.length === 0) {
257
+ return {
258
+ errors: 0,
259
+ warnings: 0,
260
+ parsed: false,
261
+ skipped: true,
262
+ mode: 'changed-only',
263
+ executionFailed: false,
264
+ degradations: [],
265
+ };
266
+ }
267
+
268
+ const surfaces = [];
269
+ if (code.length > 0) {
270
+ surfaces.push({
271
+ surface: 'biome',
272
+ summary: parseLintOutput(runnerFn('biome', ['lint', ...code], cwd)),
273
+ });
274
+ }
275
+ if (md.length > 0) {
276
+ const runner = resolveMarkdownRunner(cwd, existsFn);
277
+ if (runner === null) {
278
+ surfaces.push({
279
+ surface: 'markdownlint',
280
+ summary: {
281
+ errors: 0,
282
+ warnings: 0,
283
+ parsed: false,
284
+ executionFailed: true,
285
+ emptyScope: false,
286
+ reason: DEGRADATION_REASONS.RUNNER_NOT_INSTALLED,
287
+ },
288
+ });
289
+ } else {
290
+ surfaces.push({
291
+ surface: runner.bin,
292
+ summary: parseLintOutput(
293
+ runnerFn(runner.bin, [...md, ...runner.extraArgs], cwd),
294
+ ),
295
+ });
296
+ }
297
+ }
298
+
299
+ return mergeSurfaceSummaries(surfaces);
300
+ }
@@ -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 graduateRetroProposals({
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') {
@@ -70,7 +70,17 @@ import { Logger, resolveLevel } from '../../Logger.js';
70
70
  */
71
71
  export const REPLAY_TAIL_LINES = 200;
72
72
 
73
- /** Basename of the per-Story gate log inside the temp directory. */
73
+ /**
74
+ * Basename of the per-Story gate log inside the temp directory.
75
+ *
76
+ * `closeGateLogPath` in `lib/config/temp-paths.js` spells the same name for the
77
+ * READER — `deliver-recover.js` uses this file's freshness to tell a live close
78
+ * from a dead one. Deliberately not shared through an import: this sink needs
79
+ * the basename alone (it honours a `logDir` override the path helper knows
80
+ * nothing about), and calling that helper for it would drag tempRoot
81
+ * resolution — a git spawn and scratch-dir creation — into a filename lookup.
82
+ * The two spellings are pinned equal by test instead.
83
+ */
74
84
  function logNameFor(storyId) {
75
85
  return `close-gates-${storyId ?? 'unknown'}.log`;
76
86
  }
@@ -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}) — ${tally}.`
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
- progress(
226
- 'REVIEW',
227
- `Findings — critical:${sev.critical} high:${sev.high} medium:${sev.medium} suggestion:${sev.suggestion}. Posted to PR #${prNumber}: ${result.posted}.`,
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
+ }
@@ -71,7 +71,7 @@ async function emitTerminal({ terminal, result, config }) {
71
71
  },
72
72
  });
73
73
  }
74
- emitTerminalEnvelope(terminal);
74
+ emitTerminalEnvelope(terminal, { config });
75
75
  await emitTerminalFriction({ envelope: terminal, config });
76
76
  }
77
77
 
@@ -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
- return `Review completehigh=${high} medium=${medium} suggestion=${suggestion} (posted=${posted}).`;
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
  /**