pi-verdict 0.2.0 → 0.2.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
@@ -21,7 +21,11 @@
21
21
  | [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅ (outcome) | ❌ single-turn, no context | closed (→ ask) | 4 |
22
22
  | [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ deterministic only | — (no built-in classifier) | closed | 3 |
23
23
 
24
- Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md). The single-file, zero-dependency shape is deliberate — the whole extension is one readable [~590-line file](extensions/auto-mode.ts).
24
+ Full landscape: [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · convergence analysis with the closest architectural relative: [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md).
25
+
26
+ Honest framing: pi-automode and pi-verdict have **converged on the same architecture** (deny floor → user rules → classifier, fail-closed — see the convergence analysis). What remains distinct here: a classifier that can say `ask` (runtime human-in-the-loop, not just rule-declared), a built-in floor you can turn off (`builtinDenyFloor` — user sovereignty), a zero-dependency single file (~700 lines, deliberate), and the measurement habit — every design decision in this repo is backed by shipped research.
27
+
28
+ The single-file, zero-dependency shape is deliberate — the whole extension is one readable [~700-line file](extensions/auto-mode.ts).
25
29
 
26
30
  ## Pipeline
27
31
 
@@ -84,13 +88,16 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
84
88
  {
85
89
  "allow": ["^ls\\b", "^git (status|log|diff)\\b"],
86
90
  "deny": ["rm ", "docker ", "^/etc/"],
87
- "builtinDenyFloor": true
91
+ "builtinDenyFloor": true,
92
+ "classifierModel": null
88
93
  }
89
94
  ```
90
95
 
91
96
  - `allow`/`deny` are JS regex arrays; **`deny` wins over `allow`**, both beat the classifier
92
97
  - matched against the **full command string** for bash, the **absolute path** for file tools (read/write/edit/grep/find/ls); other tools (MCP etc.) always go to the classifier
93
98
  - `builtinDenyFloor: false` turns the built-in danger/path floor off entirely (risk accepted by you; the classifier and your rules remain)
99
+ - `classifierModel: "provider/model-id"` persistently sets the classifier model (e.g. a fast flash-class model); precedence is flag > env > config > session model (self-reflection); an invalid value falls back to the session model with a one-time warning
100
+ - the spec accepts pi's native `--model` thinking suffix: `"zai/glm-4-flash:low"` sets classifier thinking to effort low (default without suffix: thinking explicitly off — the [measured](research/thinking-param-blackhole.md) default)
94
101
  - first run generates a template at `~/.pi/agent/config/pi-verdict.json` (honors `PI_CODING_AGENT_DIR`); changes apply to new sessions
95
102
 
96
103
  Why no built-in allowlist? A third-party security audit ([`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md)) showed that allowlist soundness requires shell AST analysis — every built-in "always allow" would be a security claim maintained by the author. The built-in layer only makes **deny** claims (the sound direction); allow claims are yours.
@@ -105,6 +112,8 @@ Design decisions here are settled by measurement, and the lab notes ship with th
105
112
  - [`research/thinking-param-blackhole.md`](research/thinking-param-blackhole.md) — three-layer forensic root-cause of thinking models burning the classifier budget; why the fix is `thinkingEnabled: false`
106
113
  - [`research/rule-engine-sim`](research/rule-engine-sim/README.md) — measured a tree-sitter rule-engine port against 746 real bash calls (**absorbs 0 gray calls**) and rejected it
107
114
  - [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) — the competitive landscape this README's positioning is checked against
115
+ - [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md) — third-party audit of the rule layer (8/8 reproduced → fixed architecturally in 0.2.0)
116
+ - [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md) — where this project genuinely converges with pi-automode, and what remains distinct
108
117
  - [`research/claude-code-classifier-prompts.md`](research/claude-code-classifier-prompts.md) — structural reconstruction of Claude Code's classifier design (via self-hosted Langfuse observations) that this extension's transcript contract descends from
109
118
 
110
119
  ## Status & limitations
package/README.zh-CN.md CHANGED
@@ -21,7 +21,11 @@
21
21
  | [@zhushanwen/pi-permission](https://www.npmjs.com/package/@zhushanwen/pi-permission) | ✅(outcome) | ❌ 单轮无上下文 | closed(→ ask) | 4 |
22
22
  | [@gotgenes/pi-permission-system](https://github.com/gotgenes/pi-packages) | ✅ 纯确定性 | —(无内置分类器) | closed | 3 |
23
23
 
24
- 完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md)。零依赖单文件形态是有意为之——整个扩展就是一个可通读的 [~590 行文件](extensions/auto-mode.ts)。
24
+ 完整全景:[`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) · 与最近架构亲缘的收敛分析:[`research/pi-automode-convergence.md`](research/pi-automode-convergence.md)。
25
+
26
+ 诚实地说:pi-automode 与 pi-verdict 在**架构上已收敛**(deny floor → 用户规则 → 分类器,fail-closed——见收敛分析)。这里仍然不同的是:分类器能说 `ask`(运行时人工介入,而非仅由规则预声明)、内置 floor 可以关(`builtinDenyFloor`——用户主权)、零依赖单文件(~700 行,刻意为之)、以及测量的习惯——本仓库每个设计决策都有随库研究背书。
27
+
28
+ 零依赖单文件形态是有意为之——整个扩展就是一个可通读的 [~700 行文件](extensions/auto-mode.ts)。
25
29
 
26
30
  ## 管线
27
31
 
@@ -83,13 +87,16 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
83
87
  {
84
88
  "allow": ["^ls\\b", "^git (status|log|diff)\\b"],
85
89
  "deny": ["rm ", "docker ", "^/etc/"],
86
- "builtinDenyFloor": true
90
+ "builtinDenyFloor": true,
91
+ "classifierModel": null
87
92
  }
88
93
  ```
89
94
 
90
95
  - `allow`/`deny` 为 JS 正则数组;**`deny` 优先于 `allow`**,两者都优先于分类器
91
96
  - 匹配目标:bash = **完整命令串**;文件类工具(read/write/edit/grep/find/ls)= **绝对路径**;其余工具(MCP 等)恒走分类器
92
97
  - `builtinDenyFloor: false` 可整体关闭内置危险/路径拦截(风险自担;分类器与你的规则仍在)
98
+ - `classifierModel: "provider/model-id"` 持久指定分类器模型(如轻量 flash 类);优先级 flag > env > config > 自省;无效值回退会话模型并一次性警告
99
+ - spec 支持 pi 原生 `--model` 思考级别后缀:`"zai/glm-4-flash:low"` 将分类器思考设为 effort low(无后缀缺省 = 显式关思考,[实测](research/thinking-param-blackhole.md)背书的默认)
93
100
  - 首次运行自动生成模板 `~/.pi/agent/config/pi-verdict.json`(尊重 `PI_CODING_AGENT_DIR`);修改后新会话生效
94
101
 
95
102
  为什么没有内置白名单?第三方安全审计(见 [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md))证明白名单的健全性需要 shell AST 分析——每条内置「永远放行」都是作者维护的安全声明。因此内置层只做 **deny** 声明(方向健全),allow 声明归你。
@@ -104,6 +111,8 @@ cp extensions/auto-mode.ts ~/.pi/agent/extensions/
104
111
  - [`research/thinking-param-blackhole.md`](research/thinking-param-blackhole.md) —— 思考模型烧尽分类器预算的三层取证,以及为什么修复是 `thinkingEnabled: false`
105
112
  - [`research/rule-engine-sim`](research/rule-engine-sim/README.md) —— 用 746 条真实 bash 调用实测 tree-sitter 规则引擎移植(**灰区吸收 0 条**)并否决
106
113
  - [`research/pi-permission-landscape.md`](research/pi-permission-landscape.md) —— 本 README 定位所对照的竞品全景
114
+ - [`research/rule-layer-security-audit.md`](research/rule-layer-security-audit.md) —— 规则层第三方安全审计(8/8 复现 → 0.2.0 架构性修复)
115
+ - [`research/pi-automode-convergence.md`](research/pi-automode-convergence.md) —— 与 pi-automode 何处真正收敛、何处仍然不同
107
116
  - [`research/claude-code-classifier-prompts.md`](research/claude-code-classifier-prompts.md) —— Claude Code 分类器设计的结构化还原(基于自托管 Langfuse 观测),本扩展 transcript 契约的血统来源
108
117
 
109
118
  ## 状态与限制
@@ -23,11 +23,13 @@
23
23
  *
24
24
  * 配置:
25
25
  * --auto-mode / --no-auto-mode CLI flag,总开关(默认开)
26
- * --auto-mode-model provider/id 分类器模型(默认继承会话当前模型)
26
+ * --auto-mode-model provider/id[:thinking] 分类器模型 + 可选思考级别后缀
27
+ * (pi 原生 --model 语法;缺省 off = 显式关思考)
27
28
  * PI_AUTO_MODE_MODEL 同上的环境变量形式
28
29
  * --auto-mode-debug 所有裁决(含放行)都弹通知;影子缓存标注同步开启
29
30
  * PI_AUTO_MODE_DEBUG=1 同上的环境变量形式(兼容保留)
30
- * <agentDir>/config/pi-verdict.json 用户规则:{ allow: [regex], deny: [regex] }
31
+ * <agentDir>/config/pi-verdict.json 用户规则:{ allow: [regex], deny: [regex],
32
+ * builtinDenyFloor, classifierModel }
31
33
  * 匹配:bash=完整命令串 / 文件工具=绝对路径;新会话生效
32
34
  *
33
35
  * 已知原型简化(见 README「已知限制」):
@@ -101,9 +103,11 @@ interface UserRules {
101
103
  deny: RegExp[];
102
104
  /** 内置 deny floor 开关(危险正则 + 路径敏感度 deny),默认 true;关闭后依赖用户规则与分类器 */
103
105
  builtinDenyFloor: boolean;
106
+ /** 分类器模型 spec(provider/id);null = 未配置(自省继承会话模型) */
107
+ classifierModel: string | null;
104
108
  }
105
109
 
106
- const EMPTY_RULES: UserRules = { allow: [], deny: [], builtinDenyFloor: true };
110
+ const EMPTY_RULES: UserRules = { allow: [], deny: [], builtinDenyFloor: true, classifierModel: null };
107
111
 
108
112
  function userConfigPath(): string {
109
113
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
@@ -111,10 +115,11 @@ function userConfigPath(): string {
111
115
  }
112
116
 
113
117
  const USER_CONFIG_TEMPLATE = `${JSON.stringify({
114
- _hint: "pi-verdict 用户规则。allow/deny 为 JS 正则数组;deny 优先于 allow;匹配目标:bash=完整命令串,文件工具=绝对路径。builtinDenyFloor=false 可关闭内置危险规则/路径敏感度拦截(风险自担)。修改后新会话生效。",
118
+ _hint: "pi-verdict 用户规则。allow/deny 为 JS 正则数组;deny 优先于 allow;匹配目标:bash=完整命令串,文件工具=绝对路径。builtinDenyFloor=false 可关闭内置危险规则/路径敏感度拦截(风险自担)。classifierModel 可持久指定分类器模型(provider/id,如 zai/glm-4-flash;可带 pi 原生思考后缀如 zai/glm-4-flash:low;留空=自省继承会话模型)。修改后新会话生效。",
115
119
  allow: ["^ls\\b"],
116
120
  deny: [],
117
121
  builtinDenyFloor: true,
122
+ classifierModel: null,
118
123
  }, null, 2)}\n`;
119
124
 
120
125
  /**
@@ -131,7 +136,7 @@ function loadUserRules(): { rules: UserRules; skipped: string[] } {
131
136
  } catch { /* 只读环境静默跳过 */ }
132
137
  return { rules: EMPTY_RULES, skipped: [] };
133
138
  }
134
- const raw = JSON.parse(fs.readFileSync(p, "utf8")) as { allow?: unknown; deny?: unknown; builtinDenyFloor?: unknown };
139
+ const raw = JSON.parse(fs.readFileSync(p, "utf8")) as { allow?: unknown; deny?: unknown; builtinDenyFloor?: unknown; classifierModel?: unknown };
135
140
  const skipped: string[] = [];
136
141
  const compile = (list: unknown): RegExp[] =>
137
142
  (Array.isArray(list) ? list : []).filter((x): x is string => typeof x === "string").flatMap((src) => {
@@ -142,7 +147,15 @@ function loadUserRules(): { rules: UserRules; skipped: string[] } {
142
147
  return [];
143
148
  }
144
149
  });
145
- return { rules: { allow: compile(raw.allow), deny: compile(raw.deny), builtinDenyFloor: raw.builtinDenyFloor !== false }, skipped };
150
+ return {
151
+ rules: {
152
+ allow: compile(raw.allow),
153
+ deny: compile(raw.deny),
154
+ builtinDenyFloor: raw.builtinDenyFloor !== false,
155
+ classifierModel: typeof raw.classifierModel === "string" && raw.classifierModel.trim() ? raw.classifierModel.trim() : null,
156
+ },
157
+ skipped,
158
+ };
146
159
  } catch {
147
160
  return { rules: EMPTY_RULES, skipped: [] };
148
161
  }
@@ -345,6 +358,7 @@ async function callClassifierOnce(
345
358
  model: NonNullable<ExtensionContext["model"]>,
346
359
  userMessage: string,
347
360
  maxTokens: number,
361
+ thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
348
362
  ): Promise<{ ok: true; text: string; stopReason: string } | { ok: false; error: string }> {
349
363
  const signals = [AbortSignal.timeout(CLASSIFIER_TIMEOUT_MS)];
350
364
  if (ctx.signal) signals.push(ctx.signal);
@@ -359,14 +373,14 @@ async function callClassifierOnce(
359
373
  signal: AbortSignal.any(signals),
360
374
  maxTokens,
361
375
  temperature: 0,
362
- // 关思考必须用 API 原生字段 thinkingEnabled:false,而非 reasoning:"off"。
363
- // 扩展侧 ModelRegistry 只暴露 API 层 complete(),其选项类型没有 reasoning 字段
364
- // (它是 SimpleStreamOptions 的字段;宽类型 Model<Api> 的索引签名让 TS 静默放行,
365
- // 运行时被丢弃)——minimal/off 从未生效,GLM 按默认 max 档思考烧尽预算/超时。
366
- // anthropic-messages 栈上 thinkingEnabled:falsethinking:{"type":"disabled"}
367
- // GLM 降为 effort low 轻思考;其他 API 为无害多余
368
- // 属性,交由防御重试兜底。根因与研究:research/thinking-param-blackhole.md
369
- thinkingEnabled: false,
376
+ // 思考参数必须用 API 原生字段(thinkingEnabled/effort),而非 reasoning
377
+ // (API 层 complete() 无此字段,宽类型索引签名静默放行后运行时丢弃——见
378
+ // research/thinking-param-blackhole.md)。
379
+ // 缺省 off = 显式关思考(实证送达 thinking:{"type":"disabled"},GLM 降为
380
+ // effort low 轻思考);后缀级别经 adaptive effort 送达(minimallow 映射)。
381
+ ...(thinking === "off"
382
+ ? { thinkingEnabled: false }
383
+ : { thinkingEnabled: true, effort: thinking === "minimal" ? ("low" as const) : thinking }),
370
384
  cacheRetention: "short",
371
385
  sessionId: ctx.sessionManager.getSessionId(),
372
386
  },
@@ -391,6 +405,7 @@ async function classifyWithModel(
391
405
  ctx: ExtensionContext,
392
406
  model: NonNullable<ExtensionContext["model"]>,
393
407
  actionLine: string,
408
+ thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off",
394
409
  ): Promise<ClassifierOutcome> {
395
410
  const transcript = buildTranscript(ctx, actionLine);
396
411
  const userMessage = `<transcript>\n${transcript}\n</transcript>\nJudge the LAST action in the transcript above. Your entire response MUST begin with <verdict>.`;
@@ -398,7 +413,7 @@ async function classifyWithModel(
398
413
  const failures: string[] = [];
399
414
  for (const [n, maxTokens] of attempts) {
400
415
  if (ctx.signal?.aborted) break; // 用户已取消,不再重试
401
- const r = await callClassifierOnce(ctx, model, userMessage, maxTokens);
416
+ const r = await callClassifierOnce(ctx, model, userMessage, maxTokens, thinking);
402
417
  if (r.ok) {
403
418
  const diag = `stopReason=${r.stopReason}, model=${model.id}, 原始输出=${JSON.stringify(r.text.slice(0, 200))}`;
404
419
  if (r.stopReason !== "error" && r.stopReason !== "aborted") {
@@ -540,7 +555,7 @@ function shadowTag(probe: ShadowProbe): string {
540
555
 
541
556
  export default function autoMode(pi: ExtensionAPI) {
542
557
  pi.registerFlag("auto-mode", { description: "Enable Auto Mode (rules + model classifier gating for tool calls)", type: "boolean", default: true });
543
- pi.registerFlag("auto-mode-model", { description: "Classifier model as provider/id (default: inherit session model)", type: "string" });
558
+ pi.registerFlag("auto-mode-model", { description: "Classifier model as provider/id[:thinking] (pi --model syntax; default: inherit session model)", type: "string" });
544
559
  pi.registerFlag("auto-mode-debug", { description: "Notify every verdict incl. allows, with shadow-cache annotation", type: "boolean", default: false });
545
560
 
546
561
  let enabled = pi.getFlag("auto-mode") !== false;
@@ -590,14 +605,44 @@ export default function autoMode(pi: ExtensionAPI) {
590
605
  },
591
606
  });
592
607
 
608
+ let warnedClassifierModel = false;
609
+ /** 思考级别集(pi 原生 EXTENDED_THINKING_LEVELS;后缀语法对齐 pi --model provider/id:thinking) */
610
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
611
+
612
+ /** 解析 "provider/id:thinking" → { specPart, level }。无效后缀 → 忽略并警告一次 */
613
+ function parseModelSpec(raw: string, ctx: ExtensionContext): { specPart: string; level: string | null } {
614
+ const slash = raw.lastIndexOf("/");
615
+ const colon = raw.lastIndexOf(":");
616
+ if (colon > slash + 1 && THINKING_LEVELS.has(raw.slice(colon + 1))) {
617
+ return { specPart: raw.slice(0, colon), level: raw.slice(colon + 1) };
618
+ }
619
+ if (colon > slash + 1 && !warnedClassifierModel) {
620
+ warnedClassifierModel = true;
621
+ ctx.ui.notify(`pi-verdict:思考级别后缀 "${raw.slice(colon + 1)}" 无效(合法:${[...THINKING_LEVELS].join("/")}),已忽略`, "warning");
622
+ }
623
+ return { specPart: raw, level: null };
624
+ }
625
+
626
+ /** 分类器思考级别:spec 后缀指定;缺省 off(显式关思考,blackhole 研究背书) */
627
+ let classifierThinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = "off";
628
+
593
629
  function resolveClassifierModel(ctx: ExtensionContext): NonNullable<ExtensionContext["model"]> | null {
594
- const spec = (pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL;
595
- if (spec) {
596
- const slash = spec.indexOf("/");
630
+ // 优先级:CLI flag > 环境变量 > 配置文件(classifierModel) > 自省(会话模型)
631
+ const raw =
632
+ (pi.getFlag("auto-mode-model") as string | undefined) ?? process.env.PI_AUTO_MODE_MODEL ?? userRules.classifierModel;
633
+ classifierThinking = "off";
634
+ if (raw) {
635
+ const { specPart, level } = parseModelSpec(raw, ctx);
636
+ if (level) classifierThinking = level as typeof classifierThinking;
637
+ const slash = specPart.indexOf("/");
597
638
  if (slash > 0) {
598
- const model = ctx.modelRegistry.find(spec.slice(0, slash), spec.slice(slash + 1));
639
+ const model = ctx.modelRegistry.find(specPart.slice(0, slash), specPart.slice(slash + 1));
599
640
  if (model && ctx.modelRegistry.hasConfiguredAuth(model)) return model;
600
641
  }
642
+ if (!warnedClassifierModel) {
643
+ warnedClassifierModel = true; // 每会话仅警告一次,避免逐调用刷屏
644
+ ctx.ui.notify(`pi-verdict:分类器模型 "${raw}" 不可用(未找到或未配置凭证),回退会话模型(自省)`, "warning");
645
+ }
601
646
  }
602
647
  return ctx.model ?? null; // 自省:继承当前会话模型
603
648
  }
@@ -635,7 +680,7 @@ export default function autoMode(pi: ExtensionAPI) {
635
680
  const ctxKey = shadowContextKey(ctx);
636
681
  const probe = shadow.probe(cmdKey, ctxKey);
637
682
 
638
- const outcome = await classifyWithModel(ctx, model, action);
683
+ const outcome = await classifyWithModel(ctx, model, action, classifierThinking);
639
684
 
640
685
  // 影子回记:真实模型 allow/deny 入缓存;ask 与 fail-closed 不入(#5 定案);
641
686
  // 命中且本次为可缓存裁决时,对比反事实一致性
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-verdict",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pi coding agent extension: automatic tool-call permission verdicts — rule layer + model classifier with three-state adjudication (allow/ask/deny), fail-closed, evidence-driven",
5
5
  "author": "Jesset (https://github.com/jesset)",
6
6
  "type": "module",