@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,344 @@
1
+ export const SPEC_REVIEW_DIMENSIONS = `# Spec-relative review dimensions
2
+
3
+ Reference for the post-impl-review **spec-relative** pass: Compliance, Drift,
4
+ Integration, and Convention Compliance. Apply every dimension below against the
5
+ spec, the filtered diff, the collaborator code you read, and the loaded project
6
+ rules.
7
+
8
+ ## Map external touchpoints first (read the unchanged collaborators)
9
+
10
+ **Do this before judging anything.** Most real defects in a focused diff live on
11
+ the boundary between the changed code and the *unchanged* code it calls into —
12
+ and that unchanged code is, by definition, not in the diff. A diff-only read
13
+ cannot see them.
14
+
15
+ From the filtered diff, build a list of **external touchpoints** — every symbol
16
+ the changed code *uses* but does not *define* in the diff:
17
+
18
+ - **Callees:** functions/methods the new lines call whose body lives in an
19
+ unchanged file (e.g. \`strategy_instance.get_references(...)\`,
20
+ \`provider.cache.get_ohlcv(...)\`).
21
+ - **Polymorphic / interface calls:** any call dispatched through a base class,
22
+ protocol, or registry. The diff sees one signature; the real behaviour is in
23
+ *every concrete implementation*. Enumerate them.
24
+ - **New or changed arguments to existing APIs:** a value the diff now passes that
25
+ the callee didn't receive before — especially empty/sentinel/\`None\`/\`{}\`/\`[]\`
26
+ values, or a newly-shaped object. Verify the callee tolerates it.
27
+ - **Consumers of changed outputs:** unchanged code that reads a field, sentinel,
28
+ or return value whose meaning the diff altered.
29
+ - **Collaborators named in the spec:** if the spec asserts a cross-cutting goal
30
+ ("every built-in strategy works", "all callers", "each adapter"), that goal is
31
+ a claim *about unchanged code*. You must open those files to verify it — the
32
+ diff alone can never prove it.
33
+
34
+ For each touchpoint, **read the actual definition(s)** with Read/Grep and check
35
+ the changed code's assumption holds for *all* of them, not just the convenient
36
+ case. Use Grep to find every implementation of an overridden method before
37
+ concluding it's safe.
38
+
39
+ Examples of assumptions that only break in unchanged code:
40
+ - The diff calls \`iface.method(emptyValue)\`; one concrete implementation
41
+ immediately indexes a required key → runtime \`KeyError\`/\`NullPointerException\`
42
+ for that case.
43
+ - The diff sets a sentinel to \`{}\` instead of \`None\`; a downstream guard checks
44
+ \`is None\`, so \`{}\` slips through and produces a misleading error or silent NaN.
45
+ - The spec says "works for every strategy"; the diff only added tests for
46
+ static/test-double strategies, leaving the dynamic real ones unverified.
47
+
48
+ Treat an untested cross-cutting claim as **unverified, not satisfied** — surface
49
+ it as a finding rather than assuming coverage.
50
+
51
+ ## Compliance — per AC/story/requirement
52
+
53
+ For each numbered or named AC, story, or requirement in the spec, determine:
54
+ - **Covered** — the diff clearly addresses it
55
+ - **Partial** — the diff touches it but leaves something incomplete
56
+ - **Missing** — nothing in the diff implements it
57
+
58
+ **Coverage ≠ correctness:** when an AC's coverage is a test, do not stop at "a
59
+ test exists." Open the test body and verify it (a) restores any global /
60
+ \`os.environ\` / filesystem / singleton state it mutates (teardown or fixture),
61
+ (b) is deterministic and order-independent, and (c) asserts the AC's actual
62
+ behaviour rather than a tautology. A test that passes only by accident of
63
+ ordering, or that asserts nothing meaningful, is **Partial**, not Covered.
64
+
65
+ **If the spec has no numbered or named ACs** (it's written as prose): derive
66
+ implicit requirements from the prose — treat each described behaviour, endpoint,
67
+ or constraint as a requirement. Note in the findings header: \`(Spec has no
68
+ structured ACs — requirements inferred from prose)\`.
69
+
70
+ **Renames and deletions:** treat them as intentional changes when evaluating
71
+ compliance. A diff showing \`rename from A to B\` or a deleted file counts as
72
+ coverage for an AC that required moving or removing that module.
73
+
74
+ ## Drift — holistic across the diff
75
+
76
+ Check whether the implementation matches the spec's described intent:
77
+ - API shape: do endpoints, request fields, response fields, and status codes
78
+ match?
79
+ - Approach: is the architectural pattern (module structure, design pattern, data
80
+ flow) what the spec called for?
81
+ - Constraints: are hard requirements respected (e.g. "must use HMAC-SHA256",
82
+ "must be idempotent", "must validate at startup")?
83
+ - Naming: do key identifiers (routes, types, env vars, functions) match the
84
+ spec's terminology?
85
+
86
+ ## Integration — does the changed code actually work against the unchanged collaborators?
87
+
88
+ For each external touchpoint mapped above, check whether the changed code's
89
+ assumptions hold for *every* real implementation/consumer:
90
+ - Will any concrete callee raise (KeyError, NPE, ValueError, panic) for an input
91
+ the diff now passes — especially empty/sentinel/\`None\`/\`[]\`/\`{}\` values?
92
+ - Does any sentinel or output the diff changed reach a downstream guard that
93
+ interprets it the wrong way (\`{}\` slipping past an \`is None\` check; \`[]\`
94
+ treated as "provided")?
95
+ - Does the spec's cross-cutting claim ("every X works") actually hold for the
96
+ real, non-test-double implementations — and is each one exercised by a test?
97
+ An untested real path is a finding, not a pass.
98
+ - Are there edge inputs the new tool/endpoint schema now permits (e.g. an
99
+ explicitly empty array) that route into a broken branch?
100
+
101
+ ## Convention Compliance — does the diff obey the project's own rules?
102
+
103
+ **Load the rules first.** Find the repo's own rule files and read every one that
104
+ exists (they may all be absent — then skip this dimension entirely and note
105
+ \`(No project rule files found — Convention Compliance skipped)\` in your findings):
106
+
107
+ \`\`\`bash
108
+ ls CLAUDE.md AGENTS.md 2>/dev/null
109
+ find .nax/rules .claude/rules -name "*.md" 2>/dev/null
110
+ \`\`\`
111
+
112
+ \`.nax/rules/\` takes priority over \`.claude/rules/\` when they conflict (nax-native
113
+ is canonical). Honour each file's \`paths:\` / \`appliesTo:\` frontmatter if present —
114
+ a rule scoped to \`src/agents/**\` does not apply to a diff under \`apps/web/\`.
115
+ Extract the concrete, checkable directives (forbidden APIs, required patterns,
116
+ naming, logging fields, file-size limits) and hold them for the checks below.
117
+
118
+ For each concrete directive extracted from the loaded rule files, check whether
119
+ the changed lines violate it. Only flag rules that actually apply to the changed
120
+ files (respect \`paths:\` / \`appliesTo:\` scoping). Examples of the *kind* of
121
+ directive to check — the real list comes from the loaded files, not this list:
122
+ - Forbidden APIs / patterns (e.g. a banned import, \`console.log\` in source, a
123
+ Node API in a Bun-native repo, hardcoded patterns the project routes through a
124
+ resolver).
125
+ - Required structure (barrel imports vs internal paths, file-size limits,
126
+ mandated error/base classes, dependency-injection patterns).
127
+ - Required fields / format (e.g. a mandated structured-log field,
128
+ conventional-commit style, naming conventions for routes/types/env vars).
129
+
130
+ Cite the specific rule file and directive in the finding
131
+ (\`forbidden-patterns.md: no console.log in src/\`). A violation of an explicit,
132
+ in-scope project rule is a real finding; a generic style opinion **not** backed
133
+ by a loaded rule is not — do not invent rules. If no rule files were found, skip
134
+ this dimension entirely.
135
+
136
+ ## Confidence threshold (spec-relative)
137
+
138
+ **Report only findings you are ≥80% confident are real**, not pre-existing ones
139
+ the diff didn't introduce. A false "AC missing" or a phantom integration crash
140
+ is expensive and erodes trust in the whole report. A missing AC, a runtime crash
141
+ you traced through the collaborator, and an in-scope project-rule violation clear
142
+ this bar easily; a "this might be slow" hunch you haven't reasoned through does
143
+ not — drop it.
144
+ `;
145
+
146
+ export const QUALITY_REVIEW_DIMENSIONS = `# Code quality & test integrity dimension
147
+
148
+ Reference for the post-impl-review **code-quality** pass: spec-independent
149
+ defects and design/maintainability concerns in the changed lines. These findings
150
+ are real regardless of what the spec says. Scan the diff — production **and**
151
+ test code — and judge each changed function on its own merits.
152
+
153
+ ## Forcing function — enumerate before you conclude
154
+
155
+ Before reporting, walk **every function/method the diff adds or changes** and
156
+ write yourself a one-line verdict for each: *earns its place* or *concern: …*.
157
+ This enumeration is a thinking tool — it does not go in the final report, only
158
+ the resulting findings do. Skipping it is how real maintainability issues get
159
+ missed: an agent that pattern-matches a few obvious smells and stops will always
160
+ under-report. Look at each changed function deliberately.
161
+
162
+ ## What to look for
163
+
164
+ Report only concrete, objective issues, not style preferences:
165
+
166
+ - **Test isolation:** mutating \`os.environ\` / globals / singletons / filesystem
167
+ without teardown; cross-test ordering dependence; shared mutable fixtures. A
168
+ test that only passes because another test happens to clean up after it is a
169
+ defect even when the suite is currently green.
170
+ - **Dead / redundant code:** assignments with no effect, unreachable branches,
171
+ set-up the constructor already performed, unused locals introduced by the diff;
172
+ logic duplicated from an existing helper the diff could have reused.
173
+ - **Resource leaks:** opened files / sockets / handles / subprocesses not closed;
174
+ timers / listeners not cleared.
175
+ - **Error handling:** swallowed exceptions, bare catches that hide failures,
176
+ missing validation on a newly-introduced input path.
177
+ - **Concurrency:** shared state mutated without synchronisation; \`await\` inside a
178
+ loop that should be batched; a race between a check and the action it guards.
179
+ - **Performance:** N+1 queries or network calls in a loop; blocking I/O on a hot
180
+ path; an obviously quadratic scan over a large collection the diff introduces.
181
+ - **Accessibility (UI diffs only):** interactive elements without an accessible
182
+ name/label, missing \`alt\`, non-keyboard-reachable controls, form inputs with
183
+ no associated label.
184
+ - **Security (only when the diff touches it):** hardcoded secrets, unvalidated
185
+ user input reaching a sink, injection vectors.
186
+ - **Type safety:** unsafe casts, \`any\`/\`unknown\` escapes, weakened or widened
187
+ types the diff introduces, or a narrowing the diff drops — anywhere the change
188
+ trades a compile-time guarantee for a runtime risk.
189
+ - **Design & maintainability (open-ended — not a closed checklist):** a changed
190
+ function that conflates multiple responsibilities (poor separation of
191
+ concerns); an abstraction the diff introduces that is premature (single caller,
192
+ speculative generality) or leaky (callers must understand its internals to use
193
+ it safely); logic the diff writes inline that an existing helper already
194
+ provides (reinvention, not just literal duplication); control flow so nested or
195
+ convoluted the next reader will misread it; an identifier whose name actively
196
+ misleads about what it holds or does; a comment or docstring the diff now leaves
197
+ stale or contradicting the code it sits on, or a changed public API left without
198
+ the docs a caller needs; an edge case the changed code's *own* logic implies but
199
+ doesn't handle. These are the qualitative "this code isn't good yet" findings —
200
+ judge them, don't skip them because they aren't on the defect list above. Anchor
201
+ each to the changed line and state the concrete cost (what breaks, or who is
202
+ misled, later).
203
+
204
+ Every finding here must point at a specific changed line and name a concrete cost
205
+ — a bug, a future break, or a reader who will be misled. Skip pure formatting and
206
+ personal taste that carry no such cost, and skip hypotheticals about code outside
207
+ the diff. But a design or maintainability concern grounded in a changed line and
208
+ its cost **is** in scope even though it's not on the defect checklist above —
209
+ that is exactly the signal this dimension exists to surface.
210
+
211
+ ## Confidence threshold (code quality)
212
+
213
+ **Report findings you are ≥60% confident are real**, *provided* each is anchored
214
+ to a specific changed line and names a concrete maintenance or correctness cost.
215
+ Design and maintainability problems are inherently probabilistic — a muddy
216
+ abstraction, a misleading name, or a fragile edge case rarely clears 80%, and a
217
+ blanket 80% gate is precisely what makes a review miss the quality issues it
218
+ exists to catch. Let these land as MEDIUM or LOW per the severity table rather
219
+ than dropping them; the implementer can waive them, but they should see them.
220
+ Still exclude pure formatting and personal taste that carry no stated cost. Do
221
+ **not** over-suppress this tier to hit an arbitrary count — a real
222
+ maintainability concern stated with its cost is worth surfacing even at moderate
223
+ confidence.
224
+ `;
225
+
226
+ export const WORKER_PROTOCOL = `# Worker protocol (shared mechanics)
227
+
228
+ Self-contained mechanics for a post-impl-review **worker** (SPEC or QUALITY).
229
+ Read this plus your dimension reference (\`spec-review.md\` or \`code-quality.md\`) —
230
+ you do **not** need to read the dispatcher's \`SKILL.md\`. The dispatcher already
231
+ resolved the spec, detected the base branch, and ran the empty-diff/size guards;
232
+ your job is to review the diff and return findings.
233
+
234
+ ## Get the diff
235
+
236
+ The dispatcher gave you the base branch. Fetch the diff content:
237
+
238
+ \`\`\`bash
239
+ git diff origin/<branch>...HEAD --name-only # changed file list
240
+ git diff origin/<branch>...HEAD # full diff content
241
+ \`\`\`
242
+
243
+ ## Filter noise
244
+
245
+ Do not treat churn in these as a reviewable change (you may still *read* them as
246
+ context):
247
+
248
+ - Lockfiles: \`bun.lock\`, \`package-lock.json\`, \`yarn.lock\`, \`pnpm-lock.yaml\`,
249
+ \`Cargo.lock\`, \`poetry.lock\`, or anything ending in \`.lock\`.
250
+ - Generated output: files in \`dist/\`, \`build/\`, \`.next/\`, \`.turbo/\`,
251
+ \`__pycache__/\`, or matching \`*.generated.*\`.
252
+ - nax artifacts: anything under a \`.nax/\` directory at **any depth**
253
+ (\`**/.nax/**\` — root or nested per-package in a monorepo): specs, PRDs,
254
+ acceptance result JSON, config, and the generated acceptance tests.
255
+ - Binary files: git marks these \`Binary files a/... and b/... differ\` — skip them.
256
+
257
+ ## Read the unchanged collaborators (before judging)
258
+
259
+ Most real defects in a focused diff live on the boundary between the changed code
260
+ and the *unchanged* code it calls into — which is, by definition, not in the
261
+ diff. Build the list of external touchpoints (every symbol the changed code
262
+ *uses* but does not *define*: callees, polymorphic/interface calls, new arguments
263
+ to existing APIs, consumers of changed outputs, collaborators named in the spec)
264
+ and read each definition with Read/Grep before concluding. Treat an untested
265
+ cross-cutting claim as **unverified, not satisfied**. (The SPEC dimension file
266
+ has the full procedure with worked examples; the QUALITY worker needs the same
267
+ reads to judge an integration-shaped defect.)
268
+
269
+ ## Severity table
270
+
271
+ | Severity | Meaning |
272
+ |:---------|:--------|
273
+ | CRITICAL | AC entirely missing; implementation directly contradicts a hard spec requirement; the changed code raises/crashes at runtime for a case the spec requires to work; or a security defect the diff introduces (hardcoded secret, injection sink) |
274
+ | HIGH | Significant drift (wrong API shape, missing constraint, wrong architectural approach); an integration defect that breaks a real collaborator the spec depends on; or a violation of a project rule explicitly marked as required/forbidden (a banned API, a hard-blocked pattern) |
275
+ | MEDIUM | Partial coverage — AC present but incomplete; minor drift affecting correctness; an integration gap reachable through a now-permitted input; a test-isolation defect that can cause false positives or flakiness under reordering/parallelism; a resource leak; a swallowed error on a real path; a concurrency/race or performance regression the diff introduces; or an accessibility defect on a new interactive UI element |
276
+ | LOW | Minor naming deviation, style mismatch, dead/redundant/duplicated code, unused locals, a soft convention deviation, or other non-blocking gap |
277
+
278
+ ## Output format — return ONLY this
279
+
280
+ Return **only your findings**, nothing else: no \`Spec:\`/\`Base:\` header, no
281
+ \`FINDINGS\` divider, no \`VERDICT\` line (the dispatcher adds those). Emit each
282
+ finding as a block:
283
+
284
+ \`\`\`
285
+ [SEVERITY] <short title>
286
+ Problem: <what's wrong, with file/line and the concrete cost>
287
+ Fix: <the concrete change, or "note intentional deviation">
288
+ \`\`\`
289
+
290
+ If you found nothing in your group, return the literal line \`No findings.\` as
291
+ your entire final message. That message is the only thing that travels back to
292
+ the dispatcher.
293
+ `;
294
+
295
+ const CLASSIFIER = [
296
+ "After producing findings, classify the OVERALL route for this phase:",
297
+ '- Route "proceed" when every finding has a clear recommended fix you can apply now',
298
+ " (CRITICAL/HIGH, or MEDIUM whose fix is clear and low-risk) — you will fix them and re-verify.",
299
+ '- Route "escalate" when ANY finding is a spec conflict, a contradiction with the spec,',
300
+ " or a design/judgment concern with no safe mechanical fix. Do not attempt to fix those.",
301
+ ].join("\n");
302
+
303
+ const JSON_CONTRACT = [
304
+ "Return exactly one JSON object and nothing else. First char `{`, last char `}`.",
305
+ "Shape:",
306
+ "{",
307
+ ' "route": "proceed" | "escalate",',
308
+ ' "findings": [{ "severity": "CRITICAL"|"HIGH"|"MEDIUM"|"LOW", "title": string, "problem": string, "fix": string }],',
309
+ ' "escalationReason": string // required when route is "escalate"; omit otherwise',
310
+ "}",
311
+ ].join("\n");
312
+
313
+ export function buildReviewPrompt(phase: "spec" | "quality", args: { base: string; specPath: string }): string {
314
+ const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
315
+ return [
316
+ `You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
317
+ `The spec/requirements source is: ${args.specPath}. Read it in full.`,
318
+ `Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
319
+ WORKER_PROTOCOL,
320
+ dims,
321
+ CLASSIFIER,
322
+ JSON_CONTRACT,
323
+ ].join("\n\n");
324
+ }
325
+
326
+ export function fixPrompt(
327
+ phase: "acceptance" | "spec" | "quality" | "gate",
328
+ ctx: { outputs: Record<string, unknown> },
329
+ ): string {
330
+ const outs = ctx.outputs as Record<string, { findings?: unknown; output?: string }>;
331
+ const detail =
332
+ phase === "gate"
333
+ ? (outs.quality_gates?.output ?? "")
334
+ : phase === "acceptance"
335
+ ? (outs.acceptance?.output ?? "")
336
+ : JSON.stringify(outs[`review_${phase}`]?.findings ?? []);
337
+ return [
338
+ `Apply the recommended fixes for the ${phase} phase, directly in the repo.`,
339
+ "Do not commit, push, or open PRs — nax-finish commits and pushes your edits itself.",
340
+ `Context:\n${detail}`,
341
+ "After fixing, re-run the feature's acceptance tests and the relevant checks; only proceed when they pass.",
342
+ 'Return exactly {"route":"proceed"} when done and green.',
343
+ ].join("\n\n");
344
+ }
@@ -0,0 +1,53 @@
1
+ import { DEFAULT_ACCEPTANCE_TIMEOUT_MS, runShell } from "../exec";
2
+ import type { AcceptanceGroup, ShellRunFn } from "../types";
3
+
4
+ export const _acceptanceDeps: { runShell: ShellRunFn } = { runShell };
5
+
6
+ /** Single-quote a path for `/bin/sh -c`, so spaces in a repo path can't split the command. */
7
+ function shellQuote(value: string): string {
8
+ return `'${value.replaceAll("'", `'\\''`)}'`;
9
+ }
10
+
11
+ /**
12
+ * Build the acceptance command for one group, mirroring nax's own execution:
13
+ * the configured `command` template (any `{{FILE}}` / `{{file}}` / `{{files}}`
14
+ * placeholder replaced by the **absolute** test path) run from the group's
15
+ * package dir. The command is a string, run through `/bin/sh -c`, because
16
+ * configured commands legitimately contain `&&`, quotes and flags.
17
+ */
18
+ export function buildAcceptanceCommand(repoRoot: string, group: AcceptanceGroup): string {
19
+ const absFile = shellQuote(`${repoRoot}/${group.testPath}`);
20
+ const template = group.command ?? `${languageRunner(group.language)} {{FILE}}`;
21
+ return template.replace(/\{\{FILE\}\}|\{\{file\}\}|\{\{files\}\}/g, absFile);
22
+ }
23
+
24
+ export async function runAcceptanceGate(
25
+ repoRoot: string,
26
+ groups: AcceptanceGroup[],
27
+ opts: { timeoutMs?: number } = {},
28
+ ): Promise<{ passed: boolean; ran: number; output: string }> {
29
+ const chunks: string[] = [];
30
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_ACCEPTANCE_TIMEOUT_MS;
31
+ let ran = 0;
32
+ for (const g of groups) {
33
+ if (!g.exists) continue;
34
+ const cwd = g.packageDir ? `${repoRoot}/${g.packageDir}` : repoRoot;
35
+ ran += 1;
36
+ const res = await _acceptanceDeps.runShell(buildAcceptanceCommand(repoRoot, g), { cwd, timeoutMs });
37
+ chunks.push(`[${g.packageDir || "root"}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
38
+ if (res.exitCode !== 0) return { passed: false, ran, output: chunks.join("\n\n") };
39
+ }
40
+ if (ran === 0) chunks.push("[acceptance] no acceptance test files present — nothing to run");
41
+ return { passed: true, ran, output: chunks.join("\n\n") };
42
+ }
43
+
44
+ function languageRunner(language: string): string {
45
+ switch (language) {
46
+ case "python":
47
+ return "uv run pytest";
48
+ case "go":
49
+ return "go test";
50
+ default:
51
+ return "bun test";
52
+ }
53
+ }
@@ -0,0 +1,64 @@
1
+ import { FinishError } from "../errors";
2
+ import { runArgv } from "../exec";
3
+ import type { AcceptanceGroup, RunFn } from "../types";
4
+
5
+ export const _contextDeps: { run: RunFn } = { run: runArgv };
6
+
7
+ export async function detectBaseBranch(workdir: string): Promise<string> {
8
+ const res = await _contextDeps.run(["git", "remote", "show", "origin"], { cwd: workdir });
9
+ const m = res.stdout.match(/HEAD branch:\s*(\S+)/);
10
+ if (m) return `origin/${m[1]}`;
11
+ const main = await _contextDeps.run(["git", "rev-parse", "--verify", "origin/main"], { cwd: workdir });
12
+ return main.exitCode === 0 ? "origin/main" : "origin/master";
13
+ }
14
+
15
+ export interface FeatureResolution {
16
+ specPath: string;
17
+ specKind: "markdown" | "prd";
18
+ acceptanceStatus: string;
19
+ groups: AcceptanceGroup[];
20
+ }
21
+
22
+ /**
23
+ * One `nax features resolve` call for the whole flow — it yields both the spec
24
+ * source (for the review prompts) and the acceptance groups (for the gate), so
25
+ * `load_ctx` resolves once and the acceptance node reads the groups off
26
+ * `ctx.outputs.load_ctx` instead of shelling out a second time.
27
+ */
28
+ export async function resolveFeature(feature: string, workdir: string): Promise<FeatureResolution> {
29
+ const res = await _contextDeps.run(["nax", "features", "resolve", feature, "--json"], { cwd: workdir });
30
+ let parsed: {
31
+ specSource?: { kind: "markdown" | "prd"; path: string };
32
+ acceptance?: { status?: string; groups?: AcceptanceGroup[] };
33
+ };
34
+ try {
35
+ parsed = JSON.parse(res.stdout);
36
+ } catch (cause) {
37
+ throw new FinishError(
38
+ `nax features resolve returned unparseable JSON for "${feature}"`,
39
+ "FINISH_RESOLVE_UNPARSEABLE",
40
+ { stage: "finish-context", feature, exitCode: res.exitCode, cause },
41
+ );
42
+ }
43
+ if (!parsed.specSource) {
44
+ throw new FinishError(`nax features resolve returned no specSource for "${feature}"`, "FINISH_SPEC_NOT_FOUND", {
45
+ stage: "finish-context",
46
+ feature,
47
+ });
48
+ }
49
+ return {
50
+ specPath: parsed.specSource.path,
51
+ specKind: parsed.specSource.kind,
52
+ acceptanceStatus: parsed.acceptance?.status ?? "no-prd",
53
+ groups: parsed.acceptance?.groups ?? [],
54
+ };
55
+ }
56
+
57
+ export async function preflight(
58
+ workdir: string,
59
+ base: string,
60
+ ): Promise<{ commitsAhead: number; route: "proceed" | "nothing-to-finish" }> {
61
+ const res = await _contextDeps.run(["git", "rev-list", "--count", `${base}..HEAD`], { cwd: workdir });
62
+ const commitsAhead = Number.parseInt(res.stdout.trim(), 10) || 0;
63
+ return { commitsAhead, route: commitsAhead > 0 ? "proceed" : "nothing-to-finish" };
64
+ }
@@ -0,0 +1,93 @@
1
+ import { FinishError } from "../errors";
2
+ import { runArgv } from "../exec";
3
+ import type { Finding, RunFn } from "../types";
4
+ import { detectForge, extractUrl, viewArgv } from "./forge";
5
+
6
+ export const _escalateDeps: { run: RunFn } = { run: runArgv };
7
+
8
+ export function buildEscalationComment(feature: string, escalationReason: string, findings: Finding[]): string {
9
+ const lines = [
10
+ `## nax-finish escalation — \`${feature}\``,
11
+ "",
12
+ "This feature needs human judgment before it can ship. nax-finish stopped rather than guess.",
13
+ "",
14
+ `**Needs judgment:** ${escalationReason}`,
15
+ "",
16
+ "### Findings",
17
+ ...findings.map((f) => `- **[${f.severity}] ${f.title}** — ${f.problem}\n - Suggested: ${f.fix}`),
18
+ ];
19
+ return lines.join("\n");
20
+ }
21
+
22
+ export interface EscalationOutcome {
23
+ url?: string;
24
+ /** Where the "needs judgment" message was delivered. */
25
+ channel: "telegram" | "pr-comment";
26
+ }
27
+
28
+ /**
29
+ * Deliver an escalation.
30
+ *
31
+ * `preferTelegram` (set by the plugin only when Telegram is both enabled and
32
+ * credentialed) makes Telegram the sole channel: the flow posts no comment and
33
+ * — critically — opens no draft PR to hold one, matching the design's
34
+ * "prefer Telegram when configured, else fall back to a PR/MR comment". The
35
+ * plugin sends the message itself after reading the result file. It still reads
36
+ * any existing PR/MR so the notification can carry a link, which is a read-only
37
+ * lookup with no side effect.
38
+ */
39
+ export async function postEscalation(
40
+ repoRoot: string,
41
+ branch: string,
42
+ comment: string,
43
+ opts: { preferTelegram?: boolean } = {},
44
+ ): Promise<EscalationOutcome> {
45
+ const forge = await detectForge(_escalateDeps.run, repoRoot, "finish-escalate");
46
+ const view = await _escalateDeps.run(viewArgv(forge, branch, "url"), { cwd: repoRoot });
47
+ const existingUrl = view.exitCode === 0 ? extractUrl(view.stdout) : undefined;
48
+
49
+ if (opts.preferTelegram) {
50
+ return { url: existingUrl, channel: "telegram" };
51
+ }
52
+
53
+ if (view.exitCode === 0) {
54
+ const commentCmd =
55
+ forge === "github"
56
+ ? ["gh", "pr", "comment", branch, "--body", comment]
57
+ : ["glab", "mr", "note", branch, "--message", comment];
58
+ const commented = await _escalateDeps.run(commentCmd, { cwd: repoRoot });
59
+ if (commented.exitCode !== 0) {
60
+ throw new FinishError(
61
+ `Failed to post escalation comment on "${branch}": ${commented.stderr.trim() || `exit ${commented.exitCode}`}`,
62
+ "FINISH_ESCALATION_COMMENT_FAILED",
63
+ { stage: "finish-escalate", branch },
64
+ );
65
+ }
66
+ return { url: existingUrl, channel: "pr-comment" };
67
+ }
68
+
69
+ const createCmd =
70
+ forge === "github"
71
+ ? ["gh", "pr", "create", "--draft", "--title", `nax-finish: ${branch}`, "--body", comment, "--head", branch]
72
+ : [
73
+ "glab",
74
+ "mr",
75
+ "create",
76
+ "--draft",
77
+ "--title",
78
+ `nax-finish: ${branch}`,
79
+ "--description",
80
+ comment,
81
+ "--source-branch",
82
+ branch,
83
+ ];
84
+ const create = await _escalateDeps.run(createCmd, { cwd: repoRoot });
85
+ if (create.exitCode !== 0) {
86
+ throw new FinishError(
87
+ `Failed to open a draft to hold the escalation for "${branch}": ${create.stderr.trim() || `exit ${create.exitCode}`}`,
88
+ "FINISH_ESCALATION_DRAFT_FAILED",
89
+ { stage: "finish-escalate", branch },
90
+ );
91
+ }
92
+ return { url: extractUrl(create.stdout), channel: "pr-comment" };
93
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared forge (GitHub / GitLab) helpers for the nax-finish flow.
3
+ *
4
+ * `run` is passed in by the caller rather than held here so each step keeps a
5
+ * single injectable `_deps` object for tests.
6
+ */
7
+ import { FinishError } from "../errors";
8
+ import type { RunFn } from "../types";
9
+
10
+ /** Matches the first http(s) URL on a line — `gh`/`glab` print the URL on stdout. */
11
+ const URL_REGEX = /https?:\/\/\S+/;
12
+
13
+ export type Forge = "github" | "gitlab";
14
+
15
+ export async function detectForge(run: RunFn, repoRoot: string, stage: string): Promise<Forge> {
16
+ const remote = await run(["git", "remote", "get-url", "origin"], { cwd: repoRoot });
17
+ const remoteUrl = remote.stdout.trim();
18
+ if (remoteUrl.includes("github.com")) return "github";
19
+ if (remoteUrl.includes("gitlab.com")) return "gitlab";
20
+ throw new FinishError(`Unable to determine forge from remote URL "${remoteUrl}"`, "FINISH_UNKNOWN_FORGE", {
21
+ stage,
22
+ remoteUrl,
23
+ });
24
+ }
25
+
26
+ /** Best-effort URL extraction: try `{url}` JSON first, fall back to a raw URL regex. */
27
+ export function extractUrl(stdout: string): string | undefined {
28
+ try {
29
+ const parsed = JSON.parse(stdout) as { url?: string; web_url?: string };
30
+ if (parsed.url) return parsed.url;
31
+ if (parsed.web_url) return parsed.web_url;
32
+ } catch {
33
+ // fall through to regex extraction
34
+ }
35
+ const match = stdout.match(URL_REGEX);
36
+ return match ? match[0] : undefined;
37
+ }
38
+
39
+ /** Argv for reading the branch's existing PR/MR as JSON. */
40
+ export function viewArgv(forge: Forge, branch: string, githubFields: string): string[] {
41
+ return forge === "github"
42
+ ? ["gh", "pr", "view", branch, "--json", githubFields]
43
+ : ["glab", "mr", "view", branch, "--output", "json"];
44
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Branch synchronisation for the nax-finish flow.
3
+ *
4
+ * Every fix node edits the working tree in place. Without this step those edits
5
+ * stay local and uncommitted: `gh pr create --head <branch>` then opens a PR
6
+ * from the *remote* branch, which is missing every fix the flow just made (and
7
+ * an escalation comment would describe state nobody else can see). Both
8
+ * terminal nodes call `commitAndPush` before touching the forge.
9
+ */
10
+ import { FinishError } from "../errors";
11
+ import { runArgv } from "../exec";
12
+ import type { RunFn } from "../types";
13
+
14
+ export const _gitDeps: { run: RunFn } = { run: runArgv };
15
+
16
+ export interface SyncOutcome {
17
+ /** True when the flow's fixes produced a new commit. */
18
+ committed: boolean;
19
+ /** True when the branch was pushed (always attempted, so the forge sees HEAD). */
20
+ pushed: boolean;
21
+ }
22
+
23
+ async function isDirty(repoRoot: string): Promise<boolean> {
24
+ const status = await _gitDeps.run(["git", "status", "--porcelain"], { cwd: repoRoot });
25
+ if (status.exitCode !== 0) {
26
+ throw new FinishError(
27
+ `git status failed in "${repoRoot}": ${status.stderr.trim() || `exit ${status.exitCode}`}`,
28
+ "FINISH_GIT_STATUS_FAILED",
29
+ { stage: "finish-git", repoRoot },
30
+ );
31
+ }
32
+ return status.stdout.trim().length > 0;
33
+ }
34
+
35
+ /**
36
+ * Commit any outstanding fixes and push the branch to `origin`.
37
+ *
38
+ * The push is unconditional — even with nothing new to commit the local branch
39
+ * may be ahead of its remote (nax's own run commits, or a previous partial
40
+ * finish), and the PR must reflect HEAD.
41
+ */
42
+ export async function commitAndPush(repoRoot: string, branch: string, message: string): Promise<SyncOutcome> {
43
+ let committed = false;
44
+ if (await isDirty(repoRoot)) {
45
+ const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
46
+ if (add.exitCode !== 0) {
47
+ throw new FinishError(
48
+ `git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
49
+ "FINISH_GIT_ADD_FAILED",
50
+ { stage: "finish-git", repoRoot },
51
+ );
52
+ }
53
+ const commit = await _gitDeps.run(["git", "commit", "-m", message], { cwd: repoRoot });
54
+ if (commit.exitCode !== 0) {
55
+ throw new FinishError(
56
+ `git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
57
+ "FINISH_GIT_COMMIT_FAILED",
58
+ { stage: "finish-git", repoRoot, branch },
59
+ );
60
+ }
61
+ committed = true;
62
+ }
63
+
64
+ const push = await _gitDeps.run(["git", "push", "--set-upstream", "origin", branch], { cwd: repoRoot });
65
+ if (push.exitCode !== 0) {
66
+ throw new FinishError(
67
+ `git push of "${branch}" failed: ${push.stderr.trim() || `exit ${push.exitCode}`}`,
68
+ "FINISH_GIT_PUSH_FAILED",
69
+ { stage: "finish-git", repoRoot, branch, committed },
70
+ );
71
+ }
72
+ return { committed, pushed: true };
73
+ }