From 87186585d60beaebbc861c50429638d18caf9e74 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 20 Aug 2026 21:03:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20sdd-guard.sh=20=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=A4=B1=E6=95=97=E4=BB=8D=E7=85=A7=E6=93=8B?= =?UTF-8?q?=E3=80=81=E4=B8=94=E8=A8=8A=E6=81=AF=E6=B4=A9=E6=BC=8F=20/nonex?= =?UTF-8?q?istent=E3=80=8D=EF=BC=88InkStoneCo#22=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 症狀(總管 2026-08-12 實撞):寫暫存腳本進 scratchpad (/private/tmp/.../scratchpad/foo.py)被 sdd-guard.sh 攔下,訊息印出字面的 「/nonexistent/3-specs/ 下找不到任何 SDD」。 兩個洞: - 洞 A:scratchpad 不在任何 git repo 裡,卻被當成「repo 裡的 code 變動」誤判 需要 SDD。改成先問 path_in_git_worktree()(見 hooks/lib/path-resolve.sh): 不在任何 git repo 裡 → SDD 天生管不到,直接放行,不必先猜專案根。 這個檢查放在 $_root 的 case 分岔之前、對兩邊都適用——第一版只放進「專案外」 分支,被本次新增的 hooks/tests/sdd-guard.test.sh 抓到一個不對稱漏洞(cwd 剛好 等於 scratchpad 祖先目錄時會漏判),改成統一檢查後修掉。 - 洞 B:舊版用內部 sentinel `/nonexistent/3-specs` 重用既有的擋下路徑,但這個 假路徑被直接印進使用者看到的訊息。改用 RESOLVED 旗標記解析成不成功,訊息 改用人話描述原因,不洩漏假路徑。 fail-closed / fail-open 的判準(票上明確要求回答,不能各憑運氣): 真的落在某個 git repo 裡、但那個 repo 沒有 3-specs(或沒有 active SDD)→ 仍然 fail-closed(擋)。理由:這道閘存在的目的就是防止「沒有 SDD 卻能動 code」,把「判斷不出來」直接放行,等於把環境跑歪(cwd 被切走、 $CLAUDE_PROJECT_DIR 沒設)悄悄變成「這道閘關掉了、且沒人知道」——silent bypass 的代價遠高於多打一次確認。#22 紅線亦明寫「不要把閘改成解析失敗就 放行」。 順手修的殘留 cwd 依賴:SPECS_DIR 的預設值原本是相對路徑 「system-dev/docs/3-specs」,專案內迴圈找不到時會被拿去跟 hook 執行當下的 cwd 兜;改成絕對路徑 $_root/system-dev/docs/3-specs。 同時修 ADR-0001(ISEP 自建 wiki):標題與內文原本會讓人誤解成「ISEP plugin 裝到哪個 repo,就會在那裡自建一份 wiki」,但實際查證(marketplace.json 只宣告 hooks/commands/skills、README 明文排除 wiki/docs、hooks 一律用 ${CLAUDE_PLUGIN_ROOT} 讀自己不是寫別處)並非如此——那份 wiki 只是 ISEP 這個 repo自己的開發歷史,跟裝 plugin 無關。唯一真的會在某 repo 建 wiki 的 scripts/install.sh 是 system-dev-template 的獨立安裝器殘留,要手動執行, 作用對象是 cwd 不是「plugin 裝到的地方」——這多半是誤解的真正來源,已在 ADR 的「常見誤解」段說明。 驗證: - 造出 08-12 原始事故情境(cwd=InkStoneCo、CLAUDE_PROJECT_DIR 未設、寫 scratchpad),修前擋(印 /nonexistent)、修後放行——實測輸出見票留言。 - 造出「真的在 git repo 裡但沒有 3-specs」情境,修後仍擋、訊息不含 /nonexistent。 - 新增 hooks/tests/sdd-guard.test.sh:8 案例全過(洞 A/洞 B/fail-open 陷阱/單一活性違反/恰好一份 active/改文件放行)。 - 既有六套 scripts/test-*.sh 全過,無退步。 Co-Authored-By: Claude Sonnet 5 --- hooks/lib/path-resolve.sh | 47 ++++++++++ hooks/sdd-guard.sh | 91 ++++++++++++++++--- hooks/tests/sdd-guard.test.sh | 90 ++++++++++++++++++ .../decisions/ADR-0001-isep-自建wiki.md | 78 +++++++++++++--- 4 files changed, 281 insertions(+), 25 deletions(-) create mode 100644 hooks/lib/path-resolve.sh create mode 100755 hooks/tests/sdd-guard.test.sh diff --git a/hooks/lib/path-resolve.sh b/hooks/lib/path-resolve.sh new file mode 100644 index 0000000..ee5f13a --- /dev/null +++ b/hooks/lib/path-resolve.sh @@ -0,0 +1,47 @@ +# hooks/lib/path-resolve.sh — 共用:判斷一個檔案路徑「歸不歸某個 git repo 管」。 +# 不是獨立掛的閘(沒進 hooks.json),給其他 PreToolUse 閘 `source` 用的函式庫。 +# +# 背景(inkstone/InkStoneCo#22):sdd-guard.sh 曾經把 scratchpad 暫存檔 +# (`/private/tmp/.../scratchpad/foo.py`)誤判成「repo 裡的 code 變動」而擋下—— +# 因為它只會「猜專案根($CLAUDE_PROJECT_DIR 或 cwd)+往上找 3-specs」, +# 猜錯或猜不到時,找不到 3-specs 就一律當「找不到 SDD」擋下,連「這條路徑根本不在 +# 任何 repo 裡、SDD 這件事天生管不到它」都沒判斷過。 +# +# path_in_git_worktree 提供一個不必先猜對專案根的判法:直接問 git +# 「這個路徑在不在某個 repo 的工作樹裡」。不必窮舉暫存區的路徑關鍵字(/tmp、scratchpad…), +# 任何真的不在 git repo 裡的路徑,一律視同「這是暫存/非受管檔案」。 +# +# 同一個 `${CLAUDE_PROJECT_DIR:-$(pwd)}` 猜根目錄寫法,實測(2026-08-20)還出現在: +# component-guard.sh、factory-idle-guard.sh、github-contact-guard.sh、 +# history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、 +# not-my-branch-guard.sh、release-tag-guard.sh、skill-deploy-drift-guard.sh、 +# stage-before-prod-guard.sh、unpushed-police.sh、wiki-first-police.sh。 +# 另有 claim-verify-police.sh、subagent-claim-worksheet.sh、empty-handed-stop-guard.sh、 +# issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`(無 `:-` fallback)—— +# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。 +# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地, +# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。 +# +# 用法: +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh" +# if ! path_in_git_worktree "$FILE_PATH"; then +# # 不在任何 git repo 裡 ⇒ 這支閘通常管不到,多半該放行 +# fi + +# path_in_git_worktree +# 回傳 0=這個路徑落在某個 git 工作樹裡;1=不在任何 git repo 裡(含路徑本身不存在的情況)。 +# 做法:從路徑的目錄部分開始,往上找到「第一個真的存在的祖先目錄」, +# 對那個目錄問 `git rev-parse --is-inside-work-tree`。 +# 為什麼要往上找存在的祖先,不能直接對 dirname 問: +# 要在 repo 裡建一個還沒建立的子目錄下的新檔案時,dirname 也不存在, +# 若不往上找,`git -C <不存在的目錄>` 會直接失敗 ⇒ 誤判成「不在 repo 裡」 +# ⇒ 放行了本來該擋的東西(fail-open 的洞,不是這支函式該製造的)。 +path_in_git_worktree() { + local p="$1" d + d=$(dirname -- "$p") + while [ ! -d "$d" ] && [ "$d" != "/" ]; do + d=$(dirname -- "$d") + done + [ -d "$d" ] || return 1 + git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 +} diff --git a/hooks/sdd-guard.sh b/hooks/sdd-guard.sh index 4a609d3..f97827c 100755 --- a/hooks/sdd-guard.sh +++ b/hooks/sdd-guard.sh @@ -1,4 +1,11 @@ #!/bin/bash +# 管什麼: Write/Edit 動 code 檔(.ts/.py/.go…)前,要不要有對應的一份 status: active SDD(design.md)。 +# 為什麼: SDD 生命週期鐵律——動 code 前必須有規格可對,且整個 repo 同一時刻只准一份 active。 +# 把「動手前先讀 SDD」從只能靠人記,升級成機器擋(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。 +# 誤觸時怎麼關: 改文件/測試檔/3-specs 自己一律放行(下方 case 已排除);不在任何 git repo +# 裡的路徑(scratchpad、/tmp 暫存檔)一律放行,SDD 管不到它們。真的要臨時豁免 +# 一次小改動,說明範圍後由人手動放行——這支閘不設「一行關掉」的旗標。 +# # PreToolUse hook — 動 code 前檢查 SDD + 單一活性 SDD 鐵律(issue #6) # wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。 # 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md @@ -18,6 +25,8 @@ set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh" + INPUT=$(cat) # 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。 @@ -38,8 +47,51 @@ fi # ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。 # ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…) # 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。 -SPECS_DIR="system-dev/docs/3-specs" +# +# 🔴 2026-08-20 修(inkstone/InkStoneCo#22):上面這套邏輯有兩個洞,都是總管 08-12 實撞的: +# +# 洞 A — scratchpad 暫存檔被當成「code 變動」: +# `/private/tmp/.../scratchpad/foo.py` 不在 `$_root` 底下、往上找不到 3-specs, +# 於是走到「找不到 SDD」擋下路徑——但 scratchpad 是 session 專用暫存區,從不進版控, +# SDD 管的是 repo 裡的產品程式碼,管不到它。**先問「這條路徑到底在不在某個 git repo +# 裡」(`path_in_git_worktree`,見 lib/path-resolve.sh),不在 ⇒ 這道閘天生管不到 +# ⇒ 直接放行**,不必先繞去猜專案根、再證明找不到才擋。 +# 用「有沒有 .git 可尋」判斷,比列舉路徑關鍵字(/tmp、scratchpad…)更穩: +# 不必窮舉每一種暫存區的命名法,任何真的不在 repo 裡的路徑都一視同仁。 +# +# 洞 B — 訊息裡印出字面的 `/nonexistent`: +# 舊版用 `/nonexistent/3-specs` 當內部 sentinel,讓「找不到 SDD」的既有擋下路徑可以 +# 重用;但這個 sentinel 值被直接印進使用者看到的訊息,讀起來像是「這支腳本認真去 +# /nonexistent 這個地方找過」——具體、卻是假的。改成用 RESOLVED 旗標記「解析成不成功」, +# 擋下訊息另外用人話描述「為什麼找不到」,不洩漏內部實作用的假路徑。 +# +# ⚠️ 洞 A/B 都不改變「真的解析失敗時」的判定方向:路徑確實落在某個 git repo 裡, +# 但那個 repo 沒有 3-specs(或裡面沒有 active SDD)→ 仍然 **fail-closed**(擋,不放行)。 +# 為什麼是 fail-closed、不是 fail-open:這道閘存在的目的就是防止「沒有 SDD 卻能動 +# code」,若把「判斷不出來」直接放行,等於把一次環境跑歪(cwd 被切走、 +# `$CLAUDE_PROJECT_DIR` 沒設、worktree 缺 3-specs…)悄悄變成「這道閘關掉了,而且沒有 +# 任何人被告知」——silent bypass 的代價遠高於「多打一次確認」。#22 的紅線也明寫 +# 「不要把閘改成『解析失敗就放行』——那是把誤判換成漏判」。 +# 洞 A 的修法,套用在 case 分岔**之前**:不管 `$_root` 猜不猜得對, +# 先問「這條路徑到底在不在某個 git repo 裡」。不在 ⇒ SDD 這道閘天生管不到,直接放行。 +# 🔴 這個檢查故意放在 `$FILE_PATH` 是否落在 `$_root` 底下的判斷之前、且對兩邊都適用 +# (不是只套用在「專案外」那個分支):第一版只把它放進「專案外」分支,結果測試 +# (hooks/tests/sdd-guard.test.sh)就抓到一個不對稱漏洞——當 `$_root` 剛好等於 +# scratchpad 的某層祖先目錄(例如 hook 被叫用時 cwd 已經跑到 /private/tmp 底下、 +# `$CLAUDE_PROJECT_DIR` 也沒設),scratchpad 路徑會被判成「在 `$_root` 底下」而 +# 走進另一條完全沒做 git-repo 檢查的路徑,同一個誤判换個路徑重新出現。 +# 改成「先問是不是在 git repo 裡,不管路徑跟 `$_root` 的關係」就沒有這個不對稱。 +if ! path_in_git_worktree "$FILE_PATH"; then + exit 0 +fi + _root="${CLAUDE_PROJECT_DIR:-$(pwd)}" +# 預設值一律絕對路徑(不留相對路徑「system-dev/docs/3-specs」退回目前 cwd 的洞—— +# 舊版這裡曾經是相對路徑,若專案內迴圈找不到就會被拿去跟 hook 執行當下的 cwd 兜, +# cwd 湊巧有同名目錄就會判斷到不相干的資料)。 +SPECS_DIR="$_root/system-dev/docs/3-specs" +RESOLVED=1 # 1=SPECS_DIR 是有意義的答案;0=真的解析失敗,SPECS_DIR 留空,訊息另外講原因 + case "$FILE_PATH" in "$_root"/*) _d=$(dirname "$FILE_PATH") @@ -53,16 +105,17 @@ case "$FILE_PATH" in done ;; *) - # 專案外的路徑:**不可退回頂層 SDD 就放行**,否則原本擋得住的會變成擋不住。 - # 但 **git worktree 是正當工作區**(本專案大量使用 /private/tmp 下的 worktree 出貨), - # 它自己就帶著該 repo 的 system-dev/docs/3-specs ⇒ 一樣往上找,找得到就認。 - # 找不到才指向不存在目錄 ⇒ 走原有的「找不到 SDD」擋下路徑。 - # (2026-08-02:第一版忘了 worktree,把正當的出貨工作區也擋掉。) - SPECS_DIR="/nonexistent/3-specs" + # 專案外的路徑:`$_root` 猜錯,或這條路徑本來就不屬於目前的 `$_root`。 + # 已知落在某個 git repo 裡(上面剛確認過):往上找它自己的 3-specs。 + # **不可退回 `$_root` 的 3-specs 就放行**——那會把「這個 repo 沒有 SDD」 + # 誤判成「用別的 repo 的 SDD 蒙混過關」,原本擋得住的會變成擋不住。 + SPECS_DIR="" + RESOLVED=0 _d=$(dirname "$FILE_PATH") while [ "$_d" != "/" ] && [ -n "$_d" ]; do if [ -d "$_d/system-dev/docs/3-specs" ]; then SPECS_DIR="$_d/system-dev/docs/3-specs" + RESOLVED=1 break fi _d=$(dirname "$_d") @@ -70,6 +123,18 @@ case "$FILE_PATH" in ;; esac +# 給訊息用的人話描述:解析成功就印真路徑,失敗就誠實講「為什麼」,不印假路徑 +# (洞 B 的修法——舊版這裡印的是內部 sentinel `/nonexistent/3-specs`)。 +if [ "$RESOLVED" -eq 1 ]; then + SPECS_DIR_DESC="${SPECS_DIR}/" + SPECS_NOT_FOUND_MSG="${SPECS_DIR}/ 下找不到任何 SDD" + SPECS_NOT_ACTIVE_MSG="${SPECS_DIR}/ 下沒有任何 status: active 的 SDD" +else + SPECS_DIR_DESC="" + SPECS_NOT_FOUND_MSG="這條路徑所在的 git repo 裡找不到 system-dev/docs/3-specs,也就沒有任何 SDD 可對(或這支閘沒能定位到正確的專案根——這是 fail-closed:寧可誤擋也不悄悄放行,見檔頭註解)" + SPECS_NOT_ACTIVE_MSG="$SPECS_NOT_FOUND_MSG" +fi + # ── 統計 active / frontmatter ────────────────────── # 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數—— # 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。 @@ -77,7 +142,7 @@ esac ACTIVE_COUNT=0 FM_COUNT=0 ACTIVE_LIST="" -if [ -d "$SPECS_DIR" ]; then +if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then while IFS= read -r f; do [ -n "$f" ] || continue HEAD10=$(head -10 "$f" 2>/dev/null || true) @@ -121,20 +186,20 @@ esac # 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。 if [ "$FM_COUNT" -eq 0 ]; then SDD_COUNT=0 - if [ -d "$SPECS_DIR" ]; then + if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ') fi if [ "$SDD_COUNT" -eq 0 ]; then cat >&2 <&2 + echo "📋 提醒:${SPECS_DIR_DESC} 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2 exit 0 fi # ── 新行為:寫 code 檔需「恰好 1 份」active SDD ── if [ "$ACTIVE_COUNT" -eq 0 ]; then cat >&2 < -> JSON on stdout + python3 -c "import json,sys;print(json.dumps({'tool_name':'Write','tool_input':{'file_path':sys.argv[1],'content':'x'}}))" "$1" +} + +t() { # t <期望 exit code> <說明> [額外檢查關鍵字] + local want="$1" desc="$2" path="$3" must_not_contain="${4:-}" + local out rc + out=$(mk "$path" | "$HOOK" 2>&1) + rc=$? + local ok=1 + [ "$rc" -eq "$want" ] || ok=0 + if [ -n "$must_not_contain" ] && printf '%s' "$out" | grep -qF "$must_not_contain"; then + ok=0 + fi + if [ "$ok" -eq 1 ]; then + echo " ✅ $desc"; PASS=$((PASS+1)) + else + echo " ❌ $desc —— 期望 exit=$want,實得 exit=$rc" + [ -n "$must_not_contain" ] && echo " (且訊息不該含「$must_not_contain」)" + echo " 輸出:$out" | head -3 + FAIL=$((FAIL+1)) + fi +} + +# ── 準備:一個真的沒有 3-specs 的 git repo(模擬「真的解析失敗」)── +REPO_NO_SDD="$TMP/repo-no-sdd" +mkdir -p "$REPO_NO_SDD/src" +git init -q "$REPO_NO_SDD" + +# ── 準備:一個有 1 份 active SDD 的 git repo ── +REPO_ONE_ACTIVE="$TMP/repo-one-active" +mkdir -p "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x" "$REPO_ONE_ACTIVE/src" +git init -q "$REPO_ONE_ACTIVE" +printf -- '---\nstatus: active\n---\n# X\n' > "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x/design.md" + +# ── 準備:一個有 2 份 active SDD 的 git repo(單一活性違反)── +REPO_MULTI="$TMP/repo-multi-active" +mkdir -p "$REPO_MULTI/system-dev/docs/3-specs/a" "$REPO_MULTI/system-dev/docs/3-specs/b" "$REPO_MULTI/src" +git init -q "$REPO_MULTI" +printf -- '---\nstatus: active\n---\n# A\n' > "$REPO_MULTI/system-dev/docs/3-specs/a/design.md" +printf -- '---\nstatus: active\n---\n# B\n' > "$REPO_MULTI/system-dev/docs/3-specs/b/design.md" + +# ── 準備:scratchpad 風格的暫存區(不在任何 git repo 裡)── +SCRATCH="$TMP/private/tmp/claude-fake-session/scratchpad" +mkdir -p "$SCRATCH" + +# 讓 $_root(CLAUDE_PROJECT_DIR 或 pwd)刻意跟這些假 repo 對不上, +# 逼所有案例都走「專案外的路徑」那個分支——這正是 #22 實撞的情境(cwd 跑歪/ +# CLAUDE_PROJECT_DIR 沒設,路徑不落在 $_root 底下)。 +unset CLAUDE_PROJECT_DIR +cd "$TMP" + +echo "── 洞 A:不在任何 git repo 裡的路徑,SDD 管不到,該放行 ──" +t 0 "scratchpad 暫存 .py(本票原始事故)" "$SCRATCH/fix-project-settings.py" +t 0 "scratchpad 巢狀更深" "$SCRATCH/nested/deep/tmp.js" + +echo "── 洞 B:真的解析失敗(repo 存在但沒有 3-specs)仍要 fail-closed,但訊息不准洩漏內部假路徑 ──" +t 2 "真 repo 沒有 3-specs → 仍擋" "$REPO_NO_SDD/src/foo.py" +t 2 "上面那筆的訊息不准出現 /nonexistent" "$REPO_NO_SDD/src/foo.py" "/nonexistent" + +echo "── fail-open 陷阱:新檔案要建在還沒建立的子目錄下,不可被誤判成「不在 repo 裡」──" +t 2 "真 repo、目標子目錄還沒建立 → 仍擋(不能因為 dirname 不存在就放行)" "$REPO_NO_SDD/brand-new/not-yet/bar.py" + +echo "── 既有行為不能退步 ──" +t 0 "只有 1 份 active SDD,改 code 檔 → 放行" "$REPO_ONE_ACTIVE/src/x.py" +t 2 "2 份 active SDD(單一活性違反)→ 擋" "$REPO_MULTI/src/x.py" +t 0 "改 .md 文件(非 code 檔)→ 放行,即使找不到 3-specs" "$REPO_NO_SDD/README.md" + +echo +echo "結果:通過 $PASS / 失敗 $FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/system-dev/docs/2-architecture/decisions/ADR-0001-isep-自建wiki.md b/system-dev/docs/2-architecture/decisions/ADR-0001-isep-自建wiki.md index 8098752..173dc1a 100644 --- a/system-dev/docs/2-architecture/decisions/ADR-0001-isep-自建wiki.md +++ b/system-dev/docs/2-architecture/decisions/ADR-0001-isep-自建wiki.md @@ -1,21 +1,40 @@ -# ADR-0001:ISEP 自建 wiki,不繼承 InkStoneCo 的內容 +# ADR-0001:ISEP 這個 repo 自己維護一份 wiki(記 ISEP 自己的事,跟「裝 plugin」無關) -- **狀態**:已採納 +- **狀態**:已採納(決策未變,本次僅修訂標題與內文的誤導處,見文末「常見誤解」) - **日期**:2026-08-20 -- **票**:`inkstone/ISEP#3` +- **票**:`inkstone/ISEP#3`(原案)、`inkstone/InkStoneCo#22`(本次修訂) + +## 先講結論,避免讀到一半就會錯意 + +本 ADR 談的「wiki」,是 **`inkstone/ISEP` 這個 git repo 自己的開發歷史**—— +跟其他任何 repo(`InkStoneCo`、`arcrun`…)在自己 repo 底下放一份 +`system-dev/wiki/` 記自己的事,是同一種、完全獨立的東西。 + +🔴 **這件事不會發生**:把 ISEP 這個 Claude Code plugin「裝」到別的 repo(本機或雲端的 +Claude Code session 啟用這個 plugin),**不會在那個 repo 裡多寫出任何檔案**, +更不會在那裡生出一份 `system-dev/wiki/`。「plugin 裝到哪、wiki 就跟著長在哪, +所以每個 repo 都會有兩份」是誤讀——見文末「常見誤解」段的查證。 ## 背景 -ISEP 是獨立 repo,裝的是「環境」(hooks/commands/skills/scripts),本來刻意不放 -「知識」(wiki/docs/`_archive`)——見 `README.md`「裝什麼」段。但接手 ISEP 的 session -(含雲端)若要查「這裡的決定、踩過的坑、現在什麼狀態」,過去只能回頭 clone InkStoneCo -頂層知識庫,多一層跳轉、且 ISEP 自己的事並不天然屬於 InkStoneCo 頂層(那裡管的是跨專案決策)。 +ISEP 是獨立 repo,對外扮演的角色是「環境」(hooks/commands/skills/scripts, +`README.md`「裝什麼」段列了清單,白紙黑字排除 `wiki/`/`docs/`/`_archive/`—— +那些是「知識」不是「環境」)。但 ISEP**自己也是一個在持續開發的 repo**:它有自己的 +決策(例如這份 ADR 本身)、踩過的坑、現在的狀態。過去要查「ISEP 這裡為什麼這樣設計、 +之前討論到哪」,只能回頭 clone InkStoneCo 頂層知識庫,多一層跳轉,而且 ISEP 自己的 +開發細節並不天然屬於 InkStoneCo 頂層(那裡管的是跨專案決策,不是單一 repo 的施工細節)。 ## 決策 -ISEP 建立自己的 `system-dev/wiki/`,骨架取自 `inkstone/system-dev-template` 的 wiki -template(三層 + 標籤橫切:`INDEX.md`/`TAXONOMY.md`/`status.md`/`mistakes.md`/ -`principles.md`/`cards//`),照它的規約裝,不自創格式。 +**`inkstone/ISEP` 這個 repo 自己**建立 `system-dev/wiki/`,骨架取自 +`inkstone/system-dev-template` 的 wiki template(三層 + 標籤橫切:`INDEX.md`/ +`TAXONOMY.md`/`status.md`/`mistakes.md`/`principles.md`/`cards//`), +照它的規約裝,不自創格式。 + +這份 wiki 只在 ISEP 這個 repo 的 git 歷史裡,跟著 `git clone inkstone/ISEP` 走; +它**不是** plugin payload 的一部分(`plugin.json`/`marketplace.json` 只宣告 +`hooks/`/`commands/`/`skills/`,任何 Claude Code session 啟用這個 plugin 時載入的 +也只有這些),所以其他 repo 啟用 ISEP plugin 時,這份 wiki 不會、也無法出現在那裡。 **紅線**:這份 wiki 只記 ISEP 自己的事。不把 InkStoneCo 頂層 wiki 的內容複製過來—— 複製即 fork,fork 即漂移,跟「真身薄殼合一」(見 `cards/isep/真身薄殼合一.md`)要解的病 @@ -23,12 +42,47 @@ template(三層 + 標籤橫切:`INDEX.md`/`TAXONOMY.md`/`status.md`/`m ## 後果 -- 好處:接手 session 在 ISEP 內就能查到 ISEP 自己的歷史,不必先 clone 別的 repo。 -- 代價:多一份骨架要維護(跟 InkStoneCo 頂層、以及其他裝了 template 的子 repo 一樣)。 +- 好處:接手 ISEP 這個 repo 的 session,在它自己的 checkout 裡就查得到它自己的歷史, + 不必先 clone 別的 repo。 +- 代價:多一份骨架要維護(跟 InkStoneCo 頂層、以及其他裝了 template 的子 repo 一樣, + 各自一份、各自維護,不互相複製)。 - 邊界:跨專案的決策、鐵律、部署架構全局,仍然只在 InkStoneCo 頂層記錄,ISEP 不重複。 +## 常見誤解,與查證 + +**誤解**:「ISEP 這個 plugin 裝到哪個 repo,就會在那個 repo 裡自建一份 wiki, +於是每個裝了 ISEP 的 repo 都會多出兩份(自己的 + ISEP 幫它建的)。」 + +**這不是實際行為。查證如下(2026-08-20 實查,不是抄口述)**: + +1. `.claude-plugin/marketplace.json` 把整個 repo 根目錄(`"source": "./"`)宣告成 + plugin 來源,Claude Code 依慣例目錄(`hooks/`、`commands/`、`skills/`)載入內容; + `README.md`「裝什麼」表列出的也正是這幾個目錄(外加 `scripts/` 供它們呼叫)—— + **沒有任何一項是 wiki 或 docs**。啟用這個 plugin,載入的是 hook 腳本的路徑 + (`${CLAUDE_PLUGIN_ROOT}/hooks/*.sh`)、command/skill 的定義;這個載入過程本身 + 不涉及「往目前工作的 repo 寫入任何檔案」——它是讀,不是寫。 +2. `README.md`「裝什麼」段明文把 `wiki/`/`docs/`/`_archive/` 列在「不放」—— + 這條界線本來就是刻意畫的(環境 vs 知識分離),不是本 ADR 才立的。 +3. 全部 hooks 對「自己這支腳本」的路徑一律用 `${CLAUDE_PLUGIN_ROOT}`(不用 + `$CLAUDE_PROJECT_DIR`,見 `README.md`「路徑規約」段)——這條規約本身就代表 + hook 的邏輯設計上就是「讀 plugin 自己的檔案」,不是「往目前工作的 repo 寫東西」。 +4. **唯一一支「真的會在某個 repo 裡建出 wiki」的腳本是 `scripts/install.sh`**—— + 但它是 `system-dev-template` 的獨立安裝器(不是 ISEP 的功能),要**人或 AI 手動執行 + 一次**才會動作,且動作對象是**執行當下的 cwd**,不是「ISEP 被啟用的地方」。 + 它會混進這個 repo,是搬家時帶過來的殘留(`docs/governance/DIVERGENCE-v0.5.0-to-v0.6.0.md` + A6 節已標記它是待清理項,跟 `.claude-plugin` 宣告的 plugin 功能無關)。 + **這支腳本的存在,多半就是本誤解真正的來源**——它看起來像「ISEP 會建 wiki」, + 但觸發方式(手動跑一次)與作用對象(cwd,不是「plugin 裝到的地方」)都跟 + 「裝 plugin 就自動建」完全不同。 + +⇒ 結論:本 ADR 的「wiki」只指 ISEP 這個 repo 自己 checkout 裡的那一份, +跟其他任何 repo 有沒有、要不要各自裝一份 wiki(那是它們自己的 `/wiki-init` 決定), +兩件事互不影響、也不會因為裝了 ISEP plugin 而自動被牽動。 + ## 相關 - `cards/isep/真身薄殼合一.md` - `cards/isep/repo邊界與紅線.md` - `cards/isep/hook路徑規約.md` +- `inkstone/InkStoneCo#22`(本次修訂的來由:leo 讀完舊版誤解成「plugin 裝到哪、 + wiki 就跟著建到哪」)