Files
system-dev-template/scripts/update.sh
T
Leo 2f5d9f3bb2 feat(W2 Phase 2-3): JDD 文件範本+八條封路 hook+還清兩件舊債
SDD: docs/3-specs/jdd-dual-profile(active)。編號 task 26/33 完成,Phase 4-5 未開工。

■ Phase 2 JDD 文件範本(orchestrator profile)
範本形狀對齊「實際跑出來的那兩份」(總管已寫的 root.md 15 卡、journeys.md J-1 九站),
不是照規格憑空造:
- 卡片是巢狀 bullet(`- **P1** 🟢 …` + 子項放來源/對帳),非規格畫的平行文字行
- 站點索引**巢狀 bullet 不用表格**(表格會把層級壓平,看不出從屬)
- 兩份都保留「這卷還缺什麼(誠實記)」收尾段——規格沒有,但那是防假綠的地方
新增:root.md / journeys.md / sprint.md / triage-map.md 四範本(add-if-missing,
填了就永不覆蓋)+ plugin-load-order.md(W3 插槽,框架不發明平行外掛格式)

■ Phase 3 封路 hook(八條規則落六支檔)
- role-guard(J1+J2+J3)★命門:考生不能改考卷。六組實測含「考題藏在別的 md 裡」也擋
- jdd-format-guard(J4+J5+J8):紅卡缺對帳日/任務缺站號/PM 文件混技術名詞
- station-done-guard(J6):收工判準是站的考題全綠,不是任務全關
- regression-scope(J7):動實作 → 列出要重考哪幾題(只提醒不擋)
- install-artifact-guard(S1):實例不改機制
- orchestrator-scope-guard(S4):總管不進成員 repo 動實作(從實例上收進框架,
  路徑清單改由實例自填,範本零專名)
掛載鏈依「範圍大的擋在前」:改機制 → 角色 → 位置 → 格式 → 既有三支

■ 還清兩件舊債
- update.sh 檔案清單改讀 manifest(舊硬編降為抓不到來源時的 fallback)
  ——install/update 兩份手抄清單漂移的根因全修
- CLAUDE.md 界標補植:舊實例全文原封包進本地區、框架區重鋪、原檔備份、冪等
  ——解開「沒界標⇒不敢覆蓋⇒框架改的憲法永遠送不到既有實例」這個死結

■ 修掉三個自己造的問題(實測抓出來的,不是想出來的)
- jdd-format-guard 誤擋真實 journeys.md 的「這卷還缺什麼」自述段
  → 排除法改**正面圈定**(只掃卡片本體與站內文),說明區/自述段/索引自然不在範圍
- install-artifact-guard 把 pre-write-guard.sh 也擋了——而它的錯誤訊息正叫人去改那支
  → 使用者自訂插槽列為最優先放行
- check-legacy-paths 用 HEAD 當基準會**自我弱化**:改成清單驅動後保護範圍 35→29 條
  → 基準改指最後一次真正發佈的版本

■ 實測(全部貼過輸出)
- G2 考生改考卷:6/6,含 orchestrator 寫 code/engineer 改考題/考題藏別處
- G4 憲法分流:兩環境重裝,總管版技術軌關鍵字 0、成員版上游指針 8,界標 4/4
- G6 實例改機制:4/4,含框架開發標記放行與自訂插槽放行
- G7 CI 擋實例名:注入違規 → 指出檔案行號 exit 1
- G3 進度以站計量:起牀推「J-1 已點亮 2/9 站」、收工列未亮站並禁用任務數當理由
- 回歸考、界標補植冪等、orchestrator-scope-guard 四組:全通

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:26:56 +08:00

649 lines
32 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# system-dev-template updater
# 已安裝舊版的人,一鍵更新到新版。
#
# 核心安全原則:只覆蓋「模板/邏輯檔」,絕不碰「使用者資料檔」。
# ✅ 可覆蓋:hooks/*.sh、commands/*.md、TEMPLATE-*、wiki/INDEX.md
# ——這些由模板維護,使用者不會手改,新版直接換掉。
# 🔒 絕不碰:wiki/status.md、mistakes.md、decisions-summary.md、TAXONOMY.md、.wikiignore、
# settings.json、CLAUDE.md
# ——這些是使用者自己填的內容,覆蓋=清空他的記憶與設定。
#
# 「第一次更新」的雞生蛋問題:
# 舊版本機沒有 update.sh。所以第一次靠 README 那行 curl 從遠端抓這支腳本來跑。
# 跑完它會把自己也更新進 scripts/update.sh,之後就能直接跑本機的 `bash scripts/update.sh`。
set -euo pipefail
# ── i18n:依 locale 選語言,預設英文(curl | bash 常為 LANG=C)──
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
zh*|*Hant*|*Hans*) IS_ZH="yes" ;;
*) IS_ZH="no" ;;
esac
t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; }
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
# 來源預設=公開 GitHubleo 2026-07-21:「要發佈的正稿,從頭就不要用奇怪的網址,
# 以免改來改去」)。發佈時不再需要改寫網址——寫死的就是對外正確的那一個。
# 內部要改指私有草稿源(Gitea/未來 CF git)時,用環境變數覆寫,不改檔:
# TEMPLATE_SOURCE=https://<你的私有 raw base> bash scripts/update.sh
REPO_RAW="${TEMPLATE_SOURCE:-https://raw.githubusercontent.com/youlinhsieh/system-dev-template/main}"
TEMPLATE_URL="$REPO_RAW/template"
UPDATED=()
KEPT=()
NEW=()
TEMPLATED=()
MIGRATED=()
COEXIST=()
# ── 版本比對:先看本機 vs 遠端,給使用者「值不值得更新」的判斷 ──
# VERSION 新位置在 system-dev/,舊位置在 .claude/(1.8.x 以前)。優先讀新、回退舊。
LOCAL_VER="$(tn '(未知)' '(unknown)')"
if [ -f "system-dev/VERSION" ]; then
LOCAL_VER="$(tr -d '[:space:]' < system-dev/VERSION)"
elif [ -f ".claude/VERSION" ]; then
LOCAL_VER="$(tr -d '[:space:]' < .claude/VERSION)"
fi
REMOTE_VER="$(curl -sSL "$TEMPLATE_URL/system-dev/VERSION" 2>/dev/null | tr -d '[:space:]' || echo '')"
# 容錯:curl 對 404 會把「404:NotFound」當內容輸出(非空),舊版誤把它寫進 VERSION。
# 這裡驗證必須像版號(X.Y.Z),否則一律視為取不到,避免污染 VERSION 檔。
case "$REMOTE_VER" in
[0-9]*.[0-9]*.[0-9]*) : ;; # 形如 1.9.0 → 合法
*) REMOTE_VER="" ;; # 404 / HTML 錯誤頁 / 其他 → 當作沒抓到
esac
echo ""
echo "🔄 system-dev-template updater"
echo "================================="
t " 本機版本:${LOCAL_VER}" " Local version: ${LOCAL_VER}"
t " 最新版本:${REMOTE_VER:-取不到(檢查網路)}" \
" Latest version: ${REMOTE_VER:-unavailable (check network)}"
echo ""
if [ -z "$REMOTE_VER" ]; then
t "❌ 取不到遠端版本,可能是網路問題。請稍後再試。" \
"❌ Could not fetch the remote version (likely a network issue). Please try again later."
exit 1
fi
if [ "$LOCAL_VER" = "$REMOTE_VER" ]; then
t "✅ 已是最新版(${LOCAL_VER}),不需更新。" \
"✅ Already up to date (${LOCAL_VER}), nothing to update."
t " (仍會同步模板邏輯檔,確保 hooks/commands 與最新一致。)" \
" (Template logic files will still be synced to keep hooks/commands in line with the latest.)"
echo ""
fi
# ── 結構遷移(1.9.0):舊版把 wiki/VERSION 放 .claude/、工具 docs 放根 docs/ ──
# 新版一律收進 system-dev/。這裡冪等遷移:偵測舊位置 → 搬到 system-dev/,已搬過則略過。
# 必須在「模組偵測」之前跑(偵測靠目錄存在與否判斷,搬完才看得到新位置)。
#
# 安全原則:
# - wiki 整包搬(含 cards/ 與可能的 wiki/.git),用 mv 保留內含 .git。
# - docs 只搬「工具自己鋪的白名單」子目錄;用戶自填在 docs/ 的其他內容一律不動。
# - 目的地已存在同名 → 不覆蓋(保留用戶在新位置的東西),略過該項。
migrate_dir() { # $1=舊路徑 $2=新路徑
local from="$1" to="$2"
[ -e "$from" ] || return 0 # 舊的不存在 → 無需遷移
if [ -e "$to" ]; then
# 目的地已存在。兩種可能:
# (a) 已遷移過 → 舊位置不該還在;冪等略過即可。
# (b) 用戶先 install 建了空殼 → 舊位置仍有真資料,現在「並存」。
# 不能靜默跳過 (b),也絕不自動合併(覆蓋風險)。→ 記為「並存待合併」,警告。
COEXIST+=("$from$to")
return 0
fi
mkdir -p "$(dirname "$to")"
if mv "$from" "$to" 2>/dev/null; then
MIGRATED+=("$from$to")
fi
}
# 任一舊位置還在 → 需要遷移(遷移本身冪等:已搬的項目會被 migrate_dir 略過)。
NEEDS_MIGRATE="no"
if [ -d ".claude/wiki" ] || [ -f ".claude/VERSION" ] \
|| [ -d "docs/3-specs" ] || [ -f "docs/SKILL.md" ] || [ -f "docs/README.md" ]; then
NEEDS_MIGRATE="yes"
fi
if [ "$NEEDS_MIGRATE" = "yes" ]; then
t "🔧 偵測到舊版結構,遷移到 system-dev/ …" "🔧 Old layout detected — migrating into system-dev/ …"
mkdir -p system-dev
# wiki(含 cards/ 與內含的 .git)整包搬
migrate_dir ".claude/wiki" "system-dev/wiki"
# 工具版號
migrate_dir ".claude/VERSION" "system-dev/VERSION"
# 工具文件白名單(只搬工具鋪的,用戶自填的 docs 內容不動)
migrate_dir "docs/SKILL.md" "system-dev/docs/SKILL.md"
migrate_dir "docs/README.md" "system-dev/docs/README.md"
migrate_dir "docs/1-vision" "system-dev/docs/1-vision"
migrate_dir "docs/2-architecture" "system-dev/docs/2-architecture"
migrate_dir "docs/3-specs" "system-dev/docs/3-specs"
migrate_dir "docs/4-guides" "system-dev/docs/4-guides"
migrate_dir "docs/5-records" "system-dev/docs/5-records"
migrate_dir "docs/6-user" "system-dev/docs/6-user"
echo ""
fi
# ── 漂移偵測(1.19.0 新增)─────────────────────────
# 病根:實例會手改安裝產物(hook、範本),而**沒有任何機制知道**。
# 實測基線(2026-08-05,拿一個真實使用中的實例對照框架):
# 6 支框架 hook 裡 **4 支已被手改**,另有 11 支是該實例自己發明的。
# 兩層後果:① 框架修好的 bug 送不到那些檔 ② 手改內容沒人審、沒回饋回框架
# ⇒ 同一個坑每個實例各踩一次。
#
# 判準:安裝當下的 sha(記在 system-dev/.template-manifestvs 現在的 sha。
# 相同 → 沒動過 → 安全覆蓋成新版
# 不同 → **被手改過** → 不覆蓋、另存 .new、列進漂移清單,讓人自己決定
#
# 為什麼不直接覆蓋:無聲吃掉別人的修改(他多半是在修一個框架還沒修的問題)。
# 為什麼不永遠不覆蓋:那更新就永遠送不到 = 等於沒有更新機制。
# **分辨得出來**才是解法,兩個極端都不對。
DRIFTED=()
sdt_sha256() {
if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
else printf 'nohash'; fi
}
MANIFEST_FILE="system-dev/.template-manifest"
manifest_sha() { # 查登記的 sha;查不到回空
[ -f "$MANIFEST_FILE" ] || return 0
awk -F'\t' -v d="$1" '!/^#/ && $1==d {print $4; exit}' "$MANIFEST_FILE"
}
# 本次 update 經手過的檔(不論更新、保留或漂移)。收工時用它重寫 manifest。
# ⚠️ 沒有這一步的話會出現**自己造的 bug**:update 把檔覆蓋成新版後,manifest 還留著舊 sha
# ⇒ 下一次跑 update 會把「框架自己更新的檔」誤判成「使用者手改」,整排假漂移。
# 基準快照必須跟著實檔一起前進。
MANAGED=()
is_drifted() { # 0=被手改過;1=沒動過或無從判斷(保守,不誤報)
local dest="$1" recorded
recorded="$(manifest_sha "$dest")"
[ -z "$recorded" ] && return 1
[ -f "$dest" ] || return 1
[ "$(sdt_sha256 "$dest")" = "$recorded" ] && return 1
return 0
}
# ── 下載健全性檢查(修一個舊 bug)──────────────────
# 原本只用 [ -s ]=「非空就接受」。但 curl 對 404 會把「404: Not Found」當內容寫出來,
# **那也是非空** ⇒ 好檔被覆寫成一行垃圾,而且沒有任何錯誤訊息。
# 實錄:SKILL.md 曾被無聲覆寫 260 行 → 1 行(template issue #13)。
# 諷刺的是本腳本的「版本號比對」那段早就防了這招(見上方 REMOTE_VER 的驗證),
# 檔案下載這條路卻沒防——同一個坑,防了一半。
looks_like_error_page() {
local f="$1"
[ -s "$f" ] || return 0 # 空的 → 當失敗
# 極短又長得像錯誤訊息 → 當失敗(正常範本檔不會只有一兩行還寫著 404)
if [ "$(wc -l < "$f" | tr -d ' ')" -le 2 ] && head -c 200 "$f" | grep -qiE '40[0-9]|not found|<html'; then
return 0
fi
return 1
}
# ── 工具函式 ───────────────────────────────────────
# 覆蓋更新:模板/邏輯檔,抓最新版蓋掉——**除非它被手改過**。
update_file() {
local dest="$1" src="$2"
mkdir -p "$(dirname "$dest")"
MANAGED+=("$dest\toverwrite")
if [ -f "$dest" ]; then
if is_drifted "$dest"; then
# 被手改過:不覆蓋,把新版放旁邊供 diff
if curl -sSL "$src" -o "$dest.new" 2>/dev/null && ! looks_like_error_page "$dest.new"; then
if cmp -s "$dest" "$dest.new"; then
rm -f "$dest.new" # 手改後剛好等於新版 → 沒事,不用吵
else
DRIFTED+=("$dest")
fi
else
rm -f "$dest.new"
DRIFTED+=("$dest")
fi
return 0
fi
if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && ! looks_like_error_page "$dest.tmp"; then
if cmp -s "$dest" "$dest.tmp"; then
rm -f "$dest.tmp" # 內容相同,不算更新
else
mv "$dest.tmp" "$dest"
UPDATED+=("$dest")
fi
else
rm -f "$dest.tmp"
t " ⚠️ 抓取失敗,保留原檔:$dest" " ⚠️ Download failed, keeping the original: $dest"
fi
else
if curl -sSL "$src" -o "$dest" 2>/dev/null && ! looks_like_error_page "$dest"; then
NEW+=("$dest") # 新功能:舊版沒有的檔
else
rm -f "$dest"
t " ⚠️ 抓取失敗:$dest" " ⚠️ Download failed: $dest"
fi
fi
}
# 保留:使用者資料檔,只記錄「有保留」,永遠不動。
keep_file() {
[ -f "$1" ] && KEPT+=("$1") || true
}
# 補新檔:舊版沒有、新版才有的「使用者資料檔」(如 principles.md)。
# 不存在 → 抓範本下來(之後由使用者/CC 填);已存在 → 當用戶資料保留,絕不覆蓋。
add_if_missing() {
local dest="$1" src="$2"
MANAGED+=("$dest\tadd-if-missing")
if [ -f "$dest" ]; then
KEPT+=("$dest")
return 0
fi
# 🐛 修:原本少了 mkdir -p ⇒ 目標在**新目錄**時 curl 直接失敗,
# 但 VERSION 照樣升上去 = 典型假綠(2026-07 在一個實例升級時撞到,當時記「待回報 template」)。
mkdir -p "$(dirname "$dest")"
if curl -sSL "$src" -o "$dest" 2>/dev/null && ! looks_like_error_page "$dest"; then
NEW+=("$dest")
else
rm -f "$dest"
t " ⚠️ 抓取失敗:$dest" " ⚠️ Download failed: $dest"
fi
}
# 客製檔:使用者一定會手填內容(如 pre-write-guard.sh)。
# - 已存在 → 絕不覆蓋,但把最新模板版抓到 <檔名>.template.sh 旁邊,供使用者自行 diff 採納。
# - 不存在 → 視同新檔,直接抓本體(第一次安裝才會走這條)。
keep_with_template() {
local dest="$1" src="$2"
if [ -f "$dest" ]; then
KEPT+=("$dest")
local tmpl="${dest%.sh}.template.sh"
if curl -sSL "$src" -o "$tmpl.tmp" 2>/dev/null && [ -s "$tmpl.tmp" ]; then
if [ -f "$tmpl" ] && cmp -s "$tmpl" "$tmpl.tmp"; then
rm -f "$tmpl.tmp" # 模板版沒變,不重複提示
else
mv "$tmpl.tmp" "$tmpl"
TEMPLATED+=("$tmpl")
fi
else
rm -f "$tmpl.tmp"
fi
else
update_file "$dest" "$src" # 還沒裝過 → 當新檔處理
fi
}
# ── 偵測已安裝哪些模組(依現有檔案判斷,更新只動已裝的)──
# 遷移已在上面跑完,這裡看新位置 system-dev/。
HAS_WIKI=false
HAS_SDD=false
[ -d "system-dev/wiki" ] && HAS_WIKI=true
if [ -f ".claude/hooks/sdd-guard.sh" ] || [ -d "system-dev/docs/3-specs/TEMPLATE-sdd" ]; then HAS_SDD=true; fi
t "📦 偵測到已安裝模組:" "📦 Detected installed modules:"
$HAS_WIKI && echo " • LLM Wiki"
$HAS_SDD && echo " • SDD"
{ $HAS_WIKI || $HAS_SDD; } || \
t " (未偵測到任何模組——這裡可能還沒安裝,請改跑 install.sh" \
" (No modules detected — nothing installed here yet; run install.sh instead.)"
echo ""
# ── 客製檔:使用者手填的 guardrail,永不覆蓋(issue #3)──
# pre-write-guard.sh 的定位是「空白客製模板,使用者沒配置前不提供保護」(CHANGELOG 1.2.0)。
# 下游通常已塞滿自己的 enforcement,直接覆蓋=無聲關掉整套 guardrail。
# 改為:保留原檔不動,新版範本另存 pre-write-guard.template.sh,由使用者自行 diff 採納。
keep_with_template ".claude/hooks/pre-write-guard.sh" "$TEMPLATE_URL/.claude/hooks/pre-write-guard.sh"
# ── 舊版硬編清單(僅在抓不到 manifest 時使用的退路)──────────
# 保留它的唯一理由:來源不可達時,更新不該整個停擺。
# ⚠️ 新增產物請改 template/manifest/*.tsv**不要**再往這裡加——
# 這份清單就是當初漂移的來源。
legacy_update_list() {
# ── 模板/邏輯檔:覆蓋更新 ──────────────────────────
# 共用 hook 與指引
update_file ".claude/commands/issue-handle.md" "$TEMPLATE_URL/.claude/commands/issue-handle.md"
update_file "system-dev/VERSION" "$TEMPLATE_URL/system-dev/VERSION"
# Logseq 任務 marker 解析(單一真相源):vault 萃取(/wiki-extract)與 tasks→Project
# 投影共用同一套;任一模組在用就補/更新(邏輯檔,可覆蓋)。
if $HAS_WIKI || $HAS_SDD; then
update_file "system-dev/docs/4-guides/logseq-markers.md" "$TEMPLATE_URL/system-dev/docs/4-guides/logseq-markers.md"
fi
if $HAS_WIKI; then
# wiki 的「邏輯檔」:導航與 hooks,可覆蓋。wiki 資料在 system-dev/hooks/commands 留 .claude/。
update_file "system-dev/wiki/INDEX.md" "$TEMPLATE_URL/system-dev/wiki/INDEX.md"
update_file ".claude/hooks/session-start-recall.sh" "$TEMPLATE_URL/.claude/hooks/session-start-recall.sh"
update_file ".claude/hooks/wiki-secret-scan.sh" "$TEMPLATE_URL/.claude/hooks/wiki-secret-scan.sh"
# 1.16.0:讓 wiki 真的被讀到的兩支(開場 push 全文解決不了「只讀開頭」,見 CHANGELOG)
update_file ".claude/hooks/wiki-first-search.sh" "$TEMPLATE_URL/.claude/hooks/wiki-first-search.sh"
update_file ".claude/hooks/subagent-wiki-guard.sh" "$TEMPLATE_URL/.claude/hooks/subagent-wiki-guard.sh"
# 1.18.0:公開 mirror 落後偵測(手動同步的靜默失敗只有外部用戶會撞到)
update_file ".claude/hooks/publish-lag-check.sh" "$TEMPLATE_URL/.claude/hooks/publish-lag-check.sh"
update_file ".claude/commands/wiki-init.md" "$TEMPLATE_URL/.claude/commands/wiki-init.md"
update_file ".claude/commands/wiki-capture.md" "$TEMPLATE_URL/.claude/commands/wiki-capture.md"
update_file ".claude/commands/wiki-update.md" "$TEMPLATE_URL/.claude/commands/wiki-update.md"
update_file ".claude/commands/wiki-recall.md" "$TEMPLATE_URL/.claude/commands/wiki-recall.md"
# vault 增量萃取(Logseq/Obsidian → system-dev/wiki,冪等):邏輯檔,可覆蓋。舊版沒有 → 當新檔補。
update_file ".claude/commands/wiki-extract.md" "$TEMPLATE_URL/.claude/commands/wiki-extract.md"
# Coworkclaude.ai)的 wiki 整理 skill:規則檔,可覆蓋
update_file "system-dev/docs/SKILL.md" "$TEMPLATE_URL/system-dev/docs/SKILL.md"
# wiki 的「使用者資料」:絕不碰
keep_file "system-dev/wiki/status.md"
keep_file "system-dev/wiki/mistakes.md"
# principles.md1.10):舊版沒有 → 補範本;已有 → 當用戶資料保留
add_if_missing "system-dev/wiki/principles.md" "$TEMPLATE_URL/system-dev/wiki/principles.md"
keep_file "system-dev/wiki/decisions-summary.md"
keep_file "system-dev/wiki/TAXONOMY.md"
keep_file "system-dev/wiki/.wikiignore"
fi
if $HAS_SDD; then
# SDD 範本與 hook:可覆蓋
update_file "system-dev/docs/3-specs/TEMPLATE-sdd/design.md" "$TEMPLATE_URL/system-dev/docs/3-specs/TEMPLATE-sdd/design.md"
update_file "system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md" "$TEMPLATE_URL/system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md"
update_file "system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md" "$TEMPLATE_URL/system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md"
update_file ".claude/commands/sdd-check.md" "$TEMPLATE_URL/.claude/commands/sdd-check.md"
update_file ".claude/hooks/sdd-guard.sh" "$TEMPLATE_URL/.claude/hooks/sdd-guard.sh"
# SDD 生命週期鐵律(1.14,issue #6):規則檔+獨立檢查腳本=邏輯檔可覆蓋;
# pending-changes.md 裝著用戶的 proposal=用戶資料,只補不覆蓋。
# issue #13 教訓:update 不補新檔會造成結構斷層,新檔必須在這裡鋪。)
update_file "system-dev/docs/3-specs/SDD-LIFECYCLE.md" "$TEMPLATE_URL/system-dev/docs/3-specs/SDD-LIFECYCLE.md"
add_if_missing "system-dev/docs/3-specs/pending-changes.md" "$TEMPLATE_URL/system-dev/docs/3-specs/pending-changes.md"
update_file "system-dev/scripts/sdd-active-check.sh" "$TEMPLATE_URL/scripts/sdd-active-check.sh"
# tasks⇄Project 投影(issue #16):邏輯檔,可覆蓋。舊版沒有 → add_if_missing 補。
# 啟用狀態存遠端(acr push),不在這些檔裡,覆蓋不會關掉誰的同步。
add_if_missing "system-dev/workflows/tasks-project-sync.yaml" "$TEMPLATE_URL/system-dev/workflows/tasks-project-sync.yaml"
add_if_missing "system-dev/workflows/tasks-project-sync.local.sh" "$TEMPLATE_URL/system-dev/workflows/tasks-project-sync.local.sh"
fi
}
# ── Manifest 驅動的更新(1.19.0:兩支腳本讀同一份清單)────────
# 在這之前,這裡是一份**手抄的**檔案清單,install.sh 另有一份。兩份手抄必然漂移,
# 而且已經漂了:wiki-first-search / subagent-wiki-guard / publish-lag-check /
# decisions-summary 這四個,update 會處理但 install 從來不裝
# ⇒ 乾淨安裝的人反而拿不到最新三版的招牌功能。
# 現在兩支都讀 template/manifest/*.tsv,這類漂移在結構上不可能再發生。
#
# 讀不到 manifest(來源太舊/網路問題)→ 退回舊的硬編清單(見下方 legacy_update_list),
# 不讓更新整個停擺。
MANIFEST_DIR="system-dev/.template-manifest.d"
mkdir -p "$MANIFEST_DIR"
SDT_PROFILE="repo"
[ -f "system-dev/.profile" ] && SDT_PROFILE="$(tr -d '[:space:]' < system-dev/.profile 2>/dev/null || echo repo)"
fetch_manifest() { # $1=名稱
local out="$MANIFEST_DIR/$1.tsv"
if curl -sSL "$TEMPLATE_URL/manifest/$1.tsv" -o "$out" 2>/dev/null \
&& [ -s "$out" ] && ! looks_like_error_page "$out"; then
return 0
fi
rm -f "$out"; return 1
}
resolve_src() {
case "$1" in
T:*) printf '%s/%s' "$TEMPLATE_URL" "${1#T:}" ;;
S:*) printf '%s/%s' "$REPO_RAW/scripts" "${1#S:}" ;;
*) printf '' ;;
esac
}
want_module() {
case "$1" in
core) return 0 ;;
wiki) $HAS_WIKI && return 0 || return 1 ;;
sdd) $HAS_SDD && return 0 || return 1 ;;
*) return 1 ;;
esac
}
apply_manifest() { # $1=manifest 檔
local f="$1" src dest class module profile url
[ -f "$f" ] || return 0
while IFS=$'\t' read -r src dest class module profile; do
case "$src" in ''|'#'*) continue ;; esac
[ -z "${dest:-}" ] && continue
want_module "$module" || continue
url="$(resolve_src "$src")"
case "$class" in
dir) mkdir -p "$dest" 2>/dev/null || true ;;
overwrite) [ -n "$url" ] && update_file "$dest" "$url" ;;
add-if-missing) [ -n "$url" ] && add_if_missing "$dest" "$url" ;;
keep-with-template) [ -n "$url" ] && keep_with_template "$dest" "$url" ;;
keep) keep_file "$dest" ;;
claude-md) CLAUDE_MD_SRC="$url" ;; # 憲法走界標補植,見下方專段
*) : ;;
esac
done < "$f"
}
CLAUDE_MD_SRC=""
USED_MANIFEST=false
if fetch_manifest common; then
USED_MANIFEST=true
fetch_manifest "$SDT_PROFILE" || true
apply_manifest "$MANIFEST_DIR/common.tsv"
apply_manifest "$MANIFEST_DIR/$SDT_PROFILE.tsv"
else
t "⚠️ 抓不到安裝清單,退回舊版硬編清單(功能較少但不會壞)" \
"⚠️ Manifest unavailable; falling back to the legacy hardcoded list"
legacy_update_list
fi
# ── 自我更新:把最新的 update.sh / install.sh 抓到 system-dev/scripts/ ──
# 這兩支在 main/scripts/ 下(不在 template/);落地位置新版收進 system-dev/scripts/。
update_file "system-dev/scripts/update.sh" "$REPO_RAW/scripts/update.sh"
update_file "system-dev/scripts/install.sh" "$REPO_RAW/scripts/install.sh"
chmod +x .claude/hooks/*.sh system-dev/scripts/*.sh system-dev/workflows/*.sh 2>/dev/null || true
# ── 使用者資料檔:絕不碰,但提醒「設定可能有新欄位要手動補」──
keep_file ".claude/settings.json"
# ── CLAUDE.md 界標補植(1.19.0)────────────────────────
# 1.18.x 以前的 CLAUDE.md 是「整份下載 + 往後 append」,**沒有任何區段界標**。
# 沒界標 ⇒ update 分不出「哪段是框架的、哪段是你寫的」⇒ 只能永遠不敢碰
# ⇒ 框架後來改的憲法**永遠送不到既有實例**。這一段就是來解這個死結。
#
# 保守到底,寧可什麼都不做也不弄丟一個字:
# · 已有界標 → 什麼都不做(冪等)
# · 沒有界標 → 現有全文**原封不動**包進本地補充區,框架區重鋪在它上面
# · 抓不到憲法範本 → 放棄補植、保留原檔(不留半殘的檔)
# 最壞情況是同一段內容在兩區各一份,由人自己刪——我們不代刪。
if [ -f "CLAUDE.md" ]; then
if grep -q 'sdt:framework begin' CLAUDE.md 2>/dev/null; then
keep_file "CLAUDE.md"
elif [ -n "${CLAUDE_MD_SRC:-}" ]; then
FW_TMP="$(mktemp)"
if curl -sSL "$CLAUDE_MD_SRC" -o "$FW_TMP" 2>/dev/null && ! looks_like_error_page "$FW_TMP"; then
cp CLAUDE.md "CLAUDE.md.before-markers"
{
printf '<!-- sdt:framework begin profile=%s version=%s sha256=%s -->\n' \
"$SDT_PROFILE" "$REMOTE_VER" "$(sdt_sha256 "$FW_TMP" | cut -c1-12)"
cat "$FW_TMP"
printf '<!-- sdt:framework end -->\n\n'
printf '<!-- sdt:local begin — 這一區是你的,update 永遠不會動它 -->\n'
printf '\n<!-- ⬇️ 以下是你原本的 CLAUDE.md,一字未動地搬進來。\n'
printf ' 上面的框架區從現在起由 template 維護;兩邊若有重複的段落,\n'
printf ' 請自己刪掉這裡的舊版本(我們不敢代你刪)。\n'
printf ' 原檔備份:CLAUDE.md.before-markers -->\n\n'
cat "CLAUDE.md.before-markers"
printf '\n<!-- sdt:local end -->\n'
} > CLAUDE.md
rm -f "$FW_TMP"
MIGRATED+=("CLAUDE.md → 補上界標(原檔備份 CLAUDE.md.before-markers")
else
rm -f "$FW_TMP"
keep_file "CLAUDE.md"
fi
else
keep_file "CLAUDE.md"
fi
fi
# ── 結果輸出 ───────────────────────────────────────
echo ""
echo "─────────────────────────────────"
if [ ${#MIGRATED[@]} -gt 0 ]; then
echo ""
t "📦 結構遷移(已收進 system-dev/):" "📦 Layout migrated (moved into system-dev/):"
for f in "${MIGRATED[@]}"; do echo " ⇒ $f"; done
fi
if [ ${#COEXIST[@]} -gt 0 ]; then
echo ""
t "🛑 偵測到 wiki 並存(新舊位置都有資料,需要合併):" \
"🛑 Coexisting wiki detected (both old and new locations have data — needs merging):"
for f in "${COEXIST[@]}"; do echo " ↔ $f"; done
t " 成因:先跑過 install(建了空殼)才遷移,舊位置真資料沒被搬。" \
" Cause: install ran first (created an empty shell), so migration skipped your real data in the old location."
t " 不自動合併(避免覆蓋你的資料)。請叫你的 CC:" \
" Not auto-merged (to avoid overwriting your data). Ask your CC:"
t " 「.claude/wiki/ 和 system-dev/wiki/ 並存,請逐檔比對、把真資料合進 system-dev/,再刪舊的」" \
" \"There are two wikis (.claude/wiki/ and system-dev/wiki/) — diff each file, merge the real data into system-dev/, then delete the old one.\""
fi
if [ ${#NEW[@]} -gt 0 ]; then
echo ""
t "🆕 新功能(舊版沒有,已加入):" "🆕 New features (absent in the old version, now added):"
for f in "${NEW[@]}"; do echo " + $f"; done
fi
if [ ${#UPDATED[@]} -gt 0 ]; then
echo ""
t "⬆️ 已更新(覆蓋成新版):" "⬆️ Updated (overwritten with the new version):"
for f in "${UPDATED[@]}"; do echo " ~ $f"; done
fi
if [ ${#DRIFTED[@]} -gt 0 ]; then
echo ""
t "⚠️ 下列 ${#DRIFTED[@]} 個檔被手改過,這一版**沒有**覆蓋它們:" \
"⚠️ ${#DRIFTED[@]} file(s) were modified by hand and were NOT overwritten:"
for f in "${DRIFTED[@]}"; do
if [ -f "$f.new" ]; then
t " ✋ $f → 新版已放在 $f.new,請 diff" \
" ✋ $f → new version saved as $f.new — please diff"
else
t " ✋ $f (新版抓取失敗,稍後再試)" \
" ✋ $f (couldn't fetch the new version; try again later)"
fi
done
echo ""
t " 這代表什麼:你(或某個 CC)改過框架發下來的檔。" \
" What this means: you (or a CC) edited a file that the framework ships."
t " 兩條路,擇一——別放著不管,放著=框架之後的修正永遠送不到這個檔:" \
" Pick one — leaving it means future framework fixes will never reach this file:"
t " ① 把你的改動寫成框架提案(推薦)——一次修全家,別人也拿得到" \
" 1. Turn your change into a framework proposal (recommended) — fixes it for everyone"
t " ② 放棄本地改動:mv <檔>.new <檔>" \
" 2. Drop your local change: mv <file>.new <file>"
fi
if [ ${#NEW[@]} -eq 0 ] && [ ${#UPDATED[@]} -eq 0 ]; then
echo ""
t "✨ 模板邏輯檔已全部最新,無需變動。" \
"✨ All template logic files are already up to date — no changes needed."
fi
if [ ${#KEPT[@]} -gt 0 ]; then
echo ""
t "🔒 完整保留(你的內容/設定,從未碰過):" \
"🔒 Fully preserved (your content/settings, never touched):"
for f in "${KEPT[@]}"; do echo " = $f"; done
fi
if [ ${#TEMPLATED[@]} -gt 0 ]; then
echo ""
t "📋 客製檔有新版範本(你的原檔沒動,新版另存旁邊,請自行 diff 採納):" \
"📋 Custom files have a new template version (your original is untouched; the new one is saved alongside — diff and adopt as you like):"
for f in "${TEMPLATED[@]}"; do
echo " → $f"
t " 比對:diff \"${f%.template.sh}.sh\" \"$f\"" \
" compare: diff \"${f%.template.sh}.sh\" \"$f\""
done
fi
# ── settings.json 提醒:新模組 hook 可能要手動補 ──
if [ -f ".claude/settings.json" ]; then
MISSING=()
$HAS_WIKI && ! grep -q "session-start-recall.sh" .claude/settings.json && MISSING+=("SessionStart: session-start-recall.sh")
$HAS_WIKI && ! grep -q "wiki-secret-scan.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): wiki-secret-scan.sh")
$HAS_SDD && ! grep -q "sdd-guard.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): sdd-guard.sh")
# ── 1.16.0:兩支 wiki 讀取 hook 自動註冊(不只提醒)──
# 理由:這兩支的整個存在意義就是「不依賴任何人記得」。
# 若靠人看提醒去手動補 settings.json,等於把同一個病搬到安裝環節。
if $HAS_WIKI && command -v python3 >/dev/null 2>&1; then
python3 - <<'PYEOF' 2>/dev/null || true
import json, os
p = ".claude/settings.json"
try:
with open(p) as f: d = json.load(f)
except Exception:
raise SystemExit(0) # 壞掉的 settings 不碰,交給下方提醒
pre = d.setdefault("hooks", {}).setdefault("PreToolUse", [])
blob = json.dumps(pre)
added = []
# 1.16.1:既有註冊若漏 Bash(原版只掛 Grep|Glob|Read)就地補上——
# 破口實例:用 curl/wrangler 亂試部署方法走 Bash,整支 hook 不觸發。
for _e in pre:
if "wiki-first-search" in json.dumps(_e) and "Bash" not in _e.get("matcher", ""):
_e["matcher"] = "Grep|Glob|Read|Bash"; added.append("wiki-first-search(補Bash)")
if "wiki-first-search" not in blob:
pre.append({"matcher": "Grep|Glob|Read|Bash", "hooks": [
{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/wiki-first-search.sh"}]})
added.append("wiki-first-search")
if "subagent-wiki-guard" not in blob:
pre.append({"matcher": "Task", "hooks": [
{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-wiki-guard.sh"}]})
added.append("subagent-wiki-guard")
if added:
with open(p, "w") as f: json.dump(d, f, ensure_ascii=False, indent=2)
print(" ✅ 已自動註冊 wiki 讀取 hook" + "、".join(added))
PYEOF
fi
if [ ${#MISSING[@]} -gt 0 ]; then
echo ""
t "📌 settings.json 是你的設定(沒動),但偵測到缺以下 hook,請手動補上:" \
"📌 settings.json is yours (untouched), but these hooks are missing — please add them manually:"
for h in "${MISSING[@]}"; do echo " • $h"; done
fi
fi
# ── 重寫漂移基準快照(manifest)──────────────────────
# 兩個作用:
# ① 舊實例(1.18.x 以前沒有 manifest)→ 這一輪之後就有了,下次起就能偵測漂移。
# 注意本輪它們**不會**被判漂移(查無登記=不判定),這是刻意的保守:
# 沒有基準就宣稱「你改過」等於瞎猜。第一輪建基準,第二輪起才有話語權。
# ② 已有 manifest → 把「本輪更新過的檔」的 sha 推進到新值。
# 少了這步就會自己造一個 bug:框架自己覆蓋的檔,下一輪被誤判成使用者手改。
# 漂移中的檔**不更新其基準**——它們仍然是「相對於安裝版被改過」,下次還要繼續報。
{
printf '# dest\tclass\tversion\tsha256 —— update 維護的漂移基準,勿手改\n'
for entry in ${MANAGED[@]+"${MANAGED[@]}"}; do
m_dest="$(printf '%b' "$entry" | cut -f1)"
m_class="$(printf '%b' "$entry" | cut -f2)"
[ -f "$m_dest" ] || continue
skip=""
for d in ${DRIFTED[@]+"${DRIFTED[@]}"}; do
[ "$d" = "$m_dest" ] && skip="yes" && break
done
if [ -n "$skip" ]; then
# 保留原登記值(它還在漂移中,基準不能跟著漂)
old="$(manifest_sha "$m_dest")"
[ -n "$old" ] && printf '%s\t%s\t%s\t%s\n' "$m_dest" "$m_class" "$LOCAL_VER" "$old"
continue
fi
printf '%s\t%s\t%s\t%s\n' "$m_dest" "$m_class" "$REMOTE_VER" "$(sdt_sha256 "$m_dest")"
done
} > "$MANIFEST_FILE.tmp" && mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
echo ""
t "🚀 更新完成:${LOCAL_VER}${REMOTE_VER}" "🚀 Update complete: ${LOCAL_VER}${REMOTE_VER}"
t " 下次更新直接跑:bash system-dev/scripts/update.sh" " Next time, just run: bash system-dev/scripts/update.sh"
t " 改了什麼看:CHANGELOG.md" " See what changed: CHANGELOG.md"
echo ""