@jwilger/pi-development-system 0.1.2 → 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 +30 -7
- package/package.json +4 -2
- package/src/context/context-tail.ts +39 -0
- package/src/context/status.ts +7 -4
- package/src/core/departure.ts +96 -0
- package/src/core/gates.ts +42 -0
- package/src/core/git-intent.ts +275 -0
- 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 +101 -0
- package/src/gates/git-guard.ts +79 -0
- package/src/gates/record-departure-tool.ts +102 -0
- package/src/gates/request-approval-tool.ts +62 -0
- 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
- package/src/state/decision-log.ts +28 -0
- package/src/state/session-state.ts +8 -25
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Departure, parseDeparture } from "../core/departure.ts";
|
|
3
|
+
import { lookupGate } from "../core/gates.ts";
|
|
4
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
5
|
+
import { type GateId, isParseError } from "../core/types.ts";
|
|
6
|
+
import { appendDecision } from "../state/decision-log.ts";
|
|
7
|
+
import { DEPARTURE_ENTRY_TYPE } from "./record-departure-tool.ts";
|
|
8
|
+
|
|
9
|
+
export const APPROVAL_ENTRY_TYPE = "devsys-approval";
|
|
10
|
+
|
|
11
|
+
type Approval = { id: string; gate: string; command: string; used: boolean };
|
|
12
|
+
|
|
13
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
14
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15
|
+
|
|
16
|
+
/** Pre-granted, single-use approvals for an exact (gate, command) pair, persisted as session entries. */
|
|
17
|
+
export type ApprovalStore = {
|
|
18
|
+
grant(gate: string, command: string, id: string): void;
|
|
19
|
+
/** True (and marks the approval used) when an unused approval exists for this exact pair. */
|
|
20
|
+
consume(gate: string, command: string): boolean;
|
|
21
|
+
rebuildFrom(entries: ReadonlyArray<{ customType: string; data: unknown }>): void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createApprovalStore(pi: ExtensionAPI): ApprovalStore {
|
|
25
|
+
let approvals: Approval[] = [];
|
|
26
|
+
const persist = (a: Approval) => pi.appendEntry(APPROVAL_ENTRY_TYPE, a);
|
|
27
|
+
return {
|
|
28
|
+
grant(gate, command, id) {
|
|
29
|
+
const approval = { id, gate, command: command.trim(), used: false };
|
|
30
|
+
approvals.push(approval);
|
|
31
|
+
persist(approval);
|
|
32
|
+
},
|
|
33
|
+
consume(gate, command) {
|
|
34
|
+
const found = approvals.find(
|
|
35
|
+
(a) => !a.used && a.gate === gate && a.command === command.trim(),
|
|
36
|
+
);
|
|
37
|
+
if (found === undefined) return false;
|
|
38
|
+
found.used = true;
|
|
39
|
+
persist(found);
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
rebuildFrom(entries) {
|
|
43
|
+
const byId = new Map<string, Approval>();
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
if (e.customType !== APPROVAL_ENTRY_TYPE || !isRecord(e.data)) continue;
|
|
46
|
+
const { id, gate, command, used } = e.data;
|
|
47
|
+
if (
|
|
48
|
+
typeof id === "string" &&
|
|
49
|
+
typeof gate === "string" &&
|
|
50
|
+
typeof command === "string" &&
|
|
51
|
+
typeof used === "boolean"
|
|
52
|
+
) {
|
|
53
|
+
byId.set(id, { id, gate, command, used });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
approvals = [...byId.values()];
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type HardStopRequest = {
|
|
62
|
+
pi: ExtensionAPI;
|
|
63
|
+
ctx: ExtensionContext;
|
|
64
|
+
gate: GateId;
|
|
65
|
+
command: string;
|
|
66
|
+
why: string;
|
|
67
|
+
toolCallId: string;
|
|
68
|
+
now?: () => Date;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type HardStopOutcome =
|
|
72
|
+
| { kind: "approved"; departure: Departure }
|
|
73
|
+
| { kind: "declined" }
|
|
74
|
+
| { kind: "unavailable" };
|
|
75
|
+
|
|
76
|
+
/** Asks the user to approve one irreversible action; on approval records it (hard · user · once). */
|
|
77
|
+
export async function requestHardStop(req: HardStopRequest): Promise<HardStopOutcome> {
|
|
78
|
+
if (!req.ctx.hasUI) return { kind: "unavailable" };
|
|
79
|
+
const approved = await req.ctx.ui.confirm(
|
|
80
|
+
"Development system — hard stop",
|
|
81
|
+
`${redactSecrets(req.command)}\nGate: ${req.gate}\nThis is irreversible. Approve once?`,
|
|
82
|
+
);
|
|
83
|
+
if (!approved) return { kind: "declined" };
|
|
84
|
+
const at = (req.now ?? (() => new Date()))();
|
|
85
|
+
const departure = parseDeparture({
|
|
86
|
+
id: `dep-${at.getTime()}-${req.toolCallId}`,
|
|
87
|
+
gate: req.gate,
|
|
88
|
+
tier: "hard",
|
|
89
|
+
default: lookupGate(req.gate)?.default ?? "hard stop",
|
|
90
|
+
chosen: redactSecrets(req.command),
|
|
91
|
+
why: redactSecrets(req.why),
|
|
92
|
+
costIfWrong: "an irreversible git operation is performed",
|
|
93
|
+
approver: "user",
|
|
94
|
+
scope: { kind: "once", toolCallId: req.toolCallId },
|
|
95
|
+
recordedAt: at.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
96
|
+
});
|
|
97
|
+
if (isParseError(departure)) throw new Error(departure.message);
|
|
98
|
+
await appendDecision(req.ctx.cwd, departure, at);
|
|
99
|
+
req.pi.appendEntry(DEPARTURE_ENTRY_TYPE, departure);
|
|
100
|
+
return { kind: "approved", departure };
|
|
101
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
isToolCallEventType,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { classifyGitCommand, type GitIntent } from "../core/git-intent.ts";
|
|
7
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
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";
|
|
11
|
+
import { type ApprovalStore, requestHardStop } from "./approvals.ts";
|
|
12
|
+
|
|
13
|
+
const HARD: ReadonlySet<GitIntent> = new Set([
|
|
14
|
+
"history-rewrite",
|
|
15
|
+
"force-push",
|
|
16
|
+
"branch-delete-remote",
|
|
17
|
+
"destructive-reset",
|
|
18
|
+
"no-verify",
|
|
19
|
+
]);
|
|
20
|
+
|
|
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
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Registers the `tool_call` hard stop for irreversible git operations in bash. */
|
|
30
|
+
export function registerGitGuard(deps: GitGuardDeps): void {
|
|
31
|
+
deps.pi.on("tool_call", async (event, ctx) => {
|
|
32
|
+
if (!isToolCallEventType("bash", event)) return undefined;
|
|
33
|
+
const command = event.input.command;
|
|
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
|
+
}
|
|
57
|
+
if (!HARD.has(intent)) return undefined;
|
|
58
|
+
const gate = parseGateId(`git.${intent}`);
|
|
59
|
+
if (isParseError(gate)) return { block: true, reason: gate.message };
|
|
60
|
+
if (deps.approvals.consume(gate, command)) return undefined;
|
|
61
|
+
const outcome = await requestHardStop({
|
|
62
|
+
pi: deps.pi,
|
|
63
|
+
ctx,
|
|
64
|
+
gate,
|
|
65
|
+
command,
|
|
66
|
+
why: "approved interactively at the hard-stop dialog",
|
|
67
|
+
toolCallId: event.toolCallId,
|
|
68
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
69
|
+
});
|
|
70
|
+
if (outcome.kind === "approved") return undefined;
|
|
71
|
+
return {
|
|
72
|
+
block: true,
|
|
73
|
+
reason:
|
|
74
|
+
outcome.kind === "unavailable"
|
|
75
|
+
? `hard stop ${gate}: requires user approval; run interactively`
|
|
76
|
+
: `hard stop ${gate}: the user declined this command. Do not retry it; propose a reversible alternative.`,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import { parseDeparture, renderDepartureMarkdown } from "../core/departure.ts";
|
|
4
|
+
import { gateIds, lookupGate } from "../core/gates.ts";
|
|
5
|
+
import { redactSecrets } from "../core/redact.ts";
|
|
6
|
+
import { isParseError, parseGateId } from "../core/types.ts";
|
|
7
|
+
import { appendDecision } from "../state/decision-log.ts";
|
|
8
|
+
import type { SessionState } from "../state/session-state.ts";
|
|
9
|
+
|
|
10
|
+
export const DEPARTURE_ENTRY_TYPE = "devsys-departure";
|
|
11
|
+
|
|
12
|
+
const Parameters = Type.Object({
|
|
13
|
+
gate: Type.String({ description: `Gate id. One of: ${gateIds().join(", ")}` }),
|
|
14
|
+
chosen: Type.String({ description: "What you are doing instead of the default" }),
|
|
15
|
+
why: Type.String({ description: "Why the default does not fit here" }),
|
|
16
|
+
costIfWrong: Type.String({ description: "What goes wrong if this judgement is mistaken" }),
|
|
17
|
+
scope: Type.Union([Type.Literal("slice"), Type.Literal("session"), Type.Literal("once")], {
|
|
18
|
+
description: "How long the departure applies",
|
|
19
|
+
}),
|
|
20
|
+
revisitWhen: Type.Optional(Type.String({ description: "Condition that should reopen this" })),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type RecordDepartureDeps = {
|
|
24
|
+
pi: ExtensionAPI;
|
|
25
|
+
state: SessionState;
|
|
26
|
+
now?: () => Date;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const fail = (text: string) => ({
|
|
30
|
+
content: [{ type: "text" as const, text }],
|
|
31
|
+
details: undefined,
|
|
32
|
+
isError: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function resolveScope(kind: "slice" | "session" | "once", toolCallId: string, slice?: string) {
|
|
36
|
+
if (kind === "once") return { kind, toolCallId };
|
|
37
|
+
if (kind === "session") return { kind };
|
|
38
|
+
return slice !== undefined ? { kind, slice } : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `devsys_record_departure`: the sanctioned escape hatch for soft gates. */
|
|
42
|
+
export function createRecordDepartureTool(
|
|
43
|
+
deps: RecordDepartureDeps,
|
|
44
|
+
): ToolDefinition<typeof Parameters> {
|
|
45
|
+
const now = deps.now ?? (() => new Date());
|
|
46
|
+
return {
|
|
47
|
+
name: "devsys_record_departure",
|
|
48
|
+
label: "Record departure",
|
|
49
|
+
description:
|
|
50
|
+
"Record a deliberate departure from a soft default before acting against it. " +
|
|
51
|
+
"Hard stops (git history/force/no-verify) cannot be recorded here; use devsys_request_approval.",
|
|
52
|
+
promptSnippet: "Record a departure from a development-system default (soft gates only)",
|
|
53
|
+
parameters: Parameters,
|
|
54
|
+
exposure: "direct",
|
|
55
|
+
async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
|
|
56
|
+
const gate = parseGateId(params.gate);
|
|
57
|
+
const info = isParseError(gate) ? undefined : lookupGate(gate);
|
|
58
|
+
if (isParseError(gate) || info === undefined) {
|
|
59
|
+
return fail(`Unknown gate "${params.gate}". Registered gates: ${gateIds().join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
if (info.tier === "hard") {
|
|
62
|
+
return fail(
|
|
63
|
+
`Gate ${gate} is a hard stop: it needs the user. Call devsys_request_approval with the command and your reason.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const current = deps.state.get();
|
|
67
|
+
const scope = resolveScope(params.scope, toolCallId, current.activeSlice);
|
|
68
|
+
if (scope === undefined) {
|
|
69
|
+
return fail('scope "slice" needs an active slice; use "session" or "once" instead.');
|
|
70
|
+
}
|
|
71
|
+
const recordedAt = now();
|
|
72
|
+
const departure = parseDeparture({
|
|
73
|
+
id: `dep-${recordedAt.getTime()}-${toolCallId}`,
|
|
74
|
+
gate,
|
|
75
|
+
tier: info.tier,
|
|
76
|
+
default: info.default,
|
|
77
|
+
chosen: redactSecrets(params.chosen),
|
|
78
|
+
why: redactSecrets(params.why),
|
|
79
|
+
costIfWrong: redactSecrets(params.costIfWrong),
|
|
80
|
+
approver: "agent",
|
|
81
|
+
scope,
|
|
82
|
+
...(params.revisitWhen !== undefined
|
|
83
|
+
? { revisitWhen: redactSecrets(params.revisitWhen) }
|
|
84
|
+
: {}),
|
|
85
|
+
recordedAt: recordedAt.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
86
|
+
});
|
|
87
|
+
if (isParseError(departure)) return fail(departure.message);
|
|
88
|
+
const { path } = await appendDecision(ctx.cwd, departure, recordedAt);
|
|
89
|
+
deps.pi.appendEntry(DEPARTURE_ENTRY_TYPE, departure);
|
|
90
|
+
deps.state.update((s) => ({ ...s, openDepartures: [...s.openDepartures, departure] }));
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{
|
|
94
|
+
type: "text" as const,
|
|
95
|
+
text: `Recorded in ${path}:\n\n${renderDepartureMarkdown(departure)}`,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
details: undefined,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import { gateIds, lookupGate } from "../core/gates.ts";
|
|
4
|
+
import { isParseError, parseGateId } from "../core/types.ts";
|
|
5
|
+
import { type ApprovalStore, requestHardStop } from "./approvals.ts";
|
|
6
|
+
|
|
7
|
+
const Parameters = Type.Object({
|
|
8
|
+
gate: Type.String({ description: "A hard gate id, e.g. git.force-push" }),
|
|
9
|
+
command: Type.String({ description: "The exact command you intend to run afterwards" }),
|
|
10
|
+
why: Type.String({ description: "Why this irreversible action is needed" }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const text = (t: string, isError = false) => ({
|
|
14
|
+
content: [{ type: "text" as const, text: t }],
|
|
15
|
+
details: undefined,
|
|
16
|
+
isError,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type RequestApprovalDeps = { pi: ExtensionAPI; approvals: ApprovalStore; now?: () => Date };
|
|
20
|
+
|
|
21
|
+
/** `devsys_request_approval`: ask the user ahead of time to approve one hard-stop command. */
|
|
22
|
+
export function createRequestApprovalTool(
|
|
23
|
+
deps: RequestApprovalDeps,
|
|
24
|
+
): ToolDefinition<typeof Parameters> {
|
|
25
|
+
return {
|
|
26
|
+
name: "devsys_request_approval",
|
|
27
|
+
label: "Request approval",
|
|
28
|
+
description:
|
|
29
|
+
"Ask the user to approve one irreversible command (hard stop). Approval is single-use and " +
|
|
30
|
+
"only valid for the exact command text. Unavailable when running headless.",
|
|
31
|
+
promptSnippet: "Ask the user to approve an irreversible git command (hard stop)",
|
|
32
|
+
parameters: Parameters,
|
|
33
|
+
exposure: "direct",
|
|
34
|
+
async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
|
|
35
|
+
const gate = parseGateId(params.gate);
|
|
36
|
+
if (isParseError(gate) || gate.includes(":") || lookupGate(gate)?.tier !== "hard") {
|
|
37
|
+
const hard = gateIds().filter((id) => lookupGate(id)?.tier === "hard");
|
|
38
|
+
return text(`"${params.gate}" is not a hard gate. Hard gates: ${hard.join(", ")}`, true);
|
|
39
|
+
}
|
|
40
|
+
const outcome = await requestHardStop({
|
|
41
|
+
pi: deps.pi,
|
|
42
|
+
ctx,
|
|
43
|
+
gate,
|
|
44
|
+
command: params.command,
|
|
45
|
+
why: params.why,
|
|
46
|
+
toolCallId,
|
|
47
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
48
|
+
});
|
|
49
|
+
if (outcome.kind === "unavailable") {
|
|
50
|
+
return text("unavailable headless: hard stops need an interactive user.", true);
|
|
51
|
+
}
|
|
52
|
+
if (outcome.kind === "declined") {
|
|
53
|
+
return text(
|
|
54
|
+
"The user declined. Do not run this command; propose a reversible alternative.",
|
|
55
|
+
true,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
deps.approvals.grant(gate, params.command, outcome.departure.id);
|
|
59
|
+
return text(`Approved once for gate ${gate}. Run exactly: ${params.command}`);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -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
|
+
}
|