6a49f25aef
SDD: docs/3-specs/jdd-dual-profile(draft → active,leo 2026-08-05 回「開工」)
範圍:總管指定的「防炸兩件 → Phase 0 → Phase 1」,Phase 2 以後未開工。
■ 防炸(排在所有 task 之前,因為它們炸的是既有的東西)
- check-no-instance-names.sh + instance-names.txt:框架範本不得混入實例專名
基線實測 22 行命中(非先前誤報的 20)→ 9 行無損泛化改寫、13 行檔級豁免記帳待 W3 搬走
拒絕假性清理(把專名換成模糊詞=資訊消失、分層問題還在)
- check-legacy-paths.sh:已發佈腳本引用的 35 條遠端路徑只增不移
舊實例跑的是舊腳本、路徑寫死;搬檔=整排 404 且不會有下一次更新來修它(1.16.0 前科)
■ Phase 0 地基
- template/manifest/{common,repo,orchestrator}.tsv:安裝清單單一真相源
修好 install/update 兩份硬編清單的既有漂移——install 從不裝 wiki-first-search /
subagent-wiki-guard / publish-lag-check / decisions-summary,但 update 會
⇒ 乾淨安裝反而拿不到 1.16/1.17/1.18 的招牌功能
- .claude/hooks/lib/role-lib.sh:scope×role 兩軸機械判定,零自陳
身分矩陣六組實測全通過,含「成員 repo × orchestrator」不存在的格子擋下
- .sdt-framework-dev:框架開發標記(官方沒有 --framework-dev 這個參數,實查非記憶)
■ Phase 1 雙 profile
- profiles/{repo,orchestrator}/CLAUDE.md 兩部憲法
- install.sh:--profile + 自動偵測+寫檔前確認、manifest 驅動、
CLAUDE.md 三段組裝(框架區/本地補充區界標+sha256)、.profile、.template-manifest、
settings.json 寫入 env.AGENT_ROLE 預設
- update.sh:漂移偵測(不覆蓋手改檔、另存 .new、白話清單)+ 基準快照隨更新前進
- template/CLAUDE.md 原路徑凍結留底(相容)
■ 順手修掉兩個舊 bug(都在本次要動的函式裡)
- add_if_missing 少了 mkdir -p ⇒ 新目錄的檔 curl 失敗但 VERSION 照升(2026-07 記「待回報」至今未修)
- 下載健全性只用 [ -s ]=非空即接受 ⇒ 404 頁面會無聲覆寫好檔
(SKILL.md 260→1 行的機制;同一支腳本的版本號那條路早就防了,檔案這條沒防)
■ 實測(非推論)
- G4 憲法分流:兩個乾淨環境各裝一次,orchestrator 版含 SDD 三件式關鍵字 0 次、
repo 版含上游指針 8 次;界標 4/4;sha 宣告與實算相符
- G7 CI 擋實例名:注入違規行 → fail 並指出 sdd-check.md:77,exit 1;還原後 exit 0
- 漂移偵測:手改兩支 hook → 正確報 2 支、手改內容保住、產 .new;
解掉後歸零;連跑三輪冪等
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
7.7 KiB
Bash
173 lines
7.7 KiB
Bash
#!/bin/bash
|
||
# role-lib.sh — 兩軸身分判定函式庫(被 source,不獨立掛 hook)
|
||
#
|
||
# 出處:《分離導入規格》第二節「兩軸身分(scope × role,全部機械判定,agent 不自我判斷)」
|
||
# +《JDD 導入規格》第四節「角色與權限」。
|
||
#
|
||
# ── 為什麼是「兩軸」而不是「一個角色設定」──────────────────────
|
||
# scope(我在誰的地盤)=**安裝位置**決定:agent 在哪個資料夾醒來,
|
||
# 就只讀得到那部憲法。來源= system-dev/.profile(install 時寫死)。
|
||
# role (我是什麼工種)=**環境變數 AGENT_ROLE** 決定:
|
||
# orchestrator 效忠 root+journeys、禁寫 code 禁改 tasks;
|
||
# engineer 效忠 requirements、禁改考卷(journeys/root/Gherkin)。
|
||
#
|
||
# 兩軸都有機械來源 ⇒ **沒有任何一格是靠 agent 自陳的**。
|
||
# 這是整套封路的地基:agent 說自己是誰不算數,檔案和環境說了才算。
|
||
#
|
||
# ── 身分矩陣(注意右下角那個空格)──────────────────────────
|
||
# AGENT_ROLE=orchestrator AGENT_ROLE=engineer
|
||
# orchestrator profile PM 本尊 ✅ 總管 repo 裡的技術 subagent ✅
|
||
# repo profile ❌ 不存在(擋) 寫 code 的 subagent ✅
|
||
#
|
||
# 左下角為什麼要擋而不是靜默降級:成員 repo 裡沒有 PM。
|
||
# 靜默降級=把一個設定錯誤變成「看起來正常但權限不對」,那比報錯危險。
|
||
# 空格也是封路(分離導入規格 §2)。
|
||
#
|
||
# ── 為什麼是函式庫不是六支各自為政的 hook ────────────────────
|
||
# 六支封路 hook 都要做同樣四件事:解析 JSON 取 file_path、算 repo 相對路徑、
|
||
# 判 scope、判 role。各寫一份=四處維護同一條規則,正是分離導入規格 §6.4
|
||
# 要避免的「兩處維護同一條規則」。
|
||
#
|
||
# 用法(在 hook 裡):
|
||
# source "$(dirname "${BASH_SOURCE[0]}")/lib/role-lib.sh"
|
||
# INPUT="$(cat)"
|
||
# FILE_PATH="$(sdt_file_path "$INPUT")"
|
||
# sdt_assert_identity || exit 2
|
||
#
|
||
# 誠實限制:這裡判的是「宣告出來的身分」。有人硬改 .profile 或亂設 AGENT_ROLE
|
||
# 一樣會通過——它擋的是**意外與疏忽**,不是刻意繞道。留痕可審,不宣稱防偽。
|
||
|
||
# 注意:本檔被 source,**不要**在這裡 set -euo pipefail
|
||
#(會把呼叫端的 shell 選項一起改掉,hook 的容錯行為會跟著變)。
|
||
|
||
# ── repo 根目錄 ────────────────────────────────────────
|
||
# 從本檔位置往上推三層:<root>/.claude/hooks/lib/role-lib.sh
|
||
# 為什麼不用 $CLAUDE_PROJECT_DIR:雲端/CI/被 source 進別的腳本時不保證存在
|
||
#(分離導入規格 §3:封路 hook 工具箱要「環境偵測、repo 相對路徑,雲端可跑」)。
|
||
sdt_repo_root() {
|
||
local libdir
|
||
libdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
(cd "$libdir/../../.." && pwd)
|
||
}
|
||
|
||
# ── 路徑正規化:絕對或相對 → repo 相對 ──────────────────
|
||
sdt_rel_path() {
|
||
local file_path="$1" root
|
||
root="$(sdt_repo_root)"
|
||
case "$file_path" in
|
||
"$root"/*) printf '%s' "${file_path#"$root"/}" ;;
|
||
/*) printf '%s' "$file_path" ;; # 別的地方的絕對路徑,原樣回(呼叫端自己判要不要管)
|
||
*) printf '%s' "$file_path" ;; # 已是相對路徑
|
||
esac
|
||
}
|
||
|
||
# ── scope 軸:安裝位置決定 ──────────────────────────────
|
||
# 讀不到 .profile 時回 repo:多數實例是成員 repo,而且此時 orchestrator 專屬的
|
||
# 攔截本來就不該觸發(總管禁入成員 repo 那條只裝在 orchestrator profile)。
|
||
# 這是「最小驚訝」而非「最小權限」——選擇的代價寫在這裡,別假裝沒有。
|
||
sdt_scope() {
|
||
local f v
|
||
f="$(sdt_repo_root)/system-dev/.profile"
|
||
if [ -f "$f" ]; then
|
||
v="$(tr -d '[:space:]' < "$f" 2>/dev/null || true)"
|
||
case "$v" in
|
||
repo|orchestrator) printf '%s' "$v"; return 0 ;;
|
||
esac
|
||
fi
|
||
printf 'repo'
|
||
}
|
||
|
||
# ── role 軸:AGENT_ROLE 環境變數決定;沒設就依 scope 推定 ──
|
||
sdt_role() {
|
||
case "${AGENT_ROLE:-}" in
|
||
orchestrator|engineer) printf '%s' "$AGENT_ROLE"; return 0 ;;
|
||
esac
|
||
# 未設定 → 依 scope 推定(不問 agent、不猜)
|
||
case "$(sdt_scope)" in
|
||
orchestrator) printf 'orchestrator' ;;
|
||
*) printf 'engineer' ;;
|
||
esac
|
||
}
|
||
|
||
# ── 矩陣檢查:擋掉不存在的那一格 ────────────────────────
|
||
# 回傳 0 = 身分合法;回傳 2 = 非法(呼叫端應 exit 2)
|
||
sdt_assert_identity() {
|
||
local scope role
|
||
scope="$(sdt_scope)"
|
||
role="$(sdt_role)"
|
||
if [ "$scope" = "repo" ] && [ "$role" = "orchestrator" ]; then
|
||
cat >&2 <<EOF
|
||
❌ 身分組合不存在:成員 repo × orchestrator
|
||
|
||
scope(安裝位置):repo ← system-dev/.profile
|
||
role (AGENT_ROLE):orchestrator ← 環境變數
|
||
|
||
成員 repo 裡沒有 PM。PM(orchestrator)的動作——改 root.md/journeys.md/
|
||
排 sprint 站號——只能在總管 repo 做。
|
||
|
||
怎麼修(擇一):
|
||
· 你其實是來寫 code 的 → unset AGENT_ROLE,或設成 engineer
|
||
· 你真的要做 PM 的事 → 回總管 repo 做,不要在成員 repo 裡做
|
||
· 這個 repo 其實是總管 repo → system-dev/.profile 內容應為 orchestrator
|
||
(裝錯 profile 了,重跑 install.sh --profile=orchestrator)
|
||
EOF
|
||
return 2
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ── 從 hook 的 JSON 取 file_path ────────────────────────
|
||
# 三段 fallback:jq → python3 → grep。沿本 template 既有 hook 的容錯慣例
|
||
#(拿不到就回空字串,讓呼叫端放行——寧可漏擋也不誤殺)。
|
||
# 用法:FILE_PATH="$(sdt_file_path "$INPUT")" ← 傳字串,不是讀 stdin
|
||
#(stdin 只能讀一次,統一由呼叫端 `INPUT="$(cat)"` 讀走再傳進來)
|
||
sdt_file_path() {
|
||
local input="$1"
|
||
[ -z "$input" ] && return 0
|
||
if command -v jq >/dev/null 2>&1; then
|
||
printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null && return 0
|
||
fi
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
printf '%s' "$input" | python3 -c '
|
||
import json,sys
|
||
try:
|
||
d=json.load(sys.stdin)
|
||
print(d.get("tool_input",{}).get("file_path","") or "")
|
||
except Exception:
|
||
print("")
|
||
' 2>/dev/null && return 0
|
||
fi
|
||
printf '%s' "$input" \
|
||
| grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||
| head -1 \
|
||
| sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//'
|
||
}
|
||
|
||
# ── 從 hook 的 JSON 取要寫入的內容(Write 用 content、Edit 用 new_string)──
|
||
sdt_write_content() {
|
||
local input="$1"
|
||
[ -z "$input" ] && return 0
|
||
if command -v jq >/dev/null 2>&1; then
|
||
printf '%s' "$input" \
|
||
| jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")' 2>/dev/null \
|
||
&& return 0
|
||
fi
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
printf '%s' "$input" | python3 -c '
|
||
import json,sys
|
||
try:
|
||
ti=json.load(sys.stdin).get("tool_input",{})
|
||
print("\n".join(x for x in (ti.get("content"), ti.get("new_string")) if x))
|
||
except Exception:
|
||
print("")
|
||
' 2>/dev/null && return 0
|
||
fi
|
||
# 無 jq/python3:退回整包(寧可多掃不漏掃,沿 wiki-secret-scan.sh 的慣例)
|
||
printf '%s' "$input"
|
||
}
|
||
|
||
# ── 除錯用:一行印出目前身分 ────────────────────────────
|
||
sdt_identity_line() {
|
||
printf 'scope=%s role=%s root=%s' "$(sdt_scope)" "$(sdt_role)" "$(sdt_repo_root)"
|
||
}
|