@nathapp/nax 0.77.2 → 0.78.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.
@@ -23,10 +23,74 @@ const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
23
23
  /** How much gate output to quote in the body before it stops being a commit message. */
24
24
  const MAX_GATE_OUTPUT_LINES = 20;
25
25
 
26
+ /**
27
+ * Markers a test runner uses to introduce a failing case, worst-supported-first.
28
+ *
29
+ * A heuristic, deliberately: nax orchestrates polyglot repos, so this cannot be
30
+ * one runner's format. Each entry is the literal token that precedes the test's
31
+ * name — bun/jest `(fail)`, go `--- FAIL:`, pytest `FAILED`, and the tick-style
32
+ * reporters. Nothing downstream depends on a match; a miss just falls back to
33
+ * the output tail, which is what shipped before.
34
+ */
35
+ const FAILURE_MARKERS = ["(fail)", "--- FAIL:", "FAILED ", "FAIL ", "✗ ", "× "];
36
+
37
+ /** How many failing test names to name before the message stops being a commit message. */
38
+ const MAX_NAMED_FAILURES = 10;
39
+
40
+ /**
41
+ * Strip machine-local filesystem layout out of text bound for shipped history.
42
+ *
43
+ * Two passes, because the two cases differ: a path under the repo is meaningful
44
+ * once made relative, while a path outside it is noise no reader of the commit
45
+ * can act on. The home-directory pattern catches what remains — runner output
46
+ * routinely quotes absolute paths from outside the repo (caches, toolchains).
47
+ */
48
+ function redactPaths(text: string, workdir?: string): string {
49
+ const withoutRepo = workdir ? text.split(`${workdir}/`).join("") : text;
50
+ return withoutRepo.replace(/(?:\/Users\/|\/home\/)[^/\s)]+\//g, "~/");
51
+ }
52
+
53
+ /**
54
+ * The names of the tests that actually failed, in output order.
55
+ *
56
+ * This is the whole point of the change: the body used to be the last 20 lines
57
+ * of runner stdout, and a suite whose *passing* tests write to stderr pushes the
58
+ * real failure out of that window — so the commit named a stack trace from a
59
+ * test that passed (#1506).
60
+ */
61
+ function failingTestNames(output: string): string[] {
62
+ const names: string[] = [];
63
+ for (const line of output.split("\n")) {
64
+ const trimmed = line.trim();
65
+ const marker = FAILURE_MARKERS.find((m) => trimmed.startsWith(m));
66
+ if (!marker) continue;
67
+ // Drop bun's trailing `[0.12ms]` timing — it is noise in a commit message
68
+ // and makes otherwise-identical messages differ between runs.
69
+ const name = trimmed
70
+ .slice(marker.length)
71
+ .replace(/\s*\[[\d.]+m?s\]$/, "")
72
+ .trim();
73
+ if (name) names.push(name);
74
+ }
75
+ // Say so when the list is cut short. A bare list of ten reads as "ten tests
76
+ // failed", and a reader who acts on that count is acting on a truncation.
77
+ if (names.length > MAX_NAMED_FAILURES) {
78
+ const dropped = names.length - MAX_NAMED_FAILURES;
79
+ return [...names.slice(0, MAX_NAMED_FAILURES), `...and ${dropped} more failing test(s)`];
80
+ }
81
+ return names;
82
+ }
83
+
26
84
  interface MessageCtx {
27
85
  outputs: Record<string, unknown>;
28
86
  }
29
87
 
88
+ /** Options carrying what the message builder cannot read off `ctx.outputs`. */
89
+ interface MessageOptions {
90
+ /** Absolute repo root, used to rewrite quoted paths as repo-relative. */
91
+ workdir?: string;
92
+ }
93
+
30
94
  interface PhaseOutputs {
31
95
  findings?: Finding[];
32
96
  failing?: string[];
@@ -88,20 +152,30 @@ function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
88
152
  return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
89
153
  }
90
154
 
91
- function bodyFor(phase: FinishPhase, ctx: MessageCtx): string[] {
155
+ /**
156
+ * What to quote from a runner's output: the failing test names if they can be
157
+ * identified, otherwise the tail, as before.
158
+ *
159
+ * Never both. Naming the failures *and* pasting the tail reproduces the noise
160
+ * this replaces, and the tail is the weaker signal whenever the names exist.
161
+ */
162
+ function runnerEvidence(output: string, opts: MessageOptions): string {
163
+ const clean = redactPaths(output, opts.workdir).trim();
164
+ const names = failingTestNames(clean);
165
+ if (names.length > 0) return ["Failed tests:", ...names.map((n) => `- ${n}`)].join("\n");
166
+ return clean.split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
167
+ }
168
+
169
+ function bodyFor(phase: FinishPhase, ctx: MessageCtx, opts: MessageOptions): string[] {
92
170
  if (phase === "gate") {
93
171
  const gate = outputsFor(ctx, "quality_gates");
94
172
  const failing = gate.failing ?? [];
95
- const tail = (gate.output ?? "").trim().split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
96
- return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(tail ? [tail] : [])];
173
+ const evidence = runnerEvidence(gate.output ?? "", opts);
174
+ return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(evidence ? [evidence] : [])];
97
175
  }
98
176
  if (phase === "acceptance") {
99
- const tail = (outputsFor(ctx, "acceptance").output ?? "")
100
- .trim()
101
- .split("\n")
102
- .slice(-MAX_GATE_OUTPUT_LINES)
103
- .join("\n");
104
- return tail ? [tail] : [];
177
+ const evidence = runnerEvidence(outputsFor(ctx, "acceptance").output ?? "", opts);
178
+ return evidence ? [evidence] : [];
105
179
  }
106
180
  const findings = findingsFor(ctx, phase);
107
181
  if (findings.length === 0) return [];
@@ -129,8 +203,13 @@ function phaseLabel(phase: FinishPhase): string {
129
203
  * described is still a commit that must happen — failing here would strand the
130
204
  * fix uncommitted and reintroduce the stale-diff bug (#1397).
131
205
  */
132
- export function buildFixCommitMessage(phase: FinishPhase, feature: string, ctx: MessageCtx): string {
206
+ export function buildFixCommitMessage(
207
+ phase: FinishPhase,
208
+ feature: string,
209
+ ctx: MessageCtx,
210
+ opts: MessageOptions = {},
211
+ ): string {
133
212
  const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
134
- const body = bodyFor(phase, ctx);
213
+ const body = bodyFor(phase, ctx, opts);
135
214
  return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
136
215
  }
@@ -17,6 +17,7 @@
17
17
  * *outcome*, so the currently-executing node is never in this list — which is
18
18
  * what lets `incrementalSince` find the *previous* review rather than itself.
19
19
  */
20
+ import type { AcceptanceStatus } from "./steps/context";
20
21
  import type { AcceptanceGroup, Finding, FinishInput, FinishPhase, ReviewVerdict } from "./types";
21
22
 
22
23
  /** Minimal shapes so each reader takes only the part of the context it reads. */
@@ -34,11 +35,13 @@ export interface LoadCtxOutput {
34
35
  base?: string;
35
36
  specPath?: string;
36
37
  groups?: AcceptanceGroup[];
37
- /** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
38
- acceptanceStatus?: string;
38
+ /** `nax features resolve`'s acceptance status, narrowed at `resolveFeature`. */
39
+ acceptanceStatus?: AcceptanceStatus;
39
40
  /** Test-file regex sources from `nax features resolve`; empty = cannot classify. */
40
41
  testFileRegex?: string[];
41
42
  route?: string;
43
+ /** Set only when `route` is `escalate` — see `preflight`. */
44
+ reason?: string;
42
45
  }
43
46
 
44
47
  export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
@@ -111,15 +114,32 @@ export function findingsOf(ctx: OutputsCtx, phase: FinishPhase): Finding[] {
111
114
  * would have discarded the earlier `shaBefore` and silently under-scoped the
112
115
  * review.
113
116
  *
117
+ * Only commit steps that actually **committed** anchor the window. A fix node
118
+ * that edited nothing still records a `commit_*` step, and its `shaBefore` is
119
+ * the current HEAD — so scoping to it asks the reviewer for `HEAD..HEAD`, an
120
+ * empty diff, while the prompt tells it the prior findings "have since been
121
+ * fixed and committed". It returns clean, `route_*` sends the flow onward, and
122
+ * the findings ship unfixed. That leaves the loop through the green door, so
123
+ * `MAX_FIX_ATTEMPTS` never catches it. A no-op round therefore either yields
124
+ * the window to a later real commit, or falls back to a full review.
125
+ *
126
+ * Rounds journalled before `committed` existed carry no such field; `!== false`
127
+ * keeps replaying them on the previous behaviour rather than widening every
128
+ * resumed review to the whole branch.
129
+ *
114
130
  * Returns null — a full review — when there is no prior review of this phase
115
- * (round 1), no commit since it (nothing new to look at), or the commit step
116
- * recorded no `shaBefore`.
131
+ * (round 1), no commit landed since it (nothing new to look at), or the commit
132
+ * step recorded no `shaBefore`.
117
133
  */
118
134
  export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "quality"): string | null {
119
135
  const steps = ctx.state.steps ?? [];
120
136
  const lastReview = steps.map((s) => s.nodeId).lastIndexOf(`review_${phase}`);
121
137
  if (lastReview < 0) return null;
122
- const firstCommit = steps.slice(lastReview + 1).find((s) => s.nodeId.startsWith("commit_"));
138
+ const firstCommit = steps
139
+ .slice(lastReview + 1)
140
+ .find(
141
+ (s) => s.nodeId.startsWith("commit_") && (s.output as { committed?: boolean } | undefined)?.committed !== false,
142
+ );
123
143
  if (!firstCommit) return null;
124
144
  return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
125
145
  }
@@ -34,7 +34,14 @@
34
34
  * - `commit_gate` re-enters `review_quality` when its fix touched non-test code
35
35
  * — the gate loop was previously the one editing loop whose output only ever
36
36
  * faced mechanical checks. A test-only fix skips the re-review by explicit
37
- * cost tradeoff; see `gateCommitRoute` for why that is a known hole.
37
+ * cost tradeoff; see `gateCommitRoute` for why that is a known hole. The skip
38
+ * records `review-skipped`, so it is visible in the audit.
39
+ * - `load_ctx` and `open_pr` both route to `escalate` rather than throwing on
40
+ * their own failures. acpx has no error edge, so a throw ends the run with no
41
+ * result file for the plugin to read or notify from — the one outcome that
42
+ * must always be reported is "a human is needed" (#1399). `open_pr` matters
43
+ * most: it is reached only after every gate is green, so a failed push or
44
+ * forge call there discards a completed, verified run.
38
45
  * - Every `commit_*` node appends its round to the finish-audit trail as it
39
46
  * happens, rather than a terminal node reconstructing them from
40
47
  * `ctx.state.steps`. Appending live is what makes the trail survive a flow
@@ -48,8 +55,10 @@ import { narrativePrompt, parseNarrativeNode } from "./narrative";
48
55
  import { buildReviewPrompt, fixPrompt } from "./review-prompts";
49
56
  import {
50
57
  _contextDeps,
58
+ acceptanceGateNode,
51
59
  amendPrBodyNode,
52
60
  appendRound,
61
+ buildCommitRound,
53
62
  buildEscalationComment,
54
63
  commitAndPush,
55
64
  commitFixes,
@@ -57,20 +66,19 @@ import {
57
66
  detectForge,
58
67
  filesInCommit,
59
68
  loadFinishPrContext,
60
- loadQualityCommands,
61
69
  openOrPromotePr,
62
70
  partitionTestFiles,
63
71
  postEscalation,
64
72
  preflight,
73
+ qualityGatesNode,
65
74
  resolveFeature,
66
- runAcceptanceGate,
67
- runQualityGates,
75
+ routeReviewAndRecord,
68
76
  writeResult,
69
77
  } from "./steps";
70
78
  import type { Forge } from "./steps/forge";
71
79
  import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
72
80
  import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
73
- import { MAX_FIX_ATTEMPTS, parseFixVerdict, parseReviewVerdict, repromptCount, routeReview } from "./verdict";
81
+ import { parseFixVerdict, parseReviewVerdict, repromptCount } from "./verdict";
74
82
 
75
83
  /**
76
84
  * Disabled only on an explicit "0". An unset variable means enabled, so a flow
@@ -90,59 +98,6 @@ export const _openPrDeps = {
90
98
  buildFinishBody,
91
99
  };
92
100
 
93
- /**
94
- * Re-run the acceptance gate, routing on the shared fix-cap rules.
95
- *
96
- * "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
97
- * unconfigured repo. `nax features resolve` reports `groups: []` for BOTH
98
- * `no-prd` and `disabled`, and reports `exists: false` for a group whose test
99
- * was expected at its canonical path but never generated. Treating all of those
100
- * as green let the flow open a ready PR having verified nothing about the
101
- * feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
102
- * — skips cleanly.
103
- */
104
- async function acceptanceGateNode(ctx: {
105
- input: unknown;
106
- outputs: unknown;
107
- state: { steps: { nodeId: string }[] };
108
- }): Promise<{ route: string; reason?: string; output: string }> {
109
- const i = inputOf(ctx);
110
- const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
111
- if (acceptanceStatus === "disabled") {
112
- return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
113
- }
114
- if (acceptanceStatus === "no-prd") {
115
- return {
116
- route: "escalate",
117
- reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
118
- output: "[acceptance] no prd.json resolved — acceptance targets unknown",
119
- };
120
- }
121
-
122
- const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
123
- if (r.passed) {
124
- // A real failure below routes to the fix loop, which is more actionable;
125
- // the coverage hole is only reported once the runnable groups are green.
126
- if (r.missing.length > 0) {
127
- return {
128
- route: "escalate",
129
- reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
130
- output: r.output,
131
- };
132
- }
133
- return { route: "proceed", output: r.output };
134
- }
135
- const attempts = fixAttemptCount(ctx, "fix_acceptance");
136
- if (attempts >= MAX_FIX_ATTEMPTS) {
137
- return {
138
- route: "escalate",
139
- reason: `Acceptance tests still failing after ${attempts} fix attempts.`,
140
- output: r.output,
141
- };
142
- }
143
- return { route: "fix", output: r.output };
144
- }
145
-
146
101
  /**
147
102
  * Route for `commit_gate`, whose successor depends on what the fix touched.
148
103
  *
@@ -153,7 +108,7 @@ async function acceptanceGateNode(ctx: {
153
108
  * The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
154
109
  * test-only — 8 copy-pasted stubs across 3 test files — so this route would
155
110
  * not have caught it. Widen it here if test-quality regressions start
156
- * shipping.
111
+ * shipping — the audit's `review-skipped` rounds are the evidence.
157
112
  * - `changed` — production code was touched, or the paths could not be
158
113
  * classified at all. "Cannot classify" reviews rather than skips.
159
114
  */
@@ -202,30 +157,32 @@ function commitFixNode(phase: FinishPhase) {
202
157
  // PR opens, and a hook failure here would kill the flow mid-loop.
203
158
  const { committed, shaBefore, shaAfter } = await commitFixes(
204
159
  i.workdir,
205
- buildFixCommitMessage(phase, i.feature, messageCtx),
160
+ buildFixCommitMessage(phase, i.feature, messageCtx, { workdir: i.workdir }),
206
161
  { skipHooks: true },
207
162
  );
208
- await appendRound(i, {
209
- ts: new Date().toISOString(),
210
- phase,
211
- attempt: fixAttemptCount(ctx, `fix_${phase}`),
212
- committed,
213
- findings: findingsOf(ctx, phase),
214
- ...(phase === "gate" ? { failing: gateOutputs(ctx).failing ?? [] } : {}),
215
- // Carry `shaAfter` onto committed rounds only: a no-op round has no
216
- // commit, so no SHA to record — keeping the field absent (rather than
217
- // null/undefined) lets the result-file reader distinguish "no commit"
218
- // from "record lost".
219
- ...(committed && shaAfter ? { sha: shaAfter } : {}),
220
- });
221
- // Only `commit_gate` routes on this; the other phases have unconditional
222
- // edges and ignore it.
163
+ // Routed BEFORE the round is recorded: `buildCommitRound` needs the
164
+ // successor to tell an owed-but-skipped re-review from a phase that never
165
+ // had a reviewer. Only `commit_gate` routes on this; the other phases have
166
+ // unconditional edges and ignore it.
223
167
  const route =
224
168
  phase === "gate"
225
169
  ? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
226
170
  : committed
227
171
  ? "changed"
228
172
  : "unchanged";
173
+ await appendRound(
174
+ i,
175
+ buildCommitRound({
176
+ phase,
177
+ attempt: fixAttemptCount(ctx, `fix_${phase}`),
178
+ committed,
179
+ route,
180
+ findings: findingsOf(ctx, phase),
181
+ failing: phase === "gate" ? (gateOutputs(ctx).failing ?? []) : undefined,
182
+ shaAfter,
183
+ now: new Date().toISOString(),
184
+ }),
185
+ );
229
186
  return { committed, route, shaBefore, shaAfter };
230
187
  },
231
188
  };
@@ -255,6 +212,7 @@ export default defineFlow({
255
212
  testFileRegex: resolution.testFileRegex,
256
213
  commitsAhead: pf.commitsAhead,
257
214
  route: pf.route,
215
+ ...(pf.reason ? { reason: pf.reason } : {}),
258
216
  };
259
217
  },
260
218
  },
@@ -286,7 +244,7 @@ export default defineFlow({
286
244
  },
287
245
  route_spec: {
288
246
  nodeType: "compute",
289
- run: (ctx) => routeReview(ctx, "spec"),
247
+ run: (ctx) => routeReviewAndRecord(ctx, "spec"),
290
248
  },
291
249
  fix_spec: {
292
250
  nodeType: "acp",
@@ -312,7 +270,7 @@ export default defineFlow({
312
270
  },
313
271
  route_quality: {
314
272
  nodeType: "compute",
315
- run: (ctx) => routeReview(ctx, "quality"),
273
+ run: (ctx) => routeReviewAndRecord(ctx, "quality"),
316
274
  },
317
275
  fix_quality: {
318
276
  nodeType: "acp",
@@ -328,75 +286,7 @@ export default defineFlow({
328
286
  commit_gate: commitFixNode("gate"),
329
287
  quality_gates: {
330
288
  nodeType: "action",
331
- async run(ctx) {
332
- const i = inputOf(ctx);
333
-
334
- // Acceptance is gate zero here, not just at the `acceptance` node.
335
- // Both fix loops that run after it — quality review and this gate —
336
- // edit code, and the repo-root `test` command does not cover the
337
- // feature's acceptance tests: they live under `<pkg>/.nax/features/<f>/`
338
- // and usually need their own runner config. Re-running them here is what
339
- // makes "nothing reaches open_pr without the feature's own contract
340
- // passing against the tree as it will ship" true on every path (#1398).
341
- //
342
- // Unconditional, though the common green path re-runs a gate that
343
- // already passed: acceptance is the cheapest gate in the pipeline, and a
344
- // conditional skip derived from step history would be a check that can
345
- // be *wrong* — a silent false green, the failure mode this exists to
346
- // prevent.
347
- //
348
- // `missing` is deliberately ignored: groups are resolved once at
349
- // load_ctx, so a coverage hole was already escalated by the acceptance
350
- // node and cannot appear here.
351
- const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
352
- timeoutMs: i.timeouts?.acceptanceMs,
353
- });
354
- if (!acc.passed) {
355
- // Short-circuit: the repo gates are re-run next round anyway, and
356
- // skipping them keeps this out of the "nothing configured" branch
357
- // below, which would otherwise misreport configured-but-skipped
358
- // commands as absent.
359
- const accAttempts = fixAttemptCount(ctx, "fix_gate");
360
- const failing = ["acceptance"];
361
- if (accAttempts >= MAX_FIX_ATTEMPTS) {
362
- return {
363
- route: "escalate",
364
- reason: `A later fix broke the feature's own contract: acceptance still failing after ${accAttempts} fix attempts.`,
365
- ran: [],
366
- failing,
367
- output: acc.output,
368
- };
369
- }
370
- return { route: "fix", ran: [], failing, output: acc.output };
371
- }
372
-
373
- const cmds = await loadQualityCommands(i.workdir);
374
- const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
375
- if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
376
- // Nothing configured is not a pass — escalate immediately rather than
377
- // open a "ready" PR having verified nothing. An LLM fix node cannot
378
- // invent the repo's build/test commands.
379
- if (r.ran.length === 0) {
380
- return {
381
- route: "escalate",
382
- reason: "No quality.commands configured in .nax/config.json — nax-finish verified nothing.",
383
- ran: r.ran,
384
- failing: r.failing,
385
- output: r.output,
386
- };
387
- }
388
- const attempts = fixAttemptCount(ctx, "fix_gate");
389
- if (attempts >= MAX_FIX_ATTEMPTS) {
390
- return {
391
- route: "escalate",
392
- reason: `Quality gates still failing after ${attempts} fix attempts (${r.failing.join(", ")}).`,
393
- ran: r.ran,
394
- failing: r.failing,
395
- output: r.output,
396
- };
397
- }
398
- return { route: "fix", ran: r.ran, failing: r.failing, output: r.output };
399
- },
289
+ run: qualityGatesNode,
400
290
  },
401
291
  open_pr: {
402
292
  nodeType: "action",
@@ -409,7 +299,24 @@ export default defineFlow({
409
299
  }
410
300
  // Every fix node edited the working tree; without this the PR would be
411
301
  // opened from a remote branch missing all of them.
412
- const sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
302
+ //
303
+ // Routed, not thrown. acpx has no error edge, so a throw here kills the
304
+ // flow — and this is the last node on the happy path, reached only once
305
+ // every gate is green and every fix has landed. It died before
306
+ // `writeResult`, so the plugin found no result file and notified
307
+ // nobody: the #1399 failure mode the `escalate` node was hardened
308
+ // against and this one was not. A protected branch, an expired token or
309
+ // a non-fast-forward push is exactly the kind of dead end `escalate`
310
+ // exists to report.
311
+ let sync: { committed: boolean };
312
+ try {
313
+ sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
314
+ } catch (error) {
315
+ return {
316
+ route: "escalate",
317
+ reason: `nax-finish could not push "${i.branch}", so no PR was opened: ${String(error)}`,
318
+ };
319
+ }
413
320
 
414
321
  const fallbackTitle = `nax-finish: ${i.feature}`;
415
322
  const fallbackBody = `Automated finish of \`${i.feature}\`.`;
@@ -436,7 +343,18 @@ export default defineFlow({
436
343
  body = fallbackBody;
437
344
  }
438
345
 
439
- const r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
346
+ // Same reasoning as the push above: a forge that refuses to create or
347
+ // promote (rate limit, revoked token, unrecognised remote) must reach a
348
+ // human through `escalate`, not take the flow down silently.
349
+ let r: { status: "opened" | "promoted" | "already-ready"; url?: string };
350
+ try {
351
+ r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
352
+ } catch (error) {
353
+ return {
354
+ route: "escalate",
355
+ reason: `nax-finish could not open or promote the PR for "${i.branch}": ${String(error)}`,
356
+ };
357
+ }
440
358
  await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
441
359
  // The PR now exists with the mechanical narrative already in place.
442
360
  // Anything the narrative node does from here is an improvement on a
@@ -473,12 +391,14 @@ export default defineFlow({
473
391
  : routed.route_quality?.route === "escalate"
474
392
  ? routed.route_quality
475
393
  : undefined;
476
- const loopExhausted =
477
- outs.acceptance?.route === "escalate"
478
- ? outs.acceptance
479
- : outs.quality_gates?.route === "escalate"
480
- ? outs.quality_gates
481
- : undefined;
394
+ // Ordered by how far down the graph the node sits, so the *last* thing
395
+ // that gave up names the reason. `load_ctx` and `open_pr` are here
396
+ // because both can now route here rather than throw — a base ref that
397
+ // does not resolve, and a push or forge call that failed after every
398
+ // gate was green.
399
+ const loopExhausted = [outs.open_pr, outs.quality_gates, outs.acceptance, outs.load_ctx].find(
400
+ (o) => o?.route === "escalate",
401
+ );
482
402
  const reason =
483
403
  verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
484
404
 
@@ -526,7 +446,15 @@ export default defineFlow({
526
446
  },
527
447
  },
528
448
  edges: [
529
- { from: "load_ctx", switch: { on: "$.route", cases: { proceed: "acceptance", "nothing-to-finish": "open_pr" } } },
449
+ {
450
+ from: "load_ctx",
451
+ switch: {
452
+ on: "$.route",
453
+ // `escalate`: the branch could not be measured against its base at all,
454
+ // so neither "proceed" nor "nothing-to-finish" would be a true claim.
455
+ cases: { proceed: "acceptance", "nothing-to-finish": "open_pr", escalate: "escalate" },
456
+ },
457
+ },
530
458
  {
531
459
  from: "acceptance",
532
460
  switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
@@ -586,7 +514,10 @@ export default defineFlow({
586
514
  },
587
515
  // The narrative runs only once the PR exists. acpx has no error edge, so an
588
516
  // acp node before `open_pr` would be able to fail the flow and cost the PR.
589
- { from: "open_pr", switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done" } } },
517
+ {
518
+ from: "open_pr",
519
+ switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done", escalate: "escalate" } },
520
+ },
590
521
  { from: "narrative", to: "amend_body" },
591
522
  ],
592
523
  });