@nathapp/nax 0.73.4 → 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,8 @@
1
+ export * from "./context";
2
+ export * from "./acceptance";
3
+ export * from "./quality";
4
+ export * from "./escalate";
5
+ export * from "./forge";
6
+ export * from "./git";
7
+ export * from "./pr";
8
+ export * from "./result";
@@ -0,0 +1,71 @@
1
+ import { FinishError } from "../errors";
2
+ import { runArgv } from "../exec";
3
+ import type { RunFn } from "../types";
4
+ import { type Forge, detectForge, extractUrl, viewArgv } from "./forge";
5
+
6
+ export const _prDeps: { run: RunFn } = { run: runArgv };
7
+
8
+ /**
9
+ * Parse `gh pr view --json isDraft,url` / `glab mr view --output json` stdout.
10
+ *
11
+ * GitHub's schema is well-defined: `{ isDraft, url }`. GitLab's `glab mr view --output json`
12
+ * schema is not guaranteed to expose an equivalent boolean under a stable name across
13
+ * versions, so this is best-effort: it checks a few plausible field names for draft status
14
+ * and falls back to treating any successfully-parsed MR as ready (not draft) when none are
15
+ * found — per the task brief, "treat any successful view as already-ready unless you find
16
+ * clear evidence otherwise."
17
+ */
18
+ function parseView(stdout: string, forge: Forge): { isDraft: boolean; url?: string } {
19
+ try {
20
+ const parsed = JSON.parse(stdout) as Record<string, unknown>;
21
+ if (forge === "github") {
22
+ return { isDraft: parsed.isDraft === true, url: typeof parsed.url === "string" ? parsed.url : undefined };
23
+ }
24
+ const isDraft = parsed.isDraft === true || parsed.draft === true || parsed.work_in_progress === true;
25
+ return { isDraft, url: extractUrl(stdout) };
26
+ } catch {
27
+ return { isDraft: false, url: extractUrl(stdout) };
28
+ }
29
+ }
30
+
31
+ export async function openOrPromotePr(
32
+ repoRoot: string,
33
+ branch: string,
34
+ title: string,
35
+ body: string,
36
+ ): Promise<{ status: "opened" | "promoted" | "already-ready"; url?: string }> {
37
+ const forge = await detectForge(_prDeps.run, repoRoot, "finish-pr");
38
+ const view = await _prDeps.run(viewArgv(forge, branch, "isDraft,url"), { cwd: repoRoot });
39
+
40
+ if (view.exitCode !== 0) {
41
+ const createCmd =
42
+ forge === "github"
43
+ ? ["gh", "pr", "create", "--title", title, "--body", body, "--head", branch]
44
+ : ["glab", "mr", "create", "--title", title, "--description", body, "--source-branch", branch];
45
+ const create = await _prDeps.run(createCmd, { cwd: repoRoot });
46
+ if (create.exitCode !== 0) {
47
+ throw new FinishError(
48
+ `Failed to create PR/MR for "${branch}": ${create.stderr.trim() || `exit ${create.exitCode}`}`,
49
+ "FINISH_PR_CREATE_FAILED",
50
+ { stage: "finish-pr", branch },
51
+ );
52
+ }
53
+ return { status: "opened", url: extractUrl(create.stdout) };
54
+ }
55
+
56
+ const { isDraft, url } = parseView(view.stdout, forge);
57
+ if (isDraft) {
58
+ const readyCmd = forge === "github" ? ["gh", "pr", "ready", branch] : ["glab", "mr", "update", branch, "--ready"];
59
+ const ready = await _prDeps.run(readyCmd, { cwd: repoRoot });
60
+ if (ready.exitCode !== 0) {
61
+ throw new FinishError(
62
+ `Failed to promote PR/MR "${branch}" to ready: ${ready.stderr.trim() || `exit ${ready.exitCode}`}`,
63
+ "FINISH_PR_PROMOTE_FAILED",
64
+ { stage: "finish-pr", branch },
65
+ );
66
+ }
67
+ return { status: "promoted", url };
68
+ }
69
+
70
+ return { status: "already-ready", url };
71
+ }
@@ -0,0 +1,93 @@
1
+ import { FinishError } from "../errors";
2
+ import { DEFAULT_GATE_TIMEOUT_MS, runShell } from "../exec";
3
+ import type { ShellRunFn } from "../types";
4
+
5
+ export interface QualityCommands {
6
+ build?: string;
7
+ typecheck?: string;
8
+ lint?: string;
9
+ test?: string;
10
+ format?: string;
11
+ }
12
+
13
+ export const _qualityDeps: { runShell: ShellRunFn; readText: (path: string) => Promise<string | null> } = {
14
+ runShell,
15
+ readText: async (path) => {
16
+ const file = Bun.file(path);
17
+ return (await file.exists()) ? await file.text() : null;
18
+ },
19
+ };
20
+
21
+ const GATE_ORDER: (keyof QualityCommands)[] = ["build", "typecheck", "lint", "test", "format"];
22
+
23
+ export interface QualityGateOutcome {
24
+ passed: boolean;
25
+ /** Gate names that actually ran — empty means nothing was configured. */
26
+ ran: string[];
27
+ failing: string[];
28
+ output: string;
29
+ }
30
+
31
+ /**
32
+ * Run the repo's configured quality commands in order, each through
33
+ * `/bin/sh -c` (matching `src/quality/runner.ts`) so `&&`, quoting and globs
34
+ * survive, and each under a wall-clock cap so a hung gate can't stall the flow.
35
+ *
36
+ * `passed` is only true when at least one gate ran. A repo with no configured
37
+ * commands must not report a green gate — that previously let the flow open a
38
+ * "ready" PR having verified nothing.
39
+ */
40
+ export async function runQualityGates(
41
+ repoRoot: string,
42
+ commands: QualityCommands,
43
+ opts: { timeoutMs?: number } = {},
44
+ ): Promise<QualityGateOutcome> {
45
+ const failing: string[] = [];
46
+ const ran: string[] = [];
47
+ const chunks: string[] = [];
48
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
49
+ for (const gate of GATE_ORDER) {
50
+ const command = commands[gate];
51
+ if (!command) continue;
52
+ ran.push(gate);
53
+ const res = await _qualityDeps.runShell(command, { cwd: repoRoot, timeoutMs });
54
+ chunks.push(`[${gate}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
55
+ if (res.exitCode !== 0) failing.push(gate);
56
+ }
57
+ if (ran.length === 0) {
58
+ return {
59
+ passed: false,
60
+ ran,
61
+ failing: [],
62
+ output: "[quality] no quality.commands configured in .nax/config.json — nothing was verified",
63
+ };
64
+ }
65
+ return { passed: failing.length === 0, ran, failing, output: chunks.join("\n\n") };
66
+ }
67
+
68
+ /**
69
+ * Read `quality.commands` from the repo-root `.nax/config.json` only.
70
+ *
71
+ * Deliberately root-scoped: per-package `.nax/mono/<pkg>/config.json` overrides
72
+ * are unreliable as a repo-root gate (a package's own `test` command does not
73
+ * verify the repo), and this gate runs once at the root by design.
74
+ */
75
+ export async function loadQualityCommands(workdir: string): Promise<QualityCommands> {
76
+ const text = await _qualityDeps.readText(`${workdir}/.nax/config.json`);
77
+ if (!text) return {};
78
+ let cfg: { quality?: { commands?: QualityCommands } };
79
+ try {
80
+ cfg = JSON.parse(text);
81
+ } catch (cause) {
82
+ throw new FinishError(
83
+ "Failed to parse .nax/config.json while loading quality commands",
84
+ "FINISH_CONFIG_UNPARSEABLE",
85
+ {
86
+ stage: "finish-quality",
87
+ path: `${workdir}/.nax/config.json`,
88
+ cause,
89
+ },
90
+ );
91
+ }
92
+ return cfg.quality?.commands ?? {};
93
+ }
@@ -0,0 +1,15 @@
1
+ import type { FinishResult } from "../types";
2
+
3
+ export function resultPath(repoRoot: string): string {
4
+ return `${repoRoot}/.nax/nax-finish-result.json`;
5
+ }
6
+
7
+ export const _resultDeps: { writeText: (p: string, s: string) => Promise<void> } = {
8
+ writeText: async (p, s) => {
9
+ await Bun.write(p, s);
10
+ },
11
+ };
12
+
13
+ export async function writeResult(repoRoot: string, result: FinishResult): Promise<void> {
14
+ await _resultDeps.writeText(resultPath(repoRoot), `${JSON.stringify(result, null, 2)}\n`);
15
+ }
@@ -0,0 +1,59 @@
1
+ /** One acceptance-test group as reported by `nax features resolve --json`. */
2
+ export interface AcceptanceGroup {
3
+ packageDir: string;
4
+ testPath: string;
5
+ exists: boolean;
6
+ command?: string;
7
+ language: string;
8
+ }
9
+
10
+ export type Severity = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW";
11
+ export interface Finding {
12
+ severity: Severity;
13
+ title: string;
14
+ problem: string;
15
+ fix: string;
16
+ }
17
+ export interface ReviewVerdict {
18
+ /**
19
+ * `clean` is not a model-produced route — the review nodes' `parse` rewrites
20
+ * `proceed` with zero findings to `clean` so the graph can skip the fix node
21
+ * entirely instead of prompting an agent to "apply fixes" for nothing.
22
+ */
23
+ route: "proceed" | "escalate" | "clean";
24
+ findings: Finding[];
25
+ escalationReason?: string;
26
+ }
27
+ /** Wall-clock budgets, forwarded from `finish.autoFlow.timeouts` by the plugin. */
28
+ export interface FinishTimeouts {
29
+ acceptanceMs?: number;
30
+ gateMs?: number;
31
+ }
32
+ export interface FinishInput {
33
+ feature: string;
34
+ workdir: string;
35
+ branch: string;
36
+ prdPath: string;
37
+ /**
38
+ * True only when Telegram escalation is both enabled *and* credentialed, as
39
+ * determined by the plugin. When true the flow skips the PR/MR comment
40
+ * fallback (and does not open a draft to hold one) — the plugin sends the
41
+ * Telegram message from the result file instead.
42
+ */
43
+ escalateTelegram: boolean;
44
+ timeouts?: FinishTimeouts;
45
+ }
46
+ export interface FinishResult {
47
+ feature: string;
48
+ status: "opened" | "promoted" | "already-ready" | "escalated" | "nothing-to-finish";
49
+ url?: string;
50
+ escalationReason?: string;
51
+ }
52
+ export interface RunResult {
53
+ exitCode: number;
54
+ stdout: string;
55
+ stderr: string;
56
+ timedOut?: boolean;
57
+ }
58
+ export type RunFn = (cmd: string[], opts: { cwd: string; timeoutMs?: number }) => Promise<RunResult>;
59
+ export type ShellRunFn = (command: string, opts: { cwd: string; timeoutMs?: number }) => Promise<RunResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.73.4",
3
+ "version": "0.74.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,9 +11,9 @@
11
11
  "dev": "bun run bin/nax.ts",
12
12
  "build": "bun build bin/nax.ts --outdir dist --target bun --define \"GIT_COMMIT=\\\"$(git rev-parse --short HEAD)\\\"\"",
13
13
  "typecheck": "bun x tsc --noEmit && bun x tsc --noEmit -p tsconfig.contracts.json",
14
- "lint": "bun x biome check src/ bin/ && bun run check:no-real-global-nax && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes",
15
- "lint:json": "bun x biome check src/ bin/ --reporter json && bun run check:nax-error 1>&2 && bun run check:logger-storyid 1>&2",
16
- "lint:fix": "bun x biome check --write src/ bin/",
14
+ "lint": "bun x biome check src/ bin/ flows/ && bun run check:no-real-global-nax && bun run check:alias-internals && bun run check:deep-relatives && bun run check:nax-error && bun run check:logger-storyid && bun run check:log-format-layering && bun run check:file-sizes",
15
+ "lint:json": "bun x biome check src/ bin/ flows/ --reporter json && bun run check:nax-error 1>&2 && bun run check:logger-storyid 1>&2",
16
+ "lint:fix": "bun x biome check --write src/ bin/ flows/",
17
17
  "check:no-real-global-nax": "bun run scripts/check-no-real-global-nax.ts",
18
18
  "check:alias-internals": "bun run scripts/check-alias-internals.ts",
19
19
  "check:deep-relatives": "bun run scripts/check-deep-relatives.ts",
@@ -50,6 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@types/react": "^19.2.14",
53
+ "acpx": "^0.12.1",
53
54
  "chalk": "^5.6.2",
54
55
  "commander": "^13.1.0",
55
56
  "ink": "^6.7.0",
@@ -75,6 +76,7 @@
75
76
  ],
76
77
  "files": [
77
78
  "dist/",
79
+ "flows/",
78
80
  "README.md",
79
81
  "CHANGELOG.md"
80
82
  ],