@nathapp/nax 0.75.6 → 0.77.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,136 @@
1
+ /**
2
+ * Commit messages for the flow's `commit_<phase>` checkpoints.
3
+ *
4
+ * These commits are shipped history — they land on the feature branch and a
5
+ * human reviews them in the PR. Every one of them used to read
6
+ * `fix(<feature>): nax-finish <phase> fixes` with an empty body, so a reviewer
7
+ * looking at six such commits could not tell which one re-enabled a disabled
8
+ * market gate and which one renamed a variable. The reviewer already produced
9
+ * exactly the material needed to say so — severity, title, problem, fix — and
10
+ * it was being discarded at the one moment it could have been recorded.
11
+ *
12
+ * Subject lines follow the repo's conventional-commit rule and the 72-column
13
+ * git summary convention; the findings go in the body, one bullet each.
14
+ */
15
+ import type { Finding, FinishPhase } from "./types";
16
+
17
+ /** Git's conventional soft cap for a commit summary line. */
18
+ const MAX_SUBJECT_LEN = 72;
19
+
20
+ /** Worst-first, so the subject of a mixed batch reports the severity that matters. */
21
+ const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
22
+
23
+ /** How much gate output to quote in the body before it stops being a commit message. */
24
+ const MAX_GATE_OUTPUT_LINES = 20;
25
+
26
+ interface MessageCtx {
27
+ outputs: Record<string, unknown>;
28
+ }
29
+
30
+ interface PhaseOutputs {
31
+ findings?: Finding[];
32
+ failing?: string[];
33
+ output?: string;
34
+ }
35
+
36
+ function outputsFor(ctx: MessageCtx, nodeId: string): PhaseOutputs {
37
+ return (ctx.outputs[nodeId] ?? {}) as PhaseOutputs;
38
+ }
39
+
40
+ function findingsFor(ctx: MessageCtx, phase: FinishPhase): Finding[] {
41
+ const raw = outputsFor(ctx, `review_${phase}`).findings;
42
+ return Array.isArray(raw) ? raw.filter((f): f is Finding => Boolean(f?.title)) : [];
43
+ }
44
+
45
+ function worstSeverity(findings: Finding[]): string {
46
+ const present = new Set(findings.map((f) => f.severity));
47
+ return SEVERITY_ORDER.find((s) => present.has(s)) ?? findings[0]?.severity ?? "LOW";
48
+ }
49
+
50
+ /**
51
+ * Lowercase a finding title's leading word for the subject line.
52
+ *
53
+ * Reviewers write titles as sentences ("Market gate skip branch is
54
+ * unreachable"); conventional-commit subjects read better in lower case. Only
55
+ * the first character is touched — an all-caps leading token is an acronym
56
+ * (`SSRF guard …`) and must survive intact.
57
+ */
58
+ function subjectCase(title: string): string {
59
+ const [first = "", ...rest] = title.split(" ");
60
+ const isAcronym = first.length > 1 && first === first.toUpperCase();
61
+ return isAcronym
62
+ ? title
63
+ : `${first.charAt(0).toLowerCase()}${first.slice(1)}${rest.length ? ` ${rest.join(" ")}` : ""}`;
64
+ }
65
+
66
+ function truncate(s: string): string {
67
+ return s.length <= MAX_SUBJECT_LEN ? s : `${s.slice(0, MAX_SUBJECT_LEN - 3)}...`;
68
+ }
69
+
70
+ /** "lint and test", "lint, test and typecheck" — a readable list for the subject. */
71
+ function humanList(items: string[]): string {
72
+ if (items.length <= 1) return items[0] ?? "";
73
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
74
+ }
75
+
76
+ function reviewSubject(phase: FinishPhase, findings: Finding[]): string {
77
+ if (findings.length === 1) return subjectCase(findings[0].title);
78
+ return `address ${findings.length} ${phase} review findings (worst: ${worstSeverity(findings)})`;
79
+ }
80
+
81
+ function subjectFor(phase: FinishPhase, ctx: MessageCtx): string {
82
+ if (phase === "gate") {
83
+ const failing = outputsFor(ctx, "quality_gates").failing ?? [];
84
+ return failing.length > 0 ? `repair failing ${humanList(failing)} gates` : "repair failing quality gates";
85
+ }
86
+ if (phase === "acceptance") return "repair failing acceptance tests";
87
+ const findings = findingsFor(ctx, phase);
88
+ return findings.length > 0 ? reviewSubject(phase, findings) : `apply ${phase} review fixes`;
89
+ }
90
+
91
+ function bodyFor(phase: FinishPhase, ctx: MessageCtx): string[] {
92
+ if (phase === "gate") {
93
+ const gate = outputsFor(ctx, "quality_gates");
94
+ const failing = gate.failing ?? [];
95
+ const tail = (gate.output ?? "").trim().split("\n").slice(-MAX_GATE_OUTPUT_LINES).join("\n");
96
+ return [...(failing.length > 0 ? [`Failing: ${failing.join(", ")}`] : []), ...(tail ? [tail] : [])];
97
+ }
98
+ if (phase === "acceptance") {
99
+ const tail = (outputsFor(ctx, "acceptance").output ?? "")
100
+ .trim()
101
+ .split("\n")
102
+ .slice(-MAX_GATE_OUTPUT_LINES)
103
+ .join("\n");
104
+ return tail ? [tail] : [];
105
+ }
106
+ const findings = findingsFor(ctx, phase);
107
+ if (findings.length === 0) return [];
108
+ return [
109
+ findings
110
+ .map((f) =>
111
+ [`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
112
+ .filter(Boolean)
113
+ .join("\n"),
114
+ )
115
+ .join("\n"),
116
+ ];
117
+ }
118
+
119
+ /** Human-readable phase label for the attribution trailer. */
120
+ function phaseLabel(phase: FinishPhase): string {
121
+ return phase === "gate" ? "quality gate" : phase === "acceptance" ? "acceptance" : `${phase} review`;
122
+ }
123
+
124
+ /**
125
+ * Build the commit message for a `commit_<phase>` checkpoint.
126
+ *
127
+ * Never throws and never returns an empty subject: a missing or malformed
128
+ * reviewer output degrades to the phase label. A commit that cannot be
129
+ * described is still a commit that must happen — failing here would strand the
130
+ * fix uncommitted and reintroduce the stale-diff bug (#1397).
131
+ */
132
+ export function buildFixCommitMessage(phase: FinishPhase, feature: string, ctx: MessageCtx): string {
133
+ const subject = truncate(`fix(${feature}): ${subjectFor(phase, ctx)}`);
134
+ const body = bodyFor(phase, ctx);
135
+ return [subject, ...body, `nax-finish: ${phaseLabel(phase)} fixes`].join("\n\n");
136
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Readers over an acpx `FlowNodeContext` — the flow graph's view of its own
3
+ * state.
4
+ *
5
+ * Split out of `nax-finish.flow.ts` (600-line source cap). These are all pure
6
+ * functions of `ctx.input` / `ctx.outputs` / `ctx.state.steps`; anything that
7
+ * shells out lives under `./steps/`.
8
+ *
9
+ * Two views of the same run, and the difference matters in a graph whose whole
10
+ * shape is loops:
11
+ *
12
+ * - `ctx.outputs` is a map keyed by node id, so it holds only each node's
13
+ * **latest** output. A node re-entering a loop cannot see its own previous
14
+ * round there.
15
+ * - `ctx.state.steps` is the ordered history and carries every step's `output`,
16
+ * so an earlier round IS recoverable from it. A step is appended on its
17
+ * *outcome*, so the currently-executing node is never in this list — which is
18
+ * what lets `incrementalSince` find the *previous* review rather than itself.
19
+ */
20
+ import type { AcceptanceGroup, Finding, FinishInput, FinishPhase, ReviewVerdict } from "./types";
21
+
22
+ /** Minimal shapes so each reader takes only the part of the context it reads. */
23
+ export interface StepsCtx {
24
+ state: { steps: { nodeId: string; output?: unknown }[] };
25
+ }
26
+ export interface OutputsCtx {
27
+ outputs: unknown;
28
+ }
29
+
30
+ export const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
31
+
32
+ /** What `load_ctx` resolves once, for every downstream node to read. */
33
+ export interface LoadCtxOutput {
34
+ base?: string;
35
+ specPath?: string;
36
+ groups?: AcceptanceGroup[];
37
+ /** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
38
+ acceptanceStatus?: string;
39
+ /** Test-file regex sources from `nax features resolve`; empty = cannot classify. */
40
+ testFileRegex?: string[];
41
+ route?: string;
42
+ }
43
+
44
+ export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
45
+ return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
46
+ }
47
+
48
+ export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
49
+ return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
50
+ }
51
+ /**
52
+ * The narrative node's parsed prose.
53
+ *
54
+ * Absent when the node was skipped by config, died, or produced only
55
+ * whitespace — `amend_body` treats all three identically, so there is one
56
+ * branch downstream rather than three.
57
+ */
58
+ export function narrativeOf(ctx: OutputsCtx): string | undefined {
59
+ const out = (ctx.outputs as Record<string, unknown>).narrative;
60
+ return typeof out === "string" && out.trim().length > 0 ? out : undefined;
61
+ }
62
+
63
+ export function gateOutputs(ctx: OutputsCtx): { failing?: string[]; ran?: string[] } {
64
+ return ((ctx.outputs as Record<string, { failing?: string[]; ran?: string[] } | undefined>).quality_gates ?? {}) as {
65
+ failing?: string[];
66
+ ran?: string[];
67
+ };
68
+ }
69
+
70
+ /** The findings the `fix_<phase>` node was asked to resolve; empty for non-review phases. */
71
+ export function findingsOf(ctx: OutputsCtx, phase: FinishPhase): Finding[] {
72
+ if (phase !== "spec" && phase !== "quality") return [];
73
+ return (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`]?.findings ?? [];
74
+ }
75
+
76
+ /**
77
+ * The ref a re-review should diff from, or null to review the whole branch.
78
+ *
79
+ * A reviewer node re-reads the spec in full and the entire `git diff
80
+ * base...HEAD` on every round. Reviews were 58% of the wall clock on
81
+ * rs-stock/pipeline-run-outcome (7 calls, 1306s of 2232s), and round 3 re-read
82
+ * everything rounds 1-2 had already cleared.
83
+ *
84
+ * The scoping ref is the `shaBefore` of the **first** `commit_*` step after this
85
+ * phase's last review — that commit's parent is, by construction, the tree the
86
+ * previous verdict passed on, since only `commit_*` nodes commit. Taking the
87
+ * first (not the last) is what makes the window complete when more than one
88
+ * commit landed in it, which happens when the acceptance loop commits between a
89
+ * spec fix and its re-review: `firstCommit.shaBefore..HEAD` spans both.
90
+ *
91
+ * Read from `ctx.state.steps[].output`, not `ctx.outputs` — the latter keeps
92
+ * only each node's newest output, which for two commit steps of the same node id
93
+ * would have discarded the earlier `shaBefore` and silently under-scoped the
94
+ * review.
95
+ *
96
+ * Returns null — a full review — when there is no prior review of this phase
97
+ * (round 1), no commit since it (nothing new to look at), or the commit step
98
+ * recorded no `shaBefore`.
99
+ */
100
+ export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "quality"): string | null {
101
+ const steps = ctx.state.steps ?? [];
102
+ const lastReview = steps.map((s) => s.nodeId).lastIndexOf(`review_${phase}`);
103
+ if (lastReview < 0) return null;
104
+ const firstCommit = steps.slice(lastReview + 1).find((s) => s.nodeId.startsWith("commit_"));
105
+ if (!firstCommit) return null;
106
+ return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
107
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The PR body's "What changed" section — prompt, parse, and the chain that
3
+ * decides what text (if any) the section carries.
4
+ *
5
+ * Prompt building lives here rather than in `src/prompts/builders/` because
6
+ * `flows/` is loaded by acpx in its own Node process and imports nothing from
7
+ * `src/`. `review-prompts.ts` sits beside this file for the same reason.
8
+ *
9
+ * `resolveNarrative` is a standalone pure function, not flow wiring, because
10
+ * the acp node that produces the model text cannot be executed in tests. The
11
+ * degradation chain is the part that must never break, so it lives where a
12
+ * test can reach it.
13
+ */
14
+
15
+ /** Longest narrative rendered into a PR body, in characters, including the ellipsis. */
16
+ export const NARRATIVE_MAX_CHARS = 4000;
17
+
18
+ const TRUNCATION_SUFFIX = "…";
19
+
20
+ /** Headings a spec uses for its lead paragraph, in priority order. */
21
+ const SUMMARY_HEADINGS = ["summary", "overview"] as const;
22
+
23
+ /**
24
+ * Prompt for the narrative node.
25
+ *
26
+ * Two jobs: point the agent at the real diff (never the spec, which describes
27
+ * intent rather than what shipped), and forbid restating the sections the body
28
+ * already renders deterministically.
29
+ */
30
+ /**
31
+ * `prompt` for the `narrative` flow node. Lives here rather than inline in
32
+ * `nax-finish.flow.ts` to keep that file under its 600-line cap — the node
33
+ * just needs `ctx.outputs.load_ctx.base`, which is all this wrapper reads.
34
+ */
35
+ export function narrativePrompt(ctx: { outputs: unknown }): string {
36
+ const base = (ctx.outputs as { load_ctx?: { base?: string } }).load_ctx?.base ?? "origin/main";
37
+ return buildNarrativePrompt({ base });
38
+ }
39
+
40
+ export function buildNarrativePrompt(args: { base: string }): string {
41
+ return [
42
+ 'Write the "What changed" section of a pull request body.',
43
+ "",
44
+ `Read the branch diff yourself: \`git diff ${args.base}...HEAD\`.`,
45
+ "Read whatever source files you need to understand it.",
46
+ "",
47
+ "The PR body ALREADY renders these deterministically, from run artifacts:",
48
+ "- a Stories table (story id, title, acceptance-criteria count)",
49
+ "- a Verification block (acceptance status, regression status, gates run, diffstat)",
50
+ "- a Review rounds block (every finding, with its severity)",
51
+ "- an Out of scope list",
52
+ "",
53
+ "Do NOT restate, summarise, or refer to any of them. Repeating them is how the",
54
+ "written and the generated halves of this body drift apart.",
55
+ "",
56
+ "Describe what the change actually does, in prose: the shape of the change, and",
57
+ "anything a reviewer would otherwise have to reconstruct from the diff by hand.",
58
+ `Hard limit: ${NARRATIVE_MAX_CHARS} characters.`,
59
+ "Do not write a heading — the heading is added for you.",
60
+ "Return the prose only. No JSON, no code fences, no preamble.",
61
+ ].join("\n");
62
+ }
63
+
64
+ /**
65
+ * `parse` for the narrative acp node.
66
+ *
67
+ * Never throws. A throw inside `parse` fails the node, and acpx has no error
68
+ * edge — see `verdict.ts`. Here that would mean the flow dying *after* the PR
69
+ * was already opened.
70
+ */
71
+ export function parseNarrative(text: string): string {
72
+ return typeof text === "string" ? text.trim() : "";
73
+ }
74
+
75
+ function truncate(text: string): string {
76
+ if (text.length <= NARRATIVE_MAX_CHARS) return text;
77
+ return text.slice(0, NARRATIVE_MAX_CHARS - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
78
+ }
79
+
80
+ /**
81
+ * Pick the narrative text, best source first.
82
+ *
83
+ * The spec summary is the fallback rather than the primary source because a
84
+ * spec describes intent: when an implementation deviates and the deviation is
85
+ * accepted, a spec-derived narrative confidently describes code that does not
86
+ * exist.
87
+ *
88
+ * `undefined` means "render no section at all" — never an empty heading.
89
+ */
90
+ export function resolveNarrative(agentText: string | undefined, specSummary: string | null): string | undefined {
91
+ const fromAgent = agentText?.trim();
92
+ if (fromAgent) return truncate(fromAgent);
93
+ const fromSpec = specSummary?.trim();
94
+ if (fromSpec) return truncate(fromSpec);
95
+ return undefined;
96
+ }
97
+
98
+ function sectionBody(lines: string[], heading: string): string | null {
99
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading}`);
100
+ if (start === -1) return null;
101
+ const rest = lines.slice(start + 1);
102
+ const end = rest.findIndex((line) => line.startsWith("## "));
103
+ const body = (end === -1 ? rest : rest.slice(0, end)).join("\n").trim();
104
+ return body.length > 0 ? body : null;
105
+ }
106
+
107
+ /**
108
+ * First `## Summary` or `## Overview` block in the spec, or `null`.
109
+ *
110
+ * Both headings are accepted because both occur in this repository's real
111
+ * specs — five of six use `## Summary`, the older `plugin-001` uses
112
+ * `## Overview`. Fail-open on every read error: a missing or unreadable spec
113
+ * costs the section, never the PR.
114
+ */
115
+ export async function readSpecSummary(
116
+ specPath: string | undefined,
117
+ readText: (path: string) => Promise<string | null>,
118
+ ): Promise<string | null> {
119
+ if (!specPath) return null;
120
+ let text: string | null;
121
+ try {
122
+ text = await readText(specPath);
123
+ } catch {
124
+ return null;
125
+ }
126
+ if (text === null) return null;
127
+ const lines = text.split(/\r?\n/);
128
+ for (const heading of SUMMARY_HEADINGS) {
129
+ const body = sectionBody(lines, heading);
130
+ if (body !== null) return body;
131
+ }
132
+ return null;
133
+ }