pi-verdict 0.9.0 → 0.10.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 +14 -8
- package/README.zh-CN.md +14 -8
- package/extensions/jev-adapter.ts +145 -53
- package/extensions/pi-verdict.ts +292 -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
|
+
"classifierFallbackModel": null,
|
|
106
|
+
"classifierFallbackConfidence": 50,
|
|
107
|
+
"classifierFallbackMode": "shadow"
|
|
105
108
|
}
|
|
106
109
|
```
|
|
107
110
|
|
|
@@ -109,26 +112,29 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
|
|
|
109
112
|
- `denyPaths` are plain paths you declare **protected** — touches trigger a terminal ask you adjudicate (non-interactive → deny); the classifier never learns the paths themselves, only that they exist. `grep`/`find`/`ls` compare their whole **search scope**: an omitted `path` (pi's default: the current directory) or a parent directory of a declared path triggers the ask as well. A fresh install pre-fills a **starter list** (`~/.ssh/`, `~/.gnupg`, `~/.mc`, shell rc/profile files), active from the first session after the initial run (any config change applies to new sessions) — a pre-filled *user declaration*, not a built-in floor: edit or empty it freely, add your own (`~/Documents/private`, …) alongside; existing configs are never rewritten
|
|
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
|
-
- `classifierModel: "typesafe/jev-latest"` opts into the bundled **jev decisions adapter** — gray-zone verdicts via TypeSafe's jev
|
|
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
|
|
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)
|
|
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
|
+
- `classifierFallbackModel` (optional, [ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)) adds a **second-layer classifier** consulted only when the first layer is uncertain (ask / fail-closed / jev confidence below `classifierFallbackConfidence`, default 50); `classifierFallbackMode: "shadow"` (default) observes without changing verdicts, `"enforce"` escalates strictness only (a safety ratchet — never relaxes; a failed fallback denies the triggered call). Off unless set — 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
|
|
|
118
122
|
### Jev decisions backend (experimental — [ADR-0003](docs/adr/0003-jev-decisions-adapter.md))
|
|
119
123
|
|
|
120
124
|
1. Install a version that ships the adapter (v0.8+): `pi install npm:pi-verdict`
|
|
121
|
-
2.
|
|
122
|
-
- run `/login openrouter` inside pi
|
|
123
|
-
-
|
|
125
|
+
2. Pick a transport (both serve the same decisions wire contract):
|
|
126
|
+
- **OpenRouter (default)**: run `/login openrouter` inside pi, or `export OPENROUTER_API_KEY=sk-or-v1...` in your shell
|
|
127
|
+
- **TypeSafe direct (official v1 API)**: grab a self-service key at console.typesafe.ai, then `export TYPESAFE_API_KEY=apikey_...` and `export PI_VERDICT_JEV_TRANSPORT=typesafe`
|
|
124
128
|
3. Point the classifier at jev (applies to new sessions)
|
|
125
129
|
- persistent: edit `~/.pi/agent/config/pi-verdict.json` outside pi and set `{ "classifierModel": "typesafe/jev-latest" }`
|
|
126
130
|
- or try it once: `PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
|
|
127
131
|
|
|
128
132
|
**Limits**:
|
|
129
|
-
- **
|
|
133
|
+
- **Transports**: OpenRouter decisions (default) or TypeSafe direct — on the TypeSafe transport per-call cost shows $0 (its API does not report it)
|
|
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
|
-
- **Escape hatch**: `PI_VERDICT_JEV_URL` overrides the
|
|
135
|
+
- **Escape hatch**: `PI_VERDICT_JEV_URL` overrides the active transport's endpoint (OpenRouter's is an alpha API)
|
|
136
|
+
|
|
137
|
+
jev's calibrated confidence is exactly what the fallback cascade keys on — pair it with a second layer (`"classifierFallbackModel": "anthropic/claude-haiku-4-5"`) to route its low-confidence calls to a deeper model ([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)).
|
|
132
138
|
|
|
133
139
|
### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
134
140
|
|
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
|
+
"classifierFallbackModel": null,
|
|
108
|
+
"classifierFallbackConfidence": 50,
|
|
109
|
+
"classifierFallbackMode": "shadow"
|
|
107
110
|
}
|
|
108
111
|
```
|
|
109
112
|
|
|
@@ -111,26 +114,29 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
|
|
|
111
114
|
- `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件),自初次运行后的第一个会话起生效(一切配置变更均自新会话生效)——它是预填的*用户声明*而非内置 floor:可随意增删清空,也可与自己的路径(`~/Documents/private`、……)并列;既有配置永不被改写
|
|
112
115
|
- `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
|
|
113
116
|
- `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
|
|
114
|
-
- `classifierModel: "typesafe/jev-latest"` 启用随包的 **jev 决策适配器**——灰区裁决经
|
|
115
|
-
- `audit: true` 把每次**灰区裁决**(发给分类器的完整转录、其原始响应、解析出的裁决)以 JSONL 记录到 `~/.pi/agent/verdicts/<sessionId>.jsonl`——按会话一分文件,保留最近 20
|
|
117
|
+
- `classifierModel: "typesafe/jev-latest"` 启用随包的 **jev 决策适配器**——灰区裁决经 TypeSafe jev 完成(默认 OpenRouter,或 `PI_VERDICT_JEV_TRANSPORT=typesafe` 直连官方 API);实验性质,详见 [ADR-0003](docs/adr/0003-jev-decisions-adapter.md)
|
|
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
|
+
- `classifierFallbackModel`(可选,[ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))添加**第二层分类器**,仅当第一层不确定时征询(ask / fail-closed / jev confidence 低于 `classifierFallbackConfidence`,默认 50);`classifierFallbackMode: "shadow"`(默认)只观察不改判,`"enforce"` 仅升严(安全棘轮——永不放宽;fallback 失败时该次触发调用 deny)。未设置即完全关闭——天然搭配:jev 打头 + haiku 级兜底
|
|
117
121
|
|
|
118
122
|
没有内置白名单——每一条「永远放行」声明都归你([为什么](docs/configuration.md#why-no-built-in-allowlist))。完整参考:[docs/configuration.md](docs/configuration.md)。
|
|
119
123
|
|
|
120
124
|
### Jev 决策后端(实验性——[ADR-0003](docs/adr/0003-jev-decisions-adapter.md))
|
|
121
125
|
|
|
122
126
|
1. 安装含适配器的版本( v0.8 及以上): `pi install npm:pi-verdict`
|
|
123
|
-
2.
|
|
124
|
-
- pi 内执行 `/login openrouter`
|
|
125
|
-
-
|
|
127
|
+
2. 选一条 transport(两条走同一 decisions wire 契约):
|
|
128
|
+
- **OpenRouter(默认)**: pi 内执行 `/login openrouter`,或 shell 里 `export OPENROUTER_API_KEY=sk-or-v1...`
|
|
129
|
+
- **TypeSafe 直连(官方 v1 API)**: 在 console.typesafe.ai 自助发 key,然后 `export TYPESAFE_API_KEY=apikey_...` 并 `export PI_VERDICT_JEV_TRANSPORT=typesafe`
|
|
126
130
|
3. 把分类器指到 jev(新会话生效)
|
|
127
131
|
- 持久:在 pi 之外编辑 `~/.pi/agent/config/pi-verdict.json` 并设置 `{ "classifierModel": "typesafe/jev-latest" }`
|
|
128
132
|
- 或者临时试一把:`PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
|
|
129
133
|
|
|
130
134
|
**限制**:
|
|
131
|
-
- **
|
|
135
|
+
- **Transport**: OpenRouter decisions(默认)或 TypeSafe 直连——TypeSafe 侧单次成本显示 $0(其 API 不返回 cost)
|
|
132
136
|
- **宿主**:仅支持pi。omp 上该设置会警告并回退会话模型。也绝不能选作会话主模型(不生成文本,选中即警告)
|
|
133
|
-
- **逃生口**:`PI_VERDICT_JEV_URL`
|
|
137
|
+
- **逃生口**:`PI_VERDICT_JEV_URL` 可覆盖当前 transport 的端点(OpenRouter 侧为 alpha 接口)
|
|
138
|
+
|
|
139
|
+
jev 的校准 confidence 正是回退级联的触发依据——搭配第二层使用(`"classifierFallbackModel": "anthropic/claude-haiku-4-5"`),把低置信调用交给更深的模型([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))。
|
|
134
140
|
|
|
135
141
|
### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
|
|
136
142
|
|
|
@@ -2,18 +2,29 @@
|
|
|
2
2
|
* pi-verdict jev adapter (ADR-0003) — exposes TypeSafe's jev decisions model
|
|
3
3
|
* as a pi provider (`typesafe/jev-latest`) so `classifierModel` can name it.
|
|
4
4
|
*
|
|
5
|
-
* jev is not an LLM:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* jev is not an LLM: its decisions API takes `{state, questions}` and returns
|
|
6
|
+
* typed answers, which is why the model cannot ride pi's chat-completions
|
|
7
|
+
* providers. Two transports (PI_VERDICT_JEV_TRANSPORT, default `openrouter`),
|
|
8
|
+
* whose wire contracts are isomorphic except for the model slug
|
|
9
|
+
* (live-verified 2026-09-19: same `{state, questions}` body; answers carry
|
|
10
|
+
* choice/probabilities/confidence; usage snake_case, TypeSafe's own API omits
|
|
11
|
+
* `cost` and mapUsage defaults it to 0):
|
|
12
|
+
* - `openrouter`: POST /api/alpha/decisions, model `~typesafe/jev-latest`,
|
|
13
|
+
* credentials reuse pi's OpenRouter login with OPENROUTER_API_KEY fallback
|
|
14
|
+
* (no second credential channel);
|
|
15
|
+
* - `typesafe`: POST api.typesafe.ai/v1/systemone, model `jev-latest` —
|
|
16
|
+
* TypeSafe's official v1 API. pi has no typesafe login, so TYPESAFE_API_KEY
|
|
17
|
+
* is this transport's only source, still resolved through the provider
|
|
18
|
+
* auth pipeline rather than a bare fetch (ADR-0003 amendment).
|
|
10
19
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
20
|
+
* This adapter translates the classifier's completion call into one `choice`
|
|
21
|
+
* question and synthesizes the `<verdict>…</verdict>` contract text from the
|
|
22
|
+
* typed answer. The transport is pinned at provider creation (env is
|
|
23
|
+
* process-constant), so provider metadata, auth, and request routing always
|
|
24
|
+
* agree. Because `hasConfiguredAuth` reads a sync snapshot built
|
|
25
|
+
* before any extension event fires, the provider is re-registered on
|
|
26
|
+
* `session_start` to re-run the availability check with the stashed
|
|
27
|
+
* resolver (see ADR-0003).
|
|
17
28
|
*
|
|
18
29
|
* Known limitations (ADR-0003): the classifier system prompt — including the
|
|
19
30
|
* denyPaths existence hint — does not reach jev; jev treats state as data and
|
|
@@ -36,11 +47,63 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
36
47
|
|
|
37
48
|
export const PROVIDER_ID = "typesafe";
|
|
38
49
|
export const MODEL_ID = "jev-latest";
|
|
39
|
-
/** Wire slug OpenRouter resolves to the newest jev snapshot. */
|
|
40
|
-
export const WIRE_MODEL = "~typesafe/jev-latest";
|
|
41
50
|
export const API_ID = "jev-decisions";
|
|
42
|
-
|
|
43
|
-
export const
|
|
51
|
+
|
|
52
|
+
export const TRANSPORTS = ["openrouter", "typesafe"] as const;
|
|
53
|
+
export type Transport = (typeof TRANSPORTS)[number];
|
|
54
|
+
|
|
55
|
+
/** Everything that differs between transports, in one place: the decisions
|
|
56
|
+
* endpoint, the model slug it expects (OpenRouter wants the `~latest` alias;
|
|
57
|
+
* TypeSafe's own API wants the bare slug), the provider/auth display names,
|
|
58
|
+
* the credential sources, and the missing-key error hint. PI_VERDICT_JEV_URL
|
|
59
|
+
* overrides either endpoint. */
|
|
60
|
+
export interface TransportConfig {
|
|
61
|
+
/** Decisions endpoint (PI_VERDICT_JEV_URL overrides). */
|
|
62
|
+
url: string;
|
|
63
|
+
/** Model slug this endpoint expects. */
|
|
64
|
+
wireModel: string;
|
|
65
|
+
providerName: string;
|
|
66
|
+
authName: string;
|
|
67
|
+
/** Env var carrying the API key. */
|
|
68
|
+
keyEnv: "OPENROUTER_API_KEY" | "TYPESAFE_API_KEY";
|
|
69
|
+
/** Pi provider-auth id when a pi login exists to reuse; absent = env-only. */
|
|
70
|
+
loginProvider?: "openrouter";
|
|
71
|
+
/** Completes "no API key resolved (…)". */
|
|
72
|
+
keyHint: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const TRANSPORT_DEFAULTS: Record<Transport, TransportConfig> = {
|
|
76
|
+
openrouter: {
|
|
77
|
+
url: "https://openrouter.ai/api/alpha/decisions",
|
|
78
|
+
wireModel: "~typesafe/jev-latest",
|
|
79
|
+
providerName: "TypeSafe (jev via OpenRouter)",
|
|
80
|
+
authName: "OpenRouter credentials (reused for jev)",
|
|
81
|
+
keyEnv: "OPENROUTER_API_KEY",
|
|
82
|
+
loginProvider: "openrouter",
|
|
83
|
+
keyHint: "openrouter login or OPENROUTER_API_KEY",
|
|
84
|
+
},
|
|
85
|
+
typesafe: {
|
|
86
|
+
url: "https://api.typesafe.ai/v1/systemone",
|
|
87
|
+
wireModel: "jev-latest",
|
|
88
|
+
providerName: "TypeSafe (jev direct)",
|
|
89
|
+
authName: "TYPESAFE_API_KEY",
|
|
90
|
+
keyEnv: "TYPESAFE_API_KEY",
|
|
91
|
+
keyHint: "TYPESAFE_API_KEY",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Unknown or unset values fall back to `openrouter` (the historical default). */
|
|
96
|
+
export function activeTransport(): Transport {
|
|
97
|
+
return process.env.PI_VERDICT_JEV_TRANSPORT?.trim().toLowerCase() === "typesafe" ? "typesafe" : "openrouter";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function decisionsUrl(transport: Transport = activeTransport()): string {
|
|
101
|
+
return process.env.PI_VERDICT_JEV_URL?.trim() || TRANSPORT_DEFAULTS[transport].url;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function wireModel(transport: Transport = activeTransport()): string {
|
|
105
|
+
return TRANSPORT_DEFAULTS[transport].wireModel;
|
|
106
|
+
}
|
|
44
107
|
|
|
45
108
|
const VERDICTS = ["allow", "ask", "deny"] as const;
|
|
46
109
|
type Verdict = (typeof VERDICTS)[number];
|
|
@@ -84,8 +147,8 @@ export function extractState(context: { messages: unknown[] }): string {
|
|
|
84
147
|
return state;
|
|
85
148
|
}
|
|
86
149
|
|
|
87
|
-
export function buildDecisionsBody(state: string,
|
|
88
|
-
return { model
|
|
150
|
+
export function buildDecisionsBody(state: string, model: string = wireModel()): Record<string, unknown> {
|
|
151
|
+
return { model, state, questions: VERDICT_QUESTIONS };
|
|
89
152
|
}
|
|
90
153
|
|
|
91
154
|
interface DecisionAnswer {
|
|
@@ -98,21 +161,38 @@ interface DecisionAnswer {
|
|
|
98
161
|
* (`<verdict>…</verdict>` + one-line reason). Any malformed shape throws —
|
|
99
162
|
* the classifier's fail-closed path owns the fallout. The reason is
|
|
100
163
|
* user-facing (block reasons, ask dialogs): plain percentages, no internal
|
|
101
|
-
* 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. */
|
|
102
168
|
export function verdictText(parsed: unknown): string {
|
|
103
169
|
const answer = (parsed as { answers?: { verdict?: DecisionAnswer } })?.answers?.verdict;
|
|
104
170
|
const choice = String(answer?.choice ?? "").trim().toLowerCase();
|
|
105
171
|
if (!VERDICTS.includes(choice as Verdict)) {
|
|
106
172
|
throw new Error(`jev adapter: malformed verdict answer (choice=${JSON.stringify(answer?.choice) ?? "missing"})`);
|
|
107
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
|
+
}
|
|
108
178
|
const probs = (answer?.probabilities ?? {}) as Record<string, unknown>;
|
|
109
179
|
const pct = (n: unknown): string => `${Math.round((typeof n === "number" && Number.isFinite(n) ? n : 0) * 100)}%`;
|
|
110
180
|
const rest = VERDICTS.filter((v) => v !== choice)
|
|
111
181
|
.map((v) => `${v} ${pct(probs[v])}`)
|
|
112
182
|
.join(", ");
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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;
|
|
116
196
|
}
|
|
117
197
|
|
|
118
198
|
function mapUsage(u: unknown): AssistantMessage["usage"] {
|
|
@@ -130,7 +210,7 @@ function mapUsage(u: unknown): AssistantMessage["usage"] {
|
|
|
130
210
|
};
|
|
131
211
|
}
|
|
132
212
|
|
|
133
|
-
function streamDecisions(model: Model<string>, context: Context, options: StreamOptions | SimpleStreamOptions | undefined, fetcher: typeof fetch): AssistantMessageEventStream {
|
|
213
|
+
function streamDecisions(transport: Transport, model: Model<string>, context: Context, options: StreamOptions | SimpleStreamOptions | undefined, fetcher: typeof fetch): AssistantMessageEventStream {
|
|
134
214
|
const stream = createAssistantMessageEventStream();
|
|
135
215
|
void (async () => {
|
|
136
216
|
const output: AssistantMessage = {
|
|
@@ -146,11 +226,11 @@ function streamDecisions(model: Model<string>, context: Context, options: Stream
|
|
|
146
226
|
try {
|
|
147
227
|
stream.push({ type: "start", partial: output });
|
|
148
228
|
const apiKey = options?.apiKey;
|
|
149
|
-
if (!apiKey) throw new Error(
|
|
150
|
-
const response = await fetcher(
|
|
229
|
+
if (!apiKey) throw new Error(`jev adapter: no API key resolved (${TRANSPORT_DEFAULTS[transport].keyHint})`);
|
|
230
|
+
const response = await fetcher(decisionsUrl(transport), {
|
|
151
231
|
method: "POST",
|
|
152
232
|
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
153
|
-
body: JSON.stringify(buildDecisionsBody(extractState(context))),
|
|
233
|
+
body: JSON.stringify(buildDecisionsBody(extractState(context), wireModel(transport))),
|
|
154
234
|
signal: options?.signal,
|
|
155
235
|
});
|
|
156
236
|
const text = await response.text();
|
|
@@ -181,50 +261,62 @@ function streamDecisions(model: Model<string>, context: Context, options: Stream
|
|
|
181
261
|
return stream;
|
|
182
262
|
}
|
|
183
263
|
|
|
184
|
-
/** Input $0.042/MTok, output free (research/typesafe-jev-classifiermodel.md
|
|
185
|
-
*
|
|
264
|
+
/** Input $0.042/MTok, output free (research/typesafe-jev-classifiermodel.md).
|
|
265
|
+
* OpenRouter settles per-call cost in usage; TypeSafe's own API omits it and
|
|
266
|
+
* mapUsage defaults it to 0. Context ceiling is undocumented upstream;
|
|
186
267
|
* 30k matches the classifier transcript budget with margin. */
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
268
|
+
function jevModel(transport: Transport): Model<typeof API_ID> {
|
|
269
|
+
return {
|
|
270
|
+
id: MODEL_ID,
|
|
271
|
+
name: "Jev (latest, decisions)",
|
|
272
|
+
api: API_ID,
|
|
273
|
+
provider: PROVIDER_ID,
|
|
274
|
+
baseUrl: decisionsUrl(transport),
|
|
275
|
+
reasoning: false,
|
|
276
|
+
input: ["text"],
|
|
277
|
+
cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
278
|
+
contextWindow: 30_000,
|
|
279
|
+
maxTokens: 512,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
199
282
|
|
|
200
283
|
type OpenRouterKeyResolver = () => Promise<string | undefined>;
|
|
201
284
|
|
|
202
285
|
export function createJevProvider(openRouterKey: OpenRouterKeyResolver | undefined, fetcher: typeof fetch = fetch): Provider {
|
|
286
|
+
// Transport is pinned at creation: env is constant for the process
|
|
287
|
+
// lifetime, and pinning keeps provider metadata, auth, and request
|
|
288
|
+
// routing in agreement (no half-switched state).
|
|
289
|
+
const transport = activeTransport();
|
|
290
|
+
const config = TRANSPORT_DEFAULTS[transport];
|
|
203
291
|
return createProvider({
|
|
204
292
|
id: PROVIDER_ID,
|
|
205
|
-
name:
|
|
206
|
-
baseUrl:
|
|
293
|
+
name: config.providerName,
|
|
294
|
+
baseUrl: decisionsUrl(transport),
|
|
207
295
|
auth: {
|
|
208
|
-
// Ambient-only (no login):
|
|
209
|
-
// login or the env fallback
|
|
296
|
+
// Ambient-only (no login): the openrouter transport reuses pi's
|
|
297
|
+
// OpenRouter login or the env fallback; the typesafe transport has
|
|
298
|
+
// no pi credential store (pi has no typesafe provider) and reads
|
|
299
|
+
// TYPESAFE_API_KEY only. Neither path opens a second channel.
|
|
210
300
|
apiKey: {
|
|
211
|
-
name:
|
|
301
|
+
name: config.authName,
|
|
212
302
|
resolve: async () => {
|
|
213
303
|
let key: string | undefined;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
304
|
+
if (config.loginProvider) {
|
|
305
|
+
try {
|
|
306
|
+
key = await openRouterKey?.();
|
|
307
|
+
} catch {
|
|
308
|
+
/* getProviderAuth may reject on auth-store errors; env still applies */
|
|
309
|
+
}
|
|
218
310
|
}
|
|
219
|
-
key ||= process.env.
|
|
220
|
-
return key ? { auth: { apiKey: key }, source:
|
|
311
|
+
key ||= process.env[config.keyEnv]?.trim();
|
|
312
|
+
return key ? { auth: { apiKey: key }, source: transport } : undefined;
|
|
221
313
|
},
|
|
222
314
|
},
|
|
223
315
|
},
|
|
224
|
-
models: [
|
|
316
|
+
models: [jevModel(transport)],
|
|
225
317
|
api: {
|
|
226
|
-
stream: (m, c, o) => streamDecisions(m, c, o, fetcher),
|
|
227
|
-
streamSimple: (m, c, o) => streamDecisions(m, c, o, fetcher),
|
|
318
|
+
stream: (m, c, o) => streamDecisions(transport, m, c, o, fetcher),
|
|
319
|
+
streamSimple: (m, c, o) => streamDecisions(transport, m, c, o, fetcher),
|
|
228
320
|
},
|
|
229
321
|
});
|
|
230
322
|
}
|
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,15 @@ 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
|
+
/** #63: second-layer classifier spec (provider/id[:thinking]); null = the cascade is entirely off */
|
|
268
|
+
classifierFallbackModel: string | null;
|
|
269
|
+
/** #63: trigger when the first layer's jev confidence is strictly below this (0–100). Default 50. */
|
|
270
|
+
classifierFallbackConfidence: number;
|
|
271
|
+
/** #63: "shadow" (default — observe-only, verdicts unchanged) | "enforce" (safety ratchet: the fallback may only escalate strictness, never relax) */
|
|
272
|
+
classifierFallbackMode: "shadow" | "enforce";
|
|
266
273
|
}
|
|
267
274
|
|
|
268
|
-
const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false };
|
|
275
|
+
const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false, classifierFallbackModel: null, classifierFallbackConfidence: 50, classifierFallbackMode: "shadow" };
|
|
269
276
|
|
|
270
277
|
/** This module's own file location (import.meta.url resolved; null = unresolvable). */
|
|
271
278
|
const OWN_FILE_PATH: string | null = (() => {
|
|
@@ -314,7 +321,7 @@ function userConfigPath(): string {
|
|
|
314
321
|
}
|
|
315
322
|
|
|
316
323
|
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.",
|
|
324
|
+
_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
325
|
allow: ["^ls\\b"],
|
|
319
326
|
deny: [],
|
|
320
327
|
denyPaths: [
|
|
@@ -330,6 +337,9 @@ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
|
|
|
330
337
|
toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
|
|
331
338
|
audit: false,
|
|
332
339
|
notifyAllows: false,
|
|
340
|
+
classifierFallbackModel: null,
|
|
341
|
+
classifierFallbackConfidence: 50,
|
|
342
|
+
classifierFallbackMode: "shadow",
|
|
333
343
|
}, null, 2)}\n`;
|
|
334
344
|
|
|
335
345
|
/**
|
|
@@ -347,7 +357,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
347
357
|
} catch { /* 只读环境静默跳过 */ }
|
|
348
358
|
return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
|
|
349
359
|
}
|
|
350
|
-
let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown };
|
|
360
|
+
let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown; classifierFallbackModel?: unknown; classifierFallbackConfidence?: unknown; classifierFallbackMode?: unknown };
|
|
351
361
|
try {
|
|
352
362
|
raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
|
|
353
363
|
} catch (err) {
|
|
@@ -376,6 +386,12 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
376
386
|
return [x.trim()];
|
|
377
387
|
});
|
|
378
388
|
const shortcut = resolveToggleShortcut(raw.toggleShortcut);
|
|
389
|
+
// #63: fallback cascade keys — invalid values skip into the one-shot warning channel and default (50 / shadow)
|
|
390
|
+
const fbConfRaw = raw.classifierFallbackConfidence;
|
|
391
|
+
const fbConfOk = typeof fbConfRaw === "number" && Number.isFinite(fbConfRaw) && fbConfRaw >= 0 && fbConfRaw <= 100;
|
|
392
|
+
if (fbConfRaw !== undefined && !fbConfOk) skipped.push(`classifierFallbackConfidence: ${JSON.stringify(fbConfRaw)}`);
|
|
393
|
+
const fbModeRaw = raw.classifierFallbackMode;
|
|
394
|
+
if (fbModeRaw !== undefined && fbModeRaw !== "shadow" && fbModeRaw !== "enforce") skipped.push(`classifierFallbackMode: ${JSON.stringify(fbModeRaw)}`);
|
|
379
395
|
return {
|
|
380
396
|
rules: {
|
|
381
397
|
allow: compile(raw.allow),
|
|
@@ -386,6 +402,9 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
|
|
|
386
402
|
toggleShortcut: shortcut.key,
|
|
387
403
|
audit: raw.audit === true,
|
|
388
404
|
notifyAllows: raw.notifyAllows === true,
|
|
405
|
+
classifierFallbackModel: typeof raw.classifierFallbackModel === "string" && raw.classifierFallbackModel.trim() ? raw.classifierFallbackModel.trim() : null,
|
|
406
|
+
classifierFallbackConfidence: fbConfOk ? fbConfRaw : 50,
|
|
407
|
+
classifierFallbackMode: fbModeRaw === "enforce" ? "enforce" : "shadow",
|
|
389
408
|
},
|
|
390
409
|
skipped,
|
|
391
410
|
shortcutWarning: shortcut.warning,
|
|
@@ -1052,6 +1071,7 @@ interface ClassifierOutcome {
|
|
|
1052
1071
|
}
|
|
1053
1072
|
|
|
1054
1073
|
const CLASSIFIER_TIMEOUT_MS = 25_000; // 本网关 CC 分类器分布 p90=19.8s(15s 会误杀 ~15%),research/cache-sim 数据
|
|
1074
|
+
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
1075
|
const CLASSIFIER_MAX_TOKENS = 512;
|
|
1056
1076
|
const CLASSIFIER_RETRY_MAX_TOKENS = 1024; // 防御重试档:覆盖无视 reasoning:off 或轻思考仍超预算的模型
|
|
1057
1077
|
const APIS_WITHOUT_TEMPERATURE = new Set<string>([
|
|
@@ -1143,11 +1163,12 @@ async function callClassifierOnce(
|
|
|
1143
1163
|
maxTokens: number,
|
|
1144
1164
|
thinking: ThinkingLevel = "off",
|
|
1145
1165
|
systemPrompt: string = CLASSIFIER_SYSTEM,
|
|
1166
|
+
timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
|
|
1146
1167
|
): Promise<{ ok: true; text: string; stopReason: string; errorMessage?: string } | { ok: false; error: string }> {
|
|
1147
1168
|
const fire = async (
|
|
1148
1169
|
withTemperature: boolean,
|
|
1149
1170
|
): Promise<{ ok: true; text: string; stopReason: string; errorMessage?: string } | { ok: false; error: string }> => {
|
|
1150
|
-
const signals = [AbortSignal.timeout(
|
|
1171
|
+
const signals = [AbortSignal.timeout(timeoutMs)];
|
|
1151
1172
|
if (signal) signals.push(signal);
|
|
1152
1173
|
try {
|
|
1153
1174
|
const response = await complete(
|
|
@@ -1216,6 +1237,7 @@ async function classifyWithModel(
|
|
|
1216
1237
|
actionLine: string,
|
|
1217
1238
|
thinking: ThinkingLevel = "off",
|
|
1218
1239
|
denyPathsActive = false,
|
|
1240
|
+
timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
|
|
1219
1241
|
): Promise<ClassifierOutcome> {
|
|
1220
1242
|
const transcript = buildTranscript(host, actionLine);
|
|
1221
1243
|
const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
|
|
@@ -1225,7 +1247,7 @@ async function classifyWithModel(
|
|
|
1225
1247
|
let rawResponse = ""; // #54: raw output of the last attempt ("" for exception attempts — diagnostics already live in failures)
|
|
1226
1248
|
for (const [n, maxTokens] of attempts) {
|
|
1227
1249
|
if (signal?.aborted) break; // 用户已取消,不再重试
|
|
1228
|
-
const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt);
|
|
1250
|
+
const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt, timeoutMs);
|
|
1229
1251
|
if (r.ok) {
|
|
1230
1252
|
rawResponse = r.text;
|
|
1231
1253
|
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 +1384,46 @@ function shadowTag(probe: ShadowProbe): string {
|
|
|
1362
1384
|
return `(shadow cache: miss:no-entry)`;
|
|
1363
1385
|
}
|
|
1364
1386
|
|
|
1387
|
+
// ============================================================================
|
|
1388
|
+
// Fallback cascade stats (#63: observe-first, session-memory state; the #7 discipline)
|
|
1389
|
+
// ============================================================================
|
|
1390
|
+
|
|
1391
|
+
/** #63: ratchet strictness order — the fallback may only escalate, never relax */
|
|
1392
|
+
const STRICTNESS_RANK: Record<"allow" | "ask" | "deny", number> = { allow: 0, ask: 1, deny: 2 };
|
|
1393
|
+
|
|
1394
|
+
interface FallbackStats {
|
|
1395
|
+
triggered: number; // the gate fired (ask / fail-closed / confidence below threshold)
|
|
1396
|
+
agreed: number; // fallback verdict no stricter than the first layer's
|
|
1397
|
+
escalated: number; // fallback stricter than the first layer (enforce applies it; shadow observes the would-be)
|
|
1398
|
+
errored: number; // fallback unresolvable or its call failed
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
class FallbackCascade {
|
|
1402
|
+
readonly stats: FallbackStats = { triggered: 0, agreed: 0, escalated: 0, errored: 0 };
|
|
1403
|
+
|
|
1404
|
+
/** Session reset (#7 discipline: session-memory state) */
|
|
1405
|
+
reset(): void {
|
|
1406
|
+
Object.assign(this.stats, { triggered: 0, agreed: 0, escalated: 0, errored: 0 });
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
note(first: "allow" | "ask" | "deny", fb: "allow" | "ask" | "deny" | null): void {
|
|
1410
|
+
this.stats.triggered++;
|
|
1411
|
+
if (fb === null) {
|
|
1412
|
+
this.stats.errored++;
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (STRICTNESS_RANK[fb] > STRICTNESS_RANK[first]) this.stats.escalated++;
|
|
1416
|
+
else this.stats.agreed++;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Summary line for /automode */
|
|
1420
|
+
summary(mode: "shadow" | "enforce"): string {
|
|
1421
|
+
const s = this.stats;
|
|
1422
|
+
if (s.triggered === 0) return "fallback cascade: not triggered this session";
|
|
1423
|
+
return `fallback cascade (${mode}): triggered ${s.triggered} · agreed ${s.agreed} · ${mode === "enforce" ? "escalated" : "would-escalate"} ${s.escalated} · errored ${s.errored}`;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1365
1427
|
// ============================================================================
|
|
1366
1428
|
// Gray-zone verdict audit (#54): opt-in JSONL decision records, observe-only
|
|
1367
1429
|
// (never an adjudication input)
|
|
@@ -1369,7 +1431,26 @@ function shadowTag(probe: ShadowProbe): string {
|
|
|
1369
1431
|
|
|
1370
1432
|
const AUDIT_KEEP_SESSIONS = 20;
|
|
1371
1433
|
|
|
1372
|
-
/**
|
|
1434
|
+
/** #63: second-layer classifier outcome on a triggered call. The record's top-level
|
|
1435
|
+
* fields keep first-layer semantics for corpus comparability (grill decision); the
|
|
1436
|
+
* verdict actually applied under enforce lives in `effective` (absent in shadow). */
|
|
1437
|
+
export interface FallbackAudit {
|
|
1438
|
+
model: string;
|
|
1439
|
+
mode: "shadow" | "enforce";
|
|
1440
|
+
triggeredBy: "ask" | "confidence" | "fail-closed";
|
|
1441
|
+
/** jev confidence that fired the gate; null unless triggeredBy = "confidence" */
|
|
1442
|
+
confidence: number | null;
|
|
1443
|
+
/** null = the fallback call itself failed (unresolvable model, timeout, parse) */
|
|
1444
|
+
verdict: "allow" | "ask" | "deny" | null;
|
|
1445
|
+
reason: string | null;
|
|
1446
|
+
durationMs: number;
|
|
1447
|
+
error: string | null;
|
|
1448
|
+
/** enforce mode only: the verdict applied after the ratchet */
|
|
1449
|
+
effective?: "allow" | "ask" | "deny";
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** One adjudication record (#54; #62 widened the surface to protected-path asks and
|
|
1453
|
+
* added the ground-truth fields). Full fidelity on purpose: the file is
|
|
1373
1454
|
* local-trust-domain (same as pi-verdict.json, per the ADR-0002 boundary note),
|
|
1374
1455
|
* so protected-path plaintext is allowed here — it never leaves the machine nor
|
|
1375
1456
|
* flows into agent context. */
|
|
@@ -1386,9 +1467,20 @@ export interface AuditRecord {
|
|
|
1386
1467
|
rawResponse: string | null;
|
|
1387
1468
|
verdict: "allow" | "ask" | "deny";
|
|
1388
1469
|
reason: string;
|
|
1389
|
-
|
|
1470
|
+
/** #62: protected-path asks are recorded too — their user answers grade the
|
|
1471
|
+
* denyPaths rules; rule allow/deny verdicts remain unaudited. */
|
|
1472
|
+
source: "model" | "fail-closed" | "protected-path";
|
|
1390
1473
|
shadow: string;
|
|
1391
1474
|
degraded: boolean;
|
|
1475
|
+
/** #62 ground truth: the user's answer to an interactive ask confirm. Present only
|
|
1476
|
+
* on records whose confirm actually ran; headless/degraded asks omit it. */
|
|
1477
|
+
userAnswer?: "allowed" | "declined";
|
|
1478
|
+
/** #62: ISO timestamp of the confirm resolution; `ts` stays adjudication time. */
|
|
1479
|
+
answeredAt?: string;
|
|
1480
|
+
/** #62: protected-path records only — the matched path. */
|
|
1481
|
+
detail?: string;
|
|
1482
|
+
/** #63: second-layer outcome when the uncertainty gate fired. */
|
|
1483
|
+
fallback?: FallbackAudit;
|
|
1392
1484
|
}
|
|
1393
1485
|
|
|
1394
1486
|
/** Audit sink (#54): append-only and fail-soft (the first write failure surfaces
|
|
@@ -1458,6 +1550,7 @@ export class AuditLog {
|
|
|
1458
1550
|
export class SessionState {
|
|
1459
1551
|
readonly prot: ProtectedSet;
|
|
1460
1552
|
readonly shadow = new ShadowCache();
|
|
1553
|
+
readonly fallback = new FallbackCascade();
|
|
1461
1554
|
userRules: UserRules;
|
|
1462
1555
|
audit: AuditLog | null;
|
|
1463
1556
|
private denyPathBases: string[] | null = null;
|
|
@@ -1482,6 +1575,7 @@ export class SessionState {
|
|
|
1482
1575
|
this.userRules = loaded.rules;
|
|
1483
1576
|
this.denyPathBases = anchorDenyPaths(loaded.rules.denyPaths, cwd); // anchored to the session cwd, once (ADR-0002)
|
|
1484
1577
|
this.shadow.reset();
|
|
1578
|
+
this.fallback.reset();
|
|
1485
1579
|
this.audit = this.makeAudit(loaded.rules);
|
|
1486
1580
|
return { skipped: loaded.skipped, shortcutWarning: loaded.shortcutWarning };
|
|
1487
1581
|
}
|
|
@@ -1513,10 +1607,16 @@ export interface Verdict {
|
|
|
1513
1607
|
source: VerdictSource;
|
|
1514
1608
|
degraded: boolean;
|
|
1515
1609
|
shadow?: string;
|
|
1610
|
+
/** #62: pending audit record for an interactive ask — adjudicate defers the append so
|
|
1611
|
+
* the handler can attach the user's answer after the confirm resolves. The handler
|
|
1612
|
+
* owns the single finalize: append with userAnswer/answeredAt, or without them when
|
|
1613
|
+
* presentation throws. Unset for every non-interactive verdict. */
|
|
1614
|
+
pendingAudit?: AuditRecord;
|
|
1516
1615
|
}
|
|
1517
1616
|
|
|
1518
1617
|
/** 逐调用环境:呈现无关的宿主能力。model 经 getModel 惰性求值——保持「仅灰区才
|
|
1519
|
-
* 解析」的原行为(回退警告不会出现在规则已裁决的调用上);null → fail-closed。
|
|
1618
|
+
* 解析」的原行为(回退警告不会出现在规则已裁决的调用上);null → fail-closed。
|
|
1619
|
+
* getFallbackModel(#63)更惰性:仅在门控触发后才解析。 */
|
|
1520
1620
|
export interface AdjudicateEnv {
|
|
1521
1621
|
cwd: string;
|
|
1522
1622
|
hasUI: boolean;
|
|
@@ -1524,6 +1624,69 @@ export interface AdjudicateEnv {
|
|
|
1524
1624
|
complete: CompletionFn;
|
|
1525
1625
|
host: PipelineHost;
|
|
1526
1626
|
signal?: AbortSignal;
|
|
1627
|
+
getFallbackModel?: () => { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** #63: should the second layer be consulted for this first-layer outcome? Precedence:
|
|
1631
|
+
* fail-closed → ask → jev confidence strictly below the threshold. LLM reasons carry
|
|
1632
|
+
* no numeric confidence (parseJevConfidence → null) — their gate is ask/fail-closed only. */
|
|
1633
|
+
function fallbackTrigger(outcome: ClassifierOutcome, rules: UserRules): { triggeredBy: "ask" | "confidence" | "fail-closed"; confidence: number | null } | null {
|
|
1634
|
+
if (!rules.classifierFallbackModel) return null;
|
|
1635
|
+
if (outcome.source === "fail-closed") return { triggeredBy: "fail-closed", confidence: null };
|
|
1636
|
+
if (outcome.verdict === "ask") return { triggeredBy: "ask", confidence: null };
|
|
1637
|
+
const conf = parseJevConfidence(outcome.reason);
|
|
1638
|
+
if (conf !== null && conf < rules.classifierFallbackConfidence) return { triggeredBy: "confidence", confidence: conf };
|
|
1639
|
+
return null;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
interface CascadeResult {
|
|
1643
|
+
/** audit material; absent when no trigger fired */
|
|
1644
|
+
fb?: FallbackAudit;
|
|
1645
|
+
/** enforce-mode override; absent = keep the first-layer verdict (shadow never overrides) */
|
|
1646
|
+
effective?: { verdict: "allow" | "ask" | "deny"; reason: string; source: "classifier" | "fail-closed" };
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** #63: run the second layer on a triggered call. Safety ratchet: the fallback may
|
|
1650
|
+
* only escalate strictness, never relax. A failed fallback (unresolvable model or
|
|
1651
|
+
* failed call) denies in enforce — an explicitly configured second layer must not
|
|
1652
|
+
* silently degrade the gate to single-layer (grill decision); in shadow a failure
|
|
1653
|
+
* is recorded and never changes the verdict. */
|
|
1654
|
+
async function runFallbackCascade(
|
|
1655
|
+
state: SessionState,
|
|
1656
|
+
env: AdjudicateEnv,
|
|
1657
|
+
first: "allow" | "ask" | "deny",
|
|
1658
|
+
trigger: { triggeredBy: "ask" | "confidence" | "fail-closed"; confidence: number | null },
|
|
1659
|
+
denyPathsActive: boolean,
|
|
1660
|
+
actionLine: string,
|
|
1661
|
+
): Promise<CascadeResult> {
|
|
1662
|
+
const rules = state.userRules;
|
|
1663
|
+
if (!rules.classifierFallbackModel || !env.getFallbackModel) return {};
|
|
1664
|
+
const mode = rules.classifierFallbackMode;
|
|
1665
|
+
const start = Date.now();
|
|
1666
|
+
const base = { mode, triggeredBy: trigger.triggeredBy, confidence: trigger.confidence };
|
|
1667
|
+
// A failed fallback (unresolvable model or failed call) records the error and, under
|
|
1668
|
+
// enforce, denies the triggered call; `fallback.effective` carries the applied "deny"
|
|
1669
|
+
// so failure rows read through the same sub-object as every other enforce row
|
|
1670
|
+
const failed = (model: string, error: string): CascadeResult => {
|
|
1671
|
+
state.fallback.note(first, null);
|
|
1672
|
+
const fb: FallbackAudit = { ...base, model, verdict: null, reason: null, durationMs: Date.now() - start, error };
|
|
1673
|
+
return mode === "enforce" ? { fb: { ...fb, effective: "deny" }, effective: { verdict: "deny", reason: "fallback classifier unavailable (fail-closed)", source: "fail-closed" } } : { fb };
|
|
1674
|
+
};
|
|
1675
|
+
const resolved = env.getFallbackModel();
|
|
1676
|
+
if (!resolved) return failed(rules.classifierFallbackModel, "fallback model unresolvable (not found or no configured auth)");
|
|
1677
|
+
const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, actionLine, resolved.thinking, denyPathsActive, FALLBACK_TIMEOUT_MS);
|
|
1678
|
+
const durationMs = Date.now() - start;
|
|
1679
|
+
if (outcome.source !== "model") return failed(resolved.model.id, outcome.reason);
|
|
1680
|
+
state.fallback.note(first, outcome.verdict);
|
|
1681
|
+
const fb: FallbackAudit = { ...base, model: resolved.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs, error: null };
|
|
1682
|
+
if (mode === "enforce") {
|
|
1683
|
+
const effective = STRICTNESS_RANK[outcome.verdict] > STRICTNESS_RANK[first] ? outcome.verdict : first;
|
|
1684
|
+
if (effective !== first) {
|
|
1685
|
+
return { fb: { ...fb, effective }, effective: { verdict: outcome.verdict, reason: `${outcome.reason} (second-opinion classifier escalated ${first} to ${outcome.verdict})`, source: "classifier" } };
|
|
1686
|
+
}
|
|
1687
|
+
return { fb: { ...fb, effective } };
|
|
1688
|
+
}
|
|
1689
|
+
return { fb };
|
|
1527
1690
|
}
|
|
1528
1691
|
|
|
1529
1692
|
/**
|
|
@@ -1541,38 +1704,52 @@ export async function adjudicate(
|
|
|
1541
1704
|
const rule = classifyByRules(call.toolName, call.input, env.cwd, state.userRules, state.prot, state.anchoredDenyPathBases(env.cwd));
|
|
1542
1705
|
if (rule.verdict === "allow") return { verdict: "allow", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1543
1706
|
if (rule.verdict === "deny") return { verdict: "deny", reason: rule.reason ?? "", source: "rule", degraded: false };
|
|
1707
|
+
|
|
1708
|
+
// #62: the audit surface widens to protected-path asks (their user answers grade the
|
|
1709
|
+
// denyPaths rules); rule allow/deny stay unaudited (no corpus value, #54). Record
|
|
1710
|
+
// building is split from appending: an interactive ask returns via pendingAudit and the
|
|
1711
|
+
// handler appends after the confirm resolves (with the ground truth); everything else
|
|
1712
|
+
// appends immediately. Recording stays observe-only — it never changes a verdict; write
|
|
1713
|
+
// failures stay fail-soft in the sink and surface once via drainWarning.
|
|
1714
|
+
const actionLine = toolCallLine(call.toolName, call.input);
|
|
1715
|
+
const buildRecord = (v: Pick<AuditRecord, "verdict" | "reason" | "source" | "degraded">, raw: ClassifierOutcome["auditRaw"] | null, shadow: string): AuditRecord => ({
|
|
1716
|
+
ts: new Date().toISOString(),
|
|
1717
|
+
sessionId: env.host.getSessionId(),
|
|
1718
|
+
cwd: env.cwd,
|
|
1719
|
+
model: raw?.modelId ?? null,
|
|
1720
|
+
tool: call.toolName,
|
|
1721
|
+
input: call.input,
|
|
1722
|
+
actionLine,
|
|
1723
|
+
thinking: raw?.thinking ?? null,
|
|
1724
|
+
transcript: raw?.transcript ?? null,
|
|
1725
|
+
rawResponse: raw?.rawResponse ?? null,
|
|
1726
|
+
shadow,
|
|
1727
|
+
...v,
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1544
1730
|
if (rule.verdict === "ask") {
|
|
1545
1731
|
// denyPaths 命中 → ask 终局(ADR-0002):声明者本人裁决例外;无 UI 降级为 deny
|
|
1546
|
-
|
|
1732
|
+
if (env.hasUI) {
|
|
1733
|
+
const ppRecord: AuditRecord = { ...buildRecord({ verdict: "ask", reason: rule.reason ?? "", source: "protected-path", degraded: false }, null, "-"), detail: rule.detail };
|
|
1734
|
+
return { verdict: "ask", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: false, ...(state.audit ? { pendingAudit: ppRecord } : {}) };
|
|
1735
|
+
}
|
|
1736
|
+
// headless: the ask degrades to deny — recorded like the gray-zone rule (the effective post-degradation verdict is what lands in the record)
|
|
1737
|
+
state.audit?.append({ ...buildRecord({ verdict: "deny", reason: rule.reason ?? "", source: "protected-path", degraded: true }, null, "-"), detail: rule.detail });
|
|
1738
|
+
return { verdict: "deny", reason: rule.reason ?? "", detail: rule.detail, source: "protected-path", degraded: true };
|
|
1547
1739
|
}
|
|
1548
1740
|
|
|
1549
1741
|
// 灰区 → 分类器;无可用模型 → 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
1742
|
|
|
1572
1743
|
const resolved = env.getModel();
|
|
1573
1744
|
if (!resolved) {
|
|
1574
1745
|
const reason = "no classifier model available (fail-closed)";
|
|
1575
|
-
|
|
1746
|
+
// #63: no-model fail-closed triggers the cascade as well — the ratchet has no
|
|
1747
|
+
// exception for first-layer absence (grill decision: enforce can never relax this
|
|
1748
|
+
// deny; in shadow it is observability only)
|
|
1749
|
+
const cascade = await runFallbackCascade(state, env, "deny", { triggeredBy: "fail-closed", confidence: null }, state.userRules.denyPaths.length > 0, actionLine);
|
|
1750
|
+
const fcRecord = buildRecord({ verdict: "deny", reason, source: "fail-closed", degraded: false }, null, "-");
|
|
1751
|
+
if (cascade.fb) fcRecord.fallback = cascade.fb;
|
|
1752
|
+
state.audit?.append(fcRecord);
|
|
1576
1753
|
return { verdict: "deny", reason, source: "fail-closed", degraded: false };
|
|
1577
1754
|
}
|
|
1578
1755
|
|
|
@@ -1591,12 +1768,31 @@ export async function adjudicate(
|
|
|
1591
1768
|
}
|
|
1592
1769
|
|
|
1593
1770
|
const shadow = shadowTag(probe);
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1771
|
+
|
|
1772
|
+
// #63 cascade: consult the second layer when the gate fires; `effective` is the
|
|
1773
|
+
// ratchet result the returned verdict follows (shadow never overrides)
|
|
1774
|
+
const trigger = fallbackTrigger(outcome, state.userRules);
|
|
1775
|
+
const cascade = trigger ? await runFallbackCascade(state, env, outcome.verdict, trigger, state.userRules.denyPaths.length > 0, actionLine) : {};
|
|
1776
|
+
const effVerdict = cascade.effective?.verdict ?? outcome.verdict;
|
|
1777
|
+
const effReason = cascade.effective?.reason ?? outcome.reason;
|
|
1778
|
+
const effSource = cascade.effective?.source ?? "classifier";
|
|
1779
|
+
|
|
1780
|
+
// #62: record top-level keeps FIRST-layer semantics (grill decision — corpus
|
|
1781
|
+
// comparability); the enforced outcome lives in fallback.effective and evaluators
|
|
1782
|
+
// must read enforce rows accordingly
|
|
1783
|
+
const firstAskDegraded = !env.hasUI && outcome.verdict === "ask";
|
|
1784
|
+
const grayRecord = buildRecord({ verdict: firstAskDegraded ? "deny" : outcome.verdict, reason: outcome.reason, source: outcome.source, degraded: firstAskDegraded }, outcome.auditRaw ?? null, shadow);
|
|
1785
|
+
if (cascade.fb) grayRecord.fallback = cascade.fb;
|
|
1786
|
+
// #62: an interactive ask defers the append to the handler finalize (ground truth);
|
|
1787
|
+
// a headless degraded ask and every other outcome append immediately as before
|
|
1788
|
+
if (effVerdict === "ask" && env.hasUI) {
|
|
1789
|
+
return { verdict: "ask", reason: effReason, source: effSource, degraded: false, shadow, ...(state.audit ? { pendingAudit: grayRecord } : {}) };
|
|
1790
|
+
}
|
|
1791
|
+
state.audit?.append(grayRecord);
|
|
1792
|
+
if (effVerdict === "allow") return { verdict: "allow", reason: effReason, source: effSource, degraded: false, shadow };
|
|
1793
|
+
if (effVerdict === "deny") return { verdict: "deny", reason: effReason, source: effSource, degraded: false, shadow };
|
|
1598
1794
|
// ask:无 UI 降级为 deny(ask 降级,CONTEXT.md 词条)
|
|
1599
|
-
return { verdict:
|
|
1795
|
+
return { verdict: "deny", reason: effReason, source: effSource, degraded: !env.hasUI, shadow };
|
|
1600
1796
|
}
|
|
1601
1797
|
|
|
1602
1798
|
// ============================================================================
|
|
@@ -1727,6 +1923,8 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1727
1923
|
const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
|
|
1728
1924
|
/** Status line audit hint (#54): shown only while the sink is active */
|
|
1729
1925
|
const auditHint = () => (state.audit ? `\naudit: on → ${state.audit.dir}` : "");
|
|
1926
|
+
/** Status line fallback hint (#63): shown only while the cascade is configured */
|
|
1927
|
+
const fallbackHint = () => (state.userRules.classifierFallbackModel ? `\n${state.fallback.summary(state.userRules.classifierFallbackMode)}` : "");
|
|
1730
1928
|
|
|
1731
1929
|
pi.registerCommand("automode", {
|
|
1732
1930
|
description: "Show Auto Mode status and shadow-cache stats, or set it: /automode on|off",
|
|
@@ -1734,7 +1932,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1734
1932
|
const arg = args.trim().toLowerCase();
|
|
1735
1933
|
// 裸调用:只读状态展示,无副作用(含影子缓存统计行)
|
|
1736
1934
|
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");
|
|
1935
|
+
ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}${auditHint()}${fallbackHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
|
|
1738
1936
|
return;
|
|
1739
1937
|
}
|
|
1740
1938
|
// 幂等设定:与现值相同不翻转,仅确认
|
|
@@ -1745,7 +1943,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1745
1943
|
const head = next
|
|
1746
1944
|
? `🛡️ Auto Mode enabled${changed ? "" : " (unchanged)"}: tool calls adjudicated by rules + classifier`
|
|
1747
1945
|
: `Auto Mode disabled${changed ? "" : " (unchanged)"}: tool calls execute directly`;
|
|
1748
|
-
ctx.ui.notify(`${head}\n${state.shadow.summary()}`, "info");
|
|
1946
|
+
ctx.ui.notify(`${head}\n${state.shadow.summary()}${fallbackHint()}`, "info");
|
|
1749
1947
|
return;
|
|
1750
1948
|
}
|
|
1751
1949
|
// 未知参数:严格拒绝并列出用法(大小写已归一化)
|
|
@@ -1757,17 +1955,16 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1757
1955
|
/** 思考级别集(pi 原生 EXTENDED_THINKING_LEVELS;后缀语法对齐 pi --model provider/id:thinking) */
|
|
1758
1956
|
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
1759
1957
|
|
|
1760
|
-
/**
|
|
1761
|
-
|
|
1958
|
+
/** Parse "provider/id:thinking" → { specPart, level }. An invalid suffix is ignored and
|
|
1959
|
+
* reported through warnOnce — the one-shot latch is the caller's, so the two layers'
|
|
1960
|
+
* warnings never suppress each other (#63 review fix). */
|
|
1961
|
+
function parseModelSpec(raw: string, warnOnce: (msg: string) => void): { specPart: string; level: string | null } {
|
|
1762
1962
|
const slash = raw.lastIndexOf("/");
|
|
1763
1963
|
const colon = raw.lastIndexOf(":");
|
|
1764
1964
|
if (colon > slash + 1 && THINKING_LEVELS.has(raw.slice(colon + 1))) {
|
|
1765
1965
|
return { specPart: raw.slice(0, colon), level: raw.slice(colon + 1) };
|
|
1766
1966
|
}
|
|
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
|
-
}
|
|
1967
|
+
if (colon > slash + 1) warnOnce(`pi-verdict: invalid thinking-level suffix "${raw.slice(colon + 1)}" (valid: ${[...THINKING_LEVELS].join("/")}), ignored`);
|
|
1771
1968
|
return { specPart: raw, level: null };
|
|
1772
1969
|
}
|
|
1773
1970
|
|
|
@@ -1780,7 +1977,11 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1780
1977
|
(pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
|
|
1781
1978
|
let thinking: ThinkingLevel = "off";
|
|
1782
1979
|
if (raw) {
|
|
1783
|
-
const { specPart, level } = parseModelSpec(raw,
|
|
1980
|
+
const { specPart, level } = parseModelSpec(raw, (msg) => {
|
|
1981
|
+
if (warnedClassifierModel) return;
|
|
1982
|
+
warnedClassifierModel = true;
|
|
1983
|
+
ctx.ui.notify(msg, "warning");
|
|
1984
|
+
});
|
|
1784
1985
|
thinking = (level ?? "off") as ThinkingLevel;
|
|
1785
1986
|
const slash = specPart.indexOf("/");
|
|
1786
1987
|
if (slash > 0) {
|
|
@@ -1796,6 +1997,34 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1796
1997
|
return ctx.model ? { model: ctx.model, thinking } : null;
|
|
1797
1998
|
}
|
|
1798
1999
|
|
|
2000
|
+
let warnedFallbackSuffix = false;
|
|
2001
|
+
let warnedFallbackModel = false;
|
|
2002
|
+
/** #63: second-layer resolution — config-only (no flag/env precedence) and NO
|
|
2003
|
+
* session-model fallback: silently inheriting the session model would bill the same
|
|
2004
|
+
* judgment twice instead of adding a second opinion. Unresolvable → one-time warning
|
|
2005
|
+
* + null (shadow: inert; enforce: triggered calls fail-closed, see runFallbackCascade).
|
|
2006
|
+
* Resolved lazily via AdjudicateEnv.getFallbackModel, only after the gate fires. */
|
|
2007
|
+
function resolveFallbackClassifier(ctx: ExtensionContext): { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | null {
|
|
2008
|
+
const raw = state.userRules.classifierFallbackModel;
|
|
2009
|
+
if (!raw) return null;
|
|
2010
|
+
const { specPart, level } = parseModelSpec(raw, (msg) => {
|
|
2011
|
+
if (warnedFallbackSuffix) return;
|
|
2012
|
+
warnedFallbackSuffix = true;
|
|
2013
|
+
ctx.ui.notify(msg, "warning");
|
|
2014
|
+
});
|
|
2015
|
+
const thinking = (level ?? "off") as ThinkingLevel;
|
|
2016
|
+
const slash = specPart.indexOf("/");
|
|
2017
|
+
if (slash > 0) {
|
|
2018
|
+
const model = ctx.modelRegistry.find(specPart.slice(0, slash), specPart.slice(slash + 1));
|
|
2019
|
+
if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return { model, thinking };
|
|
2020
|
+
}
|
|
2021
|
+
if (!warnedFallbackModel) {
|
|
2022
|
+
warnedFallbackModel = true; // one warning per session
|
|
2023
|
+
ctx.ui.notify(`pi-verdict: fallback model "${raw}" unavailable (not found or no configured auth) — classifierFallbackModel inactive this session`, "warning");
|
|
2024
|
+
}
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1799
2028
|
function describeAction(toolName: string, input: Record<string, unknown>): string {
|
|
1800
2029
|
return toolCallLine(toolName, input);
|
|
1801
2030
|
}
|
|
@@ -1844,9 +2073,26 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
|
|
|
1844
2073
|
complete: completionFor(ctx.modelRegistry, deps.compatLoader),
|
|
1845
2074
|
host: ctx.sessionManager,
|
|
1846
2075
|
signal: ctx.signal,
|
|
2076
|
+
getFallbackModel: () => resolveFallbackClassifier(ctx),
|
|
1847
2077
|
});
|
|
1848
2078
|
const auditWarning = state.audit?.drainWarning(); // #54: fail-soft one-shot warning
|
|
1849
2079
|
if (auditWarning) ctx.ui.notify(`pi-verdict: ${auditWarning}`, "warning");
|
|
1850
|
-
|
|
2080
|
+
// #62: an interactive ask's record is finalized here — exactly one append after the
|
|
2081
|
+
// confirm, carrying the user's answer; a presentVerdict throw still lands the record
|
|
2082
|
+
// (without the answer) and the error propagates unchanged. `undefined` = allowed.
|
|
2083
|
+
let presented: { block: true; reason: string } | undefined;
|
|
2084
|
+
try {
|
|
2085
|
+
presented = await presentVerdict(verdict, action, ctx);
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
if (verdict.pendingAudit) state.audit?.append(verdict.pendingAudit);
|
|
2088
|
+
throw err;
|
|
2089
|
+
}
|
|
2090
|
+
if (verdict.pendingAudit) {
|
|
2091
|
+
state.audit?.append({ ...verdict.pendingAudit, userAnswer: presented === undefined ? "allowed" : "declined", answeredAt: new Date().toISOString() });
|
|
2092
|
+
verdict.pendingAudit = undefined;
|
|
2093
|
+
const lateWarning = state.audit?.drainWarning();
|
|
2094
|
+
if (lateWarning) ctx.ui.notify(`pi-verdict: ${lateWarning}`, "warning");
|
|
2095
|
+
}
|
|
2096
|
+
return presented;
|
|
1851
2097
|
});
|
|
1852
2098
|
}
|