@nathapp/nax 0.80.1 → 0.81.0-canary.2

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.
@@ -1,566 +0,0 @@
1
- /**
2
- * nax-finish: autonomous acpx flow driving a completed feature branch to a
3
- * ready PR (or an escalation) — acceptance gate, spec review, quality
4
- * review, quality gates, each iterated with an LLM fix-and-reverify loop.
5
- *
6
- * Reviewer agent profiles (review_spec / review_quality) are read from
7
- * NAX_FINISH_SPEC_PROFILE / NAX_FINISH_QUALITY_PROFILE at module load time.
8
- * The post-run plugin that invokes `acpx flow run` sets these env vars from
9
- * `finish.autoFlow.reviewers.{spec,quality}` config before spawning, since
10
- * this flow module reloads fresh on every `acpx flow run` invocation.
11
- * Unset → both fall back to acpx's `--default-agent`.
12
- *
13
- * This module is loaded by acpx, from wherever `flows/` is installed, with the
14
- * user's repo as cwd — so it never imports from nax's `src/` (see ./errors.ts).
15
- *
16
- * Graph shape (deviations from the design doc are deliberate and noted):
17
- * - `load_ctx` is an `action`, not a `compute`: it shells git + `nax features
18
- * resolve` once, and its output feeds both the review prompts (specPath) and
19
- * the acceptance gate (groups), so nothing resolves the feature twice.
20
- * - Review fixes loop: `review_* → route_* → fix_* → commit_* → (re-run
21
- * acceptance | re-review) → review_*` until the reviewer comes back clean or
22
- * the fix cap trips. A single-shot fix left the fixed diff unverified.
23
- * - Every `fix_*` node is followed by a `commit_*` node. The reviewers read
24
- * `git diff <base>...HEAD`, so an uncommitted fix is invisible to the
25
- * re-review: the loop re-reported findings that were already fixed and always
26
- * escalated at the cap (issue #1397).
27
- * - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
28
- * enforced deterministically rather than trusting the model's own route.
29
- * - `quality_gates` re-runs the feature's acceptance tests before the repo's
30
- * own commands. The quality-review and gate fix loops both edit code after
31
- * the `acceptance` node last passed, and the repo-root `test` command does
32
- * not cover per-feature acceptance tests — so without this a fix could break
33
- * the contract the first gate proved and still ship.
34
- * - `commit_gate` re-enters `review_quality` whenever its fix committed — the
35
- * gate loop was otherwise the one editing loop whose output faced only
36
- * mechanical checks, which a fix that degrades the tests it repairs will
37
- * satisfy. A test-only fix used to skip the re-review as a cost tradeoff;
38
- * #1510 closed that hole. See `gateCommitRoute`.
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.
45
- * - Every `commit_*` node appends its round to the finish-audit trail as it
46
- * happens, rather than a terminal node reconstructing them from
47
- * `ctx.state.steps`. Appending live is what makes the trail survive a flow
48
- * that is killed or times out — no terminal node runs on that path, and a
49
- * crashed finish is exactly when the record of what it changed matters most.
50
- */
51
- import { defineFlow } from "acpx/flows";
52
- import { buildFixCommitMessage } from "./commit-message";
53
- import {
54
- findingsOf,
55
- fixAttemptCount,
56
- gateOutputs,
57
- incrementalSince,
58
- inputOf,
59
- loadCtxOf,
60
- reviewGapsOf,
61
- } from "./flow-ctx";
62
- import { narrativePrompt, parseNarrativeNode } from "./narrative";
63
- import { buildReviewPrompt, fixPrompt } from "./review-prompts";
64
- import {
65
- _contextDeps,
66
- acceptanceGateNode,
67
- amendPrBodyNode,
68
- appendRound,
69
- buildCommitRound,
70
- buildEscalationComment,
71
- commitAndPush,
72
- commitFixes,
73
- detectBaseBranch,
74
- detectForge,
75
- filesInCommit,
76
- loadFinishPrContext,
77
- openOrPromotePr,
78
- partitionTestFiles,
79
- postEscalation,
80
- preflight,
81
- qualityGatesNode,
82
- resolveFeature,
83
- routeReviewAndRecord,
84
- validateDispositions,
85
- writeResult,
86
- } from "./steps";
87
- import type { Forge } from "./steps/forge";
88
- import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
89
- import type { FindingDisposition, FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
90
- import { parseFixVerdict, parseReviewVerdict } from "./verdict";
91
-
92
- /**
93
- * Disabled only on an explicit "0". An unset variable means enabled, so a flow
94
- * invoked directly by `acpx flow run` — outside the plugin that sets the env —
95
- * still writes the narrative.
96
- */
97
- const NARRATIVE_ENABLED = process.env.NAX_FINISH_NARRATIVE !== "0";
98
-
99
- /**
100
- * Injectable seam for the `open_pr` node's title/body assembly — tests stub
101
- * these to control the fallback-vs-built-metadata paths without a real PRD or
102
- * git checkout.
103
- */
104
- export const _openPrDeps = {
105
- loadFinishPrContext,
106
- buildFinishTitle,
107
- buildFinishBody,
108
- };
109
-
110
- /**
111
- * Route for `commit_gate`, whose successor depends on what the fix touched.
112
- *
113
- * - `unchanged` — nothing committed; no new diff, so nothing to review.
114
- * - `tests-only` — every touched path matched the repo's test-file patterns.
115
- * - `changed` — production code was touched, or the paths could not be
116
- * classified at all. "Cannot classify" reviews rather than skips.
117
- *
118
- * `tests-only` and `changed` both re-enter `review_quality` (#1510). They used
119
- * to diverge: `tests-only` skipped the re-review as a cost tradeoff, on the
120
- * reasoning that a gate fix is usually a mechanical test repair. That was a
121
- * real hole — the defect that motivated the re-entry was itself test-only, 8
122
- * copy-pasted stubs across 3 test files, so the skip would not have caught the
123
- * very thing it was built for. A gate fix can turn a red suite green by
124
- * degrading the tests it repairs, and `quality_gates` is satisfied by exactly
125
- * that; nothing else read that diff. The audit settled the cost side: across
126
- * every finish recorded, exactly one `gate` round has ever fired, so the skip
127
- * was saving a review that almost never runs.
128
- *
129
- * The classification is kept even though both routes now review. It is what a
130
- * cheaper test-quality-scoped reviewer would key off if gate rounds ever become
131
- * frequent enough for the full re-review to hurt.
132
- */
133
- async function gateCommitRoute(
134
- i: FinishInput,
135
- committed: boolean,
136
- shaAfter: string | null,
137
- testFileRegex: string[],
138
- ): Promise<string> {
139
- if (!committed) return "unchanged";
140
- // Committed, but HEAD did not resolve: the fix is real and unclassifiable, so
141
- // it must be reviewed. Folding this into the `!committed` branch would skip
142
- // the review for a change that actually landed — the one direction this
143
- // function must never fail in.
144
- if (!shaAfter) return "changed";
145
- const files = await filesInCommit(i.workdir, shaAfter);
146
- if (files.length === 0) return "changed";
147
- return partitionTestFiles(files, testFileRegex).nonTest.length > 0 ? "changed" : "tests-only";
148
- }
149
-
150
- /**
151
- * Build the `commit_<phase>` node that follows `fix_<phase>`.
152
- *
153
- * One node per phase rather than a single shared one because each returns to a
154
- * different successor, and acpx routes on the node id — a shared node would
155
- * need a switch reconstructing which fix ran from the step history.
156
- *
157
- * Also the audit seam: this is the only point in the graph where a round's
158
- * findings and its commit are both known. `ctx.outputs` keeps only the latest
159
- * output per node, so a round not recorded here is a round no terminal node
160
- * can reconstruct. `shaBefore` is recorded for the same reason — it is what the
161
- * next review of this phase diffs from (see `incrementalSince`).
162
- */
163
- function commitFixNode(phase: FinishPhase) {
164
- return {
165
- nodeType: "action" as const,
166
- async run(ctx: {
167
- input: unknown;
168
- outputs: unknown;
169
- state: { steps: { nodeId: string }[] };
170
- }): Promise<{ committed: boolean; route: string; shaBefore: string | null; shaAfter: string | null }> {
171
- const i = inputOf(ctx);
172
- const messageCtx = { outputs: ctx.outputs as Record<string, unknown> };
173
- // A rejection is only as good as its citation, so the path is checked the
174
- // same way a reviewer's touchpoints are. A missing file does not veto the
175
- // rejection — the fixer may have cited a line rather than a path, or moved
176
- // the file — it marks it, so the PR body (and the commit message below)
177
- // can say the waiver is unverified rather than silently presenting it as
178
- // evidenced. Resolved before the commit so the shipped commit message can
179
- // render a rejection the same way the PR body does, instead of `Fix: …`.
180
- const dispositions = await validateDispositions(
181
- i.workdir,
182
- (ctx.outputs as Record<string, { dispositions?: FindingDisposition[] } | undefined>)[`fix_${phase}`]
183
- ?.dispositions ?? [],
184
- );
185
- // skipHooks: an intermediate checkpoint must not be rejected by a repo's
186
- // pre-commit hook — quality_gates runs the repo's real gates before any
187
- // PR opens, and a hook failure here would kill the flow mid-loop.
188
- const { committed, shaBefore, shaAfter } = await commitFixes(
189
- i.workdir,
190
- buildFixCommitMessage(phase, i.feature, messageCtx, { workdir: i.workdir, dispositions }),
191
- { skipHooks: true },
192
- );
193
- // Routed BEFORE the round is recorded: `buildCommitRound` needs the
194
- // successor to tell an owed-but-skipped re-review from a phase that never
195
- // had a reviewer. Only `commit_gate` routes on this; the other phases have
196
- // unconditional edges and ignore it.
197
- const route =
198
- phase === "gate"
199
- ? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
200
- : committed
201
- ? "changed"
202
- : "unchanged";
203
- await appendRound(
204
- i,
205
- buildCommitRound({
206
- phase,
207
- attempt: fixAttemptCount(ctx, `fix_${phase}`),
208
- committed,
209
- route,
210
- findings: findingsOf(ctx, phase),
211
- failing: phase === "gate" ? (gateOutputs(ctx).failing ?? []) : undefined,
212
- shaAfter,
213
- now: new Date().toISOString(),
214
- dispositions,
215
- }),
216
- );
217
- return { committed, route, shaBefore, shaAfter };
218
- },
219
- };
220
- }
221
-
222
- export default defineFlow({
223
- name: "nax-finish",
224
- permissions: {
225
- requiredMode: "approve-all",
226
- requireExplicitGrant: true,
227
- reason: "This flow edits files, pushes commits, runs quality gates, comments on and opens/promotes PRs.",
228
- },
229
- startAt: "load_ctx",
230
- nodes: {
231
- load_ctx: {
232
- nodeType: "action",
233
- async run(ctx) {
234
- const i = inputOf(ctx);
235
- const base = await detectBaseBranch(i.workdir);
236
- const resolution = await resolveFeature(i.feature, i.workdir);
237
- const pf = await preflight(i.workdir, base);
238
- return {
239
- base,
240
- specPath: resolution.specPath,
241
- acceptanceStatus: resolution.acceptanceStatus,
242
- groups: resolution.groups,
243
- testFileRegex: resolution.testFileRegex,
244
- commitsAhead: pf.commitsAhead,
245
- route: pf.route,
246
- ...(pf.reason ? { reason: pf.reason } : {}),
247
- };
248
- },
249
- },
250
- acceptance: {
251
- nodeType: "action",
252
- run: acceptanceGateNode,
253
- },
254
- fix_acceptance: {
255
- nodeType: "acp",
256
- prompt: (ctx) => fixPrompt("acceptance", ctx),
257
- parse: parseFixVerdict,
258
- },
259
- commit_acceptance: commitFixNode("acceptance"),
260
- review_spec: {
261
- nodeType: "acp",
262
- session: { isolated: true },
263
- profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
264
- prompt(ctx) {
265
- const outs = loadCtxOf(ctx);
266
- return buildReviewPrompt("spec", {
267
- base: outs.base ?? "origin/main",
268
- specPath: outs.specPath ?? "",
269
- since: incrementalSince(ctx, "spec"),
270
- priorFindings: findingsOf(ctx, "spec"),
271
- gaps: reviewGapsOf(ctx, "spec"),
272
- });
273
- },
274
- parse: parseReviewVerdict,
275
- },
276
- route_spec: {
277
- nodeType: "compute",
278
- run: (ctx) => routeReviewAndRecord(ctx, "spec"),
279
- },
280
- fix_spec: {
281
- nodeType: "acp",
282
- prompt: (ctx) => fixPrompt("spec", ctx),
283
- parse: parseFixVerdict,
284
- },
285
- commit_spec: commitFixNode("spec"),
286
- review_quality: {
287
- nodeType: "acp",
288
- session: { isolated: true },
289
- profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
290
- prompt(ctx) {
291
- const outs = loadCtxOf(ctx);
292
- return buildReviewPrompt("quality", {
293
- base: outs.base ?? "origin/main",
294
- specPath: outs.specPath ?? "",
295
- since: incrementalSince(ctx, "quality"),
296
- priorFindings: findingsOf(ctx, "quality"),
297
- gaps: reviewGapsOf(ctx, "quality"),
298
- });
299
- },
300
- parse: parseReviewVerdict,
301
- },
302
- route_quality: {
303
- nodeType: "compute",
304
- run: (ctx) => routeReviewAndRecord(ctx, "quality"),
305
- },
306
- fix_quality: {
307
- nodeType: "acp",
308
- prompt: (ctx) => fixPrompt("quality", ctx),
309
- parse: parseFixVerdict,
310
- },
311
- commit_quality: commitFixNode("quality"),
312
- fix_gate: {
313
- nodeType: "acp",
314
- prompt: (ctx) => fixPrompt("gate", ctx),
315
- parse: parseFixVerdict,
316
- },
317
- commit_gate: commitFixNode("gate"),
318
- quality_gates: {
319
- nodeType: "action",
320
- run: qualityGatesNode,
321
- },
322
- open_pr: {
323
- nodeType: "action",
324
- async run(ctx) {
325
- const i = inputOf(ctx);
326
- const loadCtx = loadCtxOf(ctx);
327
- if (loadCtx.route === "nothing-to-finish") {
328
- await writeResult(i, { feature: i.feature, status: "nothing-to-finish" });
329
- return { route: "done", status: "nothing-to-finish" };
330
- }
331
- // Every fix node edited the working tree; without this the PR would be
332
- // opened from a remote branch missing all of them.
333
- //
334
- // Routed, not thrown. acpx has no error edge, so a throw here kills the
335
- // flow — and this is the last node on the happy path, reached only once
336
- // every gate is green and every fix has landed. It died before
337
- // `writeResult`, so the plugin found no result file and notified
338
- // nobody: the #1399 failure mode the `escalate` node was hardened
339
- // against and this one was not. A protected branch, an expired token or
340
- // a non-fast-forward push is exactly the kind of dead end `escalate`
341
- // exists to report.
342
- let sync: { committed: boolean };
343
- try {
344
- sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
345
- } catch (error) {
346
- return {
347
- route: "escalate",
348
- reason: `nax-finish could not push "${i.branch}", so no PR was opened: ${String(error)}`,
349
- };
350
- }
351
-
352
- const fallbackTitle = `nax-finish: ${i.feature}`;
353
- const fallbackBody = `Automated finish of \`${i.feature}\`.`;
354
- let title = fallbackTitle;
355
- let body = fallbackBody;
356
- // Detected once, here, and handed to both the body builder (which needs
357
- // it for the repo template) and the opener. Detecting in both would let
358
- // them disagree. On a throw it stays undefined and `openOrPromotePr`
359
- // detects for itself, exactly as it did before.
360
- let forge: Forge | undefined;
361
- try {
362
- forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
363
- const prCtx = await _openPrDeps.loadFinishPrContext(i, {
364
- base: loadCtx.base ?? "",
365
- gatesRan: gateOutputs(ctx).ran ?? [],
366
- forge,
367
- specPath: loadCtx.specPath,
368
- });
369
- title = _openPrDeps.buildFinishTitle(prCtx);
370
- body = _openPrDeps.buildFinishBody(prCtx);
371
- } catch (error) {
372
- _prBodyDeps.warn("[finish-pr] Falling back to default PR title/body", { path: i.prdPath, error });
373
- title = fallbackTitle;
374
- body = fallbackBody;
375
- }
376
-
377
- // Same reasoning as the push above: a forge that refuses to create or
378
- // promote (rate limit, revoked token, unrecognised remote) must reach a
379
- // human through `escalate`, not take the flow down silently.
380
- let r: { status: "opened" | "promoted" | "already-ready"; url?: string };
381
- try {
382
- r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
383
- } catch (error) {
384
- return {
385
- route: "escalate",
386
- reason: `nax-finish could not open or promote the PR for "${i.branch}": ${String(error)}`,
387
- };
388
- }
389
- await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
390
- // The PR now exists with the mechanical narrative already in place.
391
- // Anything the narrative node does from here is an improvement on a
392
- // body that is already correct.
393
- return { route: NARRATIVE_ENABLED ? "narrate" : "done", committed: sync.committed, ...r };
394
- },
395
- },
396
- narrative: {
397
- nodeType: "acp",
398
- session: { isolated: true },
399
- profile: process.env.NAX_FINISH_NARRATIVE_PROFILE || undefined,
400
- prompt: narrativePrompt,
401
- parse: parseNarrativeNode,
402
- },
403
- amend_body: {
404
- nodeType: "action",
405
- run: amendPrBodyNode,
406
- },
407
- // Inert terminal. acpx switch cases must name a real node, so the `done`
408
- // route out of open_pr needs somewhere to land.
409
- finish_done: {
410
- nodeType: "compute",
411
- run: () => ({ route: "done" }),
412
- },
413
- escalate: {
414
- nodeType: "action",
415
- async run(ctx) {
416
- const i = inputOf(ctx);
417
- const outs = ctx.outputs as Record<string, { route?: string; reason?: string } | undefined>;
418
- const routed = ctx.outputs as Record<string, ReviewVerdict | undefined>;
419
- const verdict =
420
- routed.route_spec?.route === "escalate"
421
- ? routed.route_spec
422
- : routed.route_quality?.route === "escalate"
423
- ? routed.route_quality
424
- : undefined;
425
- // Ordered by how far down the graph the node sits, so the *last* thing
426
- // that gave up names the reason. `load_ctx` and `open_pr` are here
427
- // because both can now route here rather than throw — a base ref that
428
- // does not resolve, and a push or forge call that failed after every
429
- // gate was green.
430
- const loopExhausted = [outs.open_pr, outs.quality_gates, outs.acceptance, outs.load_ctx].find(
431
- (o) => o?.route === "escalate",
432
- );
433
- const reason =
434
- verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
435
-
436
- // Push what was fixed so the escalation describes state a human can see.
437
- // A push failure must not swallow the escalation itself, so it is
438
- // reported in the message rather than thrown.
439
- let syncNote = "";
440
- try {
441
- await commitAndPush(i.workdir, i.branch, `wip(${i.feature}): nax-finish partial fixes before escalation`);
442
- } catch (err) {
443
- syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
444
- }
445
-
446
- // Write the result BEFORE attempting delivery. Delivery touches the
447
- // network and the forge — a rate limit, an expired token, a locked PR
448
- // or an unrecognised remote used to throw here, killing the node before
449
- // any result existed. The plugin then had nothing to report and, on the
450
- // Telegram channel, nothing to notify from: the one path whose job is
451
- // to say "a human is needed" was the one path with no fallback (#1399).
452
- const result: FinishResult = {
453
- feature: i.feature,
454
- status: "escalated",
455
- escalationReason: reason,
456
- findings: verdict?.findings ?? [],
457
- };
458
- await writeResult(i, result);
459
-
460
- const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
461
- let url: string | undefined;
462
- let channel: string | undefined;
463
- let deliveryError: string | undefined;
464
- try {
465
- const posted = await postEscalation(i.workdir, i.branch, comment, {
466
- preferTelegram: i.escalateTelegram,
467
- });
468
- url = posted.url;
469
- channel = posted.channel;
470
- } catch (err) {
471
- deliveryError = String(err);
472
- }
473
- await writeResult(i, { ...result, url, deliveryError });
474
-
475
- return { route: "done", url, channel, deliveryError, escalationReason: reason };
476
- },
477
- },
478
- },
479
- edges: [
480
- {
481
- from: "load_ctx",
482
- switch: {
483
- on: "$.route",
484
- // `escalate`: the branch could not be measured against its base at all,
485
- // so neither "proceed" nor "nothing-to-finish" would be a true claim.
486
- cases: { proceed: "acceptance", "nothing-to-finish": "open_pr", escalate: "escalate" },
487
- },
488
- },
489
- {
490
- from: "acceptance",
491
- switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
492
- },
493
- // Each fix commits before anything re-reads the diff: the reviewers see
494
- // `git diff <base>...HEAD` only, so an uncommitted fix would be re-reported
495
- // verbatim until the cap escalated it (#1397).
496
- { from: "fix_acceptance", to: "commit_acceptance" },
497
- { from: "commit_acceptance", to: "acceptance" },
498
- { from: "review_spec", to: "route_spec" },
499
- {
500
- from: "route_spec",
501
- switch: {
502
- on: "$.route",
503
- cases: {
504
- clean: "review_quality",
505
- fix: "fix_spec",
506
- escalate: "escalate",
507
- reprompt: "review_spec",
508
- incomplete: "review_spec",
509
- },
510
- },
511
- },
512
- // Spec fixes re-run the acceptance gate first (they can break it), and the
513
- // acceptance node's `proceed` edge leads back into review_spec for re-review.
514
- { from: "fix_spec", to: "commit_spec" },
515
- { from: "commit_spec", to: "acceptance" },
516
- { from: "review_quality", to: "route_quality" },
517
- {
518
- from: "route_quality",
519
- switch: {
520
- on: "$.route",
521
- cases: {
522
- clean: "quality_gates",
523
- fix: "fix_quality",
524
- escalate: "escalate",
525
- reprompt: "review_quality",
526
- incomplete: "review_quality",
527
- },
528
- },
529
- },
530
- // Quality fixes are re-reviewed by the same lens; the repo-root gates that
531
- // follow catch anything the fix broke mechanically.
532
- { from: "fix_quality", to: "commit_quality" },
533
- { from: "commit_quality", to: "review_quality" },
534
- {
535
- from: "quality_gates",
536
- switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
537
- },
538
- { from: "fix_gate", to: "commit_gate" },
539
- // A gate fix that changed code goes back through the quality reviewer, not
540
- // straight to the gates. The gate loop is the last one to edit the tree and
541
- // was the only one whose edits nothing reviewed: `quality_gates` proves the
542
- // repo's commands are green, which a bad fix can satisfy. Observed on
543
- // rs-stock/pipeline-run-outcome — the gate round repaired 8 tests by
544
- // copy-pasting an identical 4-line stub into each, and it shipped, because
545
- // no reviewer ran after it. Re-entry costs one review per gate round; both
546
- // loops stay bounded by their own MAX_FIX_ATTEMPTS caps.
547
- //
548
- // `unchanged` skips it: with nothing committed there is no new diff to
549
- // review, and re-running the reviewer on an identical tree would burn a
550
- // turn to re-report what route_quality already called clean.
551
- {
552
- from: "commit_gate",
553
- switch: {
554
- on: "$.route",
555
- cases: { changed: "review_quality", "tests-only": "review_quality", unchanged: "quality_gates" },
556
- },
557
- },
558
- // The narrative runs only once the PR exists. acpx has no error edge, so an
559
- // acp node before `open_pr` would be able to fail the flow and cost the PR.
560
- {
561
- from: "open_pr",
562
- switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done", escalate: "escalate" } },
563
- },
564
- { from: "narrative", to: "amend_body" },
565
- ],
566
- });