Files
dsh_shenxian/dsh-server-docs/04-调整方案/73-让锁真正拦得住人-措辞修正与PreToolUse强制钩子.md
T

275 lines
20 KiB
Markdown
Raw Normal View History

# 73 · 让「锁」真正拦得住人:措辞修正 + PreToolUse 强制钩子
- 日期:2026-09-12
- 触发:用户「会话并行修改不是应该有锁的机制吗,**知道这个机制吗**」→ 复盘发现机制**存在**,但**当天被跳过两次**(含我自己的那一次)
- 结论一句话:机制不缺,缺的是**强制**。根因有两条 —— ① **没有任何强制入口**(`settings.json` 的 `hooks` 段实测为 `null`)② **guard 的输出语义有歧义**:「无全局锁」被读成「环境干净,可以开工」,而正确读法是「**你快去抢锁**」。本次两条都治:措辞修正(库内)+ **PreToolUse 强制钩子**(配置层,无锁直接拒写)。
- 状态:✅ **已实现并单点验证**;⚠️ **钩子待用户在 `/hooks` 面板审核启用**(外部改 `settings.json` 需审核才生效)
- 关联:档案 69(三把锁的建立)/`交接单/README.md` §一 · §三 0·13 · §六 /`scripts/handoff-guard.sh` · `op-lock.sh` /`scripts/lock-guard-hook.py`(本次新增)/决策方法 `dsh-decision-method` §4.4
---
## 一、问题:机制有,人没遵守
| 谁 | 情况 |
|---|---|
| **本会话** | 做 T06(实例回收自动恢复)时,只跑了 `handoff-guard.sh` 的**信息模式** —— 看到「(无单级锁)(无全局锁)」→ **读成"环境干净,可以动手",直接开工**。正确动作是:**没锁 ⇒ 下一步就是抢锁**。 |
| **另一个会话** | 改 `README.md` / `DEPLOY-本部署.md` / `skills/…/SKILL.md` 时也没占锁(当时三把锁全空)。 |
**定性(按 `dsh-decision-method` X3 的判据)**:冲突只认两个硬信号 —— **别人的占用锁** + **待推送清单里的未声明文件**。按此复核,**双方改的文件不重叠,没有产生实际损害** → 本次属**流程失效,不是事故**(避免过度反应)。但它确实在同一文件 `INDEX.md` 上留了残留(对方 1 行改动被我的 commit 收进去)。
## 二、根因(两条)
1. **没有强制入口** —— 三个位置的 `settings.json`(用户级 / 项目级 / 库内)**`hooks` 段全为 `null`**。锁完全靠"自觉"。
2. **`handoff-guard.sh` 的输出语义有歧义** —— 「✓ 无全局锁」在视觉上像"检查通过",而它的**真实含义是"尚未持锁"**。这是本会话踩坑的直接原因,**是我自己写的文案没说清**。
## 三、方案对比(按 `dsh-decision-method` §4.4 裁决顺序)
| 方案 | 内容 | 判定 | 理由 |
|---|---|---|---|
| **a. 措辞修正** | guard 无锁时明确输出「⚠ 不是可以开工,是**你快去抢**」+ 交接单补语义 | ✅ **采用** | 治第 2 条根因;库内改动、零风险 |
| **b. 只做 a** | —— | ❌ | 约定**拦不住不看的人**(今天已证) |
| **c. PreToolUse 强制钩子** | 写本库/代码库时**无锁直接 deny** + SessionStart 打印锁状态 | ✅ **采用** | 治第 1 条根因;`§4.4 第 3 条「能配置解决就不改码」`命中(hooks 是配置层,免 build 免重启) |
| **d. 拦 Bash** | 连 `bash` 写文件也拦 | ❌ **不做** | **会把自己锁死**(抢锁命令本身要能跑);Bash 写文件是少数派且破坏面可见(`git status`) |
| **e. 靠自觉** | —— | ❌ | 今天已证明不可靠 |
**十问自检(关键四条)**:影响谁 → **仅本库/本代码库**(路径前缀匹配,**对其他项目零影响**)|断多久 → **不中断任何服务**|回滚 → 删 `settings.json` 的 `hooks` 段或面板关闭|验收 → 四态命令可复现(见 §四)。
## 四、实现
### 4.1 措辞修正(库内)
| 文件 | 改动 |
|---|---|
| `scripts/handoff-guard.sh` | ① 【1】单级锁「✓ 无人占用」→ 追加「⚠ 这不是「可以开工」,是「**你快去抢**」」+ 抢锁命令<br>② 【1c】「✓ 无全局锁」→ 改为「⚠️ 这**不是「可以开工」,是「你快去抢」**」+ 命令 + 一句判据(「环境干净」≠「没人动过」)<br>③ 信息模式结论行追加同样的提示,并注明 2026-09-12 的实证 |
| `交接单/README.md` §一 | 两级锁小节补一条:「**「无锁」的正确读法 =「你快去抢」**;看到无锁 ⇒ 下一个动作就是 `--claim-exec`;**抢不到 = 有人在跑 = 停手**」 |
### 4.2 强制钩子(`scripts/lock-guard-hook.py`,新增)
| 事件 | 行为 |
|---|---|
| **PreToolUse**(matcher `Write\|Edit`) | 目标路径落在**受保护根**内 且 **`.exec-lock` 不存在** → 输出 `permissionDecision: "deny"`,理由含**可直接粘贴的抢锁命令** |
| **SessionStart** | 打印锁状态:已占用 → 显示占用者;空闲 → 提醒「空闲 ≠ 可以开工,动手前先抢锁」 |
**作用域(刻意收窄)**:只判两类根 —— `DSH_DOCS_ROOT`(默认 `E:\ProgramData\AI技能\aliyun-dsh-server\dsh-server-docs`)与 `DSH_CODE_REPO`(默认 `D:\github\dsh_shenxian`),可用 env 覆盖。**其余路径一律放行** → 对其他项目零影响。
**四态单点验证(用临时库模拟,真锁未被触碰)**:
| # | 场景 | 期望 | 实测 |
|---|---|---|---|
| ① | 受保护路径 + **无锁** | deny | ✅ 返回 deny + 完整理由 + 抢锁命令 |
| ② | 受保护路径 + **有锁** | 放行 | ✅ 空输出 |
| ③ | **非**受保护路径 | 放行 | ✅ 空输出 |
| ④ | SessionStart(有锁态) | 提示占用者 | ✅ 「🔐 全局执行锁【已被占用】:tester」 |
**异常安全**:payload 解析失败 / 脚本内部异常 → **一律放行**(`except: pass` + 退出码恒 0),绝不因为钩子自身问题阻断工作。
### 4.3 启用方式(**需用户操作**)
写入 `~/.workbuddy/settings.json` 的 `hooks` 段:
```json
{
"hooks": {
"PreToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "python \"E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs/scripts/lock-guard-hook.py\"", "timeout": 10 } ] } ],
"SessionStart":[ { "matcher": "startup", "hooks": [ { "type": "command", "command": "python \"E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs/scripts/lock-guard-hook.py\"", "timeout": 10 } ] } ]
}
}
```
⚠️ **外部修改 `settings.json` 需在 `/hooks` 面板审核后才生效**(本次由我写入配置,**启用动作留给用户**)。
## 五、为什么刻意不拦 Bash(重要)
1. **抢锁命令本身必须能跑** —— 否则「拿不到锁就不能改文件、不能改文件就抢不了锁」= 死锁;
2. Bash 写文件是少数派,且破坏面**可见**(`git status` / 对账能查出来);
3. 宁可留一个**显式的安全阀**(Bash 可绕),也不要一个可能导致死锁的强制层。
## 六、回滚
- **措辞**:`git revert <本次 commit>`。
- **钩子**:删 `settings.json` 的 `hooks` 段(或在 `/hooks` 面板关闭);`lock-guard-hook.py` 为独立文件,可留可删。
- 钩子**不加任何持久状态**,不写锁、不写日志。
## 七、遗留 / 边界
1. **归属判定未做**:钩子只判「**有没有锁**」,不判「锁是不是你的」(hook 拿不到会话名与锁 OWNER 的对应关系)。这已足够 —— 今天的失败模式正是"**两边都没抢锁**",钩子会**两边都拦**,而抢锁是原子的 → 串行化达成。
2. **Bash 是显式缺口**(见 §五),不修。
3. **钩子只在 WorkBuddy 层生效**:若换个工具(或用户手工编辑文件)绕过,仍以约定与台账为准。
4. 本次**未改动 `04-调整方案/` 既有档案**(遵守库内新约定「**档案只增不改**」);与此前档案冲突处一律以**本档 + 入口文件**为准。
---
## 八、补:让规则真正到达每个会话(用户追问后)
**用户追问**:「关键是**别的会话怎么知道遵循这套规则**,比如另外 5 个会话也在执行 dsh 服务相关任务」
**核实结果 —— 这是真缺口**:
| 载体 | 何时被会话看到 | 现状 |
|---|---|---|
| `~/.codebuddy/CODEBUDDY.md`(用户级) | 启动时注入 | ❌ **不存在**(`~/.codebuddy/rules/` 也没有) |
| **项目根 `CODEBUDDY.md`** | **启动时注入 → 覆盖本工作区所有会话** | ✅ 存在,且**已有 §6「并发纪律」**—— 但内容有漏(见下) |
| `.codebuddy/rules/*.md` | 条件注入(碰对应文件时) | ✅ 3 个(archive-doc / frontend-ui / server-ops)—— 均未提锁 |
| `dsh-server-docs/CODEBUDDY.md` | 条件注入(碰库内文件时) | ✅ 存在 —— 管文档库约定,未提锁 |
| Skills(`dsh-change-workflow` / `dsh-feature-first`) | **被触发才加载(不保证)** | 有「三把锁」章节,但**不保证到达** |
| SessionStart / PreToolUse hook | 会话启动 / 动手时 | 已配,**待审核** |
**缺口精确到两处 —— 都在唯一可靠的载体里**:
1. **§2 表格**:「**改任何文件之前** → 跑 `handoff-guard.sh`」—— 只写「**跑**」(检查),**没写「抢」**。
**这正是我当天翻车的同一处**("跑了检查、看到无锁、直接动手")。
2. **§6 并发纪律**:只有「`--claim <单号>`」(**细锁**,管"同一个单别被两人做"),
**漏了「全局执行锁 `--claim-exec`」** —— 而它才是"**多个会话同时干活**"场景的**唯一防线**
(细锁**管不住跨单撞车**:5 个会话各做各的单,细锁互不冲突,但都会改 `README`/`INDEX`/`03-路线图`)。
**已补齐**(改 `CODEBUDDY.md`,改前已手工备份):
| 位置 | 改为 |
|---|---|
| §2 该行 | 「**改任何文件之前(第一步,不是"检查"是"抢")**」+ 完整 `--claim-exec` 命令 + 「**抢不到 = 停手**」 |
| §6 | 新增「**三把锁,顺序固定**」表(粗/细/生产,含各自命令与职责)+ 「**「无锁」= 你快去抢**」读法 + 指向本档与 `lock-guard-hook.py` |
### ⚠️ 两条必须知道的限制
1. **`CODEBUDDY.md` 是启动时加载** → 本次改动**对已经在跑的会话无效**(要重启才重载)。
⇒ **对"已经在跑的 5 个会话",唯一即时生效的手段是**:
- **hook**(`PreToolUse` 会**硬拦**,无需会话配合);
- 或**用户直接在那些会话里说一句**「动 dsh 前先抢锁」(最直接,一轮见效)。
2. **项目根 `CODEBUDDY.md` 不在任何 git 仓库内**(项目根不是仓库,也不在文档库仓库里)→
**无版本保护**,只能靠手工备份(本次已留 `.bak-locksect-<ts>`)。
**建议把规则文件纳入版本管理**(否则它的改动既无锁保护、也无历史)—— 属"是否需要新造机制",
按 §4.4 留作独立决策,**本次未做**。
---
## 九、启用步骤(**用户在 WorkBuddy 里操作**)
### `/hooks` 面板在哪
**在 WorkBuddy 的对话输入框里输入 `/hooks`**(斜杠命令)→ 打开 hooks 配置面板。
官方文档原文:*"/hooks CLI panel for reviewing and approving any configuration changes before they take effect, ensuring safety."*
(外部改 `settings.json` **必须经此面板审核/批准**才会生效 —— 这是官方安全机制,不是我这边的限制。)
### 步骤
1. 输入框敲 `/hooks` 回车 → 面板打开
2. 找到 **`PreToolUse`**(matcher `Write|Edit`)与 **`SessionStart`**(matcher `startup`)
3. **审核** command 指向 `dsh-server-docs/scripts/lock-guard-hook.py` → **批准/启用**
4. 按 `Esc` 返回
### 启用前的加固(2026-09-12 实测)
官方文档明确:**Windows 上 hooks 强制走 Git Bash**(`cmd.exe` / PowerShell 不支持)→
命令里的裸 `python` **若不在 hook 的 PATH 里就会直接失败**。故把 command 改为**绝对路径**:
```
"D:/miniconda3/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs/scripts/lock-guard-hook.py"
```
**实测记录**:`env -i "D:/miniconda3/python.exe" "<中文路径>/lock-guard-hook.py"`(**干净环境、不继承 PATH**)
→ 退出码 0、行为正确(持锁态放行 / 无锁态 deny)。即:**绝对路径 + 中文路径均可用**。
### 启用后自检(第三方可复现)
| # | 操作 | 期望 |
|---|---|---|
| 1 | 无锁状态下,让任一会话改 `dsh-server-docs/INDEX.md` | **被拦**,并返回可直接粘贴的抢锁命令 |
| 2 | 先 `--claim-exec` 抢锁,再改同一文件 | 通过 |
| 3 | 改 `D:/其他项目/xx.md`(不在受保护根内) | 通过(**其他项目零影响**) |
| 4 | 新开会话 | 启动时打印锁状态提示 |
### 若不启用
库内约定仍然存在(项目根 `CODEBUDDY.md` §2/§6 + `dsh-server-docs/CODEBUDDY.md` + `交接单/README.md §一`),
但**只对"读过它"的会话有效** —— 这正是"另外 5 个会话不知道"的成因(见 §八)。
⇒ **对已在运行的会话,hook 是唯一不需要它们配合的到达方式**。
---
## 十、⚠️ 修正:桌面版没有 `/hooks` 面板(2026-09-12 17:45,用户实测反馈)
**用户反馈**:按 §九 输入 `/hooks` —— **什么也没有**。
**修正**:`/hooks` 是 **CodeBuddy CLI 的命令**,**WorkBuddy 桌面版没有这个面板**。
**§九 的操作步骤对桌面版不适用。** 成因如实记:那是照着 CLI 文档写的,**没在桌面版核实** ——
教训:**跨端能力先在目标端实测,再写进文档**(同 `§4.3 验收口径分级`:L1 推断不能当 L5)。
### 桌面版的正确加载方式(第三方探针实测记录)
> ① ✅ **2026-09-13 实测确认本行原文正确**:hook 命令**在会话启动时快照**,改完 `settings.json` **对已在跑的会话无效**(须「完全重启」或新开会话)。⚠️ 当天一度被误写成「每次调用现读」、并据此宣布本行作废,**已纠正**(成因见文末「修正」节)。**路径失配时 fail-closed** —— hook 打不开脚本 → 报错 → 该机所有会话的 Write/Edit 全被拒。别把「被拒」当成"锁被别人占了",先去核对 hook 里的绝对路径。
> ② **关窗 ≠ 退出** —— WorkBuddy 有常驻能力,点关闭按钮只是关窗口、**进程还活着**,必须**彻底退出**。
**本机实测印证**:当前有 **5 个 `WorkBuddy.exe` 进程**在跑(见 17:42 的 `tasklist` 输出)→ "关窗不退出"属实。
### 正确步骤(Windows)
1. **彻底退出 WorkBuddy**:托盘图标右键 → 退出;或任务管理器结束**所有** `WorkBuddy.exe`(含后台进程)
2. **重新启动** WorkBuddy
3. **验证是否生效** —— 本次为此新增了**自证手段**:hook 会写低频日志到
`E:\ProgramData\AI技能\aliyun-dsh-server\.workbuddy\lock-hook.log`(可用 `DSH_LOCK_HOOK_LOG` 覆盖)
```
2026-09-12 17:42:17 SessionStart 空闲 source=startup
2026-09-12 17:42:18 PreToolUse-deny Edit D:/.../a.md
```
| 观察 | 含义 |
|---|---|
| 重启后出现 **`SessionStart` 行** | ✅ 配置已加载、hook 已生效 |
| 之后无锁改本库被拦 → 多一行 **`PreToolUse-deny`** | ✅ 强制层工作正常 |
| **两行都没有** | ❌ 未生效(回来反馈,改走下一步) |
日志**只记 SessionStart 与 deny 两类**(低频),不记录每次写操作,避免刷屏。
### 若桌面版最终不支持 hooks
则本机制退化为「**约定 + guard**」,届时需要另找"到达方式"。按 `dsh-decision-method §4.4`,
届时候选顺序为:① 让 `--claim-exec` 成为**每次动手前的固定动作**(写进 `CODEBUDDY.md`,已做)
② 把检查做进**更早的钩子点**(若桌面版有其它可挂的事件)③ 由**用户在每个会话里说一句**(最直接但需人工)。
---
## 十一、⚠️ 修正:禁止「人工删锁 / 接管」(R9,2026-09-12 用户明令)
**用户原话**:「**严格禁止这类操作必须!!!!!!记录到红线中**」,并引用本库当时仍在流传的一句操作指引:
> 接管 —— 按交接单的流程**人工删锁**(`rm -rf 交接单/.exec-lock`)由我接手。但这等于判定那个会话已死/已完成,风险是它其实还在跑 → 撞车,所以我需要你明确点头。
**定性**:这句话**本身就暴露了机制缺陷** —— 它把"接管"的判据交给**人的感觉**("疑似已死"),而平台**没有心跳机制**,AI **没有任何客观判据**能确认对方是否还在跑。删锁 = 在**无法验证**的前提下单方面撤销互斥 → 一旦对方仍在跑,就**退回「两个会话同时改同一批文件」**,而后者正是三把锁存在的唯一理由。
**根因不是人不懂,是文档在教**:当时全库有 **4 处**写着"人工删锁",连 `handoff-guard.sh` 自己的抢锁失败提示里都在教。
**处置(2026-09-12 18:50 本库,持全局锁下完成)**:
| # | 位置 | 改动 |
|---|---|---|
| 1 | 项目根 `CODEBUDDY.md` §3 | 新增红线 **R9**(另一并行会话先行落地,已核对内容一致) |
| 2 | 项目根 `CODEBUDDY.md` §6 | 增「⛔ 抢不到锁就是终点,不是待办」一条 |
| 3 | `dsh-server-docs/CODEBUDDY.md` | 锁小节增 R9 指针 + 「guard 输出不构成授权」 |
| 4 | `交接单/README.md` §三 13 条 | 「**接管**:…人工删锁…」→ **改为禁止**,原文标注「已作废」 |
| 5 | `交接单/README.md` §三 11 条 | 「接管必须无损」加限定:**接管动作不得由 AI 自行发起** |
| 6 | `scripts/handoff-guard.sh`(2 处:抢锁失败提示 + 信息模式提示) | 删掉"人工删锁 / 接管",改为 **R9 禁止 + 停手 + 报告用户** |
| 7 | `skills/dsh-change-workflow/SKILL.md`(工作副本 + 归档副本,两处 md5 需一致) | 三把锁章节增 R9 条目 |
**R9 唯一合规路径(判据)**:
1. 抢不到锁 ⇒ **停手**(不重试、不"抢一下看看"、不绕道);
2. **报告用户** —— 说清「谁占着 / 占多久 / 我卡在哪」,而不是自己找理由;
3. **锁的处置权只属于用户本人** —— 要撤也只能用户自己动手,AI 不得代判断;
4. 仅在「**用户已点头 + 用户自己撤锁之后**」,才读 `OWNER` + 台账进度**续做**(T03 的 A→B 那套只适用于这种情形)。
**验证**:`bash -n scripts/handoff-guard.sh` → 语法 OK;全库 `grep -rn 人工删锁` → **只剩"禁止性条款 / 已作废说明",无任何还在教人删锁的位置**。
**对本档上文的影响**:§七 第 1 条(钩子只判"有没有锁"、**不判"锁是不是你的"**)在 R9 下更关键 ——
无锁时 hook 两边都拦(天然串行化),但**有锁时它不判归属**,所以"这是别人的锁"仍**只能靠 guard + R9 拦人**,
这正是本次把措辞从"可以删锁接管"改成"停手报告"的原因。
---
## 修正(2026-09-13):hook 路径随工作区迁移
工作区 2026-09-13 由 `D:\AI技能\aliyun-dsh-server` 迁至 **`E:\ProgramData\AI技能\aliyun-dsh-server`**;本档上文的 hook 命令片段与 `DSH_DOCS_ROOT` 默认值**已同步改为新路径**(实体在 `~/.workbuddy/settings.json` 与 `dsh-server-docs/scripts/lock-guard-hook.py`)。代码仓 `D:\github\dsh_shenxian` 未变动。
**迁移时踩到的坑(值得记住)**:工作区一搬,`settings.json` 里 hooks 的**绝对路径**立刻失配 → `python` 打不开脚本 → **hook 报错 → 该机所有会话的 Write/Edit 全被拒**(2026-09-13 实际发生,连"改 `settings.json` 本身"都被拦)。
**语义定论(2026-09-13,含一次自我纠错)**:**hook 命令是「会话启动时快照」** —— 本会话 06:47 启动 → 06:55 把 `settings.json` 的 hook 路径改成 E: → **07:05 拆掉临时目录联接后,Write/Edit 报的仍是旧路径 `D:/AI技能/...`** ⇒ 改配置**对已在跑的会话无效**。
⚠️ 期间一度写成「每次调用现读、无需重启」并据此宣布「目录联接多余」—— 实为**联接在 06:53–07:05 存在**,把「改完就能写」伪装成了现读(同机另一会话也据此得出同样错论,可见**这就是该误判的成因**)。
**正确处置**:改完 hook 路径 → **完全重启 WorkBuddy**(关窗 ≠ 退出)或**新开会话**;重启前该机所有会话的写操作一直被拒。**应急兜底**:本钩子**不拦 Bash**(有意留的安全阀)⇒ 可用 shell 写文件过渡(09-13 实际走通)。
(临时目录联接已按用户要求移除;`D:/AI技能` 现无任何实体目录或联接。)