#!/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 已寫明兩者不對稱)。 # # 產出**一份檔、兩個用途**(中間用 `` 切開): # ① 標記以上=**開場注入**的摘要(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 = ('---', '===', '|', '```', '") 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