@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.
- package/dist/nax.js +1764 -1646
- package/flows/nax-finish/commit-message.ts +34 -10
- package/flows/nax-finish/findings-parse.ts +150 -0
- package/flows/nax-finish/flow-ctx.ts +5 -0
- package/flows/nax-finish/nax-finish.flow.ts +42 -8
- package/flows/nax-finish/review-prompts.ts +103 -40
- package/flows/nax-finish/steps/commit-round.ts +4 -1
- package/flows/nax-finish/steps/index.ts +1 -0
- package/flows/nax-finish/steps/pr-body.ts +22 -2
- package/flows/nax-finish/steps/review-audit.ts +91 -0
- package/flows/nax-finish/steps/review-round.ts +42 -7
- package/flows/nax-finish/types.ts +59 -1
- package/flows/nax-finish/verdict.ts +39 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
"
|
|
299
|
-
|
|
300
|
-
"
|
|
301
|
-
|
|
302
|
-
"
|
|
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
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
369
|
+
gaps?: string[];
|
|
352
370
|
},
|
|
353
371
|
): string {
|
|
354
372
|
const dims = phase === "spec" ? SPEC_REVIEW_DIMENSIONS : QUALITY_REVIEW_DIMENSIONS;
|
|
355
|
-
const
|
|
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
|
-
...
|
|
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
|
-
|
|
392
|
+
outputContract(phase),
|
|
366
393
|
].join("\n\n");
|
|
367
394
|
}
|
|
368
395
|
return [
|
|
369
|
-
...
|
|
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
|
-
|
|
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?:
|
|
392
|
-
|
|
393
|
-
phase === "gate"
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|