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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:41:46 +08:00
commit c2638668e3
85 changed files with 11879 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
#!/bin/bash
# wiki-panorama.sh — 產生「各 repo 的 wiki 有哪些檔」的全景 index`system-dev/wiki/PANORAMA.md`
#
# 解的問題(leo 2026-08-12):
# 「我在 AR-Mira 看到所有 Gitea Repo 的 wiki,又可以看到所有票現況,
# 有一個總圖用 md 一次看到全景。」
# 開場總圖(Arcrun 工作流 `global_index`)的「票」那段已經好用,
# 但「知識」那段接的是 KBDB 藏書地圖,而那張表 10 個庫有 8 個是空的(Leo/Arcrun#87 在修)
# ⇒ **各 repo 真正的 wikisystem-dev/wiki/*.md)一份都沒進總圖。** 這支補的就是那一半。
#
# 為什麼是本機腳本、不是加進雲端的 `global_index` 工作流:
# 工作流跑在 Cloudflare 上,拿 repo 樹只有一條路=Gitea 的 contents API 列檔,
# 而 principles 紅線寫死「**讀 repo 走 git clone/fetch,不走 API 列檔**」。
# ⇒ 這件事的正解是在「有 git 的地方」算,算完存成 repo 裡的一份 md,開場 hook 直接印。
# 票留在雲端現算(狀態必須即時),知識走這條(可以稍舊,且 D71 已寫明兩者不對稱)。
#
# 產出**一份檔、兩個用途**(中間用 `<!-- panorama:inject-end -->` 切開):
# ① 標記以上=**開場注入**的摘要(hook 只印到這裡)。控制在幾 kB,不撐爆 context。
# ② 標記以下=**給 grep 用的完整清單**(每個 repo 每一張卡的名字)。
# 「某件事有沒有記過」就 `grep -i <關鍵字> system-dev/wiki/PANORAMA.md`。
#
# 紅線:
# - **不輪詢**。這支只在人/本機發起時跑(改完 wiki 順手跑一次、或開場 hook 提醒你過期了)。
# - **只讀 git**clone/fetchblobless + sparse,只抓 wiki 目錄。不打任何列檔 API。
# - **只產 index 不搬內容**:一檔一行(檔名+最後更新日+大小+一句話),不倒全文。
#
# 用法:
# bash scripts/wiki-panorama.sh # 算出來印到 stdout(不寫檔)
# bash scripts/wiki-panorama.sh --write # 同時寫進 system-dev/wiki/PANORAMA.md
# bash scripts/wiki-panorama.sh --no-fetch # 用快取算,完全不碰網路
# bash scripts/wiki-panorama.sh --only mira # 只處理某幾個 repo(除錯用)
#
# 快取:$WIKI_PANORAMA_CACHE,預設 ~/.cache/inkstone-wiki-panorama(不落在 repo 裡)
#
# 配套:開場注入要靠 `.claude/hooks/session-start-recall.sh` 的 push 2/5。
# 還沒套的話跑:`git apply scripts/patches/session-start-recall--wiki-panorama.patch`
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
ROSTER="$ROOT/system-dev/wiki/.panorama-repos.txt"
OUT="$ROOT/system-dev/wiki/PANORAMA.md"
CACHE="${WIKI_PANORAMA_CACHE:-$HOME/.cache/inkstone-wiki-panorama}"
WIKI_DIRS="system-dev/wiki .claude/wiki" # 第二個是舊慣例,順手收
GITEA_TOTAL_REPOS=24 # 總管 2026-08-12 用 Gitea API 實查的總數
GITEA_TOTAL_ASOF=2026-08-12
DO_WRITE=0
DO_FETCH=1
ONLY=""
while [ $# -gt 0 ]; do
case "$1" in
--write) DO_WRITE=1 ;;
--no-fetch) DO_FETCH=0 ;;
--only) shift; ONLY="${ONLY} $1" ;;
-h|--help) sed -n '1,40p' "$0"; exit 0 ;;
*) echo "不認得的參數:$1" >&2; exit 2 ;;
esac
shift
done
[ -f "$ROSTER" ] || { echo "找不到 roster$ROSTER" >&2; exit 1; }
# ── Gitea base(含憑證,**絕不可印出來**)────────────────────────────────
REMOTE=$(git -C "$ROOT" remote get-url gitea 2>/dev/null || true)
[ -n "$REMOTE" ] || { echo "本 repo 沒有 gitea remote,無法取 repo。" >&2; exit 1; }
BASE=${REMOTE%/InkStoneCo.git}
BASE=${BASE%/InkStoneCo}
SELF=$(basename "$REMOTE" .git)
SECRET=$(printf '%s' "$REMOTE" | sed -nE 's|.*//[^:]+:([^@]+)@.*|\1|p')
# 任何要外流的字串都先過這關(錯誤訊息可能夾帶 clone URL)
scrub() { if [ -n "$SECRET" ]; then sed "s|$SECRET|***|g"; else cat; fi; }
mkdir -p "$CACHE"
# 自己這個 repo 一律讀工作副本 ⇒ 快取裡若留著一份舊的自己,是誤導來源(會被人拿去讀)
if [ -d "$CACHE/$SELF" ]; then rm -rf "$CACHE/$SELF"; fi
REPOS=$(grep -vE '^[[:space:]]*(#|$)' "$ROSTER" | tr -d '\r')
if [ -n "$ONLY" ]; then
REPOS=$(printf '%s\n' "$REPOS" | grep -Fx -f <(printf '%s\n' "$ONLY" | tr ' ' '\n' | grep -v '^$'))
fi
# Gitea 網址大小寫不敏感 ⇒ 同一個 repo 用兩種拼法會被算成兩個。去重。
REPOS=$(printf '%s\n' "$REPOS" | awk '{k=tolower($0)} !seen[k]++')
STATUS_TSV=$(mktemp)
trap 'rm -f "$STATUS_TSV"' EXIT
for repo in $REPOS; do
# 自己這個 repo 讀工作副本,不繞一圈回 Gitea——
# 剛寫完還沒推的 wiki 也要看得見(人就站在這裡改)
if [ "$repo" = "$SELF" ]; then
printf '%s\tok\t%s\n' "$repo" "$ROOT" >> "$STATUS_TSV"; continue
fi
dst="$CACHE/$repo"
if [ -d "$dst/.git" ]; then
if [ "$DO_FETCH" = 1 ]; then
br=$(git -C "$dst" symbolic-ref --short HEAD 2>/dev/null || echo main)
git -C "$dst" fetch --quiet origin "$br" 2>&1 | scrub >&2 || true
git -C "$dst" reset --quiet --hard FETCH_HEAD 2>/dev/null || true
fi
else
if [ "$DO_FETCH" = 0 ]; then
printf '%s\tno-cache\t\n' "$repo" >> "$STATUS_TSV"; continue
fi
# blobless + sparse:只下載 wiki 目錄的內容,但保留完整 commit 歷史
#(要歷史才算得出「每個檔最後更新是哪天」——淺 clone 會讓所有檔同一天)
if ! err=$(git clone --quiet --filter=blob:none --sparse "$BASE/$repo.git" "$dst" 2>&1 | scrub); then
printf '%s\tclone-failed\t%s\n' "$repo" "$(printf '%s' "$err" | tr '\n' ' ')" >> "$STATUS_TSV"
rm -rf "$dst"; continue
fi
git -C "$dst" sparse-checkout set $WIKI_DIRS >/dev/null 2>&1 || true
fi
printf '%s\tok\t%s\n' "$repo" "$dst" >> "$STATUS_TSV"
done
# ── 掃檔 + 排版(python3:BSD/GNU 工具差異多,交給它比較穩)────────────
MD=$(WIKI_DIRS="$WIKI_DIRS" SELF="$SELF" CACHE="$CACHE" \
TOTAL="$GITEA_TOTAL_REPOS" ASOF="$GITEA_TOTAL_ASOF" \
python3 - "$STATUS_TSV" <<'PY'
import os, re, subprocess, sys, datetime
status_tsv = sys.argv[1]
wiki_dirs = os.environ["WIKI_DIRS"].split()
SELF = os.environ["SELF"]
CACHE = os.environ["CACHE"].replace(os.path.expanduser("~"), "~")
TOTAL = int(os.environ["TOTAL"])
ASOF = os.environ["ASOF"]
OUT_NAME = "PANORAMA.md" # 產生物自己不列進全景
# 每個 repo 裝 system-dev-template 就會有的骨架檔。全部都有 ⇒ 逐一列出來只是雜訊,
# 對其他 repo 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔」。
SKELETON = {"INDEX", "TAXONOMY", "decisions-summary", "mistakes", "principles", "status"}
def run(cwd, *args):
try:
return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=60).stdout
except Exception:
return ""
def dates_for(repo_dir, wdir):
"""一次 git log 走完整個目錄,取每個檔第一次出現(=最後一次被改)的日期。"""
out = run(repo_dir, "git", "log", "--date=short", "--format=@%ad", "--name-only", "--", wdir)
seen, cur = {}, None
for line in out.splitlines():
if line.startswith("@"):
cur = line[1:].strip()
elif line.strip() and cur:
seen.setdefault(line.strip(), cur)
return seen
WIDE = re.compile(r'[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]')
def width(s): return sum(2 if WIDE.match(c) else 1 for c in s)
def clip(s, w):
out, acc = "", 0
for c in s:
cw = 2 if WIDE.match(c) else 1
if acc + cw > w: return out.rstrip(" 、,·-—") + "…"
out, acc = out + c, acc + cw
return out
def clean(s):
s = re.sub(r'<!--.*?-->', '', s)
s = re.sub(r'\[\[([^\]]+)\]\]', r'\1', s)
s = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', s)
s = s.replace('**', '').replace('`', '').replace('~~', '')
s = re.sub(r'^[>#\-\*\s]+', '', s)
return re.sub(r'\s+', ' ', s).strip()
SKIP = ('---', '===', '|', '```', '<!--', '<')
def title_and_hook(path):
try:
text = open(path, encoding="utf-8", errors="replace").read(9000)
except OSError:
return "", ""
lines = text.splitlines()
title, rest = "", lines
for i, ln in enumerate(lines):
if ln.startswith("# "):
title, rest = clean(ln), lines[i+1:]
break
hook = ""
for ln in rest[:60]:
s = ln.strip()
if not s or s.startswith(SKIP) or s.startswith("#"):
continue
c = clean(s)
if len(c) < 4:
continue
# hook 跟標題講同一件事就不重複佔位,往下找一句真的有增量的
if title and (c[:10] in title or title[:10] in c):
continue
hook = c
break
return title, hook
def one_line(title, hook, fallback):
"""一檔一行的那句話:標題優先,標題太短就補一句 hook。"""
t = re.sub(r'^[\W_]*', '', title or "") or fallback
if hook and width(t) < 46:
t = t + "" + hook
return clip(t, 78)
# ── 掃 ────────────────────────────────────────────────────────────────
repos, rows, cards, misses = [], {}, {}, []
for line in open(status_tsv, encoding="utf-8"):
parts = (line.rstrip("\n") + "\t\t").split("\t")
repo, st, dirpath = parts[0], parts[1], parts[2]
if not repo:
continue
repos.append(repo)
if st != "ok":
misses.append((repo, {"clone-failed": "clone 失敗",
"no-cache": "沒有快取且指定了 --no-fetch"}.get(st, st)))
continue
found = False
for wdir in wiki_dirs:
full = os.path.join(dirpath, wdir)
if not os.path.isdir(full):
continue
dmap = dates_for(dirpath, wdir)
for name in sorted(os.listdir(full)):
p = os.path.join(full, name)
if not (name.endswith(".md") and os.path.isfile(p)) or name == OUT_NAME:
continue
rel = f"{wdir}/{name}"
t, h = title_and_hook(p)
rows.setdefault(repo, []).append(
dict(rel=rel, name=name, stem=name[:-3], date=dmap.get(rel, "?"),
size=os.path.getsize(p), line=one_line(t, h, name[:-3])))
found = True
cdir = os.path.join(full, "cards")
if os.path.isdir(cdir):
for bucket in sorted(os.listdir(cdir)):
bp = os.path.join(cdir, bucket)
if not os.path.isdir(bp):
continue
for n in sorted(os.listdir(bp)):
if n.endswith(".md") and not n.startswith("00-INDEX"):
cards.setdefault(repo, []).append(n[:-3]); found = True
if not found:
misses.append((repo, "沒有 wiki"))
def kb_of(n): return max(1, round(n / 1024))
def newest(rs): return max([r["date"] for r in rs if r["date"] != "?"] or ["?"])
today = datetime.date.today().isoformat()
n_files = sum(len(v) for v in rows.values())
n_cards = sum(len(v) for v in cards.values())
have = [r for r in repos if r in rows or r in cards]
nowiki = [r for r, why in misses if why == "沒有 wiki"]
broken = [(r, why) for r, why in misses if why != "沒有 wiki"]
o = []; w = o.append
# ── 標記以上:開場注入的那一段 ────────────────────────────────────────
w("# 全景:各 repo 的 wiki 裡有哪些檔(index,不是內容)")
w("")
w(f"> **{today} 產生**`bash scripts/wiki-panorama.sh --write`,人發起、不輪詢) · "
f"來源=`git clone/fetch` Gitea `Leo/*` 各 repo 的預設分支,不走 API 列檔。")
w(f"> {SELF} 這段讀的是**本機工作副本**(剛寫完還沒推的也算數)。**改這個檔沒用**,要改去改那個 repo 的 wiki。")
w(">")
w("> 🔴 **這裡沒有內容,只有「有這件事、在哪個 repo 的哪個檔」。**")
w(f"> - 「某件事 wiki 記過沒有」→ `grep -i <關鍵字> system-dev/wiki/{OUT_NAME}`"
f"(本檔下半部有全部 {n_cards} 張卡的名字)")
w(f"> - 要讀內容 → 本機有那個 repo 就直接讀;沒有就看快取 `{CACHE}/<repo>/system-dev/wiki/`")
w("")
w(f"**{n_files} 份主檔 {n_cards} 張卡,散在 {len(have)} 個 repo**"
f"(點名 {len(repos)} 個 repo,其中 {len(nowiki)} 個掃過確定沒有 wiki")
w("")
# 自己這個 repo:逐檔一行(這是你最可能真的去讀的那份)
if SELF in rows:
rs = rows[SELF]
head = f"## {SELF}(你現在站的地方)— {len(rs)} 份主檔"
if cards.get(SELF): head += f"、{len(cards[SELF])} 張卡"
w(head)
for r in rs:
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
if cards.get(SELF):
w(f"- `system-dev/wiki/cards/` {len(cards[SELF])} 張:" + "、".join(sorted(cards[SELF])))
w("")
# 其他 repo:一 repo 一行。骨架六檔每個 repo 都有,逐一列是雜訊——
# 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔 + 卡數」。
others = [r for r in have if r != SELF]
if others:
w("## 其他 repo — 一 repo 一行")
w("")
w("> 每個 repo 都有 `INDEX``TAXONOMY``decisions-summary``mistakes``principles``status` "
"這套骨架(裝 system-dev-template 就有),所以只標**新鮮度、最肥的那份、多出來的檔**。")
for repo in others:
rs = rows.get(repo, [])
extra = [r["name"] for r in rs if r["stem"] not in SKELETON]
big = max(rs, key=lambda r: r["size"]) if rs else None
seg = [f"**{repo}** — {len(rs)} 份"]
if cards.get(repo): seg.append(f"{len(cards[repo])} 張卡")
if rs: seg.append(f"最近改 {newest(rs)}")
if big: seg.append(f"最肥 `{big['name']}` {kb_of(big['size'])}kB")
if extra: seg.append("多出來的:" + "、".join(f"`{e}`" for e in extra))
w("- " + " · ".join(seg))
w("")
w("## 涵蓋範圍——**沒掃到的也要看得見**")
w("")
if nowiki:
w(f"- **掃過、確定沒有 wiki 的 {len(nowiki)} 個**" + "、".join(f"`{r}`" for r in nowiki))
if broken:
w("- ⚠️ **這次沒抓到的**" + "、".join(f"`{r}`{why}" for r, why in broken))
w(f"- 名單=`system-dev/wiki/.panorama-repos.txt`{len(repos)} 個,逐一 `git ls-remote` 驗過存在)。")
gap = TOTAL - len(repos)
if gap > 0:
w(f"- ⚠️ **本圖點不到「名單上沒有的 repo」**:git 只能驗名字、不能枚舉。"
f"Gitea `Leo/*` 在 {ASOF} 實查是 **{TOTAL} 個**,名單 {len(repos)} 個 ⇒ **還有 {gap} 個沒被點名**。")
w(" 補法(人發起,一次呼叫,不排程)——拿到完整清單、把缺的名字加進名單再重跑:")
w(" ```")
w(" TOKEN=$(git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\\1|')")
w(" curl -s -H \"Authorization: token $TOKEN\" \\")
w(" 'https://git.uncle6.me/api/v1/orgs/Leo/repos?limit=100' | python3 -c \\")
w(" 'import sys,json;[print(r[\"name\"]) for r in json.load(sys.stdin)]'")
w(" ```")
w("")
# ── 標記以下:不進注入,給 grep ────────────────────────────────────────
w("<!-- panorama:inject-end —— 開場注入只印到這一行為止。以下是給 grep 的完整清單 -->")
w("")
w("## 完整清單(不進開場注入,給 `grep` 用)")
w("")
for repo in have:
rs = rows.get(repo, [])
if repo != SELF and rs:
w(f"### {repo} — 主檔")
for r in rs:
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
w("")
if cards.get(repo):
w(f"### {repo} — cards{len(cards[repo])} 張)")
for c in sorted(cards[repo]):
w(f"- {c}")
w("")
print("\n".join(o))
PY
)
if [ "$DO_WRITE" = 1 ]; then
printf '%s\n' "$MD" > "$OUT"
TOTAL_B=$(printf '%s\n' "$MD" | wc -c | tr -d ' ')
INJ_B=$(printf '%s\n' "$MD" | awk '/panorama:inject-end/{exit} {print}' | wc -c | tr -d ' ')
echo "已寫入 $OUT(全檔 ${TOTAL_B} bytes,其中開場注入的那段 ${INJ_B} bytes" >&2
else
printf '%s\n' "$MD"
fi