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:
Executable
+70
@@ -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 "① UI(console-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 "② cypher(cypher-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 其餘 tier2(kbdb/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 "③ daemon(collector 產物 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
|
||||
Executable
+85
@@ -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 "✅ 無漂移"
|
||||
Executable
+11
@@ -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-armed,component-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,並留痕。用完自動失效。"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""daemon-selfcheck.py — 桌面小幫手(daemon)本機狀態自檢,唯讀。
|
||||
|
||||
為什麼有這支(leo 2026-08-19):
|
||||
「現在本機的資料夾跟雲端已經不相連了。」
|
||||
這句話有好幾種可能的形狀,而它們的處置完全不同:
|
||||
① 帳號指的那台實例被重裝過(網址沒變、鑰匙換了) ← arcrun-rag#103
|
||||
② 監看資料夾在本機被搬走/改名/刪掉
|
||||
③ 資料夾是空的,雲端因此「什麼都沒有」 ← arcrun-rag#106
|
||||
④ 真的連不上網路
|
||||
這支不猜,只把**分得出這四種**的事實印出來。
|
||||
|
||||
🔴 唯讀:不寫任何檔、不連網、不印任何金鑰(api_key/gemini_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]))
|
||||
Executable
+128
@@ -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 部署源必須落在 git(07-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 未推上 Gitea(git 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"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# B1 備援三件套之二:每日 git bundle 備份(de-Gitea brief B1)
|
||||
# 對本機所有 repo:git 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
|
||||
Executable
+211
@@ -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 打不到/回應非 200(curl_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
|
||||
Executable
+145
@@ -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_leo/Telegram 轉告 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 \"出 prod:xxx\"" >&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 + 掛 Human(leo 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"
|
||||
Executable
+51
@@ -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
|
||||
Executable
+55
@@ -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"
|
||||
Executable
+80
@@ -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 # 指定 repo(token 仍從當前 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/done:closed 就是 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
|
||||
Executable
+96
@@ -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"
|
||||
Executable
+472
@@ -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 Wiki(CC 記憶系統 + 機敏防護)
|
||||
--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"
|
||||
|
||||
# Cowork(claude.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 讀取順序(push:hook 開 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 變動前必須有對應 SDD(system-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 issue:CC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \
|
||||
" GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)"
|
||||
echo ""
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"account": {
|
||||
"id": "58309bb90fd93ad6d0fe0aae99170e9d",
|
||||
"name": "Uncle6.me@gmail.com's Account"
|
||||
}
|
||||
}
|
||||
@@ -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 CLI(leo 2026-08-15 指定)══════════════
|
||||
# 「你可以叫它用 CLI 考試。」CLI/MCP/portal 是同一套 API 的三個薄殼
|
||||
# (`cli/src/commands/kbdb.ts` 檔頭:能力長在基本盤 API,CLI 只做介面轉換)。
|
||||
# `acr kbdb` 的動作**剛好就是那六個按鈕**,多一個少一個都沒有。
|
||||
#
|
||||
# 🔴 **它打哪一台,由 cwd 決定**:解析順序是
|
||||
# env > 資料夾層 `.arcrun.yaml`(就近往上找)> 全域 `~/.arcrun/config.yaml`,
|
||||
# 而**全域指的是 leo21c(leo 的真庫,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()
|
||||
@@ -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`。
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
考前佈置:把 L3/L6 需要的「已經存在的資料」先種進去。
|
||||
|
||||
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])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
# kitesurf-mcp.sh — 把 Cloudflare Kitesurf 無頭瀏覽器掛成一個 MCP server(stdio)。
|
||||
#
|
||||
# 【為什麼需要這支包裝,不能直接把 leo 給的那段 JSON 貼進設定】
|
||||
# leo 給的那段是 **CDP(Chrome 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_FILE(leo21c 的 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" \
|
||||
"$@"
|
||||
@@ -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-data:CF 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 現況綁定存成 JSON(rollback 的唯一依據)")
|
||||
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()
|
||||
Executable
+103
@@ -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 一個常數,OWNER/REPO/APPROVER_LOGIN 三個維持寫死、
|
||||
# 不接受環境變數覆蓋,且 ISSUE 也只能透過腳本參數指定,不接受環境變數。
|
||||
|
||||
# ── 安全邊界常數(OWNER/REPO/APPROVER_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)"
|
||||
}
|
||||
Executable
+174
@@ -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#85(embed 補算)、Leo/mira#6(Mira 主線)。
|
||||
**那三張修好之後這段要拿掉**,否則它會變成下一個「看起來像現況的舊東西」。
|
||||
|
||||
--- 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 " 所以 arcrun/mira/arcrun-rag/arcrun-harness/inkstoneco 等庫會顯示 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#85(embed)|Leo/mira#6(Mira 主線)"
|
||||
+echo "────────────────────────────────────────────────"
|
||||
+echo ""
|
||||
+
|
||||
# ── push 2/5:各 repo 的 wiki 全景(leo 2026-08-12「一次看到全景」的另一半)──
|
||||
# 上面那段是**票**(雲端現算)+ KBDB 藏書地圖;這段是**各 repo 真正的 wiki**。
|
||||
# 為什麼不塞進雲端那支工作流:工作流跑在 Cloudflare 上,要拿 repo 樹只有
|
||||
Executable
+54
@@ -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
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ticket — 讓 Gitea 變成「可追蹤的線」,不是「越積越大的池子」。
|
||||
|
||||
leo 2026-08-16 三句話,本工具就是它們的機械化:
|
||||
「不要每個開新票,現有的票開在它下面的對話裡」
|
||||
「我希望你把 gitea 變成可以追蹤,不是變成一個池子」
|
||||
「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
|
||||
|
||||
當天實錯(本工具的來由):總管要派人查一個部署擋路石,**沒有搜尋就直接開新票**
|
||||
(arcrun-rag#110),而那條線早就有 hub(InkStoneCo#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 Story(leo 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:])
|
||||
Executable
+332
@@ -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"
|
||||
# Cowork(claude.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.md(1.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 ""
|
||||
@@ -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 真正的 wiki(system-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/fetch,blobless + 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
|
||||
Reference in New Issue
Block a user