pi-verdict 0.9.1 → 0.11.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 +8 -2
- package/README.zh-CN.md +8 -2
- package/extensions/jev-adapter.ts +21 -4
- package/extensions/pi-verdict.ts +319 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,7 +101,10 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
101
101
|
"classifierModel": null,
|
|
102
102
|
"toggleShortcut": "ctrl+shift+a",
|
|
103
103
|
"audit": false,
|
|
104
|
-
"notifyAllows": false
|
|
104
|
+
"notifyAllows": false,
|
|
105
|
+
"classifierMinConfidence": null,
|
|
106
|
+
"classifierFallbackModel": null,
|
|
107
|
+
"classifierFallbackMode": "shadow"
|
|
105
108
|
}
|
|
106
109
|
```
|
|
107
110
|
|
|
@@ -110,8 +113,9 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
110
113
|
- `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
|
|
111
114
|
- `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
|
|
112
115
|
- `classifierModel: "typesafe/jev-latest"` opts into the bundled **jev decisions adapter** — gray-zone verdicts via TypeSafe's jev (OpenRouter by default, or TypeSafe's official API directly with `PI_VERDICT_JEV_TRANSPORT=typesafe`); experimental, see [ADR-0003](docs/adr/0003-jev-decisions-adapter.md)
|
|
113
|
-
- `audit: true` records every **gray-zone adjudication** (the full transcript sent to the classifier, its raw response, the parsed verdict) as JSONL under `~/.pi/agent/verdicts/<sessionId>.jsonl` — one file per session, the 20 most recent kept. Local-only and full-fidelity (protected-path plaintext may appear — it never leaves your machine; [ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) boundary note); the agent can neither read nor write the directory. `/automode` shows the audit state and path while on
|
|
116
|
+
- `audit: true` records every **gray-zone adjudication** (the full transcript sent to the classifier, its raw response, the parsed verdict) as JSONL under `~/.pi/agent/verdicts/<sessionId>.jsonl` — one file per session, the 20 most recent kept. Interactive asks also record your answer (`userAnswer` ground truth, written after the confirm resolves), and protected-path asks are recorded too (#62); rule allow/deny stays unaudited. Local-only and full-fidelity (protected-path plaintext may appear — it never leaves your machine; [ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) boundary note); the agent can neither read nor write the directory. `/automode` shows the audit state and path while on
|
|
114
117
|
- `notifyAllows: true` notifies on every **classifier allow** (reason + action line — e.g. jev's probability breakdown); default `false` keeps passes silent. Mechanical passes (your own allow rules, protected-path confirms) never notify; shadow-cache annotations stay debug-only; with both switches on the notification appears once
|
|
118
|
+
- `classifierMinConfidence` (optional, [ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)) sets the **confidence floor**: a jev verdict below it is demoted — cascaded to `classifierFallbackModel` if set (`shadow` = the second layer records its opinion and you are asked; `enforce` = the second layer adjudicates, except a demoted deny can never be auto-allowed), otherwise asked of you directly. At/above the floor the first layer is autonomous. A natural pairing: jev first + a haiku-class fallback
|
|
115
119
|
|
|
116
120
|
No built-in allowlist — every "always allow" claim is yours ([why](docs/configuration.md#why-no-built-in-allowlist)). Full reference: [docs/configuration.md](docs/configuration.md).
|
|
117
121
|
|
|
@@ -130,6 +134,8 @@ No built-in allowlist — every "always allow" claim is yours ([why](docs/config
|
|
|
130
134
|
- **Hosts**: pi only. On omp the setting warns and falls back to the session model; and it must never be selected as the session model (no text generation — selecting it warns)
|
|
131
135
|
- **Escape hatch**: `PI_VERDICT_JEV_URL` overrides the active transport's endpoint (OpenRouter's is an alpha API)
|
|
132
136
|
|
|
137
|
+
jev's calibrated confidence is exactly what the confidence floor keys on — pair it with a second layer (`"classifierMinConfidence": 50, "classifierFallbackModel": "anthropic/claude-haiku-4-5"`) so its low-confidence calls go to a deeper model instead of standing ([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)).
|
|
138
|
+
|
|
133
139
|
### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
134
140
|
|
|
135
141
|
The gate's own files — the config and the installed extension copy — are **user-editable only**: writes from inside the gate hard-deny (reads pass); your editor never passes through the gate, the sudoers/visudo precedent.
|
package/README.zh-CN.md
CHANGED
|
@@ -103,7 +103,10 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
103
103
|
"classifierModel": null,
|
|
104
104
|
"toggleShortcut": "ctrl+shift+a",
|
|
105
105
|
"audit": false,
|
|
106
|
-
"notifyAllows": false
|
|
106
|
+
"notifyAllows": false,
|
|
107
|
+
"classifierMinConfidence": null,
|
|
108
|
+
"classifierFallbackModel": null,
|
|
109
|
+
"classifierFallbackMode": "shadow"
|
|
107
110
|
}
|
|
108
111
|
```
|
|
109
112
|
|
|
@@ -112,8 +115,9 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
112
115
|
- `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
|
|
113
116
|
- `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
|
|
114
117
|
- `classifierModel: "typesafe/jev-latest"` 启用随包的 **jev 决策适配器**——灰区裁决经 TypeSafe jev 完成(默认 OpenRouter,或 `PI_VERDICT_JEV_TRANSPORT=typesafe` 直连官方 API);实验性质,详见 [ADR-0003](docs/adr/0003-jev-decisions-adapter.md)
|
|
115
|
-
- `audit: true` 把每次**灰区裁决**(发给分类器的完整转录、其原始响应、解析出的裁决)以 JSONL 记录到 `~/.pi/agent/verdicts/<sessionId>.jsonl`——按会话一分文件,保留最近 20
|
|
118
|
+
- `audit: true` 把每次**灰区裁决**(发给分类器的完整转录、其原始响应、解析出的裁决)以 JSONL 记录到 `~/.pi/agent/verdicts/<sessionId>.jsonl`——按会话一分文件,保留最近 20 个。交互式 ask 还会记录你的应答(`userAnswer` ground truth,确认结束后落盘),protected-path ask 也入审计(#62);规则 allow/deny 仍不入。仅存本机且全保真(受保护路径明文可能出现——永不出本机;[ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) 边界注);agent 对该目录读写双拒。开启时 `/automode` 会显示审计状态与路径
|
|
116
119
|
- `notifyAllows: true` 对每次 **classifier 放行**发通知(reason + action 行——如 jev 的概率分解);默认 `false` 保持放行静默。机械放行(你自己的 allow 规则、protected-path 确认)永不通知;shadow 标注仍属 debug;两开关同开时通知只出现一次
|
|
120
|
+
- `classifierMinConfidence`(可选,[ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))设定**置信地板**:低于它的 jev 裁决被降级——配置了 `classifierFallbackModel` 则级联(`shadow` = 第二层只记录意见、由你裁决;`enforce` = 第二层全权裁决,但降级 deny 永不被自动翻成 allow),否则直接问你。不低于地板时第一层自主。天然搭配:jev 打头 + haiku 级兜底
|
|
117
121
|
|
|
118
122
|
没有内置白名单——每一条「永远放行」声明都归你([为什么](docs/configuration.md#why-no-built-in-allowlist))。完整参考:[docs/configuration.md](docs/configuration.md)。
|
|
119
123
|
|
|
@@ -132,6 +136,8 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
132
136
|
- **宿主**:仅支持pi。omp 上该设置会警告并回退会话模型。也绝不能选作会话主模型(不生成文本,选中即警告)
|
|
133
137
|
- **逃生口**:`PI_VERDICT_JEV_URL` 可覆盖当前 transport 的端点(OpenRouter 侧为 alpha 接口)
|
|
134
138
|
|
|
139
|
+
jev 的校准 confidence 正是置信地板的判定依据——搭配第二层使用(`"classifierMinConfidence": 50, "classifierFallbackModel": "anthropic/claude-haiku-4-5"`),让低置信调用交给更深的模型而非直接生效([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))。
|
|
140
|
+
|
|
135
141
|
### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
136
142
|
|
|
137
143
|
门禁自身的文件——配置与扩展安装副本——**仅用户可改**:门禁之内的写入一律硬 deny(读放行);你的编辑器修改不经门禁,最近的同构先例是 sudoers 必须经 visudo。
|
|
@@ -161,21 +161,38 @@ interface DecisionAnswer {
|
|
|
161
161
|
* (`<verdict>…</verdict>` + one-line reason). Any malformed shape throws —
|
|
162
162
|
* the classifier's fail-closed path owns the fallout. The reason is
|
|
163
163
|
* user-facing (block reasons, ask dialogs): plain percentages, no internal
|
|
164
|
-
* notation.
|
|
164
|
+
* notation. Confidence is hard-required (#63): the decisions contract
|
|
165
|
+
* guarantees it on choice answers, so absence is contract drift and drift
|
|
166
|
+
* fails closed like any malformed shape — the cascade's confidence gate
|
|
167
|
+
* depends on the segment always being present. */
|
|
165
168
|
export function verdictText(parsed: unknown): string {
|
|
166
169
|
const answer = (parsed as { answers?: { verdict?: DecisionAnswer } })?.answers?.verdict;
|
|
167
170
|
const choice = String(answer?.choice ?? "").trim().toLowerCase();
|
|
168
171
|
if (!VERDICTS.includes(choice as Verdict)) {
|
|
169
172
|
throw new Error(`jev adapter: malformed verdict answer (choice=${JSON.stringify(answer?.choice) ?? "missing"})`);
|
|
170
173
|
}
|
|
174
|
+
const conf = answer?.confidence;
|
|
175
|
+
if (typeof conf !== "number" || !Number.isFinite(conf)) {
|
|
176
|
+
throw new Error(`jev adapter: verdict answer missing numeric confidence (confidence=${JSON.stringify(conf) ?? "missing"})`);
|
|
177
|
+
}
|
|
171
178
|
const probs = (answer?.probabilities ?? {}) as Record<string, unknown>;
|
|
172
179
|
const pct = (n: unknown): string => `${Math.round((typeof n === "number" && Number.isFinite(n) ? n : 0) * 100)}%`;
|
|
173
180
|
const rest = VERDICTS.filter((v) => v !== choice)
|
|
174
181
|
.map((v) => `${v} ${pct(probs[v])}`)
|
|
175
182
|
.join(", ");
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
// The confidence segment floors instead of rounding: the cascade gate parses it back
|
|
184
|
+
// with a strict-below threshold, and overstating a 49.6% as 50% would slip past a 50
|
|
185
|
+
// gate. The 1e-9 epsilon only absorbs FP representation error (0.29*100 = 28.999…).
|
|
186
|
+
return `<verdict>${choice}</verdict> jev: ${choice} ${pct(probs[choice])} (confidence ${Math.floor(conf * 100 + 1e-9)}%; ${rest})`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** #63: parse the confidence back out of a `verdictText` reason. Returns null for any
|
|
190
|
+
* non-jev reason — LLM classifiers emit free text and carry no numeric confidence
|
|
191
|
+
* (their gate is ask/fail-closed only). jev reasons always carry the segment
|
|
192
|
+
* (hard-required in verdictText). Format pinned by tests/jev-adapter.test.ts. */
|
|
193
|
+
export function parseJevConfidence(reason: string): number | null {
|
|
194
|
+
const m = /jev: (?:allow|ask|deny) \d+% \(confidence (\d+)%/.exec(reason);
|
|
195
|
+
return m ? Number(m[1]) : null;
|
|
179
196
|
}
|
|
180
197
|
|
|
181
198
|
function mapUsage(u: unknown): AssistantMessage["usage"] {
|
package/extensions/pi-verdict.ts
CHANGED
|
@@ -91,6 +91,7 @@ import * as os from "node:os";
|
|
|
91
91
|
import * as path from "node:path";
|
|
92
92
|
import { fileURLToPath } from "node:url";
|
|
93
93
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
94
|
+
import { parseJevConfidence } from "./jev-adapter";
|
|
94
95
|
|
|
95
96
|
// ============================================================================
|
|
96
97
|
// 规则层:bash
|
|
@@ -263,9 +264,19 @@ interface UserRules {
|
|
|
263
264
|
audit: boolean;
|
|
264
265
|
/** Allow visibility (#60): info notification on classifier allows; mechanical passes stay silent. Default off. */
|
|
265
266
|
notifyAllows: boolean;
|
|
267
|
+
/** #67: autonomy floor for the first layer — a jev verdict with confidence strictly
|
|
268
|
+
* below this is demoted (cascaded to the fallback if configured, else asked of the
|
|
269
|
+
* user; non-interactive degrades to deny). null = floor off. */
|
|
270
|
+
classifierMinConfidence: number | null;
|
|
271
|
+
/** #63/#67: second-layer model spec (provider/id[:thinking]); consulted on demotion
|
|
272
|
+
* and fail-closed only. null = no second layer. */
|
|
273
|
+
classifierFallbackModel: string | null;
|
|
274
|
+
/** #67: does the second layer adjudicate cascaded calls ("enforce") or only record its
|
|
275
|
+
* opinion while the human decides ("shadow", default)? */
|
|
276
|
+
classifierFallbackMode: "shadow" | "enforce";
|
|
266
277
|
}
|
|
267
278
|
|
|
268
|
-
const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false };
|
|
279
|
+
const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false, classifierMinConfidence: null, classifierFallbackModel: null, classifierFallbackMode: "shadow" };
|
|
269
280
|
|
|
270
281
|
/** This module's own file location (import.meta.url resolved; null = unresolvable). */
|
|
271
282
|
const OWN_FILE_PATH: string | null = (() => {
|
|
@@ -314,7 +325,7 @@ function userConfigPath(): string {
|
|
|
314
325
|
}
|
|
315
326
|
|
|
316
327
|
const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
317
|
-
_hint: "pi-verdict user rules — full reference: https://github.com/jesset/pi-verdict/blob/main/docs/configuration.md. deny beats allow. denyPaths: protected paths, any touch asks for your confirmation (non-interactive degrades to deny); the pre-filled starter list is your declaration, edit or empty freely. builtinDenyFloor=false disables the built-in danger floor at your own risk (the self-protection layer always stays on). classifierModel pins the classifier (provider/id, e.g. zai/glm-5.3-flash; empty = session model). toggleShortcut sets the master-switch toggle key (null or empty disables). This file is part of the permission gate: agent-side modification is denied — edit it manually outside pi. Changes apply to new sessions.",
|
|
328
|
+
_hint: "pi-verdict user rules — full reference: https://github.com/jesset/pi-verdict/blob/main/docs/configuration.md. deny beats allow. denyPaths: protected paths, any touch asks for your confirmation (non-interactive degrades to deny); the pre-filled starter list is your declaration, edit or empty freely. builtinDenyFloor=false disables the built-in danger floor at your own risk (the self-protection layer always stays on). classifierModel pins the classifier (provider/id, e.g. zai/glm-5.3-flash; empty = session model). classifierFallbackModel (optional) adds a second-layer classifier consulted only when the first layer is uncertain (ask / fail-closed / jev confidence below classifierFallbackConfidence, default 50); mode shadow (default) observes without changing verdicts, enforce escalates strictness only. toggleShortcut sets the master-switch toggle key (null or empty disables). This file is part of the permission gate: agent-side modification is denied — edit it manually outside pi. Changes apply to new sessions.",
|
|
318
329
|
allow: ["^ls\\b"],
|
|
319
330
|
deny: [],
|
|
320
331
|
denyPaths: [
|
|
@@ -330,6 +341,9 @@ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
|
330
341
|
toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
|
|
331
342
|
audit: false,
|
|
332
343
|
notifyAllows: false,
|
|
344
|
+
classifierMinConfidence: null,
|
|
345
|
+
classifierFallbackModel: null,
|
|
346
|
+
classifierFallbackMode: "shadow",
|
|
333
347
|
}, null, 2)}\n`;
|
|
334
348
|
|
|
335
349
|
/**
|
|
@@ -347,7 +361,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
347
361
|
} catch { /* 只读环境静默跳过 */ }
|
|
348
362
|
return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
|
|
349
363
|
}
|
|
350
|
-
let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown };
|
|
364
|
+
let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown; classifierFallbackModel?: unknown; classifierFallbackConfidence?: unknown; classifierMinConfidence?: unknown; classifierFallbackMode?: unknown };
|
|
351
365
|
try {
|
|
352
366
|
raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
|
|
353
367
|
} catch (err) {
|
|
@@ -376,6 +390,13 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
376
390
|
return [x.trim()];
|
|
377
391
|
});
|
|
378
392
|
const shortcut = resolveToggleShortcut(raw.toggleShortcut);
|
|
393
|
+
// #63/#67: confidence-floor keys — invalid values skip into the one-shot warning channel
|
|
394
|
+
if (raw.classifierFallbackConfidence !== undefined) skipped.push("classifierFallbackConfidence: renamed to classifierMinConfidence (0.11.0) — key ignored");
|
|
395
|
+
const minConfRaw = raw.classifierMinConfidence;
|
|
396
|
+
const minConfOk = typeof minConfRaw === "number" && Number.isFinite(minConfRaw) && minConfRaw >= 0 && minConfRaw <= 100;
|
|
397
|
+
if (minConfRaw !== undefined && minConfRaw !== null && !minConfOk) skipped.push(`classifierMinConfidence: ${JSON.stringify(minConfRaw)}`);
|
|
398
|
+
const fbModeRaw = raw.classifierFallbackMode;
|
|
399
|
+
if (fbModeRaw !== undefined && fbModeRaw !== "shadow" && fbModeRaw !== "enforce") skipped.push(`classifierFallbackMode: ${JSON.stringify(fbModeRaw)}`);
|
|
379
400
|
return {
|
|
380
401
|
rules: {
|
|
381
402
|
allow: compile(raw.allow),
|
|
@@ -386,6 +407,9 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
386
407
|
toggleShortcut: shortcut.key,
|
|
387
408
|
audit: raw.audit === true,
|
|
388
409
|
notifyAllows: raw.notifyAllows === true,
|
|
410
|
+
classifierFallbackModel: typeof raw.classifierFallbackModel === "string" && raw.classifierFallbackModel.trim() ? raw.classifierFallbackModel.trim() : null,
|
|
411
|
+
classifierMinConfidence: minConfOk ? minConfRaw : null,
|
|
412
|
+
classifierFallbackMode: fbModeRaw === "enforce" ? "enforce" : "shadow",
|
|
389
413
|
},
|
|
390
414
|
skipped,
|
|
391
415
|
shortcutWarning: shortcut.warning,
|
|
@@ -1052,6 +1076,7 @@ interface ClassifierOutcome {
|
|
|
1052
1076
|
}
|
|
1053
1077
|
|
|
1054
1078
|
const CLASSIFIER_TIMEOUT_MS = 25_000; // 本网关 CC 分类器分布 p90=19.8s(15s 会误杀 ~15%),research/cache-sim 数据
|
|
1079
|
+
const FALLBACK_TIMEOUT_MS = 15_000; // #63: second-layer per-attempt budget — matches the first layer's per-attempt discipline (the two-tier retry can spend it twice)
|
|
1055
1080
|
const CLASSIFIER_MAX_TOKENS = 512;
|
|
1056
1081
|
const CLASSIFIER_RETRY_MAX_TOKENS = 1024; // 防御重试档:覆盖无视 reasoning:off 或轻思考仍超预算的模型
|
|
1057
1082
|
const APIS_WITHOUT_TEMPERATURE = new Set<string>([
|
|
@@ -1143,11 +1168,12 @@ async function callClassifierOnce(
|
|
|
1143
1168
|
maxTokens: number,
|
|
1144
1169
|
thinking: ThinkingLevel = "off",
|
|
1145
1170
|
systemPrompt: string = CLASSIFIER_SYSTEM,
|
|
1171
|
+
timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
|
|
1146
1172
|
): Promise<{ ok: true; text: string; stopReason: string; errorMessage?: string } | { ok: false; error: string }> {
|
|
1147
1173
|
const fire = async (
|
|
1148
1174
|
withTemperature: boolean,
|
|
1149
1175
|
): Promise<{ ok: true; text: string; stopReason: string; errorMessage?: string } | { ok: false; error: string }> => {
|
|
1150
|
-
const signals = [AbortSignal.timeout(
|
|
1176
|
+
const signals = [AbortSignal.timeout(timeoutMs)];
|
|
1151
1177
|
if (signal) signals.push(signal);
|
|
1152
1178
|
try {
|
|
1153
1179
|
const response = await complete(
|
|
@@ -1216,6 +1242,7 @@ async function classifyWithModel(
|
|
|
1216
1242
|
actionLine: string,
|
|
1217
1243
|
thinking: ThinkingLevel = "off",
|
|
1218
1244
|
denyPathsActive = false,
|
|
1245
|
+
timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
|
|
1219
1246
|
): Promise<ClassifierOutcome> {
|
|
1220
1247
|
const transcript = buildTranscript(host, actionLine);
|
|
1221
1248
|
const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
|
|
@@ -1225,7 +1252,7 @@ async function classifyWithModel(
|
|
|
1225
1252
|
let rawResponse = ""; // #54: raw output of the last attempt ("" for exception attempts — diagnostics already live in failures)
|
|
1226
1253
|
for (const [n, maxTokens] of attempts) {
|
|
1227
1254
|
if (signal?.aborted) break; // 用户已取消,不再重试
|
|
1228
|
-
const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt);
|
|
1255
|
+
const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt, timeoutMs);
|
|
1229
1256
|
if (r.ok) {
|
|
1230
1257
|
rawResponse = r.text;
|
|
1231
1258
|
const diag = `stopReason=${r.stopReason}, model=${model.id}, errorMessage=${JSON.stringify(r.errorMessage ?? null)}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
|
|
@@ -1362,6 +1389,44 @@ function shadowTag(probe: ShadowProbe): string {
|
|
|
1362
1389
|
return `(shadow cache: miss:no-entry)`;
|
|
1363
1390
|
}
|
|
1364
1391
|
|
|
1392
|
+
// ============================================================================
|
|
1393
|
+
// Confidence cascade stats (#63/#67: observe-first, session-memory state; the #7 discipline)
|
|
1394
|
+
// ============================================================================
|
|
1395
|
+
|
|
1396
|
+
interface FallbackStats {
|
|
1397
|
+
triggered: number; // the floor fired or the first layer fail-closed (with a fallback configured)
|
|
1398
|
+
agreed: number; // fallback verdict equals the first layer's (fail-closed defaults to deny)
|
|
1399
|
+
overruled: number; // fallback verdict differs (enforce applies it; shadow observes the would-be)
|
|
1400
|
+
errored: number; // fallback unresolvable or its call failed
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
class FallbackCascade {
|
|
1404
|
+
readonly stats: FallbackStats = { triggered: 0, agreed: 0, overruled: 0, errored: 0 };
|
|
1405
|
+
|
|
1406
|
+
/** Session reset (#7 discipline: session-memory state) */
|
|
1407
|
+
reset(): void {
|
|
1408
|
+
Object.assign(this.stats, { triggered: 0, agreed: 0, overruled: 0, errored: 0 });
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
note(first: "allow" | "ask" | "deny" | null, fb: "allow" | "ask" | "deny" | null): void {
|
|
1412
|
+
this.stats.triggered++;
|
|
1413
|
+
if (fb === null) {
|
|
1414
|
+
this.stats.errored++;
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
// A fail-closed origin produced no first-layer verdict; its default outcome is deny
|
|
1418
|
+
if ((first ?? "deny") !== fb) this.stats.overruled++;
|
|
1419
|
+
else this.stats.agreed++;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** Summary line for /automode */
|
|
1423
|
+
summary(mode: "shadow" | "enforce"): string {
|
|
1424
|
+
const s = this.stats;
|
|
1425
|
+
if (s.triggered === 0) return "confidence cascade: not triggered this session";
|
|
1426
|
+
return `confidence cascade (${mode}): triggered ${s.triggered} · agreed ${s.agreed} · ${mode === "enforce" ? "overruled" : "would-overrule"} ${s.overruled} · errored ${s.errored}`;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1365
1430
|
// ============================================================================
|
|
1366
1431
|
// Gray-zone verdict audit (#54): opt-in JSONL decision records, observe-only
|
|
1367
1432
|
// (never an adjudication input)
|
|
@@ -1369,7 +1434,26 @@ function shadowTag(probe: ShadowProbe): string {
|
|
|
1369
1434
|
|
|
1370
1435
|
const AUDIT_KEEP_SESSIONS = 20;
|
|
1371
1436
|
|
|
1372
|
-
/**
|
|
1437
|
+
/** #63/#67: second-layer outcome on a cascaded call. The record's top-level fields keep
|
|
1438
|
+
* first-layer semantics for corpus comparability; the verdict actually applied under
|
|
1439
|
+
* enforce lives in `effective` (failure rows carry the ask the human got). */
|
|
1440
|
+
export interface FallbackAudit {
|
|
1441
|
+
model: string;
|
|
1442
|
+
mode: "shadow" | "enforce";
|
|
1443
|
+
triggeredBy: "confidence" | "fail-closed";
|
|
1444
|
+
/** jev confidence that fired the floor; null unless triggeredBy = "confidence" */
|
|
1445
|
+
confidence: number | null;
|
|
1446
|
+
/** null = the fallback call itself failed (unresolvable model, timeout, parse) */
|
|
1447
|
+
verdict: "allow" | "ask" | "deny" | null;
|
|
1448
|
+
reason: string | null;
|
|
1449
|
+
durationMs: number;
|
|
1450
|
+
error: string | null;
|
|
1451
|
+
/** enforce mode only: the verdict applied (pre headless-degradation) */
|
|
1452
|
+
effective?: "allow" | "ask" | "deny";
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** One adjudication record (#54; #62 widened the surface to protected-path asks and
|
|
1456
|
+
* added the ground-truth fields). Full fidelity on purpose: the file is
|
|
1373
1457
|
* local-trust-domain (same as pi-verdict.json, per the ADR-0002 boundary note),
|
|
1374
1458
|
* so protected-path plaintext is allowed here — it never leaves the machine nor
|
|
1375
1459
|
* flows into agent context. */
|
|
@@ -1386,9 +1470,22 @@ export interface AuditRecord {
|
|
|
1386
1470
|
rawResponse: string | null;
|
|
1387
1471
|
verdict: "allow" | "ask" | "deny";
|
|
1388
1472
|
reason: string;
|
|
1389
|
-
|
|
1473
|
+
/** #62: protected-path asks are recorded too — their user answers grade the
|
|
1474
|
+
* denyPaths rules; rule allow/deny verdicts remain unaudited. */
|
|
1475
|
+
source: "model" | "fail-closed" | "protected-path";
|
|
1390
1476
|
shadow: string;
|
|
1391
1477
|
degraded: boolean;
|
|
1478
|
+
/** #62 ground truth: the user's answer to an interactive ask confirm. Present only
|
|
1479
|
+
* on records whose confirm actually ran; headless/degraded asks omit it. */
|
|
1480
|
+
userAnswer?: "allowed" | "declined";
|
|
1481
|
+
/** #62: ISO timestamp of the confirm resolution; `ts` stays adjudication time. */
|
|
1482
|
+
answeredAt?: string;
|
|
1483
|
+
/** #62: protected-path records only — the matched path. */
|
|
1484
|
+
detail?: string;
|
|
1485
|
+
/** #67: the confidence floor fired — the first-layer verdict was demoted. */
|
|
1486
|
+
demoted?: true;
|
|
1487
|
+
/** #63/#67: second-layer outcome when the fallback was consulted. */
|
|
1488
|
+
fallback?: FallbackAudit;
|
|
1392
1489
|
}
|
|
1393
1490
|
|
|
1394
1491
|
/** Audit sink (#54): append-only and fail-soft (the first write failure surfaces
|
|
@@ -1458,6 +1555,7 @@ export class AuditLog {
|
|
|
1458
1555
|
export class SessionState {
|
|
1459
1556
|
readonly prot: ProtectedSet;
|
|
1460
1557
|
readonly shadow = new ShadowCache();
|
|
1558
|
+
readonly fallback = new FallbackCascade();
|
|
1461
1559
|
userRules: UserRules;
|
|
1462
1560
|
audit: AuditLog | null;
|
|
1463
1561
|
private denyPathBases: string[] | null = null;
|
|
@@ -1482,6 +1580,7 @@ export class SessionState {
|
|
|
1482
1580
|
this.userRules = loaded.rules;
|
|
1483
1581
|
this.denyPathBases = anchorDenyPaths(loaded.rules.denyPaths, cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1484
1582
|
this.shadow.reset();
|
|
1583
|
+
this.fallback.reset();
|
|
1485
1584
|
this.audit = this.makeAudit(loaded.rules);
|
|
1486
1585
|
return { skipped: loaded.skipped, shortcutWarning: loaded.shortcutWarning };
|
|
1487
1586
|
}
|
|
@@ -1513,10 +1612,16 @@ export interface Verdict {
|
|
|
1513
1612
|
source: VerdictSource;
|
|
1514
1613
|
degraded: boolean;
|
|
1515
1614
|
shadow?: string;
|
|
1615
|
+
/** #62: pending audit record for an interactive ask — adjudicate defers the append so
|
|
1616
|
+
* the handler can attach the user's answer after the confirm resolves. The handler
|
|
1617
|
+
* owns the single finalize: append with userAnswer/answeredAt, or without them when
|
|
1618
|
+
* presentation throws. Unset for every non-interactive verdict. */
|
|
1619
|
+
pendingAudit?: AuditRecord;
|
|
1516
1620
|
}
|
|
1517
1621
|
|
|
1518
1622
|
/** 逐调用环境:呈现无关的宿主能力。model 经 getModel 惰性求值——保持「仅灰区才
|
|
1519
|
-
* 解析」的原行为(回退警告不会出现在规则已裁决的调用上);null → fail-closed。
|
|
1623
|
+
* 解析」的原行为(回退警告不会出现在规则已裁决的调用上);null → fail-closed。
|
|
1624
|
+
* getFallbackModel(#63)更惰性:仅在门控触发后才解析。 */
|
|
1520
1625
|
export interface AdjudicateEnv {
|
|
1521
1626
|
cwd: string;
|
|
1522
1627
|
hasUI: boolean;
|
|
@@ -1524,6 +1629,81 @@ export interface AdjudicateEnv {
|
|
|
1524
1629
|
complete: CompletionFn;
|
|
1525
1630
|
host: PipelineHost;
|
|
1526
1631
|
signal?: AbortSignal;
|
|
1632
|
+
getFallbackModel?: () => { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** #67: the confidence floor. Below it the first layer abstains and the call cascades —
|
|
1636
|
+
* to the fallback if configured, else to the human (headless degrades to deny). Numeric
|
|
1637
|
+
* confidence exists only on jev-formatted reasons; LLM first layers never demote. */
|
|
1638
|
+
function confidenceDemotion(outcome: ClassifierOutcome, rules: UserRules): { confidence: number } | null {
|
|
1639
|
+
if (rules.classifierMinConfidence === null || outcome.source === "fail-closed") return null;
|
|
1640
|
+
const conf = parseJevConfidence(outcome.reason);
|
|
1641
|
+
if (conf !== null && conf < rules.classifierMinConfidence) return { confidence: conf };
|
|
1642
|
+
return null;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
interface CascadeResult {
|
|
1646
|
+
/** set whenever the confidence floor fired (with or without a fallback) */
|
|
1647
|
+
demoted?: true;
|
|
1648
|
+
/** audit material; present when the fallback was consulted */
|
|
1649
|
+
fb?: FallbackAudit;
|
|
1650
|
+
/** the applied outcome when the cascade changes it (pre-degradation — the caller's
|
|
1651
|
+
* tail applies the usual headless ask → deny rule) */
|
|
1652
|
+
effective?: { verdict: "allow" | "ask" | "deny"; reason: string; source: "classifier" | "fail-closed" };
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** #67: run the cascade for one triggered call. `first` is the first-layer verdict, or
|
|
1656
|
+
* null when the first layer never produced one (fail-closed origin). Semantics:
|
|
1657
|
+
* - demotion with no fallback → ask the human
|
|
1658
|
+
* - shadow → the fallback records its opinion; a demotion still asks the human, a
|
|
1659
|
+
* fail-closed deny stands
|
|
1660
|
+
* - enforce → the fallback adjudicates de novo, with one carve-out: a demoted first-layer
|
|
1661
|
+
* deny may not be flipped to an automatic allow — the human decides
|
|
1662
|
+
* - fallback failure/unresolvable on a cascaded call → ask the human (the tier that was
|
|
1663
|
+
* to adjudicate is down); headless degrades downstream */
|
|
1664
|
+
async function runConfidenceCascade(
|
|
1665
|
+
state: SessionState,
|
|
1666
|
+
env: AdjudicateEnv,
|
|
1667
|
+
first: { verdict: "allow" | "ask" | "deny"; reason: string } | null,
|
|
1668
|
+
trigger: { kind: "demotion"; confidence: number } | { kind: "fail-closed" },
|
|
1669
|
+
denyPathsActive: boolean,
|
|
1670
|
+
actionLine: string,
|
|
1671
|
+
): Promise<CascadeResult> {
|
|
1672
|
+
const rules = state.userRules;
|
|
1673
|
+
const demotionAsk = (): CascadeResult["effective"] => ({
|
|
1674
|
+
verdict: "ask",
|
|
1675
|
+
reason: `${first!.reason} (confidence ${trigger.kind === "demotion" ? trigger.confidence : "?"}% is below your classifierMinConfidence of ${rules.classifierMinConfidence}%)`,
|
|
1676
|
+
source: "classifier",
|
|
1677
|
+
});
|
|
1678
|
+
const getFb = env.getFallbackModel;
|
|
1679
|
+
if (!rules.classifierFallbackModel || !getFb) {
|
|
1680
|
+
// A fail-closed without a fallback keeps its deny; a demotion asks the human
|
|
1681
|
+
return trigger.kind === "demotion" ? { demoted: true, effective: demotionAsk() } : {};
|
|
1682
|
+
}
|
|
1683
|
+
const mode = rules.classifierFallbackMode;
|
|
1684
|
+
const start = Date.now();
|
|
1685
|
+
const base = { mode, triggeredBy: trigger.kind === "demotion" ? ("confidence" as const) : ("fail-closed" as const), confidence: trigger.kind === "demotion" ? trigger.confidence : null };
|
|
1686
|
+
const demotedMark = trigger.kind === "demotion" ? ({ demoted: true } as const) : {};
|
|
1687
|
+
const shadowApplied = trigger.kind === "demotion" ? { effective: demotionAsk() } : {};
|
|
1688
|
+
const failed = (model: string, error: string): CascadeResult => {
|
|
1689
|
+
state.fallback.note(first?.verdict ?? null, null);
|
|
1690
|
+
const fb: FallbackAudit = { ...base, model, verdict: null, reason: null, durationMs: Date.now() - start, error };
|
|
1691
|
+
if (mode === "shadow") return { ...demotedMark, fb, ...shadowApplied };
|
|
1692
|
+
return { ...demotedMark, fb: { ...fb, effective: "ask" }, effective: { verdict: "ask", reason: "fallback classifier unavailable (first layer abstained) — your call", source: "fail-closed" } };
|
|
1693
|
+
};
|
|
1694
|
+
const resolved = getFb();
|
|
1695
|
+
if (!resolved) return failed(rules.classifierFallbackModel, "fallback model unresolvable (not found or no configured auth)");
|
|
1696
|
+
const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, actionLine, resolved.thinking, denyPathsActive, FALLBACK_TIMEOUT_MS);
|
|
1697
|
+
if (outcome.source !== "model") return failed(resolved.model.id, outcome.reason);
|
|
1698
|
+
state.fallback.note(first?.verdict ?? null, outcome.verdict);
|
|
1699
|
+
const fb: FallbackAudit = { ...base, model: resolved.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs: Date.now() - start, error: null };
|
|
1700
|
+
if (mode === "shadow") return { ...demotedMark, fb, ...shadowApplied };
|
|
1701
|
+
// The one carve-out on second-layer authority: a demoted first-layer deny may not
|
|
1702
|
+
// become an automatic allow — the human decides (headless degrades to deny downstream)
|
|
1703
|
+
if (trigger.kind === "demotion" && first?.verdict === "deny" && outcome.verdict === "allow") {
|
|
1704
|
+
return { demoted: true, fb: { ...fb, effective: "ask" }, effective: { verdict: "ask", reason: `${outcome.reason} (first layer said deny at confidence ${trigger.confidence}%; second opinion allows — your call)`, source: "classifier" } };
|
|
1705
|
+
}
|
|
1706
|
+
return { ...demotedMark, fb: { ...fb, effective: outcome.verdict }, effective: { verdict: outcome.verdict, reason: outcome.reason, source: "classifier" } };
|
|
1527
1707
|
}
|
|
1528
1708
|
|
|
1529
1709
|
/**
|
|
@@ -1541,38 +1721,58 @@ export async function adjudicate(
|
|
|
1541
1721
|
const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
|
|
1542
1722
|
if (rule.verdict === "allow") return { verdict: "allow", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1543
1723
|
if (rule.verdict === "deny") return { verdict: "deny", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1724
|
+
|
|
1725
|
+
// #62: the audit surface widens to protected-path asks (their user answers grade the
|
|
1726
|
+
// denyPaths rules); rule allow/deny stay unaudited (no corpus value, #54). Record
|
|
1727
|
+
// building is split from appending: an interactive ask returns via pendingAudit and the
|
|
1728
|
+
// handler appends after the confirm resolves (with the ground truth); everything else
|
|
1729
|
+
// appends immediately. Recording stays observe-only — it never changes a verdict; write
|
|
1730
|
+
// failures stay fail-soft in the sink and surface once via drainWarning.
|
|
1731
|
+
const actionLine = toolCallLine(call.toolName, call.input);
|
|
1732
|
+
const buildRecord = (v: Pick<AuditRecord, "verdict" | "reason" | "source" | "degraded">, raw: ClassifierOutcome["auditRaw"] | null, shadow: string): AuditRecord => ({
|
|
1733
|
+
ts: new Date().toISOString(),
|
|
1734
|
+
sessionId: env.host.getSessionId(),
|
|
1735
|
+
cwd: env.cwd,
|
|
1736
|
+
model: raw?.modelId ?? null,
|
|
1737
|
+
tool: call.toolName,
|
|
1738
|
+
input: call.input,
|
|
1739
|
+
actionLine,
|
|
1740
|
+
thinking: raw?.thinking ?? null,
|
|
1741
|
+
transcript: raw?.transcript ?? null,
|
|
1742
|
+
rawResponse: raw?.rawResponse ?? null,
|
|
1743
|
+
shadow,
|
|
1744
|
+
...v,
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1544
1747
|
if (rule.verdict === "ask") {
|
|
1545
1748
|
// denyPaths 命中 → ask 终局(ADR-0002):声明者本人裁决例外;无 UI 降级为 deny
|
|
1546
|
-
|
|
1749
|
+
if (env.hasUI) {
|
|
1750
|
+
const ppRecord: AuditRecord = { ...buildRecord({ verdict: "ask", reason: rule.reason ?? "", source: "protected-path", degraded: false }, null, "-"), detail: rule.detail };
|
|
1751
|
+
return { verdict: "ask", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: false, ...(state.audit ? { pendingAudit: ppRecord } : {}) };
|
|
1752
|
+
}
|
|
1753
|
+
// headless: the ask degrades to deny — recorded like the gray-zone rule (the effective post-degradation verdict is what lands in the record)
|
|
1754
|
+
state.audit?.append({ ...buildRecord({ verdict: "deny", reason: rule.reason ?? "", source: "protected-path", degraded: true }, null, "-"), detail: rule.detail });
|
|
1755
|
+
return { verdict: "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: true };
|
|
1547
1756
|
}
|
|
1548
1757
|
|
|
1549
1758
|
// 灰区 → 分类器;无可用模型 → fail-closed
|
|
1550
|
-
// #54: gray-zone only (rule-layer verdicts carry no transcript corpus —
|
|
1551
|
-
// brief decision); observe-only — recording never changes a verdict, and
|
|
1552
|
-
// write failures are swallowed fail-soft by the sink and surfaced once via drainWarning
|
|
1553
|
-
const actionLine = toolCallLine(call.toolName, call.input);
|
|
1554
|
-
const audit = (v: Pick<AuditRecord, "verdict" | "reason" | "source" | "degraded">, raw: ClassifierOutcome["auditRaw"] | null, shadow: string): void => {
|
|
1555
|
-
if (!state.audit) return;
|
|
1556
|
-
state.audit.append({
|
|
1557
|
-
ts: new Date().toISOString(),
|
|
1558
|
-
sessionId: env.host.getSessionId(),
|
|
1559
|
-
cwd: env.cwd,
|
|
1560
|
-
model: raw?.modelId ?? null,
|
|
1561
|
-
tool: call.toolName,
|
|
1562
|
-
input: call.input,
|
|
1563
|
-
actionLine,
|
|
1564
|
-
thinking: raw?.thinking ?? null,
|
|
1565
|
-
transcript: raw?.transcript ?? null,
|
|
1566
|
-
rawResponse: raw?.rawResponse ?? null,
|
|
1567
|
-
shadow,
|
|
1568
|
-
...v,
|
|
1569
|
-
});
|
|
1570
|
-
};
|
|
1571
1759
|
|
|
1572
1760
|
const resolved = env.getModel();
|
|
1573
1761
|
if (!resolved) {
|
|
1574
1762
|
const reason = "no classifier model available (fail-closed)";
|
|
1575
|
-
|
|
1763
|
+
// #67: a fail-closed origin cascades to the fallback if configured — under enforce
|
|
1764
|
+
// the fallback adjudicates de novo (superseding the 0.10.0 ratchet decision);
|
|
1765
|
+
// shadow records its opinion and the deny stands
|
|
1766
|
+
const cascade = await runConfidenceCascade(state, env, null, { kind: "fail-closed" }, state.userRules.denyPaths.length > 0, actionLine);
|
|
1767
|
+
const eff = cascade.effective;
|
|
1768
|
+
const fcRecord = buildRecord({ verdict: "deny", reason, source: "fail-closed", degraded: false }, null, "-");
|
|
1769
|
+
if (cascade.fb) fcRecord.fallback = cascade.fb;
|
|
1770
|
+
if (eff?.verdict === "ask" && env.hasUI) {
|
|
1771
|
+
return { verdict: "ask", reason: eff.reason, source: eff.source, degraded: false, ...(state.audit ? { pendingAudit: fcRecord } : {}) };
|
|
1772
|
+
}
|
|
1773
|
+
state.audit?.append(fcRecord);
|
|
1774
|
+
if (eff?.verdict === "allow") return { verdict: "allow", reason: eff.reason, source: "classifier", degraded: false };
|
|
1775
|
+
if (eff) return { verdict: "deny", reason: eff.reason, source: eff.source, degraded: !env.hasUI };
|
|
1576
1776
|
return { verdict: "deny", reason, source: "fail-closed", degraded: false };
|
|
1577
1777
|
}
|
|
1578
1778
|
|
|
@@ -1591,12 +1791,35 @@ export async function adjudicate(
|
|
|
1591
1791
|
}
|
|
1592
1792
|
|
|
1593
1793
|
const shadow = shadowTag(probe);
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1794
|
+
|
|
1795
|
+
// #67 cascade: a confidence-floor demotion, or a classifier fail-closed outcome
|
|
1796
|
+
// (the first layer produced no verdict)
|
|
1797
|
+
const demotion = confidenceDemotion(outcome, state.userRules);
|
|
1798
|
+
const cascade = demotion || outcome.source === "fail-closed"
|
|
1799
|
+
? await runConfidenceCascade(state, env, demotion ? { verdict: outcome.verdict, reason: outcome.reason } : null, demotion ? { kind: "demotion", confidence: demotion.confidence } : { kind: "fail-closed" }, state.userRules.denyPaths.length > 0, actionLine)
|
|
1800
|
+
: {};
|
|
1801
|
+
const effVerdict = cascade.effective?.verdict ?? outcome.verdict;
|
|
1802
|
+
const effReason = cascade.effective?.reason ?? outcome.reason;
|
|
1803
|
+
const effSource = cascade.effective?.source ?? "classifier";
|
|
1804
|
+
|
|
1805
|
+
// #62/#67: top-level keeps first-layer semantics (corpus comparability); the applied
|
|
1806
|
+
// verdict lives in fallback.effective (enforce rows). Non-interactive asks of any
|
|
1807
|
+
// origin — native, demoted, escalated — record as their effective deny, the
|
|
1808
|
+
// pre-existing ask-degradation convention.
|
|
1809
|
+
const appliedAskHeadless = !env.hasUI && effVerdict === "ask";
|
|
1810
|
+
const grayRecord = buildRecord({ verdict: appliedAskHeadless ? "deny" : outcome.verdict, reason: outcome.reason, source: outcome.source, degraded: appliedAskHeadless }, outcome.auditRaw ?? null, shadow);
|
|
1811
|
+
if (cascade.demoted) grayRecord.demoted = true;
|
|
1812
|
+
if (cascade.fb) grayRecord.fallback = cascade.fb;
|
|
1813
|
+
// #62: an interactive ask defers the append to the handler finalize (ground truth);
|
|
1814
|
+
// every other outcome appends immediately as before
|
|
1815
|
+
if (effVerdict === "ask" && env.hasUI) {
|
|
1816
|
+
return { verdict: "ask", reason: effReason, source: effSource, degraded: false, shadow, ...(state.audit ? { pendingAudit: grayRecord } : {}) };
|
|
1817
|
+
}
|
|
1818
|
+
state.audit?.append(grayRecord);
|
|
1819
|
+
if (effVerdict === "allow") return { verdict: "allow", reason: effReason, source: effSource, degraded: false, shadow };
|
|
1820
|
+
if (effVerdict === "deny") return { verdict: "deny", reason: effReason, source: effSource, degraded: false, shadow };
|
|
1598
1821
|
// ask:无 UI 降级为 deny(ask 降级,CONTEXT.md 词条)
|
|
1599
|
-
return { verdict:
|
|
1822
|
+
return { verdict: "deny", reason: effReason, source: effSource, degraded: true, shadow };
|
|
1600
1823
|
}
|
|
1601
1824
|
|
|
1602
1825
|
// ============================================================================
|
|
@@ -1727,6 +1950,8 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1727
1950
|
const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
|
|
1728
1951
|
/** Status line audit hint (#54): shown only while the sink is active */
|
|
1729
1952
|
const auditHint = () => (state.audit ? `\naudit: on → ${state.audit.dir}` : "");
|
|
1953
|
+
/** Status line cascade hint (#63/#67): shown while the floor or the fallback is configured */
|
|
1954
|
+
const fallbackHint = () => (state.userRules.classifierMinConfidence !== null || state.userRules.classifierFallbackModel ? `\n${state.fallback.summary(state.userRules.classifierFallbackMode)}` : "");
|
|
1730
1955
|
|
|
1731
1956
|
pi.registerCommand("automode", {
|
|
1732
1957
|
description: "Show Auto Mode status and shadow-cache stats, or set it: /automode on|off",
|
|
@@ -1734,7 +1959,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1734
1959
|
const arg = args.trim().toLowerCase();
|
|
1735
1960
|
// 裸调用:只读状态展示,无副作用(含影子缓存统计行)
|
|
1736
1961
|
if (arg === "") {
|
|
1737
|
-
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}${auditHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1962
|
+
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}${auditHint()}${fallbackHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1738
1963
|
return;
|
|
1739
1964
|
}
|
|
1740
1965
|
// 幂等设定:与现值相同不翻转,仅确认
|
|
@@ -1745,7 +1970,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1745
1970
|
const head = next
|
|
1746
1971
|
? `🛡️ Auto Mode enabled${changed ? "" : " (unchanged)"}: tool calls adjudicated by rules + classifier`
|
|
1747
1972
|
: `Auto Mode disabled${changed ? "" : " (unchanged)"}: tool calls execute directly`;
|
|
1748
|
-
ctx.ui.notify(`${head}\n${state.shadow.summary()}`, "info");
|
|
1973
|
+
ctx.ui.notify(`${head}\n${state.shadow.summary()}${fallbackHint()}`, "info");
|
|
1749
1974
|
return;
|
|
1750
1975
|
}
|
|
1751
1976
|
// 未知参数:严格拒绝并列出用法(大小写已归一化)
|
|
@@ -1757,17 +1982,16 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1757
1982
|
/** 思考级别集(pi 原生 EXTENDED_THINKING_LEVELS;后缀语法对齐 pi --model provider/id:thinking) */
|
|
1758
1983
|
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
1759
1984
|
|
|
1760
|
-
/**
|
|
1761
|
-
|
|
1985
|
+
/** Parse "provider/id:thinking" → { specPart, level }. An invalid suffix is ignored and
|
|
1986
|
+
* reported through warnOnce — the one-shot latch is the caller's, so the two layers'
|
|
1987
|
+
* warnings never suppress each other (#63 review fix). */
|
|
1988
|
+
function parseModelSpec(raw: string, warnOnce: (msg: string) => void): { specPart: string; level: string | null } {
|
|
1762
1989
|
const slash = raw.lastIndexOf("/");
|
|
1763
1990
|
const colon = raw.lastIndexOf(":");
|
|
1764
1991
|
if (colon > slash + 1 && THINKING_LEVELS.has(raw.slice(colon + 1))) {
|
|
1765
1992
|
return { specPart: raw.slice(0, colon), level: raw.slice(colon + 1) };
|
|
1766
1993
|
}
|
|
1767
|
-
if (colon > slash + 1
|
|
1768
|
-
warnedClassifierModel = true;
|
|
1769
|
-
ctx.ui.notify(`pi-verdict: invalid thinking-level suffix "${raw.slice(colon + 1)}" (valid: ${[...THINKING_LEVELS].join("/")}), ignored`, "warning");
|
|
1770
|
-
}
|
|
1994
|
+
if (colon > slash + 1) warnOnce(`pi-verdict: invalid thinking-level suffix "${raw.slice(colon + 1)}" (valid: ${[...THINKING_LEVELS].join("/")}), ignored`);
|
|
1771
1995
|
return { specPart: raw, level: null };
|
|
1772
1996
|
}
|
|
1773
1997
|
|
|
@@ -1780,7 +2004,11 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1780
2004
|
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
|
|
1781
2005
|
let thinking: ThinkingLevel = "off";
|
|
1782
2006
|
if (raw) {
|
|
1783
|
-
const { specPart, level } = parseModelSpec(raw,
|
|
2007
|
+
const { specPart, level } = parseModelSpec(raw, (msg) => {
|
|
2008
|
+
if (warnedClassifierModel) return;
|
|
2009
|
+
warnedClassifierModel = true;
|
|
2010
|
+
ctx.ui.notify(msg, "warning");
|
|
2011
|
+
});
|
|
1784
2012
|
thinking = (level ?? "off") as ThinkingLevel;
|
|
1785
2013
|
const slash = specPart.indexOf("/");
|
|
1786
2014
|
if (slash > 0) {
|
|
@@ -1796,6 +2024,34 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1796
2024
|
return ctx.model ? { model: ctx.model, thinking } : null;
|
|
1797
2025
|
}
|
|
1798
2026
|
|
|
2027
|
+
let warnedFallbackSuffix = false;
|
|
2028
|
+
let warnedFallbackModel = false;
|
|
2029
|
+
/** #63: second-layer resolution — config-only (no flag/env precedence) and NO
|
|
2030
|
+
* session-model fallback: silently inheriting the session model would bill the same
|
|
2031
|
+
* judgment twice instead of adding a second opinion. Unresolvable → one-time warning
|
|
2032
|
+
* + null (shadow: inert; enforce: triggered calls fail-closed, see runFallbackCascade).
|
|
2033
|
+
* Resolved lazily via AdjudicateEnv.getFallbackModel, only after the gate fires. */
|
|
2034
|
+
function resolveFallbackClassifier(ctx: ExtensionContext): { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null {
|
|
2035
|
+
const raw = state.userRules.classifierFallbackModel;
|
|
2036
|
+
if (!raw) return null;
|
|
2037
|
+
const { specPart, level } = parseModelSpec(raw, (msg) => {
|
|
2038
|
+
if (warnedFallbackSuffix) return;
|
|
2039
|
+
warnedFallbackSuffix = true;
|
|
2040
|
+
ctx.ui.notify(msg, "warning");
|
|
2041
|
+
});
|
|
2042
|
+
const thinking = (level ?? "off") as ThinkingLevel;
|
|
2043
|
+
const slash = specPart.indexOf("/");
|
|
2044
|
+
if (slash > 0) {
|
|
2045
|
+
const model = ctx.modelRegistry.find(specPart.slice(0, slash), specPart.slice(slash + 1));
|
|
2046
|
+
if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return { model, thinking };
|
|
2047
|
+
}
|
|
2048
|
+
if (!warnedFallbackModel) {
|
|
2049
|
+
warnedFallbackModel = true; // one warning per session
|
|
2050
|
+
ctx.ui.notify(`pi-verdict: fallback model "${raw}" unavailable (not found or no configured auth) — classifierFallbackModel inactive this session`, "warning");
|
|
2051
|
+
}
|
|
2052
|
+
return null;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
1799
2055
|
function describeAction(toolName: string, input: Record<string, unknown>): string {
|
|
1800
2056
|
return toolCallLine(toolName, input);
|
|
1801
2057
|
}
|
|
@@ -1844,9 +2100,26 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1844
2100
|
complete: completionFor(ctx.modelRegistry, deps.compatLoader),
|
|
1845
2101
|
host: ctx.sessionManager,
|
|
1846
2102
|
signal: ctx.signal,
|
|
2103
|
+
getFallbackModel: () => resolveFallbackClassifier(ctx),
|
|
1847
2104
|
});
|
|
1848
2105
|
const auditWarning = state.audit?.drainWarning(); // #54: fail-soft one-shot warning
|
|
1849
2106
|
if (auditWarning) ctx.ui.notify(`pi-verdict: ${auditWarning}`, "warning");
|
|
1850
|
-
|
|
2107
|
+
// #62: an interactive ask's record is finalized here — exactly one append after the
|
|
2108
|
+
// confirm, carrying the user's answer; a presentVerdict throw still lands the record
|
|
2109
|
+
// (without the answer) and the error propagates unchanged. `undefined` = allowed.
|
|
2110
|
+
let presented: { block: true; reason: string } | undefined;
|
|
2111
|
+
try {
|
|
2112
|
+
presented = await presentVerdict(verdict, action, ctx);
|
|
2113
|
+
} catch (err) {
|
|
2114
|
+
if (verdict.pendingAudit) state.audit?.append(verdict.pendingAudit);
|
|
2115
|
+
throw err;
|
|
2116
|
+
}
|
|
2117
|
+
if (verdict.pendingAudit) {
|
|
2118
|
+
state.audit?.append({ ...verdict.pendingAudit, userAnswer: presented === undefined ? "allowed" : "declined", answeredAt: new Date().toISOString() });
|
|
2119
|
+
verdict.pendingAudit = undefined;
|
|
2120
|
+
const lateWarning = state.audit?.drainWarning();
|
|
2121
|
+
if (lateWarning) ctx.ui.notify(`pi-verdict: ${lateWarning}`, "warning");
|
|
2122
|
+
}
|
|
2123
|
+
return presented;
|
|
1851
2124
|
});
|
|
1852
2125
|
}
|