@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -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`, i.e. everything uncommitted). It computes the diff digest, lets Jev
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: [...]`; one packet per lens reviewer if you split them).
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 move a finding's severity when it is at least 0.8 confident; the reply
51
- says what moved. A round is clean when it has no blocking or should-fix findings.
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.
@@ -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 function reviewGap(
29
- state: DevsysState,
30
- slice: SliceRef,
31
- digestNow: string,
32
- ): string | undefined {
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, digestNow)) {
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
- /** Take Jev's severity only when it is confident and different; say what moved so it is never silent. */
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 ${judged.confidence.toFixed(2)})`,
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",
@@ -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
- return { n, lenses: lenses as string[], findings: parsed, reviewedAt, diffDigest };
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(deps.state.get(), activeSlice, snap.ok ? snap.value.digest : "unknown");
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
 
@@ -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 a diff's text. Pure. */
6
- export const digestOf = (diff: string): string =>
7
- createHash("sha256").update(diff).digest("hex").slice(0, 16);
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 = { digest: string; stat: string; sample: string };
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
- /** The diff for `range` (default: everything not yet committed, `HEAD`), with its digest. */
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([
19
- exec("git", ["diff", range], { cwd, timeout: 15_000 }),
20
- exec("git", ["diff", "--stat", range], { cwd, timeout: 15_000 }),
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(diff.stdout),
25
- stat: stat.stdout,
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
- const pathSafe = (slice: string): string => slice.toLowerCase().replace(/[^a-z0-9]+/g, "-");
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: `/review-${pathSafe(slice)}-r${round}`,
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
- "Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record:",
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
- const merged = combinePackets(packets);
218
- const { findings, notes } = await adjusted(deps, ctx, merged.findings, snap.value.sample);
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: snap.value.digest,
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, snap.value.digest)}`,
289
+ `next: ${nextAction(review, reviewed.digest)}`,
249
290
  ].join("\n"),
250
291
  );
251
292
  },