@jwilger/pi-development-system 0.3.0 → 0.3.1

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.1",
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,7 @@
1
1
  export type TestPathProfile = { readonly testGlobs?: readonly string[] };
2
2
 
3
3
  const PATTERNS: readonly RegExp[] = [
4
- /(^|\/)(test|tests|__tests__|spec|specs|e2e)\//,
4
+ /(^|\/)(test|tests|__tests__|spec|specs|e2e)(\/|$)/,
5
5
  /\.(test|spec|cy)\.[cm]?[jt]sx?$/,
6
6
  /_test\.(go|rb|py|exs?|rs)$/,
7
7
  /(^|\/)test_[^/]+\.py$/,
@@ -1,3 +1,4 @@
1
+ import { posix } from "node:path";
1
2
  import { parse } from "shell-quote";
2
3
 
3
4
  export type WeakeningSignals = { addsSkip: boolean; emptied: boolean };
@@ -9,6 +10,7 @@ const SKIP_MARKERS: readonly RegExp[] = [
9
10
  /@pytest\.mark\.skip|@unittest\.skip|\bpytest\.skip\(/,
10
11
  /\bt\.Skip(?:Now|f)?\(/,
11
12
  /@Disabled\b|@Ignore\b/,
13
+ /\bprocess\.exit\(/,
12
14
  ];
13
15
 
14
16
  const countSkips = (text: string): number =>
@@ -45,24 +47,170 @@ export function isPureAddition(before: string, after: string): boolean {
45
47
  return true;
46
48
  }
47
49
 
48
- const DELETERS = new Set(["rm", "unlink", "trash", "rmdir"]);
50
+ type Segment = { words: string[]; truncates: string[] };
51
+ type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
49
52
 
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[] = [];
53
+ /** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
54
+ function tokenize(command: string): Token[] {
55
+ return parse(command).flatMap((entry): Token[] => {
56
+ if (typeof entry === "string") return [{ kind: "word", value: entry }];
57
+ if ("pattern" in entry) return [{ kind: "word", value: entry.pattern }];
58
+ if ("op" in entry) return [{ kind: "op", op: entry.op }];
59
+ return [];
60
+ });
61
+ }
62
+
63
+ const TRUNCATING = new Set([">", ">|"]);
64
+ const NON_TRUNCATING_REDIRECTS = new Set([">>", "<", ">&", "<&"]);
65
+
66
+ /** Splits a shell line into simple commands; `>` targets are kept apart from arguments. */
67
+ function segments(command: string): Segment[] {
68
+ const out: Segment[] = [];
69
+ let words: string[] = [];
70
+ let truncates: string[] = [];
71
+ let redirect: "none" | "truncate" | "other" = "none";
54
72
  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 = [];
73
+ if (words.length > 0 || truncates.length > 0) out.push({ words, truncates });
74
+ words = [];
75
+ truncates = [];
76
+ redirect = "none";
61
77
  };
62
- for (const token of parse(command)) {
63
- if (typeof token === "string") current.push(token);
78
+ for (const token of tokenize(command)) {
79
+ if (token.kind === "word") {
80
+ if (redirect === "truncate") truncates.push(token.value);
81
+ else if (redirect === "none") words.push(token.value);
82
+ redirect = "none";
83
+ } else if (TRUNCATING.has(token.op)) redirect = "truncate";
84
+ else if (NON_TRUNCATING_REDIRECTS.has(token.op)) redirect = "other";
64
85
  else flush();
65
86
  }
66
87
  flush();
67
- return paths;
88
+ return out;
89
+ }
90
+
91
+ const WRAPPERS = new Set(["sudo", "env", "command", "nohup", "nice", "time", "exec", "doas"]);
92
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash"]);
93
+ const KNOWN = new Set([
94
+ ...WRAPPERS,
95
+ ...SHELLS,
96
+ "rm",
97
+ "unlink",
98
+ "trash",
99
+ "rmdir",
100
+ "mv",
101
+ "git",
102
+ "truncate",
103
+ "find",
104
+ "sed",
105
+ "tee",
106
+ "cp",
107
+ "cd",
108
+ ]);
109
+
110
+ const base = (word: string): string => word.slice(word.lastIndexOf("/") + 1);
111
+ const operands = (words: readonly string[]): string[] => words.filter((w) => !w.startsWith("-"));
112
+
113
+ /** Strips env assignments and wrappers so the real command is first. */
114
+ function realCommand(words: readonly string[]): string[] {
115
+ let rest = [...words];
116
+ for (;;) {
117
+ while (rest[0] !== undefined && /^\w+=/.test(rest[0])) rest = rest.slice(1);
118
+ const head = rest[0];
119
+ if (head === undefined || !WRAPPERS.has(base(head))) return rest;
120
+ const next = rest.findIndex((w, i) => i > 0 && KNOWN.has(base(w)) && !WRAPPERS.has(base(w)));
121
+ const nextWrapper = rest.findIndex((w, i) => i > 0 && WRAPPERS.has(base(w)));
122
+ if (nextWrapper > 0 && (next < 0 || nextWrapper < next)) rest = rest.slice(nextWrapper);
123
+ else if (next > 0) return rest.slice(next);
124
+ else return [];
125
+ }
126
+ }
127
+
128
+ function findTargets(args: readonly string[]): string[] {
129
+ const deletes =
130
+ args.includes("-delete") || (args.includes("-exec") && args.some((a) => base(a) === "rm"));
131
+ if (!deletes) return [];
132
+ const starts: string[] = [];
133
+ for (const a of args) {
134
+ if (a.startsWith("-") || a === "(" || a === "!") break;
135
+ starts.push(a);
136
+ }
137
+ const patterns = args.flatMap((a, i) =>
138
+ ["-name", "-iname", "-path", "-ipath", "-regex"].includes(a) && args[i + 1] !== undefined
139
+ ? [args[i + 1] ?? ""]
140
+ : [],
141
+ );
142
+ return [...starts, ...patterns];
143
+ }
144
+
145
+ const removers = (args: readonly string[]): string[] => operands(args);
146
+
147
+ const gitTargets = (args: readonly string[]): string[] => {
148
+ let i = 0;
149
+ while (args[i]?.startsWith("-")) i += args[i] === "-C" || args[i] === "-c" ? 2 : 1;
150
+ const sub = args[i];
151
+ return sub === "rm" || sub === "mv" ? operands(args.slice(i + 1)) : [];
152
+ };
153
+
154
+ const sedTargets = (args: readonly string[]): string[] =>
155
+ args.some((a) => /^-[A-Za-z]*i|^--in-place/.test(a)) ? operands(args) : [];
156
+
157
+ const cpTargets = (args: readonly string[]): string[] =>
158
+ args.includes("/dev/null") ? operands(args).slice(-1) : [];
159
+
160
+ const truncateTargets = (args: readonly string[]): string[] =>
161
+ operands(args).filter((a) => !/^[+\-<>/%]?\d+$/.test(a));
162
+
163
+ const TARGETS: Readonly<Record<string, (args: readonly string[]) => string[]>> = {
164
+ rm: removers,
165
+ unlink: removers,
166
+ trash: removers,
167
+ rmdir: removers,
168
+ mv: removers,
169
+ tee: removers,
170
+ truncate: truncateTargets,
171
+ find: findTargets,
172
+ sed: sedTargets,
173
+ cp: cpTargets,
174
+ git: gitTargets,
175
+ };
176
+
177
+ function commandTargets(words: readonly string[]): string[] {
178
+ const [head, ...args] = words;
179
+ if (head === undefined) return [];
180
+ const targets = Object.hasOwn(TARGETS, base(head)) ? TARGETS[base(head)] : undefined;
181
+ return targets === undefined ? [] : targets(args);
182
+ }
183
+
184
+ const joinCwd = (cwd: string, path: string): string =>
185
+ cwd === "" || path.startsWith("/") ? path : posix.join(cwd, path);
186
+
187
+ function collect(command: string, startCwd: string, depth: number): string[] {
188
+ const found: string[] = [];
189
+ let cwd = startCwd;
190
+ for (const seg of segments(command)) {
191
+ const words = realCommand(seg.words);
192
+ const head = words[0] === undefined ? "" : base(words[0]);
193
+ if (head === "cd" && words[1] !== undefined) {
194
+ cwd = joinCwd(cwd, words[1]);
195
+ continue;
196
+ }
197
+ if (SHELLS.has(head) && depth < 3) {
198
+ const at = words.findIndex((w, i) => i > 0 && /^-[A-Za-z]*c$/.test(w));
199
+ const script = at < 0 ? undefined : words[at + 1];
200
+ if (script !== undefined) found.push(...collect(script, cwd, depth + 1));
201
+ continue;
202
+ }
203
+ found.push(...commandTargets(words).map((p) => joinCwd(cwd, p)));
204
+ found.push(...seg.truncates.flatMap((p) => (p === "/dev/null" ? [] : [joinCwd(cwd, p)])));
205
+ }
206
+ return found;
207
+ }
208
+
209
+ /**
210
+ * Paths a bash command removes, moves away, truncates or overwrites (best effort): rm/unlink/mv,
211
+ * `git rm|mv`, `find -delete`, truncate, `sed -i`, tee and `>` redirects; sees through wrappers,
212
+ * `bash -c`, `cd` and glob arguments.
213
+ */
214
+ export function bashMutatedPaths(command: string): string[] {
215
+ return collect(command, "", 0);
68
216
  }
@@ -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"),
@@ -4,11 +4,12 @@ import {
4
4
  type ExtensionAPI,
5
5
  type ExtensionContext,
6
6
  isToolCallEventType,
7
+ type ToolCallEvent,
7
8
  } from "@earendil-works/pi-coding-agent";
8
9
  import { isTestPath } from "../core/test-paths.ts";
9
10
  import {
10
11
  applyEdits,
11
- bashDeletedPaths,
12
+ bashMutatedPaths,
12
13
  isPureAddition,
13
14
  weakeningSignals,
14
15
  } from "../core/test-weakening.ts";
@@ -88,89 +89,114 @@ export function registerTestGuard(deps: TestGuardDeps): void {
88
89
 
89
90
  const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
90
91
  const deleted = change.after === undefined;
91
- if (!deleted && isPureAddition(change.before, change.after ?? "")) return { kind: "allow" };
92
92
  const signals = deleted
93
93
  ? { addsSkip: false, emptied: false }
94
94
  : weakeningSignals(change.before, change.after ?? "");
95
+ // Adding lines never weakens a test unless the added line itself is a skip marker.
96
+ if (!deleted && !signals.addsSkip && isPureAddition(change.before, change.after ?? "")) {
97
+ return { kind: "allow" };
98
+ }
95
99
  const deterministic = describeSignals(deleted, signals);
96
100
  const judged = await judgeTestChange(deps.jev(ctx), {
97
101
  path: change.path,
98
102
  before: change.before,
99
103
  ...(change.after !== undefined ? { after: change.after } : {}),
100
104
  });
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
- };
105
+ if (judged.ok) {
106
+ const { weakens, motive } = judged.value;
107
+ const weakening = weakens >= WEAKENS_THRESHOLD || deterministic !== undefined;
108
+ if (weakening && motive === "gate-gaming") {
109
+ return {
110
+ kind: "hard-stop",
111
+ why: "Jev reads this change as weakening a check to get a green result",
112
+ };
113
+ }
114
+ if (weakens >= WEAKENS_THRESHOLD) {
115
+ return {
116
+ kind: "require-departure",
117
+ why: `Jev reads this change as weakening the tests (motive: ${motive})`,
118
+ };
119
+ }
111
120
  }
112
121
  return deterministic !== undefined
113
122
  ? { kind: "require-departure", why: `this change ${deterministic}` }
114
123
  : { kind: "allow" };
115
124
  };
116
125
 
117
- deps.pi.on("tool_call", async (event, ctx) => {
118
- const changes: Change[] = [];
126
+ const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
119
127
  if (isToolCallEventType("bash", event)) {
120
- for (const raw of bashDeletedPaths(event.input.command)) {
128
+ return bashMutatedPaths(event.input.command).flatMap((raw) => {
121
129
  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;
130
+ return isTestPath(path)
131
+ ? [{ path, before: readIfExists(join(ctx.cwd, path)) ?? "", after: undefined }]
132
+ : [];
133
+ });
136
134
  }
135
+ if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return [];
136
+ const path = repoPath(ctx.cwd, event.input.path);
137
+ const before = isTestPath(path) ? readIfExists(join(ctx.cwd, path)) : undefined;
138
+ if (before === undefined) return [];
139
+ const after = isToolCallEventType("write", event)
140
+ ? event.input.content
141
+ : applyEdits(before, event.input.edits);
142
+ return [{ path, before, after }];
143
+ };
137
144
 
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
- };
145
+ const hardStop = async (
146
+ event: ToolCallEvent,
147
+ ctx: ExtensionContext,
148
+ change: Change,
149
+ why: string,
150
+ ): Promise<{ block: true; reason: string } | undefined> => {
151
+ const command = `${isToolCallEventType("bash", event) ? "delete" : "change"} ${change.path}`;
152
+ if (deps.approvals.consume(gate, command)) return undefined;
153
+ const outcome = await requestHardStop({
154
+ pi: deps.pi,
155
+ ctx,
156
+ gate,
157
+ command,
158
+ why,
159
+ costIfWrong: "the tests verify less behaviour, so regressions can ship undetected",
160
+ toolCallId: event.toolCallId,
161
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
162
+ });
163
+ if (outcome.kind === "approved") return undefined;
164
+ return {
165
+ block: true,
166
+ reason:
167
+ outcome.kind === "unavailable"
168
+ ? `hard stop ${GATE_ID}: ${why} (${change.path}); requires user approval; run interactively`
169
+ : `hard stop ${GATE_ID}: the user declined this change to ${change.path}. Fix the code instead of weakening the test.`,
170
+ };
171
+ };
172
+
173
+ deps.pi.on("tool_call", async (event, ctx) => {
174
+ const verdicts: Array<{ change: Change; verdict: Verdict }> = [];
175
+ for (const change of collectChanges(event, ctx)) {
176
+ verdicts.push({ change, verdict: await judge(change, ctx) });
173
177
  }
174
- return undefined;
178
+ for (const { change, verdict } of verdicts) {
179
+ if (verdict.kind !== "hard-stop") continue;
180
+ const blocked = await hardStop(event, ctx, change, verdict.why);
181
+ if (blocked !== undefined) return blocked;
182
+ }
183
+ const needing = verdicts.flatMap(({ change, verdict }) =>
184
+ verdict.kind === "require-departure" ? [{ change, why: verdict.why }] : [],
185
+ );
186
+ const first = needing[0];
187
+ if (first === undefined) return undefined;
188
+ if (hasOpenDeparture()) {
189
+ // One departure covers every path in this single tool call.
190
+ consumeOpenDeparture();
191
+ return undefined;
192
+ }
193
+ return {
194
+ block: true,
195
+ reason:
196
+ `${GATE_ID}: ${first.why} (${needing.map((n) => n.change.path).join(", ")}). Tests are the oracle; ` +
197
+ `weakening them needs a recorded decision. If this is deliberate, call devsys_record_departure ` +
198
+ `with gate "${GATE_ID}", what you are doing instead, why, and the cost if wrong; then retry. ` +
199
+ "Otherwise fix the code, not the test.",
200
+ };
175
201
  });
176
202
  }
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),