@nathapp/nax 0.76.0 → 0.77.1
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 +5073 -3173
- package/flows/nax-finish/flow-ctx.ts +32 -2
- package/flows/nax-finish/narrative.ts +215 -0
- package/flows/nax-finish/nax-finish.flow.ts +98 -74
- package/flows/nax-finish/pr-template.ts +56 -0
- package/flows/nax-finish/pr-title.ts +140 -0
- package/flows/nax-finish/review-prompts.ts +21 -1
- package/flows/nax-finish/steps/index.ts +1 -0
- package/flows/nax-finish/steps/pr-body.ts +410 -0
- package/flows/nax-finish/steps/pr-narrative.ts +46 -0
- package/flows/nax-finish/steps/pr.ts +48 -3
- package/flows/nax-finish/types.ts +19 -4
- package/flows/nax-finish/verdict.ts +159 -0
- package/package.json +1 -1
|
@@ -48,10 +48,40 @@ export function fixAttemptCount(ctx: StepsCtx, fixNodeId: string): number {
|
|
|
48
48
|
export function loadCtxOf(ctx: OutputsCtx): LoadCtxOutput {
|
|
49
49
|
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
50
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
|
+
* Accepts the bare string the node used to return as well as the
|
|
59
|
+
* `{ narrative, title }` it returns now: a flow resumed from a run recorded
|
|
60
|
+
* before the title landed replays the old shape from its journal.
|
|
61
|
+
*/
|
|
62
|
+
export function narrativeOf(ctx: OutputsCtx): string | undefined {
|
|
63
|
+
const out = (ctx.outputs as Record<string, unknown>).narrative;
|
|
64
|
+
const prose = typeof out === "string" ? out : (out as { narrative?: unknown } | undefined)?.narrative;
|
|
65
|
+
return typeof prose === "string" && prose.trim().length > 0 ? prose : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The narrative node's parsed PR title, already sanitised by `parseTitle`.
|
|
70
|
+
*
|
|
71
|
+
* Absent whenever the node is — `resolveTitle` then falls back to
|
|
72
|
+
* `feat: <feature>`, which is what shipped before and what auto-PR opens with.
|
|
73
|
+
*/
|
|
74
|
+
export function prTitleOf(ctx: OutputsCtx): string | undefined {
|
|
75
|
+
const out = (ctx.outputs as Record<string, unknown>).narrative;
|
|
76
|
+
if (typeof out !== "object" || out === null) return undefined;
|
|
77
|
+
const title = (out as { title?: unknown }).title;
|
|
78
|
+
return typeof title === "string" && title.trim().length > 0 ? title : undefined;
|
|
79
|
+
}
|
|
51
80
|
|
|
52
|
-
export function gateOutputs(ctx: OutputsCtx): { failing?: string[] } {
|
|
53
|
-
return ((ctx.outputs as Record<string, { failing?: string[] } | undefined>).quality_gates ?? {}) as {
|
|
81
|
+
export function gateOutputs(ctx: OutputsCtx): { failing?: string[]; ran?: string[] } {
|
|
82
|
+
return ((ctx.outputs as Record<string, { failing?: string[]; ran?: string[] } | undefined>).quality_gates ?? {}) as {
|
|
54
83
|
failing?: string[];
|
|
84
|
+
ran?: string[];
|
|
55
85
|
};
|
|
56
86
|
}
|
|
57
87
|
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
import { TITLE_CLOSE_TAG, TITLE_MAX_CHARS, TITLE_OPEN_TAG, parseTitle } from "./pr-title";
|
|
16
|
+
|
|
17
|
+
/** Longest narrative rendered into a PR body, in characters, including the ellipsis. */
|
|
18
|
+
export const NARRATIVE_MAX_CHARS = 4000;
|
|
19
|
+
|
|
20
|
+
const TRUNCATION_SUFFIX = "…";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sentinel wrapping the prose, so `parseNarrative` has an explicit anchor
|
|
24
|
+
* rather than an inferred one.
|
|
25
|
+
*
|
|
26
|
+
* acpx hands `parse` the concatenation of *every* agent message chunk in the
|
|
27
|
+
* turn — `chunks.join("")` in its `createQuietCaptureOutput`. This node reads
|
|
28
|
+
* the diff with tools, so the agent's between-tool-call narration ("Now I have
|
|
29
|
+
* a clear picture. Let me check…") is structurally part of that string. A
|
|
30
|
+
* prompt asking for "no preamble" cannot prevent it; only a delimiter can.
|
|
31
|
+
*
|
|
32
|
+
* A sentinel rather than the JSON contract the sibling nodes use: this payload
|
|
33
|
+
* is multi-paragraph prose about code, full of backticks, quotes and newlines.
|
|
34
|
+
* Literal newlines inside a JSON string are invalid JSON, so a JSON contract
|
|
35
|
+
* would fail on exactly the inputs this node exists to carry.
|
|
36
|
+
*/
|
|
37
|
+
const OPEN_TAG = "<narrative>";
|
|
38
|
+
const CLOSE_TAG = "</narrative>";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Headings the agent emits despite being told not to. Anchors the fallback
|
|
42
|
+
* strip when the sentinel is absent: everything up to and including the
|
|
43
|
+
* heading is preamble.
|
|
44
|
+
*/
|
|
45
|
+
const HEADING_RE = /^[\s\S]*?(?:\*\*What changed\*\*|##+\s*What changed)\s*/i;
|
|
46
|
+
|
|
47
|
+
/** A `<title>…</title>` block, closed or not — removed wholesale from the prose. */
|
|
48
|
+
const TITLE_BLOCK_RE = new RegExp(`${TITLE_OPEN_TAG}[\\s\\S]*?(?:${TITLE_CLOSE_TAG}|$)`, "gi");
|
|
49
|
+
|
|
50
|
+
/** Headings a spec uses for its lead paragraph, in priority order. */
|
|
51
|
+
const SUMMARY_HEADINGS = ["summary", "overview"] as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Prompt for the narrative node.
|
|
55
|
+
*
|
|
56
|
+
* Two jobs: point the agent at the real diff (never the spec, which describes
|
|
57
|
+
* intent rather than what shipped), and forbid restating the sections the body
|
|
58
|
+
* already renders deterministically.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* `prompt` for the `narrative` flow node. Lives here rather than inline in
|
|
62
|
+
* `nax-finish.flow.ts` to keep that file under its 600-line cap — the node
|
|
63
|
+
* just needs `ctx.outputs.load_ctx.base`, which is all this wrapper reads.
|
|
64
|
+
*/
|
|
65
|
+
export function narrativePrompt(ctx: { outputs: unknown }): string {
|
|
66
|
+
const base = (ctx.outputs as { load_ctx?: { base?: string } }).load_ctx?.base ?? "origin/main";
|
|
67
|
+
return buildNarrativePrompt({ base });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildNarrativePrompt(args: { base: string }): string {
|
|
71
|
+
return [
|
|
72
|
+
'Write the "What changed" section of a pull request body.',
|
|
73
|
+
"",
|
|
74
|
+
`Read the branch diff yourself: \`git diff ${args.base}...HEAD\`.`,
|
|
75
|
+
"Read whatever source files you need to understand it.",
|
|
76
|
+
"",
|
|
77
|
+
"The PR body ALREADY renders these deterministically, from run artifacts:",
|
|
78
|
+
"- a Stories table (story id, title, acceptance-criteria count)",
|
|
79
|
+
"- a Verification block (acceptance status, regression status, gates run, diffstat)",
|
|
80
|
+
"- a Review rounds block (every finding, with its severity)",
|
|
81
|
+
"- an Out of scope list",
|
|
82
|
+
"",
|
|
83
|
+
"Do NOT restate, summarise, or refer to any of them. Repeating them is how the",
|
|
84
|
+
"written and the generated halves of this body drift apart.",
|
|
85
|
+
"",
|
|
86
|
+
"Describe what the change actually does, in prose: the shape of the change, and",
|
|
87
|
+
"anything a reviewer would otherwise have to reconstruct from the diff by hand.",
|
|
88
|
+
`Hard limit: ${NARRATIVE_MAX_CHARS} characters.`,
|
|
89
|
+
"Do not write a heading — the heading is added for you.",
|
|
90
|
+
"",
|
|
91
|
+
"Then write the pull request title: a conventional-commit subject describing",
|
|
92
|
+
`the change (\`fix: …\`, \`feat: …\`, \`refactor: …\`), at most ${TITLE_MAX_CHARS} characters.`,
|
|
93
|
+
"Describe what the change does — not the feature's name, which the reader",
|
|
94
|
+
"can already see on the branch.",
|
|
95
|
+
"",
|
|
96
|
+
"Reply with exactly these two blocks, and write nothing after the last one:",
|
|
97
|
+
`${TITLE_OPEN_TAG}conventional-commit subject${TITLE_CLOSE_TAG}`,
|
|
98
|
+
`${OPEN_TAG}the prose${CLOSE_TAG}`,
|
|
99
|
+
"",
|
|
100
|
+
"Everything outside those tags is discarded, so anything you say while working",
|
|
101
|
+
"through the diff is safe to leave where it falls.",
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `parse` for the narrative acp node.
|
|
107
|
+
*
|
|
108
|
+
* Never throws. A throw inside `parse` fails the node, and acpx has no error
|
|
109
|
+
* edge — see `verdict.ts`. Here that would mean the flow dying *after* the PR
|
|
110
|
+
* was already opened, so every branch below degrades instead of rejecting.
|
|
111
|
+
*
|
|
112
|
+
* Three tiers, strongest anchor first:
|
|
113
|
+
* 1. Sentinel — the contract the prompt asks for.
|
|
114
|
+
* 2. Heading — the agent ignored the sentinel but still wrote
|
|
115
|
+
* `**What changed**`, which marks where its preamble stopped.
|
|
116
|
+
* 3. Bare trim — no anchor available; better a narrative with preamble than
|
|
117
|
+
* no narrative at all.
|
|
118
|
+
*/
|
|
119
|
+
export function parseNarrative(text: string): string {
|
|
120
|
+
if (typeof text !== "string") return "";
|
|
121
|
+
|
|
122
|
+
// Last opening tag, not the first: if the agent narrates the tag before
|
|
123
|
+
// emitting it for real ("I'll wrap this in <narrative>"), the real one wins.
|
|
124
|
+
const open = text.lastIndexOf(OPEN_TAG);
|
|
125
|
+
if (open !== -1) {
|
|
126
|
+
const from = open + OPEN_TAG.length;
|
|
127
|
+
const close = text.indexOf(CLOSE_TAG, from);
|
|
128
|
+
const inner = (close === -1 ? text.slice(from) : text.slice(from, close)).trim();
|
|
129
|
+
if (inner) return inner;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Strip tag markers before the heading pass: an empty or malformed sentinel
|
|
133
|
+
// falls through to here, and leftover `<narrative>` markup in a PR body is
|
|
134
|
+
// worse than the preamble this function exists to remove. The title block
|
|
135
|
+
// goes entirely — tags and content — since it is not part of the prose.
|
|
136
|
+
const untagged = text.replace(TITLE_BLOCK_RE, "").split(OPEN_TAG).join("").split(CLOSE_TAG).join("");
|
|
137
|
+
return untagged.replace(HEADING_RE, "").trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What the `narrative` acp node returns: the prose, and the title to rename the PR to. */
|
|
141
|
+
export interface NarrativeNodeResult {
|
|
142
|
+
narrative: string;
|
|
143
|
+
title?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `parse` for the narrative acp node.
|
|
148
|
+
*
|
|
149
|
+
* Both halves are optional to the flow: a missing title leaves the PR on
|
|
150
|
+
* `feat: <feature>`, and missing prose leaves the body's mechanical sections
|
|
151
|
+
* alone. Never throws, for the reason `parseNarrative` documents.
|
|
152
|
+
*/
|
|
153
|
+
export function parseNarrativeNode(text: string): NarrativeNodeResult {
|
|
154
|
+
return { narrative: parseNarrative(text), title: parseTitle(text) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function truncate(text: string): string {
|
|
158
|
+
if (text.length <= NARRATIVE_MAX_CHARS) return text;
|
|
159
|
+
return text.slice(0, NARRATIVE_MAX_CHARS - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pick the narrative text, best source first.
|
|
164
|
+
*
|
|
165
|
+
* The spec summary is the fallback rather than the primary source because a
|
|
166
|
+
* spec describes intent: when an implementation deviates and the deviation is
|
|
167
|
+
* accepted, a spec-derived narrative confidently describes code that does not
|
|
168
|
+
* exist.
|
|
169
|
+
*
|
|
170
|
+
* `undefined` means "render no section at all" — never an empty heading.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveNarrative(agentText: string | undefined, specSummary: string | null): string | undefined {
|
|
173
|
+
const fromAgent = agentText?.trim();
|
|
174
|
+
if (fromAgent) return truncate(fromAgent);
|
|
175
|
+
const fromSpec = specSummary?.trim();
|
|
176
|
+
if (fromSpec) return truncate(fromSpec);
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sectionBody(lines: string[], heading: string): string | null {
|
|
181
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading}`);
|
|
182
|
+
if (start === -1) return null;
|
|
183
|
+
const rest = lines.slice(start + 1);
|
|
184
|
+
const end = rest.findIndex((line) => line.startsWith("## "));
|
|
185
|
+
const body = (end === -1 ? rest : rest.slice(0, end)).join("\n").trim();
|
|
186
|
+
return body.length > 0 ? body : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* First `## Summary` or `## Overview` block in the spec, or `null`.
|
|
191
|
+
*
|
|
192
|
+
* Both headings are accepted because both occur in this repository's real
|
|
193
|
+
* specs — five of six use `## Summary`, the older `plugin-001` uses
|
|
194
|
+
* `## Overview`. Fail-open on every read error: a missing or unreadable spec
|
|
195
|
+
* costs the section, never the PR.
|
|
196
|
+
*/
|
|
197
|
+
export async function readSpecSummary(
|
|
198
|
+
specPath: string | undefined,
|
|
199
|
+
readText: (path: string) => Promise<string | null>,
|
|
200
|
+
): Promise<string | null> {
|
|
201
|
+
if (!specPath) return null;
|
|
202
|
+
let text: string | null;
|
|
203
|
+
try {
|
|
204
|
+
text = await readText(specPath);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (text === null) return null;
|
|
209
|
+
const lines = text.split(/\r?\n/);
|
|
210
|
+
for (const heading of SUMMARY_HEADINGS) {
|
|
211
|
+
const body = sectionBody(lines, heading);
|
|
212
|
+
if (body !== null) return body;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
@@ -41,18 +41,22 @@
|
|
|
41
41
|
* that is killed or times out — no terminal node runs on that path, and a
|
|
42
42
|
* crashed finish is exactly when the record of what it changed matters most.
|
|
43
43
|
*/
|
|
44
|
-
import { defineFlow
|
|
44
|
+
import { defineFlow } from "acpx/flows";
|
|
45
45
|
import { buildFixCommitMessage } from "./commit-message";
|
|
46
46
|
import { findingsOf, fixAttemptCount, gateOutputs, incrementalSince, inputOf, loadCtxOf } from "./flow-ctx";
|
|
47
|
+
import { narrativePrompt, parseNarrativeNode } from "./narrative";
|
|
47
48
|
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
48
49
|
import {
|
|
49
50
|
_contextDeps,
|
|
51
|
+
amendPrBodyNode,
|
|
50
52
|
appendRound,
|
|
51
53
|
buildEscalationComment,
|
|
52
54
|
commitAndPush,
|
|
53
55
|
commitFixes,
|
|
54
56
|
detectBaseBranch,
|
|
57
|
+
detectForge,
|
|
55
58
|
filesInCommit,
|
|
59
|
+
loadFinishPrContext,
|
|
56
60
|
loadQualityCommands,
|
|
57
61
|
openOrPromotePr,
|
|
58
62
|
partitionTestFiles,
|
|
@@ -63,15 +67,28 @@ import {
|
|
|
63
67
|
runQualityGates,
|
|
64
68
|
writeResult,
|
|
65
69
|
} from "./steps";
|
|
70
|
+
import type { Forge } from "./steps/forge";
|
|
71
|
+
import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
|
|
66
72
|
import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
|
|
73
|
+
import { MAX_FIX_ATTEMPTS, parseFixVerdict, parseReviewVerdict, repromptCount, routeReview } from "./verdict";
|
|
67
74
|
|
|
68
75
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* time) hangs `acpx flow run` — and the post-run plugin awaits that subprocess.
|
|
76
|
+
* Disabled only on an explicit "0". An unset variable means enabled, so a flow
|
|
77
|
+
* invoked directly by `acpx flow run` — outside the plugin that sets the env —
|
|
78
|
+
* still writes the narrative.
|
|
73
79
|
*/
|
|
74
|
-
const
|
|
80
|
+
const NARRATIVE_ENABLED = process.env.NAX_FINISH_NARRATIVE !== "0";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Injectable seam for the `open_pr` node's title/body assembly — tests stub
|
|
84
|
+
* these to control the fallback-vs-built-metadata paths without a real PRD or
|
|
85
|
+
* git checkout.
|
|
86
|
+
*/
|
|
87
|
+
export const _openPrDeps = {
|
|
88
|
+
loadFinishPrContext,
|
|
89
|
+
buildFinishTitle,
|
|
90
|
+
buildFinishBody,
|
|
91
|
+
};
|
|
75
92
|
|
|
76
93
|
/**
|
|
77
94
|
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
@@ -126,50 +143,6 @@ async function acceptanceGateNode(ctx: {
|
|
|
126
143
|
return { route: "fix", output: r.output };
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
/**
|
|
130
|
-
* Turn a reviewer verdict into a deterministic route.
|
|
131
|
-
*
|
|
132
|
-
* `clean` (no findings) skips the fix node entirely — prompting an agent to
|
|
133
|
-
* "apply the recommended fixes" for an empty finding list burns a turn and
|
|
134
|
-
* invites unrequested edits.
|
|
135
|
-
*/
|
|
136
|
-
function routeReview(
|
|
137
|
-
ctx: { outputs: unknown; state: { steps: { nodeId: string }[] } },
|
|
138
|
-
phase: "spec" | "quality",
|
|
139
|
-
): { route: string; escalationReason?: string; findings: ReviewVerdict["findings"] } {
|
|
140
|
-
const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
|
|
141
|
-
const findings = verdict?.findings ?? [];
|
|
142
|
-
if (verdict?.route === "escalate") {
|
|
143
|
-
return {
|
|
144
|
-
route: "escalate",
|
|
145
|
-
escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
|
|
146
|
-
findings,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
if (findings.length === 0) return { route: "clean", findings };
|
|
150
|
-
const attempts = fixAttemptCount(ctx, `fix_${phase}`);
|
|
151
|
-
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
152
|
-
return {
|
|
153
|
-
route: "escalate",
|
|
154
|
-
escalationReason: `${phase} review still reporting ${findings.length} finding(s) after ${attempts} fix attempts.`,
|
|
155
|
-
findings,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
return { route: "fix", findings };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Build the `commit_<phase>` node that follows `fix_<phase>`.
|
|
163
|
-
*
|
|
164
|
-
* One node per phase rather than a single shared one because each returns to a
|
|
165
|
-
* different successor, and acpx routes on the node id — a shared node would
|
|
166
|
-
* need a switch reconstructing which fix ran from the step history.
|
|
167
|
-
*
|
|
168
|
-
* Also the audit seam: this is the only point in the graph where a round's
|
|
169
|
-
* findings and its commit are both known. `ctx.outputs` keeps only the latest
|
|
170
|
-
* output per node, so a round not recorded here is a round no terminal node
|
|
171
|
-
* can reconstruct.
|
|
172
|
-
*/
|
|
173
146
|
/**
|
|
174
147
|
* Route for `commit_gate`, whose successor depends on what the fix touched.
|
|
175
148
|
*
|
|
@@ -239,6 +212,11 @@ function commitFixNode(phase: FinishPhase) {
|
|
|
239
212
|
committed,
|
|
240
213
|
findings: findingsOf(ctx, phase),
|
|
241
214
|
...(phase === "gate" ? { failing: gateOutputs(ctx).failing ?? [] } : {}),
|
|
215
|
+
// Carry `shaAfter` onto committed rounds only: a no-op round has no
|
|
216
|
+
// commit, so no SHA to record — keeping the field absent (rather than
|
|
217
|
+
// null/undefined) lets the result-file reader distinguish "no commit"
|
|
218
|
+
// from "record lost".
|
|
219
|
+
...(committed && shaAfter ? { sha: shaAfter } : {}),
|
|
242
220
|
});
|
|
243
221
|
// Only `commit_gate` routes on this; the other phases have unconditional
|
|
244
222
|
// edges and ignore it.
|
|
@@ -253,14 +231,6 @@ function commitFixNode(phase: FinishPhase) {
|
|
|
253
231
|
};
|
|
254
232
|
}
|
|
255
233
|
|
|
256
|
-
/** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
|
|
257
|
-
function parseVerdict(text: string): ReviewVerdict {
|
|
258
|
-
const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
|
|
259
|
-
const findings = Array.isArray(raw.findings) ? raw.findings : [];
|
|
260
|
-
const route = raw.route === "escalate" ? "escalate" : findings.length === 0 ? "clean" : "proceed";
|
|
261
|
-
return { route, findings, escalationReason: raw.escalationReason };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
234
|
export default defineFlow({
|
|
265
235
|
name: "nax-finish",
|
|
266
236
|
permissions: {
|
|
@@ -295,7 +265,7 @@ export default defineFlow({
|
|
|
295
265
|
fix_acceptance: {
|
|
296
266
|
nodeType: "acp",
|
|
297
267
|
prompt: (ctx) => fixPrompt("acceptance", ctx),
|
|
298
|
-
parse:
|
|
268
|
+
parse: parseFixVerdict,
|
|
299
269
|
},
|
|
300
270
|
commit_acceptance: commitFixNode("acceptance"),
|
|
301
271
|
review_spec: {
|
|
@@ -309,9 +279,10 @@ export default defineFlow({
|
|
|
309
279
|
specPath: outs.specPath ?? "",
|
|
310
280
|
since: incrementalSince(ctx, "spec"),
|
|
311
281
|
priorFindings: findingsOf(ctx, "spec"),
|
|
282
|
+
retry: repromptCount(ctx, "spec") > 0,
|
|
312
283
|
});
|
|
313
284
|
},
|
|
314
|
-
parse:
|
|
285
|
+
parse: parseReviewVerdict,
|
|
315
286
|
},
|
|
316
287
|
route_spec: {
|
|
317
288
|
nodeType: "compute",
|
|
@@ -320,7 +291,7 @@ export default defineFlow({
|
|
|
320
291
|
fix_spec: {
|
|
321
292
|
nodeType: "acp",
|
|
322
293
|
prompt: (ctx) => fixPrompt("spec", ctx),
|
|
323
|
-
parse:
|
|
294
|
+
parse: parseFixVerdict,
|
|
324
295
|
},
|
|
325
296
|
commit_spec: commitFixNode("spec"),
|
|
326
297
|
review_quality: {
|
|
@@ -334,9 +305,10 @@ export default defineFlow({
|
|
|
334
305
|
specPath: outs.specPath ?? "",
|
|
335
306
|
since: incrementalSince(ctx, "quality"),
|
|
336
307
|
priorFindings: findingsOf(ctx, "quality"),
|
|
308
|
+
retry: repromptCount(ctx, "quality") > 0,
|
|
337
309
|
});
|
|
338
310
|
},
|
|
339
|
-
parse:
|
|
311
|
+
parse: parseReviewVerdict,
|
|
340
312
|
},
|
|
341
313
|
route_quality: {
|
|
342
314
|
nodeType: "compute",
|
|
@@ -345,13 +317,13 @@ export default defineFlow({
|
|
|
345
317
|
fix_quality: {
|
|
346
318
|
nodeType: "acp",
|
|
347
319
|
prompt: (ctx) => fixPrompt("quality", ctx),
|
|
348
|
-
parse:
|
|
320
|
+
parse: parseFixVerdict,
|
|
349
321
|
},
|
|
350
322
|
commit_quality: commitFixNode("quality"),
|
|
351
323
|
fix_gate: {
|
|
352
324
|
nodeType: "acp",
|
|
353
325
|
prompt: (ctx) => fixPrompt("gate", ctx),
|
|
354
|
-
parse:
|
|
326
|
+
parse: parseFixVerdict,
|
|
355
327
|
},
|
|
356
328
|
commit_gate: commitFixNode("gate"),
|
|
357
329
|
quality_gates: {
|
|
@@ -430,23 +402,65 @@ export default defineFlow({
|
|
|
430
402
|
nodeType: "action",
|
|
431
403
|
async run(ctx) {
|
|
432
404
|
const i = inputOf(ctx);
|
|
433
|
-
|
|
405
|
+
const loadCtx = loadCtxOf(ctx);
|
|
406
|
+
if (loadCtx.route === "nothing-to-finish") {
|
|
434
407
|
await writeResult(i, { feature: i.feature, status: "nothing-to-finish" });
|
|
435
408
|
return { route: "done", status: "nothing-to-finish" };
|
|
436
409
|
}
|
|
437
410
|
// Every fix node edited the working tree; without this the PR would be
|
|
438
411
|
// opened from a remote branch missing all of them.
|
|
439
412
|
const sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
413
|
+
|
|
414
|
+
const fallbackTitle = `nax-finish: ${i.feature}`;
|
|
415
|
+
const fallbackBody = `Automated finish of \`${i.feature}\`.`;
|
|
416
|
+
let title = fallbackTitle;
|
|
417
|
+
let body = fallbackBody;
|
|
418
|
+
// Detected once, here, and handed to both the body builder (which needs
|
|
419
|
+
// it for the repo template) and the opener. Detecting in both would let
|
|
420
|
+
// them disagree. On a throw it stays undefined and `openOrPromotePr`
|
|
421
|
+
// detects for itself, exactly as it did before.
|
|
422
|
+
let forge: Forge | undefined;
|
|
423
|
+
try {
|
|
424
|
+
forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
|
|
425
|
+
const prCtx = await _openPrDeps.loadFinishPrContext(i, {
|
|
426
|
+
base: loadCtx.base ?? "",
|
|
427
|
+
gatesRan: gateOutputs(ctx).ran ?? [],
|
|
428
|
+
forge,
|
|
429
|
+
specPath: loadCtx.specPath,
|
|
430
|
+
});
|
|
431
|
+
title = _openPrDeps.buildFinishTitle(prCtx);
|
|
432
|
+
body = _openPrDeps.buildFinishBody(prCtx);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
_prBodyDeps.warn("[finish-pr] Falling back to default PR title/body", { path: i.prdPath, error });
|
|
435
|
+
title = fallbackTitle;
|
|
436
|
+
body = fallbackBody;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const r = await openOrPromotePr(i.workdir, i.branch, title, body, forge);
|
|
446
440
|
await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
|
|
447
|
-
|
|
441
|
+
// The PR now exists with the mechanical narrative already in place.
|
|
442
|
+
// Anything the narrative node does from here is an improvement on a
|
|
443
|
+
// body that is already correct.
|
|
444
|
+
return { route: NARRATIVE_ENABLED ? "narrate" : "done", committed: sync.committed, ...r };
|
|
448
445
|
},
|
|
449
446
|
},
|
|
447
|
+
narrative: {
|
|
448
|
+
nodeType: "acp",
|
|
449
|
+
session: { isolated: true },
|
|
450
|
+
profile: process.env.NAX_FINISH_NARRATIVE_PROFILE || undefined,
|
|
451
|
+
prompt: narrativePrompt,
|
|
452
|
+
parse: parseNarrativeNode,
|
|
453
|
+
},
|
|
454
|
+
amend_body: {
|
|
455
|
+
nodeType: "action",
|
|
456
|
+
run: amendPrBodyNode,
|
|
457
|
+
},
|
|
458
|
+
// Inert terminal. acpx switch cases must name a real node, so the `done`
|
|
459
|
+
// route out of open_pr needs somewhere to land.
|
|
460
|
+
finish_done: {
|
|
461
|
+
nodeType: "compute",
|
|
462
|
+
run: () => ({ route: "done" }),
|
|
463
|
+
},
|
|
450
464
|
escalate: {
|
|
451
465
|
nodeType: "action",
|
|
452
466
|
async run(ctx) {
|
|
@@ -525,7 +539,10 @@ export default defineFlow({
|
|
|
525
539
|
{ from: "review_spec", to: "route_spec" },
|
|
526
540
|
{
|
|
527
541
|
from: "route_spec",
|
|
528
|
-
switch: {
|
|
542
|
+
switch: {
|
|
543
|
+
on: "$.route",
|
|
544
|
+
cases: { clean: "review_quality", fix: "fix_spec", escalate: "escalate", reprompt: "review_spec" },
|
|
545
|
+
},
|
|
529
546
|
},
|
|
530
547
|
// Spec fixes re-run the acceptance gate first (they can break it), and the
|
|
531
548
|
// acceptance node's `proceed` edge leads back into review_spec for re-review.
|
|
@@ -534,7 +551,10 @@ export default defineFlow({
|
|
|
534
551
|
{ from: "review_quality", to: "route_quality" },
|
|
535
552
|
{
|
|
536
553
|
from: "route_quality",
|
|
537
|
-
switch: {
|
|
554
|
+
switch: {
|
|
555
|
+
on: "$.route",
|
|
556
|
+
cases: { clean: "quality_gates", fix: "fix_quality", escalate: "escalate", reprompt: "review_quality" },
|
|
557
|
+
},
|
|
538
558
|
},
|
|
539
559
|
// Quality fixes are re-reviewed by the same lens; the repo-root gates that
|
|
540
560
|
// follow catch anything the fix broke mechanically.
|
|
@@ -564,5 +584,9 @@ export default defineFlow({
|
|
|
564
584
|
cases: { changed: "review_quality", "tests-only": "quality_gates", unchanged: "quality_gates" },
|
|
565
585
|
},
|
|
566
586
|
},
|
|
587
|
+
// The narrative runs only once the PR exists. acpx has no error edge, so an
|
|
588
|
+
// acp node before `open_pr` would be able to fail the flow and cost the PR.
|
|
589
|
+
{ from: "open_pr", switch: { on: "$.route", cases: { narrate: "narrative", done: "finish_done" } } },
|
|
590
|
+
{ from: "narrative", to: "amend_body" },
|
|
567
591
|
],
|
|
568
592
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository PR/MR template discovery, ported from
|
|
3
|
+
* `src/plugins/builtin/auto-pr/template.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Ported rather than imported: `flows/` is loaded by acpx in its own Node
|
|
6
|
+
* process, where nax's `src/` and its `@/*` alias do not exist. This matches
|
|
7
|
+
* the convention already in this directory — `errors.ts`, `exec.ts`, `types.ts`
|
|
8
|
+
* and the PR body builder are all flow-local re-implementations.
|
|
9
|
+
*
|
|
10
|
+
* The duplication is stable: these candidate paths are an external convention
|
|
11
|
+
* set by GitHub and GitLab, not internal logic that drifts with the codebase.
|
|
12
|
+
*
|
|
13
|
+
* Why preserve-not-fill: passing `--body` / `--description` to `gh` / `glab`
|
|
14
|
+
* suppresses the repo's default template, so it must be read and re-embedded.
|
|
15
|
+
*/
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { Forge } from "./steps/forge";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Candidate template paths for GitHub, in priority order.
|
|
21
|
+
* Multi-template directories (`PULL_REQUEST_TEMPLATE/`) are intentionally
|
|
22
|
+
* skipped because they are ambiguous unattended.
|
|
23
|
+
*/
|
|
24
|
+
const GITHUB_TEMPLATE_PATHS: readonly string[] = [
|
|
25
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
26
|
+
".github/pull_request_template.md",
|
|
27
|
+
"PULL_REQUEST_TEMPLATE.md",
|
|
28
|
+
"docs/PULL_REQUEST_TEMPLATE.md",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
/** Preferred single-template location for GitLab. */
|
|
32
|
+
const GITLAB_DEFAULT_TEMPLATE_PATH = ".gitlab/merge_request_templates/Default.md";
|
|
33
|
+
|
|
34
|
+
/** Only `readText` is consulted, so any caller with a file reader can supply it. */
|
|
35
|
+
export interface TemplateDeps {
|
|
36
|
+
readText: (path: string) => Promise<string | null>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function firstExisting(workdir: string, deps: TemplateDeps, paths: readonly string[]): Promise<string | null> {
|
|
40
|
+
for (const relPath of paths) {
|
|
41
|
+
const content = await deps.readText(join(workdir, relPath));
|
|
42
|
+
if (content !== null) return content;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Locate the PR/MR template for the current repository.
|
|
49
|
+
*
|
|
50
|
+
* @returns Template text verbatim, or `null` when none resolves — which is the
|
|
51
|
+
* common case and never an error.
|
|
52
|
+
*/
|
|
53
|
+
export async function findPrTemplate(workdir: string, forge: Forge, deps: TemplateDeps): Promise<string | null> {
|
|
54
|
+
if (forge === "github") return firstExisting(workdir, deps, GITHUB_TEMPLATE_PATHS);
|
|
55
|
+
return firstExisting(workdir, deps, [GITLAB_DEFAULT_TEMPLATE_PATH]);
|
|
56
|
+
}
|