@zhushanwen/pi-permission 1.6.3 → 1.6.5

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
@@ -354,7 +354,7 @@ publish 规则在后,last-match-wins 时 deny 胜出。
354
354
  - 查看决策来源:`PermissionDecision.source`(`mode` / `rule` / `ai` / `user`),区分是模式直接放行、规则命中、AI 分类还是人工审批(AST 分析不直接产生决策,只改变后续评估路径)
355
355
  - 查看命中规则:`PermissionDecision.matchedRule`(命中时携带 Rule 对象),从 `id` 可判断是 `builtin-safe`(白名单虚拟规则)、`bd-<n>`(内置危险)还是 `user-<n>`(用户规则)
356
356
  - 故意写一条 `deny` + 带 `description` 的用户规则触发拦截,从 tool_result 的 block reason 文案反查命中的是哪条规则
357
- - 规则 pattern 编译失败不会静默跳过:`resolvePattern` 无容错,非法 pattern 抛出的异常由 tool_call handler 兜底 fail-closed block(该次调用被拦)。实际触发面极窄——`source` 非 `user`/`builtin-safe`/`builtin-danger` 时归一化为 `user`,走 wildcard 编译(特殊字符自动转义,几乎不会失败);只有手写配置把 `source` 写成 `builtin-danger` 且 pattern 恰为非法正则时才会抛异常。怀疑某条规则没生效时先检查 JSON 语法与 wildcard 写法
357
+ - 规则 pattern 编译失败不会静默跳过:`resolvePattern` 无容错,非法 pattern 抛出的异常由 tool_call handler 兜底 fail-closed block(该次调用被拦)。实际触发范围极窄——`source` 非 `user`/`builtin-safe`/`builtin-danger` 时归一化为 `user`,走 wildcard 编译(特殊字符自动转义,几乎不会失败);只有手写配置把 `source` 写成 `builtin-danger` 且 pattern 恰为非法正则时才会抛异常。怀疑某条规则没生效时先检查 JSON 语法与 wildcard 写法
358
358
 
359
359
  ## AI Classifier
360
360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
4
4
  "description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -52,7 +52,7 @@
52
52
  "web-tree-sitter": "^0.26.11",
53
53
  "@zhushanwen/pi-ext-guards": "0.5.0",
54
54
  "@zhushanwen/pi-extension-logger": "0.6.2",
55
- "@zhushanwen/pi-llm-shared": "0.11.3"
55
+ "@zhushanwen/pi-llm-shared": "0.12.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@earendil-works/pi-ai": "^1.0.0",
@@ -286,7 +286,7 @@ describe("ApprovalComponent(G4 invalidate)", () => {
286
286
  expect(result.approved).toBe(false);
287
287
  });
288
288
 
289
- it("handleInput 在 resolved 后 no-op(守卫)", () => {
289
+ it("handleInput 在 resolved 后 no-op(检查)", () => {
290
290
  const done = vi.fn();
291
291
  const comp = new ApprovalComponent(req, { requestRender: vi.fn() }, done);
292
292
  comp.handleInput("\r"); // 首次 approve(Enter)
@@ -96,7 +96,7 @@ function makeRegistry(): MockFooterRegistry {
96
96
  };
97
97
  }
98
98
 
99
- /** 读当前 slot(version 守卫后)。 */
99
+ /** 读当前 slot(version 检查后)。 */
100
100
  function getRawSlot(): unknown {
101
101
  return Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY);
102
102
  }
@@ -23,7 +23,7 @@ import permissionExtension from "../index.js";
23
23
  import { FOOTER_HANDSHAKE_KEY, REQUEST_RENDER_KEY } from "../footer-provider.js";
24
24
 
25
25
  // migrateLegacyConfig 包 spy 计数(透传真实实现,行为零改变):迁移接线用例的
26
- // 操作级去重断言(双派发 spy = 1)经此观测,文件面断言(mtime/内容不变)同源有效。
26
+ // 操作级去重断言(双派发 spy = 1)经此观测,文件侧断言(mtime/内容不变)同源有效。
27
27
  const { spyMigrateLegacyConfig } = vi.hoisted(() => ({
28
28
  spyMigrateLegacyConfig: vi.fn(),
29
29
  }));
@@ -295,7 +295,7 @@ describe("W8 /permission rule 命令集成", () => {
295
295
  const { pi, calls } = createMockPi();
296
296
  permissionExtension(pi);
297
297
  const handler = getPermissionHandler(calls);
298
- // notify spy 注入 ctx(commands.test.ts 同款 vi.fn 形态):headless 降级提示的可观测面
298
+ // notify spy 注入 ctx(commands.test.ts 同款 vi.fn 形态):headless 降级提示的可观测范围
299
299
  const notify = vi.fn();
300
300
  const ctx = {
301
301
  mode: "json",
@@ -406,13 +406,13 @@ describe("footer line 注册", () => {
406
406
  });
407
407
 
408
408
  // ──────────────────────── session_start 配置路径迁移接线(index.ts migrateLegacyConfig 调用)────────────────────────
409
- // 迁移经 oncePerProcess 守卫(u-audit-fix 收编原内联 once flag),守卫的模块级 Map
409
+ // 迁移经 oncePerProcess 检查(u-audit-fix 收编原内联 once flag),检查的模块级 Map
410
410
  // 跨用例残留:用 vi.resetModules() + 动态 import 取新鲜模块实例,否则前面用例已触发过
411
411
  // session_start,迁移分支永不执行(静默不测)。
412
- // 末两条为守卫双派发实测(u-audit-fix 探针):
412
+ // 末两条为检查双派发实测(u-audit-fix 探针):
413
413
  // a) 操作级去重:双派发下 spy migrateLegacyConfig 调用 = 1(warn 在 migrateLegacyConfig
414
414
  // 调用点内部,调用 = 1 构造性蕴含 warn ≤ 1,不另设断言);
415
- // b) 文件面:第二次派发后重建的旧文件原样保留、新路径 mtime/内容严格不变;
415
+ // b) 文件侧:第二次派发后重建的旧文件原样保留、新路径 mtime/内容严格不变;
416
416
  // c) 豁免项防误伤:footer 注册为纯内存初始化(registry.register 同 id 覆盖),不在
417
417
  // 包装范围——双派发仍每次执行(pending push ×2)。
418
418
  describe("session_start 配置路径迁移接线", () => {
@@ -453,7 +453,7 @@ describe("session_start 配置路径迁移接线", () => {
453
453
  expect(existsSync(LEGACY_PATH)).toBe(false);
454
454
  });
455
455
 
456
- it("once 守卫:同进程第二次 session_start 不重复迁移(重建旧文件不动)", async () => {
456
+ it("once 检查:同进程第二次 session_start 不重复迁移(重建旧文件不动)", async () => {
457
457
  rmSync(CONFIG_PATH, { force: true });
458
458
  writeLegacyConfig("auto");
459
459
 
@@ -464,7 +464,7 @@ describe("session_start 配置路径迁移接线", () => {
464
464
  handler({}, makeCtx("json"));
465
465
  expect(existsSync(LEGACY_PATH)).toBe(false); // 首次已迁移
466
466
 
467
- // 重建旧文件(模拟外部残留),再次触发 session_start → oncePerProcess 守卫跳过迁移
467
+ // 重建旧文件(模拟外部残留),再次触发 session_start → oncePerProcess 检查跳过迁移
468
468
  writeLegacyConfig("yolo");
469
469
  handler({}, makeCtx("json"));
470
470
  expect(existsSync(LEGACY_PATH)).toBe(true); // 未被再次消费
@@ -498,8 +498,8 @@ describe("session_start 配置路径迁移接线", () => {
498
498
  expect(existsSync(CONFIG_PATH)).toBe(false); // 迁移不凭空造新配置
499
499
  });
500
500
 
501
- // ── oncePerProcess 守卫双派发实测(u-audit-fix 探针)──
502
- it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件面首次迁移到位、第二次 mtime/内容不变", async () => {
501
+ // ── oncePerProcess 检查双派发实测(u-audit-fix 探针)──
502
+ it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件侧首次迁移到位、第二次 mtime/内容不变", async () => {
503
503
  // spy 是文件级单例(跨 resetModules 保留计数),前面用例已触发过迁移 → 先清零
504
504
  spyMigrateLegacyConfig.mockClear();
505
505
  // 预置旧路径文件(新路径不存在 → 走 renameSync 迁移分支)
@@ -520,11 +520,11 @@ describe("session_start 配置路径迁移接线", () => {
520
520
  const contentAfterFirst = readFileSync(CONFIG_PATH, "utf-8");
521
521
 
522
522
  // 第二次派发(factory 二调/handler 累积形态:同一 handler 引用再调):
523
- // 重建旧路径文件模拟外部残留——守卫已消费 key,不得再次触碰任何文件。
523
+ // 重建旧路径文件模拟外部残留——检查已消费 key,不得再次触碰任何文件。
524
524
  writeLegacyConfig("yolo");
525
525
  handler({}, makeCtx("json"));
526
526
 
527
- // 探针 a:操作级去重(spy 透传真实实现,文件面与计数同源)
527
+ // 探针 a:操作级去重(spy 透传真实实现,文件侧与计数同源)
528
528
  expect(spyMigrateLegacyConfig).toHaveBeenCalledTimes(1);
529
529
  // 探针 b:第二次后重建的旧文件原样保留、新路径 mtime/内容严格不变
530
530
  expect(existsSync(LEGACY_PATH)).toBe(true);
@@ -14,6 +14,9 @@
14
14
  * 注册,D3「旧名不保留别名」),不对其迁移后结论作断言;
15
15
  * 5b. 反向混合集(wildcard deny 在前、精确 allow 在后)——与分支5互为镜像,
16
16
  * 区分度弱,承重位置锚 = 精确条目仍在 wildcard 之前。
17
+ * 6. subagents → subagent_batch(2026-10-23 批量工具改名入表)——单目标展开:
18
+ * 展开正确 + 字段保留 + 幂等 + 保序位置锚 + 真实 matcher 结论等价四维度合并
19
+ * 锁定(单元素展开无次序承重,四维度一用例足够)。
17
20
  *
18
21
  * 匹配器行为注释:matchNonBashTool 的 tool 字段经 wildcardToRegExp 全锚定匹配
19
22
  * (精确名 = 精确匹配,'*' = 匹配全部);pattern 对 path 匹配(path 缺省时对
@@ -34,7 +37,7 @@ function userRule(overrides: Partial<Rule> & { id: string; tool: string }): Rule
34
37
  };
35
38
  }
36
39
 
37
- describe("旧工具名精确规则迁移(D3)— 表驱动六用例(五分支 + 分支5b 反向混合集)", () => {
40
+ describe("旧工具名精确规则迁移(D3)— 表驱动七用例(六分支 + 分支5b 反向混合集)", () => {
38
41
  it("分支1:精确旧名展开(subagent 三条 + workflow 两条,展开顺序承重,其余字段保留)", () => {
39
42
  const rules: Rule[] = [
40
43
  userRule({ id: "user-1", tool: "subagent", pattern: "*", action: "deny", description: "no subagent" }),
@@ -163,7 +166,7 @@ describe("旧工具名精确规则迁移(D3)— 表驱动六用例(五分
163
166
  it("分支5b:反向混合集(wildcard deny 在前、精确 allow 在后)保序——新名结论 = 旧名结论", () => {
164
167
  // 与分支5互为镜像:旧名调用结论 = deny(尾部精确 allow 被…不——allow 在
165
168
  // wildcard deny 之后,last-match-wins 下 allow 胜。若错误追加,新名 allow
166
- // 落在最后 → 仍 allow,本场景区分度弱;承重面是「精确条目仍在 wildcard
169
+ // 落在最后 → 仍 allow,本场景区分度弱;关键部分是「精确条目仍在 wildcard
167
170
  // 之前」的位置锚 + 与分支5合并覆盖两个方向)。
168
171
  const mixed: Rule[] = [
169
172
  userRule({ id: "user-1", tool: "*", pattern: "*", action: "deny" }),
@@ -196,4 +199,36 @@ describe("旧工具名精确规则迁移(D3)— 表驱动六用例(五分
196
199
  expect(matchNonBashTool("bash", "/x", migrated).action).toBe(matchNonBashTool("bash", "/x", mixed).action);
197
200
  expect(matchNonBashTool("bash", "/x", mixed).action).toBe("deny");
198
201
  });
202
+
203
+ it("分支6:subagents → subagent_batch(2026-10-23 批量工具改名入表)— 展开 + 幂等 + 保序 + matcher 结论等价", () => {
204
+ // 展开正确 + 字段保留:单目标展开(一条旧名规则 → 一条 subagent_batch)
205
+ const rules: Rule[] = [
206
+ userRule({ id: "user-1", tool: "subagents", pattern: "*", action: "deny", description: "no batch" }),
207
+ userRule({ id: "user-2", tool: "*", pattern: "*", action: "allow" }),
208
+ ];
209
+ const migrated = migrateLegacyToolRules(rules);
210
+
211
+ // 保序位置锚:展开条目原位替换,wildcard 仍在后(单目标无展开次序承重)
212
+ expect(migrated).toHaveLength(2);
213
+ expect(migrated.map((r) => r.tool)).toEqual(["subagent_batch", "*"]);
214
+ // pattern/action/source/description 保留(只换 tool 名与 id)
215
+ expect(migrated[0]).toMatchObject({ pattern: "*", action: "deny", source: "user", description: "no batch" });
216
+ // 新 id(D3 契约②):不克隆原 id,形态 user-N
217
+ expect(migrated[0]?.id).not.toBe("user-1");
218
+ expect(migrated[0]?.id).toMatch(/^user-\d+$/);
219
+ // 纯函数:原数组不改写
220
+ expect(rules).toHaveLength(2);
221
+ expect(rules[0]?.tool).toBe("subagents");
222
+
223
+ // 幂等:新名不在旧名集合,再迁移深度相等
224
+ expect(migrateLegacyToolRules(migrated)).toEqual(migrated);
225
+
226
+ // 真实 matcher 结论等价:场景同分支5(尾部 wildcard allow 覆盖精确 deny)——
227
+ // 若实装错误地把展开条目追加到数组尾部,subagent_batch deny 落到 wildcard
228
+ // allow 之后 → 结论翻成 deny ≠ 迁移前 allow,本断言即红灯。
229
+ const beforeSubagents = matchNonBashTool("subagents", "/x", rules).action;
230
+ const afterBatch = matchNonBashTool("subagent_batch", "/x", migrated).action;
231
+ expect(beforeSubagents).toBe("allow");
232
+ expect(afterBatch).toBe(beforeSubagents);
233
+ });
199
234
  });
@@ -285,7 +285,7 @@ describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
285
285
  expect(comp.render(80).join("\n")).toContain("Select Provider"); // 回到 provider stage
286
286
  });
287
287
 
288
- it("_resolved 守卫:done 后再 handleInput no-op", () => {
288
+ it("_resolved 检查:done 后再 handleInput no-op", () => {
289
289
  const done = vi.fn();
290
290
  const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
291
291
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * production.test.ts — createProductionClassifier + createPipelineDeps 装配测试。
3
3
  *
4
- * C1a 收口后 createProductionClassifier(ctx) 的 resolveModel 注入走 llm-shared resolveModel
4
+ * C1a 收敛后 createProductionClassifier(ctx) 的 resolveModel 注入走 llm-shared resolveModel
5
5
  * (不再预检凭证),callLLM 注入闭包捕获 ctx(凭证在 callLLM 内部 getApiKeyAndHeaders)。
6
6
  * 测试用 mock ctx.modelRegistry + vi.mock llm-shared(resolveModel + callLLM)精确控制,覆盖:
7
7
  * - TC1: toSelector 任意字符串 → ref 精确选择器('auto' 不走 selector,由 resolveModel 注入层本地处理)
8
8
  * - TC3: 'auto' 且 getAvailable 空 → resolveModel 返回 null → fail-closed ask(不触达 callLLM)
9
- * - TC4(静态断言): production.ts 收口完成——无 getApiProvider/streamSimple/@ts-ignore,走 callLLM
9
+ * - TC4(静态断言): production.ts 收敛完成——无 getApiProvider/streamSimple/@ts-ignore,走 callLLM
10
10
  * - TC4(装配): resolveModel 命中 → callLLM 收到 model → ok:true 正常分类(链路通)
11
11
  * - TC5: callLLM 返回 ok:false(LLM 调用失败)→ classifier fail-closed fallback
12
12
  * - TC7: 'auto' 直接取 getAvailable() 首个(permission 本地解析,不经过 llm-shared 非精确 selector)
@@ -129,7 +129,7 @@ describe("createProductionClassifier", () => {
129
129
  });
130
130
  });
131
131
 
132
- // ──────────────────────── resolveModel 装配(C3 + C1a 收口) ────────────────────────
132
+ // ──────────────────────── resolveModel 装配(C3 + C1a 收敛) ────────────────────────
133
133
 
134
134
  describe("resolveModel 装配(resolveModel + callLLM)", () => {
135
135
  const CTX = { toolName: "bash", command: "ls", cwd: "/tmp" };
@@ -147,19 +147,19 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
147
147
  expect(callLLMShared).not.toHaveBeenCalled();
148
148
  });
149
149
 
150
- it("TC4(静态断言): production.ts 收口完成——无 getApiProvider/streamSimple/@ts-ignore,classifier 走 callLLM", () => {
150
+ it("TC4(静态断言): production.ts 收敛完成——无 getApiProvider/streamSimple/@ts-ignore,classifier 走 callLLM", () => {
151
151
  const source = readFileSync(new URL("../production.ts", import.meta.url), "utf-8");
152
152
  // 匹配代码形式(import 调用 / 字段定义),注释里的历史描述不算残留
153
153
  expect(source).not.toMatch(/getApiProvider\s*\(/);
154
154
  expect(source).not.toMatch(/streamSimple\s*[:=]/);
155
155
  expect(source).not.toMatch(/\/\/\s*@ts-ignore/);
156
156
  expect(source).not.toMatch(/import\s*\{[^}]*getApiProvider/);
157
- // classifier LLM 调用收口到 llm-shared callLLM(注入闭包捕获 ctx)
157
+ // classifier LLM 调用收敛到 llm-shared callLLM(注入闭包捕获 ctx)
158
158
  expect(source).toMatch(/callLLM\s*:/);
159
159
  expect(source).toContain('from "@zhushanwen/pi-llm-shared"');
160
160
  });
161
161
 
162
- it("TC4(装配): ref resolveModel 命中 → callLLM 收到 model → ok:true 正常分类(收口链路通)", async () => {
162
+ it("TC4(装配): ref resolveModel 命中 → callLLM 收到 model → ok:true 正常分类(收敛链路通)", async () => {
163
163
  vi.mocked(resolveModelShared).mockReturnValue(MOCK_MODEL_A);
164
164
  vi.mocked(callLLMShared).mockResolvedValue({
165
165
  ok: true,
@@ -168,7 +168,7 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
168
168
  const classifier = createProductionClassifier(makeMockCtx());
169
169
  const result = await classifier.classifyRisk(CTX, CFG_REF);
170
170
  expect(result.outcome).toBe("allow");
171
- // 关键:resolveModel 返回 model 后,callLLM 被调用且收到该 model(收口链路 resolve→callLLM 通)
171
+ // 关键:resolveModel 返回 model 后,callLLM 被调用且收到该 model(收敛链路 resolve→callLLM 通)
172
172
  expect(callLLMShared).toHaveBeenCalledOnce();
173
173
  // callLLM(ctx, opts) 两个参数:mock.calls[0] = [ctx, opts]
174
174
  const opts = vi.mocked(callLLMShared).mock.calls[0]![1];
@@ -7,7 +7,7 @@
7
7
  * - list stage [+ Add] → add stage
8
8
  * - add stage 选模板 → fill stage
9
9
  * - [Done] → settle ops
10
- * - _resolved 守卫
10
+ * - _resolved 检查
11
11
  * - currentListRules 实时反映 ops(G2)
12
12
  * - WR1 handleInput 锁定(Container override 委托)
13
13
  * - Tab 焦点路由(WR8)
@@ -134,9 +134,9 @@ describe("REC4: fill stage 命令选择 + commitFill", () => {
134
134
  });
135
135
  });
136
136
 
137
- // ──────────────────────── _resolved 守卫 ────────────────────────
137
+ // ──────────────────────── _resolved 检查 ────────────────────────
138
138
 
139
- describe("REC5: _resolved 守卫", () => {
139
+ describe("REC5: _resolved 检查", () => {
140
140
  it("settle 后再 handleInput no-op(done 只调一次)", () => {
141
141
  const { comp, done } = createComp();
142
142
  // 选 [Done]
@@ -226,7 +226,7 @@ describe("REC9: cancel", () => {
226
226
  expect(done).toHaveBeenCalledOnce();
227
227
  });
228
228
 
229
- it("cancel 后 _resolved 守卫生效", () => {
229
+ it("cancel 后 _resolved 检查生效", () => {
230
230
  const { comp, done } = createComp();
231
231
  comp.cancel();
232
232
  comp.cancel();
package/src/approval.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * ApprovalComponent 是简化版 TUI 组件(参考 ask-user AskUserComponent):
13
13
  * - 显示工具名 + 命令 + 触发原因 + 可选 AI 预分类。
14
14
  * - Enter/approve、Esc/deny。
15
- * - signal abort → comp.cancel()(复用 _resolved 守卫,避免二次 done)。
15
+ * - signal abort → comp.cancel()(复用 _resolved 检查,避免二次 done)。
16
16
  *
17
17
  * W6 T9 G3 Reject-with-Reason:用户拒绝时,若 ctx.ui.input 存在则弹出文本输入框
18
18
  * 采集真实拒绝理由(回传给 agent,辅助理解为何被拒);ctx.ui.input 不存在则 fallback
@@ -105,7 +105,7 @@ async function requestTui(
105
105
 
106
106
  return approvalCtx.ui.custom<UserDecision>((tui, _theme, _kb, done) => {
107
107
  const comp = new ApprovalComponent(req, tui as TuiLike, done);
108
- // signal abort → comp.cancel()(复用 _resolved 守卫,避免二次 done)
108
+ // signal abort → comp.cancel()(复用 _resolved 检查,避免二次 done)
109
109
  if (signal) {
110
110
  signal.addEventListener("abort", () => comp.cancel(), { once: true });
111
111
  }
@@ -275,7 +275,7 @@ interface TuiLike {
275
275
  * 键位:Enter → approve;Esc → deny/cancel(y/n 快捷键已移除,测试钉住 no-op)。
276
276
  *
277
277
  * G4:implements Component,补 invalidate()(调 tui.requestRender())。
278
- * signal abort → cancel()(复用 _resolved 守卫)。
278
+ * signal abort → cancel()(复用 _resolved 检查)。
279
279
  *
280
280
  * TODO W6:若需更丰富的 TUI(方向键选择 once/session/always scope、高亮风险等级、
281
281
  * Reject-with-Reason 的内联文本输入),参考 ask-user AskUserComponent 的 SelectList
@@ -345,7 +345,7 @@ export class ApprovalComponent implements Component {
345
345
  this.done({ approved: false, reason: "denied via tui" });
346
346
  }
347
347
 
348
- /** 取消(signal abort 调用)。复用 _resolved 守卫,避免二次 done。 */
348
+ /** 取消(signal abort 调用)。复用 _resolved 检查,避免二次 done。 */
349
349
  cancel(): void {
350
350
  if (this._resolved) return;
351
351
  this._resolved = true;
@@ -153,7 +153,7 @@ describe("WT16-WT19: malformed 语法(parseError=true)", () => {
153
153
  expect(r.clean).toBe(false);
154
154
  });
155
155
 
156
- it("WT18: unclosed \" —— 未闭合双引号", async () => {
156
+ it("WT18: unclosed \" —— 未结束的双引号", async () => {
157
157
  const r = await analyzeBashStructure('echo "unclosed');
158
158
  expect(r.parseError).toBe(true);
159
159
  expect(r.clean).toBe(false);
@@ -181,7 +181,7 @@ function parseChildToken(child: Node): string | null {
181
181
  /**
182
182
  * 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
183
183
  *
184
- * 主函数编排:类型守卫 → 逐 named child 分发(parseChildToken)→ 组装 words。
184
+ * 主函数编排:类型检查 → 逐 named child 分发(parseChildToken)→ 组装 words。
185
185
  * 任一 child 解析失败 → 返回 null。
186
186
  */
187
187
  function parsePlainCommand(cmd: Node): string[] | null {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * CT 系列:classifier.ts 单元测试(createClassifier + classifyRisk 主流程)。
3
3
  *
4
- * C1a 收口后 classifier 的 LLM 调用走 llm-shared callLLM(依赖注入 deps.callLLM,
4
+ * C1a 收敛后 classifier 的 LLM 调用走 llm-shared callLLM(依赖注入 deps.callLLM,
5
5
  * mock 返回 CallLLMResult 判别联合)。stopReason 归一化在 callLLM 层完成,
6
6
  * classifier 消费 ok:false + stopReason 透传字段保留日志区分。
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * MRT 系列:model-resolver.ts 单元测试(picker 相关 API)。
3
3
  *
4
- * E2(CL-picker-scope 收口)后 listAvailableModels 改走 ctx.modelRegistry.getAll() +
4
+ * E2(CL-picker-scope 收敛)后 listAvailableModels 改走 ctx.modelRegistry.getAll() +
5
5
  * hasConfiguredAuth() 过滤(不再读 models.json),loadModelsJson / flattenModels 已删除。
6
6
  * E1:ResolvedModelEntry.apiKey / hasApiKey 已删。
7
7
  *
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * 关键设计:
9
9
  * - 依赖注入 ClassifierDeps(callLLM 注入,便于测试 mock,不直接 import)
10
- * - C1a 收口:LLM 调用走 llm-shared callLLM(内部完成凭证 getApiKeyAndHeaders +
10
+ * - C1a 收敛:LLM 调用走 llm-shared callLLM(内部完成凭证 getApiKeyAndHeaders +
11
11
  * completeSimple + stopReason 归一化:error/aborted → {ok:false,
12
12
  * stopReason 透传}),不再走 production.ts 的 streamSimple
13
13
  * - G3 修正(由 callLLM 承接):completeSimple 的 EventStream.result() 只 resolve 不 reject,
@@ -51,9 +51,9 @@ const CLASSIFY_FALLBACK_RESULT: ClassifierResult = {
51
51
  * Classifier 的外部依赖(DI 便于测试 mock)。
52
52
  *
53
53
  * - resolveModel(async):把 ClassifierConfig.model 解析为 Model<Api>(或 null)。
54
- * P3 收口后 model 解析走 llm-shared resolveModel(ctx.modelRegistry 三源合并)。
54
+ * P3 收敛后 model 解析走 llm-shared resolveModel(ctx.modelRegistry 三源合并)。
55
55
  * - callLLM:llm-shared 的 LLM 调用封装(内部完成凭证 getApiKeyAndHeaders +
56
- * completeSimple + stopReason 归一化)。C1a 收口替代原 streamSimple 注入。
56
+ * completeSimple + stopReason 归一化)。C1a 收敛替代原 streamSimple 注入。
57
57
  * - onLog:可选日志回调(调试/审计)
58
58
  */
59
59
  export interface ClassifierDeps {
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * model picker 的模型列表(picker 用,非 classifier)。
3
3
  *
4
- * P3 收口后 classifier 不再自读 models.json(改走 llm-shared resolveModel +
4
+ * P3 收敛后 classifier 不再自读 models.json(改走 llm-shared resolveModel +
5
5
  * ctx.modelRegistry,见 classifier.ts / production.ts)。
6
6
  *
7
- * E2(CL-picker-scope 收口):listAvailableModels 改走 ctx.modelRegistry.getAll() +
7
+ * E2(CL-picker-scope 收敛):listAvailableModels 改走 ctx.modelRegistry.getAll() +
8
8
  * hasConfiguredAuth() 过滤——用户只经 `pi auth login` 配的内置/OAuth provider 同样可见,
9
9
  * 不再自读 models.json(loadModelsJson / flattenModels 已删除)。
10
- * E1:ResolvedModelEntry.apiKey 死字段已删(P3 收口后凭证走 modelRegistry,无消费者)。
10
+ * E1:ResolvedModelEntry.apiKey 死字段已删(P3 收敛后凭证走 modelRegistry,无消费者)。
11
11
  * E5:name/baseUrl/cost 死字段已删(picker 只读 id/api)。
12
12
  */
13
13
 
package/src/commands.ts CHANGED
@@ -126,7 +126,7 @@ function modeHighlight(mode: PermissionMode): string {
126
126
  * 2. pickModelViaOverlay:TUI/RPC/headless 三模式分发。
127
127
  * 3. 写回:save(newConfig)。失败 → notify error。
128
128
  *
129
- * save 为普通函数参数(唯一真实变化轴:保存 + footer 重绘副作用)。
129
+ * save 为普通函数参数(唯一真实变化原因:保存 + footer 重绘副作用)。
130
130
  * ctx 用 ModelPickerContext 子集(mode + modelRegistry + ui.notify/custom/select)。
131
131
  */
132
132
  export async function handlePermissionModelCommand(
@@ -181,7 +181,7 @@ export async function handlePermissionModelCommand(
181
181
  * 2. ops 非空 → applyOps + saveConfig + notify。
182
182
  * 3. 空 → notify no changes。
183
183
  *
184
- * save 为普通函数参数(唯一真实变化轴:保存 + footer 重绘副作用,与同步路径 onSave 同款)。
184
+ * save 为普通函数参数(唯一真实变化原因:保存 + footer 重绘副作用,与同步路径 onSave 同款)。
185
185
  */
186
186
  export async function handlePermissionRuleCommand(
187
187
  ctx: RuleEditorContext,
package/src/config.ts CHANGED
@@ -145,7 +145,7 @@ function warnLegacyConfigIfExists(): void {
145
145
  * D3:subagent/workflow 拆分改名后,持久化的旧名精确规则原地展开为新名规则——
146
146
  * 原地替换保序 + makeNextIdCounter 新 id + 幂等;只内存展开,不回写落盘,加载路径纯读)。
147
147
  * 所有配置消费方(pipeline / rule-editor / statusline / commands)都经本入口取 config,
148
- * 迁移单点在此即全消费面生效。
148
+ * 迁移单点在此即全部消费方生效。
149
149
  */
150
150
  export function loadAndWatchConfig(onWarning?: (msg: string) => void): PermissionConfig {
151
151
  const configPath = getConfigPath();
@@ -68,7 +68,7 @@ interface FooterHandshakeSlot {
68
68
  pending: PendingEntry[];
69
69
  }
70
70
 
71
- // ── Slot 读取(version + 结构守卫)──
71
+ // ── Slot 读取(version + 结构检查)──
72
72
 
73
73
  /**
74
74
  * 从 globalThis 读 slot;version !== 1 或结构不合规视为无 slot(返回 undefined)。
@@ -80,7 +80,7 @@ function readSlot(): FooterHandshakeSlot | undefined {
80
80
  const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY);
81
81
  if (slot === undefined) return undefined;
82
82
  if (typeof slot !== "object" || slot === null) return undefined;
83
- // 双字段 in 守卫:version + pending 都在才视为 slot 雏形(避免 as 到全可选类型的无校验断言)
83
+ // 双字段 in 检查:version + pending 都在才视为 slot 雏形(避免 as 到全可选类型的无校验断言)
84
84
  if (!("version" in slot) || !("pending" in slot)) return undefined;
85
85
  const loose = slot as { version: unknown; pending: unknown };
86
86
  if (loose.version !== HANDSHAKE_VERSION) {
package/src/index.ts CHANGED
@@ -43,7 +43,7 @@ import { paletteFromTheme, type PermissionPalette } from "./statusline-palette.j
43
43
  // Added in v1.0.0. Remove after v2.0.0 (one major past).
44
44
  // session_start 首次触发时把旧路径 permission-config.json 迁到 config/permission-ext-config.json。
45
45
  // 幂等、best-effort(migrateLegacyConfig 见 @zhushanwen/pi-llm-shared)。
46
- // 写 agentDir 全局配置文件属跨 session 副作用——oncePerProcess 守卫防 factory 二调/handler
46
+ // 写 agentDir 全局配置文件属跨 session 副作用——oncePerProcess 检查防 factory 二调/handler
47
47
  // 累积双跑(u-audit-fix 收编原内联 once flag;key 带包名前缀避撞,见 pi-ext-guards JSDoc)。
48
48
  // agentDir 由 getAgentDir() 推导(尊重 PI_CODING_AGENT_DIR)。
49
49
 
@@ -90,7 +90,7 @@ const _UI_OPTIONS_PARAM_INDEX = 2;
90
90
  *
91
91
  * select/input 的 opts 类型经 `Parameters<typeof ...>[typeof _UI_OPTIONS_PARAM_INDEX]`
92
92
  * 索引提取(字面量索引会触发 no-magic-numbers,typeof 常量索引语义等价)。
93
- * input 可选守卫展开:SDK 提供,mock/headless 可能缺失——rule-editor custom 模板
93
+ * input 可选检查展开:SDK 提供,mock/headless 可能缺失——rule-editor custom 模板
94
94
  * 文本输入与 Reject-with-Reason(approval.ts collectRejectReason)都依赖此透传。
95
95
  */
96
96
  function makeUiAdapter(ui: ExtensionUIContext) {
@@ -16,7 +16,7 @@
16
16
  * - ProviderModelSelectorComponent extends Container:Container 无 handleInput,必须 override
17
17
  * 委托给当前 stage 的 SelectList(WC9/WC15 critical)。
18
18
  * - stage 切换用 clear() + addChild 重建(两态状态机)。
19
- * - _resolved 守卫防二次 done(switchToModelStage 等异步路径可能重复触发)。
19
+ * - _resolved 检查防二次 done(switchToModelStage 等异步路径可能重复触发)。
20
20
  */
21
21
 
22
22
  import { type Component, Container, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
@@ -79,7 +79,7 @@ class TextLines implements Component {
79
79
  * 测试 WR1:直接调 comp.handleInput('\r') 验证 SelectList.onSelect 被触发(锁定键盘委托通路)。
80
80
  *
81
81
  * done 桥接:comp 构造时传入 done(由 ctx.ui.custom 的 factory 提供),内部 onSelect/onCancel
82
- * 最终调 done(参考 ApprovalComponent)。_resolved 守卫防二次 done。
82
+ * 最终调 done(参考 ApprovalComponent)。_resolved 检查防二次 done。
83
83
  */
84
84
  export class ProviderModelSelectorComponent extends Container {
85
85
  private stage: "provider" | "model" = "provider";
package/src/production.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * 生产装配层(W5 集成层)。
3
3
  *
4
- * P3 收口:classifier 的 model 解析改走 llm-shared resolveModel(三源合并含 OAuth/env/auth.json),
4
+ * P3 收敛:classifier 的 model 解析改走 llm-shared resolveModel(三源合并含 OAuth/env/auth.json),
5
5
  * 废弃自读 models.json。'auto' 由 permission 本地处理:直接取 ctx.modelRegistry.getAvailable()[0]
6
6
  * (不经过 llm-shared 的非精确 selector),与下方 createProductionClassifier JSDoc 一致。
7
7
  *
8
- * C1a 收口:classifier 的 LLM 调用改走 llm-shared callLLM(凭证 getApiKeyAndHeaders +
8
+ * C1a 收敛:classifier 的 LLM 调用改走 llm-shared callLLM(凭证 getApiKeyAndHeaders +
9
9
  * completeSimple + stopReason 归一化都在 callLLM 内部完成),删除 getApiProvider +
10
10
  * streamSimple(@ts-ignore 随之消除)。
11
11
  *
@@ -54,7 +54,7 @@ export function toSelector(modelSpec: string): ModelSelector {
54
54
  * - model 仍 null → 返回 null(fail-closed)。
55
55
  *
56
56
  * callLLM 注入(C1a):闭包捕获 ctx,把 llm-shared callLLM 绑定为 classifier 的 LLM 调用。
57
- * 凭证获取(getApiKeyAndHeaders)在 callLLM 内部完成(C1a 收口后不再在 resolveModel 层预检)。
57
+ * 凭证获取(getApiKeyAndHeaders)在 callLLM 内部完成(C1a 收敛后不再在 resolveModel 层预检)。
58
58
  */
59
59
  export function createProductionClassifier(ctx: ExtensionContext): {
60
60
  classifyRisk: ReturnType<typeof createClassifier>["classifyRisk"];
@@ -75,7 +75,7 @@ type FillKind =
75
75
  * TUI overlay 组件:规则 CRUD 编辑器。
76
76
  *
77
77
  * Container override handleInput(W7 模式):按 stage 委托给当前子组件。
78
- * settle 用 _resolved 守卫防二次 done。
78
+ * settle 用 _resolved 检查防二次 done。
79
79
  */
80
80
  export class RuleEditorComponent extends Container {
81
81
  private stage: Stage = "list";
@@ -244,7 +244,7 @@ export class RuleEditorComponent extends Container {
244
244
  this.settle();
245
245
  }
246
246
 
247
- /** settle:_resolved 守卫 + done(this.ops)。 */
247
+ /** settle:_resolved 检查 + done(this.ops)。 */
248
248
  private settle(): void {
249
249
  if (this._resolved) return;
250
250
  this._resolved = true;
@@ -173,8 +173,8 @@ describe("MT-last-match-wins: 后匹配的规则胜出", () => {
173
173
  // ──────────────────────── matchRules 退化路径 ────────────────────────
174
174
 
175
175
  describe("MT-matchRules: 退化路径(command 字符串)", () => {
176
- // E11:toolName 参数与 '!== bash' 守卫已删(生产调用点恒在 bash 分支内,
177
- // 非 bash 由 pipeline.matchNonBashTool 承接),对应守卫用例随之移除。
176
+ // E11:toolName 参数与 '!== bash' 检查已删(生产调用点恒在 bash 分支内,
177
+ // 非 bash 由 pipeline.matchNonBashTool 承接),对应检查用例随之移除。
178
178
 
179
179
  it("command === undefined → ask", () => {
180
180
  const r = matchRules(undefined, getDefaultRules());
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * rules 模块 barrel —— 只 re-export 有真实消费的公开 API(E10 收敛)。
3
3
  *
4
- * 生产消费面 = production.ts:getDefaultRules(内置规则拼接)+ matchRulesForArgv
4
+ * 生产消费方 = production.ts:getDefaultRules(内置规则拼接)+ matchRulesForArgv
5
5
  * (bash argv 匹配)。其余符号(matchRules / resolvePattern / wildcardToRegExp /
6
6
  * builtins 数据)无 barrel 消费——pipeline.ts 与各测试均走深路径 import;
7
7
  * 类型一律直接从 ../types.js import,barrel 不做类型 re-export。
@@ -5,6 +5,7 @@
5
5
  * subagent_start/subagent_manage/subagent_message 与 workflow_run/workflow_control,
6
6
  * 旧名不保留别名(探针 PB-fork)。用户权限规则按工具名匹配(`Rule.tool`)并持久化
7
7
  * 落盘(config.ts saveConfig),旧名精确规则随改名失配——deny 静默退化成 ask/AI 分类。
8
+ * 批量工具 `subagents` → `subagent_batch` 改名(2026-10-23)同机制入表。
8
9
  *
9
10
  * 迁移语义(D3 两条契约):
10
11
  * ① 展开落位与保序——精确匹配旧名的用户规则**原地替换**为其新名展开(展开顺序
@@ -38,6 +39,7 @@ import { makeNextIdCounter } from "../rule-templates.js";
38
39
  const LEGACY_TOOL_NAME_EXPANSION: Readonly<Record<string, readonly string[]>> = {
39
40
  subagent: ["subagent_start", "subagent_manage", "subagent_message"],
40
41
  workflow: ["workflow_run", "workflow_control"],
42
+ subagents: ["subagent_batch"],
41
43
  };
42
44
 
43
45
  /**
@@ -149,8 +149,8 @@ export function matchRulesForArgv(argv: string[], rules: readonly Rule[]): RuleM
149
149
  * - 遍历 rules(last-match-wins),对每条用 resolvePattern(rule).test(command)
150
150
  * - 无匹配 → ask(G1)
151
151
  *
152
- * E11:toolName 参数与 `toolName !== 'bash'` 守卫已删——生产调用点(pipeline.runLayer2)
153
- * 恒在 bash 分支内调用,非 bash 由 matchNonBashTool 承接,守卫是不可达的投机分支。
152
+ * E11:toolName 参数与 `toolName !== 'bash'` 检查已删——生产调用点(pipeline.runLayer2)
153
+ * 恒在 bash 分支内调用,非 bash 由 matchNonBashTool 承接,检查是不可达的投机分支。
154
154
  *
155
155
  * 不调 isKnownSafeCommand(不查白名单):command 字符串无法可靠提取 argv
156
156
  * (含引号/转义/管道),argv 级白名单判断由 matchRulesForArgv 在 happy path 负责。
package/src/types.ts CHANGED
@@ -30,7 +30,7 @@ export const MODE_LABELS: Record<PermissionMode, string> = {
30
30
  strict: "Strict",
31
31
  };
32
32
 
33
- /** PermissionMode 类型守卫 */
33
+ /** PermissionMode 类型检查 */
34
34
  export function isValidPermissionMode(value: unknown): value is PermissionMode {
35
35
  return typeof value === "string" && PERMISSION_MODES.includes(value as PermissionMode);
36
36
  }