@jwilger/pi-development-system 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/development-system.ts +11 -3
- package/package.json +1 -1
- package/src/context/context-tail.ts +3 -1
- package/src/context/status.ts +7 -4
- package/src/core/git-intent.ts +126 -12
- package/src/core/redact.ts +10 -0
- package/src/core/result.ts +7 -0
- package/src/core/test-paths.ts +27 -0
- package/src/core/test-weakening.ts +68 -0
- package/src/gates/approvals.ts +4 -3
- package/src/gates/git-guard.ts +38 -3
- package/src/gates/record-departure-tool.ts +7 -4
- package/src/gates/request-approval-tool.ts +1 -1
- package/src/gates/test-guard.ts +176 -0
- package/src/jev/client.ts +127 -0
- package/src/jev/holder.ts +54 -0
- package/src/jev/models.ts +15 -0
- package/src/jev/questions/shell-intent.ts +55 -0
- package/src/jev/questions/test-change.ts +76 -0
|
@@ -7,6 +7,8 @@ import { createApprovalStore } from "../src/gates/approvals.ts";
|
|
|
7
7
|
import { registerGitGuard } from "../src/gates/git-guard.ts";
|
|
8
8
|
import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts";
|
|
9
9
|
import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
|
|
10
|
+
import { registerTestGuard } from "../src/gates/test-guard.ts";
|
|
11
|
+
import { createJevHolder } from "../src/jev/holder.ts";
|
|
10
12
|
import { createSessionState } from "../src/state/session-state.ts";
|
|
11
13
|
|
|
12
14
|
const nonNegotiables = readFileSync(
|
|
@@ -19,14 +21,19 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
19
21
|
const state = createSessionState(pi);
|
|
20
22
|
const approvals = createApprovalStore(pi);
|
|
21
23
|
|
|
24
|
+
const jevHolder = createJevHolder();
|
|
25
|
+
let jevModel: string | undefined;
|
|
22
26
|
let lastCtx: ExtensionContext | undefined;
|
|
23
27
|
|
|
24
28
|
const refreshStatus = () => {
|
|
25
|
-
lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get()));
|
|
29
|
+
lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get(), jevModel));
|
|
26
30
|
};
|
|
27
31
|
|
|
28
32
|
const rebuild = (ctx: ExtensionContext) => {
|
|
29
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 }));
|
|
30
37
|
const entries = ctx.sessionManager
|
|
31
38
|
.getBranch()
|
|
32
39
|
.flatMap((e) => (e.type === "custom" ? [{ customType: e.customType, data: e.data }] : []));
|
|
@@ -48,14 +55,15 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
48
55
|
return messages === undefined ? undefined : { messages };
|
|
49
56
|
});
|
|
50
57
|
|
|
51
|
-
registerGitGuard({ pi, approvals });
|
|
58
|
+
registerGitGuard({ pi, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
59
|
+
registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
52
60
|
pi.registerTool(createRecordDepartureTool({ pi, state }));
|
|
53
61
|
pi.registerTool(createRequestApprovalTool({ pi, approvals }));
|
|
54
62
|
|
|
55
63
|
pi.registerCommand("devsys-status", {
|
|
56
64
|
description: "Show development-system phase, sizing, slice, departures and Jev status",
|
|
57
65
|
handler: async (_args, ctx) => {
|
|
58
|
-
ctx.ui.notify(renderStatus(state.get()), "info");
|
|
66
|
+
ctx.ui.notify(renderStatus(state.get(), jevModel), "info");
|
|
59
67
|
},
|
|
60
68
|
});
|
|
61
69
|
|
package/package.json
CHANGED
|
@@ -18,7 +18,9 @@ export function renderContextTail(state: DevsysState): string | undefined {
|
|
|
18
18
|
];
|
|
19
19
|
const tailLines = [REMINDER];
|
|
20
20
|
const room = MAX_LINES - head.length - tailLines.length - 1;
|
|
21
|
-
const lines = state.openDepartures.map(
|
|
21
|
+
const lines = state.openDepartures.map(
|
|
22
|
+
(d) => `- ${d.gate} — ${d.chosen.replace(/\s+/g, " ")} (${scopeLabel(d)})`,
|
|
23
|
+
);
|
|
22
24
|
const shown =
|
|
23
25
|
lines.length <= room
|
|
24
26
|
? lines
|
package/src/context/status.ts
CHANGED
|
@@ -2,15 +2,18 @@ import type { DevsysState } from "../core/types.ts";
|
|
|
2
2
|
|
|
3
3
|
export const STATUS_KEY = "devsys";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
`
|
|
5
|
+
const jevLabel = (state: DevsysState, model: string | undefined): string =>
|
|
6
|
+
state.jev === "online" && model !== undefined ? `online (${model})` : state.jev;
|
|
7
7
|
|
|
8
|
-
export const
|
|
8
|
+
export const renderStatusLine = (state: DevsysState, jevModel?: string): string =>
|
|
9
|
+
`devsys: ${state.phase} · jev ${jevLabel(state, jevModel)}`;
|
|
10
|
+
|
|
11
|
+
export const renderStatus = (state: DevsysState, jevModel?: string): string =>
|
|
9
12
|
[
|
|
10
13
|
"Development system status",
|
|
11
14
|
`- phase: ${state.phase}`,
|
|
12
15
|
`- sizing: ${state.sizing ?? "unset"}`,
|
|
13
16
|
`- active slice: ${state.activeSlice ?? "none"}`,
|
|
14
17
|
`- open departures: ${state.openDepartures.length}`,
|
|
15
|
-
`- jev: ${state
|
|
18
|
+
`- jev: ${jevLabel(state, jevModel)}`,
|
|
16
19
|
].join("\n");
|
package/src/core/git-intent.ts
CHANGED
|
@@ -23,15 +23,22 @@ const SEVERITY: ReadonlyArray<GitIntent> = [
|
|
|
23
23
|
const worst = (a: GitIntent, b: GitIntent): GitIntent =>
|
|
24
24
|
SEVERITY.indexOf(a) <= SEVERITY.indexOf(b) ? a : b;
|
|
25
25
|
|
|
26
|
-
/** Splits a command string on newlines that are outside quotes. */
|
|
26
|
+
/** Splits a command string on newlines that are outside quotes; backslash-newline is a continuation. */
|
|
27
27
|
function splitLines(command: string): string[] {
|
|
28
28
|
const lines: string[] = [];
|
|
29
29
|
let current = "";
|
|
30
30
|
let quote: "'" | '"' | undefined;
|
|
31
|
-
for (
|
|
31
|
+
for (let i = 0; i < command.length; i++) {
|
|
32
|
+
const ch = command.charAt(i);
|
|
32
33
|
if (quote !== undefined) {
|
|
33
34
|
if (ch === quote) quote = undefined;
|
|
34
35
|
current += ch;
|
|
36
|
+
} else if (ch === "\\" && command.charAt(i + 1) === "\n") {
|
|
37
|
+
i++;
|
|
38
|
+
} else if (ch === "#" && (current === "" || /\s$/.test(current))) {
|
|
39
|
+
// A comment runs to the end of the line; quotes inside it do not open a quote.
|
|
40
|
+
while (i < command.length && command.charAt(i) !== "\n") current += command.charAt(i++);
|
|
41
|
+
i--;
|
|
35
42
|
} else if (ch === "'" || ch === '"') {
|
|
36
43
|
quote = ch;
|
|
37
44
|
current += ch;
|
|
@@ -46,18 +53,34 @@ function splitLines(command: string): string[] {
|
|
|
46
53
|
return lines;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("(");
|
|
57
|
+
|
|
49
58
|
/** Tokens of one line grouped into simple commands (operators separate segments). */
|
|
50
59
|
function segments(line: string): string[][] {
|
|
51
60
|
const result: string[][] = [[]];
|
|
52
61
|
for (const token of parse(line, (name) => `$${name}`)) {
|
|
53
62
|
if (typeof token === "string") result[result.length - 1]?.push(token);
|
|
54
63
|
else if ("pattern" in token) result[result.length - 1]?.push(token.pattern);
|
|
64
|
+
else if ("op" in token && isRedirect(token.op)) continue;
|
|
55
65
|
else if (!("comment" in token)) result.push([]);
|
|
56
66
|
}
|
|
57
67
|
return result.filter((s) => s.length > 0);
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
const WRAPPERS = new Set(["command", "sudo", "env", "time", "nohup", "exec", "builtin", "nice"]);
|
|
71
|
+
const KEYWORDS = new Set(["then", "do", "else", "elif", "if", "while", "until", "{", "!", "time"]);
|
|
72
|
+
const DATA_ONLY = new Set([
|
|
73
|
+
"echo",
|
|
74
|
+
"printf",
|
|
75
|
+
"cat",
|
|
76
|
+
"grep",
|
|
77
|
+
"rg",
|
|
78
|
+
"man",
|
|
79
|
+
"which",
|
|
80
|
+
"type",
|
|
81
|
+
"head",
|
|
82
|
+
"tail",
|
|
83
|
+
]);
|
|
61
84
|
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
62
85
|
const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
|
|
63
86
|
const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
|
|
@@ -66,22 +89,30 @@ const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
|
|
|
66
89
|
const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
67
90
|
const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
68
91
|
|
|
92
|
+
/** True when `arg` is `name` or an unambiguous-looking git abbreviation of it (`--amen`, `--del=x`). */
|
|
93
|
+
const isLong = (arg: string, ...names: string[]): boolean => {
|
|
94
|
+
const key = arg.split("=")[0] ?? "";
|
|
95
|
+
return key.length >= 4 && key.startsWith("--") && names.some((n) => n.startsWith(key));
|
|
96
|
+
};
|
|
97
|
+
|
|
69
98
|
function classifyGitArgs(globals: string[], sub: string | undefined, args: string[]): GitIntent {
|
|
70
99
|
let intent: GitIntent = "ordinary";
|
|
71
|
-
if (globals.some((g) => /^core\.
|
|
72
|
-
if (args.
|
|
100
|
+
if (globals.some((g) => /^core\.hookspath=/i.test(g))) intent = worst(intent, "no-verify");
|
|
101
|
+
if (args.some((a) => isLong(a, "--no-verify"))) intent = worst(intent, "no-verify");
|
|
73
102
|
switch (sub) {
|
|
74
103
|
case "push": {
|
|
75
104
|
const shortFlags = args.filter((a) => /^-[A-Za-z]+$/.test(a));
|
|
76
105
|
if (
|
|
77
|
-
args.some((a) =>
|
|
106
|
+
args.some((a) =>
|
|
107
|
+
isLong(a, "--force", "--force-with-lease", "--force-if-includes", "--mirror"),
|
|
108
|
+
) ||
|
|
78
109
|
shortFlags.some((a) => a.includes("f")) ||
|
|
79
110
|
args.some((a) => /^\+\S/.test(a))
|
|
80
111
|
) {
|
|
81
112
|
return worst(intent, "force-push");
|
|
82
113
|
}
|
|
83
114
|
if (
|
|
84
|
-
args.
|
|
115
|
+
args.some((a) => isLong(a, "--delete")) ||
|
|
85
116
|
shortFlags.includes("-d") ||
|
|
86
117
|
args.some((a) => /^:\S/.test(a))
|
|
87
118
|
) {
|
|
@@ -90,7 +121,7 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
|
|
|
90
121
|
return intent;
|
|
91
122
|
}
|
|
92
123
|
case "commit":
|
|
93
|
-
if (args.
|
|
124
|
+
if (args.some((a) => isLong(a, "--amend"))) return worst(intent, "history-rewrite");
|
|
94
125
|
if (args.some((a) => /^-[A-Za-z]*n[A-Za-z]*$/.test(a))) return worst(intent, "no-verify");
|
|
95
126
|
return intent;
|
|
96
127
|
case "rebase":
|
|
@@ -101,7 +132,7 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
|
|
|
101
132
|
case "filter-repo":
|
|
102
133
|
return worst(intent, "history-rewrite");
|
|
103
134
|
case "reset":
|
|
104
|
-
return args.
|
|
135
|
+
return args.some((a) => isLong(a, "--hard")) ? worst(intent, "destructive-reset") : intent;
|
|
105
136
|
default:
|
|
106
137
|
return intent;
|
|
107
138
|
}
|
|
@@ -116,7 +147,7 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
116
147
|
if (isAssignment(t)) {
|
|
117
148
|
if (HOOK_SKIP_ENV.has(t)) hookSkip = true;
|
|
118
149
|
i++;
|
|
119
|
-
} else if (WRAPPERS.has(t)) {
|
|
150
|
+
} else if (WRAPPERS.has(t) || KEYWORDS.has(t)) {
|
|
120
151
|
i++;
|
|
121
152
|
} else {
|
|
122
153
|
break;
|
|
@@ -129,13 +160,18 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
129
160
|
hookSkip ? worst(intent, "no-verify") : intent;
|
|
130
161
|
if (head.startsWith("$") || base === "$GIT") return "unknown";
|
|
131
162
|
if (SHELLS.has(base)) {
|
|
132
|
-
const c = rest.
|
|
163
|
+
const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
|
|
133
164
|
const script = c >= 0 ? rest[c + 1] : undefined;
|
|
134
165
|
return script !== undefined ? classifyGitCommand(script) : "ordinary";
|
|
135
166
|
}
|
|
136
167
|
if (base === "eval") return classifyGitCommand(rest.join(" "));
|
|
137
168
|
if (OPAQUE.has(base)) return rest.some((t) => basename(t) === "git") ? "unknown" : "ordinary";
|
|
138
|
-
if (base !== "git")
|
|
169
|
+
if (base !== "git") {
|
|
170
|
+
if (DATA_ONLY.has(base)) return "ordinary";
|
|
171
|
+
// Wrapper with options (`sudo -E git ...`, `timeout 5 git ...`): classify from the git token.
|
|
172
|
+
const at = rest.findIndex((t) => basename(t) === "git");
|
|
173
|
+
return at >= 0 ? withHookSkip(classifySegment(rest.slice(at))) : "ordinary";
|
|
174
|
+
}
|
|
139
175
|
const globals: string[] = [];
|
|
140
176
|
let j = 0;
|
|
141
177
|
while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
|
|
@@ -148,14 +184,92 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
148
184
|
}
|
|
149
185
|
}
|
|
150
186
|
const sub = rest[j];
|
|
151
|
-
|
|
187
|
+
const args = rest.slice(j + 1);
|
|
188
|
+
const dynamic =
|
|
189
|
+
(sub ?? "").startsWith("$") ||
|
|
190
|
+
(["push", "reset", "rebase"].includes(sub ?? "") &&
|
|
191
|
+
args.some((t) => t.startsWith("$") || t.includes("`")));
|
|
192
|
+
const intent = classifyGitArgs(globals, sub, args);
|
|
193
|
+
return withHookSkip(dynamic ? worst(intent, "unknown") : intent);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The parts of a line bash may still execute or treat as syntax: single-quoted text and
|
|
198
|
+
* backslash-escaped characters are dropped; double-quoted text is kept unless `dropDouble`.
|
|
199
|
+
*/
|
|
200
|
+
function live(line: string, dropDouble: boolean): string {
|
|
201
|
+
let out = "";
|
|
202
|
+
let quote: "'" | '"' | undefined;
|
|
203
|
+
for (let i = 0; i < line.length; i++) {
|
|
204
|
+
const ch = line.charAt(i);
|
|
205
|
+
if (quote === "'") {
|
|
206
|
+
if (ch === "'") quote = undefined;
|
|
207
|
+
} else if (ch === "\\") {
|
|
208
|
+
i++;
|
|
209
|
+
} else if (quote === '"') {
|
|
210
|
+
if (ch === '"') quote = undefined;
|
|
211
|
+
else if (!dropDouble) out += ch;
|
|
212
|
+
} else if (ch === "'" || ch === '"') {
|
|
213
|
+
quote = ch;
|
|
214
|
+
} else {
|
|
215
|
+
out += ch;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Bodies of `$(...)` and backtick substitutions anywhere in the text (quoted or not). */
|
|
222
|
+
function substitutions(text: string): string[] {
|
|
223
|
+
const bodies: string[] = [];
|
|
224
|
+
for (let at = text.indexOf("$("); at >= 0; at = text.indexOf("$(", at + 2)) {
|
|
225
|
+
let depth = 1;
|
|
226
|
+
let end = at + 2;
|
|
227
|
+
for (; end < text.length && depth > 0; end++) {
|
|
228
|
+
const ch = text.charAt(end);
|
|
229
|
+
if (ch === "(") depth++;
|
|
230
|
+
else if (ch === ")") depth--;
|
|
231
|
+
}
|
|
232
|
+
bodies.push(text.slice(at + 2, depth === 0 ? end - 1 : end));
|
|
233
|
+
}
|
|
234
|
+
for (const m of text.matchAll(/`([^`]*)`/g)) bodies.push(m[1] ?? "");
|
|
235
|
+
return bodies;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const HEREDOC = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
|
|
239
|
+
|
|
240
|
+
/** The delimiter of a heredoc started by an unquoted `<<` on this line, if any. */
|
|
241
|
+
function heredocDelimiter(line: string): string | undefined {
|
|
242
|
+
let quote: "'" | '"' | undefined;
|
|
243
|
+
for (let i = 0; i < line.length; i++) {
|
|
244
|
+
const ch = line.charAt(i);
|
|
245
|
+
if (quote !== undefined) {
|
|
246
|
+
if (ch === quote) quote = undefined;
|
|
247
|
+
} else if (ch === "\\") {
|
|
248
|
+
i++;
|
|
249
|
+
} else if (ch === "'" || ch === '"') {
|
|
250
|
+
quote = ch;
|
|
251
|
+
} else if (line.startsWith("<<<", i)) {
|
|
252
|
+
i += 2;
|
|
253
|
+
} else if (ch === "<" && line.charAt(i + 1) === "<") {
|
|
254
|
+
return HEREDOC.exec(line.slice(i))?.[2];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
152
258
|
}
|
|
153
259
|
|
|
154
260
|
/** Classifies the most severe git intent in a shell command (deterministic fast path). */
|
|
155
261
|
export function classifyGitCommand(command: string): GitIntent {
|
|
156
262
|
let intent: GitIntent = "ordinary";
|
|
263
|
+
let heredocEnd: string | undefined;
|
|
157
264
|
for (const line of splitLines(command)) {
|
|
265
|
+
if (heredocEnd !== undefined) {
|
|
266
|
+
if (line.trim() === heredocEnd) heredocEnd = undefined;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
158
269
|
for (const segment of segments(line)) intent = worst(intent, classifySegment(segment));
|
|
270
|
+
for (const body of substitutions(live(line, false)))
|
|
271
|
+
intent = worst(intent, classifyGitCommand(body));
|
|
272
|
+
heredocEnd = heredocDelimiter(line) ?? heredocEnd;
|
|
159
273
|
}
|
|
160
274
|
return intent;
|
|
161
275
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Removes credentials from text before it is written to committed logs or session entries (R11). */
|
|
2
|
+
export function redactSecrets(text: string): string {
|
|
3
|
+
return text
|
|
4
|
+
.replace(/(\w+:\/\/)[^\s/@]+@/g, "$1[redacted]@")
|
|
5
|
+
.replace(/\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}/g, "[redacted]")
|
|
6
|
+
.replace(/\b(\w*(?:TOKEN|KEY|SECRET|PASSWORD)\w*)=("[^"]*"|'[^']*'|\S+)/gi, "$1=[redacted]")
|
|
7
|
+
.replace(/\b(Authorization:\s*)\w+\s+[^\s'"]+/gi, "$1[redacted]")
|
|
8
|
+
.replace(/\bBearer\s+[^\s'"]+/g, "Bearer [redacted]")
|
|
9
|
+
.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]");
|
|
10
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A value or a typed error (errors as values, R2/R3). */
|
|
2
|
+
export type Result<T, E> =
|
|
3
|
+
| { readonly ok: true; readonly value: T }
|
|
4
|
+
| { readonly ok: false; readonly error: E };
|
|
5
|
+
|
|
6
|
+
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
|
|
7
|
+
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type TestPathProfile = { readonly testGlobs?: readonly string[] };
|
|
2
|
+
|
|
3
|
+
const PATTERNS: readonly RegExp[] = [
|
|
4
|
+
/(^|\/)(test|tests|__tests__|spec|specs|e2e)\//,
|
|
5
|
+
/\.(test|spec|cy)\.[cm]?[jt]sx?$/,
|
|
6
|
+
/_test\.(go|rb|py|exs?|rs)$/,
|
|
7
|
+
/(^|\/)test_[^/]+\.py$/,
|
|
8
|
+
/_spec\.rb$/,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const SPECIALS = /[.+^$(){}|[\]\\]/g;
|
|
12
|
+
|
|
13
|
+
function globToRegExp(glob: string): RegExp {
|
|
14
|
+
const escaped = glob
|
|
15
|
+
.replace(SPECIALS, "\\$&")
|
|
16
|
+
.replace(/\*\*/g, "@@GLOBSTAR@@")
|
|
17
|
+
.replace(/\*/g, "[^/]*")
|
|
18
|
+
.replace(/@@GLOBSTAR@@/g, ".*");
|
|
19
|
+
return new RegExp(`^${escaped}$`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether a repo-relative path is a test file (conventions, plus profile-supplied globs). */
|
|
23
|
+
export function isTestPath(path: string, profile?: TestPathProfile): boolean {
|
|
24
|
+
const normalized = path.replace(/^\.\//, "");
|
|
25
|
+
if (PATTERNS.some((p) => p.test(normalized))) return true;
|
|
26
|
+
return (profile?.testGlobs ?? []).some((g) => globToRegExp(g).test(normalized));
|
|
27
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { parse } from "shell-quote";
|
|
2
|
+
|
|
3
|
+
export type WeakeningSignals = { addsSkip: boolean; emptied: boolean };
|
|
4
|
+
|
|
5
|
+
const SKIP_MARKERS: readonly RegExp[] = [
|
|
6
|
+
/\b(?:it|test|describe|context)\.(?:skip|todo)\b/,
|
|
7
|
+
/\bx(?:it|describe|test)\(/,
|
|
8
|
+
/#\[ignore\b/,
|
|
9
|
+
/@pytest\.mark\.skip|@unittest\.skip|\bpytest\.skip\(/,
|
|
10
|
+
/\bt\.Skip(?:Now|f)?\(/,
|
|
11
|
+
/@Disabled\b|@Ignore\b/,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const countSkips = (text: string): number =>
|
|
15
|
+
SKIP_MARKERS.reduce((n, re) => n + (text.match(new RegExp(re.source, "g"))?.length ?? 0), 0);
|
|
16
|
+
|
|
17
|
+
/** Deterministic weakening signals between two versions of a test file. */
|
|
18
|
+
export function weakeningSignals(before: string, after: string): WeakeningSignals {
|
|
19
|
+
return {
|
|
20
|
+
addsSkip: countSkips(after) > countSkips(before),
|
|
21
|
+
emptied: before.trim() !== "" && after.trim() === "",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Applies `edits` to `content` (first occurrence each, in order); unmatched edits are ignored. */
|
|
26
|
+
export function applyEdits(
|
|
27
|
+
content: string,
|
|
28
|
+
edits: ReadonlyArray<{ oldText: string; newText: string }>,
|
|
29
|
+
): string {
|
|
30
|
+
return edits.reduce((text, e) => {
|
|
31
|
+
const at = text.indexOf(e.oldText);
|
|
32
|
+
return at < 0 ? text : text.slice(0, at) + e.newText + text.slice(at + e.oldText.length);
|
|
33
|
+
}, content);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when every line of `before` still appears (with multiplicity) in `after`. */
|
|
37
|
+
export function isPureAddition(before: string, after: string): boolean {
|
|
38
|
+
const remaining = new Map<string, number>();
|
|
39
|
+
for (const line of after.split("\n")) remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
40
|
+
for (const line of before.split("\n")) {
|
|
41
|
+
const left = remaining.get(line) ?? 0;
|
|
42
|
+
if (left === 0) return false;
|
|
43
|
+
remaining.set(line, left - 1);
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DELETERS = new Set(["rm", "unlink", "trash", "rmdir"]);
|
|
49
|
+
|
|
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[] = [];
|
|
54
|
+
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 = [];
|
|
61
|
+
};
|
|
62
|
+
for (const token of parse(command)) {
|
|
63
|
+
if (typeof token === "string") current.push(token);
|
|
64
|
+
else flush();
|
|
65
|
+
}
|
|
66
|
+
flush();
|
|
67
|
+
return paths;
|
|
68
|
+
}
|
package/src/gates/approvals.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type Departure, parseDeparture } from "../core/departure.ts";
|
|
3
3
|
import { lookupGate } from "../core/gates.ts";
|
|
4
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
4
5
|
import { type GateId, isParseError } from "../core/types.ts";
|
|
5
6
|
import { appendDecision } from "../state/decision-log.ts";
|
|
6
7
|
import { DEPARTURE_ENTRY_TYPE } from "./record-departure-tool.ts";
|
|
@@ -77,7 +78,7 @@ export async function requestHardStop(req: HardStopRequest): Promise<HardStopOut
|
|
|
77
78
|
if (!req.ctx.hasUI) return { kind: "unavailable" };
|
|
78
79
|
const approved = await req.ctx.ui.confirm(
|
|
79
80
|
"Development system — hard stop",
|
|
80
|
-
`${req.command}\nGate: ${req.gate}\nThis is irreversible. Approve once?`,
|
|
81
|
+
`${redactSecrets(req.command)}\nGate: ${req.gate}\nThis is irreversible. Approve once?`,
|
|
81
82
|
);
|
|
82
83
|
if (!approved) return { kind: "declined" };
|
|
83
84
|
const at = (req.now ?? (() => new Date()))();
|
|
@@ -86,8 +87,8 @@ export async function requestHardStop(req: HardStopRequest): Promise<HardStopOut
|
|
|
86
87
|
gate: req.gate,
|
|
87
88
|
tier: "hard",
|
|
88
89
|
default: lookupGate(req.gate)?.default ?? "hard stop",
|
|
89
|
-
chosen: req.command,
|
|
90
|
-
why: req.why,
|
|
90
|
+
chosen: redactSecrets(req.command),
|
|
91
|
+
why: redactSecrets(req.why),
|
|
91
92
|
costIfWrong: "an irreversible git operation is performed",
|
|
92
93
|
approver: "user",
|
|
93
94
|
scope: { kind: "once", toolCallId: req.toolCallId },
|
package/src/gates/git-guard.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
isToolCallEventType,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
6
|
import { classifyGitCommand, type GitIntent } from "../core/git-intent.ts";
|
|
7
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
3
8
|
import { isParseError, parseGateId } from "../core/types.ts";
|
|
9
|
+
import type { Jev } from "../jev/client.ts";
|
|
10
|
+
import { judgeShellIntent } from "../jev/questions/shell-intent.ts";
|
|
4
11
|
import { type ApprovalStore, requestHardStop } from "./approvals.ts";
|
|
5
12
|
|
|
6
13
|
const HARD: ReadonlySet<GitIntent> = new Set([
|
|
@@ -11,14 +18,42 @@ const HARD: ReadonlySet<GitIntent> = new Set([
|
|
|
11
18
|
"no-verify",
|
|
12
19
|
]);
|
|
13
20
|
|
|
14
|
-
export type GitGuardDeps = {
|
|
21
|
+
export type GitGuardDeps = {
|
|
22
|
+
pi: ExtensionAPI;
|
|
23
|
+
approvals: ApprovalStore;
|
|
24
|
+
/** Jev for the current context; absent means the deterministic fast path only. */
|
|
25
|
+
jev?: (ctx: ExtensionContext) => Jev;
|
|
26
|
+
now?: () => Date;
|
|
27
|
+
};
|
|
15
28
|
|
|
16
29
|
/** Registers the `tool_call` hard stop for irreversible git operations in bash. */
|
|
17
30
|
export function registerGitGuard(deps: GitGuardDeps): void {
|
|
18
31
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
19
32
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
20
33
|
const command = event.input.command;
|
|
21
|
-
|
|
34
|
+
let intent = classifyGitCommand(command);
|
|
35
|
+
if (intent === "unknown") {
|
|
36
|
+
const judged =
|
|
37
|
+
deps.jev === undefined ? undefined : await judgeShellIntent(deps.jev(ctx), command);
|
|
38
|
+
if (judged?.ok === true && judged.value.intent !== "unknown") {
|
|
39
|
+
intent = judged.value.intent;
|
|
40
|
+
} else {
|
|
41
|
+
const approved = ctx.hasUI
|
|
42
|
+
? await ctx.ui.confirm(
|
|
43
|
+
"Development system — unclassified command",
|
|
44
|
+
`${redactSecrets(command)}\nCould not classify this command (Jev ${judged?.ok === false ? `unavailable: ${judged.error.kind}` : "not confident"}). It may run an irreversible git operation. Approve?`,
|
|
45
|
+
)
|
|
46
|
+
: false;
|
|
47
|
+
return approved
|
|
48
|
+
? undefined
|
|
49
|
+
: {
|
|
50
|
+
block: true,
|
|
51
|
+
reason: ctx.hasUI
|
|
52
|
+
? "the user declined an unclassifiable command; propose a simpler, explicit git command"
|
|
53
|
+
: "could not classify this command and Jev is unavailable or unsure; run interactively or use an explicit git command",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
22
57
|
if (!HARD.has(intent)) return undefined;
|
|
23
58
|
const gate = parseGateId(`git.${intent}`);
|
|
24
59
|
if (isParseError(gate)) return { block: true, reason: gate.message };
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
|
|
|
2
2
|
import { type Static, Type } from "typebox";
|
|
3
3
|
import { parseDeparture, renderDepartureMarkdown } from "../core/departure.ts";
|
|
4
4
|
import { gateIds, lookupGate } from "../core/gates.ts";
|
|
5
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
5
6
|
import { isParseError, parseGateId } from "../core/types.ts";
|
|
6
7
|
import { appendDecision } from "../state/decision-log.ts";
|
|
7
8
|
import type { SessionState } from "../state/session-state.ts";
|
|
@@ -73,12 +74,14 @@ export function createRecordDepartureTool(
|
|
|
73
74
|
gate,
|
|
74
75
|
tier: info.tier,
|
|
75
76
|
default: info.default,
|
|
76
|
-
chosen: params.chosen,
|
|
77
|
-
why: params.why,
|
|
78
|
-
costIfWrong: params.costIfWrong,
|
|
77
|
+
chosen: redactSecrets(params.chosen),
|
|
78
|
+
why: redactSecrets(params.why),
|
|
79
|
+
costIfWrong: redactSecrets(params.costIfWrong),
|
|
79
80
|
approver: "agent",
|
|
80
81
|
scope,
|
|
81
|
-
...(params.revisitWhen !== undefined
|
|
82
|
+
...(params.revisitWhen !== undefined
|
|
83
|
+
? { revisitWhen: redactSecrets(params.revisitWhen) }
|
|
84
|
+
: {}),
|
|
82
85
|
recordedAt: recordedAt.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
83
86
|
});
|
|
84
87
|
if (isParseError(departure)) return fail(departure.message);
|
|
@@ -33,7 +33,7 @@ export function createRequestApprovalTool(
|
|
|
33
33
|
exposure: "direct",
|
|
34
34
|
async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
|
|
35
35
|
const gate = parseGateId(params.gate);
|
|
36
|
-
if (isParseError(gate) || lookupGate(gate)?.tier !== "hard") {
|
|
36
|
+
if (isParseError(gate) || gate.includes(":") || lookupGate(gate)?.tier !== "hard") {
|
|
37
37
|
const hard = gateIds().filter((id) => lookupGate(id)?.tier === "hard");
|
|
38
38
|
return text(`"${params.gate}" is not a hard gate. Hard gates: ${hard.join(", ")}`, true);
|
|
39
39
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
isToolCallEventType,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { isTestPath } from "../core/test-paths.ts";
|
|
9
|
+
import {
|
|
10
|
+
applyEdits,
|
|
11
|
+
bashDeletedPaths,
|
|
12
|
+
isPureAddition,
|
|
13
|
+
weakeningSignals,
|
|
14
|
+
} from "../core/test-weakening.ts";
|
|
15
|
+
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
16
|
+
import type { Jev } from "../jev/client.ts";
|
|
17
|
+
import { judgeTestChange, WEAKENS_THRESHOLD } from "../jev/questions/test-change.ts";
|
|
18
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
19
|
+
import { type ApprovalStore, requestHardStop } from "./approvals.ts";
|
|
20
|
+
|
|
21
|
+
export type TestGuardDeps = {
|
|
22
|
+
pi: ExtensionAPI;
|
|
23
|
+
state: SessionState;
|
|
24
|
+
approvals: ApprovalStore;
|
|
25
|
+
jev: (ctx: ExtensionContext) => Jev;
|
|
26
|
+
now?: () => Date;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Change = { path: string; before: string; after: string | undefined };
|
|
30
|
+
|
|
31
|
+
type Verdict =
|
|
32
|
+
| { kind: "allow" }
|
|
33
|
+
| { kind: "require-departure"; why: string }
|
|
34
|
+
| { kind: "hard-stop"; why: string };
|
|
35
|
+
|
|
36
|
+
const GATE_ID = "tests.weaken";
|
|
37
|
+
|
|
38
|
+
const describeSignals = (
|
|
39
|
+
deleted: boolean,
|
|
40
|
+
signals: { addsSkip: boolean; emptied: boolean },
|
|
41
|
+
): string | undefined => {
|
|
42
|
+
if (deleted) return "deletes the test file";
|
|
43
|
+
if (signals.addsSkip) return "skips tests";
|
|
44
|
+
if (signals.emptied) return "empties the test file";
|
|
45
|
+
return undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const readIfExists = (file: string): string | undefined => {
|
|
49
|
+
try {
|
|
50
|
+
return readFileSync(file, "utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const repoPath = (cwd: string, path: string): string =>
|
|
57
|
+
isAbsolute(path) ? relative(cwd, path) : path;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
|
|
61
|
+
* departure; a change Jev reads as gaming a failing gate escalates to a hard stop.
|
|
62
|
+
*/
|
|
63
|
+
export function registerTestGuard(deps: TestGuardDeps): void {
|
|
64
|
+
const gate: GateId | undefined = (() => {
|
|
65
|
+
const parsed = parseGateId(GATE_ID);
|
|
66
|
+
return isParseError(parsed) ? undefined : parsed;
|
|
67
|
+
})();
|
|
68
|
+
if (gate === undefined) return;
|
|
69
|
+
|
|
70
|
+
const hasOpenDeparture = (): boolean => {
|
|
71
|
+
const { openDepartures, activeSlice } = deps.state.get();
|
|
72
|
+
return openDepartures.some(
|
|
73
|
+
(d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const consumeOpenDeparture = (): void => {
|
|
78
|
+
const { openDepartures, activeSlice } = deps.state.get();
|
|
79
|
+
const used = openDepartures.find(
|
|
80
|
+
(d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
|
|
81
|
+
);
|
|
82
|
+
if (used?.scope.kind !== "once") return;
|
|
83
|
+
deps.state.update((s) => ({
|
|
84
|
+
...s,
|
|
85
|
+
openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
|
|
86
|
+
}));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
|
|
90
|
+
const deleted = change.after === undefined;
|
|
91
|
+
if (!deleted && isPureAddition(change.before, change.after ?? "")) return { kind: "allow" };
|
|
92
|
+
const signals = deleted
|
|
93
|
+
? { addsSkip: false, emptied: false }
|
|
94
|
+
: weakeningSignals(change.before, change.after ?? "");
|
|
95
|
+
const deterministic = describeSignals(deleted, signals);
|
|
96
|
+
const judged = await judgeTestChange(deps.jev(ctx), {
|
|
97
|
+
path: change.path,
|
|
98
|
+
before: change.before,
|
|
99
|
+
...(change.after !== undefined ? { after: change.after } : {}),
|
|
100
|
+
});
|
|
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
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return deterministic !== undefined
|
|
113
|
+
? { kind: "require-departure", why: `this change ${deterministic}` }
|
|
114
|
+
: { kind: "allow" };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
deps.pi.on("tool_call", async (event, ctx) => {
|
|
118
|
+
const changes: Change[] = [];
|
|
119
|
+
if (isToolCallEventType("bash", event)) {
|
|
120
|
+
for (const raw of bashDeletedPaths(event.input.command)) {
|
|
121
|
+
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;
|
|
136
|
+
}
|
|
137
|
+
|
|
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
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
ClassifierAnswer,
|
|
4
|
+
ClassifierApi,
|
|
5
|
+
ClassifierContext,
|
|
6
|
+
ClassifierModel,
|
|
7
|
+
ClassifierQuestion,
|
|
8
|
+
ClassifierResult,
|
|
9
|
+
JsonObject,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import { err, ok, type Result } from "../core/result.ts";
|
|
12
|
+
import { splitModelRef } from "./models.ts";
|
|
13
|
+
|
|
14
|
+
export type JevAvailability = "online" | "offline" | "unknown";
|
|
15
|
+
export type JevError =
|
|
16
|
+
| { kind: "no-model" }
|
|
17
|
+
| { kind: "timeout" }
|
|
18
|
+
| { kind: "provider"; message: string }
|
|
19
|
+
| { kind: "aborted" };
|
|
20
|
+
|
|
21
|
+
type Classifier = ClassifierModel<ClassifierApi>;
|
|
22
|
+
|
|
23
|
+
/** The slice of pi's `ctx.modelRegistry` that Jev needs; tests pass a fake. */
|
|
24
|
+
export interface ClassifierRegistry {
|
|
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;
|
|
28
|
+
classify(
|
|
29
|
+
model: Classifier,
|
|
30
|
+
context: ClassifierContext,
|
|
31
|
+
options?: { signal?: AbortSignal },
|
|
32
|
+
): Promise<ClassifierResult>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type JevOptions = {
|
|
36
|
+
registry: ClassifierRegistry;
|
|
37
|
+
candidates: readonly string[];
|
|
38
|
+
timeoutMs: number;
|
|
39
|
+
cache: Map<string, ClassifierResult>;
|
|
40
|
+
now(): number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface Jev {
|
|
44
|
+
ask(
|
|
45
|
+
state: JsonObject,
|
|
46
|
+
questions: Record<string, ClassifierQuestion>,
|
|
47
|
+
): Promise<Result<Record<string, ClassifierAnswer>, JevError>>;
|
|
48
|
+
availability(): JevAvailability;
|
|
49
|
+
model(): string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** First candidate `provider/id` that exists in the registry AND has configured auth. */
|
|
53
|
+
export function resolveJevModel(
|
|
54
|
+
registry: ClassifierRegistry,
|
|
55
|
+
candidates: readonly string[],
|
|
56
|
+
): Classifier | undefined {
|
|
57
|
+
for (const ref of candidates) {
|
|
58
|
+
const parts = splitModelRef(ref);
|
|
59
|
+
if (parts === undefined) continue;
|
|
60
|
+
const model = registry.findOfType("classifier", parts.provider, parts.id);
|
|
61
|
+
if (model !== undefined && registry.hasConfiguredAuth(model)) return model;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hashOf = (state: JsonObject, questions: Record<string, ClassifierQuestion>): string =>
|
|
67
|
+
createHash("sha256").update(JSON.stringify({ state, questions })).digest("hex");
|
|
68
|
+
|
|
69
|
+
/** Wraps the host classifier: model resolution, timeout, caching and typed errors. Never rejects. */
|
|
70
|
+
export function createJev(opts: JevOptions): Jev {
|
|
71
|
+
let availability: JevAvailability = "unknown";
|
|
72
|
+
let current: string | undefined;
|
|
73
|
+
|
|
74
|
+
const run = async (
|
|
75
|
+
model: Classifier,
|
|
76
|
+
context: ClassifierContext,
|
|
77
|
+
): Promise<Result<ClassifierResult, JevError>> => {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
const timeout = new Promise<"timeout">((resolve) => {
|
|
81
|
+
timer = setTimeout(() => {
|
|
82
|
+
controller.abort();
|
|
83
|
+
resolve("timeout");
|
|
84
|
+
}, opts.timeoutMs);
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const outcome = await Promise.race([
|
|
88
|
+
opts.registry.classify(model, context, { signal: controller.signal }),
|
|
89
|
+
timeout,
|
|
90
|
+
]);
|
|
91
|
+
if (outcome === "timeout") return err({ kind: "timeout" });
|
|
92
|
+
if (outcome.stopReason === "aborted") return err({ kind: "aborted" });
|
|
93
|
+
if (outcome.stopReason === "error")
|
|
94
|
+
return err({ kind: "provider", message: outcome.errorMessage ?? "classifier error" });
|
|
95
|
+
return ok(outcome);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return err({ kind: "provider", message: e instanceof Error ? e.message : String(e) });
|
|
98
|
+
} finally {
|
|
99
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
async ask(state, questions) {
|
|
105
|
+
const model = resolveJevModel(opts.registry, opts.candidates);
|
|
106
|
+
if (model === undefined) {
|
|
107
|
+
availability = "offline";
|
|
108
|
+
current = undefined;
|
|
109
|
+
return err({ kind: "no-model" });
|
|
110
|
+
}
|
|
111
|
+
current = `${model.provider}/${model.id}`;
|
|
112
|
+
const key = hashOf(state, questions);
|
|
113
|
+
const cached = opts.cache.get(key);
|
|
114
|
+
if (cached !== undefined) return ok(cached.answers);
|
|
115
|
+
const result = await run(model, { state, questions });
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
availability = result.error.kind === "aborted" ? availability : "offline";
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
availability = "online";
|
|
121
|
+
opts.cache.set(key, result.value);
|
|
122
|
+
return ok(result.value.answers);
|
|
123
|
+
},
|
|
124
|
+
availability: () => availability,
|
|
125
|
+
model: () => current,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ClassifierResult } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type ClassifierRegistry,
|
|
5
|
+
createJev,
|
|
6
|
+
type Jev,
|
|
7
|
+
type JevAvailability,
|
|
8
|
+
resolveJevModel,
|
|
9
|
+
} from "./client.ts";
|
|
10
|
+
import { DEFAULT_JEV_CANDIDATES } from "./models.ts";
|
|
11
|
+
|
|
12
|
+
export type JevHolder = {
|
|
13
|
+
/** The Jev bound to this context's model registry (one per registry, shared cache). */
|
|
14
|
+
forContext(ctx: Pick<ExtensionContext, "modelRegistry">): Jev;
|
|
15
|
+
/** Cheap, offline probe of whether a Jev credential is configured. */
|
|
16
|
+
probe(ctx: Pick<ExtensionContext, "modelRegistry">): {
|
|
17
|
+
availability: JevAvailability;
|
|
18
|
+
model: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createJevHolder(opts?: {
|
|
23
|
+
candidates?: readonly string[];
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}): JevHolder {
|
|
27
|
+
const candidates = opts?.candidates ?? DEFAULT_JEV_CANDIDATES;
|
|
28
|
+
const cache = new Map<string, ClassifierResult>();
|
|
29
|
+
const bound = new WeakMap<object, Jev>();
|
|
30
|
+
const registryOf = (ctx: Pick<ExtensionContext, "modelRegistry">): ClassifierRegistry =>
|
|
31
|
+
ctx.modelRegistry;
|
|
32
|
+
return {
|
|
33
|
+
forContext(ctx) {
|
|
34
|
+
const registry = registryOf(ctx);
|
|
35
|
+
const existing = bound.get(registry);
|
|
36
|
+
if (existing !== undefined) return existing;
|
|
37
|
+
const jev = createJev({
|
|
38
|
+
registry,
|
|
39
|
+
candidates,
|
|
40
|
+
timeoutMs: opts?.timeoutMs ?? 15_000,
|
|
41
|
+
cache,
|
|
42
|
+
now: opts?.now ?? Date.now,
|
|
43
|
+
});
|
|
44
|
+
bound.set(registry, jev);
|
|
45
|
+
return jev;
|
|
46
|
+
},
|
|
47
|
+
probe(ctx) {
|
|
48
|
+
const model = resolveJevModel(registryOf(ctx), candidates);
|
|
49
|
+
return model === undefined
|
|
50
|
+
? { availability: "offline", model: undefined }
|
|
51
|
+
: { availability: "online", model: `${model.provider}/${model.id}` };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Default Jev classifier candidates, tried in order; overridable by `[models] jev`. */
|
|
2
|
+
export const DEFAULT_JEV_CANDIDATES: readonly string[] = [
|
|
3
|
+
"typesafe/jev-latest",
|
|
4
|
+
"openrouter/typesafe/jev-latest",
|
|
5
|
+
"opencode/jev-1.13",
|
|
6
|
+
"cloudflare-workers-ai/typesafe/jev",
|
|
7
|
+
"vercel-ai-gateway/typesafe-ai/jev",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/** Splits `provider/id` on the first slash (ids may themselves contain slashes). */
|
|
11
|
+
export function splitModelRef(ref: string): { provider: string; id: string } | undefined {
|
|
12
|
+
const at = ref.indexOf("/");
|
|
13
|
+
if (at <= 0 || at === ref.length - 1) return undefined;
|
|
14
|
+
return { provider: ref.slice(0, at), id: ref.slice(at + 1) };
|
|
15
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { GitIntent } from "../../core/git-intent.ts";
|
|
3
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
4
|
+
import type { Jev, JevError } from "../client.ts";
|
|
5
|
+
|
|
6
|
+
export const SHELL_INTENT_THRESHOLD = 0.6;
|
|
7
|
+
|
|
8
|
+
export const SHELL_INTENT_QUESTION: ClassifierChoiceQuestion = {
|
|
9
|
+
type: "choice",
|
|
10
|
+
instructions:
|
|
11
|
+
"A coding agent is about to run the shell command in `command`. Which irreversible git operation, if any, would running it perform? Consider wrappers, subshells, variables, pipes and scripts. Choose `ordinary` when it performs none of them (including commands that only mention git text).",
|
|
12
|
+
criteria: {
|
|
13
|
+
"force-push":
|
|
14
|
+
"Pushes with --force, --force-with-lease, --mirror, or a +refspec, overwriting remote history",
|
|
15
|
+
"branch-delete-remote": "Deletes a branch or tag on a remote (push --delete, push :ref)",
|
|
16
|
+
"history-rewrite": "Rewrites local history: commit --amend, rebase, filter-branch, filter-repo",
|
|
17
|
+
"destructive-reset":
|
|
18
|
+
"Discards work irrecoverably: reset --hard, clean -fd, checkout -- . over uncommitted changes",
|
|
19
|
+
"no-verify":
|
|
20
|
+
"Skips commit/push hooks: --no-verify, -n on commit, core.hooksPath override, LEFTHOOK=0 / HUSKY=0",
|
|
21
|
+
ordinary: "None of the above; read-only or reversible work",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const INTENTS: readonly GitIntent[] = [
|
|
26
|
+
"force-push",
|
|
27
|
+
"branch-delete-remote",
|
|
28
|
+
"history-rewrite",
|
|
29
|
+
"destructive-reset",
|
|
30
|
+
"no-verify",
|
|
31
|
+
"ordinary",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const isIntent = (value: string): value is GitIntent => INTENTS.some((i) => i === value);
|
|
35
|
+
|
|
36
|
+
/** Pure policy: a confident Jev choice maps to its intent; low confidence or an unknown label → `unknown`. */
|
|
37
|
+
export function intentFromChoice(choice: string, confidence: number): GitIntent {
|
|
38
|
+
if (confidence < SHELL_INTENT_THRESHOLD) return "unknown";
|
|
39
|
+
return isIntent(choice) ? choice : "unknown";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function judgeShellIntent(
|
|
43
|
+
jev: Jev,
|
|
44
|
+
command: string,
|
|
45
|
+
): Promise<Result<{ intent: GitIntent; confidence: number }, JevError>> {
|
|
46
|
+
const asked = await jev.ask({ command }, { intent: SHELL_INTENT_QUESTION });
|
|
47
|
+
if (!asked.ok) return asked;
|
|
48
|
+
const answer = asked.value.intent;
|
|
49
|
+
if (answer?.type !== "choice")
|
|
50
|
+
return err({ kind: "provider", message: "missing shell-intent answer" });
|
|
51
|
+
return ok({
|
|
52
|
+
intent: intentFromChoice(answer.choice, answer.confidence),
|
|
53
|
+
confidence: answer.confidence,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion, ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
3
|
+
import type { Jev, JevError } from "../client.ts";
|
|
4
|
+
|
|
5
|
+
export const WEAKENS_THRESHOLD = 0.7;
|
|
6
|
+
export const MOTIVE_THRESHOLD = 0.6;
|
|
7
|
+
|
|
8
|
+
export type TestChangeMotive = "requirement-change" | "gate-gaming" | "refactor" | "unclear";
|
|
9
|
+
export type TestChangeInput = {
|
|
10
|
+
path: string;
|
|
11
|
+
before?: string;
|
|
12
|
+
after?: string;
|
|
13
|
+
recentFailure?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const WEAKENS_QUESTION: ClassifierBoolQuestion = {
|
|
17
|
+
type: "bool",
|
|
18
|
+
instructions:
|
|
19
|
+
"Does the change to the test file in `path` (from `before` to `after`; `after` absent means deleted) weaken what the tests verify — removing or loosening assertions, skipping or deleting tests, widening expected values, or catching and ignoring failures?",
|
|
20
|
+
criteria: {
|
|
21
|
+
true: "The tests verify less behaviour afterwards (assertions removed or loosened, tests skipped, disabled or deleted)",
|
|
22
|
+
false:
|
|
23
|
+
"The tests verify the same or more behaviour (added tests, stronger assertions, renames, pure refactors)",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MOTIVE_QUESTION: ClassifierChoiceQuestion = {
|
|
28
|
+
type: "choice",
|
|
29
|
+
instructions:
|
|
30
|
+
"Why is the test file in `path` being changed from `before` to `after`? `recentFailure` is the most recent failing output, if any. Choose `gate-gaming` only when the change makes a failing check pass by weakening the check instead of fixing the code.",
|
|
31
|
+
criteria: {
|
|
32
|
+
"requirement-change":
|
|
33
|
+
"The behaviour under test was deliberately changed, so the test must change with it",
|
|
34
|
+
"gate-gaming": "A failing test is being weakened, skipped or deleted to get a green result",
|
|
35
|
+
refactor: "Structure of the test changes with no change in what is verified",
|
|
36
|
+
unclear: "Cannot tell from the evidence",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MOTIVES: readonly TestChangeMotive[] = [
|
|
41
|
+
"requirement-change",
|
|
42
|
+
"gate-gaming",
|
|
43
|
+
"refactor",
|
|
44
|
+
"unclear",
|
|
45
|
+
];
|
|
46
|
+
const isMotive = (value: string): value is TestChangeMotive => MOTIVES.some((m) => m === value);
|
|
47
|
+
|
|
48
|
+
/** Pure policy: low confidence or an unexpected label → `unclear`. */
|
|
49
|
+
export const motiveFromChoice = (choice: string, confidence: number): TestChangeMotive =>
|
|
50
|
+
confidence >= MOTIVE_THRESHOLD && isMotive(choice) ? choice : "unclear";
|
|
51
|
+
|
|
52
|
+
export async function judgeTestChange(
|
|
53
|
+
jev: Jev,
|
|
54
|
+
input: TestChangeInput,
|
|
55
|
+
): Promise<Result<{ weakens: number; motive: TestChangeMotive; confidence: number }, JevError>> {
|
|
56
|
+
const clip = (text: string | undefined): string => (text ?? "").slice(0, 6000);
|
|
57
|
+
const state = {
|
|
58
|
+
path: input.path,
|
|
59
|
+
before: clip(input.before),
|
|
60
|
+
after: input.after === undefined ? "" : clip(input.after),
|
|
61
|
+
deleted: input.after === undefined,
|
|
62
|
+
recentFailure: clip(input.recentFailure),
|
|
63
|
+
};
|
|
64
|
+
const asked = await jev.ask(state, { weakens: WEAKENS_QUESTION, motive: MOTIVE_QUESTION });
|
|
65
|
+
if (!asked.ok) return asked;
|
|
66
|
+
const weakens = asked.value.weakens;
|
|
67
|
+
const motive = asked.value.motive;
|
|
68
|
+
if (weakens?.type !== "bool" || motive?.type !== "choice") {
|
|
69
|
+
return err({ kind: "provider", message: "missing test-change answers" });
|
|
70
|
+
}
|
|
71
|
+
return ok({
|
|
72
|
+
weakens: weakens.probability,
|
|
73
|
+
motive: motiveFromChoice(motive.choice, motive.confidence),
|
|
74
|
+
confidence: motive.confidence,
|
|
75
|
+
});
|
|
76
|
+
}
|