@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,210 +0,0 @@
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 { parseDispositions, parseReviewReport } from "./findings-parse";
16
- import { type OutputsCtx, type StepsCtx, fixAttemptCount } from "./flow-ctx";
17
- import type { Finding, ReviewVerdict } from "./types";
18
-
19
- /**
20
- * Cap on fix-and-reverify iterations, per phase, before escalating instead of
21
- * looping forever. acpx's flow engine has no built-in cycle guard, so without
22
- * this cap a stubborn failure (LLM can't fix it, or fixes something else each
23
- * time) hangs `acpx flow run` — and the post-run plugin awaits that subprocess.
24
- *
25
- * Lives here rather than in the flow file because `routeReview` needs it; the
26
- * flow imports it back for the acceptance node and the two `quality_gates` caps.
27
- */
28
- export const MAX_FIX_ATTEMPTS = 3;
29
-
30
- /**
31
- * Unparseable reviews tolerated per phase before escalating.
32
- *
33
- * One. A reviewer that ignores the JSON contract twice in a row is not going to
34
- * comply on a third ask, and each review is the most expensive node in the flow
35
- * (128s and ~4.2M tokens on the run that motivated this).
36
- */
37
- export const MAX_REPROMPT_ATTEMPTS = 1;
38
-
39
- /**
40
- * Reviews sent back for missing evidence sections, per phase, before escalating.
41
- *
42
- * One, for the same reason `MAX_REPROMPT_ATTEMPTS` is one: a reviewer that
43
- * ignores the reply contract twice is not going to honour it on a third ask, and
44
- * a review is the most expensive node in the flow.
45
- */
46
- export const MAX_INCOMPLETE_ATTEMPTS = 1;
47
-
48
- /** How much of an unparseable reply to carry forward — it lands in a PR comment and a Telegram message. */
49
- export const RAW_TAIL_LIMIT = 500;
50
-
51
- function tail(text: string): string {
52
- const t = text.trim();
53
- return t.length <= RAW_TAIL_LIMIT ? t : `…${t.slice(-(RAW_TAIL_LIMIT - 1))}`;
54
- }
55
-
56
- /** Shared happy path: read the object, normalise findings, rewrite empty `proceed` to `clean`. */
57
- function parseVerdictJson(text: string): ReviewVerdict {
58
- const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
59
- const findings: Finding[] = Array.isArray(raw.findings) ? raw.findings : [];
60
- const route = raw.route === "escalate" ? "escalate" : findings.length === 0 ? "clean" : "proceed";
61
- return { route, findings, escalationReason: raw.escalationReason };
62
- }
63
-
64
- /**
65
- * Read a reviewer's reply, block format first.
66
- *
67
- * Three tiers, in cost order: the block contract the prompt asks for; then the
68
- * JSON object older runs produced (a flow resumed from a journal recorded before
69
- * #1614, or a reviewer that answered in the old shape anyway); then reprompt.
70
- * The JSON tier is three lines and removes a whole class of resume failure, so
71
- * it stays even though nothing asks for JSON any more.
72
- */
73
- export function parseReviewVerdict(text: string): ReviewVerdict {
74
- const report = parseReviewReport(text);
75
- if (report.findings.length > 0 || report.sawNoFindings) {
76
- const judged = report.findings.find((f) => f.judgment);
77
- const route = judged ? "escalate" : report.findings.length === 0 ? "clean" : "proceed";
78
- return {
79
- route,
80
- findings: report.findings,
81
- ...(judged ? { escalationReason: judged.judgmentReason ?? `Needs human judgment: ${judged.title}` } : {}),
82
- touchpoints: report.touchpoints,
83
- walk: report.walk,
84
- sawTouchpointsSection: report.sawTouchpointsSection,
85
- sawWalkSection: report.sawWalkSection,
86
- };
87
- }
88
- try {
89
- return parseVerdictJson(text);
90
- } catch {
91
- return { route: "reprompt", findings: [], raw: tail(text) };
92
- }
93
- }
94
-
95
- /**
96
- * Parser for the four `fix_*` nodes, whose parsed value nothing reads —
97
- * `findingsOf` only ever looks at `review_spec`/`review_quality`, and
98
- * `commitFixNode` decides from git rather than from the model's word.
99
- *
100
- * Never routes `reprompt`: the fix nodes have unconditional edges
101
- * (`fix_spec → commit_spec`), so a reprompt route would have nowhere to go.
102
- */
103
- export function parseFixVerdict(text: string): ReviewVerdict {
104
- const dispositions = parseDispositions(text);
105
- // Route is always "proceed" — nothing downstream reads it (see docstring
106
- // above), and computing it from `parseVerdictJson` is unsafe here: acpx's
107
- // balanced-JSON matcher happily parses a bare `[1]` (the disposition line
108
- // `[1] fixed`) as a one-element JSON array, which would silently flip the
109
- // route to "clean" on a perfectly good disposition-only reply.
110
- try {
111
- return { route: "proceed", findings: parseVerdictJson(text).findings, dispositions };
112
- } catch {
113
- return { route: "proceed", findings: [], dispositions };
114
- }
115
- }
116
-
117
- /**
118
- * How many times this phase's review already came back unparseable.
119
- *
120
- * Counts step *outputs*, not step ids: `commit_quality → review_quality` and
121
- * `commit_gate → review_quality` are legitimate re-entries in the normal fix
122
- * loop, so counting bare `review_<phase>` steps would escalate a healthy run.
123
- *
124
- * This is observable only because `parseReviewVerdict` returns rather than
125
- * throws — a returned verdict makes acpx record the step as successful with
126
- * this output. A throw would record it `failed`, with nothing to count.
127
- *
128
- * SELF-INCLUSIVE, not self-exclusive: acpx's runtime calls
129
- * `recordFlowStepOutcome(runDir, state, step)` (acpx/src/flows/runtime.ts:262),
130
- * which pushes the just-finished step onto `state.steps`
131
- * (acpx/src/flows/runtime.ts:499), BEFORE `resolveNextNode` runs and before the
132
- * following node (`route_<phase>`) executes. So by the time `routeReview` reads
133
- * `ctx.state.steps` here, the current round's own `review_<phase>` step is
134
- * already included. On the very first unparseable reply this already returns
135
- * 1, not 0. `routeReview`'s comparison against `MAX_REPROMPT_ATTEMPTS` MUST
136
- * stay `<=` (not `<`) for that reason — see routeReview below.
137
- */
138
- export function repromptCount(ctx: StepsCtx, phase: "spec" | "quality"): number {
139
- return (ctx.state.steps ?? []).filter(
140
- (s) => s.nodeId === `review_${phase}` && (s.output as ReviewVerdict | undefined)?.route === "reprompt",
141
- ).length;
142
- }
143
-
144
- /**
145
- * Turn a reviewer verdict into a deterministic route.
146
- *
147
- * `clean` (no findings) skips the fix node entirely — prompting an agent to
148
- * "apply the recommended fixes" for an empty finding list burns a turn and
149
- * invites unrequested edits.
150
- *
151
- * The `reprompt` branch MUST come first. A reprompt verdict carries zero
152
- * findings, so checking `findings.length === 0` ahead of it would route an
153
- * unreadable review to `clean`, and the flow would open a PR having reviewed
154
- * nothing. That silent false green is worse than the crash this replaces.
155
- *
156
- * An **absent** verdict escalates for the same reason, and it is a distinct
157
- * case from an unparseable one: `parseReviewVerdict` never returns undefined,
158
- * so a missing entry means the node produced no output at all — it never ran,
159
- * or it died before emitting. Neither is an approval. This must not fall
160
- * through to `findings ?? []`, because `ctx.outputs` holds only each node's
161
- * latest output: on a loop re-entry the previous round's clean verdict can
162
- * still be sitting there, and routing on it re-approves a diff nobody read.
163
- * There is no reprompt path here — a node that emitted nothing has no raw tail
164
- * to quote back, so a human is the only remaining reader.
165
- */
166
- export function routeReview(
167
- ctx: OutputsCtx & StepsCtx,
168
- phase: "spec" | "quality",
169
- ): { route: string; escalationReason?: string; findings: Finding[] } {
170
- const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
171
- if (!verdict) {
172
- return {
173
- route: "escalate",
174
- escalationReason: `${phase} reviewer produced no verdict — the node emitted no output, so nothing reviewed this diff.`,
175
- findings: [],
176
- };
177
- }
178
- const findings = verdict.findings ?? [];
179
- if (verdict.route === "reprompt") {
180
- // `attempts` is self-inclusive (see repromptCount) — it already counts this
181
- // round's failure, so `<=` (not `<`) is what makes MAX_REPROMPT_ATTEMPTS=1
182
- // tolerate exactly one retry before escalating.
183
- const attempts = repromptCount(ctx, phase);
184
- if (attempts <= MAX_REPROMPT_ATTEMPTS) return { route: "reprompt", findings };
185
- return {
186
- route: "escalate",
187
- escalationReason:
188
- `${phase} reviewer returned unparseable output after ${attempts} attempts. ` +
189
- `Last reply: ${verdict.raw ?? "(empty)"}`,
190
- findings,
191
- };
192
- }
193
- if (verdict.route === "escalate") {
194
- return {
195
- route: "escalate",
196
- escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
197
- findings,
198
- };
199
- }
200
- if (findings.length === 0) return { route: "clean", findings };
201
- const attempts = fixAttemptCount(ctx, `fix_${phase}`);
202
- if (attempts >= MAX_FIX_ATTEMPTS) {
203
- return {
204
- route: "escalate",
205
- escalationReason: `${phase} review still reporting ${findings.length} finding(s) after ${attempts} fix attempts.`,
206
- findings,
207
- };
208
- }
209
- return { route: "fix", findings };
210
- }