@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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The PR title — sentinel, sanitiser, and the fallback chain.
3
+ *
4
+ * `buildFinishTitle` used to return `feat: <feature>` unconditionally, so every
5
+ * finish-opened PR was titled with its feature slug: `feat: schema-drift-gate`
6
+ * describes the run, not the change. The narrative node has already read the
7
+ * whole diff by the time the body is amended, so a real conventional-commit
8
+ * subject costs one extra sentinel in a prompt that was being sent anyway.
9
+ *
10
+ * No deterministic source can replace it. The spec's H1 is the slug in prose
11
+ * (`# SPEC: Schema drift gate`), the PRD carries no feature-level title, and
12
+ * concatenating story titles reads worse than the slug it replaces — which is
13
+ * why this is the one part of the PR metadata that is model-derived, and why
14
+ * everything below assumes the model may return junk.
15
+ *
16
+ * Lives beside `narrative.ts` rather than in `src/prompts/builders/` for the
17
+ * same reason that file gives: `flows/` runs in acpx's Node process and imports
18
+ * nothing from `src/`.
19
+ */
20
+
21
+ /** Sentinel wrapping the title. See `narrative.ts` for why a delimiter is required at all. */
22
+ export const TITLE_OPEN_TAG = "<title>";
23
+ export const TITLE_CLOSE_TAG = "</title>";
24
+
25
+ /**
26
+ * Longest title rendered onto a PR.
27
+ *
28
+ * 72 is the conventional-commit subject norm, and GitHub truncates around this
29
+ * width in list views.
30
+ */
31
+ export const TITLE_MAX_CHARS = 72;
32
+
33
+ /**
34
+ * Conventional-commit prefix, split into the type-with-scope and the subject.
35
+ *
36
+ * Types mirror the list in `.claude/rules/project-conventions.md`, plus
37
+ * `revert`. Captured rather than merely tested so the two halves can be
38
+ * rejoined with exactly one space — `feat:no space` and `feat:` both reach
39
+ * here, and testing alone let the latter become `feat: feat:`.
40
+ *
41
+ * A title arriving without any prefix is prefixed rather than rejected: the
42
+ * prose is usually right even when the model forgets the ceremony.
43
+ */
44
+ const CONVENTIONAL_PREFIX_RE =
45
+ /^((?:feat|fix|refactor|perf|docs|test|chore|ci|build|style|revert)(?:\([^)]*\))?!?):\s*([\s\S]*)$/i;
46
+
47
+ const DEFAULT_TYPE = "feat";
48
+
49
+ /** Wrapping quotes/backticks the model adds when it treats the title as a quoted string. */
50
+ const WRAPPING_CHARS = new Set(['"', "'", "`", "*", "_"]);
51
+
52
+ function stripWrapping(text: string): string {
53
+ let out = text;
54
+ // Loop: models nest these ("`fix: thing`" arrives quoted *and* fenced).
55
+ while (out.length >= 2) {
56
+ const first = out[0];
57
+ const last = out[out.length - 1];
58
+ if (first !== undefined && first === last && WRAPPING_CHARS.has(first)) {
59
+ out = out.slice(1, -1).trim();
60
+ continue;
61
+ }
62
+ break;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Cut to `TITLE_MAX_CHARS` on a word boundary where one is available.
69
+ *
70
+ * A mid-word cut reads as corruption rather than brevity; falling back to a
71
+ * hard slice only matters for a title with no spaces at all.
72
+ */
73
+ function clamp(text: string): string {
74
+ if (text.length <= TITLE_MAX_CHARS) return text;
75
+ const cut = text.slice(0, TITLE_MAX_CHARS);
76
+ const lastSpace = cut.lastIndexOf(" ");
77
+ // Guard against a long type prefix eating the whole budget: only honour a
78
+ // word boundary that leaves a meaningful subject behind.
79
+ const MIN_KEEP = 20;
80
+ return (lastSpace >= MIN_KEEP ? cut.slice(0, lastSpace) : cut).trimEnd();
81
+ }
82
+
83
+ /**
84
+ * Normalise a model-supplied title, or `undefined` if nothing usable survives.
85
+ *
86
+ * Never throws — this feeds `parse` on an acp node, and the flow's PR is
87
+ * already open by the time it runs.
88
+ */
89
+ export function sanitizeTitle(raw: string | undefined): string | undefined {
90
+ if (typeof raw !== "string") return undefined;
91
+
92
+ // First non-empty line: a title is single-line by definition, and a model
93
+ // that adds a rationale below it must not push that onto the PR.
94
+ const firstLine = raw.split(/\r?\n/).find((line) => line.trim().length > 0);
95
+ if (firstLine === undefined) return undefined;
96
+
97
+ // Collapse internal runs of whitespace before measuring, so the length cap
98
+ // reflects what a reader sees.
99
+ let title = stripWrapping(firstLine.trim()).replace(/\s+/g, " ");
100
+ // Markdown heading marks, for a model that answers the "write a title" ask
101
+ // with a heading.
102
+ title = title.replace(/^#+\s*/, "").trim();
103
+ title = stripWrapping(title);
104
+ // Trailing sentence punctuation — conventional-commit subjects carry none.
105
+ title = title.replace(/[.\s]+$/, "");
106
+ if (!title) return undefined;
107
+
108
+ const match = CONVENTIONAL_PREFIX_RE.exec(title);
109
+ const type = match?.[1] ?? DEFAULT_TYPE;
110
+ const subject = (match?.[2] ?? title).trim();
111
+ // A bare `feat:` carries no subject, and a type alone is not a title.
112
+ if (!subject) return undefined;
113
+
114
+ return clamp(`${type}: ${subject}`);
115
+ }
116
+
117
+ /**
118
+ * Extract the title from the narrative node's reply.
119
+ *
120
+ * Last opening tag wins, mirroring `parseNarrative` — a model that narrates the
121
+ * tag before emitting it must not beat the real one.
122
+ */
123
+ export function parseTitle(text: string): string | undefined {
124
+ if (typeof text !== "string") return undefined;
125
+ const open = text.lastIndexOf(TITLE_OPEN_TAG);
126
+ if (open === -1) return undefined;
127
+ const from = open + TITLE_OPEN_TAG.length;
128
+ const close = text.indexOf(TITLE_CLOSE_TAG, from);
129
+ return sanitizeTitle(close === -1 ? text.slice(from) : text.slice(from, close));
130
+ }
131
+
132
+ /**
133
+ * The title to render, best source first.
134
+ *
135
+ * `feat: <feature>` remains the floor: it is what shipped before, it is what
136
+ * the auto-PR plugin opens with, and it is always available.
137
+ */
138
+ export function resolveTitle(agentTitle: string | undefined, feature: string): string {
139
+ return sanitizeTitle(agentTitle) ?? `${DEFAULT_TYPE}: ${feature}`;
140
+ }
@@ -312,6 +312,17 @@ const JSON_CONTRACT = [
312
312
  "}",
313
313
  ].join("\n");
314
314
 
315
+ /**
316
+ * Prepended when a previous attempt at this review returned something that was
317
+ * not JSON. Lead position, not appended: the failure mode is a model that
318
+ * narrates its findings and forgets the contract at the end of a long turn.
319
+ */
320
+ const RETRY_NOTICE = [
321
+ "IMPORTANT — your previous reply could not be parsed as JSON, so it was discarded entirely.",
322
+ "Do not narrate your findings in prose. Do not describe what you reported.",
323
+ "Your entire reply must be the JSON object described at the end of this prompt: first char `{`, last char `}`.",
324
+ ].join("\n");
325
+
315
326
  /**
316
327
  * Build the reviewer prompt.
317
328
  *
@@ -329,11 +340,19 @@ const JSON_CONTRACT = [
329
340
  */
330
341
  export function buildReviewPrompt(
331
342
  phase: "spec" | "quality",
332
- args: { base: string; specPath: string; since?: string | null; priorFindings?: Finding[] },
343
+ args: {
344
+ base: string;
345
+ specPath: string;
346
+ since?: string | null;
347
+ priorFindings?: Finding[];
348
+ retry?: boolean;
349
+ },
333
350
  ): string {
334
351
  const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
352
+ const lead = args.retry ? [RETRY_NOTICE] : [];
335
353
  if (!args.since) {
336
354
  return [
355
+ ...lead,
337
356
  `You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
338
357
  `The spec/requirements source is: ${args.specPath}. Read it in full.`,
339
358
  `Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
@@ -344,6 +363,7 @@ export function buildReviewPrompt(
344
363
  ].join("\n\n");
345
364
  }
346
365
  return [
366
+ ...lead,
347
367
  `You are the ${phase.toUpperCase()} reviewer for a completed feature, continuing a review you already started.`,
348
368
  `On your previous pass over \`git diff ${args.base}...HEAD\` you raised the findings below, and they have since been fixed and committed. Everything else in that diff you already judged acceptable — do not re-derive a verdict on it.`,
349
369
  `Your findings from the previous pass:\n${JSON.stringify(args.priorFindings ?? [], null, 2)}`,
@@ -5,4 +5,5 @@ export * from "./escalate";
5
5
  export * from "./forge";
6
6
  export * from "./git";
7
7
  export * from "./pr";
8
+ export * from "./pr-narrative";
8
9
  export * from "./result";
@@ -0,0 +1,410 @@
1
+ /**
2
+ * nax-finish PR title and body — pure deterministic builder, plus the loader
3
+ * that assembles a `FinishPrContext` from finish-audit artifacts on disk.
4
+ *
5
+ * The finish flow opens a PR via `openOrPromotePr` and used to ship a
6
+ * hardcoded `nax-finish: <feature>` title and a one-sentence body, throwing
7
+ * away every artifact the run produced on the way. This module restores that
8
+ * context as a deterministic markdown body, assembled by string joins over the
9
+ * fields in `FinishPrContext`. Every *section* is reproducible from artifacts
10
+ * that exist before `open_pr` runs, so the body stays greppable in PR history.
11
+ *
12
+ * Two fields are the exception, and both arrive later, from the narrative node
13
+ * that runs after the PR is already open: `narrative` and `title`. Each has a
14
+ * deterministic fallback (`resolveNarrative`, `resolveTitle`) so `open_pr` never
15
+ * waits on a model — see `steps/pr-narrative.ts`.
16
+ *
17
+ * Reimplemented here (rather than imported from `src/`) because `flows/`
18
+ * ships to a different runtime — `acpx flow run` runs it in acpx's own Node
19
+ * process where nax's `src/` and its `@/*` alias are not available.
20
+ */
21
+ import { readFile } from "node:fs/promises";
22
+ import { dirname, isAbsolute, join } from "node:path";
23
+ import { runArgv } from "../exec";
24
+ import { readSpecSummary, resolveNarrative } from "../narrative";
25
+ import { findPrTemplate } from "../pr-template";
26
+ import { resolveTitle } from "../pr-title";
27
+ import type { Finding, FinishInput, FinishRound, RunFn } from "../types";
28
+ import type { Forge } from "./forge";
29
+ import { readRounds } from "./result";
30
+
31
+ const SECONDS_PER_MINUTE = 60;
32
+ const MS_PER_SECOND = 1000;
33
+
34
+ /** Six hex + one — the abbreviated form used everywhere in PR bodies and logs. */
35
+ const SHORT_SHA_LEN = 7;
36
+
37
+ /** One row in the Stories table. */
38
+ export interface FinishPrStory {
39
+ id: string;
40
+ title: string;
41
+ acCount: number;
42
+ }
43
+
44
+ /** Everything `open_pr` renders, sourced from finish-audit artifacts. */
45
+ export interface FinishPrContext {
46
+ feature: string;
47
+ stories: FinishPrStory[];
48
+ outOfScope: string[];
49
+ acceptance?: string;
50
+ regression?: string;
51
+ gatesRan: string[];
52
+ diffstat?: string;
53
+ /**
54
+ * `--shortstat` for the nax artifacts held out of `diffstat`. Absent when the
55
+ * branch touched none, so a repo that gitignores them renders nothing.
56
+ */
57
+ artifactSummary?: string;
58
+ /** Repository PR/MR template, verbatim. Absent when none resolves. */
59
+ template?: string;
60
+ /** Resolved "What changed" prose. Absent when neither source produced text. */
61
+ narrative?: string;
62
+ /**
63
+ * Resolved conventional-commit PR title. Always set — `resolveTitle` falls
64
+ * back to `feat: <feature>` when the narrative node produced nothing usable.
65
+ */
66
+ title: string;
67
+ rounds: FinishRound[];
68
+ run: {
69
+ durationMs?: number;
70
+ storiesPassed?: number;
71
+ storiesTotal?: number;
72
+ };
73
+ }
74
+
75
+ export const _prBodyDeps: {
76
+ run: RunFn;
77
+ readText: (path: string) => Promise<string | null>;
78
+ warn: (message: string, details: { path: string; error: unknown }) => void;
79
+ } = {
80
+ run: runArgv,
81
+ // ENOENT is the routine case (status.json/prd.json not yet written) and must
82
+ // stay silent — mirrors `_qualityDeps.readText` in `steps/quality.ts`. Only a
83
+ // genuine I/O failure (permission denied, corrupted mount) should warn.
84
+ readText: async (path) => {
85
+ try {
86
+ return await readFile(path, "utf8");
87
+ } catch (err) {
88
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
89
+ throw err;
90
+ }
91
+ },
92
+ warn: (message, details) => process.emitWarning(message, { detail: `${details.path}: ${String(details.error)}` }),
93
+ };
94
+
95
+ interface PrdArtifact {
96
+ userStories?: { id: string; title: string; acceptanceCriteria?: unknown[] }[];
97
+ outOfScope?: string[];
98
+ }
99
+
100
+ interface StatusArtifact {
101
+ postRun?: { acceptance?: { status?: string }; regression?: { status?: string } };
102
+ durationMs?: number;
103
+ progress?: { passed?: number; total?: number };
104
+ }
105
+
106
+ async function readJson(path: string): Promise<unknown> {
107
+ let text: string | null;
108
+ try {
109
+ text = await _prBodyDeps.readText(path);
110
+ } catch (error) {
111
+ _prBodyDeps.warn("[finish-pr] Failed to read PR context artifact", { path, error });
112
+ return undefined;
113
+ }
114
+ if (text === null) return undefined;
115
+ try {
116
+ return JSON.parse(text);
117
+ } catch (error) {
118
+ _prBodyDeps.warn("[finish-pr] Failed to parse PR context artifact", { path, error });
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ function storiesFrom(prd: PrdArtifact | undefined): FinishPrStory[] {
124
+ if (!Array.isArray(prd?.userStories)) return [];
125
+ // A hand-edited or older-schema PRD can carry a story with a missing/non-string
126
+ // `id`/`title` — drop only that row rather than letting `escapeTableCell` throw
127
+ // and take down the entire PR body (caught upstream by `open_pr`'s fallback).
128
+ return prd.userStories
129
+ .filter((story) => typeof story.id === "string" && typeof story.title === "string")
130
+ .map((story) => ({
131
+ id: story.id,
132
+ title: story.title,
133
+ acCount: Array.isArray(story.acceptanceCriteria) ? story.acceptanceCriteria.length : 0,
134
+ }));
135
+ }
136
+
137
+ /**
138
+ * Pathspec matching nax's own run artifacts, at any depth.
139
+ *
140
+ * `**` and the `glob` magic word are both load-bearing. nax writes artifacts
141
+ * to a repo-root `.nax/` *and* to a per-package `<pkg>/.nax/` — a root-anchored
142
+ * `:!.nax/**` silently keeps the per-package copy, which is routinely the
143
+ * largest file in the diff (587 of 2039 insertions on the run that motivated
144
+ * this). Without `glob`, git's default wildmatch lets `*` cross `/` and the
145
+ * two forms stop being distinguishable.
146
+ */
147
+ const NAX_ARTIFACT_PATHSPEC = "**/.nax/**";
148
+
149
+ /** The two halves of the branch's diff: what is under review, and what was held out. */
150
+ interface DiffstatResult {
151
+ diffstat?: string;
152
+ artifactSummary?: string;
153
+ }
154
+
155
+ /** Run `git diff <...args>` under `workdir`, or `undefined` on any non-happy path. */
156
+ async function runGitDiff(workdir: string, args: string[]): Promise<string | undefined> {
157
+ try {
158
+ const res = await _prBodyDeps.run(["git", "diff", ...args], { cwd: workdir });
159
+ if (res.exitCode !== 0) return undefined;
160
+ return res.stdout;
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Diffstat of the branch, excluding nax's own artifacts.
168
+ *
169
+ * The artifacts (`spec.md`, `prd.json`, the generated acceptance test) are
170
+ * committed and real, but they are the run's exhaust rather than the change
171
+ * under review, and they dominate the totals — quoting them in the headline
172
+ * advertises a 2039-line change where 791 lines are reviewable code.
173
+ * `artifactSummary` keeps them accounted for rather than silently dropped, so
174
+ * the body still reconciles against `gh pr diff`.
175
+ *
176
+ * Fail-open on every non-happy path — a non-zero exit (no commits, divergent
177
+ * branch, base missing), a rejected run promise (forks too slow to start), or
178
+ * any thrown error — returning `undefined`. The PR's Verification block is
179
+ * optional, and a routine empty-branch finish must not lose `open_pr` to a
180
+ * throw that the body can simply skip.
181
+ */
182
+ async function runDiffstat(workdir: string, base: string): Promise<DiffstatResult> {
183
+ // An empty `base` would interpolate to `...HEAD`, which git resolves as
184
+ // `HEAD...HEAD` — exit 0, empty stdout — masking the missing-base case as
185
+ // "no changes" instead of skipping explicitly.
186
+ if (!base) return {};
187
+ const range = `${base}...HEAD`;
188
+ const [diffstat, artifacts] = await Promise.all([
189
+ runGitDiff(workdir, ["--stat", range, "--", `:(glob,exclude)${NAX_ARTIFACT_PATHSPEC}`]),
190
+ runGitDiff(workdir, ["--shortstat", range, "--", `:(glob)${NAX_ARTIFACT_PATHSPEC}`]),
191
+ ]);
192
+ const summary = artifacts?.trim();
193
+ return { diffstat, artifactSummary: summary ? summary : undefined };
194
+ }
195
+
196
+ /**
197
+ * Resolve the repository's PR/MR template, fail-open.
198
+ *
199
+ * An absent template is the common case and never warns. A genuine read failure
200
+ * is swallowed too: the body is useful without this section, and `open_pr` must
201
+ * not lose a PR to a permissions error on a file most repos do not have.
202
+ */
203
+ async function loadTemplate(workdir: string, forge: Forge | undefined): Promise<string | undefined> {
204
+ if (forge === undefined) return undefined;
205
+ try {
206
+ return (await findPrTemplate(workdir, forge, { readText: _prBodyDeps.readText })) ?? undefined;
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ }
211
+
212
+ export async function loadFinishPrContext(
213
+ input: FinishInput,
214
+ args: { base: string; gatesRan: string[]; forge?: Forge; specPath?: string; narrative?: string; title?: string },
215
+ ): Promise<FinishPrContext> {
216
+ const inputPrdPath = input.prdPath || "prd.json";
217
+ const prdPath = isAbsolute(inputPrdPath) ? inputPrdPath : join(input.workdir, inputPrdPath);
218
+ // [US-004] The audit trail (`rounds`), the diffstat, and the spec summary
219
+ // are independent of the PRD/status reads — fetching them in parallel keeps
220
+ // the loader's wall clock at max(readRounds, readJson×2, diffstat, spec).
221
+ const [prd, status, rounds, stat, template, specSummary] = (await Promise.all([
222
+ readJson(prdPath),
223
+ readJson(join(dirname(prdPath), "status.json")),
224
+ readRounds(input),
225
+ runDiffstat(input.workdir, args.base),
226
+ loadTemplate(input.workdir, args.forge),
227
+ readSpecSummary(args.specPath, _prBodyDeps.readText),
228
+ ])) as [
229
+ PrdArtifact | undefined,
230
+ StatusArtifact | undefined,
231
+ FinishRound[],
232
+ DiffstatResult,
233
+ string | undefined,
234
+ string | null,
235
+ ];
236
+ return {
237
+ feature: input.feature,
238
+ stories: storiesFrom(prd),
239
+ outOfScope: Array.isArray(prd?.outOfScope) ? prd.outOfScope : [],
240
+ acceptance: status?.postRun?.acceptance?.status,
241
+ regression: status?.postRun?.regression?.status,
242
+ gatesRan: args.gatesRan,
243
+ rounds,
244
+ diffstat: stat.diffstat,
245
+ artifactSummary: stat.artifactSummary,
246
+ template,
247
+ narrative: resolveNarrative(args.narrative, specSummary),
248
+ title: resolveTitle(args.title, input.feature),
249
+ run: {
250
+ durationMs: status?.durationMs,
251
+ storiesPassed: status?.progress?.passed,
252
+ storiesTotal: status?.progress?.total,
253
+ },
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The PR title: the narrative node's conventional-commit subject when it
259
+ * produced one, else `feat: <feature>`.
260
+ *
261
+ * That fallback is what this returned unconditionally, and is still what
262
+ * `buildTitle` in `src/plugins/builtin/auto-pr/pr-body.ts` opens with — so a
263
+ * finish run that reaches `open_pr` before the narrative node has spoken still
264
+ * reads identically to an auto-PR-opened one in a list view. The two diverge
265
+ * only once there is something better to say: `feat: schema-drift-gate` names
266
+ * the run, not the change.
267
+ */
268
+ export function buildFinishTitle(ctx: FinishPrContext): string {
269
+ return ctx.title;
270
+ }
271
+
272
+ /**
273
+ * Escape a string for safe inclusion in a single markdown table cell.
274
+ *
275
+ * Mirrors `escapeTableCell` in `src/plugins/builtin/auto-pr/pr-body.ts`,
276
+ * trimmed to the cases the finish body actually needs: pipes (which break
277
+ * the column boundary) and newlines (which create new rows). Backslashes are
278
+ * escaped first so the pipe escape survives a literal backslash in a title.
279
+ */
280
+ function escapeTableCell(value: string): string {
281
+ return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
282
+ }
283
+
284
+ function formatDuration(durationMs: number): string {
285
+ // `Math.max(0, NaN)` returns NaN, and `Math.floor(Infinity / 1000)` returns
286
+ // Infinity — both would render as `"NaNm NaNs"` / `"Infinitym Infinitys"`.
287
+ // A non-finite duration is a corrupted artifact (status.json is hand-editable),
288
+ // so fall back to zero rather than let it leak into the PR body verbatim.
289
+ if (!Number.isFinite(durationMs)) return "0m 00s";
290
+ const clampedMs = Math.max(0, Math.round(durationMs));
291
+ const totalSeconds = Math.floor(clampedMs / MS_PER_SECOND);
292
+ const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
293
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
294
+ return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
295
+ }
296
+
297
+ function buildStoriesSection(stories: FinishPrStory[]): string {
298
+ const lines: string[] = [];
299
+ lines.push("## Stories");
300
+ lines.push("| Story | Title | ACs |");
301
+ lines.push("|-------|-------|-----|");
302
+ for (const story of stories) {
303
+ lines.push(`| ${escapeTableCell(story.id)} | ${escapeTableCell(story.title)} | ${story.acCount} |`);
304
+ }
305
+ return lines.join("\n");
306
+ }
307
+
308
+ /**
309
+ * Takes the whole context rather than the five fields it reads: the section
310
+ * grew past the three-positional-parameter cap in the coding standards, and
311
+ * every field it wants is already on `FinishPrContext`.
312
+ */
313
+ function buildVerificationSection(ctx: FinishPrContext): string | null {
314
+ const { acceptance, regression, gatesRan, diffstat, artifactSummary } = ctx;
315
+ const lines: string[] = ["## Verification"];
316
+ if (acceptance !== undefined) lines.push(`- Acceptance: ${acceptance}`);
317
+ if (regression !== undefined) lines.push(`- Regression: ${regression}`);
318
+ if (gatesRan.length > 0) lines.push(`- Gates: ${gatesRan.join(", ")}`);
319
+ if (diffstat !== undefined && diffstat.length > 0) lines.push(`- Diffstat:\n\n\`\`\`\n${diffstat}\n\`\`\``);
320
+ // Stated even though the files are excluded above: a reviewer who diffs the
321
+ // branch themselves sees more than the diffstat quotes, and an unexplained
322
+ // mismatch reads as a stale body.
323
+ if (artifactSummary !== undefined && artifactSummary.length > 0) {
324
+ lines.push(`- Excluded from diffstat — nax run artifacts: ${artifactSummary}`);
325
+ }
326
+ if (lines.length === 1) return null;
327
+ return lines.join("\n");
328
+ }
329
+
330
+ function buildRoundHeading(round: FinishRound): string {
331
+ const base = `### ${round.phase} attempt ${round.attempt}`;
332
+ if (!round.committed || !round.sha) return base;
333
+ const short = round.sha.slice(0, SHORT_SHA_LEN);
334
+ return `${base} (${short})`;
335
+ }
336
+
337
+ function buildRoundBlock(round: FinishRound): string {
338
+ const lines: string[] = [buildRoundHeading(round)];
339
+ if (round.findings.length === 0) {
340
+ lines.push("- _no findings_");
341
+ } else {
342
+ for (const finding of round.findings) lines.push(renderFinding(finding));
343
+ }
344
+ return lines.join("\n");
345
+ }
346
+
347
+ function buildRoundsSection(rounds: FinishRound[]): string | null {
348
+ if (rounds.length === 0) return null;
349
+ const blocks = rounds.map(buildRoundBlock);
350
+ return ["## Review rounds", ...blocks].join("\n\n");
351
+ }
352
+
353
+ function renderFinding(finding: Finding): string {
354
+ return `- [${finding.severity}] ${finding.title}`;
355
+ }
356
+
357
+ /**
358
+ * Heading and text are produced together, so "no text" cannot render a bare
359
+ * `## What changed` heading — the empty-heading case #1477 forbids.
360
+ */
361
+ function buildNarrativeSection(narrative: string | undefined): string | null {
362
+ const text = narrative?.trim();
363
+ if (!text) return null;
364
+ return ["## What changed", text].join("\n\n");
365
+ }
366
+
367
+ function buildOutOfScopeSection(outOfScope: string[]): string | null {
368
+ if (outOfScope.length === 0) return null;
369
+ const lines: string[] = ["## Out of scope"];
370
+ for (const item of outOfScope) lines.push(`- ${item}`);
371
+ return lines.join("\n");
372
+ }
373
+
374
+ function buildFooter(run: FinishPrContext["run"]): string | null {
375
+ const { storiesPassed, storiesTotal, durationMs } = run;
376
+ if (storiesPassed === undefined && storiesTotal === undefined && durationMs === undefined) return null;
377
+ const counts =
378
+ storiesPassed !== undefined && storiesTotal !== undefined ? `${storiesPassed}/${storiesTotal} stories` : null;
379
+ const timing = durationMs !== undefined ? formatDuration(durationMs) : null;
380
+ const parts = [counts, timing].filter((p): p is string => p !== null);
381
+ if (parts.length === 0) return null;
382
+ return parts.join(" · ");
383
+ }
384
+
385
+ export function buildFinishBody(ctx: FinishPrContext): string {
386
+ const sections: string[] = [];
387
+
388
+ const narrativeSection = buildNarrativeSection(ctx.narrative);
389
+ if (narrativeSection !== null) sections.push(narrativeSection);
390
+
391
+ if (ctx.stories.length > 0) sections.push(buildStoriesSection(ctx.stories));
392
+
393
+ const verification = buildVerificationSection(ctx);
394
+ if (verification !== null) sections.push(verification);
395
+
396
+ const roundsSection = buildRoundsSection(ctx.rounds);
397
+ if (roundsSection !== null) sections.push(roundsSection);
398
+
399
+ const outOfScopeSection = buildOutOfScopeSection(ctx.outOfScope);
400
+ if (outOfScopeSection !== null) sections.push(outOfScopeSection);
401
+
402
+ const footer = buildFooter(ctx.run);
403
+ if (footer !== null) sections.push(footer);
404
+
405
+ // Appended last and verbatim: `gh` / `glab` suppress the repo's own template
406
+ // whenever `--body` / `--description` is passed, so it has to be re-embedded.
407
+ if (ctx.template !== undefined && ctx.template.trim().length > 0) sections.push(ctx.template.trim());
408
+
409
+ return sections.join("\n\n");
410
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `amend_body` — rewrite the PR body once the narrative node has produced prose.
3
+ *
4
+ * Runs *after* the PR is open and its result file written, which is the whole
5
+ * point: acpx has no error edge, so an acp node placed before `open_pr` could
6
+ * kill the flow and cost the PR. Here the worst case is a body missing one
7
+ * section.
8
+ *
9
+ * Every failure is warned and swallowed for the same reason — a throw would
10
+ * fail a flow whose real work already succeeded.
11
+ */
12
+ import { gateOutputs, inputOf, loadCtxOf, narrativeOf, prTitleOf } from "../flow-ctx";
13
+ import { detectForge } from "./forge";
14
+ import { updatePrBody } from "./pr";
15
+ import { _prBodyDeps, buildFinishBody, buildFinishTitle, loadFinishPrContext } from "./pr-body";
16
+
17
+ export async function amendPrBodyNode(ctx: {
18
+ input: unknown;
19
+ outputs: unknown;
20
+ }): Promise<{ route: "done"; amended: boolean }> {
21
+ const narrative = narrativeOf(ctx);
22
+ const title = prTitleOf(ctx);
23
+ // Nothing to add: the body already in place is correct, and rewriting it
24
+ // identically would spend a forge call to change nothing. A title alone is
25
+ // still worth the call — it is the part a reviewer reads first.
26
+ if (!narrative && !title) return { route: "done", amended: false };
27
+
28
+ const i = inputOf(ctx);
29
+ const loadCtx = loadCtxOf(ctx);
30
+ try {
31
+ const forge = await detectForge(_prBodyDeps.run, i.workdir, "finish-pr");
32
+ const prCtx = await loadFinishPrContext(i, {
33
+ base: loadCtx.base ?? "",
34
+ gatesRan: gateOutputs(ctx).ran ?? [],
35
+ forge,
36
+ specPath: loadCtx.specPath,
37
+ narrative,
38
+ title,
39
+ });
40
+ await updatePrBody(forge, i.workdir, i.branch, buildFinishTitle(prCtx), buildFinishBody(prCtx));
41
+ return { route: "done", amended: true };
42
+ } catch (error) {
43
+ _prBodyDeps.warn("[finish-pr] Failed to amend the PR body with the narrative", { path: i.branch, error });
44
+ return { route: "done", amended: false };
45
+ }
46
+ }