pi-verdict 0.8.0 → 0.9.1

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 CHANGED
@@ -99,7 +99,9 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
99
99
  ],
100
100
  "builtinDenyFloor": true,
101
101
  "classifierModel": null,
102
- "toggleShortcut": "ctrl+shift+a"
102
+ "toggleShortcut": "ctrl+shift+a",
103
+ "audit": false,
104
+ "notifyAllows": false
103
105
  }
104
106
  ```
105
107
 
@@ -107,24 +109,26 @@ pi-verdict runs on both [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi]
107
109
  - `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
108
110
  - `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
109
111
  - `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
110
- - `classifierModel: "typesafe/jev-latest"` opts into the bundled **jev decisions adapter** — gray-zone verdicts via TypeSafe's jev on OpenRouter (`/api/alpha/decisions`), reusing your pi OpenRouter login; experimental, see [ADR-0003](docs/adr/0003-jev-decisions-adapter.md)
112
+ - `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
114
+ - `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
111
115
 
112
116
  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).
113
117
 
114
118
  ### Jev decisions backend (experimental — [ADR-0003](docs/adr/0003-jev-decisions-adapter.md))
115
119
 
116
120
  1. Install a version that ships the adapter (v0.8+): `pi install npm:pi-verdict`
117
- 2. Get your OpenRouter credentials ready (OpenRouter is the only transport for now)
118
- - run `/login openrouter` inside pi
119
- - or `export OPENROUTER_API_KEY=sk-or-v1...` in your shell
121
+ 2. Pick a transport (both serve the same decisions wire contract):
122
+ - **OpenRouter (default)**: run `/login openrouter` inside pi, or `export OPENROUTER_API_KEY=sk-or-v1...` in your shell
123
+ - **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`
120
124
  3. Point the classifier at jev (applies to new sessions)
121
125
  - persistent: edit `~/.pi/agent/config/pi-verdict.json` outside pi and set `{ "classifierModel": "typesafe/jev-latest" }`
122
126
  - or try it once: `PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
123
127
 
124
128
  **Limits**:
125
- - **Provider**: OpenRouter only, for now
129
+ - **Transports**: OpenRouter decisions (default) or TypeSafe direct — on the TypeSafe transport per-call cost shows $0 (its API does not report it)
126
130
  - **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)
127
- - **Escape hatch**: `PI_VERDICT_JEV_URL` overrides the decisions endpoint (alpha API)
131
+ - **Escape hatch**: `PI_VERDICT_JEV_URL` overrides the active transport's endpoint (OpenRouter's is an alpha API)
128
132
 
129
133
  ### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
130
134
 
package/README.zh-CN.md CHANGED
@@ -101,7 +101,9 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
101
101
  ],
102
102
  "builtinDenyFloor": true,
103
103
  "classifierModel": null,
104
- "toggleShortcut": "ctrl+shift+a"
104
+ "toggleShortcut": "ctrl+shift+a",
105
+ "audit": false,
106
+ "notifyAllows": false
105
107
  }
106
108
  ```
107
109
 
@@ -109,24 +111,26 @@ pi-verdict 同时支持 [pi](https://github.com/badlogic/pi-mono) 与 [oh-my-pi]
109
111
  - `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件),自初次运行后的第一个会话起生效(一切配置变更均自新会话生效)——它是预填的*用户声明*而非内置 floor:可随意增删清空,也可与自己的路径(`~/Documents/private`、……)并列;既有配置永不被改写
110
112
  - `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
111
113
  - `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
112
- - `classifierModel: "typesafe/jev-latest"` 启用随包的 **jev 决策适配器**——灰区裁决经 OpenRouter 的 TypeSafe jev(`/api/alpha/decisions`)完成,复用 pi 的 OpenRouter 登录态;实验性质,详见 [ADR-0003](docs/adr/0003-jev-decisions-adapter.md)
114
+ - `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 个。仅存本机且全保真(受保护路径明文可能出现——永不出本机;[ADR-0002](docs/adr/0002-deny-paths-deterministic-ask.md) 边界注);agent 对该目录读写双拒。开启时 `/automode` 会显示审计状态与路径
116
+ - `notifyAllows: true` 对每次 **classifier 放行**发通知(reason + action 行——如 jev 的概率分解);默认 `false` 保持放行静默。机械放行(你自己的 allow 规则、protected-path 确认)永不通知;shadow 标注仍属 debug;两开关同开时通知只出现一次
113
117
 
114
118
  没有内置白名单——每一条「永远放行」声明都归你([为什么](docs/configuration.md#why-no-built-in-allowlist))。完整参考:[docs/configuration.md](docs/configuration.md)。
115
119
 
116
120
  ### Jev 决策后端(实验性——[ADR-0003](docs/adr/0003-jev-decisions-adapter.md))
117
121
 
118
122
  1. 安装含适配器的版本( v0.8 及以上): `pi install npm:pi-verdict`
119
- 2. 备好 OpenRouter 凭证(暂时只支持 OpenRouter)
120
- - pi 内执行 `/login openrouter`
121
- - 或 shell 里 `export OPENROUTER_API_KEY=sk-or-v1...`
123
+ 2. 选一条 transport(两条走同一 decisions wire 契约):
124
+ - **OpenRouter(默认)**: pi 内执行 `/login openrouter`,或 shell 里 `export OPENROUTER_API_KEY=sk-or-v1...`
125
+ - **TypeSafe 直连(官方 v1 API)**: 在 console.typesafe.ai 自助发 key,然后 `export TYPESAFE_API_KEY=apikey_...` 并 `export PI_VERDICT_JEV_TRANSPORT=typesafe`
122
126
  3. 把分类器指到 jev(新会话生效)
123
127
  - 持久:在 pi 之外编辑 `~/.pi/agent/config/pi-verdict.json` 并设置 `{ "classifierModel": "typesafe/jev-latest" }`
124
128
  - 或者临时试一把:`PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
125
129
 
126
130
  **限制**:
127
- - **Provider**: 暂时只支持 OpenRouter
131
+ - **Transport**: OpenRouter decisions(默认)或 TypeSafe 直连——TypeSafe 侧单次成本显示 $0(其 API 不返回 cost)
128
132
  - **宿主**:仅支持pi。omp 上该设置会警告并回退会话模型。也绝不能选作会话主模型(不生成文本,选中即警告)
129
- - **逃生口**:`PI_VERDICT_JEV_URL` 可覆盖 decisions 端点(alpha 接口)
133
+ - **逃生口**:`PI_VERDICT_JEV_URL` 可覆盖当前 transport 的端点(OpenRouter 侧为 alpha 接口)
130
134
 
131
135
  ### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
132
136
 
@@ -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: OpenRouter serves it only through the decisions endpoint
6
- * (`POST /api/alpha/decisions`, request `{model, state, questions}`), which is
7
- * why the model cannot ride pi's built-in `openrouter` provider. This adapter
8
- * translates the classifier's completion call into one `choice` question and
9
- * synthesizes the `<verdict>…</verdict>` contract text from the typed answer.
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
- * Credentials reuse pi's OpenRouter login (no second credential channel):
12
- * request-time auth resolves via `ctx.modelRegistry.getProviderAuth("openrouter")`
13
- * with `OPENROUTER_API_KEY` as fallback. Because `hasConfiguredAuth` reads a
14
- * sync snapshot built before any extension event fires, the provider is
15
- * re-registered on `session_start` to re-run the availability check with the
16
- * stashed resolver (see ADR-0003).
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
- export const DEFAULT_DECISIONS_URL = "https://openrouter.ai/api/alpha/decisions";
43
- export const DECISIONS_URL = process.env.PI_VERDICT_JEV_URL?.trim() || DEFAULT_DECISIONS_URL;
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, wireModel: string = WIRE_MODEL): Record<string, unknown> {
88
- return { model: wireModel, state, questions: VERDICT_QUESTIONS };
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 {
@@ -130,7 +193,7 @@ function mapUsage(u: unknown): AssistantMessage["usage"] {
130
193
  };
131
194
  }
132
195
 
133
- function streamDecisions(model: Model<string>, context: Context, options: StreamOptions | SimpleStreamOptions | undefined, fetcher: typeof fetch): AssistantMessageEventStream {
196
+ function streamDecisions(transport: Transport, model: Model<string>, context: Context, options: StreamOptions | SimpleStreamOptions | undefined, fetcher: typeof fetch): AssistantMessageEventStream {
134
197
  const stream = createAssistantMessageEventStream();
135
198
  void (async () => {
136
199
  const output: AssistantMessage = {
@@ -146,11 +209,11 @@ function streamDecisions(model: Model<string>, context: Context, options: Stream
146
209
  try {
147
210
  stream.push({ type: "start", partial: output });
148
211
  const apiKey = options?.apiKey;
149
- if (!apiKey) throw new Error("jev adapter: no API key resolved (openrouter login or OPENROUTER_API_KEY)");
150
- const response = await fetcher(DECISIONS_URL, {
212
+ if (!apiKey) throw new Error(`jev adapter: no API key resolved (${TRANSPORT_DEFAULTS[transport].keyHint})`);
213
+ const response = await fetcher(decisionsUrl(transport), {
151
214
  method: "POST",
152
215
  headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
153
- body: JSON.stringify(buildDecisionsBody(extractState(context))),
216
+ body: JSON.stringify(buildDecisionsBody(extractState(context), wireModel(transport))),
154
217
  signal: options?.signal,
155
218
  });
156
219
  const text = await response.text();
@@ -181,50 +244,62 @@ function streamDecisions(model: Model<string>, context: Context, options: Stream
181
244
  return stream;
182
245
  }
183
246
 
184
- /** Input $0.042/MTok, output free (research/typesafe-jev-classifiermodel.md,
185
- * verified against live usage.cost). Context ceiling is undocumented upstream;
247
+ /** Input $0.042/MTok, output free (research/typesafe-jev-classifiermodel.md).
248
+ * OpenRouter settles per-call cost in usage; TypeSafe's own API omits it and
249
+ * mapUsage defaults it to 0. Context ceiling is undocumented upstream;
186
250
  * 30k matches the classifier transcript budget with margin. */
187
- const JEV_MODEL: Model<typeof API_ID> = {
188
- id: MODEL_ID,
189
- name: "Jev (latest, decisions)",
190
- api: API_ID,
191
- provider: PROVIDER_ID,
192
- baseUrl: DECISIONS_URL,
193
- reasoning: false,
194
- input: ["text"],
195
- cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
196
- contextWindow: 30_000,
197
- maxTokens: 512,
198
- };
251
+ function jevModel(transport: Transport): Model<typeof API_ID> {
252
+ return {
253
+ id: MODEL_ID,
254
+ name: "Jev (latest, decisions)",
255
+ api: API_ID,
256
+ provider: PROVIDER_ID,
257
+ baseUrl: decisionsUrl(transport),
258
+ reasoning: false,
259
+ input: ["text"],
260
+ cost: { input: 0.042, output: 0, cacheRead: 0, cacheWrite: 0 },
261
+ contextWindow: 30_000,
262
+ maxTokens: 512,
263
+ };
264
+ }
199
265
 
200
266
  type OpenRouterKeyResolver = () => Promise<string | undefined>;
201
267
 
202
268
  export function createJevProvider(openRouterKey: OpenRouterKeyResolver | undefined, fetcher: typeof fetch = fetch): Provider {
269
+ // Transport is pinned at creation: env is constant for the process
270
+ // lifetime, and pinning keeps provider metadata, auth, and request
271
+ // routing in agreement (no half-switched state).
272
+ const transport = activeTransport();
273
+ const config = TRANSPORT_DEFAULTS[transport];
203
274
  return createProvider({
204
275
  id: PROVIDER_ID,
205
- name: "TypeSafe (jev via OpenRouter)",
206
- baseUrl: DECISIONS_URL,
276
+ name: config.providerName,
277
+ baseUrl: decisionsUrl(transport),
207
278
  auth: {
208
- // Ambient-only (no login): credentials come from pi's OpenRouter
209
- // login or the env fallback, never from a typesafe-specific store.
279
+ // Ambient-only (no login): the openrouter transport reuses pi's
280
+ // OpenRouter login or the env fallback; the typesafe transport has
281
+ // no pi credential store (pi has no typesafe provider) and reads
282
+ // TYPESAFE_API_KEY only. Neither path opens a second channel.
210
283
  apiKey: {
211
- name: "OpenRouter credentials (reused for jev)",
284
+ name: config.authName,
212
285
  resolve: async () => {
213
286
  let key: string | undefined;
214
- try {
215
- key = await openRouterKey?.();
216
- } catch {
217
- /* getProviderAuth may reject on auth-store errors; env still applies */
287
+ if (config.loginProvider) {
288
+ try {
289
+ key = await openRouterKey?.();
290
+ } catch {
291
+ /* getProviderAuth may reject on auth-store errors; env still applies */
292
+ }
218
293
  }
219
- key ||= process.env.OPENROUTER_API_KEY?.trim();
220
- return key ? { auth: { apiKey: key }, source: "openrouter" } : undefined;
294
+ key ||= process.env[config.keyEnv]?.trim();
295
+ return key ? { auth: { apiKey: key }, source: transport } : undefined;
221
296
  },
222
297
  },
223
298
  },
224
- models: [JEV_MODEL],
299
+ models: [jevModel(transport)],
225
300
  api: {
226
- stream: (m, c, o) => streamDecisions(m, c, o, fetcher),
227
- streamSimple: (m, c, o) => streamDecisions(m, c, o, fetcher),
301
+ stream: (m, c, o) => streamDecisions(transport, m, c, o, fetcher),
302
+ streamSimple: (m, c, o) => streamDecisions(transport, m, c, o, fetcher),
228
303
  },
229
304
  });
230
305
  }
@@ -259,9 +259,13 @@ interface UserRules {
259
259
  classifierModel: string | null;
260
260
  /** 主开关 toggle 快捷键键位(#15);null = 禁用;缺省 DEFAULT_TOGGLE_SHORTCUT */
261
261
  toggleShortcut: string | null;
262
+ /** Opt-in gray-zone adjudication audit (#54): per-session JSONL under <agentDir>/verdicts/ */
263
+ audit: boolean;
264
+ /** Allow visibility (#60): info notification on classifier allows; mechanical passes stay silent. Default off. */
265
+ notifyAllows: boolean;
262
266
  }
263
267
 
264
- const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT };
268
+ const EMPTY_RULES: UserRules = { allow: [], deny: [], denyPaths: [], builtinDenyFloor: true, classifierModel: null, toggleShortcut: DEFAULT_TOGGLE_SHORTCUT, audit: false, notifyAllows: false };
265
269
 
266
270
  /** This module's own file location (import.meta.url resolved; null = unresolvable). */
267
271
  const OWN_FILE_PATH: string | null = (() => {
@@ -324,6 +328,8 @@ const USER_CONFIG_TEMPLATE = `${JSON.stringify({
324
328
  builtinDenyFloor: true,
325
329
  classifierModel: null,
326
330
  toggleShortcut: DEFAULT_TOGGLE_SHORTCUT,
331
+ audit: false,
332
+ notifyAllows: false,
327
333
  }, null, 2)}\n`;
328
334
 
329
335
  /**
@@ -341,7 +347,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
341
347
  } catch { /* 只读环境静默跳过 */ }
342
348
  return { rules: EMPTY_RULES, skipped: [], shortcutWarning: null };
343
349
  }
344
- let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown };
350
+ let raw: { allow?: unknown; deny?: unknown; denyPaths?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown; toggleShortcut?: unknown; audit?: unknown; notifyAllows?: unknown };
345
351
  try {
346
352
  raw = JSON.parse(fs.readFileSync(p, "utf8")) as typeof raw;
347
353
  } catch (err) {
@@ -378,6 +384,8 @@ function loadUserRules(): { rules: UserRules; skipped: string[]; shortcutWarning
378
384
  builtinDenyFloor: raw.builtinDenyFloor !== false,
379
385
  classifierModel: typeof raw.classifierModel === "string" && raw.classifierModel.trim() ? raw.classifierModel.trim() : null,
380
386
  toggleShortcut: shortcut.key,
387
+ audit: raw.audit === true,
388
+ notifyAllows: raw.notifyAllows === true,
381
389
  },
382
390
  skipped,
383
391
  shortcutWarning: shortcut.warning,
@@ -577,6 +585,8 @@ interface ProtectedSet {
577
585
  exact: string[];
578
586
  /** 受保护目录前缀(npm 包安装形态:整个包目录) */
579
587
  prefixes: string[];
588
+ /** 读拒绝前缀(#54):verdicts 审计目录——记录含不可信原始输出,禁回流 agent context */
589
+ readPrefixes: string[];
580
590
  /** bash/powershell 命令串危险特征(子串匹配,可绕——变更检测兜底) */
581
591
  bashPatterns: RegExp[];
582
592
  /** 变更检测基线(词法路径 + 类别;session_start 时快照全文) */
@@ -713,7 +723,28 @@ export function buildProtectedSet(agentDir: string, ownFile: string | null): Pro
713
723
  bashPatterns.push(new RegExp(`(?:${[...alts].join("|")})`));
714
724
  }
715
725
 
716
- return { exact: [...exact], prefixes: [...prefixes], bashPatterns, watchBases };
726
+ // #54 verdicts dir: gate-owned audit storage. Writes ride the normal prefixes;
727
+ // reads are denied separately — records carry raw model output (including
728
+ // fail-closed failures) that must not flow back into agent context. Deliberately
729
+ // NOT added to watchBases: the log legitimately grows every adjudication, so a
730
+ // snapshot diff would false-positive as tampering.
731
+ const verdictsForms = baseForms(path.join(agentDir, "verdicts"));
732
+ for (const f of verdictsForms) prefixes.add(f);
733
+ const home = os.homedir();
734
+ const vAlts = new Set<string>(verdictsForms.map(escapeRegExp));
735
+ for (const f of verdictsForms) {
736
+ if (f.startsWith(home + path.sep)) {
737
+ const rel = f.slice(home.length + 1);
738
+ vAlts.add(escapeRegExp("~/" + rel));
739
+ vAlts.add("\\$HOME/" + escapeRegExp(rel));
740
+ }
741
+ for (const base of baseForms(agentDir)) {
742
+ if (f.startsWith(base + path.sep)) vAlts.add("\\$PI_CODING_AGENT_DIR/" + escapeRegExp(f.slice(base.length + 1)));
743
+ }
744
+ }
745
+ bashPatterns.push(new RegExp(`(?:${[...vAlts].join("|")})`));
746
+
747
+ return { exact: [...exact], prefixes: [...prefixes], readPrefixes: verdictsForms, bashPatterns, watchBases };
717
748
  }
718
749
 
719
750
  /** Does the resolved write path hit the protected set (realpath guards against
@@ -730,6 +761,20 @@ export function isProtectedWritePath(rawPath: string, cwd: string, prot: Protect
730
761
  return false;
731
762
  }
732
763
 
764
+ /** Read-deny for the verdicts dir (#54): audit records contain raw fail-closed
765
+ * model output — untrusted text that must not flow back into agent context.
766
+ * Unlike write protection (prefixes) this is read semantics, hence a separate set. */
767
+ export function isProtectedReadPath(rawPath: string | undefined, cwd: string, prot: ProtectedSet): boolean {
768
+ if (prot.readPrefixes.length === 0) return false;
769
+ const target = rawPath ?? cwd; // #48: absent path → cwd is the effective target
770
+ for (const c of rebuiltForms(path.resolve(cwd, expandHome(target)))) {
771
+ for (const p of prot.readPrefixes) {
772
+ if (c === p || c.startsWith(p + path.sep)) return true;
773
+ }
774
+ }
775
+ return false;
776
+ }
777
+
733
778
  /** 自保护层裁决(第 0 层,先于一切):触碰门禁自身文件 → 不可豁免的 deny;其余 null 交后续层 */
734
779
  function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd: string, prot: ProtectedSet): RuleResult | null {
735
780
  switch (toolName) {
@@ -739,6 +784,14 @@ function selfProtectCheck(toolName: string, input: Record<string, unknown>, cwd:
739
784
  return { verdict: "deny", reason: `self-protection layer (ADR-0001): ${input.path} is part of the permission gate itself; agent-side modification is denied — edit it manually outside pi if intended` };
740
785
  }
741
786
  return null;
787
+ case "read":
788
+ case "grep":
789
+ case "find":
790
+ case "ls":
791
+ if (isProtectedReadPath(typeof input.path === "string" ? input.path : undefined, cwd, prot)) {
792
+ return { verdict: "deny", reason: `self-protection layer (#54): ${typeof input.path === "string" ? input.path : cwd} holds the gate's verdict audit records — agent reads are denied (untrusted raw model output inside); view them outside pi` };
793
+ }
794
+ return null;
742
795
  case "bash":
743
796
  case "powershell": {
744
797
  const cmd = String(input.command ?? "");
@@ -994,6 +1047,8 @@ interface ClassifierOutcome {
994
1047
  verdict: "allow" | "ask" | "deny";
995
1048
  reason: string;
996
1049
  source: "model" | "fail-closed";
1050
+ /** #54 audit material: the transcript actually sent and the last attempt's raw output (attached on both model and fail-closed outcomes) */
1051
+ auditRaw?: { transcript: string; rawResponse: string; modelId: string; thinking: ThinkingLevel };
997
1052
  }
998
1053
 
999
1054
  const CLASSIFIER_TIMEOUT_MS = 25_000; // 本网关 CC 分类器分布 p90=19.8s(15s 会误杀 ~15%),research/cache-sim 数据
@@ -1003,6 +1058,22 @@ const APIS_WITHOUT_TEMPERATURE = new Set<string>([
1003
1058
  "openai-codex-responses",
1004
1059
  ]);
1005
1060
 
1061
+ // Models whose provider rejected a temperature-bearing request ("`temperature`
1062
+ // is deprecated for this model" — current-gen Anthropic models, #47). Filled
1063
+ // adaptively and cached for the extension's lifetime: pi's model registry has
1064
+ // no sampling-capability metadata and the reject/accept split follows neither
1065
+ // `api` nor `reasoning`, so the provider's own error is the only reliable
1066
+ // signal. Later calls for a cached model omit the parameter upfront.
1067
+ const TEMPERATURE_REJECTED_MODELS = new Set<string>();
1068
+
1069
+ /** The provider rejected the request over the `temperature` parameter itself (#47). */
1070
+ function temperatureRejection(
1071
+ r: { ok: true; stopReason: string; errorMessage?: string } | { ok: false; error: string },
1072
+ ): boolean {
1073
+ if (r.ok) return (r.stopReason === "error" || r.stopReason === "aborted") && /temperature/i.test(r.errorMessage ?? "");
1074
+ return /temperature/i.test(r.error);
1075
+ }
1076
+
1006
1077
  /**
1007
1078
  * Minimal structural shape of a completion call (#35). pi exposes it as
1008
1079
  * ModelRegistry.complete; omp 18 does not, but the pi-ai compat module exports
@@ -1056,7 +1127,13 @@ function completionFor(registry: { complete?: unknown }, compatLoader?: CompatLo
1056
1127
  /** 分类器思考级别(pi 原生词表;后缀语法对齐 pi --model provider/id:thinking) */
1057
1128
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
1058
1129
 
1059
- /** 单次分类器调用:显式 reasoning:"off"(见下方注释),失败返回错误串而非抛出 */
1130
+ /**
1131
+ * Single classifier attempt: reasoning "off" by default (see options below);
1132
+ * failures return an error string instead of throwing. A provider rejection
1133
+ * over `temperature` strips the parameter and retries once at the same tier
1134
+ * (#47) — models that accept it keep the temperature 0 determinism pin,
1135
+ * models that deprecate it self-heal instead of fail-closing every call.
1136
+ */
1060
1137
  async function callClassifierOnce(
1061
1138
  host: PipelineHost,
1062
1139
  signal: AbortSignal | undefined,
@@ -1067,50 +1144,62 @@ async function callClassifierOnce(
1067
1144
  thinking: ThinkingLevel = "off",
1068
1145
  systemPrompt: string = CLASSIFIER_SYSTEM,
1069
1146
  ): Promise<{ ok: true; text: string; stopReason: string; errorMessage?: string } | { ok: false; error: string }> {
1070
- const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
1071
- if (signal) signals.push(signal);
1072
- try {
1073
- const response = await complete(
1074
- model,
1075
- {
1076
- systemPrompt,
1077
- messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
1078
- },
1079
- {
1080
- signal: AbortSignal.any(signals),
1081
- maxTokens,
1082
- ...(APIS_WITHOUT_TEMPERATURE.has(model.api) ? {} : { temperature: 0 }),
1083
- // Thinking params go out in both hosts' native dialects (#35):
1084
- // pi's registry.complete consumes thinkingEnabled/effort (the
1085
- // API-native fields, per the blackhole findings in
1086
- // research/thinking-param-blackhole.md); omp's compat complete
1087
- // consumes reasoning/disableReasoning. Both sides ignore unknown
1088
- // option fields, so dual-send lets each host pick its own.
1089
- // pi off = explicitly disabled (verified to send
1090
- // thinking:{"type":"disabled"}; GLM downgrades to effort-low light
1091
- // thinking); suffix levels arrive via adaptive effort (minimal→low).
1092
- // omp off = disableReasoning (without it, an absent `reasoning`
1093
- // leaves the model default undefined); level vocabularies share the
1094
- // ThinkingLevel word list, reasoning passes through as-is.
1095
- ...(thinking === "off"
1096
- ? { thinkingEnabled: false, disableReasoning: true }
1097
- : {
1098
- thinkingEnabled: true,
1099
- effort: thinking === "minimal" ? ("low" as const) : thinking,
1100
- reasoning: thinking === "minimal" ? ("low" as const) : thinking,
1101
- }),
1102
- cacheRetention: "short",
1103
- sessionId: host.getSessionId(),
1104
- },
1105
- );
1106
- const text = response.content
1107
- .filter((b) => b.type === "text")
1108
- .map((b) => b.text)
1109
- .join("");
1110
- return { ok: true, text, stopReason: response.stopReason ?? "unknown", errorMessage: response.errorMessage };
1111
- } catch (err) {
1112
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
1147
+ const fire = async (
1148
+ withTemperature: boolean,
1149
+ ): Promise<{ ok: true; text: string; stopReason: string; errorMessage?: string } | { ok: false; error: string }> => {
1150
+ const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
1151
+ if (signal) signals.push(signal);
1152
+ try {
1153
+ const response = await complete(
1154
+ model,
1155
+ {
1156
+ systemPrompt,
1157
+ messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
1158
+ },
1159
+ {
1160
+ signal: AbortSignal.any(signals),
1161
+ maxTokens,
1162
+ ...(withTemperature ? { temperature: 0 } : {}),
1163
+ // Thinking params go out in both hosts' native dialects (#35):
1164
+ // pi's registry.complete consumes thinkingEnabled/effort (the
1165
+ // API-native fields, per the blackhole findings in
1166
+ // research/thinking-param-blackhole.md); omp's compat complete
1167
+ // consumes reasoning/disableReasoning. Both sides ignore unknown
1168
+ // option fields, so dual-send lets each host pick its own.
1169
+ // pi off = explicitly disabled (verified to send
1170
+ // thinking:{"type":"disabled"}; GLM downgrades to effort-low light
1171
+ // thinking); suffix levels arrive via adaptive effort (minimal→low).
1172
+ // omp off = disableReasoning (without it, an absent `reasoning`
1173
+ // leaves the model default undefined); level vocabularies share the
1174
+ // ThinkingLevel word list, reasoning passes through as-is.
1175
+ ...(thinking === "off"
1176
+ ? { thinkingEnabled: false, disableReasoning: true }
1177
+ : {
1178
+ thinkingEnabled: true,
1179
+ effort: thinking === "minimal" ? ("low" as const) : thinking,
1180
+ reasoning: thinking === "minimal" ? ("low" as const) : thinking,
1181
+ }),
1182
+ cacheRetention: "short",
1183
+ sessionId: host.getSessionId(),
1184
+ },
1185
+ );
1186
+ const text = response.content
1187
+ .filter((b) => b.type === "text")
1188
+ .map((b) => b.text)
1189
+ .join("");
1190
+ return { ok: true, text, stopReason: response.stopReason ?? "unknown", errorMessage: response.errorMessage };
1191
+ } catch (err) {
1192
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1193
+ }
1194
+ };
1195
+ const modelKey = `${model.api}|${model.id}`;
1196
+ const withTemperature = !APIS_WITHOUT_TEMPERATURE.has(model.api) && !TEMPERATURE_REJECTED_MODELS.has(modelKey);
1197
+ const first = await fire(withTemperature);
1198
+ if (withTemperature && temperatureRejection(first)) {
1199
+ TEMPERATURE_REJECTED_MODELS.add(modelKey);
1200
+ return fire(false);
1113
1201
  }
1202
+ return first;
1114
1203
  }
1115
1204
 
1116
1205
  /**
@@ -1133,14 +1222,16 @@ async function classifyWithModel(
1133
1222
  const systemPrompt = denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
1134
1223
  const attempts: Array<[number, number]> = [[1, CLASSIFIER_MAX_TOKENS], [2, CLASSIFIER_RETRY_MAX_TOKENS]];
1135
1224
  const failures: string[] = [];
1225
+ let rawResponse = ""; // #54: raw output of the last attempt ("" for exception attempts — diagnostics already live in failures)
1136
1226
  for (const [n, maxTokens] of attempts) {
1137
1227
  if (signal?.aborted) break; // 用户已取消,不再重试
1138
1228
  const r = await callClassifierOnce(host, signal, complete, model, userMessage, maxTokens, thinking, systemPrompt);
1139
1229
  if (r.ok) {
1230
+ rawResponse = r.text;
1140
1231
  const diag = `stopReason=${r.stopReason}, model=${model.id}, errorMessage=${JSON.stringify(r.errorMessage ?? null)}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
1141
1232
  if (r.stopReason !== "error" && r.stopReason !== "aborted") {
1142
1233
  const parsed = parseVerdict(r.text);
1143
- if (parsed) return { ...parsed, source: "model" };
1234
+ if (parsed) return { ...parsed, source: "model", auditRaw: { transcript, rawResponse, modelId: model.id, thinking } };
1144
1235
  failures.push(`attempt ${n} (${maxTokens}t) contract violation: ${diag}`);
1145
1236
  } else {
1146
1237
  failures.push(`attempt ${n} (${maxTokens}t) aborted/errored: ${diag}`);
@@ -1149,7 +1240,7 @@ async function classifyWithModel(
1149
1240
  failures.push(`attempt ${n} (${maxTokens}t) exception: ${r.error}`);
1150
1241
  }
1151
1242
  }
1152
- return { verdict: "deny", reason: `classifier failure (fail-closed): ${failures.join("; ")}`, source: "fail-closed" };
1243
+ return { verdict: "deny", reason: `classifier failure (fail-closed): ${failures.join("; ")}`, source: "fail-closed", auditRaw: { transcript, rawResponse, modelId: model.id, thinking } };
1153
1244
  }
1154
1245
 
1155
1246
  // ============================================================================
@@ -1271,6 +1362,90 @@ function shadowTag(probe: ShadowProbe): string {
1271
1362
  return `(shadow cache: miss:no-entry)`;
1272
1363
  }
1273
1364
 
1365
+ // ============================================================================
1366
+ // Gray-zone verdict audit (#54): opt-in JSONL decision records, observe-only
1367
+ // (never an adjudication input)
1368
+ // ============================================================================
1369
+
1370
+ const AUDIT_KEEP_SESSIONS = 20;
1371
+
1372
+ /** One gray-zone adjudication record (#54). Full fidelity on purpose: the file is
1373
+ * local-trust-domain (same as pi-verdict.json, per the ADR-0002 boundary note),
1374
+ * so protected-path plaintext is allowed here — it never leaves the machine nor
1375
+ * flows into agent context. */
1376
+ export interface AuditRecord {
1377
+ ts: string;
1378
+ sessionId: string;
1379
+ cwd: string;
1380
+ model: string | null;
1381
+ tool: string;
1382
+ input: unknown;
1383
+ actionLine: string;
1384
+ thinking: string | null;
1385
+ transcript: string | null;
1386
+ rawResponse: string | null;
1387
+ verdict: "allow" | "ask" | "deny";
1388
+ reason: string;
1389
+ source: "model" | "fail-closed";
1390
+ shadow: string;
1391
+ degraded: boolean;
1392
+ }
1393
+
1394
+ /** Audit sink (#54): append-only and fail-soft (the first write failure surfaces
1395
+ * once via drainWarning; verdicts are never affected). The dir is created
1396
+ * lazily — audit on with no gray-zone call all session leaves zero filesystem trace. */
1397
+ export class AuditLog {
1398
+ private warning: string | null = null;
1399
+ private warned = false;
1400
+ constructor(readonly dir: string) {}
1401
+
1402
+ append(record: AuditRecord): void {
1403
+ // sessionId comes from the host with no shape guarantee: narrow to a safe filename charset
1404
+ const file = path.join(this.dir, `${record.sessionId.replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`);
1405
+ try {
1406
+ fs.mkdirSync(this.dir, { recursive: true });
1407
+ fs.appendFileSync(file, JSON.stringify(record) + "\n");
1408
+ } catch (err) {
1409
+ if (!this.warned) {
1410
+ this.warned = true;
1411
+ this.warning = `audit log write failed (${err instanceof Error ? err.message : String(err)}) — verdict records are NOT being persisted to ${this.dir}; adjudication is unaffected`;
1412
+ }
1413
+ }
1414
+ }
1415
+
1416
+ /** One-shot drain: the extension handler polls after every tool_call; first failure warns, the rest stay silent */
1417
+ drainWarning(): string | null {
1418
+ const w = this.warning;
1419
+ this.warning = null;
1420
+ return w;
1421
+ }
1422
+
1423
+ /** Keep the most recent AUDIT_KEEP_SESSIONS session files (called at session_start, best-effort) */
1424
+ prune(): void {
1425
+ let files: string[];
1426
+ try {
1427
+ files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".jsonl"));
1428
+ } catch {
1429
+ return;
1430
+ }
1431
+ if (files.length <= AUDIT_KEEP_SESSIONS) return;
1432
+ const byMtime = files
1433
+ .map((f) => {
1434
+ let m = 0;
1435
+ try {
1436
+ m = fs.statSync(path.join(this.dir, f)).mtimeMs;
1437
+ } catch {}
1438
+ return { f, m };
1439
+ })
1440
+ .sort((a, b) => b.m - a.m);
1441
+ for (const { f } of byMtime.slice(AUDIT_KEEP_SESSIONS)) {
1442
+ try {
1443
+ fs.unlinkSync(path.join(this.dir, f));
1444
+ } catch {}
1445
+ }
1446
+ }
1447
+ }
1448
+
1274
1449
  // ============================================================================
1275
1450
  // 会话态:判定管线的会话期状态(复位清单集中一处)
1276
1451
  // ============================================================================
@@ -1284,11 +1459,20 @@ export class SessionState {
1284
1459
  readonly prot: ProtectedSet;
1285
1460
  readonly shadow = new ShadowCache();
1286
1461
  userRules: UserRules;
1462
+ audit: AuditLog | null;
1287
1463
  private denyPathBases: string[] | null = null;
1464
+ private readonly agentDir: string | null;
1288
1465
 
1289
- constructor(prot: ProtectedSet, userRules: UserRules = loadUserRules().rules) {
1466
+ constructor(prot: ProtectedSet, userRules: UserRules = loadUserRules().rules, agentDir: string | null = null) {
1290
1467
  this.prot = prot;
1291
1468
  this.userRules = userRules;
1469
+ this.agentDir = agentDir;
1470
+ this.audit = this.makeAudit(userRules);
1471
+ }
1472
+
1473
+ /** #54: the audit flag follows the rules (applies to new sessions); the dir is anchored to the install path */
1474
+ private makeAudit(rules: UserRules): AuditLog | null {
1475
+ return rules.audit && this.agentDir ? new AuditLog(path.join(this.agentDir, "verdicts")) : null;
1292
1476
  }
1293
1477
 
1294
1478
  /** 会话重置:重载用户规则(配置改动新会话生效)+ 按会话 cwd 重锚 denyPaths
@@ -1298,6 +1482,7 @@ export class SessionState {
1298
1482
  this.userRules = loaded.rules;
1299
1483
  this.denyPathBases = anchorDenyPaths(loaded.rules.denyPaths, cwd); // anchored to the session cwd, once (ADR-0002)
1300
1484
  this.shadow.reset();
1485
+ this.audit = this.makeAudit(loaded.rules);
1301
1486
  return { skipped: loaded.skipped, shortcutWarning: loaded.shortcutWarning };
1302
1487
  }
1303
1488
 
@@ -1362,15 +1547,41 @@ export async function adjudicate(
1362
1547
  }
1363
1548
 
1364
1549
  // 灰区 → 分类器;无可用模型 → 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
+
1365
1572
  const resolved = env.getModel();
1366
- if (!resolved) return { verdict: "deny", reason: "no classifier model available (fail-closed)", source: "fail-closed", degraded: false };
1573
+ if (!resolved) {
1574
+ const reason = "no classifier model available (fail-closed)";
1575
+ audit({ verdict: "deny", reason, source: "fail-closed", degraded: false }, null, "-");
1576
+ return { verdict: "deny", reason, source: "fail-closed", degraded: false };
1577
+ }
1367
1578
 
1368
1579
  // 影子缓存(observe-only):前置查询 would-be 命中,不改变任何裁决
1369
1580
  const cmdKey = shadowCommandKey(call.toolName, call.input, env.cwd);
1370
1581
  const ctxKey = shadowContextKey(env.host);
1371
1582
  const probe = state.shadow.probe(cmdKey, ctxKey);
1372
1583
 
1373
- const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, toolCallLine(call.toolName, call.input), resolved.thinking, state.userRules.denyPaths.length > 0);
1584
+ const outcome = await classifyWithModel(env.host, env.signal, env.complete, resolved.model, actionLine, resolved.thinking, state.userRules.denyPaths.length > 0);
1374
1585
 
1375
1586
  // 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
1376
1587
  // 命中且本次为可缓存裁决时,对比反事实一致性
@@ -1380,6 +1591,8 @@ export async function adjudicate(
1380
1591
  }
1381
1592
 
1382
1593
  const shadow = shadowTag(probe);
1594
+ const askDegraded = !env.hasUI && outcome.verdict === "ask";
1595
+ audit({ verdict: askDegraded ? "deny" : outcome.verdict, reason: outcome.reason, source: outcome.source, degraded: askDegraded }, outcome.auditRaw ?? null, shadow);
1383
1596
  if (outcome.verdict === "allow") return { verdict: "allow", reason: outcome.reason, source: "classifier", degraded: false, shadow };
1384
1597
  if (outcome.verdict === "deny") return { verdict: "deny", reason: outcome.reason, source: "classifier", degraded: false, shadow };
1385
1598
  // ask:无 UI 降级为 deny(ask 降级,CONTEXT.md 词条)
@@ -1390,6 +1603,14 @@ export async function adjudicate(
1390
1603
  // 扩展主体
1391
1604
  // ============================================================================
1392
1605
 
1606
+ /** Agent-facing block reason (#53): the text must be self-sufficient — structural
1607
+ * error signaling does not reach several provider lanes, and verbatim rule/classifier
1608
+ * reasons can be empty or too terse for the acting model to recognize as a block. */
1609
+ function blockedReason(tag: string, detail: string): string {
1610
+ const clean = detail.trim().replace(/\.+$/, "");
1611
+ return `[auto-mode ${tag} block] BLOCKED — this action did NOT run. Reason: ${clean || "(no further reason given)"}. Report the block to the user; never claim it succeeded or completed.`;
1612
+ }
1613
+
1393
1614
  /** Optional dependency injection for tests (#35): fake the compat fallback loader. */
1394
1615
  export interface AutoModeDeps {
1395
1616
  compatLoader?: CompatLoader;
@@ -1403,14 +1624,14 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1403
1624
  let enabled = pi.getFlag("auto-mode") !== false;
1404
1625
  const debug = pi.getFlag("auto-mode-debug") === true || process.env.PI_AUTO_MODE_DEBUG === "1";
1405
1626
  // 会话态与门禁完整性监视:复位清单各归 SessionState.reset / IntegrityWatch.startSession
1406
- const state = new SessionState(buildProtectedSet(agentDirPath(), OWN_FILE_PATH));
1627
+ const state = new SessionState(buildProtectedSet(agentDirPath(), OWN_FILE_PATH), undefined, agentDirPath());
1407
1628
  const integrity = new IntegrityWatch(state.prot.watchBases);
1408
1629
 
1409
1630
  /** 篡改处置呈现:还原 + fail-closed 的本地通知(含文件清单与原因) */
1410
1631
  function presentTamper(changed: Array<{ file: string; kind: WatchKind }>, ctx: ExtensionContext, cause: string): { block: true; reason: string } {
1411
1632
  const r = integrity.restoreAndFailClose(changed, cause);
1412
1633
  ctx.ui.notify(`🛡️ pi-verdict TAMPER DETECTED${cause ? ` (${cause})` : ""}: ${r.files} modified bypassing the gate; restored from session snapshot where possible. Fail-closed for the rest of this session — review the file(s) and restart the session.`, "warning");
1413
- return { block: true, reason: r.reason };
1634
+ return { block: true, reason: blockedReason("tamper", r.reason) };
1414
1635
  }
1415
1636
 
1416
1637
  /** Verdict → UI(本扩展唯一的裁决呈现点):按 source × degraded 查模板,文案与
@@ -1418,10 +1639,16 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1418
1639
  * (ADR-0002 story 11:通知与 block reason 回流 agent context)。 */
1419
1640
  async function presentVerdict(v: Verdict, action: string, ctx: ExtensionContext): Promise<{ block: true; reason: string } | undefined> {
1420
1641
  if (v.verdict === "allow") {
1642
+ // #60 (CONTEXT.md 通知): classifier allows surface via notifyAllows OR
1643
+ // debug — exactly one notification either way; the shadow suffix stays
1644
+ // debug-only; mechanical passes (rule echo, protected-path confirm) stay
1645
+ // debug-only — notifications carry judgment, the audit log carries completeness
1421
1646
  if (debug) {
1422
1647
  if (v.source === "rule") ctx.ui.notify(`🛡️ allow (rule): ${action}`, "info");
1423
1648
  else if (v.source === "protected-path") ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
1424
1649
  else ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}${v.shadow ? " " + v.shadow : ""}`, "info");
1650
+ } else if (state.userRules.notifyAllows && v.source === "classifier") {
1651
+ ctx.ui.notify(`🛡️ allow (classifier): ${v.reason}\n ${action}`, "info");
1425
1652
  }
1426
1653
  return undefined;
1427
1654
  }
@@ -1429,18 +1656,18 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1429
1656
  if (v.source === "protected-path") {
1430
1657
  // 无 action 行:action 串可内嵌被触路径,通知不得携带受保护路径明文
1431
1658
  ctx.ui.notify(`🛡️ Auto Mode blocked (non-interactive, protected-path ask→deny): ${v.reason}`, "warning");
1432
- return { block: true, reason: `[auto-mode] protected-path ask degraded to block in non-interactive mode: ${v.reason}` };
1659
+ return { block: true, reason: blockedReason("protected-path", `ask degraded to block in non-interactive mode: ${v.reason}`) };
1433
1660
  }
1434
1661
  if (v.source === "fail-closed") {
1435
1662
  ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
1436
- return { block: true, reason: `[auto-mode] ${v.reason}` };
1663
+ return { block: true, reason: blockedReason("fail-closed", v.reason) };
1437
1664
  }
1438
1665
  if (v.source === "rule") {
1439
1666
  ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}`, "warning");
1440
- return { block: true, reason: `[auto-mode rule block] ${v.reason}` };
1667
+ return { block: true, reason: blockedReason("rule", v.reason) };
1441
1668
  }
1442
1669
  ctx.ui.notify(`🛡️ Auto Mode blocked: ${v.reason}\n ${action}${debug && v.shadow ? " " + v.shadow : ""}`, "warning");
1443
- return { block: true, reason: `[auto-mode classifier block] ${v.reason}` };
1670
+ return { block: true, reason: blockedReason("classifier", v.reason) };
1444
1671
  }
1445
1672
  // ask → 人工确认;非交互已在管线内降级,能走到这里的必有 UI
1446
1673
  if (v.source === "protected-path") {
@@ -1450,10 +1677,10 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1450
1677
  if (debug) ctx.ui.notify("🛡️ allow (protected-path confirm)", "info");
1451
1678
  return undefined;
1452
1679
  }
1453
- return { block: true, reason: "[auto-mode] user declined protected-path access" };
1680
+ return { block: true, reason: blockedReason("user-declined", "user declined protected-path access") };
1454
1681
  }
1455
1682
  const ok = await ctx.ui.confirm("🛡️ Auto Mode confirmation", `${action}\n\nClassifier opinion: ${v.reason}\n\nAllow execution?`);
1456
- return ok ? undefined : { block: true, reason: "[auto-mode] user declined" };
1683
+ return ok ? undefined : { block: true, reason: blockedReason("user-declined", "user declined") };
1457
1684
  }
1458
1685
 
1459
1686
  function refreshStatus(ctx: ExtensionContext) {
@@ -1473,6 +1700,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1473
1700
  pi.on("session_start", async (_event, ctx) => {
1474
1701
  const report = state.reset(ctx.cwd);
1475
1702
  integrity.startSession();
1703
+ state.audit?.prune(); // #54: converge to the AUDIT_KEEP_SESSIONS most recent files at session start
1476
1704
  if (report.skipped.length > 0) {
1477
1705
  ctx.ui.notify(`pi-verdict: skipped ${report.skipped.length} invalid config value(s) in config (${userConfigPath()}): ${report.skipped.join(", ")}`, "warning");
1478
1706
  }
@@ -1497,6 +1725,8 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1497
1725
  const toggleHint = () => (registeredToggleKey ? ` · toggle: ${registeredToggleKey}` : "");
1498
1726
  /** Status line denyPaths count (ADR-0002): shown only when configured */
1499
1727
  const denyPathsHint = () => (state.userRules.denyPaths.length > 0 ? `\ndenyPaths: ${state.userRules.denyPaths.length} active` : "");
1728
+ /** Status line audit hint (#54): shown only while the sink is active */
1729
+ const auditHint = () => (state.audit ? `\naudit: on → ${state.audit.dir}` : "");
1500
1730
 
1501
1731
  pi.registerCommand("automode", {
1502
1732
  description: "Show Auto Mode status and shadow-cache stats, or set it: /automode on|off",
@@ -1504,7 +1734,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1504
1734
  const arg = args.trim().toLowerCase();
1505
1735
  // 裸调用:只读状态展示,无副作用(含影子缓存统计行)
1506
1736
  if (arg === "") {
1507
- ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
1737
+ ctx.ui.notify(`${enabled ? "🛡️ Auto Mode: on" : "Auto Mode: off"}\n${state.shadow.summary()}${denyPathsHint()}${auditHint()}\nUsage: /automode on|off${toggleHint()}`, "info");
1508
1738
  return;
1509
1739
  }
1510
1740
  // 幂等设定:与现值相同不翻转,仅确认
@@ -1579,7 +1809,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1579
1809
  // 第 0 层前置:变更检测(ADR-0001)——篡改后本会话恒 deny(fail-closed)
1580
1810
  if (integrity.tampered) {
1581
1811
  ctx.ui.notify(`🛡️ Auto Mode blocked: self-protection fail-closed (tamper detected this session; restart to reset)\n ${action}`, "warning");
1582
- return { block: true, reason: "[auto-mode] self-protection: fail-closed until session restart (protected file was tampered with)" };
1812
+ return { block: true, reason: blockedReason("tamper", "self-protection: fail-closed until session restart (protected file was tampered with)") };
1583
1813
  }
1584
1814
  const changed = integrity.detect();
1585
1815
  if (changed.length > 0) {
@@ -1615,6 +1845,8 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
1615
1845
  host: ctx.sessionManager,
1616
1846
  signal: ctx.signal,
1617
1847
  });
1848
+ const auditWarning = state.audit?.drainWarning(); // #54: fail-soft one-shot warning
1849
+ if (auditWarning) ctx.ui.notify(`pi-verdict: ${auditWarning}`, "warning");
1618
1850
  return presentVerdict(verdict, action, ctx);
1619
1851
  });
1620
1852
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "A minimal permission gate for Pi in the style of Claude Code's auto mode",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",
@@ -24,7 +24,12 @@
24
24
  "security",
25
25
  "tool-call",
26
26
  "classifier",
27
- "ai-agent"
27
+ "ai-agent",
28
+ "jev",
29
+ "typesafe",
30
+ "system-one",
31
+ "decisions",
32
+ "openrouter"
28
33
  ],
29
34
  "repository": {
30
35
  "type": "git",