@nathapp/nax 0.76.0 → 0.77.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 +5073 -3173
- package/flows/nax-finish/flow-ctx.ts +32 -2
- package/flows/nax-finish/narrative.ts +215 -0
- package/flows/nax-finish/nax-finish.flow.ts +98 -74
- package/flows/nax-finish/pr-template.ts +56 -0
- package/flows/nax-finish/pr-title.ts +140 -0
- package/flows/nax-finish/review-prompts.ts +21 -1
- package/flows/nax-finish/steps/index.ts +1 -0
- package/flows/nax-finish/steps/pr-body.ts +410 -0
- package/flows/nax-finish/steps/pr-narrative.ts +46 -0
- package/flows/nax-finish/steps/pr.ts +48 -3
- package/flows/nax-finish/types.ts +19 -4
- package/flows/nax-finish/verdict.ts +159 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
+
}
|