@nathapp/nax 0.75.6 → 0.76.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.
@@ -1,23 +1,112 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname } from "node:path";
3
- import type { FinishResult } from "../types";
1
+ /**
2
+ * Finish-audit artifacts.
3
+ *
4
+ * These live under nax's global per-project output directory —
5
+ * `~/.nax/<project>/finish-audit/<feature>/` — alongside `prompt-audit/` and
6
+ * `review-audit/`, not in the user's repo. Two reasons the repo was the wrong
7
+ * home: the artifact describes a *run*, not the source tree, so committing it
8
+ * and gitignoring it are both wrong answers; and a per-feature, per-run path
9
+ * makes the history queryable across runs, which a single overwritten
10
+ * `.nax/nax-finish-result.json` never was.
11
+ *
12
+ * The plugin supplies `auditDir` because it owns nax's path SSOT
13
+ * (`src/runtime/paths.ts`), which this module may not import — `flows/` is
14
+ * loaded by acpx, outside nax's own process. Absent, we fall back to a
15
+ * repo-local directory so a hand-run `acpx flow run` still records something.
16
+ *
17
+ * Two files per run:
18
+ * - `<runId>.jsonl` — one line per fix round, appended as it happens
19
+ * - `<runId>.result.json` — the terminal result the plugin reads back
20
+ */
21
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
22
+ import { dirname, join } from "node:path";
23
+ import type { FinishInput, FinishResult, FinishRound } from "../types";
4
24
 
5
- export function resultPath(repoRoot: string): string {
6
- return `${repoRoot}/.nax/nax-finish-result.json`;
25
+ /** Used when the plugin supplied no run id (e.g. a hand-run `acpx flow run`). */
26
+ const FALLBACK_RUN_ID = "run";
27
+
28
+ type AuditTarget = Pick<FinishInput, "auditDir" | "workdir" | "feature" | "runId">;
29
+
30
+ export function resolveAuditDir(input: AuditTarget): string {
31
+ return input.auditDir ?? join(input.workdir, ".nax", "finish-audit", input.feature);
32
+ }
33
+
34
+ export function resultPath(input: AuditTarget): string {
35
+ return join(resolveAuditDir(input), `${input.runId || FALLBACK_RUN_ID}.result.json`);
7
36
  }
8
37
 
9
- export const _resultDeps: { writeText: (p: string, s: string) => Promise<void> } = {
38
+ export function roundsPath(input: AuditTarget): string {
39
+ return join(resolveAuditDir(input), `${input.runId || FALLBACK_RUN_ID}.jsonl`);
40
+ }
41
+
42
+ export const _resultDeps: {
43
+ writeText: (p: string, s: string) => Promise<void>;
44
+ appendText: (p: string, s: string) => Promise<void>;
45
+ readText: (p: string) => Promise<string | null>;
46
+ } = {
10
47
  // node:fs, not Bun.write — this module runs inside acpx's Node process, where
11
48
  // the `Bun` global does not exist (see the header of `../exec.ts`). The mkdir
12
49
  // is not redundant: Bun.write creates missing parent directories implicitly,
13
- // writeFile does not, and this is the one artifact the plugin needs on disk to
14
- // report an outcome at all.
50
+ // writeFile does not, and the audit directory now lives under `~/.nax/`,
51
+ // where for a project's first run nothing on the path exists yet.
15
52
  writeText: async (p, s) => {
16
53
  await mkdir(dirname(p), { recursive: true });
17
54
  await writeFile(p, s, "utf8");
18
55
  },
56
+ appendText: async (p, s) => {
57
+ await mkdir(dirname(p), { recursive: true });
58
+ await writeFile(p, s, { encoding: "utf8", flag: "a" });
59
+ },
60
+ readText: async (p) => {
61
+ try {
62
+ return await readFile(p, "utf8");
63
+ } catch {
64
+ return null;
65
+ }
66
+ },
19
67
  };
20
68
 
21
- export async function writeResult(repoRoot: string, result: FinishResult): Promise<void> {
22
- await _resultDeps.writeText(resultPath(repoRoot), `${JSON.stringify(result, null, 2)}\n`);
69
+ /**
70
+ * Append one fix round to the run's audit trail.
71
+ *
72
+ * Best-effort: an unwritable audit directory must not take the flow down
73
+ * mid-loop. The round is a record of work already done — losing the record is
74
+ * bad, losing the run that did the work is worse.
75
+ */
76
+ export async function appendRound(input: AuditTarget, round: FinishRound): Promise<void> {
77
+ try {
78
+ await _resultDeps.appendText(roundsPath(input), `${JSON.stringify(round)}\n`);
79
+ } catch {
80
+ // Intentionally swallowed — see the doc comment above.
81
+ }
82
+ }
83
+
84
+ /** Read back every round recorded for this run, so a terminal result can embed them. */
85
+ export async function readRounds(input: AuditTarget): Promise<FinishRound[]> {
86
+ const raw = await _resultDeps.readText(roundsPath(input));
87
+ if (!raw) return [];
88
+ const rounds: FinishRound[] = [];
89
+ for (const line of raw.split("\n")) {
90
+ if (!line.trim()) continue;
91
+ try {
92
+ rounds.push(JSON.parse(line) as FinishRound);
93
+ } catch {
94
+ // A torn final line (killed mid-write) must not lose the rounds before it.
95
+ }
96
+ }
97
+ return rounds;
98
+ }
99
+
100
+ /**
101
+ * Write the terminal result, embedding every round this run recorded.
102
+ *
103
+ * Rounds are attached on *every* status, not just `escalated`: a finish that
104
+ * succeeded after four rounds is precisely the case worth auditing — it says
105
+ * the run's own review gates missed four defects — and it was the one case
106
+ * that previously recorded nothing at all.
107
+ */
108
+ export async function writeResult(input: AuditTarget, result: FinishResult): Promise<void> {
109
+ const rounds = await readRounds(input);
110
+ const withRounds: FinishResult = rounds.length > 0 ? { ...result, rounds } : result;
111
+ await _resultDeps.writeText(resultPath(input), `${JSON.stringify(withRounds, null, 2)}\n`);
23
112
  }
@@ -29,11 +29,46 @@ export interface FinishTimeouts {
29
29
  acceptanceMs?: number;
30
30
  gateMs?: number;
31
31
  }
32
+ /** The four fix-and-reverify loops, in graph order. */
33
+ export type FinishPhase = "acceptance" | "spec" | "quality" | "gate";
34
+
35
+ /**
36
+ * One completed fix round, appended to the audit trail as it happens.
37
+ *
38
+ * Rounds are appended at `commit_<phase>` as they happen rather than
39
+ * reconstructed by a terminal node from `ctx.state.steps` (which does retain
40
+ * every step's output). Appending live is what makes the trail survive a flow
41
+ * that is killed or times out: no terminal node runs on those paths, and a
42
+ * finish that died mid-loop is exactly when the record of what it already
43
+ * changed on the branch matters most.
44
+ */
45
+ export interface FinishRound {
46
+ ts: string;
47
+ phase: FinishPhase;
48
+ /** 1-based; the Nth time this phase's fix node has run. */
49
+ attempt: number;
50
+ /** True when the fix produced a commit; false when it changed nothing. */
51
+ committed: boolean;
52
+ /** Reviewer findings this round set out to fix (spec/quality phases). */
53
+ findings: Finding[];
54
+ /** Gate commands that were red this round (gate phase). */
55
+ failing?: string[];
56
+ }
57
+
32
58
  export interface FinishInput {
33
59
  feature: string;
34
60
  workdir: string;
35
61
  branch: string;
36
62
  prdPath: string;
63
+ /**
64
+ * Directory for this feature's finish-audit artifacts, e.g.
65
+ * `~/.nax/<project>/finish-audit/<feature>`. Supplied by the plugin, which
66
+ * owns nax's path SSOT (`src/runtime/paths.ts`) that this module may not
67
+ * import. Absent → the flow falls back to a repo-local directory.
68
+ */
69
+ auditDir?: string;
70
+ /** Run id, used to name this run's audit files. Absent → "run". */
71
+ runId?: string;
37
72
  /**
38
73
  * True only when Telegram escalation is both enabled *and* credentialed, as
39
74
  * determined by the plugin. When true the flow skips the PR/MR comment
@@ -62,6 +97,13 @@ export interface FinishResult {
62
97
  * rather than lost.
63
98
  */
64
99
  deliveryError?: string;
100
+ /**
101
+ * Every fix round the flow ran, on *all* terminal statuses — not just
102
+ * escalations. A successful finish that took four rounds to get there is the
103
+ * case worth auditing (it says the run's own review gates missed four
104
+ * defects), and it was previously the one case that recorded nothing.
105
+ */
106
+ rounds?: FinishRound[];
65
107
  }
66
108
  export interface RunResult {
67
109
  exitCode: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.75.6",
3
+ "version": "0.76.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {