@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.
- package/dist/nax.js +1240 -526
- package/flows/nax-finish/errors.ts +23 -0
- package/flows/nax-finish/exec.ts +70 -0
- package/flows/nax-finish/nax-finish.flow.ts +330 -0
- package/flows/nax-finish/review-prompts.ts +344 -0
- package/flows/nax-finish/steps/acceptance.ts +53 -0
- package/flows/nax-finish/steps/context.ts +64 -0
- package/flows/nax-finish/steps/escalate.ts +93 -0
- package/flows/nax-finish/steps/forge.ts +44 -0
- package/flows/nax-finish/steps/git.ts +73 -0
- package/flows/nax-finish/steps/index.ts +8 -0
- package/flows/nax-finish/steps/pr.ts +71 -0
- package/flows/nax-finish/steps/quality.ts +93 -0
- package/flows/nax-finish/steps/result.ts +15 -0
- package/flows/nax-finish/types.ts +59 -0
- package/package.json +6 -4
|
@@ -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.
|
|
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
|
],
|