@jwilger/pi-development-system 0.46.0 → 0.47.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.
@@ -1,3 +1,4 @@
1
+ // pi-lens-ignore: high-import-coupling -- composition root: it imports every module it wires, by design
1
2
  import { readFileSync } from "node:fs";
2
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import { cadenceLine, DEFAULT_PUSH_MINUTES } from "../src/context/cadence.ts";
@@ -34,6 +35,7 @@ const nonNegotiables = readFileSync(
34
35
  );
35
36
 
36
37
  /** Composition root: wires modules, holds no logic. */
38
+ // pi-lens-ignore: high-fan-out -- composition root: wiring every module is its whole job
37
39
  export function createDevelopmentSystem(pi: ExtensionAPI) {
38
40
  const state = createSessionState(pi);
39
41
  const approvals = createApprovalStore(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.46.0",
3
+ "version": "0.47.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"
@@ -1,5 +1,9 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ ToolResultEvent,
6
+ } from "@earendil-works/pi-coding-agent";
3
7
  import { redactSecrets } from "../core/redact.ts";
4
8
  import { exitCodeOf, summarizeOutput } from "../core/test-runner.ts";
5
9
  import type { DevsysState, Phase } from "../core/types.ts";
@@ -77,6 +81,46 @@ const note = (content: string) => ({
77
81
  display: true,
78
82
  });
79
83
 
84
+ /** What a finished tool call proves, for Jev: the target, the output tail and (for bash) the real exit code. */
85
+ function evidenceOf(event: ToolResultEvent): ToolEvidence {
86
+ const text = event.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
87
+ const summary = summarizeOutput(text);
88
+ const command = typeof event.input.command === "string" ? event.input.command : undefined;
89
+ // exitCodeOf sees through `npm test | tail`, which would otherwise show a failing run as exit 0.
90
+ const exitCode = exitCodeOf({
91
+ isError: event.isError,
92
+ text,
93
+ structured: event.structuredContent,
94
+ ...(command === undefined ? {} : { command }),
95
+ });
96
+ // Name what ran or which file: a check that passes silently (`tsc --noEmit`) prints nothing.
97
+ const target = redactSecrets(
98
+ command ?? (typeof event.input.path === "string" ? event.input.path : ""),
99
+ )
100
+ .replace(/\s+/g, " ")
101
+ .slice(0, TARGET_MAX);
102
+ const described = target === "" ? summary : `${target} → ${summary}`;
103
+ return event.toolName === "bash"
104
+ ? { tool: "bash", summary: described, exitCode }
105
+ : { tool: event.toolName, summary: described };
106
+ }
107
+
108
+ /** The corrective notes a judged turn earns: an unverified claim, and drift not yet covered by a departure. */
109
+ function correctionsFor(
110
+ judged: { unverifiedClaim: number; driftFromSlice: number },
111
+ state: DevsysState,
112
+ ) {
113
+ const slice = state.activeSlice;
114
+ const drifted =
115
+ judged.driftFromSlice >= VERIFIER_THRESHOLD &&
116
+ slice !== undefined &&
117
+ !expansionRecorded(state, slice);
118
+ return [
119
+ ...(judged.unverifiedClaim >= VERIFIER_THRESHOLD ? [note(claimMessage())] : []),
120
+ ...(drifted && slice !== undefined ? [note(driftMessage(slice))] : []),
121
+ ];
122
+ }
123
+
80
124
  /**
81
125
  * At turn end, asks Jev whether the final message claims results no tool call supports, or wanders
82
126
  * from the active slice, and forces ONE corrective continuation. Bounded: never on a turn that
@@ -98,28 +142,7 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
98
142
  justCorrected = false;
99
143
  });
100
144
  deps.pi.on("tool_result", (event) => {
101
- const text = event.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
102
- const summary = summarizeOutput(text);
103
- const command = typeof event.input.command === "string" ? event.input.command : undefined;
104
- // exitCodeOf sees through `npm test | tail`, which would otherwise show a failing run as exit 0.
105
- const exitCode = exitCodeOf({
106
- isError: event.isError,
107
- text,
108
- structured: event.structuredContent,
109
- ...(command === undefined ? {} : { command }),
110
- });
111
- // Name what ran or which file: a check that passes silently (`tsc --noEmit`) prints nothing.
112
- const target = redactSecrets(
113
- command ?? (typeof event.input.path === "string" ? event.input.path : ""),
114
- )
115
- .replace(/\s+/g, " ")
116
- .slice(0, TARGET_MAX);
117
- const described = target === "" ? summary : `${target} → ${summary}`;
118
- const item: ToolEvidence =
119
- event.toolName === "bash"
120
- ? { tool: "bash", summary: described, exitCode }
121
- : { tool: event.toolName, summary: described };
122
- evidence = [...evidence, item].slice(-MAX_EVIDENCE);
145
+ evidence = [...evidence, evidenceOf(event)].slice(-MAX_EVIDENCE);
123
146
  });
124
147
 
125
148
  deps.pi.on("turn_end", async (event, ctx) => {
@@ -142,14 +165,7 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
142
165
  ...(state.activeSlice === undefined ? {} : { activeSlice: state.activeSlice }),
143
166
  });
144
167
  if (!judged.ok) return undefined;
145
- const entries = [
146
- ...(judged.value.unverifiedClaim >= VERIFIER_THRESHOLD ? [note(claimMessage())] : []),
147
- ...(judged.value.driftFromSlice >= VERIFIER_THRESHOLD &&
148
- state.activeSlice !== undefined &&
149
- !expansionRecorded(state, state.activeSlice)
150
- ? [note(driftMessage(state.activeSlice))]
151
- : []),
152
- ];
168
+ const entries = correctionsFor(judged.value, state);
153
169
  if (entries.length === 0) return undefined;
154
170
  corrected += 1;
155
171
  justCorrected = true;
@@ -24,7 +24,8 @@ const worst = (a: GitIntent, b: GitIntent): GitIntent =>
24
24
  SEVERITY.indexOf(a) <= SEVERITY.indexOf(b) ? a : b;
25
25
 
26
26
  /** Splits a command string on newlines that are outside quotes; backslash-newline is a continuation. */
27
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
27
+ // biome-ignore-start lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
28
+ // pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
28
29
  export function splitLines(command: string): string[] {
29
30
  const lines: string[] = [];
30
31
  let current = "";
@@ -56,6 +57,7 @@ export function splitLines(command: string): string[] {
56
57
  lines.push(current);
57
58
  return lines;
58
59
  }
60
+ // biome-ignore-end lint/complexity/noExcessiveCognitiveComplexity: end of the shell lexer above
59
61
 
60
62
  const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("(");
61
63
 
@@ -216,6 +218,7 @@ function splitGlobals(rest: string[]): {
216
218
  return { globals, sub: rest[j], args: rest.slice(j + 1) };
217
219
  }
218
220
 
221
+ // pi-lens-ignore: high-fan-out -- the classifier dispatches one git word to many small predicates; it is a table, not coordination
219
222
  function classifySegment(tokens: string[]): GitIntent {
220
223
  const { index: i, hookSkip } = skipPrefix(tokens);
221
224
  if (i < 0) return "ordinary";
@@ -251,6 +254,7 @@ function classifySegment(tokens: string[]): GitIntent {
251
254
  * The parts of a line bash may still execute or treat as syntax: single-quoted text and
252
255
  * backslash-escaped characters are dropped; double-quoted text is kept unless `dropDouble`.
253
256
  */
257
+ // pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
254
258
  export function live(line: string, dropDouble: boolean): string {
255
259
  let out = "";
256
260
  let quote: "'" | '"' | undefined;
@@ -292,6 +296,7 @@ export function substitutions(text: string): string[] {
292
296
  const HEREDOC = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
293
297
 
294
298
  /** The delimiter of a heredoc started by an unquoted `<<` on this line, if any. */
299
+ // pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
295
300
  export function heredocDelimiter(line: string): string | undefined {
296
301
  let quote: "'" | '"' | undefined;
297
302
  for (let i = 0; i < line.length; i++) {
@@ -47,7 +47,7 @@ const direct = (matrix: ModelMatrix, tier: Tier): string[] =>
47
47
  * frontier but lives in strong). Undefined when it is in none of them.
48
48
  */
49
49
  export function tierOf(matrix: ModelMatrix, model: string): Tier | undefined {
50
- return [...TIERS].reverse().find((tier) => matches(direct(matrix, tier), model));
50
+ return TIERS.findLast((tier) => matches(direct(matrix, tier), model));
51
51
  }
52
52
 
53
53
  /** The best tier that lists the model at all: what a slot resolving to it is asking for. */
@@ -34,18 +34,23 @@ type ParsedArgs = {
34
34
 
35
35
  const VALUE_PREFIX = "--repo=";
36
36
 
37
+ function applyFlag(parsed: ParsedArgs, a: string): void {
38
+ if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
39
+ else if (a === "--all" || a === "--mirror") parsed.all = true;
40
+ else if (a === "--tags") parsed.tags = true;
41
+ else if (!a.startsWith("-")) parsed.positional.push(a);
42
+ }
43
+
37
44
  function positionalArgs(args: readonly string[]): ParsedArgs {
38
45
  const parsed: ParsedArgs = { positional: [], all: false, tags: false, repo: undefined };
39
46
  let valueFor: "repo" | "skip" | undefined;
40
47
  for (const a of args) {
41
- if (valueFor === "repo") parsed.repo = a;
42
- if (valueFor !== undefined) valueFor = undefined;
43
- else if (a === "--repo") valueFor = "repo";
44
- else if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
48
+ if (valueFor !== undefined) {
49
+ if (valueFor === "repo") parsed.repo = a;
50
+ valueFor = undefined;
51
+ } else if (a === "--repo") valueFor = "repo";
45
52
  else if (VALUE_OPTIONS.has(a)) valueFor = "skip";
46
- else if (a === "--all" || a === "--mirror") parsed.all = true;
47
- else if (a === "--tags") parsed.tags = true;
48
- else if (!a.startsWith("-")) parsed.positional.push(a);
53
+ else applyFlag(parsed, a);
49
54
  }
50
55
  return parsed;
51
56
  }
@@ -144,8 +144,11 @@ export function adoptSeverity(
144
144
  };
145
145
  }
146
146
 
147
- const where = (f: Finding): string =>
148
- f.path === undefined ? "" : ` \`${f.path}${f.line === undefined ? "" : `:${f.line}`}\``;
147
+ const where = (f: Finding): string => {
148
+ if (f.path === undefined) return "";
149
+ const line = f.line === undefined ? "" : `:${f.line}`;
150
+ return ` \`${f.path}${line}\``;
151
+ };
149
152
 
150
153
  /**
151
154
  * Nits are never stored. Each is reported back so the coordinator fixes it now or drops it
@@ -10,11 +10,7 @@ export type ReviewPacket = {
10
10
  readonly verdict: "no-blocking" | "blocking";
11
11
  };
12
12
 
13
- const SEP = "[—–-]";
14
- const HEADER = new RegExp(
15
- `^##\\s+Review\\s+${SEP}\\s+(.+?)\\s+${SEP}\\s+round\\s+(\\S+)\\s+${SEP}\\s+lenses:\\s*(.*)$`,
16
- "m",
17
- );
13
+ const HEADER = /^##\s+Review\s+[—–-]\s+(.+?)\s+[—–-]\s+round\s+(\S+)\s+[—–-]\s+lenses:\s*(.*)$/m;
18
14
  // Reviewers add text after the location (`fn`, a second location); the first backticked one is the finding's.
19
15
  const FINDING = /^-\s*\[([^\]]+)\]\s+(\S+)\s+(?:`([^`]+)`[^—–]*?\s+)?[—–-]\s+(.+)$/;
20
16
  // An indented line under a finding is its detail (trigger, fix), unless it is itself a finding.
@@ -24,8 +20,11 @@ const isDetail = (line: string): boolean =>
24
20
  // `until` ends a section; by default any heading does. Findings ends only at a known section so a stray
25
21
  // heading inside it (### Nits) makes its lines errors instead of silently cutting the findings off.
26
22
  const section = (text: string, name: string, until = /^###?\s/m): string | undefined => {
27
- const match = new RegExp(`^###\\s+${name}\\s*$`, "im").exec(text);
28
- if (match === null) return undefined;
23
+ const wanted = name.toLowerCase();
24
+ const match = [...text.matchAll(/^###\s+(.+?)\s*$/gim)].find(
25
+ (m) => m[1]?.toLowerCase() === wanted,
26
+ );
27
+ if (match === undefined) return undefined;
29
28
  const rest = text.slice(match.index + match[0].length);
30
29
  const next = until.exec(rest);
31
30
  return next === null ? rest : rest.slice(0, next.index);
@@ -65,6 +64,45 @@ function parseFinding(line: string, lens: string[], index: number): Finding | Pa
65
64
  };
66
65
  }
67
66
 
67
+ const FINDINGS_MISSING =
68
+ 'missing "### Findings" section: write "- none" under it when there are no findings';
69
+ const NO_FINDINGS = /^(?:-\s*)?[([]?(?:none|no findings?)[)\]]?\.?$/i;
70
+
71
+ /** The packet's verdict word, or the parse error explaining what is wrong with it. */
72
+ function verdictOf(text: string): "blocking" | "no-blocking" | ParseError {
73
+ const word = section(text, "Verdict")?.trim().split(/\s+/)[0]?.toLowerCase();
74
+ if (word === "no-blocking" || word === "blocking") return word;
75
+ return parseError(
76
+ 'missing or invalid verdict: end the packet with "### Verdict" then no-blocking or blocking',
77
+ );
78
+ }
79
+
80
+ /** Every finding line of the Findings section; a line that is not a finding is an error, never dropped. */
81
+ function findingsOf(text: string, lenses: string[]): Finding[] | ParseError {
82
+ const body = section(text, "Findings", AFTER_FINDINGS);
83
+ if (body === undefined) return parseError(FINDINGS_MISSING);
84
+ const lines = body
85
+ .split("\n")
86
+ .flatMap((l) => (isDetail(l) ? [] : [l.trim()]))
87
+ .filter((l) => l !== "" && !NO_FINDINGS.test(l));
88
+ const findings: Finding[] = [];
89
+ for (const [i, line] of lines.entries()) {
90
+ const finding = parseFinding(line, lenses, i + 1);
91
+ if (isParseError(finding)) return finding;
92
+ findings.push(finding);
93
+ }
94
+ return findings;
95
+ }
96
+
97
+ const isBlocking = (f: Finding): boolean =>
98
+ f.severity === "blocking" || f.severity === "should-fix";
99
+
100
+ const listOf = (text: string): string[] =>
101
+ text
102
+ .split(",")
103
+ .map((l) => l.trim())
104
+ .filter((l) => l !== "");
105
+
68
106
  /** Parse the reviewer packet of plan Appendix A. Strict about structure, tolerant of dash style. */
69
107
  export function parseReviewPacket(text: string): ReviewPacket | ParseError {
70
108
  const header = HEADER.exec(text);
@@ -75,51 +113,19 @@ export function parseReviewPacket(text: string): ReviewPacket | ParseError {
75
113
  if (!Number.isInteger(round) || round < 1) {
76
114
  return parseError(`round "${header[2]}" is not a positive integer`);
77
115
  }
78
- const lenses = (header[3] ?? "")
79
- .split(",")
80
- .map((l) => l.trim())
81
- .filter((l) => l !== "");
82
- const verdictText = section(text, "Verdict")?.trim().split(/\s+/)[0]?.toLowerCase();
83
- if (verdictText !== "no-blocking" && verdictText !== "blocking") {
84
- return parseError(
85
- 'missing or invalid verdict: end the packet with "### Verdict" then no-blocking or blocking',
86
- );
87
- }
88
- const findings: Finding[] = [];
89
- const findingsText = section(text, "Findings", AFTER_FINDINGS);
90
- if (findingsText === undefined) {
116
+ const lenses = listOf(header[3] ?? "");
117
+ const verdict = verdictOf(text);
118
+ if (isParseError(verdict)) return verdict;
119
+ const findings = findingsOf(text, lenses);
120
+ if (isParseError(findings)) return findings;
121
+ if (findings.some(isBlocking) !== (verdict === "blocking")) {
91
122
  return parseError(
92
- 'missing "### Findings" section: write "- none" under it when there are no findings',
93
- );
94
- }
95
- const lines = findingsText
96
- .split("\n")
97
- .filter((l) => !isDetail(l))
98
- .map((l) => l.trim())
99
- .filter((l) => l !== "" && !/^(?:-\s*)?[([]?(?:none|no findings?)[)\]]?\.?$/i.test(l));
100
- for (const [i, line] of lines.entries()) {
101
- const finding = parseFinding(line, lenses, i + 1);
102
- if (isParseError(finding)) return finding;
103
- findings.push(finding);
104
- }
105
- const hasBlocking = findings.some(
106
- (f) => f.severity === "blocking" || f.severity === "should-fix",
107
- );
108
- if (hasBlocking !== (verdictText === "blocking")) {
109
- return parseError(
110
- `verdict "${verdictText}" contradicts the findings: blocking/should-fix findings mean "blocking", otherwise "no-blocking"`,
123
+ `verdict "${verdict}" contradicts the findings: blocking/should-fix findings mean "blocking", otherwise "no-blocking"`,
111
124
  );
112
125
  }
113
126
  const sources = (section(text, "Sources inspected") ?? "")
114
127
  .split("\n")
115
128
  .map((l) => l.replace(/^-\s*/, "").trim())
116
129
  .filter((l) => l !== "");
117
- return {
118
- slice: (header[1] ?? "").trim(),
119
- round,
120
- lenses,
121
- sources,
122
- findings,
123
- verdict: verdictText,
124
- };
130
+ return { slice: (header[1] ?? "").trim(), round, lenses, sources, findings, verdict };
125
131
  }
@@ -18,22 +18,20 @@ const PATTERNS: readonly RegExp[] = [
18
18
  /_tests\.rs$/,
19
19
  ];
20
20
 
21
+ /** Whether `rest` matches some suffix of `path`; a `*` may not cross a `/` when `withinSegment`. */
22
+ function matchesSuffix(rest: string, path: string, withinSegment: boolean): boolean {
23
+ for (let i = 0; i <= path.length; i++) {
24
+ if (matchGlob(rest, path.slice(i))) return true;
25
+ if (withinSegment && (path[i] === "/" || i === path.length)) return false;
26
+ }
27
+ return false;
28
+ }
29
+
21
30
  /** Glob match without building a RegExp: `**` spans directories, `*` stays within a segment. */
22
31
  function matchGlob(glob: string, path: string): boolean {
23
32
  if (glob === "") return path === "";
24
- if (glob.startsWith("**")) {
25
- const rest = glob.slice(2);
26
- for (let i = 0; i <= path.length; i++) if (matchGlob(rest, path.slice(i))) return true;
27
- return false;
28
- }
29
- if (glob.startsWith("*")) {
30
- const rest = glob.slice(1);
31
- for (let i = 0; i <= path.length; i++) {
32
- if (matchGlob(rest, path.slice(i))) return true;
33
- if (path[i] === "/" || i === path.length) return false;
34
- }
35
- return false;
36
- }
33
+ if (glob.startsWith("**")) return matchesSuffix(glob.slice(2), path, false);
34
+ if (glob.startsWith("*")) return matchesSuffix(glob.slice(1), path, true);
37
35
  return path !== "" && path[0] === glob[0] && matchGlob(glob.slice(1), path.slice(1));
38
36
  }
39
37
 
@@ -9,17 +9,20 @@ const SUMMARY_MAX = 300;
9
9
 
10
10
  /** Words after wrappers (`time`, `timeout 60`, `env`), assignments and `+toolchain` selectors. */
11
11
  function commandWords(words: readonly string[]): string[] {
12
- let i = 0;
13
- while (i < words.length) {
14
- const word = words[i] ?? "";
15
- const wrapper = WRAPPERS.has(basename(word)) || basename(word) === "timeout";
16
- if (isAssignment(word)) i += 1;
17
- else if (wrapper) {
18
- i += 1;
19
- while (i < words.length && /^(?:-|\d+[smhd]?$)/.test(words[i] ?? "")) i += 1;
20
- } else break;
12
+ const flagOrDuration = (w: string | undefined): boolean => /^(?:-|\d+[smhd]?$)/.test(w ?? "");
13
+ let next = 0;
14
+ for (const [index, word] of words.entries()) {
15
+ if (index < next) continue;
16
+ if (isAssignment(word)) {
17
+ next = index + 1;
18
+ } else if (WRAPPERS.has(basename(word)) || basename(word) === "timeout") {
19
+ next = index + 1;
20
+ while (flagOrDuration(words[next])) next += 1;
21
+ } else {
22
+ return words.slice(index);
23
+ }
21
24
  }
22
- return words.slice(i);
25
+ return [];
23
26
  }
24
27
 
25
28
  function isRunner(words: readonly string[]): boolean {
@@ -125,7 +125,8 @@ type Segment = { words: string[]; truncates: string[] };
125
125
  type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
126
126
 
127
127
  /** Turns unquoted newlines into `;` (and joins backslash continuations) so shell-quote sees every command. */
128
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
128
+ // biome-ignore-start lint/complexity/noExcessiveCognitiveComplexity: a shell lexer is one flat branch per character class; splitting it would scatter a single state machine across helpers
129
+ // pi-lens-ignore: high-complexity -- a shell lexer is one flat branch per character class; splitting it would scatter one state machine across helpers
129
130
  function splitLines(command: string): string {
130
131
  let out = "";
131
132
  let quote: "'" | '"' | undefined;
@@ -155,6 +156,7 @@ function splitLines(command: string): string {
155
156
  }
156
157
  return out;
157
158
  }
159
+ // biome-ignore-end lint/complexity/noExcessiveCognitiveComplexity: end of the shell lexer above
158
160
 
159
161
  /** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
160
162
  function tokenize(command: string): Token[] {
@@ -174,7 +174,7 @@ const stagedThenEdited = (
174
174
  // re-staged from the work tree, which was reviewed. Message text is one argument and never equals a path.
175
175
  const named = upto
176
176
  .filter((i) => (i.sub === "commit" || i.sub === "add") && !isDryRun(i) && inThisDir(i, cwd))
177
- .flatMap((i) => i.args.filter((a) => !a.startsWith("-")).map(cleanPath));
177
+ .flatMap((i) => i.args.flatMap((a) => (a.startsWith("-") ? [] : [cleanPath(a)])));
178
178
  const covered = (name: string) => named.some((n) => name === n || name.startsWith(`${n}/`));
179
179
  return names.staged.filter(
180
180
  (name) => names.unstaged.includes(name) && !covered(name) && !DECISION_LOG.test(name),
@@ -1,8 +1,13 @@
1
- import type { GateId } from "../core/types.ts";
1
+ import { type GateId, isParseError, parseGateId } from "../core/types.ts";
2
2
  import type { SessionState } from "../state/session-state.ts";
3
3
 
4
4
  /** Open recorded departures for one soft gate: whether one applies now, and spending a once-scoped one. */
5
- export type DepartureUse = { hasOpen(): boolean; consume(): void };
5
+ export type DepartureUse = {
6
+ hasOpen(): boolean;
7
+ consume(): void;
8
+ /** True when an open departure covers this call; spends it when it is once-scoped. */
9
+ covers(): boolean;
10
+ };
6
11
 
7
12
  export function departureUse(state: SessionState, gate: GateId): DepartureUse {
8
13
  const applicable = () => {
@@ -11,18 +16,31 @@ export function departureUse(state: SessionState, gate: GateId): DepartureUse {
11
16
  (d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
12
17
  );
13
18
  };
14
- return {
15
- hasOpen: () => applicable().length > 0,
16
- consume() {
17
- const open = applicable();
18
- // A broader departure covers the call without being spent; only a lone once-scoped one is used up.
19
- if (open.some((d) => d.scope.kind !== "once")) return;
20
- const used = open[0];
21
- if (used === undefined) return;
22
- state.update((s) => ({
23
- ...s,
24
- openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
25
- }));
26
- },
19
+ const hasOpen = () => applicable().length > 0;
20
+ const consume = () => {
21
+ const open = applicable();
22
+ // A broader departure covers the call without being spent; only a lone once-scoped one is used up.
23
+ if (open.some((d) => d.scope.kind !== "once")) return;
24
+ const used = open[0];
25
+ if (used === undefined) return;
26
+ state.update((s) => ({
27
+ ...s,
28
+ openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
29
+ }));
27
30
  };
31
+ const covers = () => {
32
+ if (!hasOpen()) return false;
33
+ consume();
34
+ return true;
35
+ };
36
+ return { hasOpen, consume, covers };
37
+ }
38
+
39
+ /** The parsed gate id with its departures; `undefined` when the id is malformed (the guard stays off). */
40
+ export function openGate(
41
+ state: SessionState,
42
+ id: string,
43
+ ): { gate: GateId; departure: DepartureUse } | undefined {
44
+ const gate = parseGateId(id);
45
+ return isParseError(gate) ? undefined : { gate, departure: departureUse(state, gate) };
28
46
  }
@@ -10,9 +10,8 @@ import { findUnreasonedSuppressions, MIN_RATIONALE } from "../core/lint-suppress
10
10
  import { classifyPath } from "../core/path-class.ts";
11
11
  import { normalizeRepoPath } from "../core/test-paths.ts";
12
12
  import { applyEdits, normalizeText } from "../core/test-weakening.ts";
13
- import { type GateId, isParseError, parseGateId } from "../core/types.ts";
14
13
  import type { SessionState } from "../state/session-state.ts";
15
- import { departureUse } from "./departure-use.ts";
14
+ import { openGate } from "./departure-use.ts";
16
15
 
17
16
  export type LintSuppressionGuardDeps = { pi: ExtensionAPI; state: SessionState };
18
17
 
@@ -54,20 +53,16 @@ function resultingText(
54
53
  * (at least {@link MIN_RATIONALE} characters) or a recorded departure.
55
54
  */
56
55
  export function registerLintSuppressionGuard(deps: LintSuppressionGuardDeps): void {
57
- const parsed = parseGateId(GATE_ID);
58
- if (isParseError(parsed)) return;
59
- const gate: GateId = parsed;
60
- const departure = departureUse(deps.state, gate);
56
+ const opened = openGate(deps.state, GATE_ID);
57
+ if (opened === undefined) return;
58
+ const { departure } = opened;
61
59
 
62
60
  deps.pi.on("tool_call", (event, ctx) => {
63
61
  const change = resultingText(event, ctx.cwd);
64
62
  if (change === undefined) return undefined;
65
63
  const found = findUnreasonedSuppressions(change.before, change.after);
66
64
  if (found.length === 0) return undefined;
67
- if (departure.hasOpen()) {
68
- departure.consume();
69
- return undefined;
70
- }
65
+ if (departure.covers()) return undefined;
71
66
  const list = found.map((s) => `${s.marker} (line ${s.line})`).join(", ");
72
67
  return {
73
68
  block: true,
@@ -4,9 +4,8 @@ import { resolve } from "node:path";
4
4
  import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
5
5
  import { classifyPath } from "../core/path-class.ts";
6
6
  import { normalizeRepoPath } from "../core/test-paths.ts";
7
- import { type GateId, isParseError, parseGateId } from "../core/types.ts";
8
7
  import type { SessionState } from "../state/session-state.ts";
9
- import { departureUse } from "./departure-use.ts";
8
+ import { openGate } from "./departure-use.ts";
10
9
 
11
10
  export type RedFirstGuardDeps = { pi: ExtensionAPI; state: SessionState };
12
11
 
@@ -63,10 +62,9 @@ const JUDGED_EXEMPTIONS =
63
62
  * judged exemptions go through one recorded departure, which covers its whole slice.
64
63
  */
65
64
  export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
66
- const parsed = parseGateId(GATE_ID);
67
- if (isParseError(parsed)) return;
68
- const gate: GateId = parsed;
69
- const departure = departureUse(deps.state, gate);
65
+ const opened = openGate(deps.state, GATE_ID);
66
+ if (opened === undefined) return;
67
+ const { departure } = opened;
70
68
 
71
69
  deps.pi.on("tool_call", (event, ctx) => {
72
70
  if (!(isToolCallEventType("edit", event) || isToolCallEventType("write", event)))
@@ -77,10 +75,7 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
77
75
  const path = normalizeRepoPath(ctx.cwd, event.input.path, homedir());
78
76
  if (classifyPath(path) !== "source") return undefined;
79
77
  if (touchesInlineTest(event.input, () => existing(ctx.cwd, path))) return undefined;
80
- if (departure.hasOpen()) {
81
- departure.consume();
82
- return undefined;
83
- }
78
+ if (departure.covers()) return undefined;
84
79
  const seen =
85
80
  lastTestRun === undefined
86
81
  ? "no test run has been observed in this session"
@@ -99,129 +99,136 @@ function jevVerdict(
99
99
  : undefined;
100
100
  }
101
101
 
102
+ const parsedGate = (): GateId | undefined => {
103
+ const parsed = parseGateId(GATE_ID);
104
+ return isParseError(parsed) ? undefined : parsed;
105
+ };
106
+
107
+ const judge = async (
108
+ deps: TestGuardDeps,
109
+ change: Change,
110
+ ctx: ExtensionContext,
111
+ ): Promise<Verdict> => {
112
+ const deleted = change.after === undefined && change.rewritten === undefined;
113
+ const after = change.after ?? change.rewritten ?? "";
114
+ const signals = deleted ? NO_SIGNALS : weakeningSignals(change.before, after);
115
+ if (!deleted && isHarmlessAddition(change.before, after, signals)) return { kind: "allow" };
116
+ const deterministic = describeSignals(deleted, signals);
117
+ const judged = await judgeTestChange(deps.jev(ctx), {
118
+ path: change.path,
119
+ before: change.before,
120
+ after: after === "" ? undefined : redactSecrets(after),
121
+ });
122
+ const fromJev = judged.ok ? jevVerdict(judged.value, deterministic !== undefined) : undefined;
123
+ if (fromJev !== undefined) return fromJev;
124
+ return deterministic !== undefined
125
+ ? { kind: "require-departure", why: `this change ${deterministic}` }
126
+ : { kind: "allow" };
127
+ };
128
+
129
+ const bashChanges = (command: string, cwd: string): Change[] =>
130
+ bashMutations(command).flatMap(({ path: written, kind }): Change[] => {
131
+ const target = repoPath(cwd, written);
132
+ // Missing plain paths have nothing to weaken; globs and directories may still hide tests.
133
+ const touched = GLOB.test(target) || existsSync(join(cwd, target));
134
+ if (!(isTestPath(target) && touched)) return [];
135
+ const rewritten = kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
136
+ return [
137
+ { path: target, before: readIfExists(join(cwd, target)) ?? "", after: undefined, rewritten },
138
+ ];
139
+ });
140
+
141
+ const editChange = (
142
+ path: string,
143
+ before: string,
144
+ edits: ReadonlyArray<{ oldText: string; newText: string }>,
145
+ ): Change => {
146
+ const after = applyEdits(before, edits);
147
+ if (after !== undefined) return { path, before, after };
148
+ // The edit text does not match statically; let Jev read what the agent intends to write.
149
+ const intended = edits.map((e) => e.newText).join("\n");
150
+ return { path, before, after: undefined, rewritten: `(edit not matched statically) ${intended}` };
151
+ };
152
+
153
+ const fileChanges = (event: ToolCallEvent, cwd: string): Change[] => {
154
+ if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event))) return [];
155
+ const path = repoPath(cwd, event.input.path);
156
+ const raw = isTestPath(path) ? readIfExists(join(cwd, path)) : undefined;
157
+ if (raw === undefined) return [];
158
+ const before = normalizeText(raw);
159
+ return isToolCallEventType("write", event)
160
+ ? [{ path, before, after: normalizeText(event.input.content) }]
161
+ : [editChange(path, before, event.input.edits)];
162
+ };
163
+
164
+ const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] =>
165
+ isToolCallEventType("bash", event)
166
+ ? bashChanges(event.input.command, ctx.cwd)
167
+ : fileChanges(event, ctx.cwd);
168
+
169
+ type Call = { deps: TestGuardDeps; gate: GateId; event: ToolCallEvent; ctx: ExtensionContext };
170
+
171
+ const hardStop = async (
172
+ { deps, gate, event, ctx }: Call,
173
+ change: Change,
174
+ why: string,
175
+ ): Promise<{ block: true; reason: string } | undefined> => {
176
+ const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
177
+ if (deps.approvals.consume(gate, command)) return undefined;
178
+ const outcome = await requestHardStop({
179
+ pi: deps.pi,
180
+ ctx,
181
+ gate,
182
+ command,
183
+ why,
184
+ costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
185
+ toolCallId: event.toolCallId,
186
+ now: deps.now,
187
+ });
188
+ if (outcome.kind === "approved") return undefined;
189
+ return {
190
+ block: true,
191
+ reason:
192
+ outcome.kind === "unavailable"
193
+ ? `hard stop ${GATE_ID}: ${why} (${change.path}); requires user approval; run interactively`
194
+ : `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
195
+ };
196
+ };
197
+
198
+ const departureReason = (needing: ReadonlyArray<{ change: Change; why: string }>): string =>
199
+ `${GATE_ID}: ${needing[0]?.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
200
+ "weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure " +
201
+ `with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
202
+ "Otherwise fix the code, not the test.";
203
+
102
204
  /**
103
205
  * Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
104
206
  * departure; a change Jev reads as gaming a failing gate escalates to a hard stop.
105
207
  */
106
208
  export function registerTestGuard(deps: TestGuardDeps): void {
107
- const gate: GateId | undefined = (() => {
108
- const parsed = parseGateId(GATE_ID);
109
- return isParseError(parsed) ? undefined : parsed;
110
- })();
209
+ const gate = parsedGate();
111
210
  if (gate === undefined) return;
112
-
113
211
  const departure = departureUse(deps.state, gate);
114
212
 
115
- const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
116
- const deleted = change.after === undefined && change.rewritten === undefined;
117
- const after = change.after ?? change.rewritten ?? "";
118
- const signals = deleted ? NO_SIGNALS : weakeningSignals(change.before, after);
119
- if (!deleted && isHarmlessAddition(change.before, after, signals)) return { kind: "allow" };
120
- const deterministic = describeSignals(deleted, signals);
121
- const judged = await judgeTestChange(deps.jev(ctx), {
122
- path: change.path,
123
- before: change.before,
124
- after: after === "" ? undefined : redactSecrets(after),
125
- });
126
- const fromJev = judged.ok ? jevVerdict(judged.value, deterministic !== undefined) : undefined;
127
- if (fromJev !== undefined) return fromJev;
128
- return deterministic !== undefined
129
- ? { kind: "require-departure", why: `this change ${deterministic}` }
130
- : { kind: "allow" };
131
- };
132
-
133
- const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
134
- if (isToolCallEventType("bash", event)) {
135
- const { command } = event.input;
136
- return bashMutations(command).flatMap(({ path: written, kind }): Change[] => {
137
- const target = repoPath(ctx.cwd, written);
138
- // Missing plain paths have nothing to weaken; globs and directories may still hide tests.
139
- const touched = GLOB.test(target) || existsSync(join(ctx.cwd, target));
140
- if (!(isTestPath(target) && touched)) return [];
141
- const rewritten =
142
- kind === "overwrite" ? `(rewritten by shell command) ${command}` : undefined;
143
- return [
144
- {
145
- path: target,
146
- before: readIfExists(join(ctx.cwd, target)) ?? "",
147
- after: undefined,
148
- rewritten,
149
- },
150
- ];
151
- });
152
- }
153
- if (!(isToolCallEventType("write", event) || isToolCallEventType("edit", event))) return [];
154
- const path = repoPath(ctx.cwd, event.input.path);
155
- const raw = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
156
- if (raw === undefined) return [];
157
- const before = normalizeText(raw);
158
- if (isToolCallEventType("write", event)) {
159
- return [{ path, before, after: normalizeText(event.input.content) }];
160
- }
161
- const after = applyEdits(before, event.input.edits);
162
- if (after !== undefined) return [{ path, before, after }];
163
- // The edit text does not match statically; let Jev read what the agent intends to write.
164
- const intended = event.input.edits.map((e) => e.newText).join("\n");
165
- return [
166
- { path, before, after: undefined, rewritten: `(edit not matched statically) ${intended}` },
167
- ];
168
- };
169
-
170
- const hardStop = async (
171
- event: ToolCallEvent,
172
- ctx: ExtensionContext,
173
- change: Change,
174
- why: string,
175
- ): Promise<{ block: true; reason: string } | undefined> => {
176
- const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
177
- if (deps.approvals.consume(gate, command)) return undefined;
178
- const outcome = await requestHardStop({
179
- pi: deps.pi,
180
- ctx,
181
- gate,
182
- command,
183
- why,
184
- costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
185
- toolCallId: event.toolCallId,
186
- now: deps.now,
187
- });
188
- if (outcome.kind === "approved") return undefined;
189
- return {
190
- block: true,
191
- reason:
192
- outcome.kind === "unavailable"
193
- ? `hard stop ${GATE_ID}: ${why} (${change.path}); requires user approval; run interactively`
194
- : `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
195
- };
196
- };
197
-
198
213
  deps.pi.on("tool_call", async (event, ctx) => {
199
214
  const verdicts: Array<{ change: Change; verdict: Verdict }> = [];
200
215
  for (const change of collectChanges(event, ctx)) {
201
- verdicts.push({ change, verdict: await judge(change, ctx) });
216
+ verdicts.push({ change, verdict: await judge(deps, change, ctx) });
202
217
  }
203
218
  for (const { change, verdict } of verdicts) {
204
219
  if (verdict.kind !== "hard-stop") continue;
205
- const blocked = await hardStop(event, ctx, change, verdict.why);
220
+ const blocked = await hardStop({ deps, gate, event, ctx }, change, verdict.why);
206
221
  if (blocked !== undefined) return blocked;
207
222
  }
208
223
  const needing = verdicts.flatMap(({ change, verdict }) =>
209
224
  verdict.kind === "require-departure" ? [{ change, why: verdict.why }] : [],
210
225
  );
211
- const first = needing[0];
212
- if (first === undefined) return undefined;
226
+ if (needing.length === 0) return undefined;
213
227
  if (departure.hasOpen()) {
214
228
  // One departure covers every path in this single tool call.
215
229
  departure.consume();
216
230
  return undefined;
217
231
  }
218
- return {
219
- block: true,
220
- reason:
221
- `${GATE_ID}: ${first.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
222
- "weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure " +
223
- `with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
224
- "Otherwise fix the code, not the test.",
225
- };
232
+ return { block: true, reason: departureReason(needing) };
226
233
  });
227
234
  }
@@ -37,6 +37,45 @@ const PLAIN_DIFF = [
37
37
  "--ignore-submodules=dirty",
38
38
  ];
39
39
 
40
+ type GitReads = { diff: string; stat: string; listed: string[] };
41
+
42
+ const NO_OUTPUT = { code: 0, stdout: "", stderr: "" };
43
+
44
+ /** The three git reads a snapshot is made of, or the error that stopped them. */
45
+ async function readGit(exec: Exec, cwd: string, range: string): Promise<Result<GitReads, string>> {
46
+ // Plain, parseable output whatever the user's git config says: an external diff driver or
47
+ // forced colour removes the `diff --git` headers the per-file digests are cut from.
48
+ const [diff, stat, others] = await Promise.all([
49
+ exec("git", [...PLAIN_DIFF, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
50
+ exec("git", [...PLAIN_DIFF, "--stat", range], { cwd, timeout: 15_000 }),
51
+ // Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
52
+ range.includes("..")
53
+ ? Promise.resolve(NO_OUTPUT)
54
+ : exec(
55
+ "git",
56
+ ["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"],
57
+ { cwd, timeout: 15_000 },
58
+ ),
59
+ ]);
60
+ if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
61
+ if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
62
+ return ok({
63
+ diff: diff.stdout,
64
+ stat: stat.stdout,
65
+ listed: others.stdout.split("\0").filter((p) => p !== ""),
66
+ });
67
+ }
68
+
69
+ /** Per-file digests of the tracked diff; an unparseable non-empty diff is an error, never an empty digest. */
70
+ function trackedDigests(diff: string): Result<Record<string, string>, string> {
71
+ const files: Record<string, string> = {};
72
+ for (const [path, text] of Object.entries(splitDiffByFile(diff))) files[path] = fileDigest(text);
73
+ if (diff.trim() !== "" && Object.keys(files).length === 0) {
74
+ return err("could not read the diff: no per-file sections were found");
75
+ }
76
+ return ok(files);
77
+ }
78
+
40
79
  /**
41
80
  * The change in `range` (default `HEAD`: everything not yet committed) with its digest. `git diff`
42
81
  * leaves out untracked files, so for a range that includes the work tree they are listed and
@@ -48,41 +87,21 @@ export async function snapshotDiff(
48
87
  range: string,
49
88
  ): Promise<Result<DiffSnapshot, string>> {
50
89
  try {
51
- // Plain, parseable output whatever the user's git config says: an external diff driver or
52
- // forced colour removes the `diff --git` headers the per-file digests are cut from.
53
- const [diff, stat, others] = await Promise.all([
54
- exec("git", [...PLAIN_DIFF, "--full-index", "--no-renames", range], { cwd, timeout: 15_000 }),
55
- exec("git", [...PLAIN_DIFF, "--stat", range], { cwd, timeout: 15_000 }),
56
- // Any range that ends in the work tree (HEAD, HEAD~1, a branch) leaves untracked files out of git diff.
57
- !range.includes("..")
58
- ? exec(
59
- "git",
60
- ["-c", "core.quotePath=false", "ls-files", "-z", "--others", "--exclude-standard"],
61
- { cwd, timeout: 15_000 },
62
- )
63
- : Promise.resolve({ code: 0, stdout: "", stderr: "" }),
64
- ]);
65
- if (diff.code !== 0) return err(diff.stderr.trim() || `git diff ${range} failed`);
66
- if (others.code !== 0) return err(others.stderr.trim() || "git ls-files --others failed");
67
- const listed = others.stdout.split("\0").filter((p) => p !== "");
90
+ const git = await readGit(exec, cwd, range);
91
+ if (!git.ok) return git;
68
92
  // A nested repository is listed as `dir/` and has no blob to hash.
69
- const nested = listed.filter((p) => p.endsWith("/"));
70
- const untracked = listed.filter((p) => !p.endsWith("/"));
93
+ const nested = git.value.listed.filter((p) => p.endsWith("/"));
94
+ const untracked = git.value.listed.filter((p) => !p.endsWith("/"));
71
95
  if (untracked.length > MAX_UNTRACKED) {
72
96
  return err(
73
97
  `${untracked.length} untracked files (more than ${MAX_UNTRACKED}); stage (git add) or ignore some so they can be reviewed`,
74
98
  );
75
99
  }
76
- const files: Record<string, string> = {};
77
- for (const [path, text] of Object.entries(splitDiffByFile(diff.stdout))) {
78
- files[path] = fileDigest(text);
79
- }
80
- if (diff.stdout.trim() !== "" && Object.keys(files).length === 0) {
81
- return err("could not read the diff: no per-file sections were found");
82
- }
100
+ const tracked = trackedDigests(git.value.diff);
101
+ if (!tracked.ok) return tracked;
83
102
  const hashed = await hashUntracked(exec, cwd, untracked);
84
103
  if (!hashed.ok) return hashed;
85
- Object.assign(files, hashed.value);
104
+ const files = { ...tracked.value, ...hashed.value };
86
105
  const names = Object.keys(files).sort((a, b) => a.localeCompare(b));
87
106
  const untrackedStat = [
88
107
  ...untracked.map((p) => ` ${p} (untracked)`),
@@ -91,8 +110,8 @@ export async function snapshotDiff(
91
110
  return ok({
92
111
  digest: digestOf(names.map((p) => `${p}\0${files[p]}`).join("\n")),
93
112
  files,
94
- stat: [stat.stdout.trimEnd(), untrackedStat].filter((p) => p !== "").join("\n"),
95
- sample: diff.stdout.slice(0, 16_000),
113
+ stat: [git.value.stat.trimEnd(), untrackedStat].filter((p) => p !== "").join("\n"),
114
+ sample: git.value.diff.slice(0, 16_000),
96
115
  });
97
116
  } catch (cause) {
98
117
  return err(cause instanceof Error ? cause.message : String(cause));
@@ -118,6 +118,92 @@ function spawnPayload(input: {
118
118
  };
119
119
  }
120
120
 
121
+ type Reply = ReturnType<typeof reply>;
122
+ type Config = Extract<Awaited<ReturnType<typeof loadConfig>>, { ok: true }>["value"];
123
+ type Snapshot = Extract<Awaited<ReturnType<typeof snapshotDiff>>, { ok: true }>["value"];
124
+ type Prepared = { slice: SliceRef; config: Config; range: string; snap: Snapshot };
125
+
126
+ const requiredRounds = (config: Config): number =>
127
+ Math.max(config.review.requiredCleanRounds, config.review.minRounds);
128
+
129
+ const rangeOf = (given: string | undefined): string => given?.trim() || "HEAD";
130
+
131
+ /** The slice, config and diff snapshot both tools need, or the error reply that explains why not. */
132
+ async function prepare(
133
+ deps: ReviewToolDeps,
134
+ ctx: ExtensionContext,
135
+ given: { slice?: string | undefined; diffRange?: string | undefined },
136
+ failure: (range: string, error: string) => string,
137
+ ): Promise<{ ok: true; value: Prepared } | { ok: false; reply: Reply }> {
138
+ const slice = sliceOf(given.slice, deps.state);
139
+ if (slice === undefined) return { ok: false, reply: reply(NO_SLICE, true) };
140
+ const config = await loadConfig(ctx.cwd);
141
+ if (!config.ok)
142
+ return { ok: false, reply: reply(`${CONFIG_FILE}: ${config.error.message}`, true) };
143
+ const range = rangeOf(given.diffRange);
144
+ const snap = await snapshotDiff(deps.exec, ctx.cwd, range);
145
+ if (!snap.ok) return { ok: false, reply: reply(failure(range, snap.error), true) };
146
+ return { ok: true, value: { slice, config: config.value, range, snap: snap.value } };
147
+ }
148
+
149
+ const rangeNote = (range: string): string => (range === "HEAD" ? "" : ` ${GATE_RANGE_NOTE}`);
150
+
151
+ /** The reply for a round that must not start: already satisfied, or findings still open. */
152
+ function startRefusal(review: ReviewState, p: Prepared): Reply | undefined {
153
+ const action = nextAction(review, p.snap.digest);
154
+ if (action === "done") {
155
+ return reply(
156
+ `${reviewLabel(review)}. Review of ${p.slice} is already satisfied on this diff; nothing to run.${rangeNote(p.range)}`,
157
+ );
158
+ }
159
+ if (action === "fix-findings") {
160
+ return reply(
161
+ `${reviewLabel(review)}. Round ${review.rounds.length} of ${p.slice} has blocking or should-fix findings and the diff has not changed since: fix them first, then start again. ` +
162
+ "Re-running the review on unchanged code does not clear a finding. If a finding is wrong, record a `review.unsatisfied` departure with the evidence (devsys_record_departure).",
163
+ true,
164
+ );
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ async function startRound(
170
+ deps: ReviewToolDeps,
171
+ ctx: ExtensionContext,
172
+ p: Prepared,
173
+ ): Promise<Reply> {
174
+ const judged = await judgeLenses(deps.jev(ctx), {
175
+ diffStat: p.snap.stat,
176
+ diffSample: p.snap.sample,
177
+ profiles: deps.state.get().profiles ?? [],
178
+ });
179
+ const { lenses, basis } = chooseLenses(judged);
180
+ const required = requiredRounds(p.config);
181
+ const existing = reviewOf(deps.state.get(), p.slice) ?? startReview(p.slice, required);
182
+ const review: ReviewState = { ...existing, required };
183
+ deps.state.update((s) => upsertReview(s, review));
184
+ const refusal = startRefusal(review, p);
185
+ if (refusal !== undefined) return refusal;
186
+
187
+ const round = review.rounds.length + 1;
188
+ const spawn = spawnPayload({
189
+ slice: p.slice,
190
+ round,
191
+ lenses,
192
+ range: p.range,
193
+ model: resolveSlot(p.config.models, "reviewer", availableModels(ctx.modelRegistry)),
194
+ now: deps.now?.() ?? new Date(),
195
+ });
196
+ const rangeArg = p.range === "HEAD" ? "" : ` and diffRange "${p.range}"`;
197
+ return reply(
198
+ [
199
+ `${reviewLabel(review)}; round ${round}; diff ${p.snap.digest}. ${basis}`,
200
+ `lenses: ${lenses.join(", ")}`,
201
+ `Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record with slice "${p.slice}", diffDigest "${p.snap.digest}"${rangeArg}:`,
202
+ JSON.stringify(spawn),
203
+ ].join("\n"),
204
+ );
205
+ }
206
+
121
207
  /** `devsys_review_start`: Jev picks lenses for the diff; the reply carries the agent_spawn payload for a fresh reviewer. */
122
208
  export function createReviewStartTool(
123
209
  deps: ReviewToolDeps,
@@ -137,62 +223,16 @@ export function createReviewStartTool(
137
223
  _onUpdate,
138
224
  ctx: ExtensionContext,
139
225
  ) {
140
- const slice = sliceOf(params.slice, deps.state);
141
- if (slice === undefined) return reply(NO_SLICE, true);
142
- const config = await loadConfig(ctx.cwd);
143
- if (!config.ok) return reply(`${CONFIG_FILE}: ${config.error.message}`, true);
144
- const range = params.diffRange?.trim() || "HEAD";
145
- const snap = await snapshotDiff(deps.exec, ctx.cwd, range);
146
- if (!snap.ok) return reply(`cannot read the diff for ${range}: ${snap.error}`, true);
147
- if (snap.value.stat.trim() === "")
148
- return reply(`the diff for ${range} is empty; nothing to review`, true);
149
-
150
- const judged = await judgeLenses(deps.jev(ctx), {
151
- diffStat: snap.value.stat,
152
- diffSample: snap.value.sample,
153
- profiles: deps.state.get().profiles ?? [],
154
- });
155
- const { lenses, basis } = chooseLenses(judged);
156
-
157
- const required = Math.max(
158
- config.value.review.requiredCleanRounds,
159
- config.value.review.minRounds,
160
- );
161
- const existing = reviewOf(deps.state.get(), slice) ?? startReview(slice, required);
162
- const review: ReviewState = { ...existing, required };
163
- deps.state.update((s) => upsertReview(s, review));
164
- const action = nextAction(review, snap.value.digest);
165
- if (action === "done") {
166
- return reply(
167
- `${reviewLabel(review)}. Review of ${slice} is already satisfied on this diff; nothing to run.${range === "HEAD" ? "" : ` ${GATE_RANGE_NOTE}`}`,
168
- );
169
- }
170
-
171
- if (action === "fix-findings") {
172
- return reply(
173
- `${reviewLabel(review)}. Round ${review.rounds.length} of ${slice} has blocking or should-fix findings and the diff has not changed since: fix them first, then start again. ` +
174
- "Re-running the review on unchanged code does not clear a finding. If a finding is wrong, record a `review.unsatisfied` departure with the evidence (devsys_record_departure).",
175
- true,
176
- );
177
- }
178
-
179
- const round = review.rounds.length + 1;
180
- const spawn = spawnPayload({
181
- slice,
182
- round,
183
- lenses,
184
- range,
185
- model: resolveSlot(config.value.models, "reviewer", availableModels(ctx.modelRegistry)),
186
- now: deps.now?.() ?? new Date(),
187
- });
188
- return reply(
189
- [
190
- `${reviewLabel(review)}; round ${round}; diff ${snap.value.digest}. ${basis}`,
191
- `lenses: ${lenses.join(", ")}`,
192
- `Spawn the reviewer with agent_spawn using exactly this payload, then pass its packet to devsys_review_record with slice "${slice}", diffDigest "${snap.value.digest}"${range === "HEAD" ? "" : ` and diffRange "${range}"`}:`,
193
- JSON.stringify(spawn),
194
- ].join("\n"),
226
+ const prepared = await prepare(
227
+ deps,
228
+ ctx,
229
+ params,
230
+ (range, error) => `cannot read the diff for ${range}: ${error}`,
195
231
  );
232
+ if (!prepared.ok) return prepared.reply;
233
+ if (prepared.value.snap.stat.trim() === "")
234
+ return reply(`the diff for ${prepared.value.range} is empty; nothing to review`, true);
235
+ return startRound(deps, ctx, prepared.value);
196
236
  },
197
237
  };
198
238
  }
@@ -233,7 +273,82 @@ async function adjusted(
233
273
  return { findings: out, notes };
234
274
  }
235
275
 
236
- /** `devsys_review_record`: parse packets, let Jev adjust severities, record the round, file the nits. */
276
+ /** Parses every packet, or the reply naming the first malformed one. */
277
+ function parsePackets(
278
+ raw: readonly string[],
279
+ ): { ok: true; value: ReviewPacket[] } | { ok: false; reply: Reply } {
280
+ if (raw.length === 0)
281
+ return {
282
+ ok: false,
283
+ reply: reply("no packets: pass the reviewer's packet markdown in `packets`", true),
284
+ };
285
+ const packets: ReviewPacket[] = [];
286
+ for (const [i, text] of raw.entries()) {
287
+ const parsed = parseReviewPacket(text);
288
+ if (isParseError(parsed))
289
+ return { ok: false, reply: reply(`packet ${i + 1} is malformed: ${parsed.message}`, true) };
290
+ packets.push(parsed);
291
+ }
292
+ return { ok: true, value: packets };
293
+ }
294
+
295
+ /** The refusal for a packet written for another slice or round, if there is one. */
296
+ function wrongPacket(packets: readonly ReviewPacket[], slice: SliceRef, expected: number) {
297
+ const wrong = packets.findIndex((p) => p.round !== expected || p.slice !== slice);
298
+ if (wrong === -1) return undefined;
299
+ return reply(
300
+ `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.`,
301
+ true,
302
+ );
303
+ }
304
+
305
+ const countOf = (findings: readonly Finding[], sev: Finding["severity"]): number =>
306
+ findings.filter((f) => f.severity === sev).length;
307
+
308
+ function recordSummary(
309
+ review: ReviewState,
310
+ findings: readonly Finding[],
311
+ notes: readonly string[],
312
+ p: Prepared,
313
+ ): string {
314
+ return [
315
+ `Round ${review.rounds.length} recorded: ${countOf(findings, "blocking")} blocking, ${countOf(findings, "should-fix")} should-fix, ${countOf(findings, "nit")} nit, ${countOf(findings, "false-positive")} false-positive.`,
316
+ reviewLabel(review),
317
+ ...notes,
318
+ ...nitLines(findings),
319
+ `next: ${nextAction(review, p.snap.digest)}`,
320
+ ...(p.range === "HEAD" ? [] : [GATE_RANGE_NOTE]),
321
+ ].join("\n");
322
+ }
323
+
324
+ async function recordRound(
325
+ deps: ReviewToolDeps,
326
+ ctx: ExtensionContext,
327
+ p: Prepared,
328
+ packets: readonly ReviewPacket[],
329
+ ): Promise<Reply> {
330
+ const required = requiredRounds(p.config);
331
+ const before = reviewOf(deps.state.get(), p.slice) ?? startReview(p.slice, required);
332
+ const refusal = wrongPacket(packets, p.slice, before.rounds.length + 1);
333
+ if (refusal !== undefined) return refusal;
334
+
335
+ const merged = combinePackets(packets);
336
+ const { findings, notes } = await adjusted(deps, ctx, merged.findings, p.snap.sample);
337
+ const review = addRound(
338
+ { ...before, required },
339
+ {
340
+ lenses: merged.lenses,
341
+ findings,
342
+ reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
343
+ diffDigest: p.snap.digest,
344
+ files: p.snap.files,
345
+ },
346
+ );
347
+ deps.state.update((s) => upsertReview(s, review));
348
+ return reply(recordSummary(review, findings, notes, p));
349
+ }
350
+
351
+ /** `devsys_review_record`: parse packets, let Jev adjust severities, record the round. */
237
352
  export function createReviewRecordTool(
238
353
  deps: ReviewToolDeps,
239
354
  ): ToolDefinition<typeof RecordParameters> {
@@ -252,69 +367,23 @@ export function createReviewRecordTool(
252
367
  _onUpdate,
253
368
  ctx: ExtensionContext,
254
369
  ) {
255
- const slice = sliceOf(params.slice, deps.state);
256
- if (slice === undefined) return reply(NO_SLICE, true);
257
- if (params.packets.length === 0)
258
- return reply("no packets: pass the reviewer's packet markdown in `packets`", true);
259
- const packets: ReviewPacket[] = [];
260
- for (const [i, raw] of params.packets.entries()) {
261
- const parsed = parseReviewPacket(raw);
262
- if (isParseError(parsed))
263
- return reply(`packet ${i + 1} is malformed: ${parsed.message}`, true);
264
- packets.push(parsed);
265
- }
266
- const config = await loadConfig(ctx.cwd);
267
- if (!config.ok) return reply(`${CONFIG_FILE}: ${config.error.message}`, true);
268
- const range = params.diffRange?.trim() || "HEAD";
269
- const snap = await snapshotDiff(deps.exec, ctx.cwd, range);
270
- if (!snap.ok) return reply(`cannot read the diff: ${snap.error}`, true);
271
-
272
- if (params.diffDigest !== snap.value.digest) {
273
- return reply(
274
- `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.`,
275
- true,
276
- );
277
- }
278
- const required = Math.max(
279
- config.value.review.requiredCleanRounds,
280
- config.value.review.minRounds,
370
+ if (sliceOf(params.slice, deps.state) === undefined) return reply(NO_SLICE, true);
371
+ const packets = parsePackets(params.packets);
372
+ if (!packets.ok) return packets.reply;
373
+ const prepared = await prepare(
374
+ deps,
375
+ ctx,
376
+ params,
377
+ (_range, error) => `cannot read the diff: ${error}`,
281
378
  );
282
- const before = reviewOf(deps.state.get(), slice) ?? startReview(slice, required);
283
- const expected = before.rounds.length + 1;
284
- const wrong = packets.findIndex((p) => p.round !== expected || p.slice !== slice);
285
- if (wrong !== -1) {
379
+ if (!prepared.ok) return prepared.reply;
380
+ if (params.diffDigest !== prepared.value.snap.digest) {
286
381
  return reply(
287
- `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.`,
382
+ `the diff changed since the review started (reviewed ${params.diffDigest}, now ${prepared.value.snap.digest}); the reviewer did not see the current code. Run devsys_review_start again.`,
288
383
  true,
289
384
  );
290
385
  }
291
-
292
- const merged = combinePackets(packets);
293
- const { findings, notes } = await adjusted(deps, ctx, merged.findings, snap.value.sample);
294
- const reviewed = snap.value;
295
- const review = addRound(
296
- { ...before, required },
297
- {
298
- lenses: merged.lenses,
299
- findings,
300
- reviewedAt: (deps.now?.() ?? new Date()).toISOString(),
301
- diffDigest: reviewed.digest,
302
- files: reviewed.files,
303
- },
304
- );
305
- deps.state.update((s) => upsertReview(s, review));
306
- const counts = (sev: Finding["severity"]) =>
307
- findings.filter((f) => f.severity === sev).length;
308
- return reply(
309
- [
310
- `Round ${review.rounds.length} recorded: ${counts("blocking")} blocking, ${counts("should-fix")} should-fix, ${counts("nit")} nit, ${counts("false-positive")} false-positive.`,
311
- reviewLabel(review),
312
- ...notes,
313
- ...nitLines(findings),
314
- `next: ${nextAction(review, reviewed.digest)}`,
315
- ...(range === "HEAD" ? [] : [GATE_RANGE_NOTE]),
316
- ].join("\n"),
317
- );
386
+ return recordRound(deps, ctx, prepared.value, packets.value);
318
387
  },
319
388
  };
320
389
  }