@nathapp/nax 0.80.0 → 0.80.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.
@@ -12,7 +12,7 @@
12
12
  * Subject lines follow the repo's conventional-commit rule and the 72-column
13
13
  * git summary convention; the findings go in the body, one bullet each.
14
14
  */
15
- import type { Finding, FinishPhase } from "./types";
15
+ import type { Finding, FindingDisposition, FinishPhase } from "./types";
16
16
 
17
17
  /** Git's conventional soft cap for a commit summary line. */
18
18
  const MAX_SUBJECT_LEN = 72;
@@ -89,6 +89,14 @@ interface MessageCtx {
89
89
  interface MessageOptions {
90
90
  /** Absolute repo root, used to rewrite quoted paths as repo-relative. */
91
91
  workdir?: string;
92
+ /**
93
+ * What `fix_<phase>` did with each finding it was handed, by 1-based index.
94
+ *
95
+ * Only `commit_<phase>` callers have this — `gate`/`acceptance` commits, and
96
+ * any caller that has not run a fix node yet, pass nothing, and every finding
97
+ * renders as before (`Fix: <text>`).
98
+ */
99
+ dispositions?: FindingDisposition[];
92
100
  }
93
101
 
94
102
  interface PhaseOutputs {
@@ -179,15 +187,31 @@ function bodyFor(phase: FinishPhase, ctx: MessageCtx, opts: MessageOptions): str
179
187
  }
180
188
  const findings = findingsFor(ctx, phase);
181
189
  if (findings.length === 0) return [];
182
- return [
183
- findings
184
- .map((f) =>
185
- [`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
186
- .filter(Boolean)
187
- .join("\n"),
188
- )
189
- .join("\n"),
190
- ];
190
+ const dispositions = opts.dispositions ?? [];
191
+ return [findings.map((f, i) => findingBody(f, i, dispositions)).join("\n")];
192
+ }
193
+
194
+ /**
195
+ * Render one finding's line(s) in the commit body, honouring `fix_<phase>`'s
196
+ * disposition when one exists.
197
+ *
198
+ * A rejected finding must not read `Fix: <the fix text>` — nothing was applied
199
+ * — so it renders the same way `pr-body.ts`'s `renderRejected` does: as
200
+ * rejected, with its evidence citation, so shipped git history and the PR body
201
+ * agree on what happened to the finding.
202
+ */
203
+ function findingBody(f: Finding, index: number, dispositions: FindingDisposition[]): string {
204
+ const d = dispositions.find((x) => x.index === index + 1);
205
+ if (d?.disposition === "rejected") {
206
+ const evidence = d.evidence ? `\`${d.evidence}\`` : "no evidence cited";
207
+ const caveat = d.evidenceMissing ? " (evidence path not found)" : "";
208
+ return [`- [${f.severity}] ${f.title} — rejected: ${evidence}${caveat}`, f.problem ? ` ${f.problem}` : ""]
209
+ .filter(Boolean)
210
+ .join("\n");
211
+ }
212
+ return [`- [${f.severity}] ${f.title}`, f.problem ? ` ${f.problem}` : "", f.fix ? ` Fix: ${f.fix}` : ""]
213
+ .filter(Boolean)
214
+ .join("\n");
191
215
  }
192
216
 
193
217
  /** Human-readable phase label for the attribution trailer. */
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Turning a reviewer's free-form reply into structured findings.
3
+ *
4
+ * The reviewer's contract is text, not JSON, for two reasons. The dimension
5
+ * references both hinge on an enumeration — a per-AC walk and a per-changed-
6
+ * function walk — and a reply constrained to one JSON object has nowhere to put
7
+ * one. And a text reply has no cliff: a malformed line costs that line, where an
8
+ * unparseable JSON object used to cost the entire review (#1614).
9
+ *
10
+ * Every function here is pure and non-throwing. `verdict.ts` documents why that
11
+ * is load-bearing: a throw inside an acpx `parse` fails the whole flow.
12
+ */
13
+ import type { Finding, FindingDisposition, ReviewReport, Severity, Touchpoint } from "./types";
14
+
15
+ type Section = "touchpoints" | "walk" | "findings";
16
+
17
+ /** Headings are matched loosely — any level, any case, optional trailing colon. */
18
+ const HEADING = /^\s*#{1,6}\s*(TOUCHPOINTS|WALK|FINDINGS|DISPOSITIONS)\s*:?\s*$/i;
19
+ const BLOCK = /^\s*\[(CRITICAL|HIGH|MEDIUM|LOW)\]\s+(.+?)\s*$/;
20
+ const FIELD = /^\s*(Problem|Fix|Judgment)\s*:\s*(.*)$/i;
21
+ const NO_FINDINGS = /^\s*no findings\.?\s*$/i;
22
+ const BULLET = /^\s*[-*]\s+(.+?)\s*$/;
23
+ const DISPOSITION = /^\s*\[?(\d+)\]?\s*[.:)]?\s*(fixed|rejected)\b\s*(.*)$/i;
24
+ const EVIDENCE = /evidence\s*:\s*(\S+)/i;
25
+
26
+ /** `- path/to/file.ts:symbol — why`, tolerant of backticks and of `-` for `—`. */
27
+ function parseTouchpoint(text: string): Touchpoint | null {
28
+ const m = /^(\S+)\s*(?:[—–-]\s*)?(.*)$/.exec(text);
29
+ if (!m) return null;
30
+ const locator = m[1].replace(/[`,]/g, "");
31
+ const note = m[2].trim();
32
+ if (/^none$/i.test(locator)) return { path: "none", note };
33
+ const cut = locator.lastIndexOf(":");
34
+ return cut > 0 ? { path: locator.slice(0, cut), symbol: locator.slice(cut + 1), note } : { path: locator, note };
35
+ }
36
+
37
+ function parseJudgment(value: string): { judgment: boolean; judgmentReason?: string } {
38
+ const m = /^\s*(yes|true)\b\s*(?:[—–-]\s*)?(.*)$/i.exec(value);
39
+ if (!m) return { judgment: false };
40
+ const reason = m[2].trim();
41
+ return reason ? { judgment: true, judgmentReason: reason } : { judgment: true };
42
+ }
43
+
44
+ /**
45
+ * Parse a reviewer reply.
46
+ *
47
+ * Section state starts at `findings`, not "none": a reviewer that emits blocks
48
+ * and no headings at all is a partial failure of the contract, and its findings
49
+ * are still worth keeping — losing them is the exact failure this replaces. The
50
+ * `saw*Section` flags stay false in that case, which is what the audit gate in
51
+ * `steps/review-audit.ts` keys off.
52
+ */
53
+ export function parseReviewReport(text: string): ReviewReport {
54
+ const report: ReviewReport = {
55
+ findings: [],
56
+ touchpoints: [],
57
+ walk: [],
58
+ sawNoFindings: false,
59
+ sawTouchpointsSection: false,
60
+ sawWalkSection: false,
61
+ };
62
+ let section: Section = "findings";
63
+ let current: Finding | null = null;
64
+ let lastField: "problem" | "fix" | null = null;
65
+
66
+ const flush = () => {
67
+ if (current) report.findings.push(current);
68
+ current = null;
69
+ lastField = null;
70
+ };
71
+
72
+ for (const line of text.split("\n")) {
73
+ const heading = HEADING.exec(line);
74
+ if (heading) {
75
+ flush();
76
+ const name = heading[1].toLowerCase();
77
+ if (name === "touchpoints") {
78
+ section = "touchpoints";
79
+ report.sawTouchpointsSection = true;
80
+ } else if (name === "walk") {
81
+ section = "walk";
82
+ report.sawWalkSection = true;
83
+ } else {
84
+ section = "findings";
85
+ }
86
+ continue;
87
+ }
88
+
89
+ if (section === "touchpoints") {
90
+ const bullet = BULLET.exec(line);
91
+ const tp = bullet ? parseTouchpoint(bullet[1]) : null;
92
+ if (tp) report.touchpoints.push(tp);
93
+ continue;
94
+ }
95
+ if (section === "walk") {
96
+ if (line.trim().length > 0) report.walk.push(line.trim());
97
+ continue;
98
+ }
99
+
100
+ const block = BLOCK.exec(line);
101
+ if (block) {
102
+ flush();
103
+ current = { severity: block[1] as Severity, title: block[2], problem: "", fix: "" };
104
+ continue;
105
+ }
106
+ if (NO_FINDINGS.test(line)) {
107
+ report.sawNoFindings = true;
108
+ continue;
109
+ }
110
+ if (!current) continue;
111
+ const field = FIELD.exec(line);
112
+ if (field) {
113
+ const key = field[1].toLowerCase();
114
+ if (key === "problem") {
115
+ current.problem = field[2].trim();
116
+ lastField = "problem";
117
+ } else if (key === "fix") {
118
+ current.fix = field[2].trim();
119
+ lastField = "fix";
120
+ } else {
121
+ Object.assign(current, parseJudgment(field[2]));
122
+ lastField = null;
123
+ }
124
+ continue;
125
+ }
126
+ // A continuation line for the field above it — the reviewer wraps prose, and
127
+ // a wrapped Problem read as nothing is how detail silently disappears.
128
+ if (lastField && line.trim().length > 0) {
129
+ current[lastField] = `${current[lastField]} ${line.trim()}`.trim();
130
+ }
131
+ }
132
+ flush();
133
+ return report;
134
+ }
135
+
136
+ /** Parse the `## DISPOSITIONS` section of a fix node's reply. */
137
+ export function parseDispositions(text: string): FindingDisposition[] {
138
+ const out: FindingDisposition[] = [];
139
+ for (const line of text.split("\n")) {
140
+ const m = DISPOSITION.exec(line);
141
+ if (!m) continue;
142
+ const evidence = EVIDENCE.exec(m[3])?.[1];
143
+ out.push({
144
+ index: Number(m[1]),
145
+ disposition: m[2].toLowerCase() as "fixed" | "rejected",
146
+ ...(evidence ? { evidence } : {}),
147
+ });
148
+ }
149
+ return out;
150
+ }
@@ -143,3 +143,8 @@ export function incrementalSince(ctx: OutputsCtx & StepsCtx, phase: "spec" | "qu
143
143
  if (!firstCommit) return null;
144
144
  return (firstCommit.output as { shaBefore?: string | null } | undefined)?.shaBefore ?? null;
145
145
  }
146
+
147
+ /** Why this phase's last review was sent back, so the retry is told what was missing. */
148
+ export function reviewGapsOf(ctx: OutputsCtx, phase: "spec" | "quality"): string[] {
149
+ return ((ctx.outputs as Record<string, { gaps?: string[] } | undefined>)[`route_${phase}`]?.gaps ?? []) as string[];
150
+ }
@@ -50,7 +50,15 @@
50
50
  */
51
51
  import { defineFlow } from "acpx/flows";
52
52
  import { buildFixCommitMessage } from "./commit-message";
53
- import { findingsOf, fixAttemptCount, gateOutputs, incrementalSince, inputOf, loadCtxOf } from "./flow-ctx";
53
+ import {
54
+ findingsOf,
55
+ fixAttemptCount,
56
+ gateOutputs,
57
+ incrementalSince,
58
+ inputOf,
59
+ loadCtxOf,
60
+ reviewGapsOf,
61
+ } from "./flow-ctx";
54
62
  import { narrativePrompt, parseNarrativeNode } from "./narrative";
55
63
  import { buildReviewPrompt, fixPrompt } from "./review-prompts";
56
64
  import {
@@ -73,12 +81,13 @@ import {
73
81
  qualityGatesNode,
74
82
  resolveFeature,
75
83
  routeReviewAndRecord,
84
+ validateDispositions,
76
85
  writeResult,
77
86
  } from "./steps";
78
87
  import type { Forge } from "./steps/forge";
79
88
  import { _prBodyDeps, buildFinishBody, buildFinishTitle } from "./steps/pr-body";
80
- import type { FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
81
- import { parseFixVerdict, parseReviewVerdict, repromptCount } from "./verdict";
89
+ import type { FindingDisposition, FinishInput, FinishPhase, FinishResult, ReviewVerdict } from "./types";
90
+ import { parseFixVerdict, parseReviewVerdict } from "./verdict";
82
91
 
83
92
  /**
84
93
  * Disabled only on an explicit "0". An unset variable means enabled, so a flow
@@ -161,12 +170,24 @@ function commitFixNode(phase: FinishPhase) {
161
170
  }): Promise<{ committed: boolean; route: string; shaBefore: string | null; shaAfter: string | null }> {
162
171
  const i = inputOf(ctx);
163
172
  const messageCtx = { outputs: ctx.outputs as Record<string, unknown> };
173
+ // A rejection is only as good as its citation, so the path is checked the
174
+ // same way a reviewer's touchpoints are. A missing file does not veto the
175
+ // rejection — the fixer may have cited a line rather than a path, or moved
176
+ // the file — it marks it, so the PR body (and the commit message below)
177
+ // can say the waiver is unverified rather than silently presenting it as
178
+ // evidenced. Resolved before the commit so the shipped commit message can
179
+ // render a rejection the same way the PR body does, instead of `Fix: …`.
180
+ const dispositions = await validateDispositions(
181
+ i.workdir,
182
+ (ctx.outputs as Record<string, { dispositions?: FindingDisposition[] } | undefined>)[`fix_${phase}`]
183
+ ?.dispositions ?? [],
184
+ );
164
185
  // skipHooks: an intermediate checkpoint must not be rejected by a repo's
165
186
  // pre-commit hook — quality_gates runs the repo's real gates before any
166
187
  // PR opens, and a hook failure here would kill the flow mid-loop.
167
188
  const { committed, shaBefore, shaAfter } = await commitFixes(
168
189
  i.workdir,
169
- buildFixCommitMessage(phase, i.feature, messageCtx, { workdir: i.workdir }),
190
+ buildFixCommitMessage(phase, i.feature, messageCtx, { workdir: i.workdir, dispositions }),
170
191
  { skipHooks: true },
171
192
  );
172
193
  // Routed BEFORE the round is recorded: `buildCommitRound` needs the
@@ -190,6 +211,7 @@ function commitFixNode(phase: FinishPhase) {
190
211
  failing: phase === "gate" ? (gateOutputs(ctx).failing ?? []) : undefined,
191
212
  shaAfter,
192
213
  now: new Date().toISOString(),
214
+ dispositions,
193
215
  }),
194
216
  );
195
217
  return { committed, route, shaBefore, shaAfter };
@@ -246,7 +268,7 @@ export default defineFlow({
246
268
  specPath: outs.specPath ?? "",
247
269
  since: incrementalSince(ctx, "spec"),
248
270
  priorFindings: findingsOf(ctx, "spec"),
249
- retry: repromptCount(ctx, "spec") > 0,
271
+ gaps: reviewGapsOf(ctx, "spec"),
250
272
  });
251
273
  },
252
274
  parse: parseReviewVerdict,
@@ -272,7 +294,7 @@ export default defineFlow({
272
294
  specPath: outs.specPath ?? "",
273
295
  since: incrementalSince(ctx, "quality"),
274
296
  priorFindings: findingsOf(ctx, "quality"),
275
- retry: repromptCount(ctx, "quality") > 0,
297
+ gaps: reviewGapsOf(ctx, "quality"),
276
298
  });
277
299
  },
278
300
  parse: parseReviewVerdict,
@@ -478,7 +500,13 @@ export default defineFlow({
478
500
  from: "route_spec",
479
501
  switch: {
480
502
  on: "$.route",
481
- cases: { clean: "review_quality", fix: "fix_spec", escalate: "escalate", reprompt: "review_spec" },
503
+ cases: {
504
+ clean: "review_quality",
505
+ fix: "fix_spec",
506
+ escalate: "escalate",
507
+ reprompt: "review_spec",
508
+ incomplete: "review_spec",
509
+ },
482
510
  },
483
511
  },
484
512
  // Spec fixes re-run the acceptance gate first (they can break it), and the
@@ -490,7 +518,13 @@ export default defineFlow({
490
518
  from: "route_quality",
491
519
  switch: {
492
520
  on: "$.route",
493
- cases: { clean: "quality_gates", fix: "fix_quality", escalate: "escalate", reprompt: "review_quality" },
521
+ cases: {
522
+ clean: "quality_gates",
523
+ fix: "fix_quality",
524
+ escalate: "escalate",
525
+ reprompt: "review_quality",
526
+ incomplete: "review_quality",
527
+ },
494
528
  },
495
529
  },
496
530
  // Quality fixes are re-reviewed by the same lens; the repo-root gates that
@@ -295,33 +295,51 @@ the dispatcher.
295
295
  `;
296
296
 
297
297
  const CLASSIFIER = [
298
- "After producing findings, classify the OVERALL route for this phase:",
299
- '- Route "proceed" when every finding has a clear recommended fix you can apply now',
300
- " (CRITICAL/HIGH, or MEDIUM whose fix is clear and low-risk) — you will fix them and re-verify.",
301
- '- Route "escalate" when ANY finding is a spec conflict, a contradiction with the spec,',
302
- " or a design/judgment concern with no safe mechanical fix. Do not attempt to fix those.",
303
- ].join("\n");
304
-
305
- const JSON_CONTRACT = [
306
- "Return exactly one JSON object and nothing else. First char `{`, last char `}`.",
307
- "Shape:",
308
- "{",
309
- ' "route": "proceed" | "escalate",',
310
- ' "findings": [{ "severity": "CRITICAL"|"HIGH"|"MEDIUM"|"LOW", "title": string, "problem": string, "fix": string }],',
311
- ' "escalationReason": string // required when route is "escalate"; omit otherwise',
312
- "}",
298
+ "Mark a finding for human judgment and only such a finding — by adding a",
299
+ "`Judgment: yes <why>` line to its block. Use it when the finding is a spec",
300
+ "conflict, or a design/judgment call with no safe mechanical fix. Everything",
301
+ "else will be fixed and re-verified automatically, so a finding with a clear,",
302
+ "low-risk fix must NOT carry the marker.",
313
303
  ].join("\n");
314
304
 
315
305
  /**
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.
306
+ * The reply contract.
307
+ *
308
+ * This supersedes the "Output format return ONLY this" section of
309
+ * `WORKER_PROTOCOL` above, which is kept verbatim so it stays diffable against
310
+ * the skill's `references/worker-protocol.md`. Its `[SEVERITY]` blocks are
311
+ * exactly this contract's `## FINDINGS` section; the two sections before it are
312
+ * what the flow adds, because the flow — unlike the skill's dispatcher — has no
313
+ * human reading the reply and so must be able to check the obligations itself.
314
+ *
315
+ * There is no JSON. A reply constrained to one JSON object has nowhere to put
316
+ * the per-AC and per-function enumerations both dimension references depend on,
317
+ * and an unreadable object used to discard the entire review (#1614).
319
318
  */
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");
319
+ function outputContract(phase: "spec" | "quality"): string {
320
+ const walk =
321
+ phase === "spec"
322
+ ? "one line per AC in the spec: `AC-3 Covered|Partial|Missing <one clause>`"
323
+ : "one line per function or method the diff adds or changes: `path.ts:name — earns its place|concern: <one clause>`";
324
+ return `# Reply contract — your reply must be these three sections, in this order
325
+
326
+ ## TOUCHPOINTS
327
+ One line per external touchpoint whose definition you actually opened:
328
+ \`- path/to/file.ts:symbol — why you opened it\`. If the diff genuinely has none,
329
+ the single line \`- none — <justification>\`. The paths are checked against the
330
+ repo: a list whose paths do not exist is treated as an incomplete review, not a
331
+ clean one, and you will be asked again.
332
+
333
+ ## WALK
334
+ ${walk}. This is the enumeration your dimension reference requires. One line each,
335
+ no prose. A missing or empty WALK section is an incomplete review.
336
+
337
+ ## FINDINGS
338
+ The \`[SEVERITY] …\` blocks defined in the worker protocol above — or the literal
339
+ line \`No findings.\` if you found none. This section is the only thing that
340
+ becomes a finding; the two above are the evidence that you were in a position to
341
+ write it.`;
342
+ }
325
343
 
326
344
  /**
327
345
  * Build the reviewer prompt.
@@ -348,25 +366,34 @@ export function buildReviewPrompt(
348
366
  specPath: string;
349
367
  since?: string | null;
350
368
  priorFindings?: Finding[];
351
- retry?: boolean;
369
+ gaps?: string[];
352
370
  },
353
371
  ): string {
354
372
  const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
355
- const lead = args.retry ? [RETRY_NOTICE] : [];
373
+ const gapNotice =
374
+ args.gaps && args.gaps.length > 0
375
+ ? [
376
+ [
377
+ "IMPORTANT — your previous review was not accepted, because it skipped a required section:",
378
+ ...args.gaps.map((g) => `- ${g}`),
379
+ "Do the reading this time and emit all three sections. A verdict without them is not a review.",
380
+ ].join("\n"),
381
+ ]
382
+ : [];
356
383
  if (!args.since) {
357
384
  return [
358
- ...lead,
385
+ ...gapNotice,
359
386
  `You are the ${phase.toUpperCase()} reviewer for a completed feature.`,
360
387
  `The spec/requirements source is: ${args.specPath}. Read it in full.`,
361
388
  `Fetch and review the diff: \`git diff ${args.base}...HEAD\` (also \`--name-only\` for the file list).`,
362
389
  WORKER_PROTOCOL,
363
390
  dims,
364
391
  CLASSIFIER,
365
- JSON_CONTRACT,
392
+ outputContract(phase),
366
393
  ].join("\n\n");
367
394
  }
368
395
  return [
369
- ...lead,
396
+ ...gapNotice,
370
397
  `You are the ${phase.toUpperCase()} reviewer for a completed feature, continuing a review you already started.`,
371
398
  `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.`,
372
399
  `Your findings from the previous pass:\n${JSON.stringify(args.priorFindings ?? [], null, 2)}`,
@@ -380,26 +407,62 @@ export function buildReviewPrompt(
380
407
  WORKER_PROTOCOL,
381
408
  dims,
382
409
  CLASSIFIER,
383
- JSON_CONTRACT,
410
+ outputContract(phase),
384
411
  ].join("\n\n");
385
412
  }
386
413
 
414
+ /**
415
+ * The fix node's contract.
416
+ *
417
+ * For the two review phases it is no longer "apply these". Every finding used to
418
+ * be implemented unconditionally, so a false positive was always built — and on
419
+ * the diff behind #1614 the comparison review raised one finding a human
420
+ * withdrew, because the change it proposed contradicted a test deliberately
421
+ * pinning the current behaviour. `quality_gates` would then have proved the
422
+ * resulting suite green. Reporting more findings without a way to reject one
423
+ * scales the false-positive exposure with the true-positive one, so the two ship
424
+ * together.
425
+ *
426
+ * A rejection must cite the file:line that pins the behaviour: an unevidenced
427
+ * "I disagree" is how a real finding gets waived, and `commit_<phase>` checks
428
+ * that the cited path exists.
429
+ */
387
430
  export function fixPrompt(
388
431
  phase: "acceptance" | "spec" | "quality" | "gate",
389
432
  ctx: { outputs: Record<string, unknown> },
390
433
  ): string {
391
- const outs = ctx.outputs as Record<string, { findings?: unknown; output?: string }>;
392
- const detail =
393
- phase === "gate"
394
- ? (outs.quality_gates?.output ?? "")
395
- : phase === "acceptance"
396
- ? (outs.acceptance?.output ?? "")
397
- : JSON.stringify(outs[`review_${phase}`]?.findings ?? []);
434
+ const outs = ctx.outputs as Record<string, { findings?: Finding[]; output?: string }>;
435
+ if (phase === "gate" || phase === "acceptance") {
436
+ const detail = phase === "gate" ? (outs.quality_gates?.output ?? "") : (outs.acceptance?.output ?? "");
437
+ return [
438
+ `Apply the recommended fixes for the ${phase} phase, directly in the repo.`,
439
+ "Do not commit, push, or open PRs — nax-finish commits and pushes your edits itself.",
440
+ `Context:\n${detail}`,
441
+ "After fixing, re-run the feature's acceptance tests and the relevant checks; only proceed when they pass.",
442
+ 'Return exactly {"route":"proceed"} when done and green.',
443
+ ].join("\n\n");
444
+ }
445
+ const findings = outs[`review_${phase}`]?.findings ?? [];
446
+ const numbered = findings
447
+ .map((f, i) => `[${i + 1}] [${f.severity}] ${f.title}\n Problem: ${f.problem}\n Fix: ${f.fix}`)
448
+ .join("\n");
398
449
  return [
399
- `Apply the recommended fixes for the ${phase} phase, directly in the repo.`,
450
+ `Resolve the ${phase} review findings below, directly in the repo.`,
400
451
  "Do not commit, push, or open PRs — nax-finish commits and pushes your edits itself.",
401
- `Context:\n${detail}`,
402
- "After fixing, re-run the feature's acceptance tests and the relevant checks; only proceed when they pass.",
403
- 'Return exactly {"route":"proceed"} when done and green.',
452
+ numbered,
453
+ [
454
+ "A finding may be REJECTED rather than fixed — but only on evidence, not on preference.",
455
+ "Reject when the change it asks for would contradict an existing test that deliberately",
456
+ "pins the current behaviour, or a spec statement that requires it. Cite the `file:line`.",
457
+ "Anything else you fix.",
458
+ ].join("\n"),
459
+ [
460
+ "After fixing, re-run the feature's acceptance tests and the relevant checks; only proceed when they pass.",
461
+ "Then end your reply with one line per finding, in this exact shape:",
462
+ "",
463
+ "## DISPOSITIONS",
464
+ "[1] fixed",
465
+ "[2] rejected — evidence: test/unit/foo.test.ts:42",
466
+ ].join("\n"),
404
467
  ].join("\n\n");
405
468
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Pairs with `./review-round`, which records the rounds that produce no commit.
10
10
  */
11
- import type { Finding, FinishPhase, FinishRound, FinishRoundOutcome } from "../types";
11
+ import type { Finding, FindingDisposition, FinishPhase, FinishRound, FinishRoundOutcome } from "../types";
12
12
 
13
13
  /** Phases that own a reviewer node; every other phase's round has nobody behind it. */
14
14
  const REVIEWED_PHASES: FinishPhase[] = ["spec", "quality"];
@@ -40,6 +40,8 @@ export interface CommitRoundInput {
40
40
  /** Post-commit HEAD, when there was a commit. */
41
41
  shaAfter?: string | null;
42
42
  now: string;
43
+ /** What the fixer did with each finding it was handed (spec/quality phases). */
44
+ dispositions?: FindingDisposition[];
43
45
  }
44
46
 
45
47
  /**
@@ -60,5 +62,6 @@ export function buildCommitRound(i: CommitRoundInput): FinishRound {
60
62
  route: i.route,
61
63
  ...(i.failing ? { failing: i.failing } : {}),
62
64
  ...(i.committed && i.shaAfter ? { sha: i.shaAfter } : {}),
65
+ ...(i.dispositions && i.dispositions.length > 0 ? { dispositions: i.dispositions } : {}),
63
66
  };
64
67
  }
@@ -9,4 +9,5 @@ export * from "./pr";
9
9
  export * from "./pr-narrative";
10
10
  export * from "./commit-round";
11
11
  export * from "./result";
12
+ export * from "./review-audit";
12
13
  export * from "./review-round";
@@ -25,7 +25,7 @@ import { readSpecSummary, resolveNarrative } from "../narrative";
25
25
  import { findPrTemplate } from "../pr-template";
26
26
  import { type BodySection, type TemplateMode, mergeTemplate } from "../pr-template-merge";
27
27
  import { resolveTitle } from "../pr-title";
28
- import type { Finding, FinishInput, FinishRound, RunFn } from "../types";
28
+ import type { Finding, FindingDisposition, FinishInput, FinishRound, RunFn } from "../types";
29
29
  import type { Forge } from "./forge";
30
30
  import { readRounds } from "./result";
31
31
 
@@ -359,6 +359,7 @@ const EMPTY_ROUND_NOTE: Record<string, string> = {
359
359
  unparseable: "- _reviewer output could not be parsed_",
360
360
  escalated: "- _escalated for human review_",
361
361
  "review-skipped": "- _re-review skipped: this fix touched test files only_",
362
+ incomplete: "- _review sent back: required evidence sections missing_",
362
363
  };
363
364
 
364
365
  function buildRoundBlock(round: FinishRound): string {
@@ -366,7 +367,13 @@ function buildRoundBlock(round: FinishRound): string {
366
367
  if (round.findings.length === 0) {
367
368
  lines.push(EMPTY_ROUND_NOTE[round.outcome ?? ""] ?? "- _no findings_");
368
369
  } else {
369
- for (const finding of round.findings) lines.push(renderFinding(finding));
370
+ if (round.outcome === "incomplete") {
371
+ lines.push("- _not acted on — the review was sent back for missing evidence sections_");
372
+ }
373
+ for (const [i, finding] of round.findings.entries()) {
374
+ const d = round.dispositions?.find((x) => x.index === i + 1);
375
+ lines.push(d?.disposition === "rejected" ? renderRejected(finding, d) : renderFinding(finding));
376
+ }
370
377
  }
371
378
  return lines.join("\n");
372
379
  }
@@ -380,6 +387,19 @@ function renderFinding(finding: Finding): string {
380
387
  return `- [${finding.severity}] ${finding.title}`;
381
388
  }
382
389
 
390
+ /**
391
+ * A waived finding, shown as waived.
392
+ *
393
+ * The alternative — dropping it — would make a rejection indistinguishable from
394
+ * a fix in the only artifact a human reads, which is the failure this whole
395
+ * mechanism exists to avoid.
396
+ */
397
+ function renderRejected(finding: Finding, d: FindingDisposition): string {
398
+ const evidence = d.evidence ? `\`${d.evidence}\`` : "_no evidence cited_";
399
+ const caveat = d.evidenceMissing ? " — **evidence path not found**" : "";
400
+ return `- [${finding.severity}] ${finding.title} — _rejected_: ${evidence}${caveat}`;
401
+ }
402
+
383
403
  /**
384
404
  * Body only — the heading is attached by `buildFinishBody`, which drops any
385
405
  * section whose body is null. "No text" therefore cannot render a bare