@nathapp/nax 0.80.0 → 0.80.1

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,91 @@
1
+ /**
2
+ * Checking that a review discharged its obligations before its verdict counts.
3
+ *
4
+ * `WORKER_PROTOCOL` has always told the reviewer to enumerate the external
5
+ * touchpoints and open their definitions, and both dimension references have
6
+ * always required a per-item enumeration. Neither was checkable: `routeReview`
7
+ * saw only a route and a finding list, so a reviewer that read the diff and
8
+ * nothing else was indistinguishable from one that did the work — and on the run
9
+ * behind #1614 that is exactly what happened, at 86 seconds for 3,716 changed
10
+ * lines.
11
+ *
12
+ * The disk check is what makes this a gate rather than a ritual. It proves the
13
+ * paths are real, not that they were read: a reviewer can still list files it
14
+ * only globbed. That raises the cost of faking the list without eliminating it,
15
+ * which is the honest ceiling for a check that costs one `stat` per line.
16
+ *
17
+ * A verdict from the legacy JSON parsing tier carries no `saw*` fields (they are
18
+ * optional), so it always reports both gaps and is sent back for one re-review
19
+ * under the new prompt. That is intentional, not a bug: the safe direction is
20
+ * an extra review, never a false approval, and the retry self-corrects because
21
+ * the new prompt contract produces a verdict the gate can actually check.
22
+ *
23
+ * `node:fs` — not `Bun.file` — because `flows/` runs inside acpx's Node process.
24
+ *
25
+ * Both `touchpoint.path` and a disposition's `evidence` come from the
26
+ * reviewer/fixer's reply text — untrusted the same way any parsed LLM output
27
+ * is. `exists()` confines its resolved path under `workdir` before stat-ing
28
+ * it, so a `../`-laden path can never be used to probe existence outside the
29
+ * repo; a path that escapes reads as "does not exist," which is the correct
30
+ * verdict anyway since a legitimate touchpoint is always inside it.
31
+ */
32
+ import { stat } from "node:fs/promises";
33
+ import * as path from "node:path";
34
+ import type { FindingDisposition, ReviewVerdict } from "../types";
35
+
36
+ /** Paths stat-ed per review. A reviewer listing more than this is not the failure mode. */
37
+ const MAX_CHECKED = 20;
38
+
39
+ async function exists(workdir: string, rel: string): Promise<boolean> {
40
+ const root = path.resolve(workdir);
41
+ const resolved = path.resolve(root, rel);
42
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return false;
43
+ try {
44
+ await stat(resolved);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * What this review failed to do. Empty means it may be routed on.
53
+ *
54
+ * Only ever called for a verdict that already parsed; an unreadable reply is the
55
+ * `reprompt` path's business and is handled before this runs.
56
+ */
57
+ export async function auditGaps(verdict: ReviewVerdict, workdir: string): Promise<string[]> {
58
+ const gaps: string[] = [];
59
+ const touchpoints = verdict.touchpoints ?? [];
60
+ if (!verdict.sawTouchpointsSection || touchpoints.length === 0) {
61
+ gaps.push("no `## TOUCHPOINTS` section: list every external definition you opened, or `- none — <justification>`");
62
+ } else if (!touchpoints.some((t) => t.path === "none")) {
63
+ const checked = touchpoints.slice(0, MAX_CHECKED);
64
+ const found = await Promise.all(checked.map((t) => exists(workdir, t.path)));
65
+ if (!found.some(Boolean)) {
66
+ gaps.push(
67
+ `touchpoint path does not exist in the repo (checked: ${checked
68
+ .map((t) => t.path)
69
+ .join(", ")}) — list files you actually opened`,
70
+ );
71
+ }
72
+ }
73
+ if (!verdict.sawWalkSection || (verdict.walk ?? []).length === 0) {
74
+ gaps.push("no `## WALK` section: the per-AC (spec) or per-function (quality) enumeration is required");
75
+ }
76
+ return gaps;
77
+ }
78
+
79
+ /** Mark any rejection whose cited `file:line` does not resolve in the repo. */
80
+ export async function validateDispositions(
81
+ workdir: string,
82
+ dispositions: FindingDisposition[],
83
+ ): Promise<FindingDisposition[]> {
84
+ return Promise.all(
85
+ dispositions.map(async (d) => {
86
+ if (d.disposition !== "rejected" || !d.evidence) return d;
87
+ const file = d.evidence.split(":")[0];
88
+ return (await exists(workdir, file)) ? d : { ...d, evidenceMissing: true };
89
+ }),
90
+ );
91
+ }
@@ -18,15 +18,17 @@
18
18
  */
19
19
  import { inputOf } from "../flow-ctx";
20
20
  import type { OutputsCtx, StepsCtx } from "../flow-ctx";
21
- import type { Finding, FinishRoundOutcome } from "../types";
22
- import { routeReview } from "../verdict";
21
+ import type { Finding, FinishRoundOutcome, ReviewVerdict } from "../types";
22
+ import { MAX_INCOMPLETE_ATTEMPTS, routeReview } from "../verdict";
23
23
  import { appendRound } from "./result";
24
+ import { auditGaps } from "./review-audit";
24
25
 
25
26
  /** Route → what to call the round. `fix` is absent by construction — see below. */
26
27
  const OUTCOME_BY_ROUTE: Record<string, FinishRoundOutcome> = {
27
28
  clean: "passed",
28
29
  reprompt: "unparseable",
29
30
  escalate: "escalated",
31
+ incomplete: "incomplete",
30
32
  };
31
33
 
32
34
  /**
@@ -42,6 +44,20 @@ function reviewAttemptCount(ctx: StepsCtx, phase: "spec" | "quality"): number {
42
44
  return (ctx.state.steps ?? []).filter((s) => s.nodeId === `review_${phase}`).length;
43
45
  }
44
46
 
47
+ /**
48
+ * How many previous rounds of this phase were sent back as incomplete.
49
+ *
50
+ * NOT self-inclusive, unlike `repromptCount` — that one counts `review_<phase>`
51
+ * steps, which acpx has already recorded by the time `route_<phase>` runs, while
52
+ * this counts `route_<phase>` steps and we are *inside* the current one. So the
53
+ * comparison below is `<`, where `routeReview`'s reprompt comparison is `<=`.
54
+ */
55
+ function incompleteCount(ctx: StepsCtx, phase: "spec" | "quality"): number {
56
+ return (ctx.state.steps ?? []).filter(
57
+ (s) => s.nodeId === `route_${phase}` && (s.output as { route?: string } | undefined)?.route === "incomplete",
58
+ ).length;
59
+ }
60
+
45
61
  /**
46
62
  * Route this phase's review verdict, and record the round when it produced no
47
63
  * commit.
@@ -57,18 +73,37 @@ function reviewAttemptCount(ctx: StepsCtx, phase: "spec" | "quality"): number {
57
73
  export async function routeReviewAndRecord(
58
74
  ctx: { input: unknown } & OutputsCtx & StepsCtx,
59
75
  phase: "spec" | "quality",
60
- ): Promise<{ route: string; escalationReason?: string; findings: Finding[] }> {
76
+ ): Promise<{ route: string; escalationReason?: string; findings: Finding[]; gaps?: string[] }> {
61
77
  const routed = routeReview(ctx, phase);
62
- const outcome = OUTCOME_BY_ROUTE[routed.route];
78
+ const input = inputOf(ctx);
79
+ // The gate runs only on a verdict the flow would otherwise act on. `reprompt`
80
+ // and `escalate` already end the round, and re-checking a verdict with no
81
+ // content would report the same two gaps as a second failure mode.
82
+ let result: { route: string; escalationReason?: string; findings: Finding[]; gaps?: string[] } = routed;
83
+ if (routed.route === "clean" || routed.route === "fix") {
84
+ const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
85
+ const gaps = verdict ? await auditGaps(verdict, input.workdir) : [];
86
+ if (gaps.length > 0) {
87
+ result =
88
+ incompleteCount(ctx, phase) < MAX_INCOMPLETE_ATTEMPTS
89
+ ? { ...routed, route: "incomplete", gaps }
90
+ : {
91
+ ...routed,
92
+ route: "escalate",
93
+ escalationReason: `${phase} review never discharged its reading obligations: ${gaps.join("; ")}`,
94
+ };
95
+ }
96
+ }
97
+ const outcome = OUTCOME_BY_ROUTE[result.route];
63
98
  if (outcome) {
64
- await appendRound(inputOf(ctx), {
99
+ await appendRound(input, {
65
100
  ts: new Date().toISOString(),
66
101
  phase,
67
102
  attempt: reviewAttemptCount(ctx, phase),
68
103
  committed: false,
69
104
  outcome,
70
- findings: routed.findings,
105
+ findings: result.findings,
71
106
  });
72
107
  }
73
- return routed;
108
+ return result;
74
109
  }
@@ -15,6 +15,49 @@ export interface Finding {
15
15
  title: string;
16
16
  problem: string;
17
17
  fix: string;
18
+ /**
19
+ * Set when the reviewer marked this finding as needing a human — a spec
20
+ * conflict or a design call with no safe mechanical fix. This replaces the
21
+ * whole-reply `escalate` route the reviewer used to choose: escalation is a
22
+ * property of one finding, not of the phase, so reporting a design concern no
23
+ * longer halts the pipeline by itself.
24
+ */
25
+ judgment?: boolean;
26
+ /** Why this finding needs a human; the escalation reason when it escalates. */
27
+ judgmentReason?: string;
28
+ }
29
+
30
+ /** One external definition the reviewer says it opened before judging. */
31
+ export interface Touchpoint {
32
+ /** Repo-relative path, or the literal `none` sentinel. */
33
+ path: string;
34
+ /** Symbol or line after the final `:`, when the reviewer gave one. */
35
+ symbol?: string;
36
+ /** The reviewer's stated reason for opening it (or for there being none). */
37
+ note: string;
38
+ }
39
+
40
+ /** A reviewer reply, parsed. Sections are reported separately from their content
41
+ * so an *absent* section is distinguishable from an *empty* one — only the first
42
+ * is a reviewer that skipped the obligation. */
43
+ export interface ReviewReport {
44
+ findings: Finding[];
45
+ touchpoints: Touchpoint[];
46
+ walk: string[];
47
+ sawNoFindings: boolean;
48
+ sawTouchpointsSection: boolean;
49
+ sawWalkSection: boolean;
50
+ }
51
+
52
+ /** What the fix node did with one finding it was handed. */
53
+ export interface FindingDisposition {
54
+ /** 1-based index into the findings list the fix prompt numbered. */
55
+ index: number;
56
+ disposition: "fixed" | "rejected";
57
+ /** `file:line` pinning the current behaviour; required for a rejection. */
58
+ evidence?: string;
59
+ /** Set by `commit_<phase>` when the cited evidence path does not exist. */
60
+ evidenceMissing?: boolean;
18
61
  }
19
62
  export interface ReviewVerdict {
20
63
  /**
@@ -33,6 +76,15 @@ export interface ReviewVerdict {
33
76
  escalationReason?: string;
34
77
  /** Bounded tail of an unparseable reply; set only when `route` is `reprompt`. */
35
78
  raw?: string;
79
+ /** Touchpoints the reviewer listed; read by the audit gate in `routeReviewAndRecord`. */
80
+ touchpoints?: Touchpoint[];
81
+ /** The per-AC or per-function walk lines the reviewer emitted. */
82
+ walk?: string[];
83
+ /** Whether the section was present at all — absent and empty are different failures. */
84
+ sawTouchpointsSection?: boolean;
85
+ sawWalkSection?: boolean;
86
+ /** Set on a `fix_<phase>` output: what the fixer did with each finding it was handed. */
87
+ dispositions?: FindingDisposition[];
36
88
  }
37
89
  /** Wall-clock budgets, forwarded from `finish.autoFlow.timeouts` by the plugin. */
38
90
  export interface FinishTimeouts {
@@ -92,7 +144,11 @@ export type FinishRoundOutcome =
92
144
  * new meaning — a reader hitting it in an old artifact must still be told
93
145
  * what it meant when it was written.
94
146
  */
95
- | "review-skipped";
147
+ | "review-skipped"
148
+ /** The reviewer replied with findings but skipped a required audit section, so
149
+ * the verdict was not acted on. Distinct from `unparseable`: there was a
150
+ * readable verdict, it just had no evidence behind it. */
151
+ | "incomplete";
96
152
 
97
153
  export interface FinishRound {
98
154
  ts: string;
@@ -127,6 +183,8 @@ export interface FinishRound {
127
183
  * than by matching round timestamps against `git log`.
128
184
  */
129
185
  sha?: string;
186
+ /** What the fixer did with each finding it was handed (spec/quality phases). */
187
+ dispositions?: FindingDisposition[];
130
188
  }
131
189
 
132
190
  export interface FinishInput {
@@ -12,6 +12,7 @@
12
12
  * `escalate` node that exists to report precisely this.
13
13
  */
14
14
  import { extractJsonObject } from "acpx/flows";
15
+ import { parseDispositions, parseReviewReport } from "./findings-parse";
15
16
  import { type OutputsCtx, type StepsCtx, fixAttemptCount } from "./flow-ctx";
16
17
  import type { Finding, ReviewVerdict } from "./types";
17
18
 
@@ -35,6 +36,15 @@ export const MAX_FIX_ATTEMPTS = 3;
35
36
  */
36
37
  export const MAX_REPROMPT_ATTEMPTS = 1;
37
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
+
38
48
  /** How much of an unparseable reply to carry forward — it lands in a PR comment and a Telegram message. */
39
49
  export const RAW_TAIL_LIMIT = 500;
40
50
 
@@ -52,11 +62,29 @@ function parseVerdictJson(text: string): ReviewVerdict {
52
62
  }
53
63
 
54
64
  /**
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.
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.
58
72
  */
59
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
+ }
60
88
  try {
61
89
  return parseVerdictJson(text);
62
90
  } catch {
@@ -73,10 +101,16 @@ export function parseReviewVerdict(text: string): ReviewVerdict {
73
101
  * (`fix_spec → commit_spec`), so a reprompt route would have nowhere to go.
74
102
  */
75
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.
76
110
  try {
77
- return parseVerdictJson(text);
111
+ return { route: "proceed", findings: parseVerdictJson(text).findings, dispositions };
78
112
  } catch {
79
- return { route: "proceed", findings: [] };
113
+ return { route: "proceed", findings: [], dispositions };
80
114
  }
81
115
  }
82
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.80.0",
3
+ "version": "0.80.1",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {