@jwilger/pi-development-system 0.3.0 → 0.3.2

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.
@@ -31,14 +31,15 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
31
31
 
32
32
  const rebuild = (ctx: ExtensionContext) => {
33
33
  lastCtx = ctx;
34
- const probe = jevHolder.probe(ctx);
35
- jevModel = probe.model;
36
- state.update((s) => (s.jev === probe.availability ? s : { ...s, jev: probe.availability }));
37
34
  const entries = ctx.sessionManager
38
35
  .getBranch()
39
36
  .flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : []));
37
+ // Restore persisted state first: updating before it would append an empty state entry that wins.
40
38
  state.rebuildFrom(entries);
41
39
  approvals.rebuildFrom(entries);
40
+ const probe = jevHolder.probe(ctx);
41
+ jevModel = probe.model;
42
+ state.update((s) => (s.jev === probe.availability ? s : { ...s, jev: probe.availability }));
42
43
  refreshStatus();
43
44
  };
44
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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,7 +1,10 @@
1
+ import { isAbsolute, join, relative, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
1
4
  export type TestPathProfile = { readonly testGlobs?: readonly string[] };
2
5
 
3
6
  const PATTERNS: readonly RegExp[] = [
4
- /(^|\/)(test|tests|__tests__|spec|specs|e2e)\//,
7
+ /(^|\/)(test|tests|__tests__|spec|specs|e2e)(\/|$)/,
5
8
  /\.(test|spec|cy)\.[cm]?[jt]sx?$/,
6
9
  /_test\.(go|rb|py|exs?|rs)$/,
7
10
  /(^|\/)test_[^/]+\.py$/,
@@ -25,3 +28,15 @@ export function isTestPath(path: string, profile?: TestPathProfile): boolean {
25
28
  if (PATTERNS.some((p) => p.test(normalized))) return true;
26
29
  return (profile?.testGlobs ?? []).some((g) => globToRegExp(g).test(normalized));
27
30
  }
31
+
32
+ /**
33
+ * Repo-relative form of a path exactly as pi would resolve it: a leading `@` is dropped, `~` expands
34
+ * to the home directory and `file://` URLs are decoded, so guards see the file that is really touched.
35
+ */
36
+ export function normalizeRepoPath(cwd: string, input: string, home: string): string {
37
+ let path = input.startsWith("@") ? input.slice(1) : input;
38
+ if (path === "~") path = home;
39
+ else if (path.startsWith("~/")) path = join(home, path.slice(2));
40
+ else if (/^file:\/\//.test(path)) path = fileURLToPath(path);
41
+ return isAbsolute(path) ? relative(cwd, path) : relative(cwd, resolve(cwd, path)) || ".";
42
+ }
@@ -1,24 +1,59 @@
1
+ import { posix } from "node:path";
1
2
  import { parse } from "shell-quote";
2
3
 
3
- export type WeakeningSignals = { addsSkip: boolean; emptied: boolean };
4
+ export type WeakeningSignals = { addsSkip: boolean; emptied: boolean; commentedOut: boolean };
4
5
 
5
6
  const SKIP_MARKERS: readonly RegExp[] = [
6
- /\b(?:it|test|describe|context)\.(?:skip|todo)\b/,
7
+ /\b(?:it|test|describe|context)\.(?:skip|todo|only)\b/,
8
+ /\bf(?:it|describe)\(/,
7
9
  /\bx(?:it|describe|test)\(/,
8
10
  /#\[ignore\b/,
9
- /@pytest\.mark\.skip|@unittest\.skip|\bpytest\.skip\(/,
11
+ /@pytest\.mark\.(?:skip|skipif|xfail)|@unittest\.skip|\bpytest\.skip\(/,
10
12
  /\bt\.Skip(?:Now|f)?\(/,
11
13
  /@Disabled\b|@Ignore\b/,
14
+ /\bprocess\.exit\(/,
12
15
  ];
13
16
 
14
17
  const countSkips = (text: string): number =>
15
18
  SKIP_MARKERS.reduce((n, re) => n + (text.match(new RegExp(re.source, "g"))?.length ?? 0), 0);
16
19
 
20
+ /** Lines that are neither blank, inside a C-style block comment, nor a line comment. */
21
+ function codeLineCount(text: string): number {
22
+ let inBlock = false;
23
+ let count = 0;
24
+ for (const raw of text.split("\n")) {
25
+ let line = raw.trim();
26
+ let code = false;
27
+ while (line !== "") {
28
+ if (inBlock) {
29
+ const end = line.indexOf("*/");
30
+ if (end < 0) break;
31
+ inBlock = false;
32
+ line = line.slice(end + 2).trim();
33
+ } else if (line.startsWith("//") || line.startsWith("#")) {
34
+ break;
35
+ } else if (line.startsWith("/*")) {
36
+ inBlock = true;
37
+ line = line.slice(2);
38
+ } else {
39
+ code = true;
40
+ const start = line.indexOf("/*");
41
+ if (start < 0) break;
42
+ inBlock = true;
43
+ line = line.slice(start + 2);
44
+ }
45
+ }
46
+ if (code) count++;
47
+ }
48
+ return count;
49
+ }
50
+
17
51
  /** Deterministic weakening signals between two versions of a test file. */
18
52
  export function weakeningSignals(before: string, after: string): WeakeningSignals {
19
53
  return {
20
54
  addsSkip: countSkips(after) > countSkips(before),
21
55
  emptied: before.trim() !== "" && after.trim() === "",
56
+ commentedOut: codeLineCount(after) < codeLineCount(before) && isPureAddition(before, after),
22
57
  };
23
58
  }
24
59
 
@@ -45,24 +80,223 @@ export function isPureAddition(before: string, after: string): boolean {
45
80
  return true;
46
81
  }
47
82
 
48
- const DELETERS = new Set(["rm", "unlink", "trash", "rmdir"]);
83
+ /** Lines of `after` not matched by a line of `before` (multiset difference, order preserved). */
84
+ export function addedLines(before: string, after: string): string[] {
85
+ const remaining = new Map<string, number>();
86
+ for (const line of before.split("\n")) remaining.set(line, (remaining.get(line) ?? 0) + 1);
87
+ return after.split("\n").filter((line) => {
88
+ const left = remaining.get(line) ?? 0;
89
+ if (left === 0) return true;
90
+ remaining.set(line, left - 1);
91
+ return false;
92
+ });
93
+ }
94
+
95
+ type Segment = { words: string[]; truncates: string[] };
96
+ type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
97
+
98
+ /** Turns unquoted newlines into `;` (and joins backslash continuations) so shell-quote sees every command. */
99
+ function splitLines(command: string): string {
100
+ let out = "";
101
+ let quote: "'" | '"' | undefined;
102
+ for (let i = 0; i < command.length; i++) {
103
+ const c = command.charAt(i);
104
+ if (quote === undefined && c === "\\" && command.charAt(i + 1) === "\n") {
105
+ out += " ";
106
+ i++;
107
+ } else if (quote === undefined && c === "\\") {
108
+ out += c + command.charAt(i + 1);
109
+ i++;
110
+ } else if (quote === undefined && (c === "'" || c === '"')) {
111
+ quote = c;
112
+ out += c;
113
+ } else if (quote === c) {
114
+ quote = undefined;
115
+ out += c;
116
+ } else if (quote === undefined && c === "\n") {
117
+ out += ";";
118
+ } else out += c;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
124
+ function tokenize(command: string): Token[] {
125
+ return parse(splitLines(command)).flatMap((entry): Token[] => {
126
+ if (typeof entry === "string") return [{ kind: "word", value: entry }];
127
+ if ("pattern" in entry) return [{ kind: "word", value: entry.pattern }];
128
+ if ("op" in entry) return [{ kind: "op", op: entry.op }];
129
+ return [];
130
+ });
131
+ }
132
+
133
+ const TRUNCATING = new Set([">", ">|", "&>"]);
134
+ const NON_TRUNCATING_REDIRECTS = new Set([">>", "&>>", "<", ">&", "<&"]);
49
135
 
50
- /** Paths a bash command removes via rm / unlink / `git rm` (best effort, per simple command). */
51
- export function bashDeletedPaths(command: string): string[] {
52
- const paths: string[] = [];
53
- let current: string[] = [];
136
+ /** Splits a shell line into simple commands; `>` targets are kept apart from arguments. */
137
+ function segments(command: string): Segment[] {
138
+ const out: Segment[] = [];
139
+ let words: string[] = [];
140
+ let truncates: string[] = [];
141
+ let redirect: "none" | "truncate" | "other" = "none";
54
142
  const flush = () => {
55
- const head = current[0] === "git" && current[1] === "rm" ? "rm" : current[0];
56
- const args = current[0] === "git" ? current.slice(2) : current.slice(1);
57
- if (head !== undefined && DELETERS.has(head)) {
58
- paths.push(...args.filter((a) => !a.startsWith("-")));
59
- }
60
- current = [];
143
+ if (words.length > 0 || truncates.length > 0) out.push({ words, truncates });
144
+ words = [];
145
+ truncates = [];
146
+ redirect = "none";
61
147
  };
62
- for (const token of parse(command)) {
63
- if (typeof token === "string") current.push(token);
148
+ for (const token of tokenize(command)) {
149
+ if (token.kind === "word") {
150
+ if (redirect === "truncate") truncates.push(token.value);
151
+ else if (redirect === "none") words.push(token.value);
152
+ redirect = "none";
153
+ } else if (TRUNCATING.has(token.op)) redirect = "truncate";
154
+ else if (NON_TRUNCATING_REDIRECTS.has(token.op)) redirect = "other";
64
155
  else flush();
65
156
  }
66
157
  flush();
67
- return paths;
158
+ return out;
159
+ }
160
+
161
+ const WRAPPERS = new Set(["sudo", "env", "command", "nohup", "nice", "time", "exec", "doas"]);
162
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash"]);
163
+ const KNOWN = new Set([
164
+ ...WRAPPERS,
165
+ ...SHELLS,
166
+ "rm",
167
+ "unlink",
168
+ "trash",
169
+ "rmdir",
170
+ "mv",
171
+ "git",
172
+ "truncate",
173
+ "find",
174
+ "sed",
175
+ "tee",
176
+ "cp",
177
+ "dd",
178
+ "cd",
179
+ "eval",
180
+ ]);
181
+
182
+ const base = (word: string): string => word.slice(word.lastIndexOf("/") + 1);
183
+ const operands = (words: readonly string[]): string[] => words.filter((w) => !w.startsWith("-"));
184
+
185
+ const KEYWORDS = new Set(["then", "do", "else", "elif", "if", "while", "until", "{", "!"]);
186
+
187
+ /** Strips env assignments, shell keywords and wrappers so the real command is first. */
188
+ function realCommand(words: readonly string[]): string[] {
189
+ let rest = [...words];
190
+ for (;;) {
191
+ const head = rest[0];
192
+ if (head === undefined) return rest;
193
+ if (/^\w+=/.test(head) || KEYWORDS.has(head)) rest = rest.slice(1);
194
+ else if (base(head) === "timeout") {
195
+ const at = rest.findIndex((w, i) => i > 0 && /^\d+[smhd]?$/.test(w));
196
+ rest = at < 0 ? [] : rest.slice(at + 1);
197
+ } else if (WRAPPERS.has(base(head))) {
198
+ const next = rest.findIndex((w, i) => i > 0 && KNOWN.has(base(w)) && !WRAPPERS.has(base(w)));
199
+ const nextWrapper = rest.findIndex((w, i) => i > 0 && WRAPPERS.has(base(w)));
200
+ if (nextWrapper > 0 && (next < 0 || nextWrapper < next)) rest = rest.slice(nextWrapper);
201
+ else return next > 0 ? rest.slice(next) : [];
202
+ } else return rest;
203
+ }
204
+ }
205
+
206
+ function findTargets(args: readonly string[]): string[] {
207
+ const deletes =
208
+ args.includes("-delete") || (args.includes("-exec") && args.some((a) => base(a) === "rm"));
209
+ if (!deletes) return [];
210
+ const starts: string[] = [];
211
+ for (const a of args) {
212
+ if (a.startsWith("-") || a === "(" || a === "!") break;
213
+ starts.push(a);
214
+ }
215
+ const patterns = args.flatMap((a, i) =>
216
+ ["-name", "-iname", "-path", "-ipath", "-regex"].includes(a) && args[i + 1] !== undefined
217
+ ? [args[i + 1] ?? ""]
218
+ : [],
219
+ );
220
+ return [...starts, ...patterns];
221
+ }
222
+
223
+ const removers = (args: readonly string[]): string[] => operands(args);
224
+
225
+ const gitTargets = (args: readonly string[]): string[] => {
226
+ let i = 0;
227
+ while (args[i]?.startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
228
+ const sub = args[i];
229
+ return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
230
+ };
231
+
232
+ const sedTargets = (args: readonly string[]): string[] =>
233
+ args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a)) ? operands(args) : [];
234
+
235
+ /** The destination operand is overwritten, whatever the source is. */
236
+ const cpTargets = (args: readonly string[]): string[] => operands(args).slice(-1);
237
+
238
+ const ddTargets = (args: readonly string[]): string[] =>
239
+ args.flatMap((a) => (a.startsWith("of=") ? [a.slice(3)] : []));
240
+
241
+ const truncateTargets = (args: readonly string[]): string[] =>
242
+ operands(args).filter((a) => !/^[+\-<>/%]?\d+$/.test(a));
243
+
244
+ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> = {
245
+ rm: removers,
246
+ unlink: removers,
247
+ trash: removers,
248
+ rmdir: removers,
249
+ mv: removers,
250
+ tee: removers,
251
+ truncate: truncateTargets,
252
+ find: findTargets,
253
+ sed: sedTargets,
254
+ cp: cpTargets,
255
+ dd: ddTargets,
256
+ git: gitTargets,
257
+ };
258
+
259
+ function commandTargets(words: readonly string[]): string[] {
260
+ const [head, ...args] = words;
261
+ if (head === undefined) return [];
262
+ const targets = Object.hasOwn(TARGETS, base(head)) ? TARGETS[base(head)] : undefined;
263
+ return targets === undefined ? [] : targets(args);
264
+ }
265
+
266
+ const joinCwd = (cwd: string, path: string): string =>
267
+ cwd === "" || path.startsWith("/") ? path : posix.join(cwd, path);
268
+
269
+ function collect(command: string, startCwd: string, depth: number): string[] {
270
+ const found: string[] = [];
271
+ let cwd = startCwd;
272
+ for (const seg of segments(command)) {
273
+ const words = realCommand(seg.words);
274
+ const head = words[0] === undefined ? "" : base(words[0]);
275
+ if (head === "cd") {
276
+ cwd = joinCwd(cwd, operands(words.slice(1))[0] ?? "~");
277
+ continue;
278
+ }
279
+ if (head === "eval" && depth < 3) {
280
+ found.push(...collect(words.slice(1).join(" "), cwd, depth + 1));
281
+ continue;
282
+ }
283
+ if (SHELLS.has(head) && depth < 3) {
284
+ const at = words.findIndex((w, i) => i > 0 && /^-[A-Za-z]*c$/.test(w));
285
+ const script = at < 0 ? undefined : words[at + 1];
286
+ if (script !== undefined) found.push(...collect(script, cwd, depth + 1));
287
+ continue;
288
+ }
289
+ found.push(...commandTargets(words).map((p) => joinCwd(cwd, p)));
290
+ found.push(...seg.truncates.flatMap((p) => (p === "/dev/null" ? [] : [joinCwd(cwd, p)])));
291
+ }
292
+ return found;
293
+ }
294
+
295
+ /**
296
+ * Paths a bash command removes, moves away, truncates or overwrites (best effort): rm/unlink/mv,
297
+ * `git rm|mv`, `find -delete`, truncate, `sed -i`, tee and `>` redirects; sees through wrappers,
298
+ * `bash -c`, `cd` and glob arguments.
299
+ */
300
+ export function bashMutatedPaths(command: string): string[] {
301
+ return collect(command, "", 0);
68
302
  }
@@ -65,6 +65,8 @@ export type HardStopRequest = {
65
65
  command: string;
66
66
  why: string;
67
67
  toolCallId: string;
68
+ /** What goes wrong if the approved action is a mistake; defaults to the irreversible-git wording. */
69
+ costIfWrong?: string;
68
70
  now?: () => Date;
69
71
  };
70
72
 
@@ -89,7 +91,7 @@ export async function requestHardStop(req: HardStopRequest): Promise<HardStopOut
89
91
  default: lookupGate(req.gate)?.default ?? "hard stop",
90
92
  chosen: redactSecrets(req.command),
91
93
  why: redactSecrets(req.why),
92
- costIfWrong: "an irreversible git operation is performed",
94
+ costIfWrong: redactSecrets(req.costIfWrong ?? "an irreversible git operation is performed"),
93
95
  approver: "user",
94
96
  scope: { kind: "once", toolCallId: req.toolCallId },
95
97
  recordedAt: at.toISOString().replace(/\.\d{3}Z$/, "Z"),
@@ -1,15 +1,19 @@
1
- import { readFileSync } from "node:fs";
2
- import { isAbsolute, join, relative } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
3
4
  import {
4
5
  type ExtensionAPI,
5
6
  type ExtensionContext,
6
7
  isToolCallEventType,
8
+ type ToolCallEvent,
7
9
  } from "@earendil-works/pi-coding-agent";
8
- import { isTestPath } from "../core/test-paths.ts";
10
+ import { isTestPath, normalizeRepoPath } from "../core/test-paths.ts";
9
11
  import {
12
+ addedLines,
10
13
  applyEdits,
11
- bashDeletedPaths,
14
+ bashMutatedPaths,
12
15
  isPureAddition,
16
+ type WeakeningSignals,
13
17
  weakeningSignals,
14
18
  } from "../core/test-weakening.ts";
15
19
  import { type GateId, isParseError, parseGateId } from "../core/types.ts";
@@ -35,13 +39,11 @@ type Verdict =
35
39
 
36
40
  const GATE_ID = "tests.weaken";
37
41
 
38
- const describeSignals = (
39
- deleted: boolean,
40
- signals: { addsSkip: boolean; emptied: boolean },
41
- ): string | undefined => {
42
+ const describeSignals = (deleted: boolean, signals: WeakeningSignals): string | undefined => {
42
43
  if (deleted) return "deletes the test file";
43
44
  if (signals.addsSkip) return "skips tests";
44
45
  if (signals.emptied) return "empties the test file";
46
+ if (signals.commentedOut) return "comments out test code";
45
47
  return undefined;
46
48
  };
47
49
 
@@ -53,8 +55,12 @@ const readIfExists = (file: string): string | undefined => {
53
55
  }
54
56
  };
55
57
 
56
- const repoPath = (cwd: string, path: string): string =>
57
- isAbsolute(path) ? relative(cwd, path) : path;
58
+ const repoPath = (cwd: string, path: string): string => normalizeRepoPath(cwd, path, homedir());
59
+
60
+ /** Added lines that can disable tests without matching a skip marker (block comments, early exits). */
61
+ const RISKY_ADDITION = /\/\*|<!--|=begin\b|"""|'''|\breturn\b|\bthrow\b|\bexit\b|\bpanic\(/;
62
+
63
+ const GLOB = /[*?[\]{]/;
58
64
 
59
65
  /**
60
66
  * Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
@@ -88,89 +94,123 @@ export function registerTestGuard(deps: TestGuardDeps): void {
88
94
 
89
95
  const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
90
96
  const deleted = change.after === undefined;
91
- if (!deleted && isPureAddition(change.before, change.after ?? "")) return { kind: "allow" };
92
97
  const signals = deleted
93
- ? { addsSkip: false, emptied: false }
98
+ ? { addsSkip: false, emptied: false, commentedOut: false }
94
99
  : weakeningSignals(change.before, change.after ?? "");
100
+ // Adding lines never weakens a test unless the added line itself is a skip marker.
101
+ const after = change.after ?? "";
102
+ if (
103
+ !deleted &&
104
+ !signals.addsSkip &&
105
+ !signals.commentedOut &&
106
+ isPureAddition(change.before, after) &&
107
+ !addedLines(change.before, after).some((line) => RISKY_ADDITION.test(line))
108
+ ) {
109
+ return { kind: "allow" };
110
+ }
95
111
  const deterministic = describeSignals(deleted, signals);
96
112
  const judged = await judgeTestChange(deps.jev(ctx), {
97
113
  path: change.path,
98
114
  before: change.before,
99
115
  ...(change.after !== undefined ? { after: change.after } : {}),
100
116
  });
101
- if (judged.ok && judged.value.weakens >= WEAKENS_THRESHOLD) {
102
- return judged.value.motive === "gate-gaming"
103
- ? {
104
- kind: "hard-stop",
105
- why: "Jev reads this change as weakening a check to get a green result",
106
- }
107
- : {
108
- kind: "require-departure",
109
- why: `Jev reads this change as weakening the tests (motive: ${judged.value.motive})`,
110
- };
117
+ if (judged.ok) {
118
+ const { weakens, motive } = judged.value;
119
+ const weakening = weakens >= WEAKENS_THRESHOLD || deterministic !== undefined;
120
+ if (weakening && motive === "gate-gaming") {
121
+ return {
122
+ kind: "hard-stop",
123
+ why: "Jev reads this change as weakening a check to get a green result",
124
+ };
125
+ }
126
+ if (weakens >= WEAKENS_THRESHOLD) {
127
+ return {
128
+ kind: "require-departure",
129
+ why: `Jev reads this change as weakening the tests (motive: ${motive})`,
130
+ };
131
+ }
111
132
  }
112
133
  return deterministic !== undefined
113
134
  ? { kind: "require-departure", why: `this change ${deterministic}` }
114
135
  : { kind: "allow" };
115
136
  };
116
137
 
117
- deps.pi.on("tool_call", async (event, ctx) => {
118
- const changes: Change[] = [];
138
+ const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
119
139
  if (isToolCallEventType("bash", event)) {
120
- for (const raw of bashDeletedPaths(event.input.command)) {
140
+ return bashMutatedPaths(event.input.command).flatMap((raw) => {
121
141
  const path = repoPath(ctx.cwd, raw);
122
- if (!isTestPath(path)) continue;
123
- changes.push({ path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined });
124
- }
125
- } else if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
126
- const path = repoPath(ctx.cwd, event.input.path);
127
- if (!isTestPath(path)) return undefined;
128
- const before = readIfExists(join(ctx.cwd, path));
129
- if (before === undefined) return undefined;
130
- const after = isToolCallEventType("write", event)
131
- ? event.input.content
132
- : applyEdits(before, event.input.edits);
133
- changes.push({ path, before, after });
134
- } else {
135
- return undefined;
142
+ // Missing plain paths have nothing to weaken; globs and directories may still hide tests.
143
+ const touched = GLOB.test(path) || existsSync(join(ctx.cwd, path));
144
+ return isTestPath(path) && touched
145
+ ? [{ path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined }]
146
+ : [];
147
+ });
136
148
  }
149
+ if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return [];
150
+ const path = repoPath(ctx.cwd, event.input.path);
151
+ const before = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
152
+ if (before === undefined) return [];
153
+ const after = isToolCallEventType("write", event)
154
+ ? event.input.content
155
+ : applyEdits(before, event.input.edits);
156
+ return [{ path, before, after }];
157
+ };
137
158
 
138
- for (const change of changes) {
139
- const verdict = await judge(change, ctx);
140
- if (verdict.kind === "allow") continue;
141
- if (verdict.kind === "require-departure") {
142
- if (hasOpenDeparture()) {
143
- consumeOpenDeparture();
144
- continue;
145
- }
146
- return {
147
- block: true,
148
- reason:
149
- `${GATE_ID}: ${verdict.why} (${change.path}). Tests are the oracle; weakening them needs a ` +
150
- `recorded decision. If this is deliberate, call devsys_record_departure with gate "${GATE_ID}", ` +
151
- "what you are doing instead, why, and the cost if wrong; then retry. Otherwise fix the code, not the test.",
152
- };
153
- }
154
- const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
155
- if (deps.approvals.consume(gate, command)) continue;
156
- const outcome = await requestHardStop({
157
- pi: deps.pi,
158
- ctx,
159
- gate,
160
- command,
161
- why: verdict.why,
162
- toolCallId: event.toolCallId,
163
- ...(deps.now !== undefined ? { now: deps.now } : {}),
164
- });
165
- if (outcome.kind === "approved") continue;
166
- return {
167
- block: true,
168
- reason:
169
- outcome.kind === "unavailable"
170
- ? `hard stop ${GATE_ID}: ${verdict.why} (${change.path}); requires user approval; run interactively`
171
- : `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
172
- };
159
+ const hardStop = async (
160
+ event: ToolCallEvent,
161
+ ctx: ExtensionContext,
162
+ change: Change,
163
+ why: string,
164
+ ): Promise<{ block: true; reason: string } | undefined> => {
165
+ const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
166
+ if (deps.approvals.consume(gate, command)) return undefined;
167
+ const outcome = await requestHardStop({
168
+ pi: deps.pi,
169
+ ctx,
170
+ gate,
171
+ command,
172
+ why,
173
+ costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
174
+ toolCallId: event.toolCallId,
175
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
176
+ });
177
+ if (outcome.kind === "approved") return undefined;
178
+ return {
179
+ block: true,
180
+ reason:
181
+ outcome.kind === "unavailable"
182
+ ? `hard stop ${GATE_ID}: ${why} (${change.path}); requires user approval; run interactively`
183
+ : `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
184
+ };
185
+ };
186
+
187
+ deps.pi.on("tool_call", async (event, ctx) => {
188
+ const verdicts: Array<{ change: Change; verdict: Verdict }> = [];
189
+ for (const change of collectChanges(event, ctx)) {
190
+ verdicts.push({ change, verdict: await judge(change, ctx) });
173
191
  }
174
- return undefined;
192
+ for (const { change, verdict } of verdicts) {
193
+ if (verdict.kind !== "hard-stop") continue;
194
+ const blocked = await hardStop(event, ctx, change, verdict.why);
195
+ if (blocked !== undefined) return blocked;
196
+ }
197
+ const needing = verdicts.flatMap(({ change, verdict }) =>
198
+ verdict.kind === "require-departure" ? [{ change, why: verdict.why }] : [],
199
+ );
200
+ const first = needing[0];
201
+ if (first === undefined) return undefined;
202
+ if (hasOpenDeparture()) {
203
+ // One departure covers every path in this single tool call.
204
+ consumeOpenDeparture();
205
+ return undefined;
206
+ }
207
+ return {
208
+ block: true,
209
+ reason:
210
+ `${GATE_ID}: ${first.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
211
+ `weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure ` +
212
+ `with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
213
+ "Otherwise fix the code, not the test.",
214
+ };
175
215
  });
176
216
  }
package/src/jev/client.ts CHANGED
@@ -23,8 +23,7 @@ type Classifier = ClassifierModel<ClassifierApi>;
23
23
  /** The slice of pi's `ctx.modelRegistry` that Jev needs; tests pass a fake. */
24
24
  export interface ClassifierRegistry {
25
25
  findOfType(type: "classifier", provider: string, modelId: string): Classifier | undefined;
26
- // biome-ignore lint/suspicious/noExplicitAny: structural bridge to the host's Model<Api> parameter
27
- hasConfiguredAuth(model: any): boolean;
26
+ hasConfiguredAuth(model: unknown): boolean;
28
27
  classify(
29
28
  model: Classifier,
30
29
  context: ClassifierContext,
@@ -1,5 +1,6 @@
1
1
  import type { ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
2
2
  import type { GitIntent } from "../../core/git-intent.ts";
3
+ import { redactSecrets } from "../../core/redact.ts";
3
4
  import { err, ok, type Result } from "../../core/result.ts";
4
5
  import type { Jev, JevError } from "../client.ts";
5
6
 
@@ -43,7 +44,10 @@ export async function judgeShellIntent(
43
44
  jev: Jev,
44
45
  command: string,
45
46
  ): Promise<Result<{ intent: GitIntent; confidence: number }, JevError>> {
46
- const asked = await jev.ask({ command }, { intent: SHELL_INTENT_QUESTION });
47
+ const asked = await jev.ask(
48
+ { command: redactSecrets(command) },
49
+ { intent: SHELL_INTENT_QUESTION },
50
+ );
47
51
  if (!asked.ok) return asked;
48
52
  const answer = asked.value.intent;
49
53
  if (answer?.type !== "choice")
@@ -1,4 +1,5 @@
1
1
  import type { ClassifierBoolQuestion, ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
2
+ import { redactSecrets } from "../../core/redact.ts";
2
3
  import { err, ok, type Result } from "../../core/result.ts";
3
4
  import type { Jev, JevError } from "../client.ts";
4
5
 
@@ -53,7 +54,7 @@ export async function judgeTestChange(
53
54
  jev: Jev,
54
55
  input: TestChangeInput,
55
56
  ): Promise<Result<{ weakens: number; motive: TestChangeMotive; confidence: number }, JevError>> {
56
- const clip = (text: string | undefined): string => (text ?? "").slice(0, 6000);
57
+ const clip = (text: string | undefined): string => redactSecrets(text ?? "").slice(0, 6000);
57
58
  const state = {
58
59
  path: input.path,
59
60
  before: clip(input.before),