pi-verdict 0.13.0 → 0.13.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
@@ -119,7 +119,7 @@ pi-verdict 0.13+ requires **pi ≥ 0.99** and runs on pi only (native classifier
119
119
 
120
120
  - `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
121
121
  - `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)
122
- - `ignoreTools` names uncovered tools (`todo`, `web_search`, MCP/custom tools) that skip adjudication — **allow with zero model calls**; entries naming covered tools (`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`) are inert: those stay governed by the deny floor and your allow/deny rules, and the self-protection layer always runs first. A fresh install pre-fills a **starter list** (`todo`, `ask_user_question`, `memory_write`, `memory_search` — observed harmless across the 1265-verdict production audit). Caveat: an exempted tool loses the classifier's `denyPaths` existence-hint vigilance (uncovered tools never hit the path extractor anyway)
122
+ - `ignoreTools` names uncovered tools (`todo`, `web_search`, MCP/custom tools) that skip adjudication — **allow with zero model calls**; entries naming covered tools (`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`) are inert: those stay governed by the deny floor and your allow/deny rules, and the self-protection layer always runs first. A fresh install pre-fills a **starter list** (`todo`, `ask_user_question`, `memory_write`, `memory_search` — observed harmless across the 1265-verdict production audit). Caveat: an exempted tool loses the classifier's `denyPaths` existence-hint vigilance (uncovered tools never hit the path extractor anyway); MCP tool names are normalized before matching — see [codemode & MCP](#pi-099-codemode--mcp-indirect-calls-are-still-gated)
123
123
  - `builtinDenyFloor: false` turns off the built-in danger/path floor (your risk; the self-protection layer below always stays on)
124
124
  - `classifierModel` pins the classifier model, e.g. `"zai/glm-5.3-flash:low"` (thinking suffix supported; default: session model with thinking off)
125
125
  - `classifierModel: "typesafe/jev-latest"` opts into the **native jev classifier** — one structured `classify()` call per gray-zone verdict via pi's built-in classifier catalog (TypeSafe direct, or Jev on OpenRouter/OpenCode/Cloudflare/Vercel); see [ADR-0005](docs/adr/0005-native-classifier-migration.md)
@@ -141,13 +141,23 @@ No built-in allowlist — every "always allow" claim is yours ([why](docs/config
141
141
  - or try it once: `PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
142
142
 
143
143
  **Notes**:
144
- - Verdicts are structured `classify()` answers (choice + probabilities + confidence); the reason line keeps the historical `jev:` shape, other classifier APIs render `classifier:`
144
+ - Verdicts are structured `classify()` answers (choice + probabilities + confidence); the reason line keeps the historical `jev:` probability breakdown (tag-free — the `<verdict>` prefix survives only in the audit record's rawResponse), other classifier APIs render `classifier:`
145
145
  - Classifier specs resolve through pi's classifier catalog first (`findOfType`), chat registry second; on same-id dual listings (llama.cpp) the native entry wins; thinking suffixes on a classifier spec warn once and drop (recorded `thinking: null`)
146
146
  - Custom endpoints: override the provider's `baseUrl` in models.json (the 0.12 `PI_VERDICT_JEV_URL` escape hatch is gone, as is `PI_VERDICT_JEV_TRANSPORT` — transport choice is now the spec itself)
147
147
  - Carried-over limit: the denyPaths existence hint still does not reach classifier-typed models ([ADR-0005](docs/adr/0005-native-classifier-migration.md)); on the TypeSafe direct transport per-call cost shows $0 (its API does not report it)
148
148
 
149
149
  jev's calibrated confidence is exactly what the confidence floor keys on — pair it with a second layer (`"classifierMinConfidence", "classifierFallbackModel"`) so its low-confidence calls go to a deeper model instead of standing ([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md)).
150
150
 
151
+ ### pi 0.99 codemode & MCP: indirect calls are still gated
152
+
153
+ pi 0.99 can run model-written JavaScript in a QuickJS sandbox (`codemode`) that calls pi's tools, and MCP servers register tools as `mcp__<server>__<tool>`. Neither surface bypasses this gate:
154
+
155
+ - **Nested calls are gated exactly like direct ones** — pi routes every tool call a codemode script makes through the same `tool_call` pipeline (tagged `parentToolCallId`, ids `<parent>/<n>`); a blocked call returns as an error to the script, which the model sees
156
+ - **MCP tools land in the gray zone** — the rule layer covers the built-in command/file tools only; each `mcp__*` call is classified, fail-closed included
157
+ - **`ignoreTools` and MCP names**: tool names are normalized — every character outside `[A-Za-z0-9_]` becomes `_` (`mcp__dev-radius__x` → `mcp__dev_radius__x`); exemption entries must use the normalized form
158
+ - **Cost amplification**: one script may issue up to 256 nested calls; gray-zone calls classify one by one, so a slow LLM classifier multiplies per-call latency
159
+ - **Exposure boundary**: adding an MCP server auto-enables codemode, and `pi --no-extensions -e builtin:mcp` runs MCP tools with no extensions loaded — i.e. without this gate. The gate is itself an extension, so it cannot be active in a session that loads none; the boundary is inherent to pi's extension model, stated here rather than papered over
160
+
151
161
  ### Self-protection (the gate guards itself — [ADR-0001](docs/adr/0001-self-protection-layer.md))
152
162
 
153
163
  The gate's own files — the config and the installed extension copy — are **user-editable only**: writes from inside the gate hard-deny (reads pass); your editor never passes through the gate, the sudoers/visudo precedent.
@@ -195,7 +205,9 @@ tool_call
195
205
  ├─ 2. Gray zone → model classifier (defaults to session model — "self-reflection")
196
206
  │ ├─ input: CC-style <transcript> — recent user intent + tool calls,
197
207
  │ │ action under review always last
198
- │ └─ output contract: <verdict>allow|ask|deny</verdict> prefix-anchored
208
+ │ └─ output: native classify() answers (choice + probabilities + confidence;
209
+ │ tag-free reason, full contract line in the audit rawResponse) or,
210
+ │ on the chat path, <verdict>…</verdict> prefix-anchored free text
199
211
  │
200
212
  └─ 3. Three-state adjudication
201
213
  ├─ allow → pass
@@ -223,6 +235,7 @@ Design decisions here are settled by measurement, and the lab notes ship with th
223
235
  - no built-in allowlist by design (see the [bypass writeup](research/rule-layer-security-audit.md)); with an empty `allow` config most commands go to the classifier — point `--auto-mode-model` at a fast model if per-call latency matters
224
236
  - the path sensitivity floor applies to file tools only: bash command strings are matched by the danger regexes alone, so e.g. `cat ~/.ssh/id_rsa` goes to the classifier rather than the deterministic S0 deny (the file-tool spelling `read ~/.ssh/id_rsa` does deny)
225
237
  - on Windows the built-in floor covers bash-shaped patterns only — PowerShell-native dangerous commands (`Remove-Item -Recurse -Force`, `Invoke-Expression`, `Set-ExecutionPolicy`, …) rely on the classifier (fail-closed)
238
+ - on macOS the per-user temp tree (`$TMPDIR`, the `/var/folders/…/T` confstr dir) is exempt from the system-directory floor: reads allow at zero cost, writes adjudicate as ordinary outside-project writes (classifier). The exemption anchors to the runtime-resolved confstr family — a hand-set `TMPDIR` lifts nothing — and `/var/tmp` (POSIX shared temp) stays denied; symlink spellings whose real form escapes the temp tree still hit S1
226
239
  - AGENTS.md is not passed to the classifier as downweighted intent evidence (Claude Code does this)
227
240
  - parallel gray-zone calls are adjudicated serially
228
241
  - self-reflection means the session model adjudicates — point `--auto-mode-model` at a lighter model if verdict latency/cost matters (open question tracked in the issue tracker)
package/README.zh-CN.md CHANGED
@@ -119,7 +119,7 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
119
119
 
120
120
  - `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
121
121
  - `denyPaths` 是你声明**受保护**的普通路径列表:触碰触发**终局 ask** 由你裁决(非交互降级 deny);分类器只被告知路径**存在**,路径明文永不出本机。`grep`/`find`/`ls` 按**整个搜索范围**比较:省略 `path`(pi 默认:当前目录)或传入位于声明路径之上的父目录,同样触发 ask。全新安装会预填一份**入门列表**(`~/.ssh/`、`~/.gnupg`、`~/.mc`、shell rc/profile 文件)
122
- - `ignoreTools` 列出规则未覆盖的工具(`todo`、`web_search`、MCP/自定义工具):**直接放行、零模型调用**;列出已覆盖工具(`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`)的条目无效:它们仍受 deny floor 与你的 allow/deny 规则约束,自保护层也永远先行。全新安装会预填一份**入门列表**(`todo`、`ask_user_question`、`memory_write`、`memory_search`——来自项目 1265 条生产审计的观察)。注意:被豁免的工具失去分类器对 `denyPaths` 的存在性话术警戒(未覆盖工具本就不进路径提取器)
122
+ - `ignoreTools` 列出规则未覆盖的工具(`todo`、`web_search`、MCP/自定义工具):**直接放行、零模型调用**;列出已覆盖工具(`bash`/`read`/`write`/`edit`/`grep`/`find`/`ls`/`powershell`)的条目无效:它们仍受 deny floor 与你的 allow/deny 规则约束,自保护层也永远先行。全新安装会预填一份**入门列表**(`todo`、`ask_user_question`、`memory_write`、`memory_search`——来自项目 1265 条生产审计的观察)。注意:被豁免的工具失去分类器对 `denyPaths` 的存在性话术警戒(未覆盖工具本就不进路径提取器);MCP 工具名匹配前会归一化——见 [codemode 与 MCP](#pi-099-codemode-与-mcp间接调用同样受门禁)
123
123
  - `builtinDenyFloor: false` 整体关闭内置危险/路径拦截(风险自担;下方自保护层永远开启)
124
124
  - `classifierModel` 指定分类器模型,如 `"zai/glm-5.3-flash:low"`(支持思考后缀;缺省 = 会话模型且显式关思考)
125
125
  - `classifierModel: "typesafe/jev-latest"` 启用**原生 jev 分类器**——每次灰区裁决经 pi 内置分类器目录发一次结构化 `classify()` 调用(TypeSafe 直连,或 OpenRouter/OpenCode/Cloudflare/Vercel 上的 Jev);详见 [ADR-0005](docs/adr/0005-native-classifier-migration.md)
@@ -141,13 +141,23 @@ pi-verdict 0.13+ 需 **pi ≥ 0.99**,仅支持 pi(原生分类器接入,[ADR-000
141
141
  - 或者临时试用一次:`PI_AUTO_MODE_MODEL=typesafe/jev-latest pi`
142
142
 
143
143
  **说明**:
144
- - 裁决为结构化 `classify()` 应答(choice + probabilities + confidence);reason 行保留历史 `jev:` 形态,其余分类器 API 渲染 `classifier:`
144
+ - 裁决为结构化 `classify()` 应答(choice + probabilities + confidence);reason 行保留历史 `jev:` 概率分解(无标签——`<verdict>` 前缀只存活于审计记录的 rawResponse),其余分类器 API 渲染 `classifier:`
145
145
  - 分类器 spec 先经 pi 分类器目录解析(`findOfType`)、chat 注册表兜底;同 id 双型并存(llama.cpp)时原生条目优先;分类器 spec 上的思考后缀警告一次后丢弃(审计 `thinking` 记为 `null`)
146
146
  - 自定义端点:在 models.json 覆盖 provider 的 `baseUrl`(0.12 的 `PI_VERDICT_JEV_URL` 逃生口与 `PI_VERDICT_JEV_TRANSPORT` 均已移除——transport 选择即 spec 本身)
147
147
  - 沿袭限制:denyPaths 存在性话术仍不达分类器形态模型([ADR-0005](docs/adr/0005-native-classifier-migration.md));TypeSafe 直连的单次成本显示 $0(其 API 不返回 cost)
148
148
 
149
149
  jev 的校准 confidence 正是置信地板的判定依据——搭配第二层使用(`"classifierMinConfidence", "classifierFallbackModel"`),让低置信调用交由更深的模型复裁,而非就地生效([ADR-0004](docs/adr/0004-classifier-fallback-cascade.md))。
150
150
 
151
+ ### pi 0.99 codemode 与 MCP:间接调用同样受门禁
152
+
153
+ pi 0.99 可以在 QuickJS 沙箱(`codemode`)里运行模型写的 JavaScript 去调用 pi 的工具,MCP 服务器则以 `mcp__<server>__<tool>` 注册工具。这两个新增面都不会绕过本门禁:
154
+
155
+ - **嵌套调用与直接调用同样过门**——pi 把 codemode 脚本发起的每一次工具调用都路由到同一条 `tool_call` 管线(带 `parentToolCallId`,id 形如 `<父id>/<n>`);被拦截的调用以错误形式回传脚本,模型可见
156
+ - **MCP 工具落入灰区**——规则层只覆盖内置命令/文件工具;每次 `mcp__*` 调用都走分类器,含 fail-closed
157
+ - **`ignoreTools` 与 MCP 工具名**:工具名会归一化——`[A-Za-z0-9_]` 之外的字符统一变 `_`(`mcp__dev-radius__x` → `mcp__dev_radius__x`);豁免条目必须写归一化后的形态
158
+ - **成本放大**:单个脚本最多可发 256 次嵌套调用,灰区调用逐个分类,慢的 LLM 分类器会把单次延迟成倍放大
159
+ - **暴露边界**:添加 MCP 服务器会自动开启 codemode,而 `pi --no-extensions -e builtin:mcp` 可在不加载任何扩展(即无本门禁)的情况下启用 MCP 工具。门禁自身即扩展,在完全不加载扩展的会话中无法生效;此边界为 pi 扩展模型的固有属性,此处显式陈述而非掩饰
160
+
151
161
  ### 自保护(门禁守护自身——[ADR-0001](docs/adr/0001-self-protection-layer.md))
152
162
 
153
163
  门禁自身的文件——配置与扩展安装副本——**仅用户可改**:门禁之内的写入一律硬 deny(读放行);你的编辑器修改不经门禁,同类先例是 sudoers 必须经 visudo。
@@ -223,6 +233,7 @@ tool_call
223
233
  - 设计上无内置白名单(见[绕过测试](research/rule-layer-security-audit.md)与[用户自定义规则](#用户自定义规则pi-verdictjson));allow 配置为空时大多数命令进分类器 —— 延迟敏感可 `--auto-mode-model` 指向轻量模型
224
234
  - 路径敏感度 floor 只作用于文件类工具:bash 命令串仅匹配危险正则——`cat ~/.ssh/id_rsa` 走分类器而非确定性 S0 拦截(文件工具拼写 `read ~/.ssh/id_rsa` 会拦截)
225
235
  - Windows 下内置 floor 仅覆盖 bash 形态模式——PowerShell 原生危险命令(`Remove-Item -Recurse -Force`、`Invoke-Expression`、`Set-ExecutionPolicy` 等)依赖分类器兜底(fail-closed)
236
+ - macOS 下 per-user 临时目录(`$TMPDIR`,`/var/folders/…/T` confstr 目录)豁免于系统目录 floor:读零成本放行,写按普通项目外写交分类器裁决。豁免锚定运行时解析的 confstr 族——手工设置 `TMPDIR` 不会解除任何保护;`/var/tmp`(POSIX 共享临时目录)维持拦截;真实形态逃逸临时树的符号链接拼写仍命中 S1
226
237
  - AGENTS.md 未作为降权意图证据传入分类器(Claude Code 有此设计)
227
238
  - 并行灰区调用串行裁决
228
239
  - 自省意味着会话模型自身裁决 —— 若延迟/成本敏感,用 `--auto-mode-model` 指向轻量模型(开放问题见 issue tracker)
@@ -461,7 +461,62 @@ const S0_SECRET = [
461
461
  ];
462
462
  // /private prefixes: macOS firmlinks — /etc, /var are really /private/etc,
463
463
  // /private/var, and realpath'd toolchain output uses the real spelling (#21)
464
- const S1_SYSTEM = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$)/i, /^\/var(\/|$)/i, /^\/System(\/|$)/i, /(^|\/)authorized_keys$/i];
464
+ // Split into two families (#83): the directory-prefix family (what the per-user
465
+ // temp exemption may lift on macOS) and the basename family (authorized_keys —
466
+ // staging it in a temp dir must stay denied; defense in depth for the
467
+ // stage-then-copy chain).
468
+ const S1_SYSTEM_DIRS = [/^\/etc(\/|$)/i, /^\/private\/(etc|var)(\/|$)/i, /^\/usr(\/|$)/i, /^\/var(\/|$)/i, /^\/System(\/|$)/i];
469
+ const S1_SYSTEM_FILES = [/(^|\/)authorized_keys$/i];
470
+ const S1_SYSTEM = [...S1_SYSTEM_DIRS, ...S1_SYSTEM_FILES];
471
+
472
+ /** #83: the per-user temp exemption bases — macOS confstr family only, at the
473
+ * confstr DEPTH (two segments below the family root: /var/folders/<xx>/<yy>/…),
474
+ * so the family root itself or a stray one-level child can never widen the
475
+ * exemption. The guard is deliberate: os.tmpdir() reads the process env, so
476
+ * trusting it verbatim would let a hand-set TMPDIR (e.g. TMPDIR=/etc, or the
477
+ * family root) lift the floor over S1 trees — narrowing the never-config-exemptible
478
+ * floor must go through code review, not an env accident. Another user's confstr
479
+ * subtree technically passes the depth check but is macOS-permission-guarded
480
+ * (same-user threat only). /var/tmp (POSIX shared temp) intentionally stays S1.
481
+ * Returns [] (inert) on every other platform — their tmpdirs never match S1. */
482
+ export function computeTmpdirBases(
483
+ platform: NodeJS.Platform,
484
+ tmp: string,
485
+ realpath: (p: string) => string | null,
486
+ ): string[] {
487
+ if (platform !== "darwin" || !tmp) return [];
488
+ const lexical = path.resolve(tmp);
489
+ const real = realpath(lexical);
490
+ const bases = real === null ? [lexical] : [lexical, real];
491
+ const confstrFamily = /^(?:\/private)?\/var\/folders\/[^/]+\/[^/]+(?:\/|$)/;
492
+ return bases.every((b) => confstrFamily.test(b)) ? bases : [];
493
+ }
494
+
495
+ /** The session-constant exemption bases (os.tmpdir() is stable for a process
496
+ * lifetime); test-overridable via the exported seam below. */
497
+ function defaultTmpdirBases(): string[] {
498
+ return computeTmpdirBases(process.platform, os.tmpdir(), (p) => {
499
+ try {
500
+ return fs.realpathSync(p);
501
+ } catch {
502
+ return null;
503
+ }
504
+ });
505
+ }
506
+
507
+ let tmpdirExemptBases = defaultTmpdirBases();
508
+
509
+ /** Test seam for tmpdirExemptBases — exported for tests only (the internal-seam
510
+ * surface, the standing #35 pattern); null restores the production bases. */
511
+ export function setTmpdirBasesForTests(bases: string[] | null): void {
512
+ tmpdirExemptBases = bases ?? defaultTmpdirBases();
513
+ }
514
+
515
+ /** #83: is EVERY canonical form of the target under the per-user temp tree?
516
+ * Intersection semantics (#20 discipline): a lexical temp spelling whose real
517
+ * form escapes (symlink to /etc) is NOT exempt — the S1 grading still applies. */
518
+ const tmpdirExempt = (forms: string[]): boolean =>
519
+ tmpdirExemptBases.length > 0 && forms.every((f) => tmpdirExemptBases.some((b) => f === b || f.startsWith(b + path.sep)));
465
520
  const S2_USER_RC = [/\.(bashrc|zshrc|profile|bash_profile|gitconfig)$/i, /crontab/i, /Library\/LaunchAgents(\/|$)/i, /\.config\/systemd(\/|$)/i];
466
521
  const S3_GIT_META = [/(^|\/)\.git\/(hooks|config|modules)(\/|$)/i, /(^|\/)\.gitmodules$/i];
467
522
 
@@ -479,11 +534,17 @@ function classifyPath(toolName: string, rawPath: string, cwd: string, isWrite: b
479
534
  : (reason: string): RuleResult => ({ verdict: "gray", reason });
480
535
 
481
536
  if (hit(S0_SECRET)) return D(`S0 secrets/credential path: ${rawPath}`);
537
+ // #83: under the per-user temp tree the DIRECTORY-prefix family of S1 lifts (a
538
+ // macOS confstr temp dir is per-user scratch, not a system directory); the
539
+ // basename family (authorized_keys) still applies, and the fall-through keeps the
540
+ // deny-only floor philosophy — reads regain the ordinary read allow, writes grade
541
+ // as ordinary outside-project writes (gray, classifier-adjudicated).
542
+ const s1Rules = tmpdirExempt(forms) ? S1_SYSTEM_FILES : S1_SYSTEM;
482
543
  if (!isWrite) {
483
- if (hit(S1_SYSTEM)) return { verdict: "gray", reason: `read system config path: ${rawPath}` };
544
+ if (hit(s1Rules)) return { verdict: "gray", reason: `read system config path: ${rawPath}` };
484
545
  return { verdict: "allow" };
485
546
  }
486
- if (hit(S1_SYSTEM)) return D(`write to system directory: ${rawPath}`);
547
+ if (hit(s1Rules)) return D(`write to system directory: ${rawPath}`);
487
548
  if (hit(S3_GIT_META)) return D(`write to .git metadata (executable code entry point): ${rawPath}` );
488
549
  if (hit(S2_USER_RC)) return { verdict: "gray", reason: `write to user config/persistence entry point: ${rawPath}` };
489
550
  // In-cwd write allowance (#20): every canonical form must sit inside the cwd
@@ -556,10 +617,126 @@ function userRuleTarget(toolName: string, input: Record<string, unknown>, cwd: s
556
617
  // only ever sees a fixed existence hint — zero path plaintext.
557
618
  // ============================================================================
558
619
 
559
- /** Path-like tokens in a shell command string: ~/…, $HOME/…, absolute /…, ./… / ../…, and word/word relative forms. URL path segments can match the absolute branch — harmless: resolution against denyPaths prefixes is what decides, false positives ask (safe direction) */
560
- const BASH_PATH_TOKENS =
620
+ /** Path-like tokens in a shell command string: ~/…, $HOME/…, absolute /…, ./… / ../…, and word/word relative forms. URL path segments can match the absolute branch — harmless: resolution against denyPaths prefixes is what decides, false positives ask (safe direction).
621
+ *
622
+ * Exported as the SEMANTIC ORACLE for #32's linear tokenizer (bashPathTokens) — the
623
+ * production path never runs this regex: its four alternatives backtrack
624
+ * quadratically on long failure searches (a 200k separator-free run takes ~28s,
625
+ * issue #32), and unlike the danger regexes (#25's 8192 cap) it cannot be capped —
626
+ * truncation would let a protected-path spelling beyond the cap silently escape
627
+ * the deterministic ask (ADR-0002's never-silently-passed contract). */
628
+ export const BASH_PATH_TOKENS =
561
629
  /(?:~|\$HOME)(?:\/[\w.@*-]+)*|\/(?:[\w.@*-]+\/)*[\w.@*-]*|\.{1,2}(?:\/[\w.@*-]+)+|[\w.-]+(?:\/[\w.-]+)+/g;
562
630
 
631
+ /** ASCII class membership for the tokenizer (JS \w is ASCII-only; non-ASCII code
632
+ * points simply fall outside the classes, matching the regex). */
633
+ const TOKEN_W2 = new Uint8Array(128); // [\w.@*-]
634
+ const TOKEN_W4 = new Uint8Array(128); // [\w.-]
635
+ for (let c = 0; c < 128; c++) {
636
+ const ch = String.fromCharCode(c);
637
+ if (/[a-zA-Z0-9_]/.test(ch) || ".@*-".includes(ch)) TOKEN_W2[c] = 1;
638
+ if (/[a-zA-Z0-9_]/.test(ch) || ".-".includes(ch)) TOKEN_W4[c] = 1;
639
+ }
640
+
641
+ const isW2 = (s: string, i: number): boolean => i < s.length && s.charCodeAt(i) < 128 && TOKEN_W2[s.charCodeAt(i)] === 1;
642
+ const isW4 = (s: string, i: number): boolean => i < s.length && s.charCodeAt(i) < 128 && TOKEN_W4[s.charCodeAt(i)] === 1;
643
+
644
+ /** #32: linear tokenizer for BASH_PATH_TOKENS — one deterministic pass, provably
645
+ * O(n): each alternative parses greedily with at most a bounded (≤ 2) retry, and
646
+ * the scan position only advances. The regex oracle's matchAll semantics are
647
+ * reproduced exactly (alternation priority included; equivalence pinned by a
648
+ * fuzz test against the oracle). Derivation per alternative:
649
+ * - alt1 `(~|$HOME)(\/W2+)*`: the star never fails — prefix + maximal (/ + W2-run)
650
+ * repetitions; a bare ~ / $HOME is a legal zero-iteration match.
651
+ * - alt2 `\/(W2+\/)*W2*`: pairs stop at the first word-run not followed by a slash;
652
+ * the trailing star always succeeds, so the greedy parse is THE match (a lone
653
+ * "/" is a legal zero-pair, empty-tail match).
654
+ * - alt3 `\.{1,2}(\/W2+)+`: dots are tried greedily (2 then 1 — the regex's DFS
655
+ * order); the plus needs one '/'-then-W2 continuation, else the alternative fails.
656
+ * - alt4 `W4+(\/W4+)+`: the leading run is maximal [p, e); a continuation is viable
657
+ * ONLY at a '/' (a literal) immediately followed by a W4 char, and once viable
658
+ * the greedy inner always completes — so the DFS-first match takes the LARGEST
659
+ * viable '/' at or before e and extends greedily. This is exactly where the
660
+ * regex paid O(n) per start position on failure; the scan computes it in O(1)
661
+ * amortized. */
662
+ export function bashPathTokens(command: string): string[] {
663
+ const s = command;
664
+ const n = s.length;
665
+ // Right-to-left precompute of maximal-run ends — the single pass that makes every
666
+ // position O(1): runEndX[i] = first index >= i not in class X (i when s[i] itself
667
+ // is out of class; n at the end of string).
668
+ const runEnd2 = new Int32Array(n + 1);
669
+ const runEnd4 = new Int32Array(n + 1);
670
+ runEnd2[n] = n;
671
+ runEnd4[n] = n;
672
+ for (let i = n - 1; i >= 0; i--) {
673
+ runEnd2[i] = isW2(s, i) ? runEnd2[i + 1] : i;
674
+ runEnd4[i] = isW4(s, i) ? runEnd4[i + 1] : i;
675
+ }
676
+ const out: string[] = [];
677
+ let p = 0;
678
+ while (p < n) {
679
+ const c = s[p];
680
+ let m = 0; // match end (exclusive); 0 = no match at p
681
+ if (c === "~" || s.startsWith("$HOME", p)) {
682
+ // alt1: deterministic greedy (/ + W2-run) repetitions
683
+ let q = c === "~" ? p + 1 : p + 5;
684
+ for (;;) {
685
+ if (s[q] === "/" && isW2(s, q + 1)) q = runEnd2[q + 1];
686
+ else break;
687
+ }
688
+ m = q;
689
+ } else if (c === "/") {
690
+ // alt2: (W2-run + /) pairs while possible, then the trailing W2-run
691
+ let q = p + 1;
692
+ for (;;) {
693
+ if (!isW2(s, q)) break; // empty tail — the match is the consumed prefix
694
+ const r = runEnd2[q];
695
+ if (s[r] !== "/") {
696
+ q = r; // tail run consumes through r
697
+ break;
698
+ }
699
+ q = r + 1; // pair complete — another may follow
700
+ }
701
+ m = q;
702
+ } else if (c === ".") {
703
+ // alt3: dots greedy 2 then 1; inner = maximal (/ + W2-run) repetitions, >= 1 required
704
+ const innerEnd = (q: number): number | null => {
705
+ if (s[q] !== "/" || !isW2(s, q + 1)) return null;
706
+ let r = q;
707
+ for (;;) {
708
+ if (s[r] === "/" && isW2(s, r + 1)) r = runEnd2[r + 1];
709
+ else break;
710
+ }
711
+ return r;
712
+ };
713
+ if (s[p + 1] === ".") m = innerEnd(p + 2) ?? 0;
714
+ if (m === 0) m = innerEnd(p + 1) ?? 0;
715
+ }
716
+ if (m === 0 && isW4(s, p)) {
717
+ // alt4: the maximal leading run is [p, e). '/' is not in W4, so the run
718
+ // itself contains no slash and the ONLY viable continuation split is at e
719
+ // — the O(1) step that replaces the regex's O(n)-per-position backtrack.
720
+ const e = runEnd4[p];
721
+ if (s[e] === "/" && isW4(s, e + 1)) {
722
+ let q = e;
723
+ for (;;) {
724
+ if (s[q] === "/" && isW4(s, q + 1)) q = runEnd4[q + 1];
725
+ else break;
726
+ }
727
+ m = q;
728
+ }
729
+ }
730
+ if (m > p) {
731
+ out.push(s.slice(p, m));
732
+ p = m; // matchAll semantics: continue after the match
733
+ } else {
734
+ p++;
735
+ }
736
+ }
737
+ return out;
738
+ }
739
+
563
740
  /** Normalized forms of one path for denyPaths comparison: base tier only (ADR-0002) —
564
741
  * no ancestor rebuild; a nonexistent target under a symlinked dir falls to the
565
742
  * classifier + existence hint instead (pinned by a regression test). */
@@ -578,7 +755,7 @@ const anchorDenyPaths = (paths: string[], cwd: string): string[] => paths.flatMa
578
755
  * IS the cwd subtree (#48). */
579
756
  function denyPathCandidates(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
580
757
  const kind = toolKind(toolName);
581
- if (kind === "command") return [...String(input.command ?? "").matchAll(BASH_PATH_TOKENS)].map((m) => m[0]);
758
+ if (kind === "command") return bashPathTokens(String(input.command ?? "")); // #32: linear — the regex stays as the test oracle
582
759
  if (kind === "file") {
583
760
  const p = typeof input.path === "string" && input.path ? input.path : null;
584
761
  if (!p) return isScopeTool(toolName) ? [cwd] : [];
@@ -1040,12 +1217,19 @@ export interface NativeClassifierSpec {
1040
1217
  provider?: string;
1041
1218
  }
1042
1219
 
1043
- /** The resolved classifier layer (ADR-0005): native = classify() protocol path
1044
- * (floor-capable by construction), chat = LLM prompt path (thinking applies). */
1045
- export type ResolvedModel =
1220
+ /** The resolved classifier spec (ADR-0005): native = classify() protocol path
1221
+ * (floor-capable by construction), chat = LLM prompt path — what spec resolution
1222
+ * returns, before the thinking level is attached. */
1223
+ export type ResolvedSpec =
1046
1224
  | { kind: "native"; model: NativeClassifierSpec }
1047
1225
  | { kind: "chat"; model: NonNullable<ExtensionContext["model"]> };
1048
1226
 
1227
+ /** A fully resolved classifier layer: the spec plus its thinking level. Native
1228
+ * layers do not consume the level (classifier models carry no reasoning — a
1229
+ * suffix warns once and the audit records null), but the parsed value stays on
1230
+ * the layer; chat layers pass it through to the completion call. */
1231
+ export type ResolvedLayer = ResolvedSpec & { thinking: ThinkingLevel };
1232
+
1049
1233
  export interface ClassifierAnswerShape {
1050
1234
  type: string;
1051
1235
  choice?: string;
@@ -1117,12 +1301,15 @@ export function confidencePercent(conf: number): number {
1117
1301
  return Math.floor(conf * 100 + 1e-9);
1118
1302
  }
1119
1303
 
1120
- /** Validates the verdict answer and synthesizes the contract line
1121
- * (`<verdict>…</verdict>` + one-line reason) from a native classify() answer. Any
1122
- * malformed shape throws — the caller's fail-closed path owns the fallout. Reason is
1123
- * user-facing (block reasons, ask dialogs): plain percentages, no internal notation.
1124
- * Confidence is hard-required (#63 carried over): the decisions contract guarantees it
1125
- * on choice answers, so absence is contract drift and drift fails closed. */
1304
+ /** Validates the verdict answer and synthesizes the human-readable reason line
1305
+ * (probability breakdown, plain percentages, no internal notation). Any malformed
1306
+ * shape throws — the caller's fail-closed path owns the fallout. Confidence is
1307
+ * hard-required (#63 carried over): the decisions contract guarantees it on choice
1308
+ * answers, so absence is contract drift and drift fails closed. The historical
1309
+ * `<verdict>…</verdict>` prefix is NOT part of the reason anymore (see the ADR-0005
1310
+ * amendment): it existed to satisfy the LLM path's parseVerdict contract, which the
1311
+ * native path never needed — the full contract line lives on in the audit record's
1312
+ * rawResponse only. */
1126
1313
  export function composeVerdictLine(answer: ClassifierAnswerShape, api: string): string {
1127
1314
  const choice = String(answer.choice ?? "").trim().toLowerCase();
1128
1315
  if (!VERDICTS.includes(choice as VerdictChoice)) {
@@ -1138,7 +1325,7 @@ export function composeVerdictLine(answer: ClassifierAnswerShape, api: string):
1138
1325
  .map((v) => `${v} ${pct(probs[v])}`)
1139
1326
  .join(", ");
1140
1327
  const prefix = SYSTEM_ONE_APIS.has(api) ? "jev" : "classifier";
1141
- return `<verdict>${choice}</verdict> ${prefix}: ${choice} ${pct(probs[choice])} (confidence ${confidencePercent(conf)}%; ${rest})`;
1328
+ return `${prefix}: ${choice} ${pct(probs[choice])} (confidence ${confidencePercent(conf)}%; ${rest})`;
1142
1329
  }
1143
1330
 
1144
1331
  const MAX_USER_MESSAGES = 5;
@@ -1419,12 +1606,16 @@ export async function classifyNative(
1419
1606
  if (answer.type !== "choice") return fail(`malformed verdict answer (type=${JSON.stringify(answer.type)})`);
1420
1607
  try {
1421
1608
  const line = composeVerdictLine(answer, model.api);
1609
+ const verdict = String(answer.choice).trim().toLowerCase() as ClassifierOutcome["verdict"];
1422
1610
  return {
1423
- verdict: String(answer.choice).trim().toLowerCase() as ClassifierOutcome["verdict"],
1611
+ verdict,
1424
1612
  reason: line,
1425
1613
  source: "model",
1426
1614
  confidence: confidencePercent(answer.confidence as number),
1427
- auditRaw: { transcript, rawResponse: line, modelId: model.id, thinking: null },
1615
+ // The audit keeps the full contract line (tag included) in rawResponse — "what
1616
+ // the protocol said" — mirroring the LLM path's rawResponse (the model's full
1617
+ // output, tag included). The reason field is human-facing and tag-free.
1618
+ auditRaw: { transcript, rawResponse: `<verdict>${verdict}</verdict> ${line}`, modelId: model.id, thinking: null },
1428
1619
  };
1429
1620
  } catch (error) {
1430
1621
  return fail(`${error instanceof Error ? error.message : String(error)}`);
@@ -1432,27 +1623,23 @@ export async function classifyNative(
1432
1623
  }
1433
1624
 
1434
1625
  async function classifyWithModel(
1435
- host: PipelineHost,
1436
- signal: AbortSignal | undefined,
1437
- complete: CompletionFn,
1438
- classify: ClassifyFn | undefined,
1439
- model: ResolvedModel,
1440
- actionLine: string,
1441
- thinking: ThinkingLevel = "off",
1442
- denyPathsActive = false,
1443
- timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
1626
+ env: Pick<AdjudicateEnv, "host" | "signal" | "complete" | "classify">,
1627
+ resolved: ResolvedLayer,
1628
+ call: { actionLine: string; denyPathsActive: boolean; timeoutMs?: number },
1444
1629
  ): Promise<ClassifierOutcome> {
1445
- if (model.kind === "native") return classifyNative(classify, model.model, host, actionLine, signal, timeoutMs);
1446
- const chat = model.model;
1447
- const transcript = buildTranscript(host, actionLine);
1630
+ const timeoutMs = call.timeoutMs ?? CLASSIFIER_TIMEOUT_MS;
1631
+ if (resolved.kind === "native") return classifyNative(env.classify, resolved.model, env.host, call.actionLine, env.signal, timeoutMs);
1632
+ const chat = resolved.model;
1633
+ const thinking = resolved.thinking;
1634
+ const transcript = buildTranscript(env.host, call.actionLine);
1448
1635
  const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
1449
- const systemPrompt = denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
1636
+ const systemPrompt = call.denyPathsActive ? CLASSIFIER_SYSTEM + DENY_PATHS_HINT : CLASSIFIER_SYSTEM;
1450
1637
  const attempts: Array<[number, number]> = [[1, CLASSIFIER_MAX_TOKENS], [2, CLASSIFIER_RETRY_MAX_TOKENS]];
1451
1638
  const failures: string[] = [];
1452
1639
  let rawResponse = ""; // #54: raw output of the last attempt ("" for exception attempts — diagnostics already live in failures)
1453
1640
  for (const [n, maxTokens] of attempts) {
1454
- if (signal?.aborted) break; // 用户已取消,不再重试
1455
- const r = await callClassifierOnce(host, signal, complete, chat, userMessage, maxTokens, thinking, systemPrompt, timeoutMs);
1641
+ if (env.signal?.aborted) break; // 用户已取消,不再重试
1642
+ const r = await callClassifierOnce(env.host, env.signal, env.complete, chat, userMessage, maxTokens, thinking, systemPrompt, timeoutMs);
1456
1643
  if (r.ok) {
1457
1644
  rawResponse = r.text;
1458
1645
  const diag = `stopReason=${r.stopReason}, model=${chat.id}, errorMessage=${JSON.stringify(r.errorMessage ?? null)}, raw output=${JSON.stringify(r.text.slice(0, 200))}`;
@@ -1704,14 +1891,14 @@ export interface Verdict {
1704
1891
  export interface AdjudicateEnv {
1705
1892
  cwd: string;
1706
1893
  hasUI: boolean;
1707
- getModel: () => { model: ResolvedModel; thinking: ThinkingLevel } | null;
1894
+ getModel: () => ResolvedLayer | null;
1708
1895
  complete: CompletionFn;
1709
1896
  /** Native classify() seam (ADR-0005); absent on hosts without the capability —
1710
1897
  * the native path fail-closes, never silently falls back to the chat path. */
1711
1898
  classify?: ClassifyFn;
1712
1899
  host: PipelineHost;
1713
1900
  signal?: AbortSignal;
1714
- getFallbackModel?: () => { model: ResolvedModel; thinking: ThinkingLevel } | null;
1901
+ getFallbackModel?: () => ResolvedLayer | null;
1715
1902
  }
1716
1903
 
1717
1904
  /** #67 (0.13, ADR-0005): the floor gates on protocol-native confidence — set only
@@ -1777,10 +1964,10 @@ async function runConfidenceCascade(
1777
1964
  };
1778
1965
  const resolved = getFb();
1779
1966
  if (!resolved) return failed(rules.classifierFallbackModel, "fallback model unresolvable (not found or no configured auth)");
1780
- const outcome = await classifyWithModel(env.host, env.signal, env.complete, env.classify, resolved.model, actionLine, resolved.thinking, denyPathsActive, FALLBACK_TIMEOUT_MS);
1781
- if (outcome.source !== "model") return failed(resolved.model.model.id, outcome.reason);
1967
+ const outcome = await classifyWithModel(env, resolved, { actionLine, denyPathsActive, timeoutMs: FALLBACK_TIMEOUT_MS });
1968
+ if (outcome.source !== "model") return failed(resolved.model.id, outcome.reason);
1782
1969
  state.fallback.note(first?.verdict ?? null, outcome.verdict);
1783
- const fb: FallbackAudit = { ...base, model: resolved.model.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs: Date.now() - start, error: null };
1970
+ const fb: FallbackAudit = { ...base, model: resolved.model.id, verdict: outcome.verdict, reason: outcome.reason, durationMs: Date.now() - start, error: null };
1784
1971
  if (mode === "shadow") return { ...demotedMark, fb, ...shadowApplied };
1785
1972
  // The carve-outs on second-layer authority (#71): it may not auto-relax a negative
1786
1973
  // first-layer verdict — a demoted deny OR ask that the fallback would allow goes to
@@ -1868,7 +2055,7 @@ export async function adjudicate(
1868
2055
  return { verdict: "deny", reason, source: "fail-closed", degraded: false };
1869
2056
  }
1870
2057
 
1871
- const outcome = await classifyWithModel(env.host, env.signal, env.complete, env.classify, resolved.model, actionLine, resolved.thinking, state.userRules.denyPaths.length > 0);
2058
+ const outcome = await classifyWithModel(env, resolved, { actionLine, denyPathsActive: state.userRules.denyPaths.length > 0 });
1872
2059
 
1873
2060
  // #67 cascade: a confidence-floor demotion, or a classifier fail-closed outcome
1874
2061
  // (the first layer produced no verdict)
@@ -2104,6 +2291,16 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2104
2291
  return id.includes("jev");
2105
2292
  }
2106
2293
 
2294
+ /** Shared "spec unavailable" wording: jev specs get the specialized causes,
2295
+ * anything else the generic miss; `tail` carries the layer's fallback
2296
+ * semantics with its leading separator. (Extracted from two previously
2297
+ * lockstep-synchronized ternaries — see CHANGELOG [Unreleased].) */
2298
+ function unavailableNotice(subject: string, raw: string, specPart: string, tail: string): string {
2299
+ return isJevSpec(specPart)
2300
+ ? `pi-verdict: ${subject} "${raw}" unavailable — ${JEV_UNAVAILABLE_HINT}${tail}`
2301
+ : `pi-verdict: ${subject} "${raw}" unavailable (not found or no configured auth)${tail}`;
2302
+ }
2303
+
2107
2304
  /** ADR-0005: shared spec→model resolution for both classifier layers. Native
2108
2305
  * classifier entries are looked up first (findOfType) and WIN on same-id dual
2109
2306
  * listings (llama.cpp chat+classifier share ids) — the native path is the point
@@ -2113,7 +2310,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2113
2310
  * chat path the suffix stays effective and must not be called ignored). Returns
2114
2311
  * null when the spec does not resolve; the layer's fallback semantics stay with
2115
2312
  * the caller. */
2116
- function findSpecModel(ctx: ExtensionContext, specPart: string, level: string | null, layer: "classifier" | "fallback"): ResolvedModel | null {
2313
+ function findSpecModel(ctx: ExtensionContext, specPart: string, level: string | null, layer: "classifier" | "fallback"): ResolvedSpec | null {
2117
2314
  const slash = specPart.indexOf("/");
2118
2315
  if (slash <= 0) return null;
2119
2316
  const provider = specPart.slice(0, slash);
@@ -2141,7 +2338,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2141
2338
  * model and self-reflection fallback alike; a purely rule-adjudicated session never
2142
2339
  * sees it — lazy via getModel). Neutral wording: the fact, never a judgment on the
2143
2340
  * config (users may pre-set the floor for a future classifier switch). */
2144
- function warnFloorInert(ctx: ExtensionContext, model: ResolvedModel): void {
2341
+ function warnFloorInert(ctx: ExtensionContext, model: ResolvedSpec): void {
2145
2342
  if (warnedFloorInert || state.userRules.classifierMinConfidence === null || model.kind === "native") return;
2146
2343
  warnedFloorInert = true;
2147
2344
  ctx.ui.notify("pi-verdict: classifierMinConfidence has no effect on a chat-model classifier (the floor applies to native classifier models like typesafe/jev-latest, whose confidence is protocol-native)", "warning");
@@ -2151,7 +2348,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2151
2348
  * 自省(会话模型,恒为 chat 路径——0.99 分类器不进 /model)。不可用回退会话模型
2152
2349
  * 并警告一次;null = 连会话模型都没有 → fail-closed。经 AdjudicateEnv.getModel
2153
2350
  * 惰性调用(仅灰区),回退警告不会出现在规则已裁决的调用上。 */
2154
- function resolveClassifier(ctx: ExtensionContext): { model: ResolvedModel; thinking: ThinkingLevel } | null {
2351
+ function resolveClassifier(ctx: ExtensionContext): ResolvedLayer | null {
2155
2352
  const raw =
2156
2353
  (pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? state.userRules.classifierModel;
2157
2354
  let thinking: ThinkingLevel = "off";
@@ -2162,17 +2359,15 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2162
2359
  ctx.ui.notify(msg, "warning");
2163
2360
  });
2164
2361
  thinking = (level ?? "off") as ThinkingLevel;
2165
- const model = findSpecModel(ctx, specPart, level, "classifier");
2166
- if (model) {
2167
- warnFloorInert(ctx, model);
2168
- return { model, thinking };
2362
+ const spec = findSpecModel(ctx, specPart, level, "classifier");
2363
+ if (spec) {
2364
+ warnFloorInert(ctx, spec);
2365
+ return { ...spec, thinking };
2169
2366
  }
2170
2367
  if (!warnedClassifierModel) {
2171
2368
  warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
2172
2369
  ctx.ui.notify(
2173
- isJevSpec(specPart)
2174
- ? `pi-verdict: classifier model "${raw}" unavailable — ${JEV_UNAVAILABLE_HINT}; falling back to session model (self-reflection)`
2175
- : `pi-verdict: classifier model "${raw}" unavailable (not found or no configured auth), falling back to session model (self-reflection)`,
2370
+ unavailableNotice("classifier model", raw, specPart, "; falling back to session model (self-reflection)"),
2176
2371
  "warning",
2177
2372
  );
2178
2373
  }
@@ -2180,9 +2375,9 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2180
2375
  // 自省:继承当前会话模型(chat 路径——0.99 分类器不进 /model,会话模型恒为
2181
2376
  // chat/virtual);显式指定的思考级别在回退时仍生效(原语义)
2182
2377
  if (ctx.model) {
2183
- const self: ResolvedModel = { kind: "chat", model: ctx.model };
2378
+ const self: ResolvedSpec = { kind: "chat", model: ctx.model };
2184
2379
  warnFloorInert(ctx, self);
2185
- return { model: self, thinking };
2380
+ return { ...self, thinking };
2186
2381
  }
2187
2382
  return null;
2188
2383
  }
@@ -2194,7 +2389,7 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2194
2389
  * judgment twice instead of adding a second opinion. Unresolvable → one-time warning
2195
2390
  * + null (shadow: inert; enforce: triggered calls fail-closed, see runFallbackCascade).
2196
2391
  * Resolved lazily via AdjudicateEnv.getFallbackModel, only after the gate fires. */
2197
- function resolveFallbackClassifier(ctx: ExtensionContext): { model: ResolvedModel; thinking: ThinkingLevel } | null {
2392
+ function resolveFallbackClassifier(ctx: ExtensionContext): ResolvedLayer | null {
2198
2393
  const raw = state.userRules.classifierFallbackModel;
2199
2394
  if (!raw) return null;
2200
2395
  const { specPart, level } = parseModelSpec(raw, (msg) => {
@@ -2203,14 +2398,12 @@ export default function autoMode(pi: ExtensionAPI, deps: AutoModeDeps = {}) {
2203
2398
  ctx.ui.notify(msg, "warning");
2204
2399
  });
2205
2400
  const thinking = (level ?? "off") as ThinkingLevel;
2206
- const model = findSpecModel(ctx, specPart, level, "fallback");
2207
- if (model) return { model, thinking };
2401
+ const spec = findSpecModel(ctx, specPart, level, "fallback");
2402
+ if (spec) return { ...spec, thinking };
2208
2403
  if (!warnedFallbackModel) {
2209
2404
  warnedFallbackModel = true; // one warning per session
2210
2405
  ctx.ui.notify(
2211
- isJevSpec(specPart)
2212
- ? `pi-verdict: fallback model "${raw}" unavailable — ${JEV_UNAVAILABLE_HINT} — classifierFallbackModel inactive this session`
2213
- : `pi-verdict: fallback model "${raw}" unavailable (not found or no configured auth) — classifierFallbackModel inactive this session`,
2406
+ unavailableNotice("fallback model", raw, specPart, " — classifierFallbackModel inactive this session"),
2214
2407
  "warning",
2215
2408
  );
2216
2409
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "A minimal permission gate for Pi, inspired by Claude Code's auto mode",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",