@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.18.0",
3
+ "version": "0.19.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,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 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
+ 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
- /** Take Jev's severity only when it is confident and different; say what moved so it is never silent. */
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 ${judged.confidence.toFixed(2)})`,
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",
@@ -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,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 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([
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(diff.stdout),
25
- stat: stat.stdout,
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
- 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,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
- 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);
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
- const dir = join(ctx.cwd, "docs", "decisions");
238
- await mkdir(dir, { recursive: true });
239
- await appendFile(join(dir, "followups.md"), followups);
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(