@nathapp/nax 0.75.6 → 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.
@@ -16,24 +16,74 @@ 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 {
29
37
  acceptanceMs?: number;
30
38
  gateMs?: number;
31
39
  }
40
+ /** The four fix-and-reverify loops, in graph order. */
41
+ export type FinishPhase = "acceptance" | "spec" | "quality" | "gate";
42
+
43
+ /**
44
+ * One completed fix round, appended to the audit trail as it happens.
45
+ *
46
+ * Rounds are appended at `commit_<phase>` as they happen rather than
47
+ * reconstructed by a terminal node from `ctx.state.steps` (which does retain
48
+ * every step's output). Appending live is what makes the trail survive a flow
49
+ * that is killed or times out: no terminal node runs on those paths, and a
50
+ * finish that died mid-loop is exactly when the record of what it already
51
+ * changed on the branch matters most.
52
+ */
53
+ export interface FinishRound {
54
+ ts: string;
55
+ phase: FinishPhase;
56
+ /** 1-based; the Nth time this phase's fix node has run. */
57
+ attempt: number;
58
+ /** True when the fix produced a commit; false when it changed nothing. */
59
+ committed: boolean;
60
+ /** Reviewer findings this round set out to fix (spec/quality phases). */
61
+ findings: Finding[];
62
+ /** Gate commands that were red this round (gate phase). */
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;
71
+ }
72
+
32
73
  export interface FinishInput {
33
74
  feature: string;
34
75
  workdir: string;
35
76
  branch: string;
36
77
  prdPath: string;
78
+ /**
79
+ * Directory for this feature's finish-audit artifacts, e.g.
80
+ * `~/.nax/<project>/finish-audit/<feature>`. Supplied by the plugin, which
81
+ * owns nax's path SSOT (`src/runtime/paths.ts`) that this module may not
82
+ * import. Absent → the flow falls back to a repo-local directory.
83
+ */
84
+ auditDir?: string;
85
+ /** Run id, used to name this run's audit files. Absent → "run". */
86
+ runId?: string;
37
87
  /**
38
88
  * True only when Telegram escalation is both enabled *and* credentialed, as
39
89
  * determined by the plugin. When true the flow skips the PR/MR comment
@@ -62,6 +112,13 @@ export interface FinishResult {
62
112
  * rather than lost.
63
113
  */
64
114
  deliveryError?: string;
115
+ /**
116
+ * Every fix round the flow ran, on *all* terminal statuses — not just
117
+ * escalations. A successful finish that took four rounds to get there is the
118
+ * case worth auditing (it says the run's own review gates missed four
119
+ * defects), and it was previously the one case that recorded nothing.
120
+ */
121
+ rounds?: FinishRound[];
65
122
  }
66
123
  export interface RunResult {
67
124
  exitCode: number;
@@ -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.75.6",
3
+ "version": "0.77.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {