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.
Files changed (57) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/schemas/agentrc.schema.json +6 -0
  4. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  5. package/.agents/schemas/baselines/crap.schema.json +4 -0
  6. package/.agents/scripts/acceptance-eval.js +52 -12
  7. package/.agents/scripts/audit-to-stories.js +92 -25
  8. package/.agents/scripts/boot-sweep.js +28 -6
  9. package/.agents/scripts/check-baseline-drift.js +138 -0
  10. package/.agents/scripts/coverage-capture.js +74 -25
  11. package/.agents/scripts/deliver-recover.js +45 -18
  12. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  13. package/.agents/scripts/generate-lens-checklists.js +81 -30
  14. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  15. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  16. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  17. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  18. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  19. package/.agents/scripts/lib/baselines/reader.js +12 -1
  20. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  21. package/.agents/scripts/lib/baselines/writer.js +10 -0
  22. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  23. package/.agents/scripts/lib/cli-utils.js +48 -13
  24. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  25. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  26. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  27. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  28. package/.agents/scripts/lib/config/quality.js +40 -0
  29. package/.agents/scripts/lib/coverage-utils.js +92 -9
  30. package/.agents/scripts/lib/crap-engine.js +113 -23
  31. package/.agents/scripts/lib/crap-utils.js +159 -93
  32. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  33. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  34. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  35. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  36. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  37. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  38. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
  40. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  41. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  42. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  43. package/.agents/scripts/lib/stdio-flush.js +71 -0
  44. package/.agents/scripts/lib/transpile.js +133 -6
  45. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  46. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  47. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  48. package/.agents/scripts/nav-registry-diff.js +30 -8
  49. package/.agents/scripts/plan-run-epilogue.js +27 -11
  50. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  51. package/.agents/scripts/single-story-close.js +9 -92
  52. package/.agents/scripts/update-crap-baseline.js +13 -0
  53. package/README.md +14 -6
  54. package/docs/CHANGELOG.md +24 -0
  55. package/lib/cli/version-helpers.js +7 -0
  56. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  57. 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
- const rawFindings = await phase(ORCHESTRATION_PHASES.ANALYZE, async () =>
154
- Promise.all(
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 inclusion.
167
- const crossChecked = await phase(ORCHESTRATION_PHASES.CROSS_CHECK, async () =>
168
- Promise.all(
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: report } = await phase(
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 injected so
138
- * each caller can bind its own cwd (and tests can mock them).
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', '--quiet', remoteName);
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