@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.
- package/extensions/development-system.ts +4 -3
- package/package.json +1 -1
- package/src/core/test-paths.ts +1 -1
- package/src/core/test-weakening.ts +162 -14
- package/src/gates/approvals.ts +3 -1
- package/src/gates/test-guard.ts +91 -65
- package/src/jev/client.ts +1 -2
- package/src/jev/questions/shell-intent.ts +5 -1
- package/src/jev/questions/test-change.ts +2 -1
|
@@ -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
package/src/core/test-paths.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
+
type Segment = { words: string[]; truncates: string[] };
|
|
51
|
+
type Token = { kind: "word"; value: string } | { kind: "op"; op: string };
|
|
49
52
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
if (
|
|
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
|
|
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
|
}
|
package/src/gates/approvals.ts
CHANGED
|
@@ -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"),
|
package/src/gates/test-guard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
118
|
-
const changes: Change[] = [];
|
|
126
|
+
const collectChanges = (event: ToolCallEvent, ctx: ExtensionContext): Change[] => {
|
|
119
127
|
if (isToolCallEventType("bash", event)) {
|
|
120
|
-
|
|
128
|
+
return bashMutatedPaths(event.input.command).flatMap((raw) => {
|
|
121
129
|
const path = repoPath(ctx.cwd, raw);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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),
|