修好一次性戳記恆擋:InkStoneCo 專案版 hooks 與 plugin 雙掛載

inkstone/ISEP#122:leo 三次要部署被 prod-write-guard.sh 恆擋,蓋了戳記
也放不了行。根因不在 prod-write-guard.sh 本身,而在
InkStoneCo/.claude/settings.json 還登記著一份 2026-08-20 立 ISEP plugin
之前的舊 hook,跟 plugin 自己的 hooks.json 同時掛在同一個事件上——每個
PreToolUse 都跑了兩次,一次性戳記被第一份消耗掉,第二份永遠看到空戳記。

實查(在這台機器的樹上實數):InkStoneCo 專案版登記著 38 支 hook,
34 支跟 plugin 完全同名同用途(純殘骸)、3 支是已退役機制
(claim-verify-police.sh/subagent-claim-worksheet.sh/sdd-guard.sh,
ISEP#60/#91 早已裁定退役但專案版沒跟著退)、1 支(kv-write-guard.sh)
是專案版有、plugin 當時沒有的真閘。

這次:
- kv-write-guard.sh 原樣搬進本 repo(B 組,PreToolUse Write|Edit|MultiEdit),
  補齊那個真的缺口,附 8 條迴歸測試
- 新增 duplicate-hook-registration-guard.sh(E 組,SessionStart):
  往後任何專案的 settings.json 又跟 plugin 長出同名登記,開場就點名,
  不必再靠「戳記莫名其妙失效」才發現,附 8 條迴歸測試
- 兩支新閘:61→63 支、84→86 條註冊(在自己的樹上實數,不是加減推)

InkStoneCo 那邊的清理(37 支殘骸從 settings.json 與 .claude/hooks/ 移除,
kv-write-guard.sh 保留至本次升版並 /plugin update 之後)另外commit,
不在本 repo 範圍內。

驗證:重演過修好前的雙掛載(蓋一次戳記→專案版先吃掉→plugin 版恆擋,
EXIT=2),也驗過修好後單次執行放行(EXIT=0)與不蓋戳記仍被擋(EXIT=2)。

待總管定版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 00:24:32 +08:00
parent 35cc56dfc5
commit ee085c1854
7 changed files with 434 additions and 2 deletions
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# PreToolUse hookWrite|Edit|MultiEdit)— 長效資料不准寫進 KV,一律走 KBDB API
#
# 【portage 記錄,inkstone/ISEP#122】本檔 2026-08-25 立於
# `InkStoneCo/.claude/hooks/kv-write-guard.sh`(那時 ISEP 還沒吸收它),
# 2026-09-02 原樣搬進 ISEP——它是那次盤點揪出來的**唯一一支**「專案版有、
# plugin 沒有」的真閘(另外 3 支同名殘骸是已退役機制,直接刪掉,不搬)。
# 搬完之後 `InkStoneCo/.claude/hooks/kv-write-guard.sh` 就是廢棄複本,
# 待這裡升版、`/plugin update` 裝上之後即可從專案版與 settings.json 一併移除。
#
# 【leo 2026-08-25 立】原話(三句,一句比一句尖):
# 「已經禁止寫入 KV 了,這是太貴的資源,應該都去寫入 KBDB,到底寫入 KV 做什麼?」
# 「上次刪掉一堆東西,現在又寫入,**你沒有規範嗎**」
# 「**我強制禁止寫 SQL,要用 KBDB API,你就讓它繞過寫 KV 代替?**」
#
# ── 為什麼存在(2026-08-25 實查,這是本閘的正身)─────────────────────────
# 規則早就在:`system-dev/wiki/decisions-summary.md:133`
# 「**KV =暫存(cache / transient),不是長期真相源。**」
# 而且打過一場「KV 退休戰」(同檔 254 行,SUBMISSIONS_KV 併入那一役)。
#
# 🔴 **但 `.claude/hooks/` 底下跟 KV 有關的閘:0 支。**
# ⇒ 規則被讀到了,卻沒有任何機制驗證有沒有照做——與 2026-08-25 一整天挖出來的
# 四層 migration 缺陷、以及 08-10 抓到的 history-firstKBDB-firststage-first
# **完全同一個形狀**。
#
# 🔴 **更糟的是 leo 指出的因果**(總管用 git 查證屬實):
# 禁 SQL 的牆 2026-08-07
# app-system.ts 誕生 2026-08-24 ← 17 天之後,**從第一行就在寫 KV**
# 而 `kbdbFetch()` 裡的 KV 字樣是 **0** ⇒ 走 KBDB API 根本不碰 KV
# ⇒ 那些 KV 寫入**每一處都是繞過**,沒有一處經過知識庫。
# **牆立起來了,水從旁邊流走了。**
#
# ── 判準(封動作,不封措辭)──────────────────────────────────────────
# 新增一行「往 KV 寫入」的程式碼 ⇒ 擋一次,要求說明它為什麼不是長效資料
# 只是讀(.get.list ⇒ 放行
# 刪除既有的 KV 寫入(退休戰) ⇒ 放行(那正是我們要的方向)
#
# ── 豁免(留痕,不是後門)────────────────────────────────────────────
# 那一行尾端加 `kv-ok: <理由>`。
# 合法的理由只有一種形狀:**這筆資料本來就是短命的**(session、一次性 nonce、
# 有 TTL 的快取)。寫「暫時先這樣」「之後再搬」都不算——
# 那是把債留給下一個人,而 2026-08-25 證明了沒人會回來還。
set -uo pipefail
payload=$(cat)
read -r tool content file <<<"$(printf '%s' "$payload" | python3 -c "
import json,sys
try:
d=json.load(sys.stdin); ti=d.get('tool_input') or {}
# Write 用 contentEdit 用 new_stringMultiEdit 把每筆 new_string 串起來
parts=[ti.get('content') or '', ti.get('new_string') or '']
for e in (ti.get('edits') or []): parts.append(e.get('new_string') or '')
body='\n'.join(p for p in parts if p)
print(d.get('tool_name','') or 'x', len(body), ti.get('file_path','') or 'x')
sys.stderr.write(body)
except Exception:
print('x 0 x')
" 2>/tmp/.kv-guard-body)" || exit 0
body=$(cat /tmp/.kv-guard-body 2>/dev/null)
rm -f /tmp/.kv-guard-body
[ -z "$body" ] && exit 0
# 只管程式碼檔;文件、設定、測試裡提到 KV 不算違規
case "$file" in
*.ts|*.js|*.mjs|*.tsx|*.jsx) ;;
*) exit 0 ;;
esac
# 閘自己、與退休戰的測試檔放行(否則永遠改不動它)
case "$file" in
*kv-write-guard*|*.test.*|*/tests/*) exit 0 ;;
esac
# 命中的行:往 KV 綁定寫入,且**該行沒有 kv-ok 豁免**
# 判準是形狀不是名字:**全大寫的 binding 後面接 .put(.delete(**。
# 為什麼不列名字清單(第一版就是這樣寫壞的):名字會長出新的(下一個 KV 綁定叫什麼沒人知道),
# 而清單只認得已知的那幾個 ⇒ 新的 KV 從清單的縫隙走過去,本閘就變成裝飾。
# 全大寫識別字 .put/.delete 這個形狀,在 Workers 裡就是 KVD1 沒有 .putJS 物件用 .set)。
hits=$(printf '%s' "$body" | grep -nE '\b[A-Z][A-Z0-9_]{1,}\.(put|delete)\(' \
| grep -v 'kv-ok' || true)
[ -z "$hits" ] && exit 0
cat >&2 <<EOF
🚫 長效資料不准寫進 KV——一律走 KBDB APIleo 2026-08-25 立)
leo 原話:
「**我強制禁止寫 SQL,要用 KBDB API,你就讓它繞過寫 KV 代替?**」
「上次刪掉一堆東西,現在又寫入,**你沒有規範嗎**」
━━ 你這次要寫的 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
$(printf '%s' "$hits" | head -6 | sed 's/^/ /')
━━ 為什麼擋 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
規則早就在(\`system-dev/wiki/decisions-summary.md:133\`):
**KV =暫存(cache / transient),不是長期真相源。**
而 2026-08-25 實查:\`.claude/hooks/\` 底下跟 KV 有關的閘 **0 支**
⇒ 規則被讀到了,卻沒有機制驗證有沒有照做。本閘就是補那一格。
🔴 **並且**\`kbdbFetch()\` 裡的 KV 字樣是 **0**——走 KBDB API 根本不碰 KV。
所以「寫 KV」在這個系統裡**必定意味著繞過知識庫**,不是一種實作選擇。
禁 SQL 的牆 2026-08-07 立,而 app-system 2026-08-24 誕生就在寫 KV
——**牆立起來了,水從旁邊流走了。**
━━ 怎麼過 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
① **本來就該走 KBDB**(絕大多數)→ 改成 KBDB API。
新資料類型=seed 一列 template,資料展開成完整 record
**不准打包進 metadata_json**D91D93)。讀 \`arcrun-kbdb-guardrails\` skill。
② **這筆資料真的是短命的**(session/一次性 nonce/有 TTL 的快取)
→ 那一行尾端加 \`kv-ok: <為什麼它是短命的>\`,留痕放行。
③ **你在做的是退休戰(刪掉 KV 寫入)** → 本閘不擋刪除,只擋新增。
🔴 \`kv-ok: 暫時先這樣\` / \`之後再搬\` **不算理由**——那是把債留給下一個人,
而 2026-08-25 已經證明了沒人會回來還。
EOF
exit 2