mandrel 2.16.0 → 2.17.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/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +28 -6
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +24 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
* single write in the run is the report artifact, performed by the synthesis
|
|
27
27
|
* agent, which is granted the read-only allowlist plus `Write`.
|
|
28
28
|
*
|
|
29
|
+
* ## Partial-failure posture
|
|
30
|
+
*
|
|
31
|
+
* The per-dimension fan-out is settled, not all-or-nothing (Story #4783). One
|
|
32
|
+
* rejected dimension used to discard every sibling's completed work; the
|
|
33
|
+
* engine now partitions the settled results, flows the fulfilled dimensions on
|
|
34
|
+
* to the next phase, and records the rejected ones as an explicit
|
|
35
|
+
* degraded-coverage note in the report's Executive Summary. This mirrors the
|
|
36
|
+
* posture `close-validation/runner.js` already takes with per-gate errors:
|
|
37
|
+
* capture the failure into the result rather than rejecting the whole run.
|
|
38
|
+
* Only a *total* loss — every dimension rejected — throws, because there is
|
|
39
|
+
* then nothing to synthesise.
|
|
40
|
+
*
|
|
29
41
|
* ## Report-contract self-check
|
|
30
42
|
*
|
|
31
43
|
* After synthesis the engine calls the caller-supplied `assertReportContract`
|
|
@@ -43,6 +55,14 @@
|
|
|
43
55
|
* @module dynamic-workflow/audit-orchestrator
|
|
44
56
|
*/
|
|
45
57
|
|
|
58
|
+
import { withDegradedCoverageNote } from './degraded-coverage.js';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A dimension that did not complete, as recorded by the settled fan-out.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {import('./degraded-coverage.js').DimensionFailure} DimensionFailure
|
|
64
|
+
*/
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* The live dynamic-workflow runtime context the host passes to a saved
|
|
48
68
|
* `.claude/workflows/*.workflow.js` entry point. Re-exported as a typedef so
|
|
@@ -82,8 +102,11 @@
|
|
|
82
102
|
* Compose the analysis prompt for one dimension (lens-specific).
|
|
83
103
|
* @property {(dimension: string, findings: string) => string} buildCrossCheckPrompt
|
|
84
104
|
* Compose the adversarial cross-check prompt for one dimension's findings.
|
|
85
|
-
* @property {(crossCheckedBlocks: string[]) => string} buildSynthesisPrompt
|
|
86
|
-
* Compose the synthesis prompt that assembles the report and writes it.
|
|
105
|
+
* @property {(crossCheckedBlocks: string[], degraded: DimensionFailure[]) => string} buildSynthesisPrompt
|
|
106
|
+
* Compose the synthesis prompt that assembles the report and writes it. The
|
|
107
|
+
* second argument lists the dimensions that did not complete (empty on a
|
|
108
|
+
* full-coverage run); lenses may ignore it — the engine annotates the report
|
|
109
|
+
* with the degraded-coverage note either way.
|
|
87
110
|
* @property {(report: string) => { conformant: boolean, missingSections: string[], hasTitle: boolean }} assertReportContract
|
|
88
111
|
* Self-check the synthesised report against the lens's report contract.
|
|
89
112
|
* @property {(check: { conformant: boolean, missingSections: string[], hasTitle: boolean }) => string} [formatContractError]
|
|
@@ -122,6 +145,45 @@ export function defaultContractError(check) {
|
|
|
122
145
|
return `report failed contract check: missing ${titlePart}sections=[${sections}]`;
|
|
123
146
|
}
|
|
124
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Reduce a rejection reason to a single-line message.
|
|
150
|
+
*
|
|
151
|
+
* @param {unknown} reason
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
function describeRejection(reason) {
|
|
155
|
+
if (reason instanceof Error) return reason.message;
|
|
156
|
+
return String(reason ?? 'unknown error');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Partition settled fan-out results into the values that completed and the
|
|
161
|
+
* dimensions that did not. `dimensions[i]` names the dimension behind
|
|
162
|
+
* `settled[i]`, so a rejection is always attributable.
|
|
163
|
+
*
|
|
164
|
+
* @template T
|
|
165
|
+
* @param {readonly string[]} dimensions
|
|
166
|
+
* @param {readonly PromiseSettledResult<T>[]} settled
|
|
167
|
+
* @param {string} phaseName Phase recorded on each failure.
|
|
168
|
+
* @returns {{ fulfilled: T[], failures: DimensionFailure[] }}
|
|
169
|
+
*/
|
|
170
|
+
function partitionSettled(dimensions, settled, phaseName) {
|
|
171
|
+
const fulfilled = [];
|
|
172
|
+
const failures = [];
|
|
173
|
+
settled.forEach((result, index) => {
|
|
174
|
+
if (result.status === 'fulfilled') {
|
|
175
|
+
fulfilled.push(result.value);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
failures.push({
|
|
179
|
+
dimension: dimensions[index],
|
|
180
|
+
phase: phaseName,
|
|
181
|
+
reason: describeRejection(result.reason),
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
return { fulfilled, failures };
|
|
185
|
+
}
|
|
186
|
+
|
|
125
187
|
/**
|
|
126
188
|
* Run the shared three-phase audit-lens orchestration: parallel per-dimension
|
|
127
189
|
* analysis → adversarial cross-check → synthesis + report-contract self-check.
|
|
@@ -149,9 +211,10 @@ export async function runAuditOrchestration(spec) {
|
|
|
149
211
|
|
|
150
212
|
const { agent, phase } = ctx;
|
|
151
213
|
|
|
152
|
-
// Phase 1 — parallel per-dimension analysis (read-only agents).
|
|
153
|
-
|
|
154
|
-
|
|
214
|
+
// Phase 1 — parallel per-dimension analysis (read-only agents). Settled, not
|
|
215
|
+
// all-or-nothing: one dimension's rejection must not discard its siblings.
|
|
216
|
+
const analyzed = await phase(ORCHESTRATION_PHASES.ANALYZE, async () =>
|
|
217
|
+
Promise.allSettled(
|
|
155
218
|
dimensions.map(async (dimension) => {
|
|
156
219
|
const { output } = await agent({
|
|
157
220
|
prompt: buildDimensionPrompt(dimension),
|
|
@@ -161,11 +224,15 @@ export async function runAuditOrchestration(spec) {
|
|
|
161
224
|
}),
|
|
162
225
|
),
|
|
163
226
|
);
|
|
227
|
+
const { fulfilled: rawFindings, failures: analyzeFailures } =
|
|
228
|
+
partitionSettled(dimensions, analyzed, 'analyze');
|
|
164
229
|
|
|
165
230
|
// Phase 2 — adversarial cross-check: an independent agent re-verifies each
|
|
166
|
-
// dimension's findings and filters false positives before
|
|
167
|
-
|
|
168
|
-
|
|
231
|
+
// surviving dimension's findings and filters false positives before
|
|
232
|
+
// inclusion. Settled for the same reason as phase 1.
|
|
233
|
+
const checkedDimensions = rawFindings.map((entry) => entry.dimension);
|
|
234
|
+
const checked = await phase(ORCHESTRATION_PHASES.CROSS_CHECK, async () =>
|
|
235
|
+
Promise.allSettled(
|
|
169
236
|
rawFindings.map(async ({ dimension, findings }) => {
|
|
170
237
|
const { output } = await agent({
|
|
171
238
|
prompt: buildCrossCheckPrompt(dimension, findings),
|
|
@@ -175,18 +242,38 @@ export async function runAuditOrchestration(spec) {
|
|
|
175
242
|
}),
|
|
176
243
|
),
|
|
177
244
|
);
|
|
245
|
+
const { fulfilled: crossChecked, failures: crossCheckFailures } =
|
|
246
|
+
partitionSettled(checkedDimensions, checked, 'cross-check');
|
|
247
|
+
|
|
248
|
+
const degraded = [...analyzeFailures, ...crossCheckFailures];
|
|
249
|
+
if (dimensions.length > 0 && crossChecked.length === 0) {
|
|
250
|
+
// Nothing survived — there is no partial report to salvage.
|
|
251
|
+
throw new Error(
|
|
252
|
+
`every audit dimension failed: ${degraded
|
|
253
|
+
.map((f) => `${f.dimension} (${f.phase}: ${f.reason})`)
|
|
254
|
+
.join('; ')}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
178
257
|
|
|
179
258
|
// Phase 3 — synthesis: assemble the report contract and write the artifact.
|
|
180
|
-
const { output:
|
|
259
|
+
const { output: synthesised } = await phase(
|
|
181
260
|
ORCHESTRATION_PHASES.SYNTHESIZE,
|
|
182
261
|
async () =>
|
|
183
262
|
agent({
|
|
184
|
-
prompt: buildSynthesisPrompt(crossChecked),
|
|
263
|
+
prompt: buildSynthesisPrompt(crossChecked, degraded),
|
|
185
264
|
// Synthesis is the one stage permitted to write the report artifact.
|
|
186
265
|
allowedTools: [...readOnlyTools, SYNTHESIS_WRITE_TOOL],
|
|
187
266
|
}),
|
|
188
267
|
);
|
|
189
268
|
|
|
269
|
+
// The coverage gap is annotated by the engine, never left to the synthesis
|
|
270
|
+
// agent's discretion.
|
|
271
|
+
const report = withDegradedCoverageNote(
|
|
272
|
+
synthesised,
|
|
273
|
+
degraded,
|
|
274
|
+
dimensions.length,
|
|
275
|
+
);
|
|
276
|
+
|
|
190
277
|
// Self-verify report-contract conformance before returning.
|
|
191
278
|
const check = assertReportContract(report);
|
|
192
279
|
if (!check.conformant) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// .agents/scripts/lib/dynamic-workflow/degraded-coverage.js
|
|
2
|
+
/**
|
|
3
|
+
* Degraded-coverage annotation for audit-lens reports (Story #4783).
|
|
4
|
+
*
|
|
5
|
+
* An audit lens fans out one sub-agent per analysis dimension. When one of
|
|
6
|
+
* those dimensions rejects — a sub-agent that ran out of context, a
|
|
7
|
+
* measurement command that failed, a transient runtime error — the run used to
|
|
8
|
+
* discard every sibling dimension's completed work along with it.
|
|
9
|
+
*
|
|
10
|
+
* The engine now partitions instead: the fulfilled dimensions flow on to
|
|
11
|
+
* synthesis, and the rejected ones are recorded here as an explicit note in
|
|
12
|
+
* the report's Executive Summary. A lens that covers four of five dimensions
|
|
13
|
+
* *and says which one is missing* is strictly more useful than one that yields
|
|
14
|
+
* nothing — but only if the gap is visible. An unannotated partial report is
|
|
15
|
+
* worse than no report, because it reads as complete coverage.
|
|
16
|
+
*
|
|
17
|
+
* The annotation is applied by the engine, not requested of the synthesis
|
|
18
|
+
* agent: a coverage disclaimer that depends on an LLM remembering to write it
|
|
19
|
+
* is not a disclaimer.
|
|
20
|
+
*
|
|
21
|
+
* @module dynamic-workflow/degraded-coverage
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A dimension that did not complete.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {object} DimensionFailure
|
|
28
|
+
* @property {string} dimension The analysis dimension that failed.
|
|
29
|
+
* @property {string} phase The phase it failed in (`analyze` / `cross-check`).
|
|
30
|
+
* @property {string} reason The rejection's message.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Matches the Executive Summary heading at any heading level. */
|
|
34
|
+
const EXECUTIVE_SUMMARY_HEADING = /^#{1,6}\s+Executive Summary\b/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render the degraded-coverage note. Names every failed dimension, the phase
|
|
38
|
+
* it failed in, and the reason, so a reader can tell coverage loss apart from
|
|
39
|
+
* an absence of findings.
|
|
40
|
+
*
|
|
41
|
+
* @param {readonly DimensionFailure[]} failures
|
|
42
|
+
* @param {number} totalDimensions Dimensions the run set out to cover.
|
|
43
|
+
* @returns {string} A markdown blockquote.
|
|
44
|
+
*/
|
|
45
|
+
function formatDegradedCoverageNote(failures, totalDimensions) {
|
|
46
|
+
const detail = failures
|
|
47
|
+
.map((f) => `**${f.dimension}** (${f.phase}: ${f.reason})`)
|
|
48
|
+
.join('; ');
|
|
49
|
+
const noun = failures.length === 1 ? 'dimension' : 'dimensions';
|
|
50
|
+
return [
|
|
51
|
+
`> ⚠️ **Degraded coverage** — ${failures.length} of ${totalDimensions} analysis ${noun} did not complete`,
|
|
52
|
+
`> and ${failures.length === 1 ? 'is' : 'are'} unrepresented in this report: ${detail}.`,
|
|
53
|
+
'> Findings for the remaining dimensions are complete; the gap above is not evidence of their absence.',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Annotate a synthesised report with the degraded-coverage note, inserted
|
|
59
|
+
* directly beneath the `## Executive Summary` heading (every lens report
|
|
60
|
+
* contract requires that section). A report without the heading is prefixed
|
|
61
|
+
* instead, so the note can never be silently dropped.
|
|
62
|
+
*
|
|
63
|
+
* Returns the report unchanged when nothing failed — a full-coverage run must
|
|
64
|
+
* not carry a coverage caveat.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} report
|
|
67
|
+
* @param {readonly DimensionFailure[]} failures
|
|
68
|
+
* @param {number} totalDimensions
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function withDegradedCoverageNote(report, failures, totalDimensions) {
|
|
72
|
+
if (!Array.isArray(failures) || failures.length === 0) return report;
|
|
73
|
+
const note = formatDegradedCoverageNote(failures, totalDimensions);
|
|
74
|
+
const lines = String(report).split('\n');
|
|
75
|
+
const headingIndex = lines.findIndex((line) =>
|
|
76
|
+
EXECUTIVE_SUMMARY_HEADING.test(line.trim()),
|
|
77
|
+
);
|
|
78
|
+
if (headingIndex === -1) return `${note}\n\n${report}`;
|
|
79
|
+
lines.splice(headingIndex + 1, 0, '', note);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
@@ -134,16 +134,22 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
|
|
|
134
134
|
*
|
|
135
135
|
* Caller-specific log lines and error text are passed in as the `messages`
|
|
136
136
|
* data bag so behaviour stays byte-identical to the pre-extraction switches.
|
|
137
|
-
* The git seams (`spawn`, `existsLocally`, `existsRemotely`) are
|
|
138
|
-
* each caller can bind its own cwd (and tests can
|
|
137
|
+
* The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injectable so
|
|
138
|
+
* each caller can bind its own cwd (and tests can substitute stubs through the
|
|
139
|
+
* parameter rather than by module mocking). Per
|
|
140
|
+
* `.agents/rules/test-seams.md` rule 1 each seam **defaults to the real
|
|
141
|
+
* implementation** bound to `cwd`, so a caller that only knows its checkout
|
|
142
|
+
* passes `cwd` and nothing else; `single-story-init.js` keeps passing its own
|
|
143
|
+
* pre-bound seams and is unaffected.
|
|
139
144
|
*
|
|
140
145
|
* @param {object} opts
|
|
141
146
|
* @param {string} opts.storyBranch
|
|
142
147
|
* @param {string} opts.baseRef Ref to branch from on `create`.
|
|
148
|
+
* @param {string} [opts.cwd] Checkout the default seams bind to.
|
|
143
149
|
* @param {boolean} [opts.swallowCreateRace=false]
|
|
144
|
-
* @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} opts.spawn
|
|
145
|
-
* @param {(branch: string) => boolean} opts.existsLocally
|
|
146
|
-
* @param {(branch: string) => boolean} opts.existsRemotely
|
|
150
|
+
* @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} [opts.spawn]
|
|
151
|
+
* @param {(branch: string) => boolean} [opts.existsLocally]
|
|
152
|
+
* @param {(branch: string) => boolean} [opts.existsRemotely]
|
|
147
153
|
* @param {(level: string, message: string) => void} [opts.progress]
|
|
148
154
|
* @param {object} opts.messages
|
|
149
155
|
* @param {(b: string) => string} opts.messages.reuse
|
|
@@ -158,10 +164,11 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
|
|
|
158
164
|
export function seedStoryBranchRef({
|
|
159
165
|
storyBranch,
|
|
160
166
|
baseRef,
|
|
167
|
+
cwd,
|
|
161
168
|
swallowCreateRace = false,
|
|
162
|
-
spawn,
|
|
163
|
-
existsLocally,
|
|
164
|
-
existsRemotely,
|
|
169
|
+
spawn = (args) => gitSpawn(cwd, ...args),
|
|
170
|
+
existsLocally = (branch) => branchExistsLocally(branch, cwd),
|
|
171
|
+
existsRemotely = (branch) => branchExistsViaTrackingRef(branch, cwd),
|
|
165
172
|
progress = () => {},
|
|
166
173
|
messages,
|
|
167
174
|
}) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readBaseFromGit } from '../../../baselines/git-base.js';
|
|
13
13
|
import { getKindModule } from '../../../baselines/kernel.js';
|
|
14
14
|
import { resolveScope } from '../../../baselines/scope.js';
|
|
15
|
+
import { Logger } from '../../../Logger.js';
|
|
15
16
|
import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
|
|
16
17
|
|
|
17
18
|
function baselineRelativePath(kind, gateBlock) {
|
|
@@ -67,6 +68,29 @@ export async function evaluateCompare({ kind, gateBlock, scope, cwd }) {
|
|
|
67
68
|
return { baseRef: scope.ref, baseRead: true, basePayload, kindModule };
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Is the base baseline comparable to the head baseline (Story #4775)?
|
|
73
|
+
*
|
|
74
|
+
* A kind can change its SCORING SEMANTICS — how it derives a row's metric —
|
|
75
|
+
* without moving `kernelVersion`. Across that boundary the same row can carry
|
|
76
|
+
* a different score for reasons that have nothing to do with the branch's
|
|
77
|
+
* changes, so a head-vs-base diff manufactures phantom regressions (and can
|
|
78
|
+
* hide real ones behind them).
|
|
79
|
+
*
|
|
80
|
+
* The head-side stamp is already a fail-closed gate: a stale HEAD baseline
|
|
81
|
+
* never reaches this point. What reaches here is the opposite and legitimate
|
|
82
|
+
* case — a branch that DOES carry a re-derived baseline, compared against a
|
|
83
|
+
* base that predates the change. The only honest verdict is "no comparison";
|
|
84
|
+
* floors still run, so a genuine ceiling breach is still caught, and once the
|
|
85
|
+
* refreshed baseline is the base the ratchet returns to full strength on the
|
|
86
|
+
* very next run without anything to remember to reset.
|
|
87
|
+
*/
|
|
88
|
+
function baseIsComparable(headBaseline, basePayload) {
|
|
89
|
+
const head = headBaseline?.scoringSemantics ?? null;
|
|
90
|
+
const base = basePayload?.scoringSemantics ?? null;
|
|
91
|
+
return head === base;
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
export function runCompareStage(headBaseline, cmp) {
|
|
71
95
|
const empty = {
|
|
72
96
|
regressions: [],
|
|
@@ -75,6 +99,17 @@ export function runCompareStage(headBaseline, cmp) {
|
|
|
75
99
|
additions: [],
|
|
76
100
|
};
|
|
77
101
|
if (!cmp.baseRead || !cmp.basePayload || !cmp.kindModule) return empty;
|
|
102
|
+
if (!baseIsComparable(headBaseline, cmp.basePayload)) {
|
|
103
|
+
Logger.warn(
|
|
104
|
+
`[${cmp.kindModule.name}] ⚠ base baseline was scored under different ` +
|
|
105
|
+
`semantics (base=${cmp.basePayload.scoringSemantics ?? '<unstamped>'} ` +
|
|
106
|
+
`head=${headBaseline?.scoringSemantics ?? '<unstamped>'}); its rows are ` +
|
|
107
|
+
'not comparable, so the head-vs-base compare is skipped for this run. ' +
|
|
108
|
+
'Floors still enforced. The ratchet resumes once the re-derived ' +
|
|
109
|
+
'baseline is the base.',
|
|
110
|
+
);
|
|
111
|
+
return empty;
|
|
112
|
+
}
|
|
78
113
|
try {
|
|
79
114
|
const baseRows = Array.isArray(cmp.basePayload.rows)
|
|
80
115
|
? cmp.basePayload.rows
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '../../../baselines/env-overrides.js';
|
|
14
14
|
import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
|
|
15
15
|
import {
|
|
16
|
+
checkBaselineSemantics,
|
|
16
17
|
checkKernelVersion,
|
|
17
18
|
getKindModule,
|
|
18
19
|
} from '../../../baselines/kernel.js';
|
|
@@ -247,6 +248,18 @@ export async function evaluateKind({
|
|
|
247
248
|
const headLoad = loadHeadBaseline(kind, cwd, configPath);
|
|
248
249
|
if (headLoad.schemaError) return { kind, schemaError: headLoad.schemaError };
|
|
249
250
|
const baseline = headLoad.baseline;
|
|
251
|
+
// Story #4775 — scoring-semantics gate. A baseline whose rows were produced
|
|
252
|
+
// by superseded scoring semantics is structurally valid but semantically
|
|
253
|
+
// incomparable, so schema validation alone would wave it through. Fail
|
|
254
|
+
// closed on the `semantics` tag rather than compare across the boundary;
|
|
255
|
+
// the message names the exact re-baseline command.
|
|
256
|
+
const semanticsError = checkBaselineSemantics(kind, baseline);
|
|
257
|
+
if (semanticsError) {
|
|
258
|
+
return {
|
|
259
|
+
kind,
|
|
260
|
+
schemaError: { tag: 'semantics', message: semanticsError },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
250
263
|
const floorRollup = rollupExcludingIgnored({
|
|
251
264
|
kind,
|
|
252
265
|
baseline,
|
|
@@ -152,9 +152,24 @@ export function removeWorktree(worktreePath, cwd) {
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Prune the clone's stale remote-tracking refs and report which ones went.
|
|
157
|
+
*
|
|
158
|
+
* The fetch MUST NOT be `--quiet` (Story #4772). `--quiet` still prunes, but
|
|
159
|
+
* suppresses the `- [deleted] (none) -> <remote>/<ref>` progress lines that
|
|
160
|
+
* are the *only* record of what was dropped — `parsePruneFn` then reports an
|
|
161
|
+
* empty list for work that really happened, and `computeExitCode` reads the
|
|
162
|
+
* run as "nothing to do" (exit 2). The output is captured, not shown, so
|
|
163
|
+
* `--quiet` bought nothing to begin with.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} cwd
|
|
166
|
+
* @param {string} remoteName
|
|
167
|
+
* @param {(output: string, remoteName: string) => string[]} parsePruneFn
|
|
168
|
+
* @returns {{ ok: boolean, pruned: string[], stderr?: string }}
|
|
169
|
+
*/
|
|
155
170
|
/* node:coverage ignore next */
|
|
156
171
|
export function pruneRemoteTracking(cwd, remoteName, parsePruneFn) {
|
|
157
|
-
const res = gitSpawn(cwd, 'fetch', '--prune',
|
|
172
|
+
const res = gitSpawn(cwd, 'fetch', '--prune', remoteName);
|
|
158
173
|
if (res.status !== 0) return { ok: false, pruned: [], stderr: res.stderr };
|
|
159
174
|
return { ok: true, pruned: parsePruneFn(res.stderr, remoteName) };
|
|
160
175
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* single-story-close/failed-terminal.js — the `failed` terminal a close
|
|
3
|
+
* emits when a phase crashes, and the gate reconstruction it carries.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `single-story-close.js` so the CLI entry stays an entry: it
|
|
6
|
+
* parses args, dispatches the runner, and maps a terminal onto an exit code.
|
|
7
|
+
* The reasoning about which gates had run by the time a phase died belongs
|
|
8
|
+
* with the envelope it feeds, not in the file that owns process lifetime.
|
|
9
|
+
*
|
|
10
|
+
* The runner deliberately throws rather than returning a failure (a red gate
|
|
11
|
+
* must not look like a return value), so without this the most common
|
|
12
|
+
* non-happy ending — a failing close-validation gate — would emit **no
|
|
13
|
+
* envelope at all**, exiting 1 with only a stderr line while the workflow
|
|
14
|
+
* docs promise the agent a `failed` envelope naming the phase.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Logger } from '../../Logger.js';
|
|
18
|
+
import {
|
|
19
|
+
buildTerminalEnvelope,
|
|
20
|
+
NEXT_COMMANDS,
|
|
21
|
+
} from '../story-deliver-terminal.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The close pipeline's phase order, as `setPhase` walks it. Only used to
|
|
25
|
+
* decide whether a gate had already run when a later phase died.
|
|
26
|
+
*/
|
|
27
|
+
const PHASE_ORDER = Object.freeze([
|
|
28
|
+
'init',
|
|
29
|
+
'wrong-tree-guard',
|
|
30
|
+
'close-validation',
|
|
31
|
+
'base-sync',
|
|
32
|
+
'push',
|
|
33
|
+
'pull-request',
|
|
34
|
+
'code-review',
|
|
35
|
+
'auto-merge',
|
|
36
|
+
'confirm-merge',
|
|
37
|
+
'post-land',
|
|
38
|
+
'done',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Each reported gate and the pipeline phase that decides it. */
|
|
42
|
+
const GATE_PHASES = Object.freeze([
|
|
43
|
+
['validation', 'close-validation'],
|
|
44
|
+
['baseSync', 'base-sync'],
|
|
45
|
+
['codeReview', 'code-review'],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Report every gate's outcome for a run that died at `phase`.
|
|
50
|
+
*
|
|
51
|
+
* The schema's contract: "A gate the run skipped … reports `skipped` rather
|
|
52
|
+
* than being omitted, so a missing gate is never mistaken for a passing one."
|
|
53
|
+
* The previous shape named only the gate that died and omitted the rest
|
|
54
|
+
* entirely — exactly the ambiguity the contract forbids.
|
|
55
|
+
*
|
|
56
|
+
* Reconstructed from the phase order, which is sound because the pipeline is
|
|
57
|
+
* strictly sequential: reaching phase N means every gate before it completed.
|
|
58
|
+
* A gate whose phase the run never reached is `skipped`; one the operator
|
|
59
|
+
* turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
|
|
60
|
+
* not pass — it never ran).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} phase The phase the run died in.
|
|
63
|
+
* @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
|
|
64
|
+
* @returns {Record<string, 'passed'|'failed'|'skipped'>}
|
|
65
|
+
*/
|
|
66
|
+
export function gatesForFailedPhase(phase, args = {}) {
|
|
67
|
+
const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
|
|
68
|
+
const failedAt = PHASE_ORDER.indexOf(phase);
|
|
69
|
+
const gates = {};
|
|
70
|
+
for (const [gate, gatePhase] of GATE_PHASES) {
|
|
71
|
+
const at = PHASE_ORDER.indexOf(gatePhase);
|
|
72
|
+
if (gatePhase === phase) gates[gate] = 'failed';
|
|
73
|
+
else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
|
|
74
|
+
else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
|
|
75
|
+
}
|
|
76
|
+
return gates;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the `failed` terminal for a phase that crashed. Every close
|
|
81
|
+
* invocation emits exactly one envelope; this is the path that keeps that
|
|
82
|
+
* true when a phase dies.
|
|
83
|
+
*
|
|
84
|
+
* `err.closePhase` is tagged by the runner's phase tracker.
|
|
85
|
+
*
|
|
86
|
+
* **Never throws.** This runs on the path that already has one failure in
|
|
87
|
+
* hand, so a second failure here must not REPLACE the first: an
|
|
88
|
+
* envelope-build error surfacing as the run's cause sends the operator to
|
|
89
|
+
* diagnose the wrong thing entirely — a close whose PR had already merged
|
|
90
|
+
* once reported a schema `ENOENT` as its fatal error, because the worktree
|
|
91
|
+
* holding the script had been reaped mid-run. On failure this returns null
|
|
92
|
+
* and the caller rethrows the original.
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} err
|
|
95
|
+
* @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
|
|
96
|
+
* Parsed CLI args — the story id the envelope reports on, plus the skip
|
|
97
|
+
* flags `gatesForFailedPhase` needs.
|
|
98
|
+
* @returns {object|null} A validated envelope, or null when even the story id
|
|
99
|
+
* is unknown (a usage error — there is nothing to report an envelope about)
|
|
100
|
+
* or the envelope itself could not be assembled.
|
|
101
|
+
*/
|
|
102
|
+
export function failedTerminalFor(err, args = {}) {
|
|
103
|
+
const phase = err?.closePhase ?? 'init';
|
|
104
|
+
const storyId = Number(args.storyId);
|
|
105
|
+
if (!Number.isInteger(storyId) || storyId <= 0) return null;
|
|
106
|
+
try {
|
|
107
|
+
return buildTerminalEnvelope({
|
|
108
|
+
storyId,
|
|
109
|
+
status: 'failed',
|
|
110
|
+
phase,
|
|
111
|
+
gates: gatesForFailedPhase(phase, args),
|
|
112
|
+
failure: { reason: String(err?.message ?? err) },
|
|
113
|
+
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
114
|
+
elapsedSeconds: 0,
|
|
115
|
+
});
|
|
116
|
+
} catch (buildErr) {
|
|
117
|
+
Logger.error(
|
|
118
|
+
`[single-story-close] ⚠️ Could not assemble the failed terminal envelope: ${buildErr?.message ?? buildErr}. Reporting the original failure instead.`,
|
|
119
|
+
);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
* needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
|
|
34
34
|
* live streaming.
|
|
35
35
|
*
|
|
36
|
+
* Projection advisories (Story #4776). `baseBranch`, `storyBranch` and the
|
|
37
|
+
* resolved `config` are forwarded to `runCloseValidation` so its projection
|
|
38
|
+
* phase can run. They surface, after the gates pass, which committed
|
|
39
|
+
* baseline rows the post-merge tree would breach and the exact
|
|
40
|
+
* `*:update` + `baseline-refresh:` remedy — advisory only, so the close
|
|
41
|
+
* verdict is unchanged.
|
|
42
|
+
*
|
|
36
43
|
* `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
|
|
37
44
|
* are accepted as injected dependencies so the parent CLI's cache-busted
|
|
38
45
|
* bindings win in tests that mock the upstream module URLs.
|
|
@@ -154,6 +161,13 @@ export async function runCloseValidationPhase({
|
|
|
154
161
|
// epicId; the standalone flag routes the cache to
|
|
155
162
|
// temp/standalone/stories/story-<id>/validation-evidence.json.
|
|
156
163
|
standalone: true,
|
|
164
|
+
// Story #4776 — the branch pair and resolved config the advisory
|
|
165
|
+
// projections need. Without them the runner skips the projection
|
|
166
|
+
// phase entirely, which is the correct behaviour for resume/legacy
|
|
167
|
+
// callers that have no story branch to diff.
|
|
168
|
+
baseBranch,
|
|
169
|
+
storyBranch,
|
|
170
|
+
config,
|
|
157
171
|
});
|
|
158
172
|
} finally {
|
|
159
173
|
// Story #4766 — gate lines are buffered to an async stream so the drain
|