@nathapp/nax 0.73.5 → 0.74.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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Self-contained error type for the nax-finish flow.
3
+ *
4
+ * The flow module is loaded by `acpx flow run` from wherever `flows/` happens
5
+ * to be installed, in acpx's own process, with the *user's* repo as cwd. It
6
+ * therefore cannot import from nax's `src/` — neither via the `@/*` path alias
7
+ * (which only resolves through nax's own tsconfig) nor via a relative path
8
+ * (only `flows/` is published, not `src/`). `FinishError` mirrors `NaxError`'s
9
+ * shape (message + machine-readable code + structured context) so escalation
10
+ * output and logs stay consistent with the rest of nax.
11
+ */
12
+ export class FinishError extends Error {
13
+ readonly code: string;
14
+ readonly context: Record<string, unknown>;
15
+
16
+ constructor(message: string, code: string, context: Record<string, unknown> = {}) {
17
+ const { cause, ...rest } = context;
18
+ super(message, cause === undefined ? undefined : { cause });
19
+ this.name = "FinishError";
20
+ this.code = code;
21
+ this.context = rest;
22
+ }
23
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Subprocess execution for the nax-finish flow.
3
+ *
4
+ * Two distinct entry points, deliberately:
5
+ *
6
+ * - `runArgv` — for commands *this flow* constructs (`git`, `gh`, `glab`). An
7
+ * argv array is spawned directly: no shell, so no quoting or injection
8
+ * surface for branch names and review text.
9
+ * - `runShell` — for command *strings the user configured* (`quality.commands`,
10
+ * `acceptance.command`). These are run through `/bin/sh -c`, matching
11
+ * `src/quality/runner.ts`, which is the only way to preserve `&&`, quoting,
12
+ * globs and env prefixes. Splitting such a string on whitespace and spawning
13
+ * argv (the previous behaviour) silently mis-ran every non-trivial command.
14
+ *
15
+ * Both cap wall-clock time: an unbounded gate would hang `acpx flow run`, and
16
+ * the post-run plugin awaits that subprocess.
17
+ */
18
+ import type { RunResult } from "./types";
19
+
20
+ /** Fallbacks used when the plugin passes no explicit budget in the flow input. */
21
+ export const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 600_000;
22
+ export const DEFAULT_GATE_TIMEOUT_MS = 900_000;
23
+ /** Short budget for the flow's own git/forge plumbing — these are never long-running. */
24
+ export const DEFAULT_ARGV_TIMEOUT_MS = 120_000;
25
+
26
+ export interface ExecOptions {
27
+ cwd: string;
28
+ timeoutMs?: number;
29
+ }
30
+
31
+ async function spawnCapture(cmd: string[], opts: ExecOptions): Promise<RunResult> {
32
+ const proc = Bun.spawn(cmd, { cwd: opts.cwd, stdout: "pipe", stderr: "pipe" });
33
+ let timedOut = false;
34
+ // setTimeout (not Bun.sleep) because the handle must be cancellable the moment
35
+ // the process exits — the documented exception in forbidden-patterns.md.
36
+ const timer =
37
+ opts.timeoutMs && opts.timeoutMs > 0
38
+ ? setTimeout(() => {
39
+ timedOut = true;
40
+ proc.kill();
41
+ }, opts.timeoutMs)
42
+ : undefined;
43
+ try {
44
+ const [exitCode, stdout, stderr] = await Promise.all([
45
+ proc.exited,
46
+ new Response(proc.stdout).text(),
47
+ new Response(proc.stderr).text(),
48
+ ]);
49
+ return timedOut
50
+ ? {
51
+ exitCode: exitCode === 0 ? 124 : exitCode,
52
+ stdout,
53
+ stderr: `${stderr}\n[nax-finish] killed after ${opts.timeoutMs}ms timeout`,
54
+ timedOut: true,
55
+ }
56
+ : { exitCode, stdout, stderr };
57
+ } finally {
58
+ if (timer) clearTimeout(timer);
59
+ }
60
+ }
61
+
62
+ /** Spawn an argv array directly — no shell. For flow-constructed commands. */
63
+ export function runArgv(cmd: string[], opts: ExecOptions): Promise<RunResult> {
64
+ return spawnCapture(cmd, { ...opts, timeoutMs: opts.timeoutMs ?? DEFAULT_ARGV_TIMEOUT_MS });
65
+ }
66
+
67
+ /** Run a configured command string through `/bin/sh -c`, preserving shell semantics. */
68
+ export function runShell(command: string, opts: ExecOptions): Promise<RunResult> {
69
+ return spawnCapture(["/bin/sh", "-c", command], opts);
70
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * nax-finish: autonomous acpx flow driving a completed feature branch to a
3
+ * ready PR (or an escalation) — acceptance gate, spec review, quality
4
+ * review, quality gates, each iterated with an LLM fix-and-reverify loop.
5
+ *
6
+ * Reviewer agent profiles (review_spec / review_quality) are read from
7
+ * NAX_FINISH_SPEC_PROFILE / NAX_FINISH_QUALITY_PROFILE at module load time.
8
+ * The post-run plugin that invokes `acpx flow run` sets these env vars from
9
+ * `finish.autoFlow.reviewers.{spec,quality}` config before spawning, since
10
+ * this flow module reloads fresh on every `acpx flow run` invocation.
11
+ * Unset → both fall back to acpx's `--default-agent`.
12
+ *
13
+ * This module is loaded by acpx, from wherever `flows/` is installed, with the
14
+ * user's repo as cwd — so it never imports from nax's `src/` (see ./errors.ts).
15
+ *
16
+ * Graph shape (deviations from the design doc are deliberate and noted):
17
+ * - `load_ctx` is an `action`, not a `compute`: it shells git + `nax features
18
+ * resolve` once, and its output feeds both the review prompts (specPath) and
19
+ * the acceptance gate (groups), so nothing resolves the feature twice.
20
+ * - Review fixes loop: `review_* → route_* → fix_* → (re-run acceptance |
21
+ * re-review) → review_*` until the reviewer comes back clean or the fix cap
22
+ * trips. A single-shot fix left the fixed diff unverified.
23
+ * - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
24
+ * enforced deterministically rather than trusting the model's own route.
25
+ */
26
+ import { defineFlow, extractJsonObject } from "acpx/flows";
27
+ import { buildReviewPrompt, fixPrompt } from "./review-prompts";
28
+ import {
29
+ _contextDeps,
30
+ buildEscalationComment,
31
+ commitAndPush,
32
+ detectBaseBranch,
33
+ loadQualityCommands,
34
+ openOrPromotePr,
35
+ postEscalation,
36
+ preflight,
37
+ resolveFeature,
38
+ runAcceptanceGate,
39
+ runQualityGates,
40
+ writeResult,
41
+ } from "./steps";
42
+ import type { AcceptanceGroup, FinishInput, ReviewVerdict } from "./types";
43
+
44
+ const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
45
+
46
+ /**
47
+ * Cap on fix-and-reverify iterations, per phase, before escalating instead of
48
+ * looping forever. acpx's flow engine has no built-in cycle guard, so without
49
+ * this cap a stubborn failure (LLM can't fix it, or fixes something else each
50
+ * time) hangs `acpx flow run` — and the post-run plugin awaits that subprocess.
51
+ */
52
+ const MAX_FIX_ATTEMPTS = 3;
53
+
54
+ interface LoadCtxOutput {
55
+ base?: string;
56
+ specPath?: string;
57
+ groups?: AcceptanceGroup[];
58
+ route?: string;
59
+ }
60
+
61
+ function fixAttemptCount(ctx: { state: { steps: { nodeId: string }[] } }, fixNodeId: string): number {
62
+ return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
63
+ }
64
+
65
+ function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
66
+ return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
67
+ }
68
+
69
+ /** Re-run the acceptance gate, routing on the shared fix-cap rules. */
70
+ async function acceptanceGateNode(ctx: {
71
+ input: unknown;
72
+ outputs: unknown;
73
+ state: { steps: { nodeId: string }[] };
74
+ }): Promise<{ route: string; reason?: string; output: string }> {
75
+ const i = inputOf(ctx);
76
+ const groups = loadCtxOf(ctx).groups ?? [];
77
+ const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
78
+ if (r.passed) return { route: "proceed", output: r.output };
79
+ const attempts = fixAttemptCount(ctx, "fix_acceptance");
80
+ if (attempts >= MAX_FIX_ATTEMPTS) {
81
+ return {
82
+ route: "escalate",
83
+ reason: `Acceptance tests still failing after ${attempts} fix attempts.`,
84
+ output: r.output,
85
+ };
86
+ }
87
+ return { route: "fix", output: r.output };
88
+ }
89
+
90
+ /**
91
+ * Turn a reviewer verdict into a deterministic route.
92
+ *
93
+ * `clean` (no findings) skips the fix node entirely — prompting an agent to
94
+ * "apply the recommended fixes" for an empty finding list burns a turn and
95
+ * invites unrequested edits.
96
+ */
97
+ function routeReview(
98
+ ctx: { outputs: unknown; state: { steps: { nodeId: string }[] } },
99
+ phase: "spec" | "quality",
100
+ ): { route: string; escalationReason?: string; findings: ReviewVerdict["findings"] } {
101
+ const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
102
+ const findings = verdict?.findings ?? [];
103
+ if (verdict?.route === "escalate") {
104
+ return {
105
+ route: "escalate",
106
+ escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
107
+ findings,
108
+ };
109
+ }
110
+ if (findings.length === 0) return { route: "clean", findings };
111
+ const attempts = fixAttemptCount(ctx, `fix_${phase}`);
112
+ if (attempts >= MAX_FIX_ATTEMPTS) {
113
+ return {
114
+ route: "escalate",
115
+ escalationReason: `${phase} review still reporting ${findings.length} finding(s) after ${attempts} fix attempts.`,
116
+ findings,
117
+ };
118
+ }
119
+ return { route: "fix", findings };
120
+ }
121
+
122
+ /** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
123
+ function parseVerdict(text: string): ReviewVerdict {
124
+ const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
125
+ const findings = Array.isArray(raw.findings) ? raw.findings : [];
126
+ const route = raw.route === "escalate" ? "escalate" : findings.length === 0 ? "clean" : "proceed";
127
+ return { route, findings, escalationReason: raw.escalationReason };
128
+ }
129
+
130
+ export default defineFlow({
131
+ name: "nax-finish",
132
+ permissions: {
133
+ requiredMode: "approve-all",
134
+ requireExplicitGrant: true,
135
+ reason: "This flow edits files, pushes commits, runs quality gates, comments on and opens/promotes PRs.",
136
+ },
137
+ startAt: "load_ctx",
138
+ nodes: {
139
+ load_ctx: {
140
+ nodeType: "action",
141
+ async run(ctx) {
142
+ const i = inputOf(ctx);
143
+ const base = await detectBaseBranch(i.workdir);
144
+ const resolution = await resolveFeature(i.feature, i.workdir);
145
+ const pf = await preflight(i.workdir, base);
146
+ return {
147
+ base,
148
+ specPath: resolution.specPath,
149
+ acceptanceStatus: resolution.acceptanceStatus,
150
+ groups: resolution.groups,
151
+ commitsAhead: pf.commitsAhead,
152
+ route: pf.route,
153
+ };
154
+ },
155
+ },
156
+ acceptance: {
157
+ nodeType: "action",
158
+ run: acceptanceGateNode,
159
+ },
160
+ fix_acceptance: {
161
+ nodeType: "acp",
162
+ prompt: (ctx) => fixPrompt("acceptance", ctx),
163
+ parse: parseVerdict,
164
+ },
165
+ review_spec: {
166
+ nodeType: "acp",
167
+ session: { isolated: true },
168
+ profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
169
+ prompt(ctx) {
170
+ const outs = loadCtxOf(ctx);
171
+ return buildReviewPrompt("spec", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
172
+ },
173
+ parse: parseVerdict,
174
+ },
175
+ route_spec: {
176
+ nodeType: "compute",
177
+ run: (ctx) => routeReview(ctx, "spec"),
178
+ },
179
+ fix_spec: {
180
+ nodeType: "acp",
181
+ prompt: (ctx) => fixPrompt("spec", ctx),
182
+ parse: parseVerdict,
183
+ },
184
+ review_quality: {
185
+ nodeType: "acp",
186
+ session: { isolated: true },
187
+ profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
188
+ prompt(ctx) {
189
+ const outs = loadCtxOf(ctx);
190
+ return buildReviewPrompt("quality", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
191
+ },
192
+ parse: parseVerdict,
193
+ },
194
+ route_quality: {
195
+ nodeType: "compute",
196
+ run: (ctx) => routeReview(ctx, "quality"),
197
+ },
198
+ fix_quality: {
199
+ nodeType: "acp",
200
+ prompt: (ctx) => fixPrompt("quality", ctx),
201
+ parse: parseVerdict,
202
+ },
203
+ fix_gate: {
204
+ nodeType: "acp",
205
+ prompt: (ctx) => fixPrompt("gate", ctx),
206
+ parse: parseVerdict,
207
+ },
208
+ quality_gates: {
209
+ nodeType: "action",
210
+ async run(ctx) {
211
+ const i = inputOf(ctx);
212
+ const cmds = await loadQualityCommands(i.workdir);
213
+ const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
214
+ if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
215
+ // Nothing configured is not a pass — escalate immediately rather than
216
+ // open a "ready" PR having verified nothing. An LLM fix node cannot
217
+ // invent the repo's build/test commands.
218
+ if (r.ran.length === 0) {
219
+ return {
220
+ route: "escalate",
221
+ reason: "No quality.commands configured in .nax/config.json — nax-finish verified nothing.",
222
+ ran: r.ran,
223
+ failing: r.failing,
224
+ output: r.output,
225
+ };
226
+ }
227
+ const attempts = fixAttemptCount(ctx, "fix_gate");
228
+ if (attempts >= MAX_FIX_ATTEMPTS) {
229
+ return {
230
+ route: "escalate",
231
+ reason: `Quality gates still failing after ${attempts} fix attempts (${r.failing.join(", ")}).`,
232
+ ran: r.ran,
233
+ failing: r.failing,
234
+ output: r.output,
235
+ };
236
+ }
237
+ return { route: "fix", ran: r.ran, failing: r.failing, output: r.output };
238
+ },
239
+ },
240
+ open_pr: {
241
+ nodeType: "action",
242
+ async run(ctx) {
243
+ const i = inputOf(ctx);
244
+ if (loadCtxOf(ctx).route === "nothing-to-finish") {
245
+ await writeResult(i.workdir, { feature: i.feature, status: "nothing-to-finish" });
246
+ return { route: "done", status: "nothing-to-finish" };
247
+ }
248
+ // Every fix node edited the working tree; without this the PR would be
249
+ // opened from a remote branch missing all of them.
250
+ const sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
251
+ const r = await openOrPromotePr(
252
+ i.workdir,
253
+ i.branch,
254
+ `nax-finish: ${i.feature}`,
255
+ `Automated finish of \`${i.feature}\`.`,
256
+ );
257
+ await writeResult(i.workdir, { feature: i.feature, status: r.status, url: r.url });
258
+ return { route: "done", committed: sync.committed, ...r };
259
+ },
260
+ },
261
+ escalate: {
262
+ nodeType: "action",
263
+ async run(ctx) {
264
+ const i = inputOf(ctx);
265
+ const outs = ctx.outputs as Record<string, { route?: string; reason?: string } | undefined>;
266
+ const routed = ctx.outputs as Record<string, ReviewVerdict | undefined>;
267
+ const verdict =
268
+ routed.route_spec?.route === "escalate"
269
+ ? routed.route_spec
270
+ : routed.route_quality?.route === "escalate"
271
+ ? routed.route_quality
272
+ : undefined;
273
+ const loopExhausted =
274
+ outs.acceptance?.route === "escalate"
275
+ ? outs.acceptance
276
+ : outs.quality_gates?.route === "escalate"
277
+ ? outs.quality_gates
278
+ : undefined;
279
+ const reason =
280
+ verdict?.escalationReason ?? loopExhausted?.reason ?? "nax-finish could not reach a green, shippable state";
281
+
282
+ // Push what was fixed so the escalation describes state a human can see.
283
+ // A push failure must not swallow the escalation itself, so it is
284
+ // reported in the message rather than thrown.
285
+ let syncNote = "";
286
+ try {
287
+ await commitAndPush(i.workdir, i.branch, `wip(${i.feature}): nax-finish partial fixes before escalation`);
288
+ } catch (err) {
289
+ syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
290
+ }
291
+
292
+ const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
293
+ const { url, channel } = await postEscalation(i.workdir, i.branch, comment, {
294
+ preferTelegram: i.escalateTelegram,
295
+ });
296
+ await writeResult(i.workdir, { feature: i.feature, status: "escalated", url, escalationReason: reason });
297
+ return { route: "done", url, channel, escalationReason: reason };
298
+ },
299
+ },
300
+ },
301
+ edges: [
302
+ { from: "load_ctx", switch: { on: "$.route", cases: { proceed: "acceptance", "nothing-to-finish": "open_pr" } } },
303
+ {
304
+ from: "acceptance",
305
+ switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
306
+ },
307
+ { from: "fix_acceptance", to: "acceptance" },
308
+ { from: "review_spec", to: "route_spec" },
309
+ {
310
+ from: "route_spec",
311
+ switch: { on: "$.route", cases: { clean: "review_quality", fix: "fix_spec", escalate: "escalate" } },
312
+ },
313
+ // Spec fixes re-run the acceptance gate first (they can break it), and the
314
+ // acceptance node's `proceed` edge leads back into review_spec for re-review.
315
+ { from: "fix_spec", to: "acceptance" },
316
+ { from: "review_quality", to: "route_quality" },
317
+ {
318
+ from: "route_quality",
319
+ switch: { on: "$.route", cases: { clean: "quality_gates", fix: "fix_quality", escalate: "escalate" } },
320
+ },
321
+ // Quality fixes are re-reviewed by the same lens; the repo-root gates that
322
+ // follow catch anything the fix broke mechanically.
323
+ { from: "fix_quality", to: "review_quality" },
324
+ {
325
+ from: "quality_gates",
326
+ switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
327
+ },
328
+ { from: "fix_gate", to: "quality_gates" },
329
+ ],
330
+ });