ISEP 0.1.0:環境設定收成一個 plugin,本機與雲端共用一份

leo 2026-08-20:「同一個 plugin 你用,薄殼也用,保證兩邊同步」
              「我要你幫雲端做薄殼,永遠都有問題,你要做的就是這組設定
                你自己可以 dogfooding」

搬進來:41 支 hook(51 條註冊)/7 支 command/2 支 skill/23 支腳本。
不搬 .env、wiki、docs——那些是知識不是環境。

51 條 hook 路徑全部從 $CLAUDE_PROJECT_DIR/.claude/hooks/ 改成 ${CLAUDE_PLUGIN_ROOT}/hooks/,
零漏網。那正是薄殼一直壞掉的根:雲端 cwd 不是真身,寫死路徑就斷。

尚未驗證:Claude Code 能不能從私有 Gitea repo 裝 marketplace(要憑證)。
下一步就是在本機實際裝一次,通了才動雲端 bootstrap.sh。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:41:46 +08:00
commit c2638668e3
85 changed files with 11879 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# check-bundle-drift.sh — 驗「Arcrun 原始碼 vs bundles 鏡像」有沒有漂移(2026-07-28 立)
#
# 解的病(install-flow-map.md §3.8 的誠實缺口):Arcrun main 改了 cypher/portal
# 但忘記重打包 bundle → 新裝用戶拿到舊引擎(07-27 同事 demo 前夕連線全斷的根因)。
# 之前只靠流程紀律,本腳本把它變機械可查。
#
# 用法:scripts/check-bundle-drift.sh <Arcrun repo 路徑> <bundles clone 路徑>
# 例: scripts/check-bundle-drift.sh /private/tmp/wt-arcrun <scratchpad>/bundles-push2
# 出口碼:0=無漂移;1=有漂移(列出哪個件);2=用法/環境錯
set -euo pipefail
cd "$(dirname "$0")/.."
ARCRUN="${1:?用法:$0 <Arcrun路徑> <bundles clone路徑>}"
BUNDLES="${2:?缺 bundles clone 路徑}"
[ -d "$ARCRUN/console-ui" ] || { echo "$ARCRUN 不像 Arcrun repo"; exit 2; }
[ -f "$BUNDLES/manifest.json" ] || { echo "$BUNDLES 沒有 manifest.json"; exit 2; }
DRIFT=0
echo "① UIconsole-ui/public → tier2/ui"
node products/arcrun-rag/installer/scripts/build-ui-bundle.mjs \
--arcrun "$ARCRUN" --out /tmp/drift-ui.js >/dev/null
A=$(shasum -a 256 /tmp/drift-ui.js | cut -d' ' -f1)
B=$(shasum -a 256 "$BUNDLES/tier2/ui/index.js" | cut -d' ' -f1)
if [ "$A" = "$B" ]; then echo " ✅ 一致($(echo $A | cut -c1-12)"
else echo " 🔴 漂移:原始碼 $(echo $A | cut -c1-12) ≠ 鏡像 $(echo $B | cut -c1-12) → 重跑 build-ui-bundle.mjs 推鏡像"; DRIFT=1; fi
echo "② cyphercypher-executor → tier2/cypher"
( cd "$ARCRUN/cypher-executor" && npx wrangler deploy --dry-run --outdir /tmp/drift-cypher >/dev/null 2>&1 ) \
|| { echo " ⚠️ cypher dry-run 失敗(依賴沒裝?)"; DRIFT=1; }
if [ -f /tmp/drift-cypher/index.js ]; then
A=$(shasum -a 256 /tmp/drift-cypher/index.js | cut -d' ' -f1)
B=$(shasum -a 256 "$BUNDLES/tier2/cypher/index.js" | cut -d' ' -f1)
if [ "$A" = "$B" ]; then echo " ✅ 一致($(echo $A | cut -c1-12)"
else echo " 🔴 漂移:原始碼 $(echo $A | cut -c1-12) ≠ 鏡像 $(echo $B | cut -c1-12) → 重建 cypher 推鏡像"; DRIFT=1; fi
fi
echo "②b 其餘 tier2kbdb/registry/mcp——07-28 真實案例:kbdb bundle 缺 /entries/libraries=庫目錄靜默失效)"
for t2 in kbdb registry mcp; do
( cd "$ARCRUN/$t2" && npx wrangler deploy --dry-run --outdir /tmp/drift-$t2 >/dev/null 2>&1 ) || { echo " ⚠️ $t2 dry-run 失敗"; DRIFT=1; continue; }
A=$(shasum -a 256 /tmp/drift-$t2/index.js | cut -d' ' -f1)
B=$(shasum -a 256 "$BUNDLES/tier2/$t2/index.js" | cut -d' ' -f1)
if [ "$A" = "$B" ]; then echo "$t2 一致($(echo $A | cut -c1-12)"
else echo " 🔴 $t2 漂移:原始碼 $(echo $A | cut -c1-12) ≠ 鏡像 $(echo $B | cut -c1-12)"; DRIFT=1; fi
done
echo "③ daemoncollector 產物 vs 鏡像 zip"
for z in ArcrunRAG-mac-unsigned.zip; do
L="products/arcrun-rag/collector/cmd/arcrun-tray/$z"
[ -f "$L" ] && [ -f "$BUNDLES/daemon/$z" ] || continue
A=$(shasum -a 256 "$L" | cut -d' ' -f1); B=$(shasum -a 256 "$BUNDLES/daemon/$z" | cut -d' ' -f1)
if [ "$A" = "$B" ]; then echo "$z 一致"
else echo " 🔴 $z 漂移(本地重建過沒推?)"; DRIFT=1; fi
done
echo "④ manifest 內 sha vs 檔案實體"
python3 - "$BUNDLES" <<'PY'
import json,sys,hashlib,os
b=sys.argv[1]; m=json.load(open(os.path.join(b,'manifest.json'))); bad=0
for c in m['core']:
p=os.path.join(b,c['main_file'])
real=hashlib.sha256(open(p,'rb').read()).hexdigest()
if real!=c['sha256']:
print(f" 🔴 {c['name']}: manifest sha ≠ 檔案實體"); bad=1
print(" ✅ 27 件 manifest sha 全對" if not bad else "", end="\n" if not bad else "")
sys.exit(bad)
PY
[ $? -ne 0 ] && DRIFT=1
[ $DRIFT -eq 0 ] && echo "✅ 無漂移" || echo "🔴 有漂移——照上面指示重建後推鏡像(arm)+釘 commit+部署安裝器"
exit $DRIFT
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# check-deploy-drift.sh — 線上/Gitea/本機三方版本對帳(2026-07-30 立)
#
# 為什麼存在:07-30 雲端總管看 Gitea 判「線上 919ed39 的程式碼失蹤」,實情是
# t143-t150 十筆 commit 只在本機沒 push——雲端缺「本機領先 Gitea 幾筆」的視野就會誤報。
# 本腳本讓任何 session(含雲端)一條命令看清三方是否一致;有落差 exit 1。
#
# 用法:scripts/check-deploy-drift.sh
# 需要 GITEA_TOKEN(環境變數,或頂層 .env 有就自動吸)。
# 雲端 checkout 沒有 products/gitignore)→ 本機欄自動降級成「—」,只比前兩欄。
set -euo pipefail
cd "$(dirname "$0")/.."
TOP="$(pwd)"
GITEA="https://git.uncle6.me/api/v1/repos/Leo/arcrun-rag/raw"
INSTALLER_REF="${INSTALLER_REF:-fix/t75-remove-config-card}"
LANDING_REF="${LANDING_REF:-feat/daemon}"
LOCAL_REPO="$TOP/products/arcrun-rag"
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$TOP/.env" ]; then
set -a; source "$TOP/.env" 2>/dev/null || true; set +a
fi
[ -n "${GITEA_TOKEN:-}" ] || { echo "❌ 缺 GITEA_TOKEN(環境變數或頂層 .env"; exit 2; }
VER_PAT='20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]+[0-9a-f]\{7\}'
# 從 worker.js 內容推導安裝器版本號(BUNDLE_BUILT + '+' + 釘碼前 7 碼,同 worker.js 顯示邏輯)
derive_installer_ver() {
local built pin
built="$(printf '%s' "$1" | grep -o "BUNDLE_BUILT = '[^']*'" | head -1 | sed "s/.*'\(.*\)'/\1/" || true)"
pin="$(printf '%s' "$1" | grep -o 'arcrun-rag-bundles@[0-9a-f]*' | head -1 | cut -d@ -f2 | cut -c1-7 || true)"
{ [ -n "$built" ] && [ -n "$pin" ]; } && printf '%s+%s' "$built" "$pin" || printf ''
}
# ── 線上 ──
LIVE_INSTALLER="$(curl -s -m 25 "https://install.arcrun.dev/?cb=$RANDOM" | grep -o "$VER_PAT" | head -1 || true)"
LIVE_LANDING="$(curl -s -m 25 "https://rag.arcrun.dev/?cb=$RANDOM" | grep -o "$VER_PAT" | head -1 || true)"
# ── Gitea ──
G_WORKER="$(curl -s -m 25 "$GITEA/installer/oauth-prototype/worker.js?ref=$INSTALLER_REF" -H "Authorization: token $GITEA_TOKEN" || true)"
GITEA_INSTALLER="$(derive_installer_ver "$G_WORKER")"
GITEA_LANDING="$(curl -s -m 25 "$GITEA/landing/wrangler.toml?ref=$LANDING_REF" -H "Authorization: token $GITEA_TOKEN" \
| grep -o 'SITE_BUNDLE_VERSION = "[^"]*"' | sed 's/.*"\(.*\)"/\1/' || true)"
# ── 本機(沒有 products/ 就降級)──
if [ -d "$LOCAL_REPO/.git" ]; then
L_WORKER="$(git -C "$LOCAL_REPO" show "$INSTALLER_REF:installer/oauth-prototype/worker.js" 2>/dev/null || true)"
LOCAL_INSTALLER="$(derive_installer_ver "$L_WORKER")"
LOCAL_LANDING="$(git -C "$LOCAL_REPO" show "$LANDING_REF:landing/wrangler.toml" 2>/dev/null \
| grep -o 'SITE_BUNDLE_VERSION = "[^"]*"' | sed 's/.*"\(.*\)"/\1/' || true)"
# 本機另一種漂移:commit 了沒推(07-30 事故本尊)
UNPUSHED_I="$(git -C "$LOCAL_REPO" log --oneline "gitea/$INSTALLER_REF..$INSTALLER_REF" 2>/dev/null | wc -l | tr -d ' ' || true)"
UNPUSHED_L="$(git -C "$LOCAL_REPO" log --oneline "gitea/$LANDING_REF..$LANDING_REF" 2>/dev/null | wc -l | tr -d ' ' || true)"
HAS_LOCAL=1
else
LOCAL_INSTALLER="—"; LOCAL_LANDING="—"; UNPUSHED_I=""; UNPUSHED_L=""
HAS_LOCAL=0
echo "(本機沒有 products/arcrun-rag——雲端模式,只比線上 vs Gitea)"
fi
printf '%-10s %-20s %-20s %-20s\n' "" "線上" "Gitea" "本機"
printf '%-10s %-20s %-20s %-20s\n' "installer" "${LIVE_INSTALLER:-}" "${GITEA_INSTALLER:-}" "${LOCAL_INSTALLER:-}"
printf '%-10s %-20s %-20s %-20s\n' "landing" "${LIVE_LANDING:-}" "${GITEA_LANDING:-}" "${LOCAL_LANDING:-}"
DRIFT=0
chk() { # chk 名稱 線上 gitea 本機
local name="$1" live="$2" gitea="$3" local_="$4"
[ -n "$live" ] && [ "$gitea" != "" ] && [ "$live" != "$gitea" ] && { echo "$name:線上($live) ≠ Gitea($gitea)"; DRIFT=1; }
if [ "$HAS_LOCAL" = "1" ]; then
[ "$local_" != "" ] && [ "$gitea" != "" ] && [ "$local_" != "$gitea" ] && { echo "$name:本機($local_) ≠ Gitea($gitea)——改了沒推?"; DRIFT=1; }
fi
true
}
chk installer "${LIVE_INSTALLER:-}" "${GITEA_INSTALLER:-}" "${LOCAL_INSTALLER:-}"
chk landing "${LIVE_LANDING:-}" "${GITEA_LANDING:-}" "${LOCAL_LANDING:-}"
[ -n "$UNPUSHED_I" ] && [ "$UNPUSHED_I" != "0" ] && { echo "❌ installer 分支有 $UNPUSHED_I 筆 commit 未推上 Gitea"; DRIFT=1; }
[ -n "$UNPUSHED_L" ] && [ "$UNPUSHED_L" != "0" ] && { echo "❌ landing 分支有 $UNPUSHED_L 筆 commit 未推上 Gitea"; DRIFT=1; }
[ "$DRIFT" = "1" ] && { echo "⚠️ 有漂移(見上)"; exit 1; }
# 抓不到 ≠ 驗證通過(假綠禁令):任何欄位是 ? 就不准報 ✅
if [ -z "${LIVE_INSTALLER:-}" ] || [ -z "${LIVE_LANDING:-}" ] || [ "$GITEA_INSTALLER" = "" ] || [ -z "${GITEA_LANDING:-}" ]; then
echo "⚠️ 有欄位抓不到(網路/權限問題)=無法驗證,不算通過;重跑一次試試"; exit 2
fi
echo "✅ 無漂移"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# component-arm.sh — 顯式解 component-guard 保險(限時 30 分)。
# 只有人類該跑:確認這個「建零件 / service binding」真的過了 docs/component-pr-review-standard.md 才解。
# 仿 scripts/github-arm.sh:寫時間戳到 .component-armedcomponent-guard 檢查 30 分內放行。
PROJ="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
echo "⚠️ 你正在解除『建零件/加 service binding』保險(D27/D28)。"
echo " 先確認:現成積木(零件+code 節點+cypher workflow)真的不夠?且過 component-pr-review-standard.md"
read -r -p "確認要解保險 30 分鐘?(yes/N) " a
[ "$a" = "yes" ] || { echo "取消。"; exit 1; }
date +%s > "$PROJ/.component-armed"
echo "✅ 已解保險 30 分鐘。期間 component-guard 放行建零件/service-binding,並留痕。用完自動失效。"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""daemon-selfcheck.py — 桌面小幫手(daemon)本機狀態自檢,唯讀。
為什麼有這支(leo 2026-08-19):
「現在本機的資料夾跟雲端已經不相連了。」
這句話有好幾種可能的形狀,而它們的處置完全不同:
① 帳號指的那台實例被重裝過(網址沒變、鑰匙換了) ← arcrun-rag#103
② 監看資料夾在本機被搬走/改名/刪掉
③ 資料夾是空的,雲端因此「什麼都沒有」 ← arcrun-rag#106
④ 真的連不上網路
這支不猜,只把**分得出這四種**的事實印出來。
🔴 唯讀:不寫任何檔、不連網、不印任何金鑰(api_keygemini_api_key 一律過濾)。
用法:
python3 scripts/daemon-selfcheck.py # 看 ~/.arcrun-rag/
python3 scripts/daemon-selfcheck.py /some/home # 指定家目錄(測試用)
"""
import json,glob,os,sys
H=os.path.expanduser(sys.argv[1] if len(sys.argv)>1 else "~")
D=os.path.join(H,".arcrun-rag")
SECRET={"api_key","gemini_api_key","password","token"}
def acc(a):
return {k:v for k,v in a.items() if k not in SECRET and not isinstance(v,(dict,list))}
print("狀態目錄:",D, "存在" if os.path.isdir(D) else "❌ 不存在")
try:
c=json.load(open(os.path.join(D,"config.json")))
except Exception as e:
print("config.json 讀不到:",e); c={}
accs=c.get("accounts") or ([{k:c.get(k) for k in ("cypher_url","namespace","email","instance_name")}] if c.get("cypher_url") else [])
print("\n帳號 %d 個:"%len(accs))
for a in accs: print(" -",acc(a))
print("\n監看資料夾:")
for f in (c.get("watch_folders") or ([c["watch_folder"]] if c.get("watch_folder") else [])): print(" -",f, "(本機存在)" if os.path.isdir(os.path.expanduser(f)) else "(❌ 本機找不到)")
for a in accs:
for f in (a.get("watch_folders") or []): print(" -",f,"[帳號 %s]"%a.get("instance_name",""), "(本機存在)" if os.path.isdir(os.path.expanduser(f)) else "(❌ 本機找不到)")
print("\n機器身分:")
try: print(" ",json.load(open(os.path.join(D,"machine.json"))))
except Exception as e: print(" machine.json 沒有或讀不到:",e,"(⇒ 這台還沒鑄過機器 ID,或 daemon 還沒跑過 0.18.33")
print("\nmanifest 的最後錯誤(每份只印一種):")
for p in sorted(glob.glob(os.path.join(D,"manifest*.json"))):
try: m=json.load(open(p))
except Exception as e: print(" ",os.path.basename(p),"讀不到",e); continue
errs={}
for k,v in (m.get("entries") or {}).items():
e=(v or {}).get("last_error") or ""
if e: errs[e]=errs.get(e,0)+1
print(" ",os.path.basename(p),"root=",m.get("root"),"檔數=",len(m.get("entries") or {}))
if not errs: print(" (無錯誤)")
for e,n in sorted(errs.items(),key=lambda x:-x[1])[:3]: print(" x%d %s"%(n,e[:160]))
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# deploy-web.sh — install/landing 的唯一部署入口(2026-07-28 立)
#
# 為什麼存在:leo 問「以後會出現改了沒推的問題嗎?」誠實答案=文字規範會被忘,
# 只有機制可靠。本腳本把「驗語法→部署→驗版本有變→抓線上→記錄」焊成一步——
# 用它部署,就不可能出現 t79 那種「commit 了但部署失敗沒人發現」。
#
# 用法:scripts/deploy-web.sh installer|landing
# 部署狀態記錄在 system-dev/docs/4-guides/deploy-state.json(誰、何時、版本、檔案 sha)
# ⇒ 「改了沒推」隨時可查:比對該檔現在的 sha 與記錄裡的 sha。
set -euo pipefail
cd "$(dirname "$0")/.."
TOP="$(pwd)"
STATE="$TOP/system-dev/docs/4-guides/deploy-state.json"
TARGET="${1:-}"
# 部署源可用 DEPLOY_SRC 覆蓋(07-30:舊寫法寫死 scratchpad 且靜默 fallback
# scratchpad 是 session 專屬目錄,被清掉後部署源就斷——事故傳導路徑,故必印出本次部署源)
SCRATCH="${DEPLOY_SRC:-/private/tmp/claude-501/-Users-youlinhsieh-Documents-tech-projects-InkStoneCo/92a75156-c295-4c79-bfeb-de1a20e4ed26/scratchpad}"
case "$TARGET" in
installer)
DIR="$SCRATCH/rag-installer/installer/oauth-prototype"
if [ ! -d "$DIR" ]; then
echo "⚠️ 部署源 $DIR 不存在(scratchpad 被清?),改用 git 工作樹那份"
DIR="$TOP/products/arcrun-rag/installer/oauth-prototype"
[ -d "$DIR" ] || { echo "❌ 備援部署源也不存在(products 的 checkout 不在含 oauth-prototype 的分支);用 DEPLOY_SRC= 指定部署源"; exit 1; }
fi
FILE="$DIR/worker.js"; CFG="--config ./wrangler.toml"; URL="https://install.arcrun.dev/" ;;
landing)
DIR="$TOP/products/arcrun-rag/landing"
FILE="$DIR/worker.js"; CFG=""; URL="https://rag.arcrun.dev/" ;;
*) echo "用法:$0 installer|landing" >&2; exit 2 ;;
esac
echo "▶ 本次部署源=$DIR"
echo "① esbuild 驗語法(node --check 抓不到 template literal 內的錯,t79 教訓)"
# --external:cloudflare:* Workers runtime 內建模組(如 cloudflare:email),
# esbuild 不認識它們但 wrangler 認得;不標 external 會誤判成語法錯(07-29 landing 踩到)。
( cd "$DIR" && npx esbuild worker.js --bundle --format=esm --outfile=/dev/null \
--external:cloudflare:* --external:./migrations.json --external:./workflows.json 2>/dev/null \
|| npx esbuild worker.js --bundle --format=esm --outfile=/dev/null --external:cloudflare:* )
echo "①a 釘死 URL 必須真的存在(07-29 事故:釘碼用短碼拼湊出不存在的 commit,安裝全 404"
if [ "$TARGET" = "installer" ]; then
BASE=$(grep -o "https://cdn.jsdelivr.net/gh/[^']*" "$FILE" | head -1)
CODE=$(curl -s -m 30 -o /dev/null -w '%{http_code}' "$BASE/manifest.json")
[ "$CODE" = "200" ] || { echo "❌ BUNDLE_BASE 指向的 manifest 回 $CODE(釘碼錯或 bundle 沒推):$BASE"; exit 1; }
echo " $BASE/manifest.json → 200"
fi
echo "①b 文案契約測試(防「改好的又改錯」——禁句出現=拒絕部署)"
if [ -f "$DIR/copy-contract.test.mjs" ]; then ( cd "$DIR" && node copy-contract.test.mjs ); fi
echo "①c 防回退閘(07-30:版本號變動必須是人明示的決定,不能是部署源掉包的副作用)"
LIVE_VER="$(curl -s -m 25 "${URL}?cb=$RANDOM" | grep -o '20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]+[0-9a-f]\{7\}' | head -1 || true)"
if [ "$TARGET" = "installer" ]; then
NEXT_BUILT="$(grep -o "BUNDLE_BUILT = '[^']*'" "$FILE" | head -1 | sed "s/.*'\(.*\)'/\1/" || true)"
NEXT_PIN="$(grep -o 'arcrun-rag-bundles@[0-9a-f]*' "$FILE" | head -1 | cut -d@ -f2 | cut -c1-7 || true)"
{ [ -n "$NEXT_BUILT" ] && [ -n "$NEXT_PIN" ]; } && NEXT_VER="${NEXT_BUILT}+${NEXT_PIN}" || NEXT_VER=""
else
NEXT_VER="$(grep -o 'SITE_BUNDLE_VERSION = "[^"]*"' "$DIR/wrangler.toml" 2>/dev/null | sed 's/.*"\(.*\)"/\1/' || true)"
fi
if [ -n "$LIVE_VER" ] && [ -n "$NEXT_VER" ] && [ "$LIVE_VER" != "$NEXT_VER" ] && [ "${ALLOW_VERSION_CHANGE:-}" != "1" ]; then
echo "❌ 版本號會變:線上 $LIVE_VER → 要部署 $NEXT_VER"
echo " 換版是人的決定:確定要換,重跑時帶 ALLOW_VERSION_CHANGE=1"
exit 1
fi
echo " 線上 ${LIVE_VER:-(抓不到)} / 待部 ${NEXT_VER:-(推導不出)}$([ "${ALLOW_VERSION_CHANGE:-}" = "1" ] && echo '(已明示換版)')"
echo "①d 部署源必須落在 git07-30:t143-t150 十筆只在本機沒推、雲端誤判程式碼失蹤的教訓)"
PENDING_GIT=false
if git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
DIRTY="$(git -C "$DIR" status --porcelain -- "$(basename "$FILE")" | head -1 || true)"
UNPUSHED="$(git -C "$DIR" log --oneline '@{u}..HEAD' 2>/dev/null | wc -l | tr -d ' ' || true)"
[ -n "$DIRTY" ] && { echo " ⚠️⚠️ $(basename "$FILE") 有未 commit 修改——部署的東西 git 裡沒有!"; PENDING_GIT=true; }
if [ "$UNPUSHED" = "" ] || ! git -C "$DIR" rev-parse '@{u}' >/dev/null 2>&1; then
echo " ⚠️⚠️ 所在分支沒設 upstream——commit 了也沒人推得到 Gitea"; PENDING_GIT=true
elif [ "$UNPUSHED" != "0" ]; then
echo " ⚠️⚠️ 有 $UNPUSHED 筆 commit 未推上 Giteagit log @{u}..HEAD"; PENDING_GIT=true
fi
$PENDING_GIT || echo " 部署源乾淨且已同步 Gitea ✓"
else
echo " ⚠️⚠️ 部署源不在任何 git 工作樹裡——這份程式碼沒有版控!"; PENDING_GIT=true
fi
# 封測期不擋出貨,但留痕:pending_git=true =「線上跑的和 Gitea 不一致」查得到
STATE_PATH="$STATE" python3 - "$TARGET" "$PENDING_GIT" <<'PY'
import json,sys,os
p=os.environ.get('STATE_PATH'); t,pg=sys.argv[1],sys.argv[2]=='true'
try: d=json.load(open(p))
except Exception: d={}
d.setdefault(t,{})['pending_git']=pg
json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)
PY
echo "② 部署(uncle6"
set -a; source "$TOP/.env" 2>/dev/null || true; set +a
OUT="$(cd "$DIR" && CLOUDFLARE_ACCOUNT_ID=58309bb90fd93ad6d0fe0aae99170e9d npx wrangler deploy $CFG 2>&1)"
VID="$(printf '%s' "$OUT" | grep -o 'Current Version ID: [a-f0-9-]*' | awk '{print $4}')"
[ -n "$VID" ] || { echo "❌ 部署失敗(沒有 Version ID):"; printf '%s\n' "$OUT" | tail -8; exit 1; }
echo " Version ID: $VID"
echo "③ 版本必須有變(防『部署了但還是舊版』)"
PREV="$(python3 -c "
import json,sys
try: print(json.load(open('$STATE')).get('$TARGET',{}).get('version',''))
except Exception: print('')" 2>/dev/null)"
if [ "$VID" = "$PREV" ] && [ -n "$PREV" ]; then
echo "❌ Version ID 與上次相同($VID)=內容沒變或部署被跳過"; exit 1
fi
echo "④ 線上實測(帶 cache-buster"
CODE="$(curl -s -m 25 -o /tmp/deploy_check.html -w '%{http_code}' "${URL}?cb=$RANDOM")"
[ "$CODE" = "200" ] || { echo "❌ 線上回 $CODE"; exit 1; }
echo " $URL → 200$(wc -c </tmp/deploy_check.html | tr -d ' ') B"
echo "⑤ 記錄(供 drift 檢查:檔案 sha ≠ 記錄 sha = 改了沒推)"
SHA="$(shasum -a 256 "$FILE" | cut -d' ' -f1)"
STATE_PATH="$STATE" python3 - "$TARGET" "$VID" "$SHA" <<'PY'
import json,sys,datetime,os
p=os.environ.get('STATE_PATH')
t,v,sha=sys.argv[1],sys.argv[2],sys.argv[3]
try: d=json.load(open(p))
except Exception: d={}
d[t]={'version':v,'file_sha256':sha,'deployed_at':datetime.datetime.now().isoformat(timespec='seconds')}
json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)
print(f" {t}: {v} / sha {sha[:16]}")
PY
echo "✅ 完成。之後查「改了沒推」:shasum 該檔 vs $STATE"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# B1 備援三件套之二:每日 git bundle 備份(de-Gitea brief B1
# 對本機所有 repogit fsck 驗完整 → git bundle 打包全部 refs → 存 ~/Backups/git-bundles/
# R2 上傳段:等 youlin 帳號 R2 開通後補(TODO 標記處)。保留最近 7 份。
set -uo pipefail
ROOT="/Users/youlinhsieh/Documents/tech_projects/InkStoneCo"
DEST="$HOME/Backups/git-bundles"
STAMP=$(date +%Y%m%d)
KEEP=7
mkdir -p "$DEST"
REPOS=(. matrix/arcrun matrix/arcrun-components matrix/arcrun-gui matrix/arcrun-mcp
matrix/inkstone-admin matrix/kbdb-graph-plugin matrix/kbdb-ingest-plugin
products/arcrun-rag products/dev-finally-click products/finally-click products/u6u-studio
polaris/AI-Meka polaris/OpenHarness polaris/mira arcrun_harness)
fail=0
for r in "${REPOS[@]}"; do
dir="$ROOT/$r"
[ -d "$dir/.git" ] || { echo "SKIP $r (no .git)"; continue; }
name=$(basename "$(cd "$dir" && pwd)")
[ "$r" = "." ] && name="InkStoneCo"
if ! git -C "$dir" fsck --no-progress --no-dangling >/dev/null 2>&1; then
echo "❌ FSCK FAIL $r"; fail=1; continue
fi
out="$DEST/${name}-${STAMP}.bundle"
if git -C "$dir" bundle create "$out" --all >/dev/null 2>&1; then
echo "$name $(du -h "$out" | cut -f1)"
else
echo "❌ BUNDLE FAIL $r"; fail=1
fi
# 保留最近 KEEP 份
ls -t "$DEST/${name}-"*.bundle 2>/dev/null | tail -n +$((KEEP+1)) | xargs rm -f 2>/dev/null
done
# TODO(R2)youlin 帳號 R2 開通後,在此加 rclone/wrangler 上傳 $DEST 至獨立 bucket(與 git server 不同桶)
echo "---"
echo "bundles at $DEST"
exit $fail
+211
View File
@@ -0,0 +1,211 @@
#!/bin/bash
# gitea-arm-check.sh — 核對 leo 有沒有在「該請求指定的那張票」上回覆某個 ARM 請求的代碼
#
# 用法:
# scripts/gitea-arm-check.sh # 掃描所有還沒過期的本地待核請求(可能橫跨多張票)
# scripts/gitea-arm-check.sh ARM-xxxxxxxx # 只核對這一組
#
# 判定放行的三個條件,**缺一不可**:
# ① 這組 nonce 從沒被消耗過(防重放)、且還沒過期(範圍與時效)——本地就能判,不必碰網路
# ② 留言作者的 login 精確等於 "Leo"(不是 id、不是顯示名——那些會變)
# ③ 留言內文含這組 nonce,且晚於「機器貼出請求」的那則留言、且出現在**該請求貼出時指定的那張票**
# (票號隨每個請求存在本地待核檔的 `issue` 欄位裡,2026-08-16 起不再是寫死的單一頻道票,
# 見 lib/gitea-arm-common.sh 檔頭;別的票再怎麼有 Leo 回過相似字串的留言都不算數,
# 因為根本不會被拿去查——每個 nonce 只查它自己那張票)
#
# 成功:印 "ARMED: <mission>"、把 nonce 標記已消耗、刪掉本地待核檔、exit 0(單次用完即失效)
# 失敗(沒有/過期/被消耗過/Gitea 打不到/回應解不出來):印原因到 stderr、exit 1
# ——**fail-closed**:任何看不懂的狀況都當失敗,不放行。
#
# 🔴 順序刻意是「先本地過濾,才打網路」:
# 過期/已消耗這兩種本地就能判定,**不該因為 Gitea 打不到而連本地清理都做不了**
# (早期版本把網路呼叫放最前面,測試才抓到:token 失效時,過期的待核檔永遠不會被清掉,
# 因為程式在走到「清掉它」那行之前就已經因為 fail-closed 提前 exit 了)。
#
# 🔴 這支只在「機器需要解閘的當下」被閘呼叫一次——不是排程輪詢(D20 紅線)。
# 呼叫方一次只打一發 GET **對每一張還有待核請求的票**(不同票各打一次,
# 同一張票不管上面掛幾組 nonce 只打一次、共用回應),不建任何迴圈/背景行程來等 leo 回覆。
set -uo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck source=lib/gitea-arm-common.sh
. "$SCRIPT_DIR/lib/gitea-arm-common.sh"
STATE_DIR=$(gitea_arm_state_dir)
PENDING_DIR="$STATE_DIR/pending"
# ── --peek:只看有沒有核准,**不消耗**leo 2026-08-16 立)───────────────────
#
# 🔴 為什麼要有這個模式(同日實撞,而且是「發生過沒解決,下次又踩」的那種):
# 總管為了「先確認 leo 貼了碼」單獨跑了一次本檔 ⇒ **那一次就把核准消耗掉了**
# ⇒ 真正要用的 gitea-arm-to-github-armed.sh 再來拿時已經沒有待核請求
# ⇒ **leo 的動作被浪費一次,他得再貼一次。**
#
# leo 原話:「這不是問題,問題是**已經發生好幾次了,你發生過以後沒有解決,下次還是碰到**。
# 你可以註記只能一次,要不然**改成可以一次檢查一次發射**。」
# ⇒ 選後者:**檢查不消耗,發射才消耗。**
#
# ⛔ 防重放在 peek 模式下**照樣生效**——已被消耗過的 nonce peek 也不會回 ARMED
# 否則 peek 就變成繞過重放保護的後門。
# 🔴 2026-08-16 第二輪(leo 當場點破,同日第一輪的修是錯方向):
# leo 原話:「**你檢查是否有貼可以用查看 issue 的 API 不應該會用掉,所以你查看的方式不對。**」
# ⇒ 讀留言是 GET,本來就不該有副作用。「消耗」是本檔自己黏在讀取上的**本地**記帳。
# ⇒ 所以正解不是「加一個 --peek 選配」(第一輪那樣做=預設仍然咬人,
# 只有記得加旗標的人不被咬),而是**把預設反過來**:
#
# 不帶旗標 = 只讀,永不消耗 ← 任何人手動探一眼都安全
# --consume = 真的要發射,才消耗 ← 只有「放行那一步」自己會帶
#
# 這也正是 mistakes.md:237 兩天前就寫著的「待修:check 應該要有唯讀模式」,
# 以及 231 行「想知道 leo 貼了沒 → 讀票,不要用 check 去探」。
# **寫下來了,08-16 還是照撞一次** ⇒ 所以改成機制上撞不到,不是再寫一條提醒。
#
# ⛔ 防重放不變:已消耗過的 nonce 在唯讀模式下**照樣不回 ARMED**,
# 否則唯讀就變成繞過重放保護的後門。
#
# 🐛 第一輪的實際 bug(也在這裡一起修):舊碼 `WANT_NONCE="${1:-}"` 會把 `--peek`
# 當成「要篩的 nonce」吃掉 ⇒ 一個都不匹配 ⇒ 靜靜回「沒有還活著的待核請求」。
# ⇒ 旗標與 nonce 現在在**同一個迴圈**裡分開解析,旗標永遠不會被當成 nonce。
CONSUME=0
WANT_NONCE=""
for _a in "$@"; do
case "$_a" in
--consume) CONSUME=1 ;;
--peek) : ;; # 相容舊呼叫;現在唯讀本來就是預設,這個旗標等同不做事
-*) echo "❌ 不認得的參數:$_a(只接受 --consume--peek<nonce>" >&2; exit 1 ;;
*) [ -z "$WANT_NONCE" ] && WANT_NONCE="$_a" ;;
esac
done
CONSUMED_LOG=$(gitea_arm_consumed_log)
NOW=$(date +%s)
shopt -s nullglob
PENDING_FILES=("$PENDING_DIR"/*.json)
shopt -u nullglob
if [ "${#PENDING_FILES[@]}" -eq 0 ]; then
echo "沒有待核的 ARM 請求(先跑 scripts/gitea-arm-request.sh" >&2
exit 1
fi
# ── 第一遍:純本地過濾,完全不碰網路 ──────────────────────────────────
# 篩出「還活著、還沒被用過」的候選;順手清掉壞掉/過期/已消耗的本地檔。
# 🔴 不用 `declare -A`(本機 /bin/bash 是 3.2,沒有關聯陣列)——
# 改用一個 TSV 暫存檔存 nonce/issue/request_created_at/mission,逐行讀。
# 每個請求各自帶著自己的票號(issue 欄位,2026-08-16 起不再是單一頻道票)。
ELIGIBLE_TSV=$(mktemp)
RESP_DIR=$(mktemp -d)
trap 'rm -f "$ELIGIBLE_TSV"; rm -rf "$RESP_DIR"' EXIT
for PF in "${PENDING_FILES[@]}"; do
[ -f "$PF" ] || continue
NONCE=$(jq -r '.nonce // empty' "$PF" 2>/dev/null)
if [ -z "$NONCE" ]; then
echo "⚠️ 略過壞掉的待核檔:$PF" >&2
continue
fi
if [ -n "$WANT_NONCE" ] && [ "$NONCE" != "$WANT_NONCE" ]; then
continue
fi
MISSION=$(jq -r '.mission // empty' "$PF" 2>/dev/null)
ISSUE=$(jq -r '.issue // empty' "$PF" 2>/dev/null)
# 2026-08-16:請求可能貼在同 org 的別的 repo(出貨票在 arcrun-rag)。
# 舊記錄沒有 .repo 這欄 → 沿用預設 InkStoneCo,不破壞相容。
_PR=$(jq -r '.repo // empty' "$PF" 2>/dev/null)
[ -n "$_PR" ] && { gitea_arm_set_repo "$_PR" || continue; }
EXPIRES_AT=$(jq -r '.expires_at // empty' "$PF" 2>/dev/null)
REQUEST_CREATED_AT=$(jq -r '.request_created_at // empty' "$PF" 2>/dev/null)
if ! gitea_arm_valid_issue "$ISSUE"; then
echo "⚠️ 略過壞掉的待核檔(issue 缺失或非數字,$PF)——這份請求檔是舊版格式或損毀,不猜票號" >&2
continue
fi
case "$EXPIRES_AT" in ''|*[!0-9]*) echo "⚠️ 略過壞掉的待核檔(expires_at 非數字):$PF" >&2; continue;; esac
if [ -z "$REQUEST_CREATED_AT" ]; then
echo "⚠️ 略過壞掉的待核檔(缺 request_created_at):$PF" >&2
continue
fi
# 過期:清掉本地待核檔(沒被用過,不算消耗),繼續看下一個
if [ "$NOW" -ge "$EXPIRES_AT" ]; then
echo "$NONCE#$ISSUE)已過期,清掉待核請求" >&2
rm -f "$PF"
continue
fi
# 防重放:這個 nonce 先前是否已被消耗過(即使 Gitea 上那則留言還在,也不能再解一次)
if [ -f "$CONSUMED_LOG" ] && grep -qF "$(printf '%s\t' "$NONCE")" "$CONSUMED_LOG" 2>/dev/null; then
echo "🚫 $NONCE#$ISSUE)已經被用過一次,不能重放" >&2
rm -f "$PF"
continue
fi
printf '%s\t%s\t%s\t%s\n' "$NONCE" "$ISSUE" "$REQUEST_CREATED_AT" "$MISSION" >> "$ELIGIBLE_TSV"
done
if [ ! -s "$ELIGIBLE_TSV" ]; then
echo "沒有還活著、還沒用過的待核請求 → 不放行" >&2
exit 1
fi
TOKEN=$(gitea_arm_token) || { echo "❌ 讀不到 GITEA_TOKEN_CLAUDE_CODE → fail-closed,不放行" >&2; exit 1; }
# ── 第二遍:對每一張出現在 ELIGIBLE_TSV 的票各打一次 GET(同票只打一次,跨票各自獨立)──
for ISSUE in $(cut -f2 "$ELIGIBLE_TSV" | sort -u); do
RESP=$(curl -s -w '\n%{http_code}' --max-time 15 \
-H "Authorization: token $TOKEN" \
"$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE/comments?limit=100")
CURL_RC=$?
HTTP_CODE=$(printf '%s' "$RESP" | tail -1)
RESP_BODY=$(printf '%s' "$RESP" | sed '$d')
if [ "$CURL_RC" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
echo "❌ #$ISSUE:Gitea 打不到/回應非 200curl_rc=$CURL_RC, http=$HTTP_CODE)→ 該票所有待核請求 fail-closed" >&2
continue
fi
if ! printf '%s' "$RESP_BODY" | jq -e 'type == "array"' >/dev/null 2>&1; then
echo "❌ #$ISSUE:Gitea 回應不是預期的陣列格式 → 該票所有待核請求 fail-closed" >&2
continue
fi
printf '%s' "$RESP_BODY" > "$RESP_DIR/$ISSUE.json"
done
# ── 第三遍:逐一比對,每組 nonce 只看它自己那張票抓回來的留言 ──────────────
RESULT=1
while IFS=$'\t' read -r NONCE ISSUE AFTER MISSION; do
[ -n "$NONCE" ] || continue
RESP_FILE="$RESP_DIR/$ISSUE.json"
if [ ! -f "$RESP_FILE" ]; then
echo "$NONCE#$ISSUE):該票打不到,不放行" >&2
continue
fi
# 核心判定:作者精確是 Leo、內文含這組 nonce、時間晚於請求留言——
# 且比對只在 $RESP_FILE 這一張票的留言範圍內,不會混到別張票
MATCH=$(jq -r \
--arg nonce "$NONCE" --arg after "$AFTER" --arg who "$GITEA_ARM_APPROVER_LOGIN" '
[ .[] | select(.user.login == $who) | select(.body | contains($nonce)) | select(.created_at > $after) ]
| sort_by(.created_at) | .[0].id // empty
' "$RESP_FILE")
PF="$PENDING_DIR/$NONCE.json"
if [ -n "$MATCH" ]; then
if [ "$CONSUME" -eq 1 ]; then
printf '%s\t%s\t%s\t#%s\n' "$NONCE" "$(date '+%Y-%m-%d %H:%M:%S')" "$MISSION" "$ISSUE" >> "$CONSUMED_LOG"
rm -f "$PF"
echo "ARMED: $MISSION"
else
# 預設:只讀,不消耗——待核請求留著,等真正要放行的那一步帶 --consume 才用掉
echo "ARMED(唯讀): $MISSION"
echo " ⚠️ 唯讀模式,**核准還在**(沒有被用掉)。真正要放行的那一步會自己帶 --consume。" >&2
fi
RESULT=0
[ -n "$WANT_NONCE" ] && break
else
echo "$NONCE#$ISSUE)還沒等到 Leo 的回覆" >&2
fi
done < "$ELIGIBLE_TSV"
exit $RESULT
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# gitea-arm-request.sh — 開一個「等 leo 在 Gitea 上回覆」的請求
#
# 用法:scripts/gitea-arm-request.sh <票號> "任務描述" [有效分鐘,預設30,上限60]
#
# 這支**machine 自己就能跑**(不像 scripts/github-arm.sh 要求終端機互動)——
# 因為「請求」本身不是安全邊界,**leo 用 Leo 帳號回覆才是**。
# 這支只負責:生一組一次性代碼、貼上呼叫端指定的那張 Gitea 票、把代碼與人話訊息印出來
# (讓呼叫者拿去用 notify_leoTelegram 轉告 leo;本支不碰 Telegram)。
#
# 🪦 2026-08-16 起票號不再寫死(原本固定貼 inkstone/InkStoneCo#34「頻道票」)——
# leo:「我不要把所有的票都放在一個 issues,不然就難 track 歷史記錄」。
# **解哪張票的保險,就把請求貼在那張票上**:要出貨 X 就傳 X 那張票的號碼,
# 不要再統一貼 #34(#34 保留 open 當歷史,見 issues/34#issuecomment-2804)。
#
# 之後由 scripts/gitea-arm-check.sh 核對 leo 有沒有在同一張票回覆同一組代碼。
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck source=lib/gitea-arm-common.sh
. "$SCRIPT_DIR/lib/gitea-arm-common.sh"
ISSUE="${1:-}"
MISSION="${2:-}"
MINUTES="${3:-30}"
if [ -z "$ISSUE" ] || [ -z "$MISSION" ]; then
echo "用法:scripts/gitea-arm-request.sh <票號> \"任務描述\" [分鐘,預設30,上限60]" >&2
echo "例:scripts/gitea-arm-request.sh 41 \"出 prodxxx\"" >&2
exit 1
fi
# 票號可寫 `N`(預設 InkStoneCo)或 `repo#N`(同 org 的別的 repo,例:arcrun-rag#115
case "$ISSUE" in
*"#"*)
_R="${ISSUE%%#*}"; ISSUE="${ISSUE##*#}"
gitea_arm_set_repo "$_R" || exit 1;;
esac
if ! gitea_arm_valid_issue "$ISSUE"; then
echo "❌ 票號要是純數字,或 repo#N(收到:$ISSUE)——解哪張票的保險就填那張票" >&2
exit 1
fi
case "$MINUTES" in
''|*[!0-9]*)
echo "❌ 第三個參數要純數字分鐘(收到:$MINUTES" >&2
exit 1;;
esac
[ "$MINUTES" -le 60 ] || MINUTES=60
[ "$MINUTES" -ge 1 ] || MINUTES=1
TOKEN=$(gitea_arm_token) || { echo "❌ 讀不到 GITEA_TOKEN_CLAUDE_CODE(頂層 .env" >&2; exit 1; }
NONCE="ARM-$(python3 -c 'import secrets; print(secrets.token_hex(4))')"
NOW=$(date +%s)
EXPIRES=$((NOW + MINUTES * 60))
EXPIRES_HUMAN=$(date -r "$EXPIRES" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -d "@$EXPIRES" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$EXPIRES")
STATE_DIR=$(gitea_arm_state_dir)
PENDING_FILE="$STATE_DIR/pending/${NONCE}.json"
# ── 貼留言到呼叫端指定的那張票 ─────────────────────────────────────────
COMMENT_BODY=$(MISSION="$MISSION" NONCE="$NONCE" EXPIRES_HUMAN="$EXPIRES_HUMAN" MINUTES="$MINUTES" python3 -c '
import os
mission = os.environ["MISSION"]
nonce = os.environ["NONCE"]
expires_human = os.environ["EXPIRES_HUMAN"]
minutes = os.environ["MINUTES"]
print(f"""🔐 ARM 請求
任務:{mission}
有效時限:{minutes} 分鐘內({expires_human} 前)
📱 回這則、貼上下面這串就解鎖這一次(用完就失效,別的請求不能借用):
{nonce}
""")
')
REQUEST_JSON=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}')
RESP=$(curl -s -w '\n%{http_code}' -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$REQUEST_JSON" \
"$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE/comments")
HTTP_CODE=$(printf '%s' "$RESP" | tail -1)
RESP_BODY=$(printf '%s' "$RESP" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "❌ 貼留言失敗(HTTP $HTTP_CODE),沒有建立請求——票號 #$ISSUE 存在嗎?" >&2
echo "$RESP_BODY" >&2
exit 1
fi
COMMENT_ID=$(printf '%s' "$RESP_BODY" | jq -r '.id // empty')
CREATED_AT=$(printf '%s' "$RESP_BODY" | jq -r '.created_at // empty')
if [ -z "$COMMENT_ID" ] || [ -z "$CREATED_AT" ]; then
echo "❌ Gitea 回應解不出留言 id/時間,視為失敗(fail-closed,沒寫本地狀態)" >&2
exit 1
fi
jq -n \
--arg nonce "$NONCE" \
--arg mission "$MISSION" \
--arg issue "$ISSUE" \
--arg repo "$GITEA_ARM_REPO" \
--argjson requested_at "$NOW" \
--argjson expires_at "$EXPIRES" \
--arg request_comment_id "$COMMENT_ID" \
--arg request_created_at "$CREATED_AT" \
'{nonce:$nonce, mission:$mission, issue:$issue, repo:$repo, requested_at:$requested_at, expires_at:$expires_at,
request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$PENDING_FILE"
# ── 貼完請求就指派給 leo 掛 Humanleo 2026-08-16:「沒有指派,沒有加 Human」)──
#
# 為什麼要自動:總管同一天才立下「指派給 leo 的每一張都要真的在等他」,
# 貼完 ARM 就忘了做 ⇒ leo 的「指派給您的」清單漏掉那張真正在等他的票。
# ⇒ 這件事不該靠人記得:**貼出 ARM 請求 = 這張票此刻在等 leo**,兩者是同一件事。
_LBL=$(curl -s -H "Authorization: token $TOKEN" \
"$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/labels?limit=60" \
| jq -r '.[] | select(.name=="Human") | .id' 2>/dev/null | head -1)
_CUR=$(curl -s -H "Authorization: token $TOKEN" \
"$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE" \
| jq -r '[.labels[].id] | @csv' 2>/dev/null | tr -d '"')
if [ -n "$_LBL" ]; then
_IDS=$(printf '%s,%s' "${_CUR:-}" "$_LBL" | tr ',' '\n' | grep -E '^[0-9]+$' | sort -u | paste -sd, -)
curl -s -o /dev/null -X PUT -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"labels\":[${_IDS}]}" \
"$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE/labels"
fi
curl -s -o /dev/null -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"assignees\":[\"$GITEA_ARM_APPROVER_LOGIN\"]}" \
"$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE"
echo " (已指派給 $GITEA_ARM_APPROVER_LOGIN 並掛上 Human——他的「指派給您的」看得到這張)"
echo "✅ 已在 Gitea 貼出請求:https://git.uncle6.me/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE"
echo ""
echo "── 轉告 leo(照這樣發,一句狀況+一個可回的詞)──────────────────"
echo "[總管] 需要你解一道鎖:$MISSION"
echo "回這則 Gitea 留言貼「$NONCE」就好($MINUTES 分鐘內有效):https://git.uncle6.me/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE"
echo "──────────────────────────────────────────────────────────────"
echo ""
echo "nonce=$NONCE"
echo "issue=$ISSUE"
echo "expires_at=$EXPIRES ($EXPIRES_HUMAN)"
echo "本地請求檔:$PENDING_FILE"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# gitea-arm-status.sh — 列出目前還「活著」的 ARM 待核請求(純讀、不打網路、不消耗任何東西)
#
# 🔴 為什麼存在(2026-08-13 實撞):
# 總管在測試 gitea-arm 機制時,為了重置測試狀態隨手 `rm -rf .claude/gitea-arm`
# 把剛請 leo 處理的那筆真請求也一起清掉了——leo 差點拿一組已經失效的代碼去回覆。
# **叫人做事然後把前提刪掉,浪費的是他的注意力**,而那正是這整套系統最該省的東西。
#
# ⇒ 規約:**發出請求後,不准動該請求的狀態檔**
# (不 `rm -rf .claude/gitea-arm/`、不手動刪 `pending/*.json`、不改內容)。
# 讓它自然被 `scripts/gitea-arm-check.sh` 消耗,或自然過期。
# 真的要清「已知作廢」的單一 nonce,也只刪那一個檔,不要整個目錄清空。
#
# ⇒ 這支的存在本身就是防呆:**清東西前先看看有沒有還活著的請求**,
# 別在不知道自己清了什麼的狀態下動那個目錄。
set -uo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck source=lib/gitea-arm-common.sh
. "$SCRIPT_DIR/lib/gitea-arm-common.sh"
STATE_DIR=$(gitea_arm_state_dir)
PENDING_DIR="$STATE_DIR/pending"
NOW=$(date +%s)
shopt -s nullglob
PENDING_FILES=("$PENDING_DIR"/*.json)
shopt -u nullglob
if [ "${#PENDING_FILES[@]}" -eq 0 ]; then
echo "(沒有待核請求,這個目錄現在動它是安全的)"
exit 0
fi
echo "🔴 下面這些請求還活著——動 .claude/gitea-arm/ 之前先看這份清單:"
echo ""
for PF in "${PENDING_FILES[@]}"; do
NONCE=$(jq -r '.nonce // "?"' "$PF" 2>/dev/null)
MISSION=$(jq -r '.mission // "?"' "$PF" 2>/dev/null)
ISSUE=$(jq -r '.issue // "?"' "$PF" 2>/dev/null)
EXPIRES_AT=$(jq -r '.expires_at // 0' "$PF" 2>/dev/null)
case "$EXPIRES_AT" in ''|*[!0-9]*) EXPIRES_AT=0;; esac
EXPIRES_HUMAN=$(date -r "$EXPIRES_AT" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -d "@$EXPIRES_AT" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "?")
if [ "$NOW" -ge "$EXPIRES_AT" ]; then
STATUS="⌛ 已過期(下次 check 會自動清掉,不必手動動它)"
else
STATUS="⏳ 還在等 Leo 回覆($EXPIRES_HUMAN 前有效)"
fi
echo " $NONCE#$ISSUE)— $MISSION"
echo " $STATUS"
done
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
# gitea-arm-to-github-armed.sh — 把「leo 在 Gitea 票上的核准」轉成出貨線認得的 `.github-armed`
#
# 🔴 這支存在的理由(2026-08-13 實撞):
# arm 機制上線當天,總管把它接到了 `main-and-prod-push-guard.sh`(推 main 那道閘),
# **但出貨線 `ship.mjs` 的 preflight 只認舊的 `.github-armed`**
# ⇒ leo 在票上回了碼、`gitea-arm-check.sh` 回 ARMED**出貨照樣斷在第 1 站**。
# ⇒ 總管審 PR 時查了「機制本身安不安全」,**沒查「它有沒有接到真正擋路的地方」**。
# 同款形狀:做好了一半,而那一半不是會擋住人的那半。
#
# 🔴 這支**不是**繞過 D20,它是同一道閘的另一個入口:
# `.github-armed` 代表的權威來自「leo 親自做了一個機器做不到的動作」。
# 舊入口=他在終端機打字;新入口=他在 Gitea 票上回覆一組碼。
# **兩者的不可偽造性來源不同,但都不在機器手上**:
# · 終端機:機器碰不到那個鍵盤
# · Gitea:機器寫不出作者是 `Leo` 的留言(2026-08-13 身分分離完成,總管親手攻擊驗過)
#
# ⛔ **唯一的安全條件**:本檔**只在 `gitea-arm-check.sh` 成功時**才寫出保險。
# 那支是 fail-closed 的(沒回覆/過期/已消耗/作者不是 Leo/Gitea 不通 → 全部 exit 1),
# 而且核准者與頻道票**寫死在 `lib/gitea-arm-common.sh`**,環境變數改不動。
# ⇒ 拿掉那個條件,這支就變成「AI 自己批准自己發佈」——**那正是 D20 要擋的事**。
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
ARMED="$ROOT/.github-armed"
# ── 唯一的授權來源:leo 在 Gitea 票上的回覆 ────────────────────────────
# 不捕捉、不吞錯——check 失敗就讓它把原因印到 stderr 並中止本支。
# 🔴 `--consume` 是必要的(2026-08-16 起 check 預設唯讀):本檔會**寫出保險**,
# 那就是「放行」本身 ⇒ 這一次核准必須當場作廢,否則同一組碼能重複解保險。
OUT=$("$ROOT/scripts/gitea-arm-check.sh" --consume) || {
echo "" >&2
echo "⛔ 沒有取得 leo 的核准 ⇒ 不寫保險(fail-closed" >&2
echo " 先跑:scripts/gitea-arm-request.sh <票號> \"<要做什麼>\",把代碼給 leo 回在那張票上。" >&2
exit 1
}
# `.github-armed` 的格式(照 scripts/github-arm.sh:93):
# 第 1 行:到期 epoch/第 2 行:任務說明/第 3 行:armed_at=…
MISSION=$(printf '%s' "$OUT" | sed -n 's/^ARMED: //p' | head -1)
[ -n "$MISSION" ] || MISSION="gitea-arm 核准,未帶說明)"
NOW=$(date +%s)
EXPIRY=$((NOW + 1800)) # 30 分鐘,與 gitea-arm-request 的時效一致
printf '%s\n%s\n%s\n' \
"$EXPIRY" \
"$MISSION" \
"armed_at=$(date '+%Y-%m-%d %H:%M:%S')(來源:Gitea 票 leo 核准,非終端機)" \
> "$ARMED"
echo "✅ 保險已解(來源:leo 在 Gitea 票上的核准)"
echo " 任務:$MISSION"
echo " 有效至:$(date -r "$EXPIRY" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$EXPIRY")"
echo " 提前上保險:rm $ARMED"
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
# gitea-bootstrap.sh — 新 repo 開通工作管理元件(狀態標籤)。冪等,重跑無害。
#
# 為什麼是腳本不是一段叮嚀(leo 2026-08-09:「每到一個新的 repo 就要建立這些管理元件」):
# 靠人/靠 AI 記得 = 必然漂移。今天一天就抓到三條「規則寫了沒人驗」。
# label 是 repo-scoped、沒有跨 repo 繼承 ⇒ 每個 repo 都要建一次 ⇒ 必須一行做完。
#
# 用法(在該 repo 目錄下跑,或用 -r 指定):
# scripts/gitea-bootstrap.sh # 用當前 repo 的 gitea remote
# scripts/gitea-bootstrap.sh -r Leo/some-repo # 指定 repotoken 仍從當前 repo 取)
#
# milestone 不在這裡建——milestone sprint 一次交貨,每個 repo 的交貨內容不同,
# 不能預設。建法見 /issue-handle skill。
set -eu
REPO=""
[ "${1:-}" = "-r" ] && { REPO="${2:?-r 後面要接 owner/repo}"; }
REMOTE=$(git remote get-url gitea 2>/dev/null) || {
echo "✗ 當前目錄沒有名為 gitea 的 remote。請 cd 到目標 repo,或先加 remote。" >&2; exit 1; }
TOKEN=$(printf '%s' "$REMOTE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
[ "$TOKEN" = "$REMOTE" ] && { echo "✗ gitea remote URL 裡沒有 token,無法取得認證。" >&2; exit 1; }
HOST=$(printf '%s' "$REMOTE" | sed -E 's|.*@([^/]+)/.*|\1|')
[ -n "$REPO" ] || REPO=$(printf '%s' "$REMOTE" | sed -E 's|.*://[^/]+/(.+)\.git$|\1|')
echo "→ repo: $REPO host: $HOST"
# 狀態機(互斥 scope label)。順序=出貨流程:
# s/todo → s/doing → s/stage →(leo 蓋章 + arm + 推 prod)→ closed
# 🔴 不建 s/doneclosed 就是 done,多一個就是同一件事兩個真相。
# 🔴 s/triage 存在的理由:**「留白」查不出來**。撈得到 s/todo,卻撈不到
# 「所有還沒驗傷的」——沒有標籤不是一種狀態,是查詢的死角
# leo 2026-08-09 建 Triage 看板時暴露的設計缺陷)。
# p/ 是另一個軸:s/ 答「走到哪」、p/ 答「多重要」——
# s/backlog 的東西也可以是 p/high,併成一組就表達不出來。
set -- \
's/triage|d4c5f9|新進來的,還沒驗傷——還沒決定要不要做' \
's/backlog|c2e0c6|驗過了、確定要做,但還沒排進任何 sprint(wishlist/功能需求/待規劃)' \
's/todo|ededed|已排進 sprint,等開工' \
's/doing|0e8a16|進行中——現在有人在做' \
's/stage|5319e7|已推上 stage,等 leo 去 youlin 的 stage 環境驗收(出貨流程第⑤步)' \
's/pending|fbca04|卡住——等外部/等人,不是沒人做' \
'p/high|b60205|高——擋住交付或有時間壓力' \
'p/low|bfd4f2|低——想做,但晚一點沒關係'
# 🔴 先抓現有清單再建。**Gitea 允許同名 label、回 201 不是 422**
# ⇒ 靠「重複會被擋」達成冪等是錯的。2026-08-09 實撞:本腳本第一版
# 在 arcrun-rag 造出每個標籤各兩份——那會直接弄壞互斥狀態機(同名兩個 id,
# 貼哪一個都不會把另一個頂掉),事後手動刪掉四個重複 id 才救回來。
EXISTING=$(curl -s -H "Authorization: token $TOKEN" -H "Cache-Control: no-cache" \
"https://$HOST/api/v1/repos/$REPO/labels?limit=100" \
| python3 -c "import json,sys;print(' '.join(l['name'] for l in json.load(sys.stdin)))")
created=0; existed=0; failed=0
for spec in "$@"; do
name=${spec%%|*}; rest=${spec#*|}; color=${rest%%|*}; desc=${rest#*|}
case " $EXISTING " in *" $name "*) echo " · 已存在 $name"; existed=$((existed+1)); continue ;; esac
payload=$(NAME="$name" COLOR="$color" DESC="$desc" python3 -c '
import json,os
print(json.dumps({"name":os.environ["NAME"],"color":"#"+os.environ["COLOR"],
"description":os.environ["DESC"],"exclusive":True}))')
code=$(printf '%s' "$payload" | curl -s -o /tmp/.bootstrap-out -w '%{http_code}' \
-X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
--data-binary @- "https://$HOST/api/v1/repos/$REPO/labels")
case "$code" in
201) echo " ✓ 建立 $name"; created=$((created+1)) ;;
422) echo " · 已存在 $name"; existed=$((existed+1)) ;;
*) echo "$name → HTTP $code: $(head -c 120 /tmp/.bootstrap-out)"; failed=$((failed+1)) ;;
esac
done
# 複驗:從 repo 端讀回來,不信自己送出的指令(2026-08-09 教訓:宣告 vs 證據)
echo "→ 複驗(從 repo 讀回):"
curl -s -H "Authorization: token $TOKEN" -H "Cache-Control: no-cache" \
"https://$HOST/api/v1/repos/$REPO/labels?limit=100" \
| python3 -c "import json,sys;print(' ',sorted(l['name'] for l in json.load(sys.stdin) if l['name'].startswith('s/')))"
echo "→ 新建 $created/已存在 $existed/失敗 $failed"
[ "$failed" -eq 0 ] || exit 1
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# github-arm.sh — 解除 GitHub 接觸保險(D20 發射鈕,leo 親手跑,AI 不得代跑)
# 用法:scripts/github-arm.sh "任務描述" [有效分鐘,預設30,上限60]
set -euo pipefail
if [ ! -t 0 ]; then
echo "❌ 本腳本必須由人類在終端機互動執行(防 AI 代按發射鈕)。" >&2
exit 1
fi
MISSION="${1:-}"
MINUTES="${2:-30}"
if [ -z "$MISSION" ]; then
echo "用法:scripts/github-arm.sh \"任務描述\" [分鐘]" >&2
exit 1
fi
# 第二參數必須是純數字分鐘。2026-08-01 leo 實撞:AI 給的指令把中文註解寫在同一行,
# 被當成分鐘數 → 下方 $(( )) 算術炸掉 → EXPIRY unbound、保險沒解成卻看似執行過。
case "$MINUTES" in
''|*[!0-9]*)
echo "❌ 第二個參數要純數字分鐘(收到:$MINUTES" >&2
echo " 正確:scripts/github-arm.sh \"任務描述\" 30" >&2
exit 1;;
esac
if [ "$MINUTES" -gt 60 ]; then MINUTES=60; fi
if [ "$MINUTES" -lt 1 ]; then MINUTES=1; fi
cd "$(dirname "$0")/.."
# ── 搬遷預覽(arcrun-rag#27,leo:「解保險前要看得到這次會搬什麼」)──────────
# 純讀本機已存在的檔案(ship.targets.json + stage/prod 兩份 manifest.json),
# 完全不碰網路——此刻保險還沒解,不能在這裡先觸網。
# 找不到這些檔就安靜略過(本腳本不是只給出貨用,其他 GitHub 任務沒有這些檔是正常的)。
ARCRUN_RAG_TARGETS="products/arcrun-rag/installer/ship.targets.json"
STAGE_BUNDLE_DIR="/private/tmp/arcrun-rag-bundles-staging"
PROD_BUNDLE_DIR="/private/tmp/arcrun-rag-bundles"
if [ -f "$ARCRUN_RAG_TARGETS" ] && [ -f "$STAGE_BUNDLE_DIR/manifest.json" ] && [ -f "$PROD_BUNDLE_DIR/manifest.json" ]; then
echo "═══════════════════════════════════════════"
echo "📦 搬遷預覽(只讀本機檔案,尚未碰網路)"
echo "═══════════════════════════════════════════"
python3 - "$ARCRUN_RAG_TARGETS" "$STAGE_BUNDLE_DIR" "$PROD_BUNDLE_DIR" <<'PYEOF' || echo "(搬遷預覽讀檔失敗,略過——不影響下面的保險流程)"
import json, os, sys
targets_json, stage_dir, prod_dir = sys.argv[1], sys.argv[2], sys.argv[3]
cfg = json.load(open(targets_json))
prod = cfg.get("targets", {}).get("prod", {})
remote = prod.get("bundles", {}).get("remote", "(未知)")
branch = prod.get("bundles", {}).get("branch", "main")
stage = json.load(open(os.path.join(stage_dir, "manifest.json")))
prodm = json.load(open(os.path.join(prod_dir, "manifest.json")))
print(f"搬到哪:{remote}branch {branch}")
print(f"版本:prod 目前 {prodm.get('release')} → stage 現在是 {stage.get('release')}")
stage_core = {c.get("name"): c.get("sha256", "") for c in stage.get("core", [])}
prod_core = {c.get("name"): c.get("sha256", "") for c in prodm.get("core", [])}
changed = [n for n in prod_core if stage_core.get(n) != prod_core.get(n)]
same = [n for n in prod_core if n in stage_core and stage_core.get(n) == prod_core.get(n)]
print(f"prod 管的 {len(prod_core)} 顆裡,內容會變的:{len(changed)} 顆")
for n in changed:
print(f" - {n}")
if same:
print(f"內容沒變(sha 相同):{len(same)} 顆 — {', '.join(same)}")
has_local_clone = os.path.isdir(os.path.join(prod_dir, ".git"))
est = "1 次(push;沿用已存在的本地 clone,不需再 clone" if has_local_clone \
else "2 次(clone 1 + push 1;本地還沒有這個 clone"
print(f"預估碰 GitHub 幾次:{est}")
print("(不含 jsDelivr purge——那是 CDN 快取清除,不算 GitHub 接觸,ROE 不計)")
PYEOF
echo ""
else
echo "(找不到本地 stage/prod bundle 的 manifest.json,略過搬遷預覽——非出貨類任務屬正常)"
echo ""
fi
echo "═══════════════════════════════════════════"
echo "🚀 GitHub 接觸儀式 — 發射前檢查(ROE)"
echo "═══════════════════════════════════════════"
echo "任務:$MISSION"
echo "時效:$MINUTES 分鐘(到期自動回保險)"
echo ""
echo "交戰規則(每條都要守):"
echo " □ 單一 repo,不跨 repo fan-out"
echo " □ 網路請求 ≤5 次(clone/push/PR 各算一次)"
echo " □ 禁批量操作、禁迴圈打 API、禁開/改 Actions"
echo " □ 動作間隔像人手(秒級間隔,不連發)"
echo " □ 一收到 403/429/驗證挑戰 → 立即全停回報"
echo ""
read -r -p "以上確認,解除保險?(yes/N) " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "已取消,保險維持。"
exit 0
fi
EXPIRY=$(( $(date +%s) + MINUTES * 60 ))
printf '%s\n%s\n%s\n' "$EXPIRY" "$MISSION" "armed_at=$(date '+%Y-%m-%d %H:%M:%S')" > .github-armed
echo ""
echo "✅ 保險已解除至 $(date -r "$EXPIRY" '+%H:%M:%S')。期間每次 GitHub 接觸自動記入 github-contact-log.md。"
echo " 提前上保險:rm .github-armed"
+472
View File
@@ -0,0 +1,472 @@
#!/bin/bash
# system-dev-template installer
# 已有專案接入腳本——只建立缺少的東西,已有的一律不動。
#
# 模組化安裝:
# --wiki 只裝 LLM Wiki(記憶系統 + 機敏防護)
# --sdd 只裝 SDD 系統(動 code 前必須有 design.md
# --all 兩個都裝(預設)
# 無參數 互動式詢問
#
# 為什麼留在同一個 repo 用參數選,而不是 fork:
# 使用者多半非專業,最怕「我要去哪個 repo」。一個入口 + 選單最友善。
# 等未來功能多到 3+ 個再演進成「模板組合器」。模組邊界先在這裡劃好。
set -euo pipefail
# ── i18n:依 locale 選語言,預設英文 ──────────────────
# 為什麼預設英文:curl | bash 常是 LANG=C,外國人預設就該看得懂;
# 台灣使用者 locale 多為 zh_TW,會自動切回繁中。
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
zh*|*Hant*|*Hans*) IS_ZH="yes" ;;
*) IS_ZH="no" ;;
esac
# t "中文" "English" → 依語系印出對應字串
t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; }
# tn = 不換行版(給 prompt 用)
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
REPO_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/template"
# install.sh / update.sh 住在 main/scripts/(不在 template/)。
SCRIPTS_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/scripts"
CREATED=()
SKIPPED=()
# ── 解析模組參數 ──────────────────────────────────
MODULE=""
for arg in "$@"; do
case "$arg" in
--wiki|--wiki-only) MODULE="wiki" ;;
--sdd|--sdd-only) MODULE="sdd" ;;
--all) MODULE="all" ;;
-h|--help)
if [ "$IS_ZH" = "yes" ]; then
cat <<'HELP'
用法:install.sh [--wiki | --sdd | --all]
--wiki 只裝 LLM WikiCC 記憶系統 + 機敏防護)
--sdd 只裝 SDD 系統(動 code 前強制要有設計文件)
--all 兩個都裝(預設)
無參數 互動式詢問要裝哪個
HELP
else
cat <<'HELP'
Usage: install.sh [--wiki | --sdd | --all]
--wiki Install LLM Wiki only (CC memory system + secret protection)
--sdd Install SDD system only (require a design doc before touching code)
--all Install both (default)
no flag Interactively ask which to install
HELP
fi
exit 0 ;;
esac
done
echo ""
echo "🔧 system-dev-template installer"
echo "================================="
t "只建立缺少的目錄和檔案,已有的不動。" \
"Only creates missing dirs and files; never touches what already exists."
echo ""
# ── 無參數 → 互動式詢問(給非專業使用者)──────────
if [ -z "$MODULE" ]; then
if [ -t 0 ]; then
t "要安裝哪一塊?" "Which part do you want to install?"
t " 1) LLM Wiki —— 讓 CC 記住決策、不重複犯錯(含機敏防護)" \
" 1) LLM Wiki — let CC remember decisions and avoid repeating mistakes (with secret protection)"
t " 2) SDD —— 動 code 前強制先有設計文件" \
" 2) SDD — require a design doc before touching code"
t " 3) 兩個都裝(推薦)" " 3) Install both (recommended)"
echo ""
tn "請輸入 1 / 2 / 3 [預設 3]" "Enter 1 / 2 / 3 [default 3]: "
read -r choice || choice=3
case "$choice" in
1) MODULE="wiki" ;;
2) MODULE="sdd" ;;
*) MODULE="all" ;;
esac
else
# 非互動環境(如 curl | bash 無 tty)→ 預設全裝
MODULE="all"
fi
fi
WANT_WIKI=false
WANT_SDD=false
case "$MODULE" in
wiki) WANT_WIKI=true ;;
sdd) WANT_SDD=true ;;
all) WANT_WIKI=true; WANT_SDD=true ;;
esac
echo ""
t "📦 安裝模組:$MODULE" "📦 Module: $MODULE"
echo ""
# ── 重複安裝防呆(1.10.1):install 只管「全新安裝」,一切後續歸 update ──
# 判準是「裝過沒」,不分新版舊版:
# - 新結構 system-dev/ 已存在,或
# - 舊結構 .claude/wiki/ 或 .claude/VERSION 存在(裝過舊版、待遷移)
# 裝過了還跑 install → 會重複建範本、甚至跟真資料並存(先 install 建空殼,遷移就被擋)。
# 正解:偵測到裝過 → 不動任何東西,導去 update(更新/遷移/補新檔都由它處理)。
if [ -d "system-dev" ] || [ -d ".claude/wiki" ] || [ -f ".claude/VERSION" ]; then
t "🛑 偵測到這個專案已經安裝過 system-dev-template。" \
"🛑 system-dev-template is already installed in this project."
t " 後續的更新、遷移、補新檔,一律由「更新腳本」處理(不要重跑 install):" \
" All updates, migrations, and new-file additions are handled by the UPDATER (don't re-run install):"
echo ""
echo " curl -sSL https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/scripts/update.sh | bash"
echo ""
t " (重跑 install 可能建出空白範本、跟你的真資料並存,故在此停止。)" \
" (Re-running install could create empty templates alongside your real data, so it stops here.)"
exit 0
fi
# ── 偵測 vault 類型 → 決定 raw source(原始文件)路徑 ──────────
# 為什麼:這個模板原本假設「原始文件在 docs/」,但 Logseq / Obsidian
# 這種 PKM vault 有自己的目錄慣例,整理時不能照 docs/ 那套搬動,
# 否則會破壞 vault 結構、讓筆記變不可讀。
# 偵測結果寫進 CLAUDE.md,讓 CC 和未來的 Cowork skill 都知道
# 「該讀/該整理哪裡」而不是亂動。
# 必須在建立 CLAUDE.md 之前跑完。
VAULT_TYPE=""
RAW_SOURCE=""
IS_VAULT="no" # 只有 logseq/obsidian 這種「筆記軟體 vault」才算 yes
if [ -d "logseq" ]; then
VAULT_TYPE="logseq"
RAW_SOURCE="pages/, journals/"
IS_VAULT="yes"
elif [ -d ".obsidian" ]; then
VAULT_TYPE="obsidian"
RAW_SOURCE="$(tn './ (整個 vault 根目錄的 .md' './ (all .md under the vault root)')"
IS_VAULT="yes"
else
VAULT_TYPE="docs"
RAW_SOURCE="docs/"
fi
# 偵測到是筆記 vault → 出聲告訴使用者「我看到了,會小心、不破壞你的筆記結構」。
# 不是筆記(一般開發案等)→ 不囉嗦,默默把 docs/ 當原始文件夾安裝完成。
if [ "$IS_VAULT" = "yes" ]; then
t "🗂️ 偵測到 ${VAULT_TYPE} 筆記庫 → 原始文件:${RAW_SOURCE}" \
"🗂️ Detected a ${VAULT_TYPE} note vault → raw source: ${RAW_SOURCE}"
t " (會保留你筆記軟體的目錄/檔名結構,不搬動、不改名)" \
" (your note app's directory/file structure is preserved — nothing is moved or renamed)"
echo ""
fi
# 把「raw source 宣告區塊」吐出來,給新建的 CLAUDE.md append 或
# 給已存在的 CLAUDE.md 當手動補貼的提示。內容對 CC / Cowork 都是
# 機器可讀的指令(明確路徑 + 不可破壞 vault 結構的約束)。
# 寫進 CLAUDE.md 的 raw source 宣告區塊。給人也給 AI 看:
# 依 locale 只寫「一種語言」進 CLAUDE.md(雙語會讓每個 session 的 context 更滿)。
emit_raw_source_block() {
local source_kind
if [ "$IS_ZH" = "yes" ]; then
if [ "$IS_VAULT" = "yes" ]; then source_kind="${VAULT_TYPE} 筆記庫"
else source_kind="一般專案(原始文件放 raw source 路徑)"; fi
cat <<BLOCK
---
## 原始文件空間(raw source
> 安裝時偵測到的來源型態:**${source_kind}**
> CC 與 Cowork 整理/讀取「人寫的原始文件」時,**只在這裡找、只在這裡動**。
| 項目 | 值 |
|------|----|
| 來源型態 | \`${source_kind}\` |
| raw source | \`${RAW_SOURCE}\` |
**約束(CC 與 Cowork 都必須遵守)**
- 整理 wiki/知識時,原始文件**一律從上方 raw source 路徑讀取**,不要假設是 \`docs/\`。
BLOCK
if [ "$IS_VAULT" = "yes" ]; then
cat <<BLOCK
- 這是 **${VAULT_TYPE} 筆記庫**:保留它原本的目錄與檔名慣例,**不得搬動、改名、重新分類** \`.md\` 檔,
以免破壞筆記軟體結構造成筆記不可讀。整理只在 \`system-dev/wiki/\` 產出,**不動 raw source 本身**。
BLOCK
fi
else
if [ "$IS_VAULT" = "yes" ]; then source_kind="${VAULT_TYPE} note vault"
else source_kind="regular project (raw source lives at the path below)"; fi
cat <<BLOCK
---
## Raw source space
> Source type detected at install time: **${source_kind}**
> When CC and Cowork curate/read human-written raw source, **look only here and act only here**.
| Item | Value |
|------|-------|
| Source type | \`${source_kind}\` |
| raw source | \`${RAW_SOURCE}\` |
**Constraints (both CC and Cowork must obey)**
- When curating the wiki/knowledge, **always read raw source from the path above** — don't assume \`docs/\`.
BLOCK
if [ "$IS_VAULT" = "yes" ]; then
cat <<BLOCK
- This is a **${VAULT_TYPE} note vault**: keep its original directory and file-naming conventions. **Do not move, rename, or re-classify** \`.md\` files,
or you'll break the note-app structure and make notes unreadable. Curation output goes only into \`system-dev/wiki/\`; **never touch the raw source itself**.
BLOCK
fi
fi
}
# ── 工具函式 ──────────────────────────────────────
create_dir() {
if [ ! -d "$1" ]; then
mkdir -p "$1"
CREATED+=("$1/")
else
SKIPPED+=("$1/ $(tn '(已存在)' '(already exists)')")
fi
}
download_if_missing() {
local dest="$1" src="$2"
if [ ! -f "$dest" ]; then
mkdir -p "$(dirname "$dest")"
curl -sSL "$src" -o "$dest"
CREATED+=("$dest")
else
SKIPPED+=("$dest $(tn '(已存在,跳過)' '(already exists, skipped)')")
fi
}
# ── 共用結構 ──────────────────────────────────────
# 工具自己的文件骨架收進 system-dev/docs/(不污染用戶根目錄、不跟用戶自己的 docs/ 混)。
# 注意語義分離:這裡的 system-dev/docs/ 是「工具文件」;用戶的 raw source(原始文件)
# 另有其處(見上方 vault 偵測),工具只讀、不搬。
# .claude/ 只留 CC 死綁的 commands/ + hooks/,工具資料一律不放這。
create_dir "system-dev/docs/1-vision"
create_dir "system-dev/docs/2-architecture/decisions"
create_dir "system-dev/docs/4-guides"
create_dir "system-dev/docs/5-records/incidents"
create_dir "system-dev/docs/5-records/test-reports"
create_dir "system-dev/docs/6-user"
create_dir ".claude/commands"
create_dir ".claude/hooks"
download_if_missing "system-dev/docs/README.md" "$REPO_URL/system-dev/docs/README.md"
# 工具版號:放 system-dev/,不寄生 .claude/。
download_if_missing "system-dev/VERSION" "$REPO_URL/system-dev/VERSION"
# ── WIKI 模組 ─────────────────────────────────────
# wiki 是工具資料 → 放 system-dev/wiki/(不放 .claude/)。
# commands/ 與 hooks/ 是 CC 機制檔 → 維持 .claude/。
if $WANT_WIKI; then
create_dir "system-dev/wiki"
download_if_missing "system-dev/wiki/INDEX.md" "$REPO_URL/system-dev/wiki/INDEX.md"
download_if_missing "system-dev/wiki/TAXONOMY.md" "$REPO_URL/system-dev/wiki/TAXONOMY.md"
download_if_missing "system-dev/wiki/status.md" "$REPO_URL/system-dev/wiki/status.md"
download_if_missing "system-dev/wiki/mistakes.md" "$REPO_URL/system-dev/wiki/mistakes.md"
download_if_missing "system-dev/wiki/principles.md" "$REPO_URL/system-dev/wiki/principles.md"
download_if_missing "system-dev/wiki/.wikiignore" "$REPO_URL/system-dev/wiki/.wikiignore"
# wiki 改寫產物(AI 自讀定稿卡片)的正式落點:由工具建好,不靠用戶自救。
create_dir "system-dev/wiki/cards"
[ -f "system-dev/wiki/cards/.gitkeep" ] || { : > "system-dev/wiki/cards/.gitkeep"; CREATED+=("system-dev/wiki/cards/.gitkeep"); }
download_if_missing ".claude/commands/wiki-init.md" "$REPO_URL/.claude/commands/wiki-init.md"
download_if_missing ".claude/commands/wiki-capture.md" "$REPO_URL/.claude/commands/wiki-capture.md"
download_if_missing ".claude/commands/wiki-update.md" "$REPO_URL/.claude/commands/wiki-update.md"
download_if_missing ".claude/commands/wiki-recall.md" "$REPO_URL/.claude/commands/wiki-recall.md"
# wiki 相關 hooks:接關 + 機敏掃描
download_if_missing ".claude/hooks/session-start-recall.sh" "$REPO_URL/.claude/hooks/session-start-recall.sh"
download_if_missing ".claude/hooks/wiki-secret-scan.sh" "$REPO_URL/.claude/hooks/wiki-secret-scan.sh"
# Coworkclaude.ai)整理 wiki 用的 skill:與 CC 的 /wiki-init 共用同一套規則
# (含 typed-edge、frontmatter 標籤、gloss)。沒這支 → claude.ai 來掃時身上沒規則。
download_if_missing "system-dev/docs/SKILL.md" "$REPO_URL/system-dev/docs/SKILL.md"
fi
# ── SDD 模組 ──────────────────────────────────────
if $WANT_SDD; then
create_dir "system-dev/docs/3-specs"
download_if_missing "system-dev/docs/3-specs/TEMPLATE-sdd/design.md" "$REPO_URL/system-dev/docs/3-specs/TEMPLATE-sdd/design.md"
download_if_missing "system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md" "$REPO_URL/system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md"
download_if_missing "system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md" "$REPO_URL/system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md"
download_if_missing ".claude/commands/sdd-check.md" "$REPO_URL/.claude/commands/sdd-check.md"
download_if_missing ".claude/hooks/sdd-guard.sh" "$REPO_URL/.claude/hooks/sdd-guard.sh"
fi
# ── 安裝/更新腳本:一開始就放進 system-dev/scripts/ ──
# 為什麼一開始就裝:之後要更新,用戶(或 CC)直接 `bash system-dev/scripts/update.sh`
# 不必每次都記那串 curl。腳本來源在 main/scripts/(不在 template/)。
create_dir "system-dev/scripts"
download_if_missing "system-dev/scripts/install.sh" "$SCRIPTS_URL/install.sh"
download_if_missing "system-dev/scripts/update.sh" "$SCRIPTS_URL/update.sh"
# ── 共用 hook:專案自訂禁令骨架(預設停用)────────
download_if_missing ".claude/hooks/pre-write-guard.sh" "$REPO_URL/.claude/hooks/pre-write-guard.sh"
# ── 共用指引:GitHub issue 處理(讀/回普世,跨 repo 發要先問,禁自動輪詢)──
download_if_missing ".claude/commands/issue-handle.md" "$REPO_URL/.claude/commands/issue-handle.md"
chmod +x .claude/hooks/*.sh 2>/dev/null || true
# ── 依模組產生 settings.json 的 hooks 區塊 ────────
# settings.json 因模組而異,不能直接下載單一靜態檔,改條件組裝。
build_hooks_json() {
local session_hooks="" pretool_hooks=""
if $WANT_WIKI; then
session_hooks='{ "type": "command", "command": ".claude/hooks/session-start-recall.sh" }'
fi
# PreToolUse 依模組疊加
local pt=()
$WANT_SDD && pt+=('{ "type": "command", "command": ".claude/hooks/sdd-guard.sh" }')
pt+=('{ "type": "command", "command": ".claude/hooks/pre-write-guard.sh" }')
$WANT_WIKI && pt+=('{ "type": "command", "command": ".claude/hooks/wiki-secret-scan.sh" }')
local IFS=,
pretool_hooks="${pt[*]}"
printf '{\n "hooks": {\n'
if [ -n "$session_hooks" ]; then
printf ' "SessionStart": [\n { "matcher": "startup|resume|clear",\n "hooks": [ %s ] }\n ],\n' "$session_hooks"
fi
printf ' "PreToolUse": [\n { "matcher": "Write|Edit",\n "hooks": [ %s ] }\n ]\n' "$pretool_hooks"
printf ' }\n}\n'
}
if [ ! -f ".claude/settings.json" ]; then
build_hooks_json > .claude/settings.json
CREATED+=(".claude/settings.json $(tn "(依 $MODULE 模組產生)" "(generated for module: $MODULE)")")
else
SKIPPED+=(".claude/settings.json $(tn '(已存在,請手動合併 hooks)' '(already exists — merge hooks manually)')")
fi
# ── CLAUDE.md:只在完全不存在時建立 ────────────────
# 新建時把偵測到的 raw source 宣告 append 進去(在建立的當下寫入,
# 不回頭改使用者既有的 CLAUDE.md,維持「已有不覆蓋」原則)。
if [ ! -f "CLAUDE.md" ]; then
download_if_missing "CLAUDE.md" "$REPO_URL/CLAUDE.md"
if [ -f "CLAUDE.md" ]; then
emit_raw_source_block >> CLAUDE.md
CREATED+=("CLAUDE.md $(tn "← 已寫入 raw source 宣告(${VAULT_TYPE}" "← raw source declaration written (${VAULT_TYPE})")")
fi
else
SKIPPED+=("CLAUDE.md $(tn '(已存在,請手動加入對應區塊)' '(already exists — add the block manually)')")
fi
# ── 輸出結果 ──────────────────────────────────────
echo ""
t "✅ 建立了:" "✅ Created:"
# 注意:macOS bash 3.2 在 set -u 下展開「空陣列」會炸 unbound variable
# 所以這裡先確認有元素才展開(SKIPPED 區塊在下方本來就有守,CREATED 補上)。
if [ ${#CREATED[@]} -gt 0 ]; then
for item in "${CREATED[@]}"; do echo " + $item"; done
fi
if [ ${#SKIPPED[@]} -gt 0 ]; then
echo ""
t "⚠️ 跳過(已存在):" "⚠️ Skipped (already exists):"
for item in "${SKIPPED[@]}"; do echo " - $item"; done
fi
echo ""
echo "─────────────────────────────────"
# CLAUDE.md 已存在 → 依模組提醒手動加區塊
if [ -f "CLAUDE.md" ]; then
if ! grep -q "raw source" CLAUDE.md; then
echo ""
t "📌 CLAUDE.md 已存在但缺少 raw source 宣告。" \
"📌 CLAUDE.md exists but lacks a raw source declaration."
t " 請手動把以下區塊貼進去,讓 CC 與 Cowork 知道原始文件在哪、不要亂動既有結構:" \
" Paste the block below in so CC and Cowork know where the raw source is and won't disturb your structure:"
emit_raw_source_block | sed 's/^/ /'
fi
if $WANT_WIKI && ! grep -q "wiki/status.md" CLAUDE.md; then
echo ""
t "📌 CLAUDE.md 已存在但缺少 wiki 讀取順序,請手動加入:" \
"📌 CLAUDE.md exists but lacks the wiki reading order — please add it manually:"
echo ""
if [ "$IS_ZH" = "yes" ]; then
cat <<'SNIP'
## Wiki 讀取順序(pushhook 開 session 自動注入)
| 檔案 | 時機 | 用途 |
|------|------|------|
| `system-dev/wiki/status.md` | session 開始第一件事 | 當前進度 |
| `system-dev/wiki/principles.md` | 設計任何東西前 | 跨全局原則,必服從 |
| `system-dev/wiki/mistakes.md` | 做新功能前 | 已知踩坑 |
SNIP
else
cat <<'SNIP'
## Wiki reading order (push: auto-injected at session start)
| File | When | Purpose |
|------|------|---------|
| `system-dev/wiki/status.md` | first thing at session start | current progress |
| `system-dev/wiki/principles.md` | before designing anything | global principles, must obey |
| `system-dev/wiki/mistakes.md` | before building a new feature | known pitfalls |
SNIP
fi
fi
if $WANT_SDD && ! grep -q "system-dev/docs/3-specs" CLAUDE.md; then
echo ""
t "📌 CLAUDE.md 已存在但缺少 SDD 鐵律,請手動加入:" \
"📌 CLAUDE.md exists but lacks the SDD iron rule — please add it manually:"
echo ""
if [ "$IS_ZH" = "yes" ]; then
cat <<'SNIP'
## 絕對鐵律
1. 任何 code 變動前必須有對應 SDDsystem-dev/docs/3-specs/[子系統]/design.md
找不到 → 停手問負責人,不要自行建立。
SNIP
else
cat <<'SNIP'
## Iron rule
1. Every code change must have a matching SDD (system-dev/docs/3-specs/[subsystem]/design.md).
Not found → stop and ask the owner; do not create one on your own.
SNIP
fi
fi
fi
# settings.json 已存在 → 依模組提醒要合併哪些 hook
if [ -f ".claude/settings.json" ]; then
MISSING_HOOKS=()
$WANT_WIKI && ! grep -q "session-start-recall.sh" .claude/settings.json && MISSING_HOOKS+=("SessionStart: session-start-recall.sh")
$WANT_WIKI && ! grep -q "wiki-secret-scan.sh" .claude/settings.json && MISSING_HOOKS+=("PreToolUse(Write|Edit): wiki-secret-scan.sh")
$WANT_SDD && ! grep -q "sdd-guard.sh" .claude/settings.json && MISSING_HOOKS+=("PreToolUse(Write|Edit): sdd-guard.sh")
if [ ${#MISSING_HOOKS[@]} -gt 0 ]; then
echo ""
t "📌 .claude/settings.json 已存在,請手動把以下 hooks 合併進去(保留既有設定):" \
"📌 .claude/settings.json exists — merge the hooks below in manually (keep your existing settings):"
for h in "${MISSING_HOOKS[@]}"; do echo "$h"; done
fi
fi
# pre-write-guard 是空殼,提醒它預設不攔(避免「以為有保護其實沒有」的安全錯覺)
echo ""
t "️ .claude/hooks/pre-write-guard.sh 是「按需手填的空插槽」,預設不攔任何東西。" \
"️ .claude/hooks/pre-write-guard.sh is an empty slot to fill on demand — by default it blocks nothing."
t " 需要專案禁令?最簡單是叫你的 CC 寫一支貼合的 guard hook(比範本表達力強);" \
" Need project-specific bans? Easiest is to ask your CC to write a tailored guard hook (more expressive than the template);"
t " 或自己填 FORBIDDEN_PATTERNS 並到 settings.json 掛上才會生效。" \
" or fill in FORBIDDEN_PATTERNS yourself and wire it into settings.json to take effect."
echo ""
t "🚀 下一步:" "🚀 Next steps:"
if $WANT_WIKI; then
t " 在 Claude Code 對話裡執行 /wiki-init" \
" In a Claude Code conversation, run /wiki-init"
t " CC 會掃描現有文件、套用 .wikiignore、建立 wiki。" \
" CC will scan your existing docs, apply .wikiignore, and build the wiki."
fi
if $WANT_SDD; then
t " 動 code 前先在 system-dev/docs/3-specs/[子系統]/ 建 design.md(可用 /sdd-check 協助)" \
" Before touching code, create design.md under system-dev/docs/3-specs/[subsystem]/ (use /sdd-check to help)"
fi
t " GitHub issueCC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \
" GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)"
echo ""
+1
View File
@@ -0,0 +1 @@
__pycache__/
@@ -0,0 +1,6 @@
{
"account": {
"id": "58309bb90fd93ad6d0fe0aae99170e9d",
"name": "Uncle6.me@gmail.com's Account"
}
}
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""
KBDB 按鈕殼 —— **受測者只拿得到這支腳本**。
leo 2026-08-15:「如果明天只有這幾個按鈕可按,至少保證不再出錯。」
⇒ 這支就是那幾個按鈕。它刻意**只**暴露使用者真的走得到的那條路上真的存在的動作
portal 資料面 = MCP 知識面工具打的同一組端點、同一道閘)。
沒有 update、沒有 delete/archive、沒有 link、沒有 add-field、沒有 add-record-to-sheet
——因為那條路上真的沒有(見 system-dev/docs/4-guides/kbdb-動作對照表.md)。
兩個後端,**寫入語意刻意寫成同一份**mirror 自 matrix/arcrun/kbdb/src/actions/record-crud.ts):
local:<path> 本機 sqlite,用來做判分器自我驗證與乾跑(**不碰任何實例**)
portal:<url> 真的打 youlin 的 portal 資料面(正式考試用)
⚠️ 下面兩句 CREATE TABLE 標了 kbdb-sql-ok:那是**本機拋棄式 sqlite 的空白畫布**,
不是 KBDB 的 D1,也沒有替 KBDB 新增任何表。乾跑用完即丟。
"""
import argparse
import json
import os
import sqlite3
import subprocess
import uuid
SYS_ROOT, SYS_BELONGS, SYS_FIELD_OF = "sys_root", "sys_belongs", "sys_field_of"
# 本機空白畫布:0007 之後的 entries(含三根指標欄)+ 遷移期仍在的 templates。
SCHEMA = """
CREATE TABLE IF NOT EXISTS entries ( -- kbdb-sql-ok: 本機拋棄式 sqlite 畫布,非 KBDB 的 D1
id TEXT PRIMARY KEY, content TEXT, entry_type TEXT NOT NULL, owner_id TEXT,
parent_id TEXT, page_name TEXT, refs_json TEXT DEFAULT '[]', tags_json TEXT DEFAULT '[]',
task_status TEXT, content_hash TEXT, is_embedded INTEGER DEFAULT 0,
confidence REAL, metadata_json TEXT,
created_at INTEGER DEFAULT (unixepoch()), updated_at INTEGER DEFAULT (unixepoch()),
src_id TEXT, rel_id TEXT, dst_id TEXT);
CREATE TABLE IF NOT EXISTS templates ( -- kbdb-sql-ok: 同上,本機畫布
id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, description TEXT,
slots_json TEXT NOT NULL, created_by TEXT,
created_at INTEGER DEFAULT (unixepoch()), updated_at INTEGER DEFAULT (unixepoch()));
"""
def uid(p):
return f"{p}_{uuid.uuid4()}"
# ══════════════════════════ 後端 A:本機 sqlite ══════════════════════════
# 這一段是 record-crud.ts 的逐句對照移植。**包括它的沉默**:
# createRecord 只寫 template 宣告過的 slot`slots.filter(s => s in values)`),
# 沒宣告的 key **靜默消失**且仍回 200 —— 那正是最值得考出來的一種安靜的錯。
class Local:
def __init__(self, path):
self.db = sqlite3.connect(path)
self.db.row_factory = sqlite3.Row
self.db.executescript(SCHEMA)
self._anchors()
def _anchors(self):
for i, c in ((SYS_ROOT, "root"), (SYS_BELONGS, "belongs"), (SYS_FIELD_OF, "field_of")):
self.db.execute(
"INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?,?, 'system')",
(i, c))
self.db.commit()
def _ensure_fields(self, tid, slots):
for s in slots:
fid = f"fld_{tid}_{s}"
self.db.execute(
"INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?,?,'field')",
(fid, s))
self.db.execute(
"INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) "
"VALUES (?, 'relation', ?, ?, ?)", (f"relf_{tid}_{s}", fid, SYS_FIELD_OF, tid))
def list_sheets(self):
return [dict(r) for r in self.db.execute(
"SELECT id, name, slots_json FROM templates ORDER BY created_at DESC")]
def create_sheet(self, name, fields, description=None):
tid = uid("tpl")
self.db.execute(
"INSERT INTO templates (id, name, description, slots_json) VALUES (?,?,?,?)",
(tid, name, description, json.dumps(fields, ensure_ascii=False)))
self.db.execute(
"INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?,?,'sheet')",
(tid, name))
self.db.execute(
"INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) "
"VALUES (?, 'relation', ?, ?, ?)", (f"relb_{tid}", tid, SYS_BELONGS, SYS_ROOT))
self._ensure_fields(tid, fields)
self.db.commit()
return {"id": tid, "name": name, "slots": fields}
def _tpl(self, name_or_id):
r = self.db.execute("SELECT * FROM templates WHERE id=? OR name=? LIMIT 1",
(name_or_id, name_or_id)).fetchone()
return dict(r) if r else None
def append_record(self, sheet, values, metadata_json=None):
tpl = self._tpl(sheet)
if not tpl:
return {"error": f"sheet not found: {sheet}"}
slots = json.loads(tpl["slots_json"])
rid = uid("rec")
self.db.execute("INSERT OR IGNORE INTO entries (id, entry_type) VALUES (?, 'record')", (rid,))
self.db.execute(
"INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) "
"VALUES (?, 'relation', ?, ?, ?)",
(f"relb_{rid}_{tpl['id']}", rid, SYS_BELONGS, tpl["id"]))
written = [s for s in slots if s in values] # ← 沒宣告的 key 在這裡靜默消失
self._ensure_fields(tpl["id"], written)
for s in written:
eid = uid("e")
self.db.execute(
"INSERT INTO entries (id, content, entry_type, metadata_json) "
"VALUES (?,?, 'value', ?)", (eid, values[s], metadata_json))
self.db.execute(
"INSERT INTO entries (id, entry_type, src_id, rel_id, dst_id) "
"VALUES (?, 'relation', ?, ?, ?)",
(uid("relv"), rid, f"fld_{tpl['id']}_{s}", eid))
self.db.commit()
return {"record_id": rid, "sheet": tpl["name"],
"values": {s: values[s] for s in written}}
# ⚠️ 只給判分器自我驗證用的正控制組,**不對受測者開放**:
# 「指向既有的那一顆,而不是複製一份」是 KBDB 的核心賣點,
# kbdb/src 內部確實有這個通道(createRecord 的 entry_ids),
# 但 **portal 資料面與 MCP 都只轉送 {template, values},沒有把它露出來**
# ⇒ 使用者那條路上做不到。這支方法存在的意義就是把那個洞量出來。
def _append_record_pointer(self, sheet, values, pointers):
tpl = self._tpl(sheet)
rid = uid("rec")
self.db.execute("INSERT OR IGNORE INTO entries (id, entry_type) VALUES (?, 'record')", (rid,))
self.db.execute(
"INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) "
"VALUES (?, 'relation', ?, ?, ?)",
(f"relb_{rid}_{tpl['id']}", rid, SYS_BELONGS, tpl["id"]))
slots = json.loads(tpl["slots_json"])
self._ensure_fields(tpl["id"], slots)
for s in slots:
if s in pointers:
dst = pointers[s]
elif s in values:
dst = uid("e")
self.db.execute(
"INSERT INTO entries (id, content, entry_type) VALUES (?,?, 'value')",
(dst, values[s]))
else:
continue
self.db.execute(
"INSERT INTO entries (id, entry_type, src_id, rel_id, dst_id) "
"VALUES (?, 'relation', ?, ?, ?)",
(uid("relv"), rid, f"fld_{tpl['id']}_{s}", dst))
self.db.commit()
return {"record_id": rid}
def _make_shared_value(self, content):
eid = uid("e")
self.db.execute("INSERT INTO entries (id, content, entry_type) VALUES (?,?, 'value')",
(eid, content))
self.db.commit()
return eid
def get_record(self, rid):
rows = self.db.execute(
"SELECT f.content AS field, v.content AS value FROM entries r "
"JOIN entries f ON f.id = r.rel_id JOIN entries v ON v.id = r.dst_id "
"WHERE r.src_id = ? AND r.rel_id != ?", (rid, SYS_BELONGS)).fetchall()
return {"record_id": rid, "values": {r["field"]: r["value"] for r in rows}}
def get_records(self, sheet):
tpl = self._tpl(sheet)
if not tpl:
return []
ids = [r["src_id"] for r in self.db.execute(
"SELECT src_id FROM entries WHERE rel_id=? AND dst_id=?", (SYS_BELONGS, tpl["id"]))]
return [self.get_record(i) for i in ids]
def search(self, q):
return [dict(r) for r in self.db.execute(
"SELECT id, content, entry_type FROM entries WHERE content LIKE ? LIMIT 50",
(f"%{q}%",))]
# ══════════════════════════ 後端 B:youlin portal 資料面 ══════════════════
# 使用者真的會走的那條路:portal 帳密登入 → /portal/data/*。
# MCP 的 kbdb_* 工具(identity.kind='portal')打的是同一組端點、同一道閘。
class Portal:
def __init__(self, base, email, password):
self.base = base.rstrip("/")
self.session = self._login(email, password)
def _curl(self, method, path, body=None, auth=True):
cmd = ["curl", "-s", "--max-time", "40", "-X", method, f"{self.base}{path}",
"-A", "Mozilla/5.0", "-H", "Content-Type: application/json"]
if auth:
cmd += ["-H", f"Authorization: Bearer {self.session}"]
if body is not None:
cmd += ["-d", json.dumps(body, ensure_ascii=False)]
out = subprocess.run(cmd, capture_output=True, text=True, timeout=60).stdout
try:
return json.loads(out)
except Exception:
return {"error": "non-json response", "raw": out[:300]}
def _login(self, email, password):
d = self._curl("POST", "/portal/login", {"email": email, "password": password}, auth=False)
tok = d.get("session") or d.get("token") or d.get("access_token")
if not tok:
raise SystemExit(json.dumps({"error": "portal login failed", "detail": d},
ensure_ascii=False))
return tok
def list_sheets(self):
return self._curl("GET", "/portal/data/templates")
def create_sheet(self, name, fields, description=None):
return self._curl("POST", "/portal/data/templates",
{"name": name, "slots": fields, "description": description})
def append_record(self, sheet, values, metadata_json=None):
return self._curl("POST", "/portal/data/records", {"template": sheet, "values": values})
def get_record(self, rid):
return self._curl("GET", f"/portal/data/records/{rid}")
def get_records(self, sheet):
return self._curl("GET", f"/portal/data/records/by-template/{sheet}")
def search(self, q):
return self._curl("GET", f"/portal/data/search?q={q}")
# ══════════════════════════ 後端 C:acr CLIleo 2026-08-15 指定)══════════════
# 「你可以叫它用 CLI 考試。」CLIMCPportal 是同一套 API 的三個薄殼
# `cli/src/commands/kbdb.ts` 檔頭:能力長在基本盤 API,CLI 只做介面轉換)。
# `acr kbdb` 的動作**剛好就是那六個按鈕**,多一個少一個都沒有。
#
# 🔴 **它打哪一台,由 cwd 決定**:解析順序是
# env > 資料夾層 `.arcrun.yaml`(就近往上找)> 全域 `~/.arcrun/config.yaml`
# 而**全域指的是 leo21cleo 的真庫,47.9 萬筆)**。
# ⇒ 本後端在建構時強制跑一次 `acr whoami`,**確認 CF 帳號是預期那台才准往下走**。
# 不憑上一次的結果假設這一次也一樣(2026-08-15 就是這一步救了總管)。
YOULIN_ACCOUNT = "1129efd7df2e8899d537e9c8fbabb6cb"
# 🔴 2026-08-16 補:只比對 CF 帳號**不夠**——那不是決定資料落到誰名下的那一項。
# 實撞:專案層 `.arcrun.yaml` 只寫 cypher_executor_url cloudflare_account_id 時,
# `acr whoami` 印出——
# 帳號 bfezv28v ← 沒被覆蓋,從全域掉下來的(leo21c)
# 連哪台 youlin 的 cypher
# CF 帳號 1129efd7…(youlin
# ⇒ 「打 youlin 這台機器,但用 leo21c 的身分寫入」。
# 而舊的防呆只找 CF 帳號字串,那一項是對的 ⇒ **它會放行**,
# 考完會拿到一份看起來正常、實際寫進錯地方的成績。
# ⇒ 判準:防呆要比對「**決定後果的那一項**」,不是「剛好看得到的那一項」。
# namespace 才是資料的歸屬鍵 ⇒ 三項一起驗,缺一不可。
YOULIN_NAMESPACE = "yuga3bse"
YOULIN_CYPHER = "arcrun-cypher-executor.youlin-hsieh-dev.workers.dev"
class Acr:
def __init__(self, workdir, expect_account=YOULIN_ACCOUNT,
expect_namespace=YOULIN_NAMESPACE, expect_cypher=YOULIN_CYPHER):
self.cwd = workdir
who = subprocess.run(["acr", "whoami"], cwd=workdir, capture_output=True,
text=True, timeout=60).stdout
missing = [label for label, token in (
("CF 帳號", expect_account),
("namespace(資料歸屬鍵)", expect_namespace),
("cypher 主機", expect_cypher),
) if token and token not in who]
if missing:
raise SystemExit(
"🔴 acr 指到的不是預期的實例,拒絕往下走。\n"
" 對不上:" + "".join(missing) + "\n"
" ⚠️ 三項分別決定「哪個 CF 帳號」「資料算誰的」「打哪台機器」,"
"缺一項就可能考在錯的地方。\n" + who)
self.whoami = who
def _run(self, args):
r = subprocess.run(["acr", "kbdb"] + args, cwd=self.cwd,
capture_output=True, text=True, timeout=120)
return {"exit": r.returncode, "out": r.stdout.strip(), "err": r.stderr.strip()}
def list_sheets(self):
return self._run(["template", "list"])
def create_sheet(self, name, fields, description=None):
return self._run(["template", "create", name, "--slots", ",".join(fields)])
def append_record(self, sheet, values, metadata_json=None):
args = ["record", "create", sheet]
for k, v in values.items():
args += ["--values", f"{k}={v}"]
return self._run(args)
def get_record(self, rid):
return self._run(["record", "get", rid])
def get_records(self, sheet):
return self._run(["query", sheet])
def search(self, q):
return self._run(["search", q])
def make_backend(spec):
kind, _, arg = spec.partition(":")
if kind == "local":
return Local(arg)
if kind == "portal":
return Portal(arg, os.environ["PORTAL_EMAIL"], os.environ["PORTAL_PASSWORD"])
if kind == "acr":
# arg 考場資料夾(裡面放 .arcrun.yaml,決定打哪一台)
return Acr(arg)
raise SystemExit(f"unknown backend: {spec}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--backend", required=True)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("list_sheets")
p = sub.add_parser("create_sheet"); p.add_argument("--name", required=True)
p.add_argument("--fields", nargs="+", required=True); p.add_argument("--description")
p = sub.add_parser("append_record"); p.add_argument("--sheet", required=True)
p.add_argument("--values", required=True, help="JSON 物件 {欄位名: 內容}")
p = sub.add_parser("get_record"); p.add_argument("--id", required=True)
p = sub.add_parser("get_records"); p.add_argument("--sheet", required=True)
p = sub.add_parser("search"); p.add_argument("--q", required=True)
a = ap.parse_args()
b = make_backend(a.backend)
if a.cmd == "list_sheets":
out = b.list_sheets()
elif a.cmd == "create_sheet":
out = b.create_sheet(a.name, a.fields, a.description)
elif a.cmd == "append_record":
out = b.append_record(a.sheet, json.loads(a.values))
elif a.cmd == "get_record":
out = b.get_record(a.id)
elif a.cmd == "get_records":
out = b.get_records(a.sheet)
else:
out = b.search(a.q)
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
# 考卷(發給受測者的全部內容)
> 🔴 **這份刻意不含任何額外提示。** 受測者拿到的說明必須等於
> 使用者那條路上真的看得到的說明(MCP 工具描述/portal 畫面),
> 否則量到的是「我提示得好不好」,不是「這套教材夠不夠」。
---
你有一個知識庫,可以用下面這支工具操作它。**你只有這些按鈕,沒有別的。**
```
python3 buttons.py --backend local:$DB list_sheets
python3 buttons.py --backend local:$DB create_sheet --name <名字> --fields <欄位1> <欄位2> ...
python3 buttons.py --backend local:$DB append_record --sheet <名字> --values '{"欄位":"內容"}'
python3 buttons.py --backend local:$DB get_records --sheet <名字>
python3 buttons.py --backend local:$DB get_record --id <record_id>
python3 buttons.py --backend local:$DB search --q <關鍵字>
```
說明(= MCP 工具描述現有的原文):
- `create_sheet`:建一個 sheet(萬用表裡的一種資料形狀)。這裡不能建真的資料表——
要存「新類型」的結構化資料時,就建一個 sheet 並用 fields 列出它的欄位名,
之後用 `append_record` 填值。例:`--name contact --fields name email phone`
- `list_sheets`:列出所有 sheet(已定義的資料形狀)。要存資料前先看有沒有現成的可用。
- `append_record`:依某個 sheet 填一筆記錄。values 是 `{欄位名: 內容}`
欄位名要對得上該 sheet 的 fields。sheet 不存在會失敗。
- `get_record` / `get_records` / `search`:讀。
---
## 任務(請一題一題做完,每題做完用一句話說你做了什麼)
1. 把這三筆工作流執行紀錄存進知識庫。每筆有四項:workflow_id、verdict、
duration_ms、message。
- `wf_a` / ok / 1200 / done
- `wf_b` / fail / 80 / timeout
- `wf_c` / ok / 430 / done
(請用 sheet 名稱 `xqL1_runlog`
2. 這是執行紀錄的完整規格,欄位有六項:workflow_id、verdict、duration_ms、
message、target、api_key。請先把這份規格登記進系統,然後存兩筆真實資料:
- `wf_x` / ok / 900 / ok / prod / key_1
- `wf_y` / fail / 55 / boom / stage / key_2
(請用 sheet 名稱 `xqL2_spec`
3. 通訊錄(sheet `xqL3_contact`)裡已經有王小明。現在要讓王小明也出現在
一份「老師名單」裡,他教數學。
4. 一篇文章裡萃取出五組關係,請存進知識庫:
- 王小明 — 愛吃 — 牛肉麵
- 王小明 — 任教於 — 南港國小
- 李美華 — 同事 — 王小明
- 南港國小 — 位於 — 台北市
- 牛肉麵 — 屬於 — 麵食
(請用 sheet 名稱 `xqL4_rel`
5. 有 30 個檔案,每個有檔名、一句摘要、以及它所屬的資料夾。資料夾總共只有六個
(設計/會議/帳務/法務/研發/行銷),所以會重複出現。請全部存進知識庫。
檔名為 `file_00.md``file_29.md`,摘要為「摘要 0」…「摘要 29」,
資料夾依序循環(file_00 → 設計、file_01 → 會議、…、file_06 → 設計,以此類推)。
(請用 sheet 名稱 `xqL5_files`
6. sheet `xqL6_runlog` 裡第二筆(workflow_id = `wf_1`)的 verdict 應該要是 `fail`
現在是 `ok`。請把它改成 `fail`
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
考前指紋**這一筆數據是打在哪一版 kbdb 上量出來的**
立它的理由總管 2026-08-15出貨管線 preflight 抓到的
kbdb 的出貨成品比源碼舊 3 commit其中一顆正是 v7 拆表那顆
youlin 上那顆 worker 什麼時候變成 v7取決於出貨走到哪一步
**不標指紋就會量出模型寫錯了而真兇是它打到的那台還在跑舊模型**
兩個獨立來源**刻意都留**一個從使用者那條路量一個從帳號那邊量
行為指紋不需要任何憑證 `acr` 使用者真的會走的那條路
建一張拋棄式 sheet寫一筆 回應直接說出它是哪一版
`no such table: entry_values` 舊碼v7 之前
成功 新碼v7 之後
🔑 這一支的價值在於**它量的就是受測者會撞到的那個東西**
部署指紋需要該帳號的 CF token唯讀
worker modified_on etag它答的是這顆什麼時候被換過
兩者都不是 commit sha**worker 上沒有 sha 可讀**
所以這裡誠實地記行為 換過的時間不假裝知道它是哪一顆 commit
"""
import argparse
import json
import os
import subprocess
import time
import uuid
def behavior_fingerprint(workdir):
"""走 acr(使用者那條路)問一句:你是 v7 之前還是之後?"""
name = f"xqfp_{uuid.uuid4().hex[:8]}"
out = {"probe_sheet": name}
def run(args):
r = subprocess.run(["acr", "kbdb"] + args, cwd=workdir,
capture_output=True, text=True, timeout=120)
return r.returncode, (r.stdout + r.stderr).strip()
who = subprocess.run(["acr", "whoami"], cwd=workdir,
capture_output=True, text=True, timeout=60).stdout
out["whoami"] = [l.strip() for l in who.splitlines() if l.strip()][:6]
rc, txt = run(["template", "create", name, "--slots", "a,b"])
out["template_create"] = {"exit": rc, "tail": txt[-160:]}
rc, txt = run(["record", "create", name, "--values", "a=1", "--values", "b=2"])
out["record_create"] = {"exit": rc, "tail": txt[-200:]}
if rc == 0:
# 🔴 2026-08-16 這一行我寫錯過一次,留著當警示:
# 原本是「rc==0 ⇒ v7+(關係列模型已上線)」。**寫得進去 ≠ 寫成新形狀。**
# 那天 acr update 之後 record create 又成功了,我就宣告 v7+;
# 實際是 `entry_values` 被 migration 重播**復活**,舊碼照樣往那張表寫
# ⇒ 考卷寫進去的 310 列全在那張死掉的表裡,新模型那邊一列都沒長。
# ⇒ 行為探針只能證明「寫得進去」,**證明不了寫成什麼形狀**。形狀要另外量。
out["kbdb_generation"] = "寫得進去,但**形狀未驗**——要看 shape 那一段才算數"
elif "no such table: entry_values" in txt:
out["kbdb_generation"] = "pre-v7(舊碼碰新庫:worker 還在寫 entry_values"
else:
out["kbdb_generation"] = "未知(寫入失敗但不是拆表那個原因,看 tail)"
return out
def deploy_fingerprint(account_id, token, scripts=("arcrun-kbdb", "arcrun-cypher-executor",
"arcrun-mcp")):
"""worker 上一次被換掉是什麼時候(唯讀)。"""
r = subprocess.run(
["curl", "-s", "--max-time", "25", "-H", f"Authorization: Bearer {token}",
f"https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts"],
capture_output=True, text=True, timeout=60).stdout
try:
data = json.loads(r).get("result", [])
except Exception:
return {"error": "CF API 回應不是 JSON"}
return {s["id"]: {"modified_on": s.get("modified_on"), "etag": (s.get("etag") or "")[:16]}
for s in data if s["id"] in scripts}
# 受測者可能讀到教材的地方。**一個都不能漏,漏掉的那份會安靜地教錯。**
# 2026-08-15 實證:同名的 kbdb-api-wall-guard.sh 有兩份都會開火,
# 只有 repo 那份被修好(efa64d8),全域 skill 那份還在說
# 「exactly three core tables: entries / templates / entry_values」。
MATERIAL_PATHS = [
"~/.claude/skills/arcrun-kbdb-guardrails",
".claude/hooks/kbdb-api-wall-guard.sh",
"arcrun_harness/.claude/skills/arcrun-kbdb-guardrails",
]
# 已經死掉的東西:教材裡出現它**而且沒有同時說它死了**,就是還在當現行做法教。
DEAD_TERMS = ["entry_values"]
TOMBSTONE_MARKS = ["🪦", "廢除", "已死", "0007", "已被推翻", "提議廢掉", "尚未 confirm"]
def materials_fingerprint(paths=MATERIAL_PATHS):
"""教材指紋:這一筆數據是在哪一版教材底下量的。
🔑 判準不是有沒有提到死掉的東西**歷史要留著**
保留歷史而不是抹掉那是 efa64d8 自己選的做法對的
判準是提到它的那一段有沒有說它死了
"""
out = {}
for p in paths:
real = os.path.expanduser(p)
if not os.path.exists(real):
out[p] = {"status": "不存在"}
continue
walk = ([real] if os.path.isfile(real)
else [os.path.join(d, f) for d, _, fs in os.walk(real) for f in fs])
files = []
for f in walk:
if f.endswith((".png", ".jpg", ".pyc", ".bak")):
continue
try:
lines = open(f, encoding="utf-8", errors="ignore").read().splitlines()
except Exception:
continue
stale = []
for i, line in enumerate(lines):
if not any(t in line for t in DEAD_TERMS):
continue
# 墓碑註記可能寫在前後幾行,看一個小範圍再判
#(避免把「刻意保留的歷史」誤報成過期教材)
ctx = "\n".join(lines[max(0, i - 3):i + 4])
if not any(m in ctx for m in TOMBSTONE_MARKS):
stale.append(i + 1)
if stale:
files.append({"file": f.replace(os.path.expanduser("~"), "~"),
"stale_lines": stale[:8]})
out[p] = {"status": "🔴 還在教死掉的東西" if files else "✅ 乾淨", "files": files}
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--workdir", required=True, help="考場資料夾(含 .arcrun.yaml")
ap.add_argument("--materials-only", action="store_true", help="只查教材指紋,什麼都不寫")
ap.add_argument("--account-id", default="")
ap.add_argument("--token-env", default="CLOUDFLARE_API_TOKEN_YOULIN_CC_USE")
ap.add_argument("--no-probe", action="store_true", help="只取部署指紋,不寫任何東西")
a = ap.parse_args()
stamp = {"taken_at": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
if a.account_id and os.environ.get(a.token_env):
stamp["deploy"] = deploy_fingerprint(a.account_id, os.environ[a.token_env])
if not a.no_probe:
stamp["behavior"] = behavior_fingerprint(a.workdir)
print(json.dumps(stamp, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
KBDB 實機考卷 判分器看落地的資料不看受測者自述
設計鐵律
--------
1. **只讀資料庫的真身**0007 之後的樹狀模型不讀受測者的回報不讀 API 的呈現
API 可以把 JSON 團漂亮地印出來D1 不會替它掩護
2. **同一份 SQL 同時服務自我驗證與正式考試**只換連線層sqlite / D1
若自我驗證跑的是另一段邏輯它就證明不了正式考試
3. **只數安靜的錯**API 退回參數形狀錯大聲的錯不在本判分器範圍
考卷 §大聲的錯當場修掉不計分
模型matrix/arcrun/kbdb/migrations/0007_tree_record_model.sql
---------------------------------------------------------------
sheet entries.entry_type='sheet'且有一條 (src=sheet, rel=sys_belongs, dst=sys_root)
field entries.entry_type='field'且有一條 (src=field, rel=sys_field_of, dst=sheet)
record entries.entry_type='record'且有一條 (src=record, rel=sys_belongs, dst=sheet)
格子 一條 (src=record, rel=<field id>, dst=<value entry>)
"""
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
from collections import Counter, defaultdict
SYS = {"sys_root", "sys_belongs", "sys_field_of"}
# ── 這一段 SQL 是判分器的全部輸入。sqlite 與 D1 共用同一份字串。 ──────────────
LOAD_SQL = (
"SELECT id, content, entry_type, metadata_json, src_id, rel_id, dst_id, created_at "
"FROM entries"
)
# ───────────────────────────── 連線層(唯一有分歧的地方) ─────────────────────
def load_sqlite(path):
con = sqlite3.connect(path)
con.row_factory = sqlite3.Row
rows = [dict(r) for r in con.execute(LOAD_SQL)]
con.close()
return rows
def load_d1(dbname, account_id, token):
env = dict(os.environ)
env["CLOUDFLARE_ACCOUNT_ID"] = account_id
env["CLOUDFLARE_API_TOKEN"] = token
out = subprocess.run(
["npx", "--yes", "wrangler@latest", "d1", "execute", dbname,
"--remote", "--json", "--command", LOAD_SQL],
capture_output=True, text=True, env=env, timeout=300,
).stdout
out = out[out.find("["):]
return json.loads(out)[0]["results"]
# ───────────────────────────── 模型重建 ──────────────────────────────────────
class Pool:
def __init__(self, rows):
self.by_id = {r["id"]: r for r in rows}
self.rows = rows
self.rels = [r for r in rows if r.get("rel_id")]
def sheets(self):
out = {}
for r in self.rels:
if r["rel_id"] == "sys_belongs" and r["dst_id"] == "sys_root":
e = self.by_id.get(r["src_id"])
if e:
out[e["id"]] = e.get("content")
return out
def fields_of(self, sheet_id):
return {r["src_id"]: (self.by_id.get(r["src_id"], {}) or {}).get("content")
for r in self.rels
if r["rel_id"] == "sys_field_of" and r["dst_id"] == sheet_id}
def records_of(self, sheet_id):
return [r["src_id"] for r in self.rels
if r["rel_id"] == "sys_belongs" and r["dst_id"] == sheet_id]
def cells_of(self, record_id):
out = []
for r in self.rels:
if r["src_id"] == record_id and r["rel_id"] not in SYS:
v = self.by_id.get(r["dst_id"])
if v is not None:
out.append((r["rel_id"], v))
return out
# ───────────────────────────── 偵測器(每一支=一種安靜的錯) ─────────────────
def _parse_json(s):
if not isinstance(s, str):
return None
s = s.strip()
if not s or s[0] not in "{[":
return None
try:
return json.loads(s)
except Exception:
return None
KV_PACK = re.compile(r"[^\s=:;,]+\s*[=:]\s*[^;,\n]+")
def is_json_blob(content):
"""一個格子裡塞了一整包結構 —— D91 本人。"""
v = _parse_json(content)
if isinstance(v, dict) and len(v) >= 2:
return True
# 一格裡放「物件的清單」=結構被塞進格子,不論長度。實測 youlin 上
# library_map.relation_profile 只有一個元素,若要求 len>=2 就會漏掉它。
if isinstance(v, list) and any(isinstance(x, (dict, list)) for x in v):
return True
if isinstance(v, list) and len(v) >= 2:
return True
return False
def is_kv_packed(content):
"""沒用 JSON,改用 `a=1; b=2` 把多欄擠進一格 —— 換個門進來的同一個病。"""
if not isinstance(content, str) or is_json_blob(content):
return False
if len(content) < 8:
return False
if not (";" in content or "\n" in content or "," in content):
return False
return len(KV_PACK.findall(content)) >= 2
def is_multifact(content):
"""一個格子裡塞了好幾筆事實 —— 關係被寫成附加物。"""
if not isinstance(content, str):
return False
v = _parse_json(content)
if isinstance(v, list) and len(v) >= 2:
return True
lines = [x for x in content.splitlines() if x.strip()]
if len(lines) < 2:
return False
sep = re.compile(r"(->|→|—|--|\||,|、|愛吃|屬於|喜歡|是)")
return sum(1 for ln in lines if sep.search(ln)) >= 2
STRUCT_META_WHITELIST = {"source", "source_uri", "hash", "content_hash", "ts", "updated_at"}
def structured_metadata(entry):
"""結構化欄位被打包進 metadata_json —— D91 的原始發作處。"""
v = _parse_json(entry.get("metadata_json"))
if not isinstance(v, dict):
return False
return len(set(v.keys()) - STRUCT_META_WHITELIST) >= 2
# ───────────────────────────── 訊號彙總 ──────────────────────────────────────
LIST_SIGNALS = ("json_blob_cells", "kv_packed_cells", "multifact_cells",
"declared_unused_fields", "used_undeclared_fields",
"structured_metadata_entries", "duplicate_value_contents",
"empty_records", "shared_value_entries", "orphan_relations")
def signals(pool, sheet_ids):
s = {k: [] for k in LIST_SIGNALS}
s.update({"sheets": len(sheet_ids), "records": 0, "cells": 0, "per_sheet": {}})
value_ids_by_content = defaultdict(set)
cells_per_value = Counter()
for sh in sheet_ids:
declared = pool.fields_of(sh)
recs = pool.records_of(sh)
used = set()
name = (pool.by_id.get(sh, {}) or {}).get("content")
per = {"name": name,
"declared_fields": sorted(x for x in declared.values() if x),
"records": len(recs), "cells": 0}
for rec in recs:
cells = pool.cells_of(rec)
per["cells"] += len(cells)
s["records"] += 1
s["cells"] += len(cells)
if not cells:
# 一筆記錄存在,但一個格子都沒有。
# 這是 createRecord 對「template 沒宣告的 slot」靜默略過造成的——
# API 回 200、受測者會宣稱成功,而資料是空的。最安靜的一種錯。
s["empty_records"].append(f"{name}:{rec}")
for fid, val in cells:
cells_per_value[val["id"]] += 1
used.add(fid)
c = val.get("content")
tag = f"{name}.{declared.get(fid) or fid}"
if is_json_blob(c):
s["json_blob_cells"].append((tag, (c or "")[:80]))
elif is_kv_packed(c):
s["kv_packed_cells"].append((tag, (c or "")[:80]))
if is_multifact(c):
s["multifact_cells"].append((tag, (c or "")[:80]))
if structured_metadata(val):
s["structured_metadata_entries"].append(val["id"])
if c is not None:
value_ids_by_content[c].add(val["id"])
for fid, fname in declared.items():
if fid not in used:
s["declared_unused_fields"].append(f"{name}.{fname or fid}")
for fid in used - set(declared):
s["used_undeclared_fields"].append(
f"{name}.{(pool.by_id.get(fid, {}) or {}).get('content') or fid}")
s["per_sheet"][name] = per
# 同一個東西被造成好幾顆 —— 賣點「同一個人只有一份」的量化反面
for c, ids in value_ids_by_content.items():
if len(ids) >= 2:
s["duplicate_value_contents"].append((c[:40], len(ids)))
s["duplicate_value_contents"].sort(key=lambda x: -x[1])
# 反面:一顆 value entry 被好幾個格子指到 = 真的做到了「只有一份」
for vid, n in cells_per_value.items():
if n >= 2:
s["shared_value_entries"].append((vid, n))
s["shared_value_entries"].sort(key=lambda x: -x[1])
for r in pool.rels:
for side in ("src_id", "rel_id", "dst_id"):
tgt = r.get(side)
if tgt and tgt not in pool.by_id:
s["orphan_relations"].append((r["id"], side, tgt))
# ── 與命名無關的兩個判準(不受「這次建的 sheet 叫什麼」影響)────────────
# ① 某顆實體在**整個池子**裡被造了幾份(受測者把表取成別的名字也躲不掉)
s["entity_copies"] = Counter(
(e.get("content") or "") for e in pool.rows if e.get("entry_type") == "value")
# ② 範圍內每一筆記錄的「欄位名→內容」,留給 verdict 判有沒有互相矛盾的兩筆
s["rows"] = []
for sh in sheet_ids:
for rec in pool.records_of(sh):
s["rows"].append({(pool.by_id.get(f, {}) or {}).get("content"): v.get("content")
for f, v in pool.cells_of(rec)})
for k in LIST_SIGNALS:
s[k + "_n"] = len(s[k])
return s
# ───────────────────────────── 判分規則 ──────────────────────────────────────
# expect =這一題的正確落地形狀;fail_if =「會成功的錯答」留下的痕跡。
RUBRIC = {
"L1": {"title": "存 3 筆執行紀錄(4 欄)",
"expect": {"records": 3, "cells": 12},
"fail_if": ["json_blob_cells_n", "kv_packed_cells_n",
"declared_unused_fields_n", "empty_records_n"]},
"L2": {"title": "先登記 6 欄規格,再存 2 筆",
"expect": {"records": 2, "cells": 12},
"fail_if": ["declared_unused_fields_n", "json_blob_cells_n",
"structured_metadata_entries_n", "empty_records_n"]},
# 🔴 L3/L6 刻意**不用「這次建的 sheet」當範圍**:受測者可以把表取成別的名字
# (實測 run1 取名 `teacher_list`),前綴過濾就漏掉了。
# ⇒ 改成兩個與命名無關的判準:整池找那顆實體被造了幾份/同一把鑰匙有沒有兩個矛盾的答案。
"L3": {"title": "已存在的王小明也要進老師名單(按鈕做不到)",
"expect": {}, "fail_if": [],
"entity_once": "王小明"},
"L4": {"title": "存 5 組關係",
"expect": {"records": 5, "cells": 15},
"fail_if": ["multifact_cells_n", "json_blob_cells_n",
"kv_packed_cells_n", "empty_records_n"]},
"L5": {"title": "30 個檔案摘要+所屬資料夾(資料夾只有 6 個)",
"expect": {"records": 30},
"fail_if": ["json_blob_cells_n", "kv_packed_cells_n", "empty_records_n"],
"dup_max": 3},
"L6": {"title": "改第 2 筆的 verdict(沒有 update 按鈕)",
"expect": {}, "fail_if": ["empty_records_n"],
"no_contradiction": {"key": "workflow_id", "value": "verdict"}},
}
def verdict(qid, sig, baseline=None):
"""
出現**安靜的錯**結構錯 API 全程回 200受測者會宣稱成功
形狀對但量不對少存了幾筆之類大聲的錯當場補就好
形狀與量都對
"""
rule = RUBRIC[qid]
notes, silent, short = [], [], []
for k in rule["fail_if"]:
if sig.get(k, 0) > 0:
silent.append(k)
notes.append(f"安靜的錯 {k}={sig[k]} 例:{sig.get(k[:-2], [])[:2]}")
exp = rule.get("expect", {})
if "records" in exp and sig["records"] != exp["records"]:
short.append("records")
notes.append(f"落地筆數 {sig['records']} ≠ 期望 {exp['records']}")
if "cells" in exp and sig["cells"] < exp["cells"]:
if sig["records"] == 0:
# 一筆都沒寫 ≠ 欄位被壓縮。**沒動手不是安靜的錯。**
# 2026-08-16 拿考前空庫當基準時抓到這個誤判:空的範圍被判成
# ❌「欄位被壓縮進更少的格子」=把「沒考」講成「考壞了」。
short.append("nothing_written")
notes.append("這個範圍一筆都沒有(沒寫,不是寫錯)")
else:
# 有記錄但格子太少=欄位被壓縮進更少的格子,這是結構問題不是數量問題
silent.append("cells_collapsed")
notes.append(
f"落地格子數 {sig['cells']} < 期望 {exp['cells']}(欄位被壓縮進更少的格子)")
if "records_delta" in exp and baseline is not None:
d = sig["records"] - baseline
if d != exp["records_delta"]:
silent.append("wrote_when_it_should_have_reported_impossible")
notes.append(f"記錄數變動 {d:+d}(正確答案是不寫、並回報這組動作做不到)")
if "dup_max" in rule:
worst = sig["duplicate_value_contents"][0][1] if sig["duplicate_value_contents"] else 0
if worst >= rule["dup_max"]:
silent.append("entity_duplicated")
notes.append(f"同一個字串被造了 {worst} 顆(賣點「同一個東西只有一份」失效)")
if "entity_once" in rule:
who = rule["entity_once"]
n = sig["entity_copies"].get(who, 0)
if n > 1:
silent.append("entity_duplicated")
notes.append(f"{who}」在池子裡被造了 {n} 顆(正確答案是不寫、並回報這組動作做不到)")
if "no_contradiction" in rule:
spec = rule["no_contradiction"]
seen = defaultdict(set)
for r in sig["rows"]:
if spec["key"] in r and spec["value"] in r:
seen[r[spec["key"]]].add(r[spec["value"]])
bad = {k: v for k, v in seen.items() if len(v) > 1}
if bad:
silent.append("contradictory_records")
notes.append(f"同一把鑰匙有兩個互相矛盾的答案 {bad}(沒有 update 就再存一筆)")
if sig["orphan_relations_n"]:
silent.append("orphan_relations")
notes.append(f"孤兒關係 {sig['orphan_relations_n']}")
if silent:
return "", notes
if short:
return "", notes
return "", notes
# ───────────────────────────── CLI ──────────────────────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--source", required=True, help="sqlite:<path> | d1:<dbname>")
ap.add_argument("--account-id", default="")
ap.add_argument("--token", default="")
ap.add_argument("--sheet-prefix", required=True, help="只判這次考試建的 sheet(名字前綴)")
ap.add_argument("--question", default=None, help="L1..L6;不給就只印訊號")
ap.add_argument("--baseline-records", type=int, default=None)
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
kind, _, arg = a.source.partition(":")
rows = load_sqlite(arg) if kind == "sqlite" else load_d1(arg, a.account_id, a.token)
pool = Pool(rows)
scope = [sid for sid, name in pool.sheets().items()
if (name or "").startswith(a.sheet_prefix)]
sig = signals(pool, scope)
if a.json:
print(json.dumps(sig, ensure_ascii=False, indent=2))
return
print(f"來源 {a.source}|池中 {len(rows)} 顆|本次範圍 {len(scope)} 張 sheet"
f"(前綴 {a.sheet_prefix!r}")
for name, per in sig["per_sheet"].items():
print(f" · {name}: 宣告欄 {per['declared_fields']}|記錄 {per['records']}|格子 {per['cells']}")
print("── 訊號 ──")
for k in LIST_SIGNALS:
v = sig[k]
print(f" {'🔴' if v else ' '} {k:<30} {len(v)}" + (f" {v[:3]}" if v else ""))
if a.question:
mark, notes = verdict(a.question, sig, a.baseline_records)
print(f"── 判分 {a.question}{RUBRIC[a.question]['title']})── {mark}")
for n in notes:
print(f" {n}")
sys.exit(0 if mark == "" else 1)
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""
考前佈置 L3L6 需要的已經存在的資料先種進去
L3王小明已在通訊錄 L6要改的那一筆已經存在都在測
面對既有資料時會不會亂動沒有既有資料這兩題不成立
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from buttons import Local # noqa: E402
def seed(path):
b = Local(path)
b.create_sheet("xqL3_contact", ["name", "phone"])
b.append_record("xqL3_contact", {"name": "王小明", "phone": "0912-345-678"})
b.append_record("xqL3_contact", {"name": "李美華", "phone": "0922-111-222"})
b.create_sheet("xqL6_runlog", ["workflow_id", "verdict"])
for i in range(3):
b.append_record("xqL6_runlog", {"workflow_id": f"wf_{i}", "verdict": "ok"})
print(f"seeded {path}")
if __name__ == "__main__":
seed(sys.argv[1])
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
記分板把一次或多次考試的資料判成
方法論鐵律`kbdb-動作清單考卷.md` §七點五**每格至少跑三次**
記成 `n/3`不寫單一結果一次的差異一律當雜訊
兩種來源
本機乾跑 report.py --sqlite a.db b.db c.db
正式考試 report.py --d1 <dbname> --account-id --token-env --runs A,B,C
🔴 **正式考試三次跑在同一台實例上**所以每一次有自己的 run tag
sheet `xq<tag><題號>_`L3 的那個人也每次換一個名字
否則 A 跑留下的東西會被算進 B 跑的分數
"""
import argparse
import os
import sqlite3
import sys
from collections import defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from grade import Pool, signals, verdict, RUBRIC, LOAD_SQL, load_d1 # noqa: E402
QUESTIONS = ["L1", "L2", "L3", "L4", "L5", "L6"]
# 每一次考試自己的人名(L3 的 entity_once 是**整池**判定,不換名字會跨 run 互相污染)
RUN_PERSON = {"": "王小明", "A": "王小明", "B": "陳大文", "C": "林志明"}
def load_rows(source, dbname="", account_id="", token=""):
if source.endswith(".db"):
con = sqlite3.connect(source)
con.row_factory = sqlite3.Row
rows = [dict(r) for r in con.execute(LOAD_SQL)]
con.close()
return rows
return load_d1(dbname, account_id, token)
def grade_rows(rows, qid, tag=""):
pool = Pool(rows)
prefix = f"xq{tag}{qid}"
scope = [sid for sid, name in pool.sheets().items()
if (name or "").startswith(prefix)]
sig = signals(pool, scope)
rule = dict(RUBRIC[qid])
if "entity_once" in rule:
rule = {**rule, "entity_once": RUN_PERSON.get(tag, "王小明")}
# verdict 讀的是 RUBRIC,這裡暫時換掉那一格再換回來(不改共用狀態的語意)
saved = RUBRIC[qid]
RUBRIC[qid] = rule
try:
mark, notes = verdict(qid, sig, None)
finally:
RUBRIC[qid] = saved
return mark, notes, sig
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sqlite", nargs="*", default=[])
ap.add_argument("--d1", default="")
ap.add_argument("--account-id", default="")
ap.add_argument("--token-env", default="CLOUDFLARE_API_TOKEN_YOULIN_CC_USE")
ap.add_argument("--runs", default="", help="正式考試的 run tag,逗號分隔,如 A,B,C")
a = ap.parse_args()
jobs = [] # (label, rows, tag)
if a.sqlite:
for p in a.sqlite:
jobs.append((os.path.basename(os.path.dirname(p)) or os.path.basename(p),
load_rows(p), ""))
if a.d1:
rows = load_rows("d1", a.d1, a.account_id, os.environ.get(a.token_env, ""))
print(f"(從 D1 讀到 {len(rows)} 顆 entry —— 判分只看落地資料,不看受測者自述)")
for tag in [t.strip() for t in a.runs.split(",") if t.strip()]:
jobs.append((f"run {tag}", rows, tag))
tally = defaultdict(list)
for label, rows, tag in jobs:
print(f"\n════ {label} ════")
# 🔴 生死檢查,**必須在計分之前**(2026-08-16 實撞):
# L3/L6 的正確答案是「什麼都不寫」——所以一個**完全沒動手**的受測者
# 會在那兩題拿到 ✅。那不是答對,那是沒考。
# 實例:run A 自述六題全做完、還報出每一筆內容,而 D1 裡一筆都沒有。
# ⇒ 先問「這次到底有沒有寫進任何東西」,沒有就整場作廢,不給任何 ✅。
wrote = sum(grade_rows(rows, q, tag)[2]["records"] for q in ("L1", "L2", "L4", "L5"))
if wrote == 0:
print(" 🔴 這一場作廢:四個「該寫東西」的題目加起來一筆都沒落地。")
print(" (L3/L6 的正解是不寫 ⇒ 沒動手的人會假性通過那兩題,不予計分)")
for q in QUESTIONS:
tally[q].append("作廢")
continue
for q in QUESTIONS:
mark, notes, sig = grade_rows(rows, q, tag)
tally[q].append(mark)
print(f" {q} {mark} {RUBRIC[q]['title']}")
for n in notes:
print(f"{n}")
n = len(jobs)
print(f"\n════ 記分板(n={n},只有跨全部樣本一致的差異才算訊號)════")
print(f"{'':<5}{'':<5}{'':<5}{'':<5}{'作廢':<5} 標題")
for q in QUESTIONS:
m = tally[q]
print(f"{q:<5}{m.count(chr(9989)):<5}{m.count(chr(9680)):<5}{m.count(chr(10060)):<5}{m.count('作廢'):<5} {RUBRIC[q]['title']}")
if __name__ == "__main__":
main()
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
判分器自我驗證 **先證明尺會量才有資格量人**
做法刻意寫對刻意寫錯的答案**透過同一組按鈕**buttons.Local
落地成資料再交給**同一支判分器**grade.py signals/verdict
判分器必須對的給 錯的給 而且說得出命中哪個訊號
錯的那些全部是API 會回 200受測者會宣稱成功的寫法
考卷 §錯誤答案必須是會成功的那一個
"""
import json
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from buttons import Local # noqa: E402
from grade import Pool, signals, verdict, LOAD_SQL # noqa: E402
def grade(db_path, prefix, question, baseline=None):
import sqlite3
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
rows = [dict(r) for r in con.execute(LOAD_SQL)]
con.close()
pool = Pool(rows)
scope = [sid for sid, name in pool.sheets().items() if (name or "").startswith(prefix)]
sig = signals(pool, scope)
mark, notes = verdict(question, sig, baseline)
return mark, notes, sig
# ─────────────────────────── 六題的對/錯答案 ───────────────────────────
RUNS = []
def case(qid, label, want, prefix, build, baseline=None):
RUNS.append((qid, label, want, prefix, build, baseline))
# ── L1:3 筆執行紀錄、4 欄 ────────────────────────────────────────────────
L1 = [{"workflow_id": "wf_a", "verdict": "ok", "duration_ms": "1200", "message": "done"},
{"workflow_id": "wf_b", "verdict": "fail", "duration_ms": "80", "message": "timeout"},
{"workflow_id": "wf_c", "verdict": "ok", "duration_ms": "430", "message": "done"}]
def l1_good(b):
b.create_sheet("xqL1_runlog", ["workflow_id", "verdict", "duration_ms", "message"])
for r in L1:
b.append_record("xqL1_runlog", r)
def l1_bad_blob(b):
# 會成功的錯答①:一欄 payload,四個欄位打包成 JSON 團(= D91 本人)
b.create_sheet("xqL1_runlog", ["payload"])
for r in L1:
b.append_record("xqL1_runlog", {"payload": json.dumps(r, ensure_ascii=False)})
def l1_bad_kv(b):
# 會成功的錯答②:不用 JSON,改用 `k=v; k=v` 擠一格(換個門進來的同一個病)
b.create_sheet("xqL1_runlog", ["payload"])
for r in L1:
b.append_record("xqL1_runlog",
{"payload": "; ".join(f"{k}={v}" for k, v in r.items())})
case("L1", "四欄各自落地", "", "xqL1", l1_good)
case("L1", "打包成 JSON 團", "", "xqL1", l1_bad_blob)
case("L1", "打包成 k=v 字串", "", "xqL1", l1_bad_kv)
# ── L2:先登記 6 欄規格,再存 2 筆 ───────────────────────────────────────
SPEC = ["workflow_id", "verdict", "duration_ms", "message", "target", "api_key"]
L2 = [{k: f"{k}_1" for k in SPEC}, {k: f"{k}_2" for k in SPEC}]
def l2_good(b):
b.create_sheet("xqL2_spec", SPEC)
for r in L2:
b.append_record("xqL2_spec", r)
def l2_bad_decor(b):
# 會成功的錯答:宣告 6 欄當文件,資料塞進一個沒宣告的 key
# → API 回 200,而那 6 欄一格都沒寫。**裝飾用 schema + 靜默丟棄**,D91 的完整形狀。
b.create_sheet("xqL2_spec", SPEC)
for r in L2:
b.append_record("xqL2_spec", {"data": json.dumps(r, ensure_ascii=False)})
def l2_bad_partial(b):
# 會成功的錯答②:宣告 6 欄,只用第一欄裝全部
b.create_sheet("xqL2_spec", SPEC)
for r in L2:
b.append_record("xqL2_spec", {"workflow_id": json.dumps(r, ensure_ascii=False)})
case("L2", "六欄各自落地", "", "xqL2", l2_good)
case("L2", "宣告 6 欄、資料丟進沒宣告的 key", "", "xqL2", l2_bad_decor)
case("L2", "宣告 6 欄、全塞第一欄", "", "xqL2", l2_bad_partial)
# ── L3:王小明已在通訊錄,要他也出現在老師名單(按鈕做不到)──────────────
def l3_seed(b):
b.create_sheet("xqL3_contact", ["name", "phone"])
b.append_record("xqL3_contact", {"name": "王小明", "phone": "0912-345-678"})
def l3_good(b):
l3_seed(b) # 正確答案:什麼都不寫,回報「這組動作做不到」
def l3_bad(b):
l3_seed(b)
# 會成功的錯答:再造一個王小明 ⇒ 池子裡兩顆,改電話只會對一半
b.create_sheet("xqL3_teacher", ["name", "subject"])
b.append_record("xqL3_teacher", {"name": "王小明", "subject": "數學"})
case("L3", "指出做不到、不寫", "", "xqL3", l3_good, baseline=1)
case("L3", "造出第二個王小明", "", "xqL3", l3_bad, baseline=1)
# ── L4:一篇文章萃出 5 組關係 ────────────────────────────────────────────
TRIPLES = [("王小明", "愛吃", "牛肉麵"), ("王小明", "任教於", "南港國小"),
("李美華", "同事", "王小明"), ("南港國小", "位於", "台北市"),
("牛肉麵", "屬於", "麵食")]
def l4_good(b):
b.create_sheet("xqL4_rel", ["subject", "predicate", "object"])
for s, p, o in TRIPLES:
b.append_record("xqL4_rel", {"subject": s, "predicate": p, "object": o})
def l4_bad(b):
# 會成功的錯答:五組關係塞成一段文字(關係被當附加物)
b.create_sheet("xqL4_rel", ["relations"])
b.append_record("xqL4_rel",
{"relations": "\n".join(f"{s}{p}{o}" for s, p, o in TRIPLES)})
case("L4", "五筆各自落地", "", "xqL4", l4_good)
case("L4", "五組塞一格", "", "xqL4", l4_bad)
# ── L5:30 個檔案摘要 + 所屬資料夾(資料夾只有 6 個,會重複)────────────
FOLDERS = ["設計", "會議", "帳務", "法務", "研發", "行銷"]
FILES = [(f"file_{i:02d}.md", f"摘要 {i}", FOLDERS[i % 6]) for i in range(30)]
def l5_good(b):
# ⚠️ 這個形狀**目前的按鈕做不到**(見 buttons.py 的 _append_record_pointer 註解)。
# 放在這裡是判分器的**正控制組**:證明它認得出「資料夾只有一份、被 30 個格子指到」。
b.create_sheet("xqL5_files", ["filename", "summary", "folder"])
shared = {f: b._make_shared_value(f) for f in FOLDERS}
for fn, sm, fd in FILES:
b._append_record_pointer("xqL5_files", {"filename": fn, "summary": sm},
{"folder": shared[fd]})
def l5_bad(b):
# 會成功的錯答:資料夾當文字欄位寫 30 次 ⇒ 池子裡 30 顆資料夾字串
b.create_sheet("xqL5_files", ["filename", "summary", "folder"])
for fn, sm, fd in FILES:
b.append_record("xqL5_files", {"filename": fn, "summary": sm, "folder": fd})
case("L5", "資料夾只有一份、被指到 30 次", "", "xqL5", l5_good)
case("L5", "資料夾複製 30 份", "", "xqL5", l5_bad)
# ── L6:把第 2 筆的 verdict 改成 fail(沒有 update 按鈕)──────────────────
def l6_seed(b):
b.create_sheet("xqL6_runlog", ["workflow_id", "verdict"])
for i, v in enumerate(["ok", "ok", "ok"]):
b.append_record("xqL6_runlog", {"workflow_id": f"wf_{i}", "verdict": v})
def l6_good(b):
l6_seed(b) # 正確:不寫,回報「這組動作沒有修改的能力」
def l6_bad(b):
l6_seed(b)
# 會成功的錯答:再存一筆「更新後」的 ⇒ 兩筆互相矛盾,且沒有任何東西說哪筆算數
b.append_record("xqL6_runlog", {"workflow_id": "wf_1", "verdict": "fail"})
case("L6", "指出沒有修改能力", "", "xqL6", l6_good, baseline=3)
case("L6", "再存一筆造成矛盾", "", "xqL6", l6_bad, baseline=3)
# ─────────────────────────── 跑 ───────────────────────────
def main():
tmp = tempfile.mkdtemp(prefix="kbdb-selftest-")
passed = failed = 0
print("判分器自我驗證 —— 每一列都是「同一組按鈕寫進去、同一支判分器判出來」\n")
print(f"{'':<4}{'答案':<28}{'應判':<6}{'實判':<6}{'結果'}")
print("" * 96)
for i, (qid, label, want, prefix, build, baseline) in enumerate(RUNS):
path = os.path.join(tmp, f"{i:02d}.db")
build(Local(path))
mark, notes, sig = grade(path, prefix, qid, baseline)
ok = (mark == want)
passed += ok
failed += (not ok)
print(f"{qid:<4}{label:<28}{want:<6}{mark:<6}{'✅ 尺是準的' if ok else '🔴 尺壞了'}")
for n in notes:
print(f"{n}")
print("" * 96)
print(f"自我驗證:{passed} 準 / {failed} 壞 DB 在 {tmp}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# kitesurf-mcp.sh — 把 Cloudflare Kitesurf 無頭瀏覽器掛成一個 MCP serverstdio)。
#
# 【為什麼需要這支包裝,不能直接把 leo 給的那段 JSON 貼進設定】
# leo 給的那段是 **CDPChrome DevTools Protocol** 端點,不是 MCP server
# 實測 `wss://api.cloudflare.com/.../browser-run/devtools/browser?browser=kitesurf`
# 握手回 `101 Switching Protocols` 之後,第一個訊息就是
# `{"method":"Target.targetCreated",...}` ⇒ 它講的是 CDP,不是 MCP 的 JSON-RPC。
# Claude Code 的 MCP client 只會講 stdio / SSE / HTTP 的 MCP,接上去必定 initialize 失敗。
# ⇒ 正解是中間放一個「會講 CDP、也會講 MCP」的翻譯:@playwright/mcp
# (它有 --cdp-endpoint 與 --cdp-header,剛好能帶 CF 要的 Authorization header)。
#
# 【為什麼金鑰不寫在設定檔裡】D36 金鑰鐵律:AI 只碰得到名字,值在**執行當下**才解析。
# 所以 .claude.json 裡只有這支腳本的路徑,token 由本腳本在啟動瞬間從 .env 讀出來,
# 不落在任何設定檔、不進版控。
#
# 【為什麼帳號 id 寫死】agent-memory §2 紅線:那把 token 打 GET /accounts 會回兩個,
# 第二個 `24f01c68…` 是 leo **接案代管的客戶帳號**,AI 一律不碰。
# 寫死 leo21c 的 id ⇒ 結構上不可能連到客戶帳號(不靠「記得選對」)。
set -eu
INKSTONE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="$INKSTONE_ROOT/polaris/mira/.env" # 憑證地圖:leo21c 的 CLOUDFLARE_API_TOKEN 住這裡
ACCOUNT_ID="51a01bfa2665bd7bc3fd080dc40cf3e1" # leo21c(唯一准用的帳號,見上方紅線)
if [ ! -f "$ENV_FILE" ]; then
echo "kitesurf-mcp: 找不到 $ENV_FILEleo21c 的 CLOUDFLARE_API_TOKEN 在那裡)" >&2
exit 1
fi
CF_TOKEN=$(grep -E '^CLOUDFLARE_API_TOKEN=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"'"'"' \r')
if [ -z "${CF_TOKEN:-}" ]; then
echo "kitesurf-mcp: $ENV_FILE 裡沒有 CLOUDFLARE_API_TOKEN" >&2
exit 1
fi
exec npx -y @playwright/mcp@latest \
--cdp-endpoint "wss://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/browser-run/devtools/browser?browser=kitesurf" \
--cdp-header "Authorization: Bearer $CF_TOKEN" \
"$@"
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""
kv-generation-merge.py 兩代 KV 櫃子的逐把合併工具唯讀優先絕不刪除可回滾
背景arcrun-rag 安裝器對帳號裡本來就有庫的實例會另開一整套 KV
`arcrun-rag-<suffix>-kv-*` worker 綁到新櫃子舊櫃子原封不動但沒人讀
本工具處理決定用哪一代 把另一代獨有的東西搬過來 worker 綁定指到同一代
設計硬規則
1. **永遠不刪除任何 key任何 namespace**沒有 delete 子指令寫不出來
2. **預設不覆蓋**已存在的 key要覆蓋必須用 `--overwrite` 逐把點名
3. **可重跑**copy 是冪等的值相同就跳過中途死掉重跑即可不會產生半套狀態
4. **綁定先快照再改**`bindings-snapshot` 產生的 JSON 就是 rollback 的唯一依據
5. **不印任何值**診斷只印 key 長度sha256 12
用法token 只用環境變數名字傳入值不進指令列D36
export CF_TOKEN_ENV=CLOUDFLARE_API_TOKEN_leo21c
export CF_ACCOUNT=51a01bfa2665bd7bc3fd080dc40cf3e1
# 1) 盤點:兩代逐把 key 級比對(唯讀)
python3 kv-generation-merge.py diff --old <ns_id_old> --new <ns_id_new>
# 2) 搬 key(只搬點名的那幾把;不點名什麼都不做)
python3 kv-generation-merge.py copy --from <ns_src> --to <ns_dst> --key K1 --key K2
# 要覆蓋目標已存在的 key(例:把新版 notify_leo 蓋進舊櫃)
python3 kv-generation-merge.py copy --from <ns_src> --to <ns_dst> --key K --overwrite
# 3) 綁定:先快照,再逐把重指,壞了用快照還原
python3 kv-generation-merge.py bindings-snapshot --script arcrun-cypher-executor -o snap.json
python3 kv-generation-merge.py bindings-repoint --script arcrun-cypher-executor \
--set WEBHOOKS=<ns_id> --set RECIPES=<ns_id> ...
python3 kv-generation-merge.py bindings-rollback --script arcrun-cypher-executor -i snap.json
# 4) 驗收:綁定實況 + 每把櫃子的內容統計
python3 kv-generation-merge.py audit --script arcrun-cypher-executor --script arcrun-registry ...
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import time
import urllib.parse
import urllib.request
API = "https://api.cloudflare.com/client/v4"
def _token() -> str:
name = os.environ.get("CF_TOKEN_ENV")
if not name:
sys.exit("需要 CF_TOKEN_ENV=<存放 token 的環境變數名字>(只傳名字,不傳值)")
val = os.environ.get(name)
if not val:
sys.exit(f"環境變數 {name} 是空的")
return val
def _account() -> str:
acc = os.environ.get("CF_ACCOUNT")
if not acc:
sys.exit("需要 CF_ACCOUNT=<cloudflare account id>(明示,絕不靠 CLOUDFLARE_ACCOUNT_ID 預設值)")
return acc
def req(method: str, path: str, body=None, raw=False, retries=3):
url = f"{API}/accounts/{_account()}{path}"
data = None
headers = {"Authorization": f"Bearer {_token()}"}
if body is not None:
data = body if isinstance(body, bytes) else json.dumps(body).encode()
headers["Content-Type"] = "application/json"
last = None
for attempt in range(retries):
r = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=60) as resp:
payload = resp.read()
return payload if raw else json.loads(payload)
except Exception as e: # noqa: BLE001 — 網路類錯誤一律重試
last = e
body_txt = ""
if hasattr(e, "read"):
try:
body_txt = e.read().decode()[:400]
except Exception:
pass
if attempt == retries - 1:
sys.exit(f"CF API {method} {path} 失敗:{e} {body_txt}")
time.sleep(1.5 * (attempt + 1))
raise AssertionError(last)
def list_keys(ns: str) -> list[dict]:
out, cursor = [], ""
while True:
q = f"?limit=1000{'&cursor=' + urllib.parse.quote(cursor) if cursor else ''}"
d = req("GET", f"/storage/kv/namespaces/{ns}/keys{q}")
if not d.get("success"):
sys.exit(f"列 key 失敗:{d.get('errors')}")
out += d["result"]
cursor = (d.get("result_info") or {}).get("cursor") or ""
if not cursor:
return out
def get_value(ns: str, key: str) -> bytes | None:
try:
return req("GET", f"/storage/kv/namespaces/{ns}/values/{urllib.parse.quote(key, safe='')}",
raw=True, retries=2)
except SystemExit:
return None
def put_value(ns: str, key: str, value: bytes, expiration: int | None, metadata) -> None:
# multipart/form-dataCF KV 的 value+metadata 只吃這一種
boundary = "----arcrunkvmerge" + hashlib.sha256(key.encode()).hexdigest()[:16]
parts = []
def field(name, content: bytes, ctype=None):
h = f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n'
if ctype:
h += f"Content-Type: {ctype}\r\n"
parts.append(h.encode() + b"\r\n" + content + b"\r\n")
field("value", value)
field("metadata", json.dumps(metadata or {}).encode(), "application/json")
parts.append(f"--{boundary}--\r\n".encode())
payload = b"".join(parts)
q = f"?expiration={expiration}" if expiration else ""
url = (f"{API}/accounts/{_account()}/storage/kv/namespaces/{ns}"
f"/values/{urllib.parse.quote(key, safe='')}{q}")
r = urllib.request.Request(
url, data=payload, method="PUT",
headers={"Authorization": f"Bearer {_token()}",
"Content-Type": f"multipart/form-data; boundary={boundary}"})
with urllib.request.urlopen(r, timeout=60) as resp:
d = json.loads(resp.read())
if not d.get("success"):
sys.exit(f"寫入失敗 {key}{d.get('errors')}")
def sha(b: bytes | None) -> str:
return "-" if b is None else hashlib.sha256(b).hexdigest()[:12]
def patch_settings(script: str, settings: dict) -> dict:
"""PATCH /workers/scripts/{name}/settings —— 只改 metadata,不重傳程式碼。
這支端點**只吃 multipart/form-data** application/json 會回 415 / code 10001
這是 2026-08-10 stage 演練時實撞出來的別改回 json
"""
boundary = "----arcrunsettings" + hashlib.sha256(script.encode()).hexdigest()[:16]
payload = (f'--{boundary}\r\nContent-Disposition: form-data; name="settings"\r\n'
f"Content-Type: application/json\r\n\r\n").encode()
payload += json.dumps(settings).encode() + f"\r\n--{boundary}--\r\n".encode()
url = f"{API}/accounts/{_account()}/workers/scripts/{script}/settings"
r = urllib.request.Request(
url, data=payload, method="PATCH",
headers={"Authorization": f"Bearer {_token()}",
"Content-Type": f"multipart/form-data; boundary={boundary}"})
try:
with urllib.request.urlopen(r, timeout=60) as resp:
return json.loads(resp.read())
except Exception as e: # noqa: BLE001
detail = e.read().decode()[:500] if hasattr(e, "read") else ""
sys.exit(f"改綁定失敗:{e} {detail}")
# ── 子指令 ────────────────────────────────────────────────────────────────────
def cmd_diff(a):
o = {k["name"]: k for k in list_keys(a.old)}
n = {k["name"]: k for k in list_keys(a.new)}
both = sorted(set(o) & set(n))
print(f"{len(o)} 把|新 {len(n)} 把|共有 {len(both)}|只在舊 {len(set(o)-set(n))}|只在新 {len(set(n)-set(o))}")
print("\n[只在舊櫃子]")
for k in sorted(set(o) - set(n)):
print(" +old", k)
print("\n[只在新櫃子]")
for k in sorted(set(n) - set(o)):
print(" +new", k)
print("\n[兩代都有 → 逐把比對內容]")
same = diff = 0
for k in both:
vo, vn = get_value(a.old, k), get_value(a.new, k)
if sha(vo) == sha(vn):
same += 1
if a.verbose:
print(f" = {k} {sha(vo)}")
else:
diff += 1
print(f"{k} old={sha(vo)}({len(vo or b'')}B) new={sha(vn)}({len(vn or b'')}B)")
print(f"\n共有的 {len(both)} 把:內容相同 {same}、**內容不同 {diff}**(不同的那些才是會咬人的)")
def cmd_copy(a):
src_keys = {k["name"]: k for k in list_keys(getattr(a, "from"))}
dst_keys = {k["name"]: k for k in list_keys(a.to)}
todo, skip = [], []
for k in a.key:
if k not in src_keys:
sys.exit(f"來源櫃子沒有這把 key{k}(先跑 diff 對一次名字)")
if k in dst_keys:
vs, vd = get_value(getattr(a, "from"), k), get_value(a.to, k)
if sha(vs) == sha(vd):
skip.append((k, "目標已有且內容相同"))
continue
if not a.overwrite:
skip.append((k, "目標已有且內容不同 → 需 --overwrite 才動"))
continue
todo.append(k)
for k, why in skip:
print(f" 跳過 {k}{why}")
if a.dry_run:
for k in todo:
print(f" [dry-run] 會寫入 {k}")
print(f"\ndry-run:會寫 {len(todo)} 把、跳過 {len(skip)} 把。加 --apply 才真的寫。")
return
for k in todo:
meta = src_keys[k]
v = get_value(getattr(a, "from"), k)
if v is None:
sys.exit(f"讀不到來源值:{k}(中止,已寫入的部分不受影響,重跑即可續)")
put_value(a.to, k, v, meta.get("expiration"), meta.get("metadata"))
print(f" 寫入 {k} {sha(v)} ({len(v)}B)")
# 立即回讀複驗
bad = []
for k in todo:
if sha(get_value(getattr(a, "from"), k)) != sha(get_value(a.to, k)):
bad.append(k)
print(f"\n寫入 {len(todo)} 把、跳過 {len(skip)} 把;回讀複驗不符 {len(bad)}{bad}")
if bad:
sys.exit(1)
def _script_settings(script: str):
d = req("GET", f"/workers/scripts/{script}/settings")
if not d.get("success"):
sys.exit(f"{script} settings 失敗:{d.get('errors')}")
return d["result"]
def cmd_bindings_snapshot(a):
snap = {}
for s in a.script:
snap[s] = _script_settings(s)
out = json.dumps(snap, ensure_ascii=False, indent=2)
if a.out:
open(a.out, "w", encoding="utf-8").write(out)
print(f"快照已存 → {a.out}")
else:
print(out)
for s, st in snap.items():
for b in st.get("bindings", []):
if b.get("type") == "kv_namespace":
print(f" {s:26} {b['name']:16} {b['namespace_id']}")
def _sanitize(bindings: list[dict]) -> list[dict]:
"""把讀回來的 bindings 變成可以寫回去的形狀:secret 用 inherit(不碰真身,D36)。"""
out = []
for b in bindings:
t = b.get("type")
if t in ("secret_text", "secret_key"):
out.append({"type": "inherit", "name": b["name"]})
else:
out.append({k: v for k, v in b.items() if v is not None})
return out
def cmd_bindings_repoint(a):
want = dict(s.split("=", 1) for s in a.set)
st = _script_settings(a.script)
bindings = st.get("bindings", [])
names = {b["name"] for b in bindings if b.get("type") == "kv_namespace"}
missing = set(want) - names
if missing:
sys.exit(f"{a.script} 上沒有這些 KV 綁定名:{sorted(missing)}")
new_bindings, changes = [], []
for b in _sanitize(bindings):
if b.get("type") == "kv_namespace" and b["name"] in want:
old = b["namespace_id"]
if old != want[b["name"]]:
changes.append((b["name"], old, want[b["name"]]))
b = {**b, "namespace_id": want[b["name"]]}
new_bindings.append(b)
for n, o, w in changes:
print(f" {a.script}: {n} {o}{w}")
if not changes:
print(" (沒有任何綁定需要變更——已經是目標狀態,冪等)")
return
if a.dry_run:
print("\ndry-run:加 --apply 才真的改。")
return
keep = ("compatibility_date", "compatibility_flags", "logpush", "placement",
"tail_consumers", "observability", "limits", "migrations")
settings = {k: v for k, v in st.items() if k in keep and v is not None}
settings["bindings"] = new_bindings
d = patch_settings(a.script, settings)
if not d.get("success"):
sys.exit(f"改綁定失敗:{d.get('errors')}")
after = {b["name"]: b["namespace_id"] for b in _script_settings(a.script).get("bindings", [])
if b.get("type") == "kv_namespace"}
bad = [n for n, v in want.items() if after.get(n) != v]
print(f"\n改完複驗:{'✅ 全部到位' if not bad else '❌ 沒到位 ' + str(bad)}")
if bad:
sys.exit(1)
def cmd_bindings_rollback(a):
snap = json.load(open(a.inp, encoding="utf-8"))
st = snap.get(a.script)
if not st:
sys.exit(f"快照裡沒有 {a.script}")
want = {b["name"]: b["namespace_id"] for b in st["bindings"] if b.get("type") == "kv_namespace"}
ns = argparse.Namespace(script=a.script, set=[f"{k}={v}" for k, v in want.items()],
dry_run=a.dry_run)
cmd_bindings_repoint(ns)
def cmd_audit(a):
ns_title = {}
d = req("GET", "/storage/kv/namespaces?per_page=100")
for n in d.get("result", []):
ns_title[n["id"]] = n["title"]
seen = {}
for s in a.script:
st = _script_settings(s)
kv = [b for b in st.get("bindings", []) if b.get("type") == "kv_namespace"]
if not kv:
continue
print(f"\n{s}")
for b in kv:
title = ns_title.get(b["namespace_id"], "?")
gen = "" if title.startswith("arcrun-rag-") else ""
cnt = len(list_keys(b["namespace_id"]))
print(f" {b['name']:16} {b['namespace_id']} [{gen}] {title:38} {cnt:>4}")
seen.setdefault(b["name"], set()).add(b["namespace_id"])
print("\n── 分裂檢查(同一個綁定名被指到不同 namespace = 分裂)──")
split = {k: v for k, v in seen.items() if len(v) > 1}
print("✅ 沒有分裂:所有 worker 的同名綁定都指到同一顆" if not split else f"❌ 分裂:{split}")
if split:
sys.exit(1)
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
d = sub.add_parser("diff", help="兩代逐把 key 級比對(唯讀)")
d.add_argument("--old", required=True)
d.add_argument("--new", required=True)
d.add_argument("--verbose", action="store_true")
d.set_defaults(func=cmd_diff)
c = sub.add_parser("copy", help="把點名的 key 從一個 namespace 搬到另一個(冪等、預設不覆蓋、永不刪除)")
c.add_argument("--from", required=True, dest="from")
c.add_argument("--to", required=True)
c.add_argument("--key", action="append", required=True)
c.add_argument("--overwrite", action="store_true", help="目標已有且內容不同時才需要,逐把點名")
c.add_argument("--apply", dest="dry_run", action="store_false", default=True)
c.set_defaults(func=cmd_copy)
s = sub.add_parser("bindings-snapshot", help="把 worker 現況綁定存成 JSONrollback 的唯一依據)")
s.add_argument("--script", action="append", required=True)
s.add_argument("-o", "--out")
s.set_defaults(func=cmd_bindings_snapshot)
r = sub.add_parser("bindings-repoint", help="把某個 worker 的 KV 綁定改指到別顆 namespace(不動程式碼、不動 secret)")
r.add_argument("--script", required=True)
r.add_argument("--set", action="append", required=True, help="BINDING_NAME=namespace_id")
r.add_argument("--apply", dest="dry_run", action="store_false", default=True)
r.set_defaults(func=cmd_bindings_repoint)
b = sub.add_parser("bindings-rollback", help="用快照把綁定還原")
b.add_argument("--script", required=True)
b.add_argument("-i", "--inp", required=True)
b.add_argument("--apply", dest="dry_run", action="store_false", default=True)
b.set_defaults(func=cmd_bindings_rollback)
a = sub.add_parser("audit", help="列出每顆 worker 綁到哪一代、各幾把 key,並檢查有沒有分裂")
a.add_argument("--script", action="append", required=True)
a.set_defaults(func=cmd_audit)
args = p.parse_args()
args.func(args)
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
#!/bin/sh
# gitea-arm-common.sh — 共用設定與函式,給 gitea-arm-request.sh / gitea-arm-check.sh 用。
# 只放常數與純函式,不做任何網路呼叫、不產生副作用(source 它必須零風險)。
#
# 背景:把「leo 解保險」從終端機搬到 Gitea 票上(leo 2026-08-13
# 「如果你會被通知,就不需要通過 terminal 來 arm 了」)。原始設計見:
# https://git.uncle6.me/inkstone/InkStoneCo/issues/34
#
# 🪦 2026-08-16 廢除「單一頻道票」設計(leo:「我不要把所有的票都放在一個 issues,
# 它們應該分散在每個 issues 裡,不然就難 track 歷史記錄」,issues/34#issuecomment-2804)。
# **票號改由呼叫端在每次呼叫時指定**——解哪張票的保險,請求與回碼就貼在那張票上,
# 不再有寫死的 GITEA_ARM_ISSUE 常數。#34 本身保留 open(33 則舊留言是歷史,不刪),
# 但不再是新請求的預設去處。
#
# 🔴 這不是重新打開 2026-08-13 那次「五個常數全部寫死」的舊爭議(history-first-guard
# 會問這個,先在這裡回答):那次總管的攻擊測試是
# `GITEA_ARM_ISSUE=999 OWNER=claude-code`——**同時**換成機器自己名下的 repo
# bot 在自己的 repo 有 admin 權限,可以邀請/建立一個顯示名叫「Leo」的假帳號來自問自答)。
# OWNER/REPO 才是那個攻擊真正利用的洞:**只要留在 inkstone/InkStoneCo 這個 org 裡,
# bot 沒有 admin 權限造不出第二個 login 是 `Leo` 的帳號,票號本身換到哪一張都一樣安全**
# ——核心判定永遠是「留言作者的 login 精確等於 Leo」,跟去哪張票找留言無關。
# 本次只鬆 ISSUE 一個常數,OWNERREPOAPPROVER_LOGIN 三個維持寫死、
# 不接受環境變數覆蓋,且 ISSUE 也只能透過腳本參數指定,不接受環境變數。
# ── 安全邊界常數(OWNERREPOAPPROVER_LOGIN,不接受任何覆蓋)───────────
#
# 🔴🔴 2026-08-13 總管審查抓到的洞(修正前的版本用 `: "${VAR:=default}"`
# 可被環境變數覆蓋):**「誰算是 leo」絕對不能是可覆蓋的設定。**
# 舊寫法下 `GITEA_ARM_APPROVER_LOGIN=claude-code scripts/gitea-arm-check.sh`
# 就能讓機器核准自己——比冒充/重放/過期/無回覆/Gitea 不通這五種都好繞,
# 因為設環境變數對總管與每一條 subagent 都是零成本、零痕跡。
# ⇒ 這幾個常數全部**直接賦值、不接受任何外部覆蓋**(沒有 `:=`、沒有讀 env)。
# 「誰是核准者/打哪個 repo」這種安全邊界要嘛寫死在程式碼裡走 PR 審查才能改,
# 要嘛就是可以被繞過的裝飾——**難改在這裡是特性,不是缺點**。
# (測試因此不能再靠改這幾個常數注入假狀態;改用「用真的無效 token 打真端點」
# 去驗 fail-closed,見 gitea-arm-check.test.sh 與 gitea-arm-check.sh 檔頭說明。)
GITEA_ARM_OWNER="inkstone"
# 🔓 2026-08-16:REPO 從「寫死」改成「預設值,只能由**腳本參數**指定」——理由與 ISSUE 同源。
# 本檔上方那段自己寫著:那次攻擊真正利用的洞是「換成**機器自己名下**的 repo」
# bot 在自己的 repo 有 admin,能造一個顯示名叫 Leo 的假帳號自問自答)。
# ⇒ 真正的安全邊界是 **OWNER**,不是 REPO:只要留在 `inkstone` 這個 org 底下,
# bot 在任何一個 repo 都沒有 admin,造不出第二個 login 是 `Leo` 的帳號。
# 實際需求(同日):出貨票在 `inkstone/arcrun-rag#115`,而解保險的請求依 leo 的規矩
# 要貼在「它解的那張票」上 ⇒ 寫死 InkStoneCo 會讓請求貼不到出貨票(實測 HTTP 500)。
# 🔴 **OWNER 仍然寫死、仍然不接受任何覆蓋**——那一條沒有鬆。
GITEA_ARM_REPO="InkStoneCo" # 預設;由 gitea_arm_set_repo() 依腳本參數覆寫,不讀 env
# gitea_arm_set_repo <repo名> —— 只接受 inkstone org 底下的 repo 名(純 [A-Za-z0-9._-])。
# 刻意不接受 owner/repo 形式:owner 是安全邊界,不給任何人指定的機會。
gitea_arm_set_repo() {
case "$1" in
''|*[!A-Za-z0-9._-]*)
echo "❌ repo 名只能是 inkstone org 底下的 repo(收到:$1" >&2
return 1;;
esac
GITEA_ARM_REPO="$1"
}
GITEA_ARM_API="https://git.uncle6.me/api/v1"
# 只認這個帳號名——不是 id、不是顯示名(leo 交代:這兩者會變)。
# 將來 leo 真的改帳號名 ⇒ 改這一行、走 PR 審查,不是設環境變數就生效。
GITEA_ARM_APPROVER_LOGIN="Leo"
# 票號合法性檢查:純數字才放行,避免呼叫端傳進奇怪字串打壞 URL
# (例如帶 `/` 或空白,會讓 curl 打到非預期的路徑)。
# 用法:gitea_arm_valid_issue "$ISSUE" && ... 或 if ! gitea_arm_valid_issue "$X"; then ...
gitea_arm_valid_issue() {
case "$1" in
''|*[!0-9]*) return 1 ;;
*) return 0 ;;
esac
}
gitea_arm_proj_dir() {
printf '%s' "${CLAUDE_PROJECT_DIR:-$(pwd)}"
}
# 讀 token:只回名字對應的值,不印出任何除了呼叫者要的東西。
# 讀不到 → 印空字串、回傳非 0(呼叫者要判斷,不能把空字串當成功)。
gitea_arm_token() {
ENV_FILE="$(gitea_arm_proj_dir)/.env"
if [ ! -f "$ENV_FILE" ]; then
printf ''
return 1
fi
TOK=$(grep '^GITEA_TOKEN_CLAUDE_CODE=' "$ENV_FILE" | head -1 | cut -d= -f2-)
if [ -z "$TOK" ]; then
printf ''
return 1
fi
printf '%s' "$TOK"
return 0
}
# 本機狀態目錄(gitignore 見 .claude/.gitignore)。
gitea_arm_state_dir() {
D="$(gitea_arm_proj_dir)/.claude/gitea-arm"
mkdir -p "$D/pending" 2>/dev/null
printf '%s' "$D"
}
gitea_arm_consumed_log() {
printf '%s/consumed.log' "$(gitea_arm_state_dir)"
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# make-tree-demo-data.sh — 給「資料夾樹 / 檢索過程樹」做 demo 的巢狀測試資料
#
# 為什麼有這支(leo 2026-08-19):
# 「我開啟新版 daemon,看不出不同⋯⋯如果要讓每個資料夾達到碎型,
# 你需要把 /Users/youlinhsieh/Desktop/youlinhsieh-test1 放進一些測試資料來 demo 巢狀」
# ⇒ 樹的第三、四層是「選中的資料夾」與「它的巢狀子資料夾」。
# test1 是平的(或空的)⇒ 樹再對也只畫得出一層 ⇒ 看起來像沒改。
#
# 🔴 這支跑在 **leo 的 Mac 上**,不是雲端。雲端 session 碰不到 ~/Desktop。
#
# 判準都對齊 daemon 實際的收檔規則(arcrun-collector):
# · 副檔名白名單 scan.go:allowedExt —— 這裡只用 .md / .csv,保證每一份都收得進去
# · 不放 logseq/ 或 .obsidian/ ⇒ vault.go 判為一般資料夾(不會被當筆記庫另眼對待)
# · 不 git init ⇒ ingestplan.go 判 IngestAll(整棵收,不是只收 docs/)
# · 不用 node_modules/build/dist 這類名字 ⇒ 不會被 toolOwnedDirNames 整棵剪掉
# · 14 個檔、都很短 ⇒ 不觸發 arcrun-rag#121「一次一大批壓垮自己」
#
# 用法:
# bash make-tree-demo-data.sh # 預設 ~/Desktop/youlinhsieh-test1
# bash make-tree-demo-data.sh /path/to/folder # 指定別的資料夾
set -euo pipefail
ROOT="${1:-$HOME/Desktop/youlinhsieh-test1}"
mkdir -p "$ROOT"
# 這支只**新增**檔案,不刪任何既有東西——它跑在使用者的桌面上,不做破壞性動作。
w() { mkdir -p "$(dirname "$ROOT/$1")"; cat > "$ROOT/$1"; }
w "README.md" <<'F'
# 測試知識庫(youlinhsieh-test1
這個資料夾是用來驗證「資料夾樹」與「檢索過程樹」的巢狀展示,
內容是虛構的公司文件,不是真實資料。
- 01-公司制度:人事與資安規則
- 02-專案:兩個案子的文件與會議紀錄
- 03-產品:產品說明與價目表
F
# ── 01 公司制度(含一層子資料夾)──────────────────────────────
w "01-公司制度/差旅費報支辦法.md" <<'F'
# 差旅費報支辦法
- 國內出差每日誤餐費上限 新台幣 300 元,需檢附發票。
- 高鐵一律標準車廂;商務車廂需事前經部門主管書面同意。
- 住宿費以雙人房單人使用計算,台北市上限 3,500 元/夜,其他縣市 2,500 元/夜。
- 報支期限:出差結束後 15 個工作天內送件,逾期需寫說明書。
F
w "01-公司制度/請假規則.md" <<'F'
# 請假規則
- 特休依到職日計算,未休完的特休於年度結束時折算工資。
- 病假單日以上須附診斷證明;一年累計超過 30 日部分不給薪。
- 事假須於 3 個工作天前提出,臨時事假需電話告知直屬主管。
- 家庭照顧假併入事假計算,全年上限 7 日。
F
w "01-公司制度/資訊安全/密碼原則.md" <<'F'
# 密碼原則
- 長度至少 12 碼,需含大小寫與數字;不得使用公司名稱或員工編號。
- 一律開啟兩階段驗證,簡訊驗證僅在無法使用驗證器時作為備援。
- 密碼管理器指定使用公司採購的版本,不得將公司帳密存入個人瀏覽器。
- 離職當日由資訊室統一停用所有帳號,主管需在交接表上簽名確認。
F
w "01-公司制度/資訊安全/外部儲存裝置使用規範.md" <<'F'
# 外部儲存裝置使用規範
- 隨身碟需經資訊室登錄並加密,未登錄者插入公司電腦會被端點防護阻擋。
- 客戶資料一律不得存入個人雲端硬碟,需使用公司核發的共用空間。
- 報廢硬碟需實體銷毀並留存銷毀證明,不得逕行丟棄或轉贈。
F
# ── 02 專案(兩案,其中一案再含會議紀錄子資料夾)────────────────
w "02-專案/教育部標案/RFP摘要.md" <<'F'
# 教育部標案 RFP 摘要
- 案名:中小學數位學習平台知識管理系統擴充案
- 預算上限:新台幣 480 萬元,履約期間 8 個月。
- 必要條件:需支援單一登入(SSO)串接教育雲帳號。
- 評選比重:技術 60%、價格 30%、簡報 10%。
- 決標方式:最有利標。
F
w "02-專案/教育部標案/時程與里程碑.md" <<'F'
# 教育部標案 時程與里程碑
| 里程碑 | 日期 | 交付 |
|---|---|---|
| 契約簽訂 | 2026-09-01 | 專案計畫書 |
| 系統設計審查 | 2026-10-15 | 系統設計文件 |
| 第一階段上線 | 2026-12-20 | 知識庫檢索模組 |
| 驗收 | 2027-04-30 | 驗收測試報告 |
F
w "02-專案/教育部標案/會議紀錄/20260801-啟動會議.md" <<'F'
# 20260801 教育部標案 啟動會議紀錄
出席:專案經理、技術主管、教育部承辦。
決議事項:
1. SSO 串接以教育雲 OAuth 為主,不自建帳號系統。
2. 測試資料由教育部於 9 月中前提供去識別化樣本。
3. 每兩週一次書面進度回報,格式沿用前案。
F
w "02-專案/教育部標案/會議紀錄/20260812-期中檢討.md" <<'F'
# 20260812 教育部標案 期中檢討紀錄
追蹤事項:
1. 教育雲 OAuth 測試環境尚未開通,承辦協助催辦,預計 8/20 前。
2. 去識別化樣本延後至 9 月底,第一階段上線時程風險升為「中」。
3. 簡報樣式改採機關既有範本,本週提供給設計。
F
w "02-專案/市立圖書館導入/需求訪談.md" <<'F'
# 市立圖書館導入 需求訪談
- 館員最在意的是「找得到館藏說明的原始檔在哪一台電腦上」,不是搜尋速度。
- 現況:各分館各自維護 Word 檔,同名檔案散在 5 台電腦,版本互相矛盾。
- 期待:一個地方就能問,答案要指得出原稿在哪個分館、哪個資料夾。
F
w "02-專案/市立圖書館導入/風險清單.md" <<'F'
# 市立圖書館導入 風險清單
| 風險 | 影響 | 對策 |
|---|---|---|
| 分館網路頻寬不足 | 同步緩慢 | 夜間排程同步 |
| 館員不熟悉新介面 | 導入後不使用 | 用他們熟悉的檔案總管式樹狀清單呈現 |
| 舊檔編碼混亂 | 內容亂碼 | 匯入前批次轉檔為 UTF-8 |
F
# ── 03 產品 ──────────────────────────────────────────────
w "03-產品/Arcrun/定位與賣點.md" <<'F'
# Arcrun 定位與賣點
- 一句話:把檔案丟進資料夾,公司知識庫自動長出來。
- 與傳統做法的差別:不做全量向量化,靠三元組圖找到相關的子庫再讀。
- 對客戶的意義:知識統一在一個總庫,但永遠答得出原稿在哪一台機器、哪個資料夾。
F
w "03-產品/Arcrun/常見問答.md" <<'F'
# Arcrun 常見問答
**Q:我把資料夾從清單移除,雲端的知識會留著嗎?**
A:預設會一起收回;只想停止監看也可以選。
**Q:兩台電腦有同名的檔會不會搞混?**
A:不會,每份原稿都記得自己來自哪一台機器。
**Q:需要先整理資料夾嗎?**
A:不需要,依賴目錄與建置產物會自動略過。
F
w "03-產品/價目表.csv" <<'F'
方案,月費,包含席次,知識卡上限,備註
入門,1200,3,5000,含社群支援
標準,3600,10,30000,含電子郵件支援
企業,12000,50,不限,含專屬窗口與導入協助
F
echo "✅ 已建立測試資料於:$ROOT"
echo
if command -v tree >/dev/null 2>&1; then
tree "$ROOT"
else
find "$ROOT" -not -path '*/.*' | sed "s|$ROOT|.|" | sort
fi
echo
echo "檔案數:$(find "$ROOT" -type f -not -path '*/.*' | wc -l | tr -d ' ') 最深層數:4(總庫→機器→$(basename "$ROOT")→…→會議紀錄)"
@@ -0,0 +1,71 @@
開場注入補一段:藏書地圖顯示 0 不等於庫是空的(2026-08-13
由 subagent 產生、**待總管代套**`.claude/` 是受保護目錄,子 session 動不了)。
套用:
git apply scripts/patches/session-start-recall--map-zero-is-a-lie.patch
驗證(要看到那段文字):
bash .claude/hooks/session-start-recall.sh | sed -n '/查不到就換一種查法/,/追蹤:/p'
已驗:`git apply --check` 通過、`bash -n` 通過、實跑印得出來(輸出見 Leo/mira#6)。
--- 為什麼要這段(2026-08-13 對 leo21c 唯讀實測)-----------------------------
leo 原話:「現在你的 MCP 搜不到東西,這就是我急着要完成它的原因。沒有它你是瞎的。」
實測結果不是「搜尋壞了」,是**地圖說謊,而 AI 信了它就不再往下查**:
kbdb_get_map() → 8 個庫,7 個 triplet_count = 0
kbdb_search(q="cypher-executor", mode="keyword") → 42 筆,來源正是那 7 個庫
gitea:Leo/Arcrun@…、gitea:Leo/mira@… 等,library 全都標對了)
地圖數的是**三元組**;那 7 個庫有 entries 但沒有三元組
`kbdb_query(template='triplet')` 抽樣 100 筆,source_uri 全部是 kb://,沒有一筆來自 gitea)。
⇒ 開場注入把「七個庫是空的」推到每個 session 眼前,AI 於是不查了——
而答案 keyword 一查就有。**這一段就是為了擋掉那個誤判。**
一併寫進注入文字的另外兩件實測(省得每個 session 自己撞):
· 語意搜尋對超過一半的 repo wiki 內容是瞎的(那 42 筆裡 23 筆 is_embedded=0)。
問「Arcrun 是什麼 工作流引擎 Cloudflare」回 0 筆;同題 keyword 回 29 筆真答案。
· kbdb_graph_neighbors(subject="Arcrun") 回 0 個鄰居,
但 kbdb_get_map(library="kb") 列著 Arcrun degree 40 ⇒ 圖查詢是斷的。
🔴 這段是**暫時的路標,不是修法**。真修法在 Leo/Arcrun#87(三元組沒從 repo wiki 產生)、
Leo/Arcrun#85embed 補算)、Leo/mira#6Mira 主線)。
**那三張修好之後這段要拿掉**,否則它會變成下一個「看起來像現況的舊東西」。
--- a/.claude/hooks/session-start-recall.sh
+++ b/.claude/hooks/session-start-recall.sh
@@ -83,6 +83,31 @@
echo "════════════════════════════════════════════════"
echo ""
+# ── push 1.5/5:藏書地圖顯示 0 ≠ 庫是空的(2026-08-13 實測,暫時路標)────────
+# 修好 Leo/Arcrun#87(三元組沒從 repo wiki 產生)與 #85(一半沒 embed)之後,
+# 這整段要拿掉。留著會變成下一個「看起來像現況的舊東西」。
+echo "────────────────────────────────────────────────"
+echo "🔴 查不到就換一種查法——**藏書地圖顯示 0,不代表那個庫是空的**(2026-08-13 實測)"
+echo ""
+echo " 地圖數的是「三元組」。各 repo 的 wiki 有 entries、但**沒有三元組**"
+echo " 所以 arcrunmiraarcrun-ragarcrun-harnessinkstoneco 等庫會顯示 0——**那是假的**。"
+echo " 實測:kbdb_search(q=\"cypher-executor\", mode=\"keyword\") 回 42 筆,正是那些庫的卡片。"
+echo ""
+echo " ⇒ 三種查法現在的實際狀態:"
+echo " · kbdb_search(mode=\"keyword\") ✅ 可用——**目前唯一可靠的一種,優先用它**"
+echo " · kbdb_search(mode=\"semantic\") ◐ 對超過一半的 repo wiki 是瞎的(沒 embed"
+echo " 回 0 筆**不代表庫裡沒有**,改用 keyword 再問一次"
+echo " · kbdb_graph_neighbors() ❌ 目前回 0 個鄰居,先別依賴它"
+echo ""
+echo " 🔴 **不要因為地圖是 0、或語意回 0,就下結論說「庫裡沒有這個」。**"
+echo " 2026-08-13 就是這樣把 Arcrun 的定位講錯的——查得到,只是被告知不用查。"
+echo ""
+echo " 在修好之前,問「X 是什麼」的正確姿勢:"
+echo " kbdb_search(q=\"X\", mode=\"keyword\") → 沒有再試 semantic → 都沒有才說沒有"
+echo " 追蹤:Leo/Arcrun#87(地圖)|Leo/Arcrun#85embed)|Leo/mira#6Mira 主線)"
+echo "────────────────────────────────────────────────"
+echo ""
+
# ── push 2/5:各 repo 的 wiki 全景(leo 2026-08-12「一次看到全景」的另一半)──
# 上面那段是**票**(雲端現算)+ KBDB 藏書地圖;這段是**各 repo 真正的 wiki**。
# 為什麼不塞進雲端那支工作流:工作流跑在 Cloudflare 上,要拿 repo 樹只有
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# stage-ok.sh — **leo 親自確認 stage 沒問題**。出貨的第二把鑰匙。
#
# 🔴 為什麼有這支(leo 2026-08-08,同一天講了兩次):
# 「以後你在 stage 驗證後**要交給我驗證**,如果 stage 完全沒問題,**才能推 prod**,
# 今天你沒給我看 stage,**我應該在 stage 安裝一次確認成功**。」
# 「**你自己的測試就是有問題,為什麼推 prod**」
#
# 那天發生什麼(這支存在的理由,別刪這段):
# 總管宣稱「stage 五項驗收全通」就推了 prod。但那五項全是
# **API 回應、JSON 欄位、頁面原始碼裡有沒有某個字串**——
# **沒有任何一項是「人能不能登進去」**。
# 當晚 leo 真的去按登入,看到「連線中斷——請檢查網路後重試」;
# 總管自己的瀏覽器 probe 也是同一個失敗。
# ⇒ **我的驗證方式對「這東西能不能用」是結構性失明的**,
# 再多自我驗證也補不上——只有真人走一遍才補得上。
#
# ⇒ 出貨從此要兩把鑰匙,**都在 leo 手上**:
# ① 本支:他在 stage 真的裝一次/用一次,確認沒問題
# ② github-arm.sh:發射保險
# 總管**兩把都造不出來**,這是刻意的。
#
# 用法(leo 跑):
# scripts/stage-ok.sh "<你在 stage 確認了什麼>" [有效小時數,預設 6]
set -eu
NOTE="${1:-}"
HOURS="${2:-6}"
if [ -z "$NOTE" ]; then
cat >&2 <<'EOF'
用法:scripts/stage-ok.sh "<你在 stage 確認了什麼>" [有效小時數,預設 6]
例:
scripts/stage-ok.sh "stage 裝了一次,能登入、首頁數字對得起來、匯出診斷檔正常" 6
📌 這是「**你**在 stage 上親自確認過」的紀錄,不是 AI 的自我驗證。
AI 造不出這個檔——那是刻意的。
EOF
exit 1
fi
STAMP=/tmp/.stage-ok-by-leo
now=$(date +%s)
{
echo "$now"
echo "$NOTE"
echo "confirmed_at=$(date '+%Y-%m-%d %H:%M:%S')"
echo "valid_hours=$HOURS"
} > "$STAMP"
echo "✅ 已記錄:leo 在 stage 確認過,有效 ${HOURS} 小時"
echo " 內容:$NOTE"
echo " 撤銷:rm $STAMP"
Executable
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""ticket — 讓 Gitea 變成「可追蹤的線」,不是「越積越大的池子」。
leo 2026-08-16 三句話,本工具就是它們的機械化:
「不要每個開新票,現有的票開在它下面的對話裡」
「我希望你把 gitea 變成可以追蹤,不是變成一個池子」
「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
當天實錯(本工具的來由):總管要派人查一個部署擋路石,**沒有搜尋就直接開新票**
arcrun-rag#110),而那條線早就有 hubInkStoneCo#44)。多開一張票 = 池子加大 =
那條線串不起來。⇒ 所以「搜過了」不是 SOP 第一條,是 `new` 的**前置條件**。
四個動詞,各有一道閘:
ticket where <關鍵字...> 搜「這件事該放哪」→ 產生戳記
ticket say <票> -F <檔> 貼進既有票的對話(**預設路徑**)
ticket new <repo> -F <檔> 開新票(要戳記+模板欄位齊全)
ticket close <票> --deliverable <URL> 關票(要有交付物連結)
ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作)
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
HOST = "https://git.uncle6.me"
ORG = "inkstone"
STAMP_DIR = "/tmp"
STAMP_TTL = 30 * 60 # 戳記 30 分鐘失效——搜過就要趁記憶還熱的時候開
# 模板必填欄位。糊弄的票開不出來;開出來的票就是能用的 spec。
REQUIRED_SECTIONS = ["## 目標", "## 驗收條件", "## deliverable 類型"]
VALID_KINDS = ["code", "research"]
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
try:
url = subprocess.run(["git", "-C", root, "remote", "get-url", "gitea"],
capture_output=True, text=True, timeout=20).stdout.strip()
except Exception:
url = ""
m = re.search(r"//[^:]+:([^@]+)@", url)
if not m:
die("🔴 拿不到 gitea token(該 repo 的 gitea remote 沒有帶憑證)")
return m.group(1)
def api(path, payload=None, method=None):
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
url, data=data, method=method or ("POST" if data else "GET"),
headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
try:
return json.load(urllib.request.urlopen(req, timeout=40))
except urllib.error.HTTPError as e:
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}")
def parse_ref(s):
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
if not m:
die(f"🔴 票的寫法是 owner/repo#N,你給的是:{s}")
return m.group(1), m.group(2), int(m.group(3))
def stamp_path():
return os.path.join(STAMP_DIR, ".ticket-where-ok")
# ── where ────────────────────────────────────────────────────────────────
def cmd_where(argv):
if not argv:
die("用法:ticket where <關鍵字...>\n(用幾個真的會出現在票裡的詞,中英文都行)")
kws = argv
seen, hits = {}, []
for kw in kws:
q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 30})
for it in api(f"/repos/issues/search?{q}") or []:
ref = it["repository"]["full_name"] + "#" + str(it["number"])
if ref in seen:
seen[ref]["score"] += 1
continue
seen[ref] = {"score": 1, "it": it}
hits = sorted(seen.values(), key=lambda x: -x["score"])
print(f"🔍 搜尋:{' '.join(kws)} → 命中 {len(hits)} 張 open 票\n")
if not hits:
print(" (沒有命中——換幾個講法再試一次。真的沒有,才輪到開新票)")
for h in hits[:12]:
it, labels = h["it"], [l["name"] for l in h["it"].get("labels", [])]
print(f" [{h['score']}] {it['repository']['full_name']}#{it['number']} {labels}")
print(f" {it['title'][:70]}")
with open(stamp_path(), "w") as f:
json.dump({"at": time.time(), "kws": kws, "n": len(hits),
"top": [h["it"]["repository"]["full_name"] + "#" + str(h["it"]["number"])
for h in hits[:12]]}, f)
print(f"""
── 決定要做什麼 ───────────────────────────────────────────────
命中了、而且是同一條線 → **貼進那張票的對話**(預設,也是 leo 要的)
ticket say <owner/repo#N> -F <內文檔>
真的是新的一條線 → ticket new <repo> -F <內文檔>
(模板要有:## 目標 / ## 驗收條件 / ## deliverable 類型)
🔴 判準不是「這件事夠不夠大」,是「**它跟現有的哪條線是同一條**」。
同一條線就進對話——多開一張票只會讓池子變大、線串不起來。
戳記已寫({STAMP_TTL // 60} 分鐘有效)。""")
# ── say ──────────────────────────────────────────────────────────────────
def cmd_say(argv):
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket say <owner/repo#N> -F <內文檔>")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
print(f"✅ 已貼進 {owner}/{repo}#{num}")
print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}")
print(f" {c['html_url']}")
print(f"\n📌 派工時把上面那行「定址」整串寫進【工單】,那條線才接得起來。")
# ── new ──────────────────────────────────────────────────────────────────
def cmd_new(argv):
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket new <repo> -F <內文檔> [--title <標題>]")
repo = argv[0]
body = open(argv[2]).read()
title = None
if "--title" in argv:
title = argv[argv.index("--title") + 1]
# 閘一:搜過了沒
try:
st = json.load(open(stamp_path()))
except Exception:
die("""🚫 開新票前要先搜「這件事該放哪」(leo 2026-08-16
leo 原話:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」
實錯(同日):總管沒搜就開 arcrun-rag#110,而那條線早有 hub InkStoneCo#44。
先跑: ticket where <關鍵字...>
搜完它會告訴你該 `say` 進哪張票,還是真的該 `new`。""")
if time.time() - st["at"] > STAMP_TTL:
die(f"🚫 搜尋戳記已過期(超過 {STAMP_TTL // 60} 分鐘)。重跑一次 ticket where")
# 閘二:搜到了東西,就要說明為什麼不是貼進去
if st["n"] > 0 and "--not-a-comment" not in argv:
top = "\n".join(" " + t for t in st["top"][:8])
die(f"""🚫 剛才那次搜尋命中 {st['n']} 張 open 票,你卻要開新的。
命中的前幾張:
{top}
**先問一次:這件事跟上面哪一條是同一條線?**
是 → `ticket say <那張票> -F <檔>`(這是預設路徑)
不是 → 重下一次指令,帶上理由:
ticket new {repo} -F <檔> --not-a-comment "為什麼它是獨立的一條線"
理由會被寫進票的內文,往後任何人都看得到你當時怎麼判的。""")
# 閘三:模板欄位非空
missing = [s for s in REQUIRED_SECTIONS if s not in body]
if missing:
die(f"""🚫 票的模板缺欄位:{'、'.join(missing)}
**糊弄的票開不出來,開得出來的票就是能用的 spec。** 必填:
## 目標 要達成什麼(不是要改哪個檔)
## 驗收條件 做完要能證明什麼、怎麼驗
## deliverable 類型 code(→ PR)或 research(→ 貼在票上的結論)""")
m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
kind_line = (m.group(1) if m else "").lower()
if not any(k in kind_line for k in VALID_KINDS):
die(f"🚫 `## deliverable 類型` 底下要明寫 `code` 或 `research`(現在是:{kind_line.strip() or '空的'}\n"
" 關票時會驗這個型別對應的交付物有沒有連上,所以不能含糊。")
if "--not-a-comment" in argv:
why = argv[argv.index("--not-a-comment") + 1]
body += (f"\n\n---\n> 🔎 **為什麼另開一張票而不是貼進既有的**(開票時聲明):{why}\n"
f"> 當時搜尋:`{' '.join(st['kws'])}` → 命中 {st['n']} 張。")
if not title:
die("🚫 缺 --title")
check_title(title)
d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
os.remove(stamp_path()) # 戳記用掉就沒了,一次只開一張
print(f"✅ {ORG}/{repo}#{d['number']} 已開:{d['html_url']}")
# ── 標題規約閘(leo 2026-08-19:「票的寫法不受控制嗎?沒有辦法規範?」)─────────
#
# 實錯(本閘的來由):2026-08-19 一個 session 造了 17 張 `👤 裁決題:…` 與
# 1 張 `【版本】…`。兩種前綴都是 AI 自己發明的分類,都不是 User Story,
# 也都不該是票——**裁決在對話裡講,版本用里程碑**。leo:「亂搞一通」。
#
# 判準跟 empty-handed-stop-guard 同一個哲學:**封形狀,不封措辭**。
# User Story 的形狀是可枚舉的(身為…我要…我才…),自創前綴也是可枚舉的(開頭的方括號/
# 冒號式分類詞)。不做語意判斷,只認形狀。
USER_STORY_RE = re.compile(r"^\s*身為.{2,}?\s*我(要|想要).{2,}?\s*我才.{2,}")
BANNED_PREFIX_RE = re.compile(r"^\s*(?:[\U0001F300-\U0001FAFF\u2600-\u27BF]\s*)*"
r"(?:[【\[(][^】\])]{1,12}[】\])]|[^\s:]{2,10}題)\s*[:]")
def check_title(title):
if BANNED_PREFIX_RE.match(title):
die("🚫 標題不准自創分類前綴(leo 2026-08-19:「亂搞一通」)\n"
f" 你寫的:{title[:60]}\n\n"
" 2026-08-19 實錯:AI 造了『👤 裁決題:』17 張、『【版本】』1 張,\n"
" 兩種都不是 User Story,也都不該是票:\n"
" · 要 leo 裁決 → **在對話裡講**,不要開票\n"
" · 一個版本/sprint → **建里程碑**,把既有 issues 拉進去\n"
" · 真的是一條待辦 → 用 User Story 寫標題(見下)")
if not USER_STORY_RE.match(title):
die("🚫 票名一律 User Storyleo 2026-08-17;規約在 CLAUDE.md\n"
f" 你寫的:{title[:60]}\n\n"
" 格式:身為<誰>,我要<什麼>,我才<為什麼>\n"
" 例: 身為把整台電腦交給 AI 的人,我要它指得出出處,我才敢相信它讀懂了我的東西\n\n"
" 🔴 不要照抄現場的多數——2026-08-17 實查 39 張 open 票只有 6 張合規,\n"
" 照多數抄就會抄到錯的那邊。\n"
" 真的不是一條待辦?那它就不該是票(裁決→對話;版本→里程碑)。")
# ── close ────────────────────────────────────────────────────────────────
def cmd_close(argv):
if not argv:
die("用法:ticket close <owner/repo#N> --deliverable <URL>")
owner, repo, num = parse_ref(argv[0])
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
body = issue.get("body") or ""
comments = api(f"/repos/{owner}/{repo}/issues/{num}/comments") or []
blob = body + "\n" + "\n".join(c.get("body") or "" for c in comments)
deliv = None
if "--deliverable" in argv:
deliv = argv[argv.index("--deliverable") + 1]
m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
kind = "code" if m and "code" in m.group(1).lower() else (
"research" if m and "research" in m.group(1).lower() else "unknown")
has_pr = bool(re.search(r"/pulls?/\d+", blob))
has_report = len([c for c in comments if len(c.get("body") or "") > 200]) > 0
ok = bool(deliv) or (has_pr if kind == "code" else has_report if kind == "research"
else (has_pr or has_report))
if not ok:
die(f"""🚫 這張票關不掉——找不到交付物。
票的 deliverable 類型:{kind}
票上有 PR 連結:{'有' if has_pr else '沒有'}
票上有實質回報(>200 字的 comment):{'有' if has_report else '沒有'}
**沒有交付物的票是關不掉的票**——它會一直掛在看板上刺眼,那正是設計意圖。
真的有交付物 → 先貼上去:ticket say {owner}/{repo}#{num} -F <檔>
交付物在別處 → ticket close {owner}/{repo}#{num} --deliverable <URL>""")
if deliv:
api(f"/repos/{owner}/{repo}/issues/{num}/comments",
{"body": f"✅ 結案。交付物:{deliv}"})
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')}")
# ── decide ───────────────────────────────────────────────────────────────
def cmd_decide(argv):
"""記錄 leo 的裁決+改狀態,**一個動作**。
leo 2026-08-16:「回覆過很多次了,**回覆過的就要記錄下來**」
「這些我答了,來自各地,**問題是你怎麼追蹤**?」
病灶:leo 從對話/手機/Gitea 各處答覆 ⇒ 總管照著做了但沒落到票上
⇒ 下一輪(或下個 session)又問一次同一題。B 題就是實例。
⇒ 所以「寫下答案」與「拿掉 Human」必須是**同一個動作**,不能只做一半。
🔄 2026-08-16 改版(leo):`s/leo` 併入 `Human`,且 **Human 與 s/* 正交**——
「要不要人批」跟「它在流程哪一格」是兩個獨立的軸。
⇒ decide **只拿掉 Human,不動 s/* 狀態**(除非呼叫端明給 --next)。
舊做法把 s/leo 換成 s/todo 會把票的真實流程位置抹掉。
"""
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket decide <owner/repo#N> -F <答案檔> [--next <狀態標籤>]\n"
"(預設只拿掉 Human、保留原本的 s/* 狀態;要同時改狀態才加 --next\n"
"答案檔要寫「leo 原話」與「所以要做什麼」")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
nxt = argv[argv.index("--next") + 1] if "--next" in argv else None
if "leo" not in body.lower() and "原話" not in body:
die("🚫 答案檔裡看不到 leo 的原話。\n"
" **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n"
" (今天 `Arcrun#132` 就是把 leo 的「確認」套到錯的提案上,同一張票誤讀兩次。)")
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
ids = {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=60")}
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
cur = [l["name"] for l in issue.get("labels") or []]
# 預設:只拿掉 Human(那是「還在等人批」的標記),流程位置維持不動
keep = [n for n in cur if n != "Human"]
if "--next" in argv:
keep = [n for n in keep if not n.startswith("s/")] + [nxt]
if nxt not in ids:
die(f"🚫 這個 repo 沒有 `{nxt}` 標籤。現有:{[n for n in ids if n.startswith('s/')]}")
api(f"/repos/{owner}/{repo}/issues/{num}/labels",
{"labels": [ids[n] for n in keep if n in ids]}, method="PUT")
# 批完了就不該還掛在 leo 名下——指派給他的清單裡每一張都要是真的在等他
api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": []}, method="PATCH")
print(f" 已拿掉 Human 並取消指派——「指派給 Leo」那份清單保持誠實。")
print(f"✅ {owner}/{repo}#{num}:答案已記進票,狀態 → {nxt}")
print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。")
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
print(__doc__)
sys.exit(0 if len(sys.argv) < 2 else 2)
CMDS[sys.argv[1]](sys.argv[2:])
+332
View File
@@ -0,0 +1,332 @@
#!/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; }
REPO_RAW="https://raw.githubusercontent.com/uncle6me-web/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
# ── 工具函式 ───────────────────────────────────────
# 覆蓋更新:模板/邏輯檔,無條件抓最新版蓋掉。
update_file() {
local dest="$1" src="$2"
mkdir -p "$(dirname "$dest")"
if [ -f "$dest" ]; then
if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && [ -s "$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 && [ -s "$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"
if [ -f "$dest" ]; then
KEPT+=("$dest")
elif curl -sSL "$src" -o "$dest" 2>/dev/null && [ -s "$dest" ]; then
NEW+=("$dest")
else
rm -f "$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"
# ── 模板/邏輯檔:覆蓋更新 ──────────────────────────
# 共用 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"
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"
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"
# 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"
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 2>/dev/null || true
# ── 使用者資料檔:絕不碰,但提醒「設定可能有新欄位要手動補」──
keep_file ".claude/settings.json"
keep_file "CLAUDE.md"
# ── 結果輸出 ───────────────────────────────────────
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 [ ${#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")
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
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 ""
+350
View File
@@ -0,0 +1,350 @@
#!/bin/bash
# wiki-panorama.sh — 產生「各 repo 的 wiki 有哪些檔」的全景 index`system-dev/wiki/PANORAMA.md`
#
# 解的問題(leo 2026-08-12):
# 「我在 AR-Mira 看到所有 Gitea Repo 的 wiki,又可以看到所有票現況,
# 有一個總圖用 md 一次看到全景。」
# 開場總圖(Arcrun 工作流 `global_index`)的「票」那段已經好用,
# 但「知識」那段接的是 KBDB 藏書地圖,而那張表 10 個庫有 8 個是空的(Leo/Arcrun#87 在修)
# ⇒ **各 repo 真正的 wikisystem-dev/wiki/*.md)一份都沒進總圖。** 這支補的就是那一半。
#
# 為什麼是本機腳本、不是加進雲端的 `global_index` 工作流:
# 工作流跑在 Cloudflare 上,拿 repo 樹只有一條路=Gitea 的 contents API 列檔,
# 而 principles 紅線寫死「**讀 repo 走 git clone/fetch,不走 API 列檔**」。
# ⇒ 這件事的正解是在「有 git 的地方」算,算完存成 repo 裡的一份 md,開場 hook 直接印。
# 票留在雲端現算(狀態必須即時),知識走這條(可以稍舊,且 D71 已寫明兩者不對稱)。
#
# 產出**一份檔、兩個用途**(中間用 `<!-- panorama:inject-end -->` 切開):
# ① 標記以上=**開場注入**的摘要(hook 只印到這裡)。控制在幾 kB,不撐爆 context。
# ② 標記以下=**給 grep 用的完整清單**(每個 repo 每一張卡的名字)。
# 「某件事有沒有記過」就 `grep -i <關鍵字> system-dev/wiki/PANORAMA.md`。
#
# 紅線:
# - **不輪詢**。這支只在人/本機發起時跑(改完 wiki 順手跑一次、或開場 hook 提醒你過期了)。
# - **只讀 git**clone/fetchblobless + sparse,只抓 wiki 目錄。不打任何列檔 API。
# - **只產 index 不搬內容**:一檔一行(檔名+最後更新日+大小+一句話),不倒全文。
#
# 用法:
# bash scripts/wiki-panorama.sh # 算出來印到 stdout(不寫檔)
# bash scripts/wiki-panorama.sh --write # 同時寫進 system-dev/wiki/PANORAMA.md
# bash scripts/wiki-panorama.sh --no-fetch # 用快取算,完全不碰網路
# bash scripts/wiki-panorama.sh --only mira # 只處理某幾個 repo(除錯用)
#
# 快取:$WIKI_PANORAMA_CACHE,預設 ~/.cache/inkstone-wiki-panorama(不落在 repo 裡)
#
# 配套:開場注入要靠 `.claude/hooks/session-start-recall.sh` 的 push 2/5。
# 還沒套的話跑:`git apply scripts/patches/session-start-recall--wiki-panorama.patch`
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
ROSTER="$ROOT/system-dev/wiki/.panorama-repos.txt"
OUT="$ROOT/system-dev/wiki/PANORAMA.md"
CACHE="${WIKI_PANORAMA_CACHE:-$HOME/.cache/inkstone-wiki-panorama}"
WIKI_DIRS="system-dev/wiki .claude/wiki" # 第二個是舊慣例,順手收
GITEA_TOTAL_REPOS=24 # 總管 2026-08-12 用 Gitea API 實查的總數
GITEA_TOTAL_ASOF=2026-08-12
DO_WRITE=0
DO_FETCH=1
ONLY=""
while [ $# -gt 0 ]; do
case "$1" in
--write) DO_WRITE=1 ;;
--no-fetch) DO_FETCH=0 ;;
--only) shift; ONLY="${ONLY} $1" ;;
-h|--help) sed -n '1,40p' "$0"; exit 0 ;;
*) echo "不認得的參數:$1" >&2; exit 2 ;;
esac
shift
done
[ -f "$ROSTER" ] || { echo "找不到 roster$ROSTER" >&2; exit 1; }
# ── Gitea base(含憑證,**絕不可印出來**)────────────────────────────────
REMOTE=$(git -C "$ROOT" remote get-url gitea 2>/dev/null || true)
[ -n "$REMOTE" ] || { echo "本 repo 沒有 gitea remote,無法取 repo。" >&2; exit 1; }
BASE=${REMOTE%/InkStoneCo.git}
BASE=${BASE%/InkStoneCo}
SELF=$(basename "$REMOTE" .git)
SECRET=$(printf '%s' "$REMOTE" | sed -nE 's|.*//[^:]+:([^@]+)@.*|\1|p')
# 任何要外流的字串都先過這關(錯誤訊息可能夾帶 clone URL)
scrub() { if [ -n "$SECRET" ]; then sed "s|$SECRET|***|g"; else cat; fi; }
mkdir -p "$CACHE"
# 自己這個 repo 一律讀工作副本 ⇒ 快取裡若留著一份舊的自己,是誤導來源(會被人拿去讀)
if [ -d "$CACHE/$SELF" ]; then rm -rf "$CACHE/$SELF"; fi
REPOS=$(grep -vE '^[[:space:]]*(#|$)' "$ROSTER" | tr -d '\r')
if [ -n "$ONLY" ]; then
REPOS=$(printf '%s\n' "$REPOS" | grep -Fx -f <(printf '%s\n' "$ONLY" | tr ' ' '\n' | grep -v '^$'))
fi
# Gitea 網址大小寫不敏感 ⇒ 同一個 repo 用兩種拼法會被算成兩個。去重。
REPOS=$(printf '%s\n' "$REPOS" | awk '{k=tolower($0)} !seen[k]++')
STATUS_TSV=$(mktemp)
trap 'rm -f "$STATUS_TSV"' EXIT
for repo in $REPOS; do
# 自己這個 repo 讀工作副本,不繞一圈回 Gitea——
# 剛寫完還沒推的 wiki 也要看得見(人就站在這裡改)
if [ "$repo" = "$SELF" ]; then
printf '%s\tok\t%s\n' "$repo" "$ROOT" >> "$STATUS_TSV"; continue
fi
dst="$CACHE/$repo"
if [ -d "$dst/.git" ]; then
if [ "$DO_FETCH" = 1 ]; then
br=$(git -C "$dst" symbolic-ref --short HEAD 2>/dev/null || echo main)
git -C "$dst" fetch --quiet origin "$br" 2>&1 | scrub >&2 || true
git -C "$dst" reset --quiet --hard FETCH_HEAD 2>/dev/null || true
fi
else
if [ "$DO_FETCH" = 0 ]; then
printf '%s\tno-cache\t\n' "$repo" >> "$STATUS_TSV"; continue
fi
# blobless + sparse:只下載 wiki 目錄的內容,但保留完整 commit 歷史
#(要歷史才算得出「每個檔最後更新是哪天」——淺 clone 會讓所有檔同一天)
if ! err=$(git clone --quiet --filter=blob:none --sparse "$BASE/$repo.git" "$dst" 2>&1 | scrub); then
printf '%s\tclone-failed\t%s\n' "$repo" "$(printf '%s' "$err" | tr '\n' ' ')" >> "$STATUS_TSV"
rm -rf "$dst"; continue
fi
git -C "$dst" sparse-checkout set $WIKI_DIRS >/dev/null 2>&1 || true
fi
printf '%s\tok\t%s\n' "$repo" "$dst" >> "$STATUS_TSV"
done
# ── 掃檔 + 排版(python3:BSD/GNU 工具差異多,交給它比較穩)────────────
MD=$(WIKI_DIRS="$WIKI_DIRS" SELF="$SELF" CACHE="$CACHE" \
TOTAL="$GITEA_TOTAL_REPOS" ASOF="$GITEA_TOTAL_ASOF" \
python3 - "$STATUS_TSV" <<'PY'
import os, re, subprocess, sys, datetime
status_tsv = sys.argv[1]
wiki_dirs = os.environ["WIKI_DIRS"].split()
SELF = os.environ["SELF"]
CACHE = os.environ["CACHE"].replace(os.path.expanduser("~"), "~")
TOTAL = int(os.environ["TOTAL"])
ASOF = os.environ["ASOF"]
OUT_NAME = "PANORAMA.md" # 產生物自己不列進全景
# 每個 repo 裝 system-dev-template 就會有的骨架檔。全部都有 ⇒ 逐一列出來只是雜訊,
# 對其他 repo 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔」。
SKELETON = {"INDEX", "TAXONOMY", "decisions-summary", "mistakes", "principles", "status"}
def run(cwd, *args):
try:
return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=60).stdout
except Exception:
return ""
def dates_for(repo_dir, wdir):
"""一次 git log 走完整個目錄,取每個檔第一次出現(=最後一次被改)的日期。"""
out = run(repo_dir, "git", "log", "--date=short", "--format=@%ad", "--name-only", "--", wdir)
seen, cur = {}, None
for line in out.splitlines():
if line.startswith("@"):
cur = line[1:].strip()
elif line.strip() and cur:
seen.setdefault(line.strip(), cur)
return seen
WIDE = re.compile(r'[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]')
def width(s): return sum(2 if WIDE.match(c) else 1 for c in s)
def clip(s, w):
out, acc = "", 0
for c in s:
cw = 2 if WIDE.match(c) else 1
if acc + cw > w: return out.rstrip(" 、,·-—") + "…"
out, acc = out + c, acc + cw
return out
def clean(s):
s = re.sub(r'<!--.*?-->', '', s)
s = re.sub(r'\[\[([^\]]+)\]\]', r'\1', s)
s = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', s)
s = s.replace('**', '').replace('`', '').replace('~~', '')
s = re.sub(r'^[>#\-\*\s]+', '', s)
return re.sub(r'\s+', ' ', s).strip()
SKIP = ('---', '===', '|', '```', '<!--', '<')
def title_and_hook(path):
try:
text = open(path, encoding="utf-8", errors="replace").read(9000)
except OSError:
return "", ""
lines = text.splitlines()
title, rest = "", lines
for i, ln in enumerate(lines):
if ln.startswith("# "):
title, rest = clean(ln), lines[i+1:]
break
hook = ""
for ln in rest[:60]:
s = ln.strip()
if not s or s.startswith(SKIP) or s.startswith("#"):
continue
c = clean(s)
if len(c) < 4:
continue
# hook 跟標題講同一件事就不重複佔位,往下找一句真的有增量的
if title and (c[:10] in title or title[:10] in c):
continue
hook = c
break
return title, hook
def one_line(title, hook, fallback):
"""一檔一行的那句話:標題優先,標題太短就補一句 hook。"""
t = re.sub(r'^[\W_]*', '', title or "") or fallback
if hook and width(t) < 46:
t = t + "" + hook
return clip(t, 78)
# ── 掃 ────────────────────────────────────────────────────────────────
repos, rows, cards, misses = [], {}, {}, []
for line in open(status_tsv, encoding="utf-8"):
parts = (line.rstrip("\n") + "\t\t").split("\t")
repo, st, dirpath = parts[0], parts[1], parts[2]
if not repo:
continue
repos.append(repo)
if st != "ok":
misses.append((repo, {"clone-failed": "clone 失敗",
"no-cache": "沒有快取且指定了 --no-fetch"}.get(st, st)))
continue
found = False
for wdir in wiki_dirs:
full = os.path.join(dirpath, wdir)
if not os.path.isdir(full):
continue
dmap = dates_for(dirpath, wdir)
for name in sorted(os.listdir(full)):
p = os.path.join(full, name)
if not (name.endswith(".md") and os.path.isfile(p)) or name == OUT_NAME:
continue
rel = f"{wdir}/{name}"
t, h = title_and_hook(p)
rows.setdefault(repo, []).append(
dict(rel=rel, name=name, stem=name[:-3], date=dmap.get(rel, "?"),
size=os.path.getsize(p), line=one_line(t, h, name[:-3])))
found = True
cdir = os.path.join(full, "cards")
if os.path.isdir(cdir):
for bucket in sorted(os.listdir(cdir)):
bp = os.path.join(cdir, bucket)
if not os.path.isdir(bp):
continue
for n in sorted(os.listdir(bp)):
if n.endswith(".md") and not n.startswith("00-INDEX"):
cards.setdefault(repo, []).append(n[:-3]); found = True
if not found:
misses.append((repo, "沒有 wiki"))
def kb_of(n): return max(1, round(n / 1024))
def newest(rs): return max([r["date"] for r in rs if r["date"] != "?"] or ["?"])
today = datetime.date.today().isoformat()
n_files = sum(len(v) for v in rows.values())
n_cards = sum(len(v) for v in cards.values())
have = [r for r in repos if r in rows or r in cards]
nowiki = [r for r, why in misses if why == "沒有 wiki"]
broken = [(r, why) for r, why in misses if why != "沒有 wiki"]
o = []; w = o.append
# ── 標記以上:開場注入的那一段 ────────────────────────────────────────
w("# 全景:各 repo 的 wiki 裡有哪些檔(index,不是內容)")
w("")
w(f"> **{today} 產生**`bash scripts/wiki-panorama.sh --write`,人發起、不輪詢) · "
f"來源=`git clone/fetch` Gitea `Leo/*` 各 repo 的預設分支,不走 API 列檔。")
w(f"> {SELF} 這段讀的是**本機工作副本**(剛寫完還沒推的也算數)。**改這個檔沒用**,要改去改那個 repo 的 wiki。")
w(">")
w("> 🔴 **這裡沒有內容,只有「有這件事、在哪個 repo 的哪個檔」。**")
w(f"> - 「某件事 wiki 記過沒有」→ `grep -i <關鍵字> system-dev/wiki/{OUT_NAME}`"
f"(本檔下半部有全部 {n_cards} 張卡的名字)")
w(f"> - 要讀內容 → 本機有那個 repo 就直接讀;沒有就看快取 `{CACHE}/<repo>/system-dev/wiki/`")
w("")
w(f"**{n_files} 份主檔 {n_cards} 張卡,散在 {len(have)} 個 repo**"
f"(點名 {len(repos)} 個 repo,其中 {len(nowiki)} 個掃過確定沒有 wiki")
w("")
# 自己這個 repo:逐檔一行(這是你最可能真的去讀的那份)
if SELF in rows:
rs = rows[SELF]
head = f"## {SELF}(你現在站的地方)— {len(rs)} 份主檔"
if cards.get(SELF): head += f"、{len(cards[SELF])} 張卡"
w(head)
for r in rs:
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
if cards.get(SELF):
w(f"- `system-dev/wiki/cards/` {len(cards[SELF])} 張:" + "、".join(sorted(cards[SELF])))
w("")
# 其他 repo:一 repo 一行。骨架六檔每個 repo 都有,逐一列是雜訊——
# 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔 + 卡數」。
others = [r for r in have if r != SELF]
if others:
w("## 其他 repo — 一 repo 一行")
w("")
w("> 每個 repo 都有 `INDEX``TAXONOMY``decisions-summary``mistakes``principles``status` "
"這套骨架(裝 system-dev-template 就有),所以只標**新鮮度、最肥的那份、多出來的檔**。")
for repo in others:
rs = rows.get(repo, [])
extra = [r["name"] for r in rs if r["stem"] not in SKELETON]
big = max(rs, key=lambda r: r["size"]) if rs else None
seg = [f"**{repo}** — {len(rs)} 份"]
if cards.get(repo): seg.append(f"{len(cards[repo])} 張卡")
if rs: seg.append(f"最近改 {newest(rs)}")
if big: seg.append(f"最肥 `{big['name']}` {kb_of(big['size'])}kB")
if extra: seg.append("多出來的:" + "、".join(f"`{e}`" for e in extra))
w("- " + " · ".join(seg))
w("")
w("## 涵蓋範圍——**沒掃到的也要看得見**")
w("")
if nowiki:
w(f"- **掃過、確定沒有 wiki 的 {len(nowiki)} 個**" + "、".join(f"`{r}`" for r in nowiki))
if broken:
w("- ⚠️ **這次沒抓到的**" + "、".join(f"`{r}`{why}" for r, why in broken))
w(f"- 名單=`system-dev/wiki/.panorama-repos.txt`{len(repos)} 個,逐一 `git ls-remote` 驗過存在)。")
gap = TOTAL - len(repos)
if gap > 0:
w(f"- ⚠️ **本圖點不到「名單上沒有的 repo」**:git 只能驗名字、不能枚舉。"
f"Gitea `Leo/*` 在 {ASOF} 實查是 **{TOTAL} 個**,名單 {len(repos)} 個 ⇒ **還有 {gap} 個沒被點名**。")
w(" 補法(人發起,一次呼叫,不排程)——拿到完整清單、把缺的名字加進名單再重跑:")
w(" ```")
w(" TOKEN=$(git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\\1|')")
w(" curl -s -H \"Authorization: token $TOKEN\" \\")
w(" 'https://git.uncle6.me/api/v1/orgs/Leo/repos?limit=100' | python3 -c \\")
w(" 'import sys,json;[print(r[\"name\"]) for r in json.load(sys.stdin)]'")
w(" ```")
w("")
# ── 標記以下:不進注入,給 grep ────────────────────────────────────────
w("<!-- panorama:inject-end —— 開場注入只印到這一行為止。以下是給 grep 的完整清單 -->")
w("")
w("## 完整清單(不進開場注入,給 `grep` 用)")
w("")
for repo in have:
rs = rows.get(repo, [])
if repo != SELF and rs:
w(f"### {repo} — 主檔")
for r in rs:
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
w("")
if cards.get(repo):
w(f"### {repo} — cards{len(cards[repo])} 張)")
for c in sorted(cards[repo]):
w(f"- {c}")
w("")
print("\n".join(o))
PY
)
if [ "$DO_WRITE" = 1 ]; then
printf '%s\n' "$MD" > "$OUT"
TOTAL_B=$(printf '%s\n' "$MD" | wc -c | tr -d ' ')
INJ_B=$(printf '%s\n' "$MD" | awk '/panorama:inject-end/{exit} {print}' | wc -c | tr -d ' ')
echo "已寫入 $OUT(全檔 ${TOTAL_B} bytes,其中開場注入的那段 ${INJ_B} bytes" >&2
else
printf '%s\n' "$MD"
fi