@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.
@@ -31,17 +31,35 @@
31
31
  * the `acceptance` node last passed, and the repo-root `test` command does
32
32
  * not cover per-feature acceptance tests — so without this a fix could break
33
33
  * the contract the first gate proved and still ship.
34
+ * - `commit_gate` re-enters `review_quality` when its fix touched non-test code
35
+ * — the gate loop was previously the one editing loop whose output only ever
36
+ * faced mechanical checks. A test-only fix skips the re-review by explicit
37
+ * cost tradeoff; see `gateCommitRoute` for why that is a known hole.
38
+ * - Every `commit_*` node appends its round to the finish-audit trail as it
39
+ * happens, rather than a terminal node reconstructing them from
40
+ * `ctx.state.steps`. Appending live is what makes the trail survive a flow
41
+ * that is killed or times out — no terminal node runs on that path, and a
42
+ * crashed finish is exactly when the record of what it changed matters most.
34
43
  */
35
- import { defineFlow, extractJsonObject } from "acpx/flows";
44
+ import { defineFlow } from "acpx/flows";
45
+ import { buildFixCommitMessage } from "./commit-message";
46
+ import { findingsOf, fixAttemptCount, gateOutputs, incrementalSince, inputOf, loadCtxOf } from "./flow-ctx";
47
+ import { narrativePrompt, parseNarrative } from "./narrative";
36
48
  import { buildReviewPrompt, fixPrompt } from "./review-prompts";
37
49
  import {
38
50
  _contextDeps,
51
+ amendPrBodyNode,
52
+ appendRound,
39
53
  buildEscalationComment,
40
54
  commitAndPush,
41
55
  commitFixes,
42
56
  detectBaseBranch,
57
+ detectForge,
58
+ filesInCommit,
59
+ loadFinishPrContext,
43
60
  loadQualityCommands,
44
61
  openOrPromotePr,
62
+ partitionTestFiles,
45
63
  postEscalation,
46
64
  preflight,
47
65
  resolveFeature,
@@ -49,34 +67,28 @@ import {
49
67
  runQualityGates,
50
68
  writeResult,
51
69
  } from "./steps";
52
- import type { AcceptanceGroup, FinishInput, FinishResult, ReviewVerdict } from "./types";
53
-
54
- const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
70
+ import type { Forge } from "./steps/forge";
71
+ import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
72
+ import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
73
+ import { MAX_FIX_ATTEMPTS, parseFixVerdict, parseReviewVerdict, repromptCount, routeReview } from "./verdict";
55
74
 
56
75
  /**
57
- * Cap on fix-and-reverify iterations, per phase, before escalating instead of
58
- * looping forever. acpx's flow engine has no built-in cycle guard, so without
59
- * this cap a stubborn failure (LLM can't fix it, or fixes something else each
60
- * 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.
61
79
  */
62
- const MAX_FIX_ATTEMPTS = 3;
63
-
64
- interface LoadCtxOutput {
65
- base?: string;
66
- specPath?: string;
67
- groups?: AcceptanceGroup[];
68
- /** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
69
- acceptanceStatus?: string;
70
- route?: string;
71
- }
72
-
73
- function fixAttemptCount(ctx: { state: { steps: { nodeId: string }[] } }, fixNodeId: string): number {
74
- return (ctx.state.steps ?? []).filter((s) => s.nodeId === fixNodeId).length;
75
- }
80
+ const NARRATIVE_ENABLED = process.env.NAX_FINISH_NARRATIVE !== "0";
76
81
 
77
- function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
78
- return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
79
- }
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
+ };
80
92
 
81
93
  /**
82
94
  * Re-run the acceptance gate, routing on the shared fix-cap rules.
@@ -132,35 +144,34 @@ async function acceptanceGateNode(ctx: {
132
144
  }
133
145
 
134
146
  /**
135
- * Turn a reviewer verdict into a deterministic route.
147
+ * Route for `commit_gate`, whose successor depends on what the fix touched.
136
148
  *
137
- * `clean` (no findings) skips the fix node entirely prompting an agent to
138
- * "apply the recommended fixes" for an empty finding list burns a turn and
139
- * invites unrequested edits.
149
+ * - `unchanged` nothing committed; no new diff, so nothing to review.
150
+ * - `tests-only` every touched path matched the repo's test-file patterns.
151
+ * Skipped by explicit choice: the re-review is the flow's most expensive node
152
+ * and a gate fix is usually a mechanical test repair. **This is a real hole.**
153
+ * The defect that motivated the re-entry (rs-stock `b6fb66dd`) was itself
154
+ * test-only — 8 copy-pasted stubs across 3 test files — so this route would
155
+ * not have caught it. Widen it here if test-quality regressions start
156
+ * shipping.
157
+ * - `changed` — production code was touched, or the paths could not be
158
+ * classified at all. "Cannot classify" reviews rather than skips.
140
159
  */
141
- function routeReview(
142
- ctx: { outputs: unknown; state: { steps: { nodeId: string }[] } },
143
- phase: "spec" | "quality",
144
- ): { route: string; escalationReason?: string; findings: ReviewVerdict["findings"] } {
145
- const verdict = (ctx.outputs as Record<string, ReviewVerdict | undefined>)[`review_${phase}`];
146
- const findings = verdict?.findings ?? [];
147
- if (verdict?.route === "escalate") {
148
- return {
149
- route: "escalate",
150
- escalationReason: verdict.escalationReason ?? `${phase} review raised a finding needing human judgment`,
151
- findings,
152
- };
153
- }
154
- if (findings.length === 0) return { route: "clean", findings };
155
- const attempts = fixAttemptCount(ctx, `fix_${phase}`);
156
- if (attempts >= MAX_FIX_ATTEMPTS) {
157
- return {
158
- route: "escalate",
159
- escalationReason: `${phase} review still reporting ${findings.length} finding(s) after ${attempts} fix attempts.`,
160
- findings,
161
- };
162
- }
163
- return { route: "fix", findings };
160
+ async function gateCommitRoute(
161
+ i: FinishInput,
162
+ committed: boolean,
163
+ shaAfter: string | null,
164
+ testFileRegex: string[],
165
+ ): Promise<string> {
166
+ if (!committed) return "unchanged";
167
+ // Committed, but HEAD did not resolve: the fix is real and unclassifiable, so
168
+ // it must be reviewed. Folding this into the `!committed` branch would skip
169
+ // the review for a change that actually landed — the one direction this
170
+ // function must never fail in.
171
+ if (!shaAfter) return "changed";
172
+ const files = await filesInCommit(i.workdir, shaAfter);
173
+ if (files.length === 0) return "changed";
174
+ return partitionTestFiles(files, testFileRegex).nonTest.length > 0 ? "changed" : "tests-only";
164
175
  }
165
176
 
166
177
  /**
@@ -169,28 +180,57 @@ function routeReview(
169
180
  * One node per phase rather than a single shared one because each returns to a
170
181
  * different successor, and acpx routes on the node id — a shared node would
171
182
  * need a switch reconstructing which fix ran from the step history.
183
+ *
184
+ * Also the audit seam: this is the only point in the graph where a round's
185
+ * findings and its commit are both known. `ctx.outputs` keeps only the latest
186
+ * output per node, so a round not recorded here is a round no terminal node
187
+ * can reconstruct. `shaBefore` is recorded for the same reason — it is what the
188
+ * next review of this phase diffs from (see `incrementalSince`).
172
189
  */
173
- function commitFixNode(phase: "acceptance" | "spec" | "quality" | "gate") {
190
+ function commitFixNode(phase: FinishPhase) {
174
191
  return {
175
192
  nodeType: "action" as const,
176
- async run(ctx: { input: unknown }): Promise<{ committed: boolean }> {
193
+ async run(ctx: {
194
+ input: unknown;
195
+ outputs: unknown;
196
+ state: { steps: { nodeId: string }[] };
197
+ }): Promise<{ committed: boolean; route: string; shaBefore: string | null; shaAfter: string | null }> {
177
198
  const i = inputOf(ctx);
199
+ const messageCtx = { outputs: ctx.outputs as Record<string, unknown> };
178
200
  // skipHooks: an intermediate checkpoint must not be rejected by a repo's
179
201
  // pre-commit hook — quality_gates runs the repo's real gates before any
180
202
  // PR opens, and a hook failure here would kill the flow mid-loop.
181
- return commitFixes(i.workdir, `fix(${i.feature}): nax-finish ${phase} fixes`, { skipHooks: true });
203
+ const { committed, shaBefore, shaAfter } = await commitFixes(
204
+ i.workdir,
205
+ buildFixCommitMessage(phase, i.feature, messageCtx),
206
+ { skipHooks: true },
207
+ );
208
+ await appendRound(i, {
209
+ ts: new Date().toISOString(),
210
+ phase,
211
+ attempt: fixAttemptCount(ctx, `fix_${phase}`),
212
+ committed,
213
+ findings: findingsOf(ctx, phase),
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 } : {}),
220
+ });
221
+ // Only `commit_gate` routes on this; the other phases have unconditional
222
+ // edges and ignore it.
223
+ const route =
224
+ phase === "gate"
225
+ ? await gateCommitRoute(i, committed, shaAfter, loadCtxOf(ctx).testFileRegex ?? [])
226
+ : committed
227
+ ? "changed"
228
+ : "unchanged";
229
+ return { committed, route, shaBefore, shaAfter };
182
230
  },
183
231
  };
184
232
  }
185
233
 
186
- /** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
187
- function parseVerdict(text: string): ReviewVerdict {
188
- const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
189
- const findings = Array.isArray(raw.findings) ? raw.findings : [];
190
- const route = raw.route === "escalate" ? "escalate" : findings.length === 0 ? "clean" : "proceed";
191
- return { route, findings, escalationReason: raw.escalationReason };
192
- }
193
-
194
234
  export default defineFlow({
195
235
  name: "nax-finish",
196
236
  permissions: {
@@ -212,6 +252,7 @@ export default defineFlow({
212
252
  specPath: resolution.specPath,
213
253
  acceptanceStatus: resolution.acceptanceStatus,
214
254
  groups: resolution.groups,
255
+ testFileRegex: resolution.testFileRegex,
215
256
  commitsAhead: pf.commitsAhead,
216
257
  route: pf.route,
217
258
  };
@@ -224,7 +265,7 @@ export default defineFlow({
224
265
  fix_acceptance: {
225
266
  nodeType: "acp",
226
267
  prompt: (ctx) => fixPrompt("acceptance", ctx),
227
- parse: parseVerdict,
268
+ parse: parseFixVerdict,
228
269
  },
229
270
  commit_acceptance: commitFixNode("acceptance"),
230
271
  review_spec: {
@@ -233,9 +274,15 @@ export default defineFlow({
233
274
  profile: process.env.NAX_FINISH_SPEC_PROFILE || undefined,
234
275
  prompt(ctx) {
235
276
  const outs = loadCtxOf(ctx);
236
- return buildReviewPrompt("spec", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
277
+ return buildReviewPrompt("spec", {
278
+ base: outs.base ?? "origin/main",
279
+ specPath: outs.specPath ?? "",
280
+ since: incrementalSince(ctx, "spec"),
281
+ priorFindings: findingsOf(ctx, "spec"),
282
+ retry: repromptCount(ctx, "spec") > 0,
283
+ });
237
284
  },
238
- parse: parseVerdict,
285
+ parse: parseReviewVerdict,
239
286
  },
240
287
  route_spec: {
241
288
  nodeType: "compute",
@@ -244,7 +291,7 @@ export default defineFlow({
244
291
  fix_spec: {
245
292
  nodeType: "acp",
246
293
  prompt: (ctx) => fixPrompt("spec", ctx),
247
- parse: parseVerdict,
294
+ parse: parseFixVerdict,
248
295
  },
249
296
  commit_spec: commitFixNode("spec"),
250
297
  review_quality: {
@@ -253,9 +300,15 @@ export default defineFlow({
253
300
  profile: process.env.NAX_FINISH_QUALITY_PROFILE || undefined,
254
301
  prompt(ctx) {
255
302
  const outs = loadCtxOf(ctx);
256
- return buildReviewPrompt("quality", { base: outs.base ?? "origin/main", specPath: outs.specPath ?? "" });
303
+ return buildReviewPrompt("quality", {
304
+ base: outs.base ?? "origin/main",
305
+ specPath: outs.specPath ?? "",
306
+ since: incrementalSince(ctx, "quality"),
307
+ priorFindings: findingsOf(ctx, "quality"),
308
+ retry: repromptCount(ctx, "quality") > 0,
309
+ });
257
310
  },
258
- parse: parseVerdict,
311
+ parse: parseReviewVerdict,
259
312
  },
260
313
  route_quality: {
261
314
  nodeType: "compute",
@@ -264,13 +317,13 @@ export default defineFlow({
264
317
  fix_quality: {
265
318
  nodeType: "acp",
266
319
  prompt: (ctx) => fixPrompt("quality", ctx),
267
- parse: parseVerdict,
320
+ parse: parseFixVerdict,
268
321
  },
269
322
  commit_quality: commitFixNode("quality"),
270
323
  fix_gate: {
271
324
  nodeType: "acp",
272
325
  prompt: (ctx) => fixPrompt("gate", ctx),
273
- parse: parseVerdict,
326
+ parse: parseFixVerdict,
274
327
  },
275
328
  commit_gate: commitFixNode("gate"),
276
329
  quality_gates: {
@@ -349,23 +402,65 @@ export default defineFlow({
349
402
  nodeType: "action",
350
403
  async run(ctx) {
351
404
  const i = inputOf(ctx);
352
- if (loadCtxOf(ctx).route === "nothing-to-finish") {
353
- await writeResult(i.workdir, { feature: i.feature, status: "nothing-to-finish" });
405
+ const loadCtx = loadCtxOf(ctx);
406
+ if (loadCtx.route === "nothing-to-finish") {
407
+ await writeResult(i, { feature: i.feature, status: "nothing-to-finish" });
354
408
  return { route: "done", status: "nothing-to-finish" };
355
409
  }
356
410
  // Every fix node edited the working tree; without this the PR would be
357
411
  // opened from a remote branch missing all of them.
358
412
  const sync = await commitAndPush(i.workdir, i.branch, `fix(${i.feature}): nax-finish automated fixes`);
359
- const r = await openOrPromotePr(
360
- i.workdir,
361
- i.branch,
362
- `nax-finish: ${i.feature}`,
363
- `Automated finish of \`${i.feature}\`.`,
364
- );
365
- await writeResult(i.workdir, { feature: i.feature, status: r.status, url: r.url });
366
- return { route: "done", committed: sync.committed, ...r };
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);
440
+ await writeResult(i, { feature: i.feature, status: r.status, url: r.url });
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 };
367
445
  },
368
446
  },
447
+ narrative: {
448
+ nodeType: "acp",
449
+ session: { isolated: true },
450
+ profile: process.env.NAX_FINISH_NARRATIVE_PROFILE || undefined,
451
+ prompt: narrativePrompt,
452
+ parse: parseNarrative,
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
+ },
369
464
  escalate: {
370
465
  nodeType: "action",
371
466
  async run(ctx) {
@@ -409,7 +504,7 @@ export default defineFlow({
409
504
  escalationReason: reason,
410
505
  findings: verdict?.findings ?? [],
411
506
  };
412
- await writeResult(i.workdir, result);
507
+ await writeResult(i, result);
413
508
 
414
509
  const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
415
510
  let url: string | undefined;
@@ -424,7 +519,7 @@ export default defineFlow({
424
519
  } catch (err) {
425
520
  deliveryError = String(err);
426
521
  }
427
- await writeResult(i.workdir, { ...result, url, deliveryError });
522
+ await writeResult(i, { ...result, url, deliveryError });
428
523
 
429
524
  return { route: "done", url, channel, deliveryError, escalationReason: reason };
430
525
  },
@@ -444,7 +539,10 @@ export default defineFlow({
444
539
  { from: "review_spec", to: "route_spec" },
445
540
  {
446
541
  from: "route_spec",
447
- switch: { on: "$.route", cases: { clean: "review_quality", fix: "fix_spec", escalate: "escalate" } },
542
+ switch: {
543
+ on: "$.route",
544
+ cases: { clean: "review_quality", fix: "fix_spec", escalate: "escalate", reprompt: "review_spec" },
545
+ },
448
546
  },
449
547
  // Spec fixes re-run the acceptance gate first (they can break it), and the
450
548
  // acceptance node's `proceed` edge leads back into review_spec for re-review.
@@ -453,7 +551,10 @@ export default defineFlow({
453
551
  { from: "review_quality", to: "route_quality" },
454
552
  {
455
553
  from: "route_quality",
456
- switch: { on: "$.route", cases: { clean: "quality_gates", fix: "fix_quality", escalate: "escalate" } },
554
+ switch: {
555
+ on: "$.route",
556
+ cases: { clean: "quality_gates", fix: "fix_quality", escalate: "escalate", reprompt: "review_quality" },
557
+ },
457
558
  },
458
559
  // Quality fixes are re-reviewed by the same lens; the repo-root gates that
459
560
  // follow catch anything the fix broke mechanically.
@@ -464,6 +565,28 @@ export default defineFlow({
464
565
  switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
465
566
  },
466
567
  { from: "fix_gate", to: "commit_gate" },
467
- { from: "commit_gate", to: "quality_gates" },
568
+ // A gate fix that changed code goes back through the quality reviewer, not
569
+ // straight to the gates. The gate loop is the last one to edit the tree and
570
+ // was the only one whose edits nothing reviewed: `quality_gates` proves the
571
+ // repo's commands are green, which a bad fix can satisfy. Observed on
572
+ // rs-stock/pipeline-run-outcome — the gate round repaired 8 tests by
573
+ // copy-pasting an identical 4-line stub into each, and it shipped, because
574
+ // no reviewer ran after it. Re-entry costs one review per gate round; both
575
+ // loops stay bounded by their own MAX_FIX_ATTEMPTS caps.
576
+ //
577
+ // `unchanged` skips it: with nothing committed there is no new diff to
578
+ // review, and re-running the reviewer on an identical tree would burn a
579
+ // turn to re-report what route_quality already called clean.
580
+ {
581
+ from: "commit_gate",
582
+ switch: {
583
+ on: "$.route",
584
+ cases: { changed: "review_quality", "tests-only": "quality_gates", unchanged: "quality_gates" },
585
+ },
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" },
468
591
  ],
469
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
+ }
@@ -1,3 +1,5 @@
1
+ import type { Finding } from "./types";
2
+
1
3
  export const SPEC_REVIEW_DIMENSIONS = `# Spec-relative review dimensions
2
4
 
3
5
  Reference for the post-impl-review **spec-relative** pass: Compliance, Drift,
@@ -310,12 +312,68 @@ const JSON_CONTRACT = [
310
312
  "}",
311
313
  ].join("\n");
312
314
 
313
- export function buildReviewPrompt(phase: "spec" | "quality", args: { base: string; specPath: string }): string {
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
+
326
+ /**
327
+ * Build the reviewer prompt.
328
+ *
329
+ * With `since` set this is a **re-review**: the same reviewer already read the
330
+ * whole branch and raised `priorFindings`, a fix was applied and committed, and
331
+ * the only new material is `since..HEAD`. Re-reading the full branch diff every
332
+ * round made reviews 58% of the flow's wall clock, most of it re-reading code an
333
+ * earlier round had already cleared. The narrowed round still has the full repo
334
+ * available — it is told to open whatever the fix touches — it just is not asked
335
+ * to re-derive a verdict on unchanged code.
336
+ *
337
+ * `since` is only ever supplied when exactly one commit separates the two
338
+ * reviews (see `incrementalSince`), so `since..HEAD` provably contains every
339
+ * change made since the previous verdict.
340
+ */
341
+ export function buildReviewPrompt(
342
+ phase: "spec" | "quality",
343
+ args: {
344
+ base: string;
345
+ specPath: string;
346
+ since?: string | null;
347
+ priorFindings?: Finding[];
348
+ retry?: boolean;
349
+ },
350
+ ): string {
314
351
  const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
352
+ const lead = args.retry ? [RETRY_NOTICE] : [];
353
+ if (!args.since) {
354
+ return [
355
+ ...lead,
356
+ `You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
357
+ `The spec/requirements source is: ${args.specPath}. Read it in full.`,
358
+ `Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
359
+ WORKER_PROTOCOL,
360
+ dims,
361
+ CLASSIFIER,
362
+ JSON_CONTRACT,
363
+ ].join("\n\n");
364
+ }
315
365
  return [
316
- `You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
317
- `The spec/requirements source is: ${args.specPath}. Read it in full.`,
318
- `Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
366
+ ...lead,
367
+ `You are the ${phase.toUpperCase()} reviewer for a completed feature, continuing a review you already started.`,
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.`,
369
+ `Your findings from the previous pass:\n${JSON.stringify(args.priorFindings ?? [], null, 2)}`,
370
+ `The fix is \`git diff ${args.since}..HEAD\` — this is the only code that has changed since your last verdict. Review it, and only it, for two questions:`,
371
+ [
372
+ "1. **Resolved?** Does the fix actually resolve each finding above? A finding that was papered over (assertion weakened, test deleted, check disabled) is NOT resolved — re-raise it.",
373
+ "2. **Broken?** Did the fix introduce a new problem, in the changed lines or in the unchanged code they now call into?",
374
+ "",
375
+ `Read whatever files you need — the spec is at ${args.specPath} and the whole repo is available. Scope means *what you judge*, not *what you may read*.`,
376
+ ].join("\n"),
319
377
  WORKER_PROTOCOL,
320
378
  dims,
321
379
  CLASSIFIER,
@@ -17,6 +17,13 @@ export interface FeatureResolution {
17
17
  specKind: "markdown" | "prd";
18
18
  acceptanceStatus: string;
19
19
  groups: AcceptanceGroup[];
20
+ /**
21
+ * Test-file classification regexes, as sources, from `nax features resolve`
22
+ * (the ADR-009 SSOT). Empty when the CLI is older than the field or could not
23
+ * resolve them — callers must treat empty as "cannot classify", never as
24
+ * "nothing is a test file".
25
+ */
26
+ testFileRegex: string[];
20
27
  }
21
28
 
22
29
  /**
@@ -30,6 +37,7 @@ export async function resolveFeature(feature: string, workdir: string): Promise<
30
37
  let parsed: {
31
38
  specSource?: { kind: "markdown" | "prd"; path: string };
32
39
  acceptance?: { status?: string; groups?: AcceptanceGroup[] };
40
+ testPatterns?: { regex?: string[] };
33
41
  };
34
42
  try {
35
43
  parsed = JSON.parse(res.stdout);
@@ -51,9 +59,39 @@ export async function resolveFeature(feature: string, workdir: string): Promise<
51
59
  specKind: parsed.specSource.kind,
52
60
  acceptanceStatus: parsed.acceptance?.status ?? "no-prd",
53
61
  groups: parsed.acceptance?.groups ?? [],
62
+ testFileRegex: parsed.testPatterns?.regex ?? [],
54
63
  };
55
64
  }
56
65
 
66
+ /**
67
+ * Split paths into test and non-test, using the regexes `nax features resolve`
68
+ * reported.
69
+ *
70
+ * With no patterns (older nax, or a config the resolver choked on) every path is
71
+ * reported as non-test. That is the safe direction for the one caller: the gate
72
+ * loop skips its re-review only for a test-only change, so "cannot classify"
73
+ * must mean "review it", never "skip it".
74
+ *
75
+ * An unparseable regex source is skipped rather than thrown — a bad pattern in
76
+ * one config entry must not take the flow down mid-loop.
77
+ */
78
+ export function partitionTestFiles(paths: string[], regexSources: string[]): { test: string[]; nonTest: string[] } {
79
+ const matchers: RegExp[] = [];
80
+ for (const src of regexSources) {
81
+ try {
82
+ matchers.push(new RegExp(src));
83
+ } catch {
84
+ // Skip — see the doc comment above.
85
+ }
86
+ }
87
+ const test: string[] = [];
88
+ const nonTest: string[] = [];
89
+ for (const p of paths) {
90
+ (matchers.some((re) => re.test(p)) ? test : nonTest).push(p);
91
+ }
92
+ return { test, nonTest };
93
+ }
94
+
57
95
  export async function preflight(
58
96
  workdir: string,
59
97
  base: string,