#!/bin/bash # PreToolUse hook — 動 code 前檢查 SDD + 單一活性 SDD 鐵律(issue #6) # wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。 # 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md # # 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。 # stdin 收到 JSON:{ tool_name, tool_input: { file_path, ... } } # 行為: # 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2), # 先收斂到一份再說。 # 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。 # 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度) # → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。 # # 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。 # 藏在 helper 裡、用 bash 繞道的改動擋不到。 # 價值是「想跳過會被抓到 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。 set -euo pipefail INPUT=$(cat) # 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。 if command -v jq >/dev/null 2>&1; then FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') else FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//') fi # 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺) [ -z "$FILE_PATH" ] && exit 0 # 🔴 2026-08-02 修:原本寫死相對路徑 `system-dev/docs/3-specs`, # 但 hook 的工作目錄是**頂層 InkStoneCo**,改子 repo 的 code 時就去頂層找 SDD # ⇒ 看不到子 repo 自己那份 ⇒ **一律誤報「找不到任何 SDD」**。 # 實撞:改 products/arcrun-rag/collector/... 被擋,但該 repo 明明有 # system-dev/docs/3-specs/daemon-beta/design.md(status: active)。 # ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。 # ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…) # 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。 SPECS_DIR="system-dev/docs/3-specs" _root="${CLAUDE_PROJECT_DIR:-$(pwd)}" case "$FILE_PATH" in "$_root"/*) _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" break fi [ "$_d" = "$_root" ] && break _d=$(dirname "$_d") done ;; *) # 專案外的路徑:**不可退回頂層 SDD 就放行**,否則原本擋得住的會變成擋不住。 # 但 **git worktree 是正當工作區**(本專案大量使用 /private/tmp 下的 worktree 出貨), # 它自己就帶著該 repo 的 system-dev/docs/3-specs ⇒ 一樣往上找,找得到就認。 # 找不到才指向不存在目錄 ⇒ 走原有的「找不到 SDD」擋下路徑。 # (2026-08-02:第一版忘了 worktree,把正當的出貨工作區也擋掉。) SPECS_DIR="/nonexistent/3-specs" _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" break fi _d=$(dirname "$_d") done ;; esac # ── 統計 active / frontmatter ────────────────────── # 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數—— # 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。 # frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。 ACTIVE_COUNT=0 FM_COUNT=0 ACTIVE_LIST="" if [ -d "$SPECS_DIR" ]; then while IFS= read -r f; do [ -n "$f" ] || continue HEAD10=$(head -10 "$f" 2>/dev/null || true) if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then FM_COUNT=$((FM_COUNT + 1)) if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then ACTIVE_COUNT=$((ACTIVE_COUNT + 1)) ACTIVE_LIST="${ACTIVE_LIST} • ${f} " fi fi done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null) fi # ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ── if [ "$ACTIVE_COUNT" -gt 1 ]; then cat >&2 </dev/null | wc -l | tr -d ' ') fi if [ "$SDD_COUNT" -eq 0 ]; then cat >&2 <&2 exit 0 fi # ── 新行為:寫 code 檔需「恰好 1 份」active SDD ── if [ "$ACTIVE_COUNT" -eq 0 ]; then cat >&2 <&2 exit 0