@jwilger/pi-development-system 0.18.0 → 0.20.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.
- package/package.json +1 -1
- package/skills/code-review/SKILL.md +9 -6
- package/src/core/review-flow.ts +56 -10
- package/src/core/review.ts +23 -1
- package/src/gates/commit-guard.ts +5 -1
- package/src/review/digest.ts +72 -10
- package/src/review/review-tools.ts +55 -14
package/package.json
CHANGED
|
@@ -12,12 +12,14 @@ Review is a soft gate (`review.unsatisfied`): skipping it needs a recorded depar
|
|
|
12
12
|
## Run a round
|
|
13
13
|
|
|
14
14
|
1. `devsys_review_start` (slice defaults to the active slice; `diffRange` defaults
|
|
15
|
-
to `HEAD
|
|
16
|
-
choose lenses, and returns an exact `agent_spawn` payload.
|
|
15
|
+
to `HEAD`: everything uncommitted, untracked files included). It computes the
|
|
16
|
+
diff digest, lets Jev choose lenses, and returns an exact `agent_spawn` payload.
|
|
17
17
|
2. Run that `agent_spawn` unchanged. The reviewer is a top-level agent, not a
|
|
18
18
|
child of this conversation, so it does not inherit your context.
|
|
19
|
-
3. Pass the reviewer's packet, verbatim, to `devsys_review_record`
|
|
20
|
-
(`packets: [...]`;
|
|
19
|
+
3. Pass the reviewer's packet, verbatim, to `devsys_review_record` with the
|
|
20
|
+
`diffDigest` the start reply gave (`packets: [...]`; all lens packets of one
|
|
21
|
+
round in one call). A packet is recorded once, in the round its header names;
|
|
22
|
+
if the diff changed since the start, the round is refused: start again.
|
|
21
23
|
4. Read the reply: `review: N/R clean` and `next:`.
|
|
22
24
|
- `fix-findings`: fix every blocking and should-fix finding, then start a new round.
|
|
23
25
|
- `review`: the streak is short (or the diff changed with findings); start another round.
|
|
@@ -47,8 +49,9 @@ The verdict must agree with the findings.
|
|
|
47
49
|
`docs/decisions/followups.md`.
|
|
48
50
|
- **false-positive**: refuted; ignored.
|
|
49
51
|
|
|
50
|
-
Jev may
|
|
51
|
-
|
|
52
|
+
Jev may raise a finding's severity when it is at least 0.8 confident; it never
|
|
53
|
+
lowers a blocking or should-fix finding (it only suggests, in the reply, and you
|
|
54
|
+
re-check it yourself). A round is clean when it has no blocking or should-fix findings.
|
|
52
55
|
The default is three consecutive clean rounds (`review.required_clean_rounds`).
|
|
53
56
|
Only real findings reset the count; a changed diff alone does not, so a small fix
|
|
54
57
|
commit keeps the clean rounds already earned unless findings come back.
|
package/src/core/review-flow.ts
CHANGED
|
@@ -25,27 +25,59 @@ export const reviewLabel = (review: ReviewState): string =>
|
|
|
25
25
|
* Why a commit on this slice lacks a satisfied review, or `undefined` when the review is complete
|
|
26
26
|
* on the diff as it is now (the `review.unsatisfied` soft gate is raised from this).
|
|
27
27
|
*/
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
export type DiffNow = {
|
|
29
|
+
readonly digest: string;
|
|
30
|
+
readonly files?: Readonly<Record<string, string>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Every file in the current diff was in the last round's diff, byte for byte: a part of what was reviewed. */
|
|
34
|
+
const coveredByLastRound = (review: ReviewState, now: DiffNow): boolean => {
|
|
35
|
+
const reviewed = review.rounds.at(-1)?.files;
|
|
36
|
+
if (reviewed === undefined || now.files === undefined) return false;
|
|
37
|
+
return Object.entries(now.files).every(([path, digest]) => reviewed[path] === digest);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function reviewGap(state: DevsysState, slice: SliceRef, now: DiffNow): string | undefined {
|
|
33
41
|
const review = reviewOf(state, slice);
|
|
34
42
|
if (review === undefined || review.rounds.length === 0) {
|
|
35
43
|
return `no review has been recorded for slice ${slice}`;
|
|
36
44
|
}
|
|
37
|
-
switch (nextAction(review,
|
|
45
|
+
switch (nextAction(review, now.digest)) {
|
|
38
46
|
case "done":
|
|
39
47
|
return undefined;
|
|
40
48
|
case "fix-findings":
|
|
41
49
|
return `the last review round of slice ${slice} has blocking or should-fix findings still to fix`;
|
|
42
50
|
case "stale-diff":
|
|
51
|
+
// Committing a reviewed change in parts leaves a smaller diff made only of reviewed files.
|
|
52
|
+
if (coveredByLastRound(review, now)) return undefined;
|
|
43
53
|
return `the diff has changed since slice ${slice} was last reviewed; review it again`;
|
|
44
54
|
case "review":
|
|
45
55
|
return `slice ${slice} has ${cleanStreak(review)}/${review.required} clean review rounds`;
|
|
46
56
|
}
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
/** A unified diff cut into one text per file, keyed by the file's new path. */
|
|
60
|
+
export function splitDiffByFile(diff: string): Record<string, string> {
|
|
61
|
+
const out: Record<string, string> = {};
|
|
62
|
+
let path: string | undefined;
|
|
63
|
+
let lines: string[] = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (path !== undefined) out[path] = lines.join("\n");
|
|
66
|
+
};
|
|
67
|
+
for (const line of diff.split("\n")) {
|
|
68
|
+
// Git quotes unusual paths ("b/na\303\257ve.md"); strip the quotes rather than lose the section.
|
|
69
|
+
const header = /^diff --git "?a\/.*?"? "?b\/(.*?)"?$/.exec(line);
|
|
70
|
+
if (header !== null) {
|
|
71
|
+
flush();
|
|
72
|
+
path = header[1] ?? line;
|
|
73
|
+
lines = [];
|
|
74
|
+
}
|
|
75
|
+
lines.push(line);
|
|
76
|
+
}
|
|
77
|
+
flush();
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
/** Packets from several lens reviewers in one round become one round of findings. */
|
|
50
82
|
export function combinePackets(packets: readonly ReviewPacket[]): {
|
|
51
83
|
lenses: string[];
|
|
@@ -61,7 +93,14 @@ export function combinePackets(packets: readonly ReviewPacket[]): {
|
|
|
61
93
|
/** Jev confidence needed before its severity replaces the reviewer's. */
|
|
62
94
|
export const SEVERITY_ADOPT_CONFIDENCE = 0.8;
|
|
63
95
|
|
|
64
|
-
|
|
96
|
+
const counts = (severity: Severity): boolean =>
|
|
97
|
+
severity === "blocking" || severity === "should-fix";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Take Jev's severity only when it is confident and different, and never to lower a finding that
|
|
101
|
+
* counts (blocking/should-fix) to one that does not: Jev reads a clipped diff, the reviewer read
|
|
102
|
+
* the code. That disagreement is reported as a suggestion and the reviewer's severity stands.
|
|
103
|
+
*/
|
|
65
104
|
export function adoptSeverity(
|
|
66
105
|
finding: Finding,
|
|
67
106
|
judged: { severity: Severity; confidence: number } | undefined,
|
|
@@ -73,9 +112,16 @@ export function adoptSeverity(
|
|
|
73
112
|
) {
|
|
74
113
|
return { finding };
|
|
75
114
|
}
|
|
115
|
+
const confidence = judged.confidence.toFixed(2);
|
|
116
|
+
if (counts(finding.severity) && !counts(judged.severity)) {
|
|
117
|
+
return {
|
|
118
|
+
finding,
|
|
119
|
+
note: `${finding.id}: Jev suggests ${judged.severity} instead of ${finding.severity} (confidence ${confidence}); the reviewer's severity stands, re-check the finding yourself`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
76
122
|
return {
|
|
77
123
|
finding: { ...finding, severity: judged.severity },
|
|
78
|
-
note: `${finding.id}: Jev moved severity ${finding.severity} → ${judged.severity} (confidence ${
|
|
124
|
+
note: `${finding.id}: Jev moved severity ${finding.severity} → ${judged.severity} (confidence ${confidence})`,
|
|
79
125
|
};
|
|
80
126
|
}
|
|
81
127
|
|
|
@@ -104,9 +150,9 @@ export function reviewerTask(input: {
|
|
|
104
150
|
const lenses = input.lenses.join(", ");
|
|
105
151
|
return [
|
|
106
152
|
`Review slice ${input.slice}, round ${input.round}, through these lenses: ${lenses}.`,
|
|
107
|
-
`See the change with \`git diff ${input.diffRange}\` (and \`git diff --stat ${input.diffRange}\`). Read the callers, callees and tests it depends on, no more.`,
|
|
153
|
+
`See the change with \`git diff ${input.diffRange}\` (and \`git diff --stat ${input.diffRange}\`), plus any untracked files listed by \`git status --short\` (git diff does not show them; read them directly). Read the callers, callees and tests it depends on, no more.`,
|
|
108
154
|
"Do not modify files; report demonstrable defects with a realistic trigger and the smallest local repair.",
|
|
109
|
-
"Return exactly the review packet from your instructions:",
|
|
155
|
+
"Return exactly the review packet from your instructions, with exactly this header (slice and round must not change):",
|
|
110
156
|
"",
|
|
111
157
|
`## Review — ${input.slice} — round ${input.round} — lenses: ${lenses}`,
|
|
112
158
|
"### Sources inspected",
|
package/src/core/review.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type ReviewRound = {
|
|
|
21
21
|
readonly reviewedAt: string;
|
|
22
22
|
/** Digest of the diff the round reviewed (see `digestOf` in src/review/digest.ts). */
|
|
23
23
|
readonly diffDigest: string;
|
|
24
|
+
/** Per-file digests of the reviewed diff, so a commit of part of it is still covered. */
|
|
25
|
+
readonly files?: Readonly<Record<string, string>>;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
export type ReviewState = {
|
|
@@ -118,6 +120,17 @@ function parseFinding(input: unknown): Finding | ParseError {
|
|
|
118
120
|
};
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
function parseFiles(input: unknown): Record<string, string> | undefined | null {
|
|
124
|
+
if (input === undefined) return undefined;
|
|
125
|
+
if (!isRecord(input)) return null;
|
|
126
|
+
const out: Record<string, string> = {};
|
|
127
|
+
for (const [path, digest] of Object.entries(input)) {
|
|
128
|
+
if (typeof digest !== "string") return null;
|
|
129
|
+
out[path] = digest;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
121
134
|
function parseRound(input: unknown): ReviewRound | ParseError {
|
|
122
135
|
if (!isRecord(input)) return parseError("round must be an object");
|
|
123
136
|
const { n, lenses, findings, reviewedAt, diffDigest } = input;
|
|
@@ -134,7 +147,16 @@ function parseRound(input: unknown): ReviewRound | ParseError {
|
|
|
134
147
|
if ("kind" in finding) return finding;
|
|
135
148
|
parsed.push(finding);
|
|
136
149
|
}
|
|
137
|
-
|
|
150
|
+
const files = parseFiles(input.files);
|
|
151
|
+
if (files === null) return parseError("round files must map paths to digest strings");
|
|
152
|
+
return {
|
|
153
|
+
n,
|
|
154
|
+
lenses: lenses as string[],
|
|
155
|
+
findings: parsed,
|
|
156
|
+
reviewedAt,
|
|
157
|
+
diffDigest,
|
|
158
|
+
...(files === undefined ? {} : { files }),
|
|
159
|
+
};
|
|
138
160
|
}
|
|
139
161
|
|
|
140
162
|
/** Boundary parse for a persisted review (one slice's rounds). */
|
|
@@ -124,7 +124,11 @@ async function reviewNeeds(deps: CommitGuardDeps, ctx: ExtensionContext): Promis
|
|
|
124
124
|
if ((phase !== "implementing" && phase !== "reviewing") || activeSlice === undefined) return [];
|
|
125
125
|
const snap = await snapshotDiff(deps.exec, ctx.cwd, "HEAD");
|
|
126
126
|
// An unreadable diff cannot be compared; the gate asks for a departure rather than guessing.
|
|
127
|
-
const gap = reviewGap(
|
|
127
|
+
const gap = reviewGap(
|
|
128
|
+
deps.state.get(),
|
|
129
|
+
activeSlice,
|
|
130
|
+
snap.ok ? { digest: snap.value.digest, files: snap.value.files } : { digest: "unknown" },
|
|
131
|
+
);
|
|
128
132
|
return gap === undefined ? [] : [{ gate: REVIEW, why: gap }];
|
|
129
133
|
}
|
|
130
134
|
|
package/src/review/digest.ts
CHANGED
|
@@ -1,31 +1,93 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { Exec } from "../core/exec.ts";
|
|
3
3
|
import { err, ok, type Result } from "../core/result.ts";
|
|
4
|
+
import { splitDiffByFile } from "../core/review-flow.ts";
|
|
4
5
|
|
|
5
|
-
/** A short, stable identity for
|
|
6
|
-
export const digestOf = (
|
|
7
|
-
createHash("sha256").update(
|
|
6
|
+
/** A short, stable identity for some text. Pure. */
|
|
7
|
+
export const digestOf = (text: string): string =>
|
|
8
|
+
createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
8
9
|
|
|
9
|
-
export type DiffSnapshot = {
|
|
10
|
+
export type DiffSnapshot = {
|
|
11
|
+
/** Identity of everything reviewed: the tracked diff plus the content of untracked files. */
|
|
12
|
+
digest: string;
|
|
13
|
+
/** Per-file digests, tracked and untracked, keyed by path. */
|
|
14
|
+
files: Record<string, string>;
|
|
15
|
+
stat: string;
|
|
16
|
+
sample: string;
|
|
17
|
+
};
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
const MAX_UNTRACKED = 200;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The change in `range` (default `HEAD`: everything not yet committed) with its digest. `git diff`
|
|
23
|
+
* leaves out untracked files, so for a range that includes the work tree they are listed and
|
|
24
|
+
* content-hashed too; otherwise a new file would be committed unreviewed.
|
|
25
|
+
*/
|
|
12
26
|
export async function snapshotDiff(
|
|
13
27
|
exec: Exec,
|
|
14
28
|
cwd: string,
|
|
15
29
|
range: string,
|
|
16
30
|
): Promise<Result<DiffSnapshot, string>> {
|
|
17
31
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
// Plain, parseable output whatever the user's git config says: an external diff driver or
|
|
33
|
+
// forced colour removes the `diff --git` headers the per-file digests are cut from.
|
|
34
|
+
const plain = ["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-color"];
|
|
35
|
+
const [diff, stat, others] = await Promise.all([
|
|
36
|
+
exec("git", [...plain, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
|
|
37
|
+
exec("git", [...plain, "--stat", range], { cwd, timeout: 15_000 }),
|
|
38
|
+
range === "HEAD"
|
|
39
|
+
? exec("git", ["ls-files", "--others", "--exclude-standard"], { cwd, timeout: 15_000 })
|
|
40
|
+
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
|
21
41
|
]);
|
|
22
42
|
if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
|
|
43
|
+
if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
|
|
44
|
+
const listed = others.stdout.split("\n").filter((p) => p !== "");
|
|
45
|
+
// A nested repository is listed as `dir/` and has no blob to hash.
|
|
46
|
+
const nested = listed.filter((p) => p.endsWith("/"));
|
|
47
|
+
const untracked = listed.filter((p) => !p.endsWith("/"));
|
|
48
|
+
if (untracked.length > MAX_UNTRACKED) {
|
|
49
|
+
return err(
|
|
50
|
+
`${untracked.length} untracked files (more than ${MAX_UNTRACKED}); commit or ignore some so they can be reviewed`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const files: Record<string, string> = {};
|
|
54
|
+
for (const [path, text] of Object.entries(splitDiffByFile(diff.stdout))) {
|
|
55
|
+
files[path] = fileDigest(text);
|
|
56
|
+
}
|
|
57
|
+
if (diff.stdout.trim() !== "" && Object.keys(files).length === 0) {
|
|
58
|
+
return err("could not read the diff: no per-file sections were found");
|
|
59
|
+
}
|
|
60
|
+
if (untracked.length > 0) {
|
|
61
|
+
const hashes = await exec("git", ["hash-object", "--", ...untracked], {
|
|
62
|
+
cwd,
|
|
63
|
+
timeout: 15_000,
|
|
64
|
+
});
|
|
65
|
+
const lines = hashes.stdout.split("\n").filter((l) => l !== "");
|
|
66
|
+
if (hashes.code !== 0 || lines.length !== untracked.length) {
|
|
67
|
+
return err(hashes.stderr.trim() || "git hash-object could not hash every untracked file");
|
|
68
|
+
}
|
|
69
|
+
for (const [i, path] of untracked.entries()) files[path] = `new:${lines[i]}`;
|
|
70
|
+
}
|
|
71
|
+
const names = Object.keys(files).sort();
|
|
72
|
+
const untrackedStat = [
|
|
73
|
+
...untracked.map((p) => ` ${p} (untracked)`),
|
|
74
|
+
...nested.map((p) => ` ${p} (nested repository, not reviewable)`),
|
|
75
|
+
].join("\n");
|
|
23
76
|
return ok({
|
|
24
|
-
digest: digestOf(
|
|
25
|
-
|
|
77
|
+
digest: digestOf(names.map((p) => `${p}\0${files[p]}`).join("\n")),
|
|
78
|
+
files,
|
|
79
|
+
stat: [stat.stdout.trimEnd(), untrackedStat].filter((p) => p !== "").join("\n"),
|
|
26
80
|
sample: diff.stdout.slice(0, 16_000),
|
|
27
81
|
});
|
|
28
82
|
} catch (cause) {
|
|
29
83
|
return err(cause instanceof Error ? cause.message : String(cause));
|
|
30
84
|
}
|
|
31
85
|
}
|
|
86
|
+
|
|
87
|
+
const NEW_FILE = /^new file mode .*\nindex 0+\.\.([0-9a-f]+)/m;
|
|
88
|
+
|
|
89
|
+
/** A new file digests as its blob, the same form an untracked file gets, so `git add` changes nothing. */
|
|
90
|
+
const fileDigest = (section: string): string => {
|
|
91
|
+
const blob = NEW_FILE.exec(section)?.[1];
|
|
92
|
+
return blob === undefined ? digestOf(section) : `new:${blob}`;
|
|
93
|
+
};
|
|
@@ -63,7 +63,13 @@ const StartParameters = Type.Object({
|
|
|
63
63
|
),
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
/** One path segment is at most 64 chars (src/subagents/orch/paths.ts); keep room for round and attempt. */
|
|
67
|
+
const pathSafe = (slice: string): string =>
|
|
68
|
+
slice
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^-+|-+$/g, "")
|
|
72
|
+
.slice(0, 32);
|
|
67
73
|
|
|
68
74
|
/** `devsys_review_start`: Jev picks lenses for the diff; the reply carries the agent_spawn payload for a fresh reviewer. */
|
|
69
75
|
export function createReviewStartTool(
|
|
@@ -128,7 +134,8 @@ export function createReviewStartTool(
|
|
|
128
134
|
availableModels(ctx.modelRegistry),
|
|
129
135
|
);
|
|
130
136
|
const spawn = {
|
|
131
|
-
path:
|
|
137
|
+
// A fresh path per attempt: a failed spawn must not leave a thread that blocks the retry.
|
|
138
|
+
path: `/review-${pathSafe(slice)}-r${round}-${(deps.now?.() ?? new Date()).getTime().toString(36)}`,
|
|
132
139
|
type: "reviewer",
|
|
133
140
|
task: reviewerTask({ slice, round, lenses, diffRange: range }),
|
|
134
141
|
...(resolved.ok ? { model: resolved.value.model } : {}),
|
|
@@ -139,7 +146,7 @@ export function createReviewStartTool(
|
|
|
139
146
|
[
|
|
140
147
|
`${reviewLabel(review)}; round ${round}; diff ${snap.value.digest}. ${basis}`,
|
|
141
148
|
`lenses: ${lenses.join(", ")}`,
|
|
142
|
-
|
|
149
|
+
`Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record with diffDigest "${snap.value.digest}":`,
|
|
143
150
|
JSON.stringify(spawn),
|
|
144
151
|
].join("\n"),
|
|
145
152
|
);
|
|
@@ -157,6 +164,10 @@ const RecordParameters = Type.Object({
|
|
|
157
164
|
diffRange: Type.Optional(
|
|
158
165
|
Type.String({ description: "The range that was reviewed; defaults to HEAD." }),
|
|
159
166
|
),
|
|
167
|
+
diffDigest: Type.String({
|
|
168
|
+
description:
|
|
169
|
+
"The diff digest devsys_review_start reported for this round; the round is refused if the diff has changed since.",
|
|
170
|
+
}),
|
|
160
171
|
});
|
|
161
172
|
|
|
162
173
|
async function adjusted(
|
|
@@ -214,30 +225,60 @@ export function createReviewRecordTool(
|
|
|
214
225
|
const snap = await snapshotDiff(deps.exec, ctx.cwd, params.diffRange?.trim() || "HEAD");
|
|
215
226
|
if (!snap.ok) return reply(`cannot read the diff: ${snap.error}`, true);
|
|
216
227
|
|
|
217
|
-
|
|
218
|
-
|
|
228
|
+
if (params.diffDigest !== snap.value.digest) {
|
|
229
|
+
return reply(
|
|
230
|
+
`the diff changed since the review started (reviewed ${params.diffDigest}, now ${snap.value.digest}); the reviewer did not see the current code. Run devsys_review_start again.`,
|
|
231
|
+
true,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
219
234
|
const required = Math.max(
|
|
220
235
|
config.value.review.requiredCleanRounds,
|
|
221
236
|
config.value.review.minRounds,
|
|
222
237
|
);
|
|
223
238
|
const before = reviewOf(deps.state.get(), slice) ?? startReview(slice, required);
|
|
239
|
+
const expected = before.rounds.length + 1;
|
|
240
|
+
const wrong = packets.findIndex((p) => p.round !== expected || p.slice !== slice);
|
|
241
|
+
if (wrong !== -1) {
|
|
242
|
+
return reply(
|
|
243
|
+
`packet ${wrong + 1} is for slice "${packets[wrong]?.slice}" round ${packets[wrong]?.round}; this is round ${expected} of slice "${slice}". A packet is recorded once, in the round it was written for; ask the reviewer for a packet with the right header.`,
|
|
244
|
+
true,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const merged = combinePackets(packets);
|
|
249
|
+
const { findings, notes } = await adjusted(deps, ctx, merged.findings, snap.value.sample);
|
|
250
|
+
// File the nits first: if this fails nothing is recorded and a retry cannot count the round twice.
|
|
251
|
+
const followups = renderFollowups(slice, expected, findings);
|
|
252
|
+
let reviewed = snap.value;
|
|
253
|
+
if (followups !== undefined) {
|
|
254
|
+
try {
|
|
255
|
+
const dir = join(ctx.cwd, "docs", "decisions");
|
|
256
|
+
await mkdir(dir, { recursive: true });
|
|
257
|
+
await appendFile(join(dir, "followups.md"), followups);
|
|
258
|
+
} catch (cause) {
|
|
259
|
+
return reply(
|
|
260
|
+
`could not write docs/decisions/followups.md (${cause instanceof Error ? cause.message : String(cause)}); the round was not recorded, retry`,
|
|
261
|
+
true,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
// The file is part of the work tree, so writing it changed the diff. The round covers the
|
|
265
|
+
// reviewed code plus its own follow-up note; otherwise `done` would be stale on arrival.
|
|
266
|
+
const after = await snapshotDiff(deps.exec, ctx.cwd, params.diffRange?.trim() || "HEAD");
|
|
267
|
+
if (!after.ok)
|
|
268
|
+
return reply(`cannot read the diff after filing follow-ups: ${after.error}`, true);
|
|
269
|
+
reviewed = after.value;
|
|
270
|
+
}
|
|
224
271
|
const review = addRound(
|
|
225
272
|
{ ...before, required },
|
|
226
273
|
{
|
|
227
274
|
lenses: merged.lenses,
|
|
228
275
|
findings,
|
|
229
276
|
reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
230
|
-
diffDigest:
|
|
277
|
+
diffDigest: reviewed.digest,
|
|
278
|
+
files: reviewed.files,
|
|
231
279
|
},
|
|
232
280
|
);
|
|
233
281
|
deps.state.update((s) => upsertReview(s, review));
|
|
234
|
-
|
|
235
|
-
const followups = renderFollowups(slice, review.rounds.length, findings);
|
|
236
|
-
if (followups !== undefined) {
|
|
237
|
-
const dir = join(ctx.cwd, "docs", "decisions");
|
|
238
|
-
await mkdir(dir, { recursive: true });
|
|
239
|
-
await appendFile(join(dir, "followups.md"), followups);
|
|
240
|
-
}
|
|
241
282
|
const counts = (sev: Finding["severity"]) =>
|
|
242
283
|
findings.filter((f) => f.severity === sev).length;
|
|
243
284
|
return reply(
|
|
@@ -245,7 +286,7 @@ export function createReviewRecordTool(
|
|
|
245
286
|
`Round ${review.rounds.length} recorded: ${counts("blocking")} blocking, ${counts("should-fix")} should-fix, ${counts("nit")} nit, ${counts("false-positive")} false-positive.`,
|
|
246
287
|
reviewLabel(review),
|
|
247
288
|
...notes,
|
|
248
|
-
`next: ${nextAction(review,
|
|
289
|
+
`next: ${nextAction(review, reviewed.digest)}`,
|
|
249
290
|
].join("\n"),
|
|
250
291
|
);
|
|
251
292
|
},
|