@nathapp/nax 0.76.0 → 0.77.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.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * nax-finish PR title and body — pure deterministic builder, plus the loader
3
+ * that assembles a `FinishPrContext` from finish-audit artifacts on disk.
4
+ *
5
+ * The finish flow opens a PR via `openOrPromotePr` and used to ship a
6
+ * hardcoded `nax-finish: <feature>` title and a one-sentence body, throwing
7
+ * away every artifact the run produced on the way. This module restores that
8
+ * context as a deterministic markdown body — the title matches
9
+ * `src/plugins/builtin/auto-pr/pr-body.ts:buildTitle`, and the body is
10
+ * assembled by string joins over the fields in `FinishPrContext`. No model
11
+ * call: every section is reproducible from artifacts that exist before
12
+ * `open_pr` runs, and so the body stays greppable in PR history.
13
+ *
14
+ * Reimplemented here (rather than imported from `src/`) because `flows/`
15
+ * ships to a different runtime — `acpx flow run` runs it in acpx's own Node
16
+ * process where nax's `src/` and its `@/*` alias are not available.
17
+ */
18
+ import { readFile } from "node:fs/promises";
19
+ import { dirname, isAbsolute, join } from "node:path";
20
+ import { runArgv } from "../exec";
21
+ import { readSpecSummary, resolveNarrative } from "../narrative";
22
+ import { findPrTemplate } from "../pr-template";
23
+ import type { Finding, FinishInput, FinishRound, RunFn } from "../types";
24
+ import type { Forge } from "./forge";
25
+ import { readRounds } from "./result";
26
+
27
+ const SECONDS_PER_MINUTE = 60;
28
+ const MS_PER_SECOND = 1000;
29
+
30
+ /** Six hex + one — the abbreviated form used everywhere in PR bodies and logs. */
31
+ const SHORT_SHA_LEN = 7;
32
+
33
+ /** One row in the Stories table. */
34
+ export interface FinishPrStory {
35
+ id: string;
36
+ title: string;
37
+ acCount: number;
38
+ }
39
+
40
+ /** Everything `open_pr` renders, sourced from finish-audit artifacts. */
41
+ export interface FinishPrContext {
42
+ feature: string;
43
+ stories: FinishPrStory[];
44
+ outOfScope: string[];
45
+ acceptance?: string;
46
+ regression?: string;
47
+ gatesRan: string[];
48
+ diffstat?: string;
49
+ /** Repository PR/MR template, verbatim. Absent when none resolves. */
50
+ template?: string;
51
+ /** Resolved "What changed" prose. Absent when neither source produced text. */
52
+ narrative?: string;
53
+ rounds: FinishRound[];
54
+ run: {
55
+ durationMs?: number;
56
+ storiesPassed?: number;
57
+ storiesTotal?: number;
58
+ };
59
+ }
60
+
61
+ export const _prBodyDeps: {
62
+ run: RunFn;
63
+ readText: (path: string) => Promise<string | null>;
64
+ warn: (message: string, details: { path: string; error: unknown }) => void;
65
+ } = {
66
+ run: runArgv,
67
+ // ENOENT is the routine case (status.json/prd.json not yet written) and must
68
+ // stay silent — mirrors `_qualityDeps.readText` in `steps/quality.ts`. Only a
69
+ // genuine I/O failure (permission denied, corrupted mount) should warn.
70
+ readText: async (path) => {
71
+ try {
72
+ return await readFile(path, "utf8");
73
+ } catch (err) {
74
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
75
+ throw err;
76
+ }
77
+ },
78
+ warn: (message, details) => process.emitWarning(message, { detail: `${details.path}: ${String(details.error)}` }),
79
+ };
80
+
81
+ interface PrdArtifact {
82
+ userStories?: { id: string; title: string; acceptanceCriteria?: unknown[] }[];
83
+ outOfScope?: string[];
84
+ }
85
+
86
+ interface StatusArtifact {
87
+ postRun?: { acceptance?: { status?: string }; regression?: { status?: string } };
88
+ durationMs?: number;
89
+ progress?: { passed?: number; total?: number };
90
+ }
91
+
92
+ async function readJson(path: string): Promise<unknown> {
93
+ let text: string | null;
94
+ try {
95
+ text = await _prBodyDeps.readText(path);
96
+ } catch (error) {
97
+ _prBodyDeps.warn("[finish-pr] Failed to read PR context artifact", { path, error });
98
+ return undefined;
99
+ }
100
+ if (text === null) return undefined;
101
+ try {
102
+ return JSON.parse(text);
103
+ } catch (error) {
104
+ _prBodyDeps.warn("[finish-pr] Failed to parse PR context artifact", { path, error });
105
+ return undefined;
106
+ }
107
+ }
108
+
109
+ function storiesFrom(prd: PrdArtifact | undefined): FinishPrStory[] {
110
+ if (!Array.isArray(prd?.userStories)) return [];
111
+ // A hand-edited or older-schema PRD can carry a story with a missing/non-string
112
+ // `id`/`title` — drop only that row rather than letting `escapeTableCell` throw
113
+ // and take down the entire PR body (caught upstream by `open_pr`'s fallback).
114
+ return prd.userStories
115
+ .filter((story) => typeof story.id === "string" && typeof story.title === "string")
116
+ .map((story) => ({
117
+ id: story.id,
118
+ title: story.title,
119
+ acCount: Array.isArray(story.acceptanceCriteria) ? story.acceptanceCriteria.length : 0,
120
+ }));
121
+ }
122
+
123
+ /**
124
+ * Run `git diff --stat <base>...HEAD` and return its stdout on success.
125
+ *
126
+ * Fail-open on every non-happy path — a non-zero exit (no commits, divergent
127
+ * branch, base missing), a rejected run promise (forks too slow to start), or
128
+ * any thrown error — returning `undefined`. The PR's Verification block is
129
+ * optional, and a routine empty-branch finish must not lose `open_pr` to a
130
+ * throw that the body can simply skip.
131
+ */
132
+ async function runDiffstat(workdir: string, base: string): Promise<string | undefined> {
133
+ // An empty `base` would interpolate to `...HEAD`, which git resolves as
134
+ // `HEAD...HEAD` — exit 0, empty stdout — masking the missing-base case as
135
+ // "no changes" instead of skipping explicitly.
136
+ if (!base) return undefined;
137
+ try {
138
+ const res = await _prBodyDeps.run(["git", "diff", "--stat", `${base}...HEAD`], { cwd: workdir });
139
+ if (res.exitCode !== 0) return undefined;
140
+ return res.stdout;
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Resolve the repository's PR/MR template, fail-open.
148
+ *
149
+ * An absent template is the common case and never warns. A genuine read failure
150
+ * is swallowed too: the body is useful without this section, and `open_pr` must
151
+ * not lose a PR to a permissions error on a file most repos do not have.
152
+ */
153
+ async function loadTemplate(workdir: string, forge: Forge | undefined): Promise<string | undefined> {
154
+ if (forge === undefined) return undefined;
155
+ try {
156
+ return (await findPrTemplate(workdir, forge, { readText: _prBodyDeps.readText })) ?? undefined;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ export async function loadFinishPrContext(
163
+ input: FinishInput,
164
+ args: { base: string; gatesRan: string[]; forge?: Forge; specPath?: string; narrative?: string },
165
+ ): Promise<FinishPrContext> {
166
+ const inputPrdPath = input.prdPath || "prd.json";
167
+ const prdPath = isAbsolute(inputPrdPath) ? inputPrdPath : join(input.workdir, inputPrdPath);
168
+ // [US-004] The audit trail (`rounds`), the diffstat, and the spec summary
169
+ // are independent of the PRD/status reads — fetching them in parallel keeps
170
+ // the loader's wall clock at max(readRounds, readJson×2, diffstat, spec).
171
+ const [prd, status, rounds, diffstat, template, specSummary] = (await Promise.all([
172
+ readJson(prdPath),
173
+ readJson(join(dirname(prdPath), "status.json")),
174
+ readRounds(input),
175
+ runDiffstat(input.workdir, args.base),
176
+ loadTemplate(input.workdir, args.forge),
177
+ readSpecSummary(args.specPath, _prBodyDeps.readText),
178
+ ])) as [
179
+ PrdArtifact | undefined,
180
+ StatusArtifact | undefined,
181
+ FinishRound[],
182
+ string | undefined,
183
+ string | undefined,
184
+ string | null,
185
+ ];
186
+ return {
187
+ feature: input.feature,
188
+ stories: storiesFrom(prd),
189
+ outOfScope: Array.isArray(prd?.outOfScope) ? prd.outOfScope : [],
190
+ acceptance: status?.postRun?.acceptance?.status,
191
+ regression: status?.postRun?.regression?.status,
192
+ gatesRan: args.gatesRan,
193
+ rounds,
194
+ diffstat,
195
+ template,
196
+ narrative: resolveNarrative(args.narrative, specSummary),
197
+ run: {
198
+ durationMs: status?.durationMs,
199
+ storiesPassed: status?.progress?.passed,
200
+ storiesTotal: status?.progress?.total,
201
+ },
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Conventional-commit title matching `buildTitle` in
207
+ * `src/plugins/builtin/auto-pr/pr-body.ts`, so finish-opened and
208
+ * auto-PR-opened PRs read the same in a list view.
209
+ */
210
+ export function buildFinishTitle(ctx: FinishPrContext): string {
211
+ return `feat: ${ctx.feature}`;
212
+ }
213
+
214
+ /**
215
+ * Escape a string for safe inclusion in a single markdown table cell.
216
+ *
217
+ * Mirrors `escapeTableCell` in `src/plugins/builtin/auto-pr/pr-body.ts`,
218
+ * trimmed to the cases the finish body actually needs: pipes (which break
219
+ * the column boundary) and newlines (which create new rows). Backslashes are
220
+ * escaped first so the pipe escape survives a literal backslash in a title.
221
+ */
222
+ function escapeTableCell(value: string): string {
223
+ return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
224
+ }
225
+
226
+ function formatDuration(durationMs: number): string {
227
+ // `Math.max(0, NaN)` returns NaN, and `Math.floor(Infinity / 1000)` returns
228
+ // Infinity — both would render as `"NaNm NaNs"` / `"Infinitym Infinitys"`.
229
+ // A non-finite duration is a corrupted artifact (status.json is hand-editable),
230
+ // so fall back to zero rather than let it leak into the PR body verbatim.
231
+ if (!Number.isFinite(durationMs)) return "0m 00s";
232
+ const clampedMs = Math.max(0, Math.round(durationMs));
233
+ const totalSeconds = Math.floor(clampedMs / MS_PER_SECOND);
234
+ const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
235
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
236
+ return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
237
+ }
238
+
239
+ function buildStoriesSection(stories: FinishPrStory[]): string {
240
+ const lines: string[] = [];
241
+ lines.push("## Stories");
242
+ lines.push("| Story | Title | ACs |");
243
+ lines.push("|-------|-------|-----|");
244
+ for (const story of stories) {
245
+ lines.push(`| ${escapeTableCell(story.id)} | ${escapeTableCell(story.title)} | ${story.acCount} |`);
246
+ }
247
+ return lines.join("\n");
248
+ }
249
+
250
+ function buildVerificationSection(
251
+ acceptance: string | undefined,
252
+ regression: string | undefined,
253
+ gatesRan: string[],
254
+ diffstat: string | undefined,
255
+ ): string | null {
256
+ const lines: string[] = ["## Verification"];
257
+ if (acceptance !== undefined) lines.push(`- Acceptance: ${acceptance}`);
258
+ if (regression !== undefined) lines.push(`- Regression: ${regression}`);
259
+ if (gatesRan.length > 0) lines.push(`- Gates: ${gatesRan.join(", ")}`);
260
+ if (diffstat !== undefined && diffstat.length > 0) lines.push(`- Diffstat:\n\n\`\`\`\n${diffstat}\n\`\`\``);
261
+ if (lines.length === 1) return null;
262
+ return lines.join("\n");
263
+ }
264
+
265
+ function buildRoundHeading(round: FinishRound): string {
266
+ const base = `### ${round.phase} attempt ${round.attempt}`;
267
+ if (!round.committed || !round.sha) return base;
268
+ const short = round.sha.slice(0, SHORT_SHA_LEN);
269
+ return `${base} (${short})`;
270
+ }
271
+
272
+ function buildRoundBlock(round: FinishRound): string {
273
+ const lines: string[] = [buildRoundHeading(round)];
274
+ if (round.findings.length === 0) {
275
+ lines.push("- _no findings_");
276
+ } else {
277
+ for (const finding of round.findings) lines.push(renderFinding(finding));
278
+ }
279
+ return lines.join("\n");
280
+ }
281
+
282
+ function buildRoundsSection(rounds: FinishRound[]): string | null {
283
+ if (rounds.length === 0) return null;
284
+ const blocks = rounds.map(buildRoundBlock);
285
+ return ["## Review rounds", ...blocks].join("\n\n");
286
+ }
287
+
288
+ function renderFinding(finding: Finding): string {
289
+ return `- [${finding.severity}] ${finding.title}`;
290
+ }
291
+
292
+ /**
293
+ * Heading and text are produced together, so "no text" cannot render a bare
294
+ * `## What changed` heading — the empty-heading case #1477 forbids.
295
+ */
296
+ function buildNarrativeSection(narrative: string | undefined): string | null {
297
+ const text = narrative?.trim();
298
+ if (!text) return null;
299
+ return ["## What changed", text].join("\n\n");
300
+ }
301
+
302
+ function buildOutOfScopeSection(outOfScope: string[]): string | null {
303
+ if (outOfScope.length === 0) return null;
304
+ const lines: string[] = ["## Out of scope"];
305
+ for (const item of outOfScope) lines.push(`- ${item}`);
306
+ return lines.join("\n");
307
+ }
308
+
309
+ function buildFooter(run: FinishPrContext["run"]): string | null {
310
+ const { storiesPassed, storiesTotal, durationMs } = run;
311
+ if (storiesPassed === undefined && storiesTotal === undefined && durationMs === undefined) return null;
312
+ const counts =
313
+ storiesPassed !== undefined && storiesTotal !== undefined ? `${storiesPassed}/${storiesTotal} stories` : null;
314
+ const timing = durationMs !== undefined ? formatDuration(durationMs) : null;
315
+ const parts = [counts, timing].filter((p): p is string => p !== null);
316
+ if (parts.length === 0) return null;
317
+ return parts.join(" · ");
318
+ }
319
+
320
+ export function buildFinishBody(ctx: FinishPrContext): string {
321
+ const sections: string[] = [];
322
+
323
+ const narrativeSection = buildNarrativeSection(ctx.narrative);
324
+ if (narrativeSection !== null) sections.push(narrativeSection);
325
+
326
+ if (ctx.stories.length > 0) sections.push(buildStoriesSection(ctx.stories));
327
+
328
+ const verification = buildVerificationSection(ctx.acceptance, ctx.regression, ctx.gatesRan, ctx.diffstat);
329
+ if (verification !== null) sections.push(verification);
330
+
331
+ const roundsSection = buildRoundsSection(ctx.rounds);
332
+ if (roundsSection !== null) sections.push(roundsSection);
333
+
334
+ const outOfScopeSection = buildOutOfScopeSection(ctx.outOfScope);
335
+ if (outOfScopeSection !== null) sections.push(outOfScopeSection);
336
+
337
+ const footer = buildFooter(ctx.run);
338
+ if (footer !== null) sections.push(footer);
339
+
340
+ // Appended last and verbatim: `gh` / `glab` suppress the repo's own template
341
+ // whenever `--body` / `--description` is passed, so it has to be re-embedded.
342
+ if (ctx.template !== undefined && ctx.template.trim().length > 0) sections.push(ctx.template.trim());
343
+
344
+ return sections.join("\n\n");
345
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `amend_body` — rewrite the PR body once the narrative node has produced prose.
3
+ *
4
+ * Runs *after* the PR is open and its result file written, which is the whole
5
+ * point: acpx has no error edge, so an acp node placed before `open_pr` could
6
+ * kill the flow and cost the PR. Here the worst case is a body missing one
7
+ * section.
8
+ *
9
+ * Every failure is warned and swallowed for the same reason — a throw would
10
+ * fail a flow whose real work already succeeded.
11
+ */
12
+ import { gateOutputs, inputOf, loadCtxOf, narrativeOf } from "../flow-ctx";
13
+ import { detectForge } from "./forge";
14
+ import { updatePrBody } from "./pr";
15
+ import { _prBodyDeps, buildFinishBody, buildFinishTitle, loadFinishPrContext } from "./pr-body";
16
+
17
+ export async function amendPrBodyNode(ctx: {
18
+ input: unknown;
19
+ outputs: unknown;
20
+ }): Promise<{ route: "done"; amended: boolean }> {
21
+ const narrative = narrativeOf(ctx);
22
+ // Nothing to add: the body already in place is correct, and rewriting it
23
+ // identically would spend a forge call to change nothing.
24
+ if (!narrative) return { route: "done", amended: false };
25
+
26
+ const i = inputOf(ctx);
27
+ const loadCtx = loadCtxOf(ctx);
28
+ try {
29
+ const forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
30
+ const prCtx = await loadFinishPrContext(i, {
31
+ base: loadCtx.base ?? "",
32
+ gatesRan: gateOutputs(ctx).ran ?? [],
33
+ forge,
34
+ specPath: loadCtx.specPath,
35
+ narrative,
36
+ });
37
+ await updatePrBody(forge, i.workdir, i.branch, buildFinishTitle(prCtx), buildFinishBody(prCtx));
38
+ return { route: "done", amended: true };
39
+ } catch (error) {
40
+ _prBodyDeps.warn("[finish-pr] Failed to amend the PR body with the narrative", { path: i.branch, error });
41
+ return { route: "done", amended: false };
42
+ }
43
+ }
@@ -1,9 +1,19 @@
1
1
  import { FinishError } from "../errors";
2
- import { runArgv } from "../exec";
3
2
  import type { RunFn } from "../types";
4
3
  import { type Forge, detectForge, extractUrl, viewArgv } from "./forge";
4
+ import { _prBodyDeps, loadFinishPrContext } from "./pr-body";
5
5
 
6
- export const _prDeps: { run: RunFn } = { run: runArgv };
6
+ // `loadFinishPrContext` moved to `./pr-body` (the spec's stated module
7
+ // boundary); re-exported here so consumers importing from `./pr` (or the
8
+ // `steps` barrel, which re-exports `./pr`) keep working.
9
+ export { loadFinishPrContext };
10
+
11
+ // `_prDeps` is deliberately the *same object* as `./pr-body`'s `_prBodyDeps`,
12
+ // not a copy — this module's `run` calls (forge CLI) and pr-body's
13
+ // `readText`/`warn`/diffstat `run` calls share one injectable seam, so a
14
+ // single test stub controls both. Typed to `{ run: RunFn }` here because
15
+ // that's the only member this module actually calls.
16
+ export const _prDeps: { run: RunFn } = _prBodyDeps;
7
17
 
8
18
  /**
9
19
  * Parse `gh pr view --json isDraft,url` / `glab mr view --output json` stdout.
@@ -33,8 +43,12 @@ export async function openOrPromotePr(
33
43
  branch: string,
34
44
  title: string,
35
45
  body: string,
46
+ // Optional so a caller whose own `detectForge` threw still gets the previous
47
+ // behaviour. Passing it in is what stops the body and the create-command from
48
+ // disagreeing about the forge when both would otherwise detect separately.
49
+ knownForge?: Forge,
36
50
  ): Promise<{ status: "opened" | "promoted" | "already-ready"; url?: string }> {
37
- const forge = await detectForge(_prDeps.run, repoRoot, "finish-pr");
51
+ const forge = knownForge ?? (await detectForge(_prDeps.run, repoRoot, "finish-pr"));
38
52
  const view = await _prDeps.run(viewArgv(forge, branch, "isDraft,url"), { cwd: repoRoot });
39
53
 
40
54
  if (view.exitCode !== 0) {
@@ -64,8 +78,39 @@ export async function openOrPromotePr(
64
78
  { stage: "finish-pr", branch },
65
79
  );
66
80
  }
81
+ await updatePrBody(forge, repoRoot, branch, title, body);
67
82
  return { status: "promoted", url };
68
83
  }
69
84
 
85
+ await updatePrBody(forge, repoRoot, branch, title, body);
70
86
  return { status: "already-ready", url };
71
87
  }
88
+
89
+ /**
90
+ * Write the finish title/body onto an already-open PR/MR.
91
+ *
92
+ * Non-fatal by design: this runs after the PR exists, so a failed metadata
93
+ * write must not throw away that state — the caller's returned status/url
94
+ * stays valid either way. Exported because `amend_body` calls it after the
95
+ * narrative node produces prose.
96
+ */
97
+ export async function updatePrBody(
98
+ forge: Forge,
99
+ repoRoot: string,
100
+ branch: string,
101
+ title: string,
102
+ body: string,
103
+ ): Promise<void> {
104
+ const editCmd =
105
+ forge === "github"
106
+ ? ["gh", "pr", "edit", branch, "--title", title, "--body", body]
107
+ : ["glab", "mr", "update", branch, "--title", title, "--description", body];
108
+ try {
109
+ const res = await _prDeps.run(editCmd, { cwd: repoRoot });
110
+ if (res.exitCode !== 0) {
111
+ _prBodyDeps.warn("[finish-pr] Failed to write PR title/body", { path: branch, error: res.stderr.trim() });
112
+ }
113
+ } catch (error) {
114
+ _prBodyDeps.warn("[finish-pr] Failed to write PR title/body", { path: branch, error });
115
+ }
116
+ }
@@ -16,13 +16,21 @@ export interface Finding {
16
16
  }
17
17
  export interface ReviewVerdict {
18
18
  /**
19
- * `clean` is not a model-produced route — the review nodes' `parse` rewrites
20
- * `proceed` with zero findings to `clean` so the graph can skip the fix node
21
- * entirely instead of prompting an agent to "apply fixes" for nothing.
19
+ * Neither `clean` nor `reprompt` is a model-produced route.
20
+ *
21
+ * `clean` `parse` rewrites `proceed` with zero findings, so the graph can
22
+ * skip the fix node instead of prompting an agent to "apply fixes" for nothing.
23
+ *
24
+ * `reprompt` — `parse` could not read JSON out of the reply at all. Returning
25
+ * this rather than throwing is deliberate: a throw fails the acp node and kills
26
+ * the whole flow with no result file, bypassing the `escalate` sink that exists
27
+ * to report exactly this kind of dead end.
22
28
  */
23
- route: "proceed" | "escalate" | "clean";
29
+ route: "proceed" | "escalate" | "clean" | "reprompt";
24
30
  findings: Finding[];
25
31
  escalationReason?: string;
32
+ /** Bounded tail of an unparseable reply; set only when `route` is `reprompt`. */
33
+ raw?: string;
26
34
  }
27
35
  /** Wall-clock budgets, forwarded from `finish.autoFlow.timeouts` by the plugin. */
28
36
  export interface FinishTimeouts {
@@ -53,6 +61,13 @@ export interface FinishRound {
53
61
  findings: Finding[];
54
62
  /** Gate commands that were red this round (gate phase). */
55
63
  failing?: string[];
64
+ /**
65
+ * `HEAD` SHA after this round's commit (set only when `committed`); absent
66
+ * on no-op rounds so a reader can distinguish "no commit" from "record lost".
67
+ * Lets "Fixed in `<sha>`" be reconstructed from the audit trail alone, rather
68
+ * than by matching round timestamps against `git log`.
69
+ */
70
+ sha?: string;
56
71
  }
57
72
 
58
73
  export interface FinishInput {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Turning a reviewer's reply into a deterministic route.
3
+ *
4
+ * Lives outside `nax-finish.flow.ts` for two reasons: the flow file sits within
5
+ * a few lines of the 600-line hard limit, and this is a cohesive unit —
6
+ * `routeReview` consumes exactly what the parsers produce.
7
+ *
8
+ * The central invariant: **no parser here ever throws.** acpx has no node-level
9
+ * retry and no error edge (`AcpNodeDefinition` offers only `prompt`/`parse`;
10
+ * `FlowEdge` is only `to` or `switch`), so a throw inside `parse` fails the node
11
+ * and fails the run — exit 1, no result file, no notification, bypassing the
12
+ * `escalate` node that exists to report precisely this.
13
+ */
14
+ import { extractJsonObject } from "acpx/flows";
15
+ import { type OutputsCtx, type StepsCtx, fixAttemptCount } from "./flow-ctx";
16
+ import type { Finding, ReviewVerdict } from "./types";
17
+
18
+ /**
19
+ * Cap on fix-and-reverify iterations, per phase, before escalating instead of
20
+ * looping forever. acpx's flow engine has no built-in cycle guard, so without
21
+ * this cap a stubborn failure (LLM can't fix it, or fixes something else each
22
+ * time) hangs `acpx flow run` — and the post-run plugin awaits that subprocess.
23
+ *
24
+ * Lives here rather than in the flow file because `routeReview` needs it; the
25
+ * flow imports it back for the acceptance node and the two `quality_gates` caps.
26
+ */
27
+ export const MAX_FIX_ATTEMPTS = 3;
28
+
29
+ /**
30
+ * Unparseable reviews tolerated per phase before escalating.
31
+ *
32
+ * One. A reviewer that ignores the JSON contract twice in a row is not going to
33
+ * comply on a third ask, and each review is the most expensive node in the flow
34
+ * (128s and ~4.2M tokens on the run that motivated this).
35
+ */
36
+ export const MAX_REPROMPT_ATTEMPTS = 1;
37
+
38
+ /** How much of an unparseable reply to carry forward — it lands in a PR comment and a Telegram message. */
39
+ export const RAW_TAIL_LIMIT = 500;
40
+
41
+ function tail(text: string): string {
42
+ const t = text.trim();
43
+ return t.length <= RAW_TAIL_LIMIT ? t : `…${t.slice(-(RAW_TAIL_LIMIT - 1))}`;
44
+ }
45
+
46
+ /** Shared happy path: read the object, normalise findings, rewrite empty `proceed` to `clean`. */
47
+ function parseVerdictJson(text: string): ReviewVerdict {
48
+ const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
49
+ const findings: Finding[] = Array.isArray(raw.findings) ? raw.findings : [];
50
+ const route = raw.route === "escalate" ? "escalate" : findings.length === 0 ? "clean" : "proceed";
51
+ return { route, findings, escalationReason: raw.escalationReason };
52
+ }
53
+
54
+ /**
55
+ * Parser for `review_spec` / `review_quality`, whose JSON is load-bearing —
56
+ * `findingsOf` reads it and the fix loop is driven by it. An unreadable reply
57
+ * routes to `reprompt` so `routeReview` can ask once more before escalating.
58
+ */
59
+ export function parseReviewVerdict(text: string): ReviewVerdict {
60
+ try {
61
+ return parseVerdictJson(text);
62
+ } catch {
63
+ return { route: "reprompt", findings: [], raw: tail(text) };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Parser for the four `fix_*` nodes, whose parsed value nothing reads —
69
+ * `findingsOf` only ever looks at `review_spec`/`review_quality`, and
70
+ * `commitFixNode` decides from git rather than from the model's word.
71
+ *
72
+ * Never routes `reprompt`: the fix nodes have unconditional edges
73
+ * (`fix_spec → commit_spec`), so a reprompt route would have nowhere to go.
74
+ */
75
+ export function parseFixVerdict(text: string): ReviewVerdict {
76
+ try {
77
+ return parseVerdictJson(text);
78
+ } catch {
79
+ return { route: "proceed", findings: [] };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * How many times this phase's review already came back unparseable.
85
+ *
86
+ * Counts step *outputs*, not step ids: `commit_quality → review_quality` and
87
+ * `commit_gate → review_quality` are legitimate re-entries in the normal fix
88
+ * loop, so counting bare `review_<phase>` steps would escalate a healthy run.
89
+ *
90
+ * This is observable only because `parseReviewVerdict` returns rather than
91
+ * throws — a returned verdict makes acpx record the step as successful with
92
+ * this output. A throw would record it `failed`, with nothing to count.
93
+ *
94
+ * SELF-INCLUSIVE, not self-exclusive: acpx's runtime calls
95
+ * `recordFlowStepOutcome(runDir, state, step)` (acpx/src/flows/runtime.ts:262),
96
+ * which pushes the just-finished step onto `state.steps`
97
+ * (acpx/src/flows/runtime.ts:499), BEFORE `resolveNextNode` runs and before the
98
+ * following node (`route_<phase>`) executes. So by the time `routeReview` reads
99
+ * `ctx.state.steps` here, the current round's own `review_<phase>` step is
100
+ * already included. On the very first unparseable reply this already returns
101
+ * 1, not 0. `routeReview`'s comparison against `MAX_REPROMPT_ATTEMPTS` MUST
102
+ * stay `<=` (not `<`) for that reason — see routeReview below.
103
+ */
104
+ export function repromptCount(ctx: StepsCtx, phase: "spec" | "quality"): number {
105
+ return (ctx.state.steps ?? []).filter(
106
+ (s) => s.nodeId === `review_${phase}` && (s.output as ReviewVerdict | undefined)?.route === "reprompt",
107
+ ).length;
108
+ }
109
+
110
+ /**
111
+ * Turn a reviewer verdict into a deterministic route.
112
+ *
113
+ * `clean` (no findings) skips the fix node entirely — prompting an agent to
114
+ * "apply the recommended fixes" for an empty finding list burns a turn and
115
+ * invites unrequested edits.
116
+ *
117
+ * The `reprompt` branch MUST come first. A reprompt verdict carries zero
118
+ * findings, so checking `findings.length === 0` ahead of it would route an
119
+ * unreadable review to `clean`, and the flow would open a PR having reviewed
120
+ * nothing. That silent false green is worse than the crash this replaces.
121
+ */
122
+ export function routeReview(
123
+ ctx: OutputsCtx & StepsCtx,
124
+ phase: "spec" | "quality",
125
+ ): { route: string; escalationReason?: string; findings: Finding[] } {
126
+ const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
127
+ const findings = verdict?.findings ?? [];
128
+ if (verdict?.route === "reprompt") {
129
+ // `attempts` is self-inclusive (see repromptCount) — it already counts this
130
+ // round's failure, so `<=` (not `<`) is what makes MAX_REPROMPT_ATTEMPTS=1
131
+ // tolerate exactly one retry before escalating.
132
+ const attempts = repromptCount(ctx, phase);
133
+ if (attempts <= MAX_REPROMPT_ATTEMPTS) return { route: "reprompt", findings };
134
+ return {
135
+ route: "escalate",
136
+ escalationReason:
137
+ `${phase} reviewer returned unparseable output after ${attempts} attempts. ` +
138
+ `Last reply: ${verdict.raw ?? "(empty)"}`,
139
+ findings,
140
+ };
141
+ }
142
+ if (verdict?.route === "escalate") {
143
+ return {
144
+ route: "escalate",
145
+ escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
146
+ findings,
147
+ };
148
+ }
149
+ if (findings.length === 0) return { route: "clean", findings };
150
+ const attempts = fixAttemptCount(ctx, `fix_${phase}`);
151
+ if (attempts >= MAX_FIX_ATTEMPTS) {
152
+ return {
153
+ route: "escalate",
154
+ escalationReason: `${phase} review still reporting ${findings.length} finding(s) after ${attempts} fix attempts.`,
155
+ findings,
156
+ };
157
+ }
158
+ return { route: "fix", findings };
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {