@nathapp/nax 0.77.2 → 0.78.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.
@@ -23,6 +23,7 @@ import { dirname, isAbsolute, join } from "node:path";
23
23
  import { runArgv } from "../exec";
24
24
  import { readSpecSummary, resolveNarrative } from "../narrative";
25
25
  import { findPrTemplate } from "../pr-template";
26
+ import { type BodySection, type TemplateMode, mergeTemplate } from "../pr-template-merge";
26
27
  import { resolveTitle } from "../pr-title";
27
28
  import type { Finding, FinishInput, FinishRound, RunFn } from "../types";
28
29
  import type { Forge } from "./forge";
@@ -57,6 +58,10 @@ export interface FinishPrContext {
57
58
  artifactSummary?: string;
58
59
  /** Repository PR/MR template, verbatim. Absent when none resolves. */
59
60
  template?: string;
61
+ /** How `template` is honoured. Absent → `merge`. See `pr-template-merge.ts`. */
62
+ templateMode?: TemplateMode;
63
+ /** Repo overrides for the template-heading → section-key table. */
64
+ templateSectionMap?: Record<string, string>;
60
65
  /** Resolved "What changed" prose. Absent when neither source produced text. */
61
66
  narrative?: string;
62
67
  /**
@@ -244,6 +249,8 @@ export async function loadFinishPrContext(
244
249
  diffstat: stat.diffstat,
245
250
  artifactSummary: stat.artifactSummary,
246
251
  template,
252
+ ...(input.prBody?.template !== undefined ? { templateMode: input.prBody.template } : {}),
253
+ ...(input.prBody?.sectionMap !== undefined ? { templateSectionMap: input.prBody.sectionMap } : {}),
247
254
  narrative: resolveNarrative(args.narrative, specSummary),
248
255
  title: resolveTitle(args.title, input.feature),
249
256
  run: {
@@ -296,7 +303,6 @@ function formatDuration(durationMs: number): string {
296
303
 
297
304
  function buildStoriesSection(stories: FinishPrStory[]): string {
298
305
  const lines: string[] = [];
299
- lines.push("## Stories");
300
306
  lines.push("| Story | Title | ACs |");
301
307
  lines.push("|-------|-------|-----|");
302
308
  for (const story of stories) {
@@ -312,7 +318,7 @@ function buildStoriesSection(stories: FinishPrStory[]): string {
312
318
  */
313
319
  function buildVerificationSection(ctx: FinishPrContext): string | null {
314
320
  const { acceptance, regression, gatesRan, diffstat, artifactSummary } = ctx;
315
- const lines: string[] = ["## Verification"];
321
+ const lines: string[] = [];
316
322
  if (acceptance !== undefined) lines.push(`- Acceptance: ${acceptance}`);
317
323
  if (regression !== undefined) lines.push(`- Regression: ${regression}`);
318
324
  if (gatesRan.length > 0) lines.push(`- Gates: ${gatesRan.join(", ")}`);
@@ -323,7 +329,7 @@ function buildVerificationSection(ctx: FinishPrContext): string | null {
323
329
  if (artifactSummary !== undefined && artifactSummary.length > 0) {
324
330
  lines.push(`- Excluded from diffstat — nax run artifacts: ${artifactSummary}`);
325
331
  }
326
- if (lines.length === 1) return null;
332
+ if (lines.length === 0) return null;
327
333
  return lines.join("\n");
328
334
  }
329
335
 
@@ -334,10 +340,31 @@ function buildRoundHeading(round: FinishRound): string {
334
340
  return `${base} (${short})`;
335
341
  }
336
342
 
343
+ /**
344
+ * What an empty finding list means, in the reader's terms.
345
+ *
346
+ * Four different things used to render identically as "_no findings_", which a
347
+ * human reads as "a reviewer looked at this and approved it" (#1507). Only
348
+ * `passed` means that. `no-reviewer` is the one that actively misleads: the
349
+ * gate phase has no reviewer node at all, so its empty list is the absence of a
350
+ * review, not the result of one.
351
+ *
352
+ * `fixed` and the legacy `undefined` both fall through to "_no findings_":
353
+ * rounds written before `outcome` existed carry no field to read, and claiming
354
+ * anything more specific about them would be inventing detail.
355
+ */
356
+ const EMPTY_ROUND_NOTE: Record<string, string> = {
357
+ passed: "- _no findings_",
358
+ "no-reviewer": "- _no reviewer for this phase_",
359
+ unparseable: "- _reviewer output could not be parsed_",
360
+ escalated: "- _escalated for human review_",
361
+ "review-skipped": "- _re-review skipped: this fix touched test files only_",
362
+ };
363
+
337
364
  function buildRoundBlock(round: FinishRound): string {
338
365
  const lines: string[] = [buildRoundHeading(round)];
339
366
  if (round.findings.length === 0) {
340
- lines.push("- _no findings_");
367
+ lines.push(EMPTY_ROUND_NOTE[round.outcome ?? ""] ?? "- _no findings_");
341
368
  } else {
342
369
  for (const finding of round.findings) lines.push(renderFinding(finding));
343
370
  }
@@ -346,8 +373,7 @@ function buildRoundBlock(round: FinishRound): string {
346
373
 
347
374
  function buildRoundsSection(rounds: FinishRound[]): string | null {
348
375
  if (rounds.length === 0) return null;
349
- const blocks = rounds.map(buildRoundBlock);
350
- return ["## Review rounds", ...blocks].join("\n\n");
376
+ return rounds.map(buildRoundBlock).join("\n\n");
351
377
  }
352
378
 
353
379
  function renderFinding(finding: Finding): string {
@@ -355,20 +381,17 @@ function renderFinding(finding: Finding): string {
355
381
  }
356
382
 
357
383
  /**
358
- * Heading and text are produced together, so "no text" cannot render a bare
359
- * `## What changed` heading the empty-heading case #1477 forbids.
384
+ * Body only the heading is attached by `buildFinishBody`, which drops any
385
+ * section whose body is null. "No text" therefore cannot render a bare
386
+ * `## What changed` heading, the empty-heading case #1477 forbids.
360
387
  */
361
388
  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");
389
+ return narrative?.trim() || null;
365
390
  }
366
391
 
367
392
  function buildOutOfScopeSection(outOfScope: string[]): string | null {
368
393
  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");
394
+ return outOfScope.map((item) => `- ${item}`).join("\n");
372
395
  }
373
396
 
374
397
  function buildFooter(run: FinishPrContext["run"]): string | null {
@@ -382,29 +405,38 @@ function buildFooter(run: FinishPrContext["run"]): string | null {
382
405
  return parts.join(" · ");
383
406
  }
384
407
 
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
+ * The nax-authored sections, in canonical order — the order they appear in
410
+ * when the repo has no template, and the order the leftovers are appended in
411
+ * when it has one. `key` is what `mergeTemplate` matches against the repo's
412
+ * headings; the run footer carries an empty heading so it stays unmatchable
413
+ * and last.
414
+ */
415
+ function buildBodySections(ctx: FinishPrContext): BodySection[] {
416
+ const candidates: { key: string; heading: string; body: string | null }[] = [
417
+ { key: "narrative", heading: "What changed", body: buildNarrativeSection(ctx.narrative) },
418
+ { key: "stories", heading: "Stories", body: ctx.stories.length > 0 ? buildStoriesSection(ctx.stories) : null },
419
+ { key: "verification", heading: "Verification", body: buildVerificationSection(ctx) },
420
+ { key: "rounds", heading: "Review rounds", body: buildRoundsSection(ctx.rounds) },
421
+ { key: "outOfScope", heading: "Out of scope", body: buildOutOfScopeSection(ctx.outOfScope) },
422
+ { key: "footer", heading: "", body: buildFooter(ctx.run) },
423
+ ];
424
+ return candidates
425
+ .filter((c): c is { key: string; heading: string; body: string } => c.body !== null)
426
+ .map(({ key, heading, body }) => ({ key, heading, body }));
427
+ }
408
428
 
409
- return sections.join("\n\n");
429
+ /**
430
+ * Assemble the body, merged into the repo's own PR template when it has one.
431
+ *
432
+ * The template supplies the *shape* (which headings, in what order) and these
433
+ * sections supply the *content* — see `pr-template-merge.ts` for why appending
434
+ * it verbatim, which is what this used to do, shipped a blank form under a
435
+ * filled one (nax#1504).
436
+ */
437
+ export function buildFinishBody(ctx: FinishPrContext): string {
438
+ return mergeTemplate(ctx.template, buildBodySections(ctx), {
439
+ mode: ctx.templateMode,
440
+ sectionMap: ctx.templateSectionMap,
441
+ });
410
442
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Recording the review rounds that produce no commit.
3
+ *
4
+ * `commit_<phase>` is the audit seam for rounds that *fix* something — it is the
5
+ * only point where a round's findings and its resulting commit are both known.
6
+ * But that made a commit the sole evidence a reviewer ever ran: a review that
7
+ * passed produced no `fix_*`, therefore no `commit_*`, therefore no round, and
8
+ * "this phase passed" became indistinguishable from "this phase never ran"
9
+ * (#1507). Worse, it made the owed re-review in #1506 unprovable after the fact.
10
+ *
11
+ * So the two seams split by what they know:
12
+ * - `commit_<phase>` records rounds that changed the tree (`outcome: "fixed"`).
13
+ * - here records rounds that did not (`passed` / `unparseable` / `escalated`).
14
+ *
15
+ * Wrapping `routeReview` rather than living inside it keeps that function pure
16
+ * and synchronous — it is the flow's routing SSOT and is exercised by a large
17
+ * table of unit tests that would all have to become async otherwise.
18
+ */
19
+ import { inputOf } from "../flow-ctx";
20
+ import type { OutputsCtx, StepsCtx } from "../flow-ctx";
21
+ import type { Finding, FinishRoundOutcome } from "../types";
22
+ import { routeReview } from "../verdict";
23
+ import { appendRound } from "./result";
24
+
25
+ /** Route → what to call the round. `fix` is absent by construction — see below. */
26
+ const OUTCOME_BY_ROUTE: Record<string, FinishRoundOutcome> = {
27
+ clean: "passed",
28
+ reprompt: "unparseable",
29
+ escalate: "escalated",
30
+ };
31
+
32
+ /**
33
+ * The Nth time this phase's *review* node has run.
34
+ *
35
+ * Deliberately not `fixAttemptCount`: that counts `fix_<phase>` steps, which is
36
+ * the right number for a round that fixed something and the wrong one here — a
37
+ * review that passes on the first look runs zero fix nodes, and every clean
38
+ * round would be numbered 0. Self-inclusive, for the same reason `repromptCount`
39
+ * is: acpx records the `review_<phase>` step before `route_<phase>` executes.
40
+ */
41
+ function reviewAttemptCount(ctx: StepsCtx, phase: "spec" | "quality"): number {
42
+ return (ctx.state.steps ?? []).filter((s) => s.nodeId === `review_${phase}`).length;
43
+ }
44
+
45
+ /**
46
+ * Route this phase's review verdict, and record the round when it produced no
47
+ * commit.
48
+ *
49
+ * `fix` is the one route that records nothing here: it leads to `fix_<phase>` →
50
+ * `commit_<phase>`, which appends the round with the commit attached. Recording
51
+ * at both seams would double-count every fixed round in the PR body.
52
+ *
53
+ * Best-effort, exactly like `appendRound` itself: the route is returned whether
54
+ * or not the write lands. Losing the record is bad; failing the run that has
55
+ * already done the work is worse.
56
+ */
57
+ export async function routeReviewAndRecord(
58
+ ctx: { input: unknown } & OutputsCtx & StepsCtx,
59
+ phase: "spec" | "quality",
60
+ ): Promise<{ route: string; escalationReason?: string; findings: Finding[] }> {
61
+ const routed = routeReview(ctx, phase);
62
+ const outcome = OUTCOME_BY_ROUTE[routed.route];
63
+ if (outcome) {
64
+ await appendRound(inputOf(ctx), {
65
+ ts: new Date().toISOString(),
66
+ phase,
67
+ attempt: reviewAttemptCount(ctx, phase),
68
+ committed: false,
69
+ outcome,
70
+ findings: routed.findings,
71
+ });
72
+ }
73
+ return routed;
74
+ }
@@ -1,3 +1,5 @@
1
+ import type { TemplateMode } from "./pr-template-merge";
2
+
1
3
  /** One acceptance-test group as reported by `nax features resolve --json`. */
2
4
  export interface AcceptanceGroup {
3
5
  packageDir: string;
@@ -50,6 +52,47 @@ export type FinishPhase = "acceptance" | "spec" | "quality" | "gate";
50
52
  * finish that died mid-loop is exactly when the record of what it already
51
53
  * changed on the branch matters most.
52
54
  */
55
+ /**
56
+ * What produced a round — the difference between "a reviewer read this and
57
+ * approved it" and "nothing read this".
58
+ *
59
+ * Rounds used to be appended only where a fix produced a commit, so a review
60
+ * that passed left no record at all and was indistinguishable from a review
61
+ * that never ran (#1507). Every phase that executes now records a round, and
62
+ * this field says which of the five things happened.
63
+ *
64
+ * Optional because rounds recorded by earlier versions have no `outcome`, and
65
+ * the PR body still has to render those without claiming more than it knows.
66
+ */
67
+ export type FinishRoundOutcome =
68
+ /** A reviewer reported findings and this phase's fix node ran. */
69
+ | "fixed"
70
+ /** A reviewer ran and reported nothing. The only value that means "approved". */
71
+ | "passed"
72
+ /** The reviewer replied, but no verdict could be read out of it. */
73
+ | "unparseable"
74
+ /** Handed off to a human — an explicit escalate, a cap, or a node that emitted nothing. */
75
+ | "escalated"
76
+ /**
77
+ * This phase has no reviewer at all (`gate`, `acceptance`). Distinct from
78
+ * `passed`: an empty finding list here means "nobody looked", and rendering it
79
+ * as "no findings" manufactures evidence of a review that does not exist.
80
+ */
81
+ | "no-reviewer"
82
+ /**
83
+ * A re-review was owed and deliberately skipped — today only a `gate` fix
84
+ * that touched test files exclusively (`gateCommitRoute` → `tests-only`).
85
+ *
86
+ * Distinct from `no-reviewer`, which the same node writes when the fix *is*
87
+ * routed on to `review_quality`. Without the distinction both wrote
88
+ * `no-reviewer`, so the audit could not tell a gate fix that was re-reviewed
89
+ * from one whose re-review was skipped by policy — the #1507 failure mode
90
+ * surviving on the one path where the omission is intentional, and the path
91
+ * where a reader most needs to know. Recording it is also what makes "how
92
+ * often does this fire?" answerable before anyone decides to close the hole.
93
+ */
94
+ | "review-skipped";
95
+
53
96
  export interface FinishRound {
54
97
  ts: string;
55
98
  phase: FinishPhase;
@@ -57,6 +100,8 @@ export interface FinishRound {
57
100
  attempt: number;
58
101
  /** True when the fix produced a commit; false when it changed nothing. */
59
102
  committed: boolean;
103
+ /** What produced this round; absent on rounds written before it existed. */
104
+ outcome?: FinishRoundOutcome;
60
105
  /** Reviewer findings this round set out to fix (spec/quality phases). */
61
106
  findings: Finding[];
62
107
  /** Gate commands that were red this round (gate phase). */
@@ -92,6 +137,19 @@ export interface FinishInput {
92
137
  */
93
138
  escalateTelegram: boolean;
94
139
  timeouts?: FinishTimeouts;
140
+ /** PR/MR body composition, forwarded from `finish.autoFlow.prBody`. */
141
+ prBody?: FinishPrBodySettings;
142
+ }
143
+
144
+ /**
145
+ * How the repo's own PR/MR template is honoured when composing the body.
146
+ * Absent (and absent fields) mean the defaults in `pr-template-merge.ts`.
147
+ */
148
+ export interface FinishPrBodySettings {
149
+ /** `merge` (default) · `strict` (keep unfillable headings, empty) · `ignore`. */
150
+ template?: TemplateMode;
151
+ /** Normalised template heading → body-section key, layered over the defaults. */
152
+ sectionMap?: Record<string, string>;
95
153
  }
96
154
  export interface FinishResult {
97
155
  feature: string;
@@ -118,14 +118,31 @@ export function repromptCount(ctx: StepsCtx, phase: "spec" | "quality"): number
118
118
  * findings, so checking `findings.length === 0` ahead of it would route an
119
119
  * unreadable review to `clean`, and the flow would open a PR having reviewed
120
120
  * nothing. That silent false green is worse than the crash this replaces.
121
+ *
122
+ * An **absent** verdict escalates for the same reason, and it is a distinct
123
+ * case from an unparseable one: `parseReviewVerdict` never returns undefined,
124
+ * so a missing entry means the node produced no output at all — it never ran,
125
+ * or it died before emitting. Neither is an approval. This must not fall
126
+ * through to `findings ?? []`, because `ctx.outputs` holds only each node's
127
+ * latest output: on a loop re-entry the previous round's clean verdict can
128
+ * still be sitting there, and routing on it re-approves a diff nobody read.
129
+ * There is no reprompt path here — a node that emitted nothing has no raw tail
130
+ * to quote back, so a human is the only remaining reader.
121
131
  */
122
132
  export function routeReview(
123
133
  ctx: OutputsCtx & StepsCtx,
124
134
  phase: "spec" | "quality",
125
135
  ): { route: string; escalationReason?: string; findings: Finding[] } {
126
136
  const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
127
- const findings = verdict?.findings ?? [];
128
- if (verdict?.route === "reprompt") {
137
+ if (!verdict) {
138
+ return {
139
+ route: "escalate",
140
+ escalationReason: `${phase} reviewer produced no verdict — the node emitted no output, so nothing reviewed this diff.`,
141
+ findings: [],
142
+ };
143
+ }
144
+ const findings = verdict.findings ?? [];
145
+ if (verdict.route === "reprompt") {
129
146
  // `attempts` is self-inclusive (see repromptCount) — it already counts this
130
147
  // round's failure, so `<=` (not `<`) is what makes MAX_REPROMPT_ATTEMPTS=1
131
148
  // tolerate exactly one retry before escalating.
@@ -139,7 +156,7 @@ export function routeReview(
139
156
  findings,
140
157
  };
141
158
  }
142
- if (verdict?.route === "escalate") {
159
+ if (verdict.route === "escalate") {
143
160
  return {
144
161
  route: "escalate",
145
162
  escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.77.2",
3
+ "version": "0.78.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,13 +39,17 @@
39
39
  "test:e2e": "timeout -k 5s 180s bun test test/e2e/ --timeout=60000",
40
40
  "test:coverage": "bun run scripts/check-coverage.ts",
41
41
  "test:coverage:report": "bun run scripts/check-coverage.ts --report",
42
- "check-test-overlap": "bun run scripts/check-test-overlap.ts",
43
- "check-dead-tests": "bun run scripts/check-dead-tests.ts",
44
- "check:test-sizes": "bun run scripts/check-test-sizes.ts",
42
+ "report:test-overlap": "bun run scripts/report-test-overlap.ts",
43
+ "report:dead-tests": "bun run scripts/report-dead-tests.ts",
45
44
  "check:test-mocks": "bun scripts/check-inline-test-mocks.ts --strict",
46
45
  "check:process-cwd": "bash scripts/check-process-cwd.sh",
47
46
  "check:no-adapter-wrap": "bash scripts/check-no-adapter-wrap.sh",
48
47
  "check:dispatch-context": "bash scripts/check-dispatch-context.sh",
48
+ "check:naxconfig-cast": "bash scripts/check-no-silent-naxconfig-cast.sh",
49
+ "check:runtime-cleanup": "bash scripts/check-runtime-cleanup.sh",
50
+ "check:adapter-no-config-import": "bash scripts/check-adapter-no-config-import.sh",
51
+ "check:gate-reachability": "bun run scripts/check-gate-reachability.ts",
52
+ "check:all": "bun run lint && bun run check:test-mocks && bun run check:process-cwd && bun run check:no-adapter-wrap && bun run check:dispatch-context && bun run check:naxconfig-cast && bun run check:runtime-cleanup && bun run check:adapter-no-config-import && bun run check:gate-reachability",
49
53
  "prepublishOnly": "bun run build",
50
54
  "test:full": "FULL=1 NAX_PRECHECK=1 bun test test/ --timeout=60000"
51
55
  },
@@ -64,7 +68,7 @@
64
68
  "@biomejs/biome": "^1.9.4",
65
69
  "@types/bun": "^1.3.8",
66
70
  "react-devtools-core": "^7.0.1",
67
- "typescript": "^5.7.3"
71
+ "typescript": "^7.0.2"
68
72
  },
69
73
  "license": "MIT",
70
74
  "author": "William Khoo",