# 严格闸 · Strict Gate
**一个模型必须记得去调用的工具,恰恰会在最需要它的时候被跳过。**
一个 DeepSeek Harness 宿主插件:把 `strict_check` 和失败日志,从 agent **可以**用的东西,
变成宿主**强制执行**的策略。
[](LICENSE)
[](#install)
[](package.json)
[](package.json)
[](https://github.com/catsenior507/dsh-policy-strict-gate/stargazers)
[English](README.md) · **简体中文**
---
## 它解决什么
前面两个插件给了 agent 能力。这一个,把"要不要用这些能力"的决定权从 agent 手里拿走。
这不是对 agent 的批评 —— 这是关于**检查会在什么时候被跳过**的陈述。自律恰恰在
最要命的时刻失效:模型很自信的时候、在赶进度的时候、已经陷入循环的时候。
那些时刻检查最值钱,也最不可能被主动调用。
还有第二个理由,也是真正说服我做这个的原因:**检查结果落在工具返回值里,是可以被略读的;
而检查结果变成"拒绝下一次写入",略不过去。**
## 三道闸
各自独立失效、独立配置,关掉一道不影响另外两道。
### 1. 重复失败
同一个失败签名在一个会话里出现 `repeatThreshold` 次(默认 3),就排入一条通知,
写明失败类别、内置修复提示,以及"你必须改点什么"的指令。之后每隔
`repeatCooldown` 次再提醒一次,所以长时间循环不会淹没上下文。
宿主对**逐字节相同**的调用已经做了类似的事。这里把它扩展到"空格或路径不同、
但死于同一个原因"的调用。
刻意挂在 `tools/result` 而不是 `tools/post-execute` 上:前者对**每一个**已结算的结果
都会触发,包括绕过 post-execute 的那些管道失败 —— 被拒绝的调用、未知工具、
被本闸拒绝的调用。一个模型反复撞被拒的调用,恰恰是最值得打断的循环。
### 2. 关键路径
对受保护 glob 命中路径的写入会被**拒绝**,直到有一份覆盖它的 Lean 规格通过检查。
两个设计选择让它成为"策略"而不是"障碍":
**拒绝理由里带诊断。** 宿主会丢弃被拒调用的结果,所以一句只说"已阻止"的拒绝,
会把满足这道闸所需的信息一起销毁。所以闸在拒绝**之前**先跑检查,并把发现的东西引出来。
**修复通道永远打开。** 正在修"闸刚刚报出的那个诊断"的写入会被放行。没有这条规则,
闸就死锁了 —— 它会拒绝"对它所报错误的修复",而且没有任何出路。
这就是计数器里的 `repairPasses`,也是这个插件里最重要的一个行为。
每次拒绝都会打印三条出路:满足闸、重试修复、或把该路径从 `criticalPaths` 移除。
### 3. 写后语法检查
每一个被接受的写入,紧接着就跑该语言自己的检查
(`py_compile`、`node --check`、PowerShell 解析器),诊断随上下文一起进入下一步。
这是最便宜、也最经常回本的一层:它在**弄坏文件的那一步**就抓住它,
而不是三个命令之后通过一个不相关的失败才发现。
## 安装
本插件作为包安装进一个 dsh **profile**。`dsh plugin` 会在 profile 目录里转发给 `pnpm`,
所以 pnpm 接受的任何 spec 都可以用。
```bash
# 从 GitHub 安装(公开发布形式)
dsh plugin --profile web add github:catsenior507/dsh-policy-strict-gate
# 本地检出安装(开发时用)
dsh plugin --profile web add /absolute/path/to/dsh-policy-strict-gate
```
`web` 是自带 GUI 的 profile;可换成 `headless`、`sdk`、`acp` 或你自己的 profile 名。
Windows 上路径用正斜杠,或给路径加引号。
关键路径闸和写后检查依赖它用来判断的检查器,请一并安装:
```bash
dsh plugin --profile web add github:catsenior507/dsh-tool-strict-check
```
闸通过遍历 DSH 的 profile 目录来定位那个包,所以两者是 link 还是各自独立安装都能工作;
如果它不在,闸会明说,并让那两道检查闸停用,而不是靠猜。
装完**重启宿主**,然后确认:
```
strict_gate_status action=status
```
### 安装**不会**做的事
- **没有构建步骤** —— 发布的 JavaScript 就是源码;不会跑 `prepare` 脚本。
- **没有依赖** —— `dependencies` 和 `peerDependencies` 都是空的。
- **`strict-check` 在加载期是可选的。** 没有它你仍然有"重复失败"那一道闸;
失去的是另外两道。
需要 Node.js 20 或更新版本。
## 开启关键路径闸
它出厂是**关闭**的,因为那份 glob 清单是关于**你的**代码的陈述,没有插件能替你猜。
在你设置之前,另外两道闸照常工作。
`dsh plugin add` 已经替你插入了插件行。把清单加进 profile 的 `cordis.patch.yml`
里那一行的 `config`:
```yaml
- insert:
- id: policy-strict-gate
name: '@dsh-external/dsh-policy-strict-gate'
config:
criticalPaths:
- 'src/core/**'
- 'src/**/*.spec.lean'
protectedTools: ['write', 'edit']
repeatThreshold: 3
repeatCooldown: 3
postWriteSyntax: true
maxDiagnostics: 5
```
用 `strict_gate_status action=targets path=<某个文件>` 确认某个路径**真的**被保护、
被哪条规则保护 —— glob 静默匹配失败、导致这道闸"看着开着其实什么都没做",
是它最常见的失效方式。
### 规格是怎么被找到的
对受保护目标 `src/core/x.ts`,闸按顺序找:
1. `src/core/x.spec.lean`
2. `src/core/x.ts.lean`
3. `src/core/specs/x.lean`
无论 glob 怎么写,`.lean` 文件**永远不会**被闸拒绝 —— 规格是满足这道闸的方式,
拦住写规格会让这道闸无法被满足。
## 自省
`strict_gate_status` 报告哪些闸处于活动状态、编译出来的 glob 规则,以及计数器:
| 计数器 | 含义 |
| --- | --- |
| `failures observed` | 观察到的已结算异常调用数 |
| `repeat notices sent` | 已送达的"你在重复"通知数 |
| `critical-path refusals` | 被 glob 闸拒绝的写入数 |
| `repair writes allowed through` | 因为属于"修复"而被放行的拒绝数 |
| `spec checks run by the gate` | 闸自己执行的 Lean 检查数 |
一个静默运行的策略,和一个坏掉的策略,是无法区分的 —— 这个工具的存在就是让差别可见。
## 开发
```bash
npm test # 72 个测试;集成测试会真的跑 Lean 内核
```
集成测试驱动的是**真实协作者**而不是桩,因为它们要防的故障是静默的:
两个插件对"已验证"的理解不一致,会让闸拒绝掉模型刚被告知"没问题"的写入。
`test/activation.test.js` 更进一步,驱动**真实的 cordis waterfall** ——
因为监听器挂在错误的事件名上、或者从不调用 `next()`,都不会抛任何异常,
只会让策略静默地不存在。
四个值得记住的 bug,全都是测试抓出来的而不是 review 看出来的:
- **状态用对象身份做键。** 状态袋原本是 `WeakMap`,键是 agent 对象。宿主对**同一个
agent 在不同钩子里传的是不同对象**,于是闸在一个袋子里记下"规格已通过",
却去另一个袋子找 —— 永久拒绝。现在按 session id 做键,并有容量上限。
- **规格的主语是用字符串手术推导的。** `x.spec.lean` 的词干是 `x`,
但真实文件是 `x.ts`;记下 `x` 等于把一个修复窗口开在**任何写入都匹配不到**的路径上。
现在主语是按目录解析出来的。
- **卸载时的未处理 Promise 拒绝。** 钩子是在异步续体里挂载的,所以一次重载会在协作者
import 完成前就 dispose 掉 fiber;此时注册 effect 会抛
`cannot create effect on inactive context`,而且抛在**游离 promise** 里。
那是"插件被卸载"引发的宿主级故障,现在通过询问框架"fiber 是否还活着"来防住。
- **重复失败通知无法挂到它所描述的那次调用上。** `tools/result` 没有决策通道,
所以通知会排队,在**下一次**调用的 `pre-execute` 上送达。这是对的,但很容易被误判成 bug。
### 送达路径,精确地说
| 通知产生于 | 送达方式 | 延迟 |
| --- | --- | --- |
| `tools/result`(一次失败) | 排队 → 下一次调用的 `pre-execute` | 一次调用 |
| `tools/post-execute`(一次写入、一次检查) | 该结果的 `additionalContexts` | 无 |
| 一次拒绝 | `deny` 的 reason 文本 | 立即 |
拒绝没有 `additionalContexts`,所以为那一步排队的东西会被追加到 reason 字符串里 ——
否则一次拒绝就会静默吞掉"由这次拒绝所参与的循环"产生的那条重复提醒。
| 文件 | 职责 |
| --- | --- |
| `lib/index.js` | 挂载、协作者发现、卸载保护 |
| `lib/gate.js` | 三道闸及其决策 |
| `lib/matching.js` | glob 编译、路径提取、规格主语解析 |
| `lib/signature.js` | 失败身份与修复提示目录 |
| `lib/notify.js` | 唯一能抵达模型下一步的通道 |
| `lib/status.js` | `strict_gate_status` |
## 许可
MIT