@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.
- package/dist/nax.js +1764 -1646
- package/flows/nax-finish/commit-message.ts +34 -10
- package/flows/nax-finish/findings-parse.ts +150 -0
- package/flows/nax-finish/flow-ctx.ts +5 -0
- package/flows/nax-finish/nax-finish.flow.ts +42 -8
- package/flows/nax-finish/review-prompts.ts +103 -40
- package/flows/nax-finish/steps/commit-round.ts +4 -1
- package/flows/nax-finish/steps/index.ts +1 -0
- package/flows/nax-finish/steps/pr-body.ts +22 -2
- package/flows/nax-finish/steps/review-audit.ts +91 -0
- package/flows/nax-finish/steps/review-round.ts +42 -7
- package/flows/nax-finish/types.ts +59 -1
- package/flows/nax-finish/verdict.ts +39 -5
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
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:
|
|
105
|
+
findings: result.findings,
|
|
71
106
|
});
|
|
72
107
|
}
|
|
73
|
-
return
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|