pi-warden 0.1.2 → 0.2.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/README.md +48 -23
- package/dist/config.d.ts +44 -6
- package/dist/config.js +61 -10
- package/dist/done.d.ts +86 -0
- package/dist/done.js +121 -0
- package/dist/extension.d.ts +3 -1
- package/dist/extension.js +165 -41
- package/dist/guard.d.ts +41 -2
- package/dist/guard.js +75 -12
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/stuck.d.ts +88 -0
- package/dist/stuck.js +148 -0
- package/package.json +2 -2
package/dist/extension.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createTypeSafe, resolveApiKey } from "pi-typesafe";
|
|
2
2
|
import { ensureApiKey } from "pi-typesafe/ui";
|
|
3
|
-
import { applyUserOverrides, defaultConfig, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
4
|
+
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
|
|
5
|
+
import { evaluateAction, formatVerdict, steerReason, textApproves } from "./guard.js";
|
|
6
|
+
import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
|
|
7
|
+
export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; and the agent's final message when it reports completion without running checks. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
6
8
|
const WIDGET = PACKAGE_NAME;
|
|
7
9
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
8
|
-
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0,
|
|
10
|
+
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, errors: 0 });
|
|
9
11
|
function latestUserPrompt(ctx) {
|
|
10
12
|
const entries = ctx.sessionManager.getBranch();
|
|
11
13
|
for (let index = entries.length - 1; index >= 0; index--) {
|
|
@@ -19,14 +21,15 @@ function latestUserPrompt(ctx) {
|
|
|
19
21
|
}
|
|
20
22
|
return undefined;
|
|
21
23
|
}
|
|
22
|
-
function
|
|
23
|
-
const env = process.env.
|
|
24
|
-
|
|
24
|
+
function activeMode(config, hasUI) {
|
|
25
|
+
const env = process.env.PI_WARDEN_MODE?.trim();
|
|
26
|
+
const mode = isMode(env) ? env : config.mode;
|
|
27
|
+
return mode === "confirm" && !hasUI ? "steer" : mode;
|
|
25
28
|
}
|
|
26
29
|
function clip(text) {
|
|
27
30
|
return text.length <= CONFIRM_TEXT_LIMIT ? text : `${text.slice(0, CONFIRM_TEXT_LIMIT)}…`;
|
|
28
31
|
}
|
|
29
|
-
function confirmMessage(verdict) {
|
|
32
|
+
export function confirmMessage(verdict) {
|
|
30
33
|
const { summary } = verdict;
|
|
31
34
|
const lines = [];
|
|
32
35
|
if (summary.command !== undefined)
|
|
@@ -41,46 +44,96 @@ function confirmMessage(verdict) {
|
|
|
41
44
|
lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
|
|
42
45
|
return lines.join("\n");
|
|
43
46
|
}
|
|
47
|
+
const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
|
|
44
48
|
/** Native Pi registration; importing the root library does not load this module. */
|
|
45
49
|
export default function wardenExtension(pi) {
|
|
46
50
|
let client;
|
|
47
51
|
let budgetExhausted = false;
|
|
48
52
|
let stats = freshStats();
|
|
49
|
-
|
|
53
|
+
const widget = new Map();
|
|
54
|
+
/** Calls held in steer mode, with the user prompt current at that time; a retry after the user replies asks Jev about approval. */
|
|
55
|
+
const held = new Map();
|
|
56
|
+
let attempts = new AttemptWindow(defaultConfig().stuck.window);
|
|
57
|
+
let evidence = emptyEvidence();
|
|
58
|
+
let doneNudged = false;
|
|
50
59
|
const configFor = (ctx) => loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
|
|
51
60
|
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
52
61
|
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
53
62
|
const judgeFor = (config) => {
|
|
54
63
|
if (!consentGiven(config) || budgetExhausted || !resolveApiKey())
|
|
55
64
|
return undefined;
|
|
56
|
-
return client ??= createTypeSafe({ maxRequests: config.
|
|
65
|
+
return client ??= createTypeSafe({ maxRequests: config.maxRequests, timeoutMs: config.timeoutMs });
|
|
57
66
|
};
|
|
67
|
+
const noteError = (ctx, message, code) => {
|
|
68
|
+
stats.errors++;
|
|
69
|
+
if (code === "budget")
|
|
70
|
+
budgetExhausted = true;
|
|
71
|
+
if (ctx.hasUI)
|
|
72
|
+
ctx.ui.notify(`warden: ${message}${budgetExhausted ? " Pattern checks continue without TypeSafe for the rest of this session." : ""}`, "warning");
|
|
73
|
+
};
|
|
74
|
+
const show = (ctx, guard, line) => {
|
|
75
|
+
widget.set(guard, line);
|
|
76
|
+
if (ctx.hasUI)
|
|
77
|
+
ctx.ui.setWidget(WIDGET, [...widget.values()]);
|
|
78
|
+
};
|
|
79
|
+
const steer = (content) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: true }, { deliverAs: "steer" });
|
|
58
80
|
pi.on("session_start", async (_event, ctx) => {
|
|
59
81
|
client = undefined;
|
|
60
82
|
budgetExhausted = false;
|
|
61
83
|
stats = freshStats();
|
|
62
|
-
|
|
84
|
+
widget.clear();
|
|
85
|
+
held.clear();
|
|
86
|
+
attempts.reset();
|
|
87
|
+
evidence = emptyEvidence();
|
|
88
|
+
doneNudged = false;
|
|
63
89
|
if (ctx.hasUI)
|
|
64
90
|
ctx.ui.setWidget(WIDGET, undefined);
|
|
65
91
|
});
|
|
92
|
+
// A new user prompt starts a new attempt history and a new done-check budget.
|
|
93
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
94
|
+
attempts = new AttemptWindow(configFor(ctx).stuck.window);
|
|
95
|
+
doneNudged = false;
|
|
96
|
+
});
|
|
97
|
+
// Each low-level run collects its own evidence of changes and checks.
|
|
98
|
+
pi.on("agent_start", async () => {
|
|
99
|
+
evidence = emptyEvidence();
|
|
100
|
+
});
|
|
66
101
|
pi.on("tool_call", async (event, ctx) => {
|
|
67
102
|
const config = configFor(ctx);
|
|
68
103
|
if (!config.enabled || !config.action.enabled || !config.action.tools.includes(event.toolName))
|
|
69
104
|
return;
|
|
70
105
|
stats.inspected++;
|
|
71
|
-
const
|
|
72
|
-
|
|
106
|
+
const task = latestUserPrompt(ctx);
|
|
107
|
+
const key = callKey(event.toolName, event.input);
|
|
108
|
+
const heldAt = held.get(key);
|
|
109
|
+
const retryAfterHold = held.has(key) && heldAt !== task;
|
|
110
|
+
const judge = judgeFor(config);
|
|
111
|
+
const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task }, { config: config.action, judge, signal: ctx.signal, slop: config.slop, retryAfterHold });
|
|
112
|
+
if (verdict.source === "skipped")
|
|
113
|
+
return;
|
|
73
114
|
if (verdict.judgment)
|
|
74
115
|
stats.judged++;
|
|
75
|
-
if (verdict.source === "error")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
116
|
+
if (verdict.source === "error")
|
|
117
|
+
noteError(ctx, verdict.error ?? "TypeSafe request failed.", verdict.errorCode);
|
|
118
|
+
// Offline stand-in for the approval question: the user has replied since the hold and the reply reads as approval.
|
|
119
|
+
if (retryAfterHold && !judge && verdict.level === "confirm" && textApproves(task)) {
|
|
120
|
+
verdict.level = "allow";
|
|
121
|
+
verdict.approvedByUser = true;
|
|
122
|
+
verdict.reasons = ["user approved in the latest message", ...verdict.reasons];
|
|
123
|
+
}
|
|
124
|
+
if (verdict.approvedByUser) {
|
|
125
|
+
stats.approved++;
|
|
126
|
+
held.delete(key);
|
|
127
|
+
}
|
|
128
|
+
if (verdict.source !== "read-only")
|
|
129
|
+
show(ctx, "action", formatVerdict(verdict));
|
|
130
|
+
if (verdict.slopReasons?.length) {
|
|
131
|
+
stats.slop++;
|
|
132
|
+
const where = verdict.summary.path ?? event.toolName;
|
|
79
133
|
if (ctx.hasUI)
|
|
80
|
-
ctx.ui.notify(`warden
|
|
134
|
+
ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
|
|
135
|
+
steer(`pi-warden: the content just written to ${where} reads as ${verdict.slopReasons.join(" and ")}. Replace stubs and placeholders with working code, remove comments that restate the code, and keep only what the request needs. If something is intentionally left unimplemented, say so in your reply instead of leaving it in the code.`);
|
|
81
136
|
}
|
|
82
|
-
if (ctx.hasUI && verdict.source !== "read-only")
|
|
83
|
-
ctx.ui.setWidget(WIDGET, [formatVerdict(verdict)]);
|
|
84
137
|
if (verdict.level === "warn") {
|
|
85
138
|
stats.warned++;
|
|
86
139
|
if (ctx.hasUI)
|
|
@@ -89,29 +142,86 @@ export default function wardenExtension(pi) {
|
|
|
89
142
|
}
|
|
90
143
|
if (verdict.level !== "confirm")
|
|
91
144
|
return undefined;
|
|
92
|
-
|
|
145
|
+
const mode = activeMode(config, ctx.hasUI);
|
|
93
146
|
const reasons = verdict.reasons.join("; ");
|
|
94
|
-
if (
|
|
147
|
+
if (mode === "advise") {
|
|
148
|
+
stats.warned++;
|
|
149
|
+
if (ctx.hasUI)
|
|
150
|
+
ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
if (mode === "confirm") {
|
|
95
154
|
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
96
155
|
if (allowed)
|
|
97
156
|
return undefined;
|
|
98
|
-
stats.
|
|
157
|
+
stats.held++;
|
|
99
158
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${reasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
100
159
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
160
|
+
stats.held++;
|
|
161
|
+
held.set(key, task);
|
|
162
|
+
if (ctx.hasUI)
|
|
163
|
+
ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
|
|
164
|
+
return { block: true, reason: steerReason(verdict, { canApprove: judge !== undefined }) };
|
|
165
|
+
});
|
|
166
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
167
|
+
const config = configFor(ctx);
|
|
168
|
+
if (!config.enabled)
|
|
169
|
+
return;
|
|
170
|
+
const failed = resultFailed(event.isError, event.details);
|
|
171
|
+
if (config.done.enabled)
|
|
172
|
+
recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input);
|
|
173
|
+
if (!config.stuck.enabled)
|
|
174
|
+
return;
|
|
175
|
+
attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
|
|
176
|
+
if (!attempts.shouldJudge(config.stuck))
|
|
177
|
+
return;
|
|
178
|
+
stats.stuckChecks++;
|
|
179
|
+
const verdict = await evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
180
|
+
if (verdict.error)
|
|
181
|
+
noteError(ctx, verdict.error, undefined);
|
|
182
|
+
if (verdict.source === "repeat" && !verdict.stuck)
|
|
183
|
+
return;
|
|
184
|
+
show(ctx, "stuck", formatStuck(verdict));
|
|
185
|
+
if (!verdict.stuck)
|
|
186
|
+
return;
|
|
187
|
+
stats.stuck++;
|
|
188
|
+
if (ctx.hasUI)
|
|
189
|
+
ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${config.stuck.nudge ? " (agent nudged)" : ""}`, "warning");
|
|
190
|
+
if (config.stuck.nudge)
|
|
191
|
+
steer(stuckNudge(verdict));
|
|
105
192
|
});
|
|
106
|
-
|
|
193
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
194
|
+
const config = configFor(ctx);
|
|
195
|
+
if (!config.enabled || !config.done.enabled || doneNudged || !needsDoneCheck(evidence))
|
|
196
|
+
return;
|
|
197
|
+
const finalMessage = finalAssistantText(event.messages);
|
|
198
|
+
const judge = judgeFor(config);
|
|
199
|
+
if (!finalMessage || !judge)
|
|
200
|
+
return;
|
|
201
|
+
stats.doneChecks++;
|
|
202
|
+
const verdict = await evaluateDone(latestUserPrompt(ctx), finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
203
|
+
if (verdict.error)
|
|
204
|
+
noteError(ctx, verdict.error, undefined);
|
|
205
|
+
show(ctx, "done", formatDone(verdict));
|
|
206
|
+
if (!verdict.unverified)
|
|
207
|
+
return;
|
|
208
|
+
stats.unverified++;
|
|
209
|
+
if (ctx.hasUI)
|
|
210
|
+
ctx.ui.notify(`warden · done-check: ${verdict.reasons.join("; ")}${config.done.nudge ? " (agent asked to verify)" : ""}`, "warning");
|
|
211
|
+
if (config.done.nudge) {
|
|
212
|
+
doneNudged = true;
|
|
213
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: doneNudge(verdict), display: true }, { deliverAs: "followUp", triggerTurn: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
const actions = ["status", "enable", "disable", "mode", "config", "test"];
|
|
107
217
|
pi.registerCommand("warden", {
|
|
108
|
-
description: "pi-warden status, TypeSafe consent, config editor, and a synthetic guard test",
|
|
218
|
+
description: "pi-warden status, TypeSafe consent, steer/confirm/advise mode, config editor, and a synthetic guard test",
|
|
109
219
|
getArgumentCompletions(prefix) {
|
|
110
220
|
const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
|
|
111
221
|
return matches.length ? matches : null;
|
|
112
222
|
},
|
|
113
223
|
async handler(args, ctx) {
|
|
114
|
-
const action = args.trim()
|
|
224
|
+
const [action = "status", argument] = args.trim().split(/\s+/);
|
|
115
225
|
const report = (text, level = "info") => {
|
|
116
226
|
if (ctx.hasUI)
|
|
117
227
|
ctx.ui.notify(text, level);
|
|
@@ -124,12 +234,13 @@ export default function wardenExtension(pi) {
|
|
|
124
234
|
const key = resolveApiKey();
|
|
125
235
|
const source = consentSource(config);
|
|
126
236
|
const usage = client?.getUsage();
|
|
237
|
+
const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop"].filter(Boolean).join(", ");
|
|
127
238
|
report([
|
|
128
|
-
`pi-warden: ${config.enabled
|
|
129
|
-
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.
|
|
130
|
-
`Thresholds: irreversible warn ${config.action.irreversible.warn} /
|
|
239
|
+
`pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
|
|
240
|
+
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests.`,
|
|
241
|
+
`Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop quality ${config.slop.quality}, stub ${config.slop.placeholder}; failOpen ${config.action.failOpen}.`,
|
|
131
242
|
`Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
|
|
132
|
-
|
|
243
|
+
widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
|
|
133
244
|
].join(" "));
|
|
134
245
|
return;
|
|
135
246
|
}
|
|
@@ -157,6 +268,15 @@ export default function wardenExtension(pi) {
|
|
|
157
268
|
report(`TypeSafe judgments disabled in ${path}. Offline pattern checks stay active; set enabled to false there to turn pi-warden off entirely.`);
|
|
158
269
|
return;
|
|
159
270
|
}
|
|
271
|
+
if (action === "mode") {
|
|
272
|
+
if (!isMode(argument)) {
|
|
273
|
+
report(`Mode is ${activeMode(config, ctx.hasUI)}${process.env.PI_WARDEN_MODE ? " (from PI_WARDEN_MODE)" : ""}. Use /warden mode steer | confirm | advise. steer holds risky calls and tells the agent why; confirm asks you with a dialog; advise only reports.`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const path = setUserSetting("mode", argument);
|
|
277
|
+
report(`Mode set to ${argument} in ${path}.`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
160
280
|
if (action === "config") {
|
|
161
281
|
if (!ctx.hasUI) {
|
|
162
282
|
report(`Edit ${userConfigPath()} directly. Defaults: ${JSON.stringify(defaultConfig())}`);
|
|
@@ -182,7 +302,7 @@ export default function wardenExtension(pi) {
|
|
|
182
302
|
const path = writeUserConfig(parsed);
|
|
183
303
|
const effective = applyUserOverrides(defaultConfig(), parsed);
|
|
184
304
|
client = undefined;
|
|
185
|
-
report(`Saved ${path}. Effective: guard ${effective.enabled && effective.action.enabled ? "on" : "off"}, TypeSafe ${effective.typesafe ? "on" : "off"}, tools ${effective.action.tools.join(", ")}, irreversible
|
|
305
|
+
report(`Saved ${path}. Effective: guard ${effective.enabled && effective.action.enabled ? "on" : "off"}, mode ${effective.mode}, TypeSafe ${effective.typesafe ? "on" : "off"}, tools ${effective.action.tools.join(", ")}, irreversible hold ≥ ${effective.action.irreversible.confirm}, off-task hold ≥ ${effective.action.offTask.confirm}, stuck ${effective.stuck.enabled ? "on" : "off"}, done-check ${effective.done.enabled ? "on" : "off"}, slop ${effective.slop.enabled ? "on" : "off"}.`);
|
|
186
306
|
return;
|
|
187
307
|
}
|
|
188
308
|
if (action === "test") {
|
|
@@ -190,13 +310,17 @@ export default function wardenExtension(pi) {
|
|
|
190
310
|
if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to api.typesafe.ai and may incur charges. ${disclosure}`))
|
|
191
311
|
return;
|
|
192
312
|
const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
|
|
193
|
-
|
|
194
|
-
ctx.ui.setWidget(WIDGET, [formatVerdict(verdict)]);
|
|
313
|
+
show(ctx, "action", formatVerdict(verdict));
|
|
195
314
|
report(`${formatVerdict(verdict)}${verdict.reasons.length ? ` — ${verdict.reasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
315
|
+
if (verdict.level === "confirm") {
|
|
316
|
+
const mode = activeMode(config, ctx.hasUI);
|
|
317
|
+
if (mode === "confirm" && ctx.hasUI) {
|
|
318
|
+
const allowed = await ctx.ui.confirm("warden: allow this bash call? (demo)", `${confirmMessage(verdict)}\n\nThis is /warden test: nothing runs either way.`);
|
|
319
|
+
report(allowed ? "Demo: you chose Yes, so a real call would have run." : "Demo: you chose No, so a real call would have been blocked and the agent told why.");
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
report(`In ${mode} mode a real call would ${mode === "advise" ? "run with this warning shown to you" : "be held and the agent would read"}: "${steerReason(verdict, { canApprove: judge !== undefined })}"`);
|
|
323
|
+
}
|
|
200
324
|
}
|
|
201
325
|
return;
|
|
202
326
|
}
|
package/dist/guard.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IntegrationErrorCode, TypeSafe } from "pi-typesafe";
|
|
2
|
-
import type { ActionGuardConfig } from "./config.js";
|
|
2
|
+
import type { ActionGuardConfig, SlopGuardConfig } from "./config.js";
|
|
3
3
|
export type Level = "allow" | "warn" | "confirm";
|
|
4
4
|
export type Severity = "destructive" | "risky" | "sensitive";
|
|
5
5
|
export interface PatternHit {
|
|
@@ -37,9 +37,17 @@ export interface Judgment {
|
|
|
37
37
|
offTask: number;
|
|
38
38
|
scope: ScopeLabel;
|
|
39
39
|
scopeConfidence: number;
|
|
40
|
+
/** P(the latest user message approves this exact action); only asked when a previously held call is retried. */
|
|
41
|
+
approved?: number;
|
|
40
42
|
model: string;
|
|
41
43
|
elapsedMs: number;
|
|
42
44
|
}
|
|
45
|
+
export interface SlopJudgment {
|
|
46
|
+
/** 0 focused … 2 sloppy. */
|
|
47
|
+
quality: number;
|
|
48
|
+
/** P(placeholder or stub where working code is needed). */
|
|
49
|
+
placeholder: number;
|
|
50
|
+
}
|
|
43
51
|
export interface Verdict {
|
|
44
52
|
level: Level;
|
|
45
53
|
source: "skipped" | "read-only" | "pattern" | "typesafe" | "error";
|
|
@@ -48,6 +56,11 @@ export interface Verdict {
|
|
|
48
56
|
/** Human-readable reasons without secrets or full commands. */
|
|
49
57
|
reasons: string[];
|
|
50
58
|
judgment?: Judgment;
|
|
59
|
+
slop?: SlopJudgment;
|
|
60
|
+
/** Set when the slop thresholds were crossed; the level itself is never raised by slop. */
|
|
61
|
+
slopReasons?: string[];
|
|
62
|
+
/** True when a previously held call was allowed because the user's latest message approves it. */
|
|
63
|
+
approvedByUser?: boolean;
|
|
51
64
|
/** Safe TypeSafe error message when the judge could not answer. */
|
|
52
65
|
error?: string;
|
|
53
66
|
errorCode?: IntegrationErrorCode;
|
|
@@ -59,6 +72,10 @@ export interface EvaluateOptions {
|
|
|
59
72
|
/** Omit to run offline pattern checks only (no consent, no network). */
|
|
60
73
|
judge?: Judge | undefined;
|
|
61
74
|
signal?: AbortSignal | undefined;
|
|
75
|
+
/** Adds quality questions for write/edit content to the same request. */
|
|
76
|
+
slop?: SlopGuardConfig | undefined;
|
|
77
|
+
/** This exact call was held earlier and the user has replied since: ask whether the reply approves it. */
|
|
78
|
+
retryAfterHold?: boolean | undefined;
|
|
62
79
|
}
|
|
63
80
|
export declare function matchPatterns(tool: string, input: Record<string, unknown>, cwd?: string): PatternHit[];
|
|
64
81
|
export declare function isReadOnlyCommand(command: string): boolean;
|
|
@@ -73,12 +90,25 @@ export declare const questions: {
|
|
|
73
90
|
readonly unclear: "`task` or `action` gives too little information to tell";
|
|
74
91
|
}>;
|
|
75
92
|
};
|
|
76
|
-
export declare
|
|
93
|
+
export declare const slopQuestions: {
|
|
94
|
+
slop_quality: import("pi-typesafe").ScoreQuestion<readonly ["Focused: does what `task` needs, clear names, comments only where they add information", "Some filler: comments that restate the code, minor dead code, hedging or repeated text", "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text"]>;
|
|
95
|
+
slop_placeholder: import("pi-typesafe").NoulQuestion;
|
|
96
|
+
};
|
|
97
|
+
export declare const approvalQuestion: {
|
|
98
|
+
approved: import("pi-typesafe").NoulQuestion;
|
|
99
|
+
};
|
|
100
|
+
export declare function buildRequest(summary: ActionSummary, task: string | undefined, extras?: {
|
|
101
|
+
slop?: boolean;
|
|
102
|
+
approval?: boolean;
|
|
103
|
+
}): {
|
|
77
104
|
state: {
|
|
78
105
|
task: string;
|
|
79
106
|
action: Record<string, string | number | boolean>;
|
|
80
107
|
};
|
|
81
108
|
questions: {
|
|
109
|
+
approved?: import("pi-typesafe").NoulQuestion;
|
|
110
|
+
slop_quality?: import("pi-typesafe").ScoreQuestion<readonly ["Focused: does what `task` needs, clear names, comments only where they add information", "Some filler: comments that restate the code, minor dead code, hedging or repeated text", "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text"]>;
|
|
111
|
+
slop_placeholder?: import("pi-typesafe").NoulQuestion;
|
|
82
112
|
irreversible: import("pi-typesafe").NoulQuestion;
|
|
83
113
|
off_task: import("pi-typesafe").NoulQuestion;
|
|
84
114
|
scope: import("pi-typesafe").ChoiceQuestion<{
|
|
@@ -90,5 +120,14 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
|
|
|
90
120
|
};
|
|
91
121
|
};
|
|
92
122
|
export declare function evaluateAction(action: ActionInput, options: EvaluateOptions): Promise<Verdict>;
|
|
123
|
+
/** Offline stand-in for the approval question when TypeSafe is not available. */
|
|
124
|
+
export declare function textApproves(task: string | undefined): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The text the agent receives when a call is held. It explains the judgment and the two acceptable next moves,
|
|
127
|
+
* so the model re-plans instead of retrying. Contains no command text (the model already has it) and no secrets.
|
|
128
|
+
*/
|
|
129
|
+
export declare function steerReason(verdict: Verdict, options: {
|
|
130
|
+
canApprove: boolean;
|
|
131
|
+
}): string;
|
|
93
132
|
/** One-line rendering for widgets and logs. Includes no command text. */
|
|
94
133
|
export declare function formatVerdict(verdict: Verdict): string;
|
package/dist/guard.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { choice, noul, TypeSafeIntegrationError } from "pi-typesafe";
|
|
4
|
+
import { choice, noul, score, TypeSafeIntegrationError } from "pi-typesafe";
|
|
5
5
|
import { redact } from "./redact.js";
|
|
6
6
|
const LEVEL_RANK = { allow: 0, warn: 1, confirm: 2 };
|
|
7
7
|
const higher = (a, b) => (LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b);
|
|
8
8
|
const TASK_LIMIT = 1500;
|
|
9
9
|
const COMMAND_LIMIT = 2000;
|
|
10
|
-
const EXCERPT_LIMIT =
|
|
11
|
-
const EDIT_LIMIT =
|
|
10
|
+
const EXCERPT_LIMIT = 1500;
|
|
11
|
+
const EDIT_LIMIT = 400;
|
|
12
12
|
function truncate(text, limit) {
|
|
13
13
|
return text.length <= limit ? text : `${text.slice(0, limit)}… [${text.length - limit} more chars]`;
|
|
14
14
|
}
|
|
@@ -186,7 +186,7 @@ export function describeAction(tool, input, cwd) {
|
|
|
186
186
|
return summary;
|
|
187
187
|
}
|
|
188
188
|
// ---------------------------------------------------------------------------
|
|
189
|
-
// TypeSafe request: named state fields,
|
|
189
|
+
// TypeSafe request: named state fields, independent questions. Slop and approval questions join the same request.
|
|
190
190
|
export const questions = {
|
|
191
191
|
irreversible: noul("Would executing `action` destroy, overwrite, or discard data or state that could not be recovered afterwards?", {
|
|
192
192
|
true: "Yes: it force-pushes or rewrites shared history, deletes untracked or uncommitted work, drops or truncates database objects, overwrites files outside the project, publishes or deploys, or removes something no build or checkout can regenerate.",
|
|
@@ -203,13 +203,31 @@ export const questions = {
|
|
|
203
203
|
unclear: "`task` or `action` gives too little information to tell",
|
|
204
204
|
}),
|
|
205
205
|
};
|
|
206
|
-
export
|
|
206
|
+
export const slopQuestions = {
|
|
207
|
+
slop_quality: score("How does the new code or text that `action` writes read as a change for `task`?", [
|
|
208
|
+
"Focused: does what `task` needs, clear names, comments only where they add information",
|
|
209
|
+
"Some filler: comments that restate the code, minor dead code, hedging or repeated text",
|
|
210
|
+
"Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text",
|
|
211
|
+
]),
|
|
212
|
+
slop_placeholder: noul("Does the content `action` writes leave placeholder, stub, mock, or 'implement later' code where `task` needs a working implementation?"),
|
|
213
|
+
};
|
|
214
|
+
export const approvalQuestion = {
|
|
215
|
+
approved: noul("Does `task` (the user's latest message) explicitly approve running `action`, which was held earlier for the user's decision?", {
|
|
216
|
+
true: "Yes: the message says to go ahead with this action or with the deletion, push, reset, or change it performs.",
|
|
217
|
+
false: "No: the message declines, asks for something else, changes the approach, or does not address this action.",
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
function hasContent(summary) {
|
|
221
|
+
return (summary.excerpt?.trim().length ?? 0) > 0 || (summary.edits?.some(edit => edit.newText.trim().length > 0) ?? false);
|
|
222
|
+
}
|
|
223
|
+
export function buildRequest(summary, task, extras = {}) {
|
|
224
|
+
const wantSlop = extras.slop && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary);
|
|
207
225
|
return {
|
|
208
226
|
state: {
|
|
209
227
|
task: task?.trim() ? truncate(task.trim(), TASK_LIMIT) : "(no user request recorded in this session)",
|
|
210
228
|
action: summary,
|
|
211
229
|
},
|
|
212
|
-
questions,
|
|
230
|
+
questions: { ...questions, ...(wantSlop ? slopQuestions : {}), ...(extras.approval ? approvalQuestion : {}) },
|
|
213
231
|
};
|
|
214
232
|
}
|
|
215
233
|
function combineSignals(timeoutMs, signal) {
|
|
@@ -217,6 +235,7 @@ function combineSignals(timeoutMs, signal) {
|
|
|
217
235
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
218
236
|
}
|
|
219
237
|
const percent = (value) => value.toFixed(2);
|
|
238
|
+
const APPROVAL_THRESHOLD = 0.7;
|
|
220
239
|
// ---------------------------------------------------------------------------
|
|
221
240
|
export async function evaluateAction(action, options) {
|
|
222
241
|
const { config, judge } = options;
|
|
@@ -247,15 +266,19 @@ export async function evaluateAction(action, options) {
|
|
|
247
266
|
if (!judge)
|
|
248
267
|
return { level, source: "pattern", summary, patterns, reasons };
|
|
249
268
|
try {
|
|
250
|
-
const
|
|
269
|
+
const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false });
|
|
270
|
+
const result = await judge.evaluate(request, { signal: combineSignals(config.timeoutMs, options.signal) });
|
|
271
|
+
const answers = result.answers;
|
|
251
272
|
const judgment = {
|
|
252
|
-
irreversible:
|
|
253
|
-
offTask:
|
|
254
|
-
scope:
|
|
255
|
-
scopeConfidence:
|
|
273
|
+
irreversible: answers.irreversible.noul,
|
|
274
|
+
offTask: answers.off_task.noul,
|
|
275
|
+
scope: answers.scope.choice,
|
|
276
|
+
scopeConfidence: answers.scope.confidence,
|
|
256
277
|
model: result.model,
|
|
257
278
|
elapsedMs: result.elapsedMs,
|
|
258
279
|
};
|
|
280
|
+
if (typeof answers.approved?.noul === "number")
|
|
281
|
+
judgment.approved = answers.approved.noul;
|
|
259
282
|
if (judgment.irreversible >= config.irreversible.confirm) {
|
|
260
283
|
level = higher(level, "confirm");
|
|
261
284
|
reasons.push(`irreversible ${percent(judgment.irreversible)}`);
|
|
@@ -272,7 +295,23 @@ export async function evaluateAction(action, options) {
|
|
|
272
295
|
level = higher(level, "warn");
|
|
273
296
|
reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
|
|
274
297
|
}
|
|
275
|
-
|
|
298
|
+
const verdict = { level, source: "typesafe", summary, patterns, reasons, judgment };
|
|
299
|
+
if (options.slop?.enabled && typeof answers.slop_quality?.score === "number" && typeof answers.slop_placeholder?.noul === "number") {
|
|
300
|
+
verdict.slop = { quality: answers.slop_quality.score, placeholder: answers.slop_placeholder.noul };
|
|
301
|
+
const slopReasons = [];
|
|
302
|
+
if (verdict.slop.quality >= options.slop.quality)
|
|
303
|
+
slopReasons.push(`quality ${verdict.slop.quality.toFixed(2)}/2 (filler or sloppy)`);
|
|
304
|
+
if (verdict.slop.placeholder >= options.slop.placeholder)
|
|
305
|
+
slopReasons.push(`placeholder or stub code ${percent(verdict.slop.placeholder)}`);
|
|
306
|
+
if (slopReasons.length)
|
|
307
|
+
verdict.slopReasons = slopReasons;
|
|
308
|
+
}
|
|
309
|
+
if (level === "confirm" && judgment.approved !== undefined && judgment.approved >= APPROVAL_THRESHOLD) {
|
|
310
|
+
verdict.level = "allow";
|
|
311
|
+
verdict.approvedByUser = true;
|
|
312
|
+
verdict.reasons = [`user approved in the latest message (${percent(judgment.approved)})`, ...reasons];
|
|
313
|
+
}
|
|
314
|
+
return verdict;
|
|
276
315
|
}
|
|
277
316
|
catch (error) {
|
|
278
317
|
const known = error instanceof TypeSafeIntegrationError ? error : undefined;
|
|
@@ -287,13 +326,37 @@ export async function evaluateAction(action, options) {
|
|
|
287
326
|
return { level, source: "error", summary, patterns, reasons, error: message, ...(known ? { errorCode: known.code } : {}) };
|
|
288
327
|
}
|
|
289
328
|
}
|
|
329
|
+
/** Offline stand-in for the approval question when TypeSafe is not available. */
|
|
330
|
+
export function textApproves(task) {
|
|
331
|
+
return /\b(?:yes|yep|yeah|go ahead|do it|proceed|approved?|confirm(?:ed)?|ok(?:ay)?|sure|please do|run it)\b/i.test(task ?? "") && !/\b(?:no|don't|do not|stop|wait|instead|not)\b/i.test(task ?? "");
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The text the agent receives when a call is held. It explains the judgment and the two acceptable next moves,
|
|
335
|
+
* so the model re-plans instead of retrying. Contains no command text (the model already has it) and no secrets.
|
|
336
|
+
*/
|
|
337
|
+
export function steerReason(verdict, options) {
|
|
338
|
+
const what = verdict.reasons.join("; ");
|
|
339
|
+
const lines = [
|
|
340
|
+
`pi-warden held this ${verdict.summary.tool} call before it ran: ${what}.`,
|
|
341
|
+
"Do not retry it unchanged. Either (1) reach the goal with a recoverable alternative that stays inside the project (a targeted path, a dry run, a move instead of a delete, a normal push), or (2) if this exact action is genuinely required, stop and tell the user in one or two sentences what it does, what cannot be undone, and why it is needed, then wait for their reply.",
|
|
342
|
+
];
|
|
343
|
+
if (options.canApprove)
|
|
344
|
+
lines.push("If the user's reply approves it, retry the same call and pi-warden will let it through.");
|
|
345
|
+
else
|
|
346
|
+
lines.push("pi-warden allows the same call again once the user has replied with approval.");
|
|
347
|
+
return lines.join(" ");
|
|
348
|
+
}
|
|
290
349
|
/** One-line rendering for widgets and logs. Includes no command text. */
|
|
291
350
|
export function formatVerdict(verdict) {
|
|
292
351
|
const parts = [`warden · ${verdict.summary.tool}`];
|
|
293
352
|
if (verdict.judgment)
|
|
294
353
|
parts.push(`irreversible ${percent(verdict.judgment.irreversible)}`, `off-task ${percent(verdict.judgment.offTask)}`, verdict.judgment.scope.replace(/_/g, " "));
|
|
354
|
+
if (verdict.slop)
|
|
355
|
+
parts.push(`slop ${verdict.slop.quality.toFixed(1)}/2`, `stub ${percent(verdict.slop.placeholder)}`);
|
|
295
356
|
if (verdict.patterns.length)
|
|
296
357
|
parts.push(`patterns: ${verdict.patterns.map(hit => hit.id).join(", ")}`);
|
|
358
|
+
if (verdict.approvedByUser)
|
|
359
|
+
parts.push("user approved");
|
|
297
360
|
if (verdict.source === "error")
|
|
298
361
|
parts.push("typesafe error");
|
|
299
362
|
if (verdict.source === "read-only")
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, questions } from "./guard.js";
|
|
2
|
-
export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
|
|
1
|
+
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion } from "./guard.js";
|
|
2
|
+
export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
|
|
3
|
+
export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
|
|
4
|
+
export type { Attempt, StuckJudgment, StuckVerdict, StuckOptions } from "./stuck.js";
|
|
5
|
+
export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
|
|
6
|
+
export type { ToolOutcome, RunEvidence, DoneJudgment, DoneVerdict, DoneOptions } from "./done.js";
|
|
3
7
|
export { redact } from "./redact.js";
|
|
4
|
-
export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|
|
5
|
-
export type { ActionGuardConfig, Threshold, WardenConfig, LoadOptions } from "./config.js";
|
|
8
|
+
export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|
|
9
|
+
export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, questions } from "./guard.js";
|
|
1
|
+
export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion } from "./guard.js";
|
|
2
|
+
export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
|
|
3
|
+
export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
|
|
2
4
|
export { redact } from "./redact.js";
|
|
3
|
-
export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|
|
5
|
+
export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
|