@jwilger/pi-development-system 0.18.0 → 0.19.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 +55 -10
- package/src/core/review.ts +23 -1
- package/src/gates/commit-guard.ts +5 -1
- package/src/review/digest.ts +39 -8
- package/src/review/review-tools.ts +44 -9
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,58 @@ 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
|
+
const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
|
|
69
|
+
if (header !== null) {
|
|
70
|
+
flush();
|
|
71
|
+
path = header[1] ?? line;
|
|
72
|
+
lines = [];
|
|
73
|
+
}
|
|
74
|
+
lines.push(line);
|
|
75
|
+
}
|
|
76
|
+
flush();
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
49
80
|
/** Packets from several lens reviewers in one round become one round of findings. */
|
|
50
81
|
export function combinePackets(packets: readonly ReviewPacket[]): {
|
|
51
82
|
lenses: string[];
|
|
@@ -61,7 +92,14 @@ export function combinePackets(packets: readonly ReviewPacket[]): {
|
|
|
61
92
|
/** Jev confidence needed before its severity replaces the reviewer's. */
|
|
62
93
|
export const SEVERITY_ADOPT_CONFIDENCE = 0.8;
|
|
63
94
|
|
|
64
|
-
|
|
95
|
+
const counts = (severity: Severity): boolean =>
|
|
96
|
+
severity === "blocking" || severity === "should-fix";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Take Jev's severity only when it is confident and different, and never to lower a finding that
|
|
100
|
+
* counts (blocking/should-fix) to one that does not: Jev reads a clipped diff, the reviewer read
|
|
101
|
+
* the code. That disagreement is reported as a suggestion and the reviewer's severity stands.
|
|
102
|
+
*/
|
|
65
103
|
export function adoptSeverity(
|
|
66
104
|
finding: Finding,
|
|
67
105
|
judged: { severity: Severity; confidence: number } | undefined,
|
|
@@ -73,9 +111,16 @@ export function adoptSeverity(
|
|
|
73
111
|
) {
|
|
74
112
|
return { finding };
|
|
75
113
|
}
|
|
114
|
+
const confidence = judged.confidence.toFixed(2);
|
|
115
|
+
if (counts(finding.severity) && !counts(judged.severity)) {
|
|
116
|
+
return {
|
|
117
|
+
finding,
|
|
118
|
+
note: `${finding.id}: Jev suggests ${judged.severity} instead of ${finding.severity} (confidence ${confidence}); the reviewer's severity stands, re-check the finding yourself`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
76
121
|
return {
|
|
77
122
|
finding: { ...finding, severity: judged.severity },
|
|
78
|
-
note: `${finding.id}: Jev moved severity ${finding.severity} → ${judged.severity} (confidence ${
|
|
123
|
+
note: `${finding.id}: Jev moved severity ${finding.severity} → ${judged.severity} (confidence ${confidence})`,
|
|
79
124
|
};
|
|
80
125
|
}
|
|
81
126
|
|
|
@@ -104,9 +149,9 @@ export function reviewerTask(input: {
|
|
|
104
149
|
const lenses = input.lenses.join(", ");
|
|
105
150
|
return [
|
|
106
151
|
`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.`,
|
|
152
|
+
`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
153
|
"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:",
|
|
154
|
+
"Return exactly the review packet from your instructions, with exactly this header (slice and round must not change):",
|
|
110
155
|
"",
|
|
111
156
|
`## Review — ${input.slice} — round ${input.round} — lenses: ${lenses}`,
|
|
112
157
|
"### 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,28 +1,59 @@
|
|
|
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
|
-
const [diff, stat] = await Promise.all([
|
|
32
|
+
const [diff, stat, others] = await Promise.all([
|
|
19
33
|
exec("git", ["diff", range], { cwd, timeout: 15_000 }),
|
|
20
34
|
exec("git", ["diff", "--stat", range], { cwd, timeout: 15_000 }),
|
|
35
|
+
range === "HEAD"
|
|
36
|
+
? exec("git", ["ls-files", "--others", "--exclude-standard"], { cwd, timeout: 15_000 })
|
|
37
|
+
: Promise.resolve({ code: 0, stdout: "", stderr: "" }),
|
|
21
38
|
]);
|
|
22
39
|
if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
|
|
40
|
+
const untracked = others.code === 0 ? others.stdout.split("\n").filter((p) => p !== "") : [];
|
|
41
|
+
const files: Record<string, string> = {};
|
|
42
|
+
for (const [path, text] of Object.entries(splitDiffByFile(diff.stdout))) {
|
|
43
|
+
files[path] = digestOf(text);
|
|
44
|
+
}
|
|
45
|
+
if (untracked.length > 0) {
|
|
46
|
+
const shown = untracked.slice(0, MAX_UNTRACKED);
|
|
47
|
+
const hashes = await exec("git", ["hash-object", "--", ...shown], { cwd, timeout: 15_000 });
|
|
48
|
+
const lines = hashes.code === 0 ? hashes.stdout.split("\n").filter((l) => l !== "") : [];
|
|
49
|
+
for (const [i, path] of shown.entries()) files[path] = `new:${lines[i] ?? "unhashed"}`;
|
|
50
|
+
}
|
|
51
|
+
const names = Object.keys(files).sort();
|
|
52
|
+
const untrackedStat = untracked.map((p) => ` ${p} (untracked)`).join("\n");
|
|
23
53
|
return ok({
|
|
24
|
-
digest: digestOf(
|
|
25
|
-
|
|
54
|
+
digest: digestOf(names.map((p) => `${p}\0${files[p]}`).join("\n")),
|
|
55
|
+
files,
|
|
56
|
+
stat: [stat.stdout.trimEnd(), untrackedStat].filter((p) => p !== "").join("\n"),
|
|
26
57
|
sample: diff.stdout.slice(0, 16_000),
|
|
27
58
|
});
|
|
28
59
|
} catch (cause) {
|
|
@@ -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,13 +225,28 @@ 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);
|
|
224
250
|
const review = addRound(
|
|
225
251
|
{ ...before, required },
|
|
226
252
|
{
|
|
@@ -228,16 +254,25 @@ export function createReviewRecordTool(
|
|
|
228
254
|
findings,
|
|
229
255
|
reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
230
256
|
diffDigest: snap.value.digest,
|
|
257
|
+
files: snap.value.files,
|
|
231
258
|
},
|
|
232
259
|
);
|
|
233
|
-
deps.state.update((s) => upsertReview(s, review));
|
|
234
260
|
|
|
261
|
+
// File the nits first: if this fails nothing is recorded and a retry cannot count the round twice.
|
|
235
262
|
const followups = renderFollowups(slice, review.rounds.length, findings);
|
|
236
263
|
if (followups !== undefined) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
264
|
+
try {
|
|
265
|
+
const dir = join(ctx.cwd, "docs", "decisions");
|
|
266
|
+
await mkdir(dir, { recursive: true });
|
|
267
|
+
await appendFile(join(dir, "followups.md"), followups);
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
return reply(
|
|
270
|
+
`could not write docs/decisions/followups.md (${cause instanceof Error ? cause.message : String(cause)}); the round was not recorded, retry`,
|
|
271
|
+
true,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
240
274
|
}
|
|
275
|
+
deps.state.update((s) => upsertReview(s, review));
|
|
241
276
|
const counts = (sev: Finding["severity"]) =>
|
|
242
277
|
findings.filter((f) => f.severity === sev).length;
|
|
243
278
|
return reply(
|