mandrel 2.20.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
/**
|
|
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}) — ${
|
|
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
|
/**
|