# 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】单级锁「✓ 无人占用」→ 追加「⚠ 这不是「可以开工」,是「**你快去抢**」」+ 抢锁命令
② 【1c】「✓ 无全局锁」→ 改为「⚠️ 这**不是「可以开工」,是「你快去抢」**」+ 命令 + 一句判据(「环境干净」≠「没人动过」)
③ 信息模式结论行追加同样的提示,并注明 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-`)。 **建议把规则文件纳入版本管理**(否则它的改动既无锁保护、也无历史)—— 属"是否需要新造机制", 按 §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技能` 现无任何实体目录或联接。)