Compare commits

...

3 Commits

Author SHA1 Message Date
Leo c0eec76b52 主線成員讀不到任何一條 /dependencies 時不准印出完整名單:set 不印 並離開碼 3、refresh/adopt 保留舊快取並講出來(inkstone/ISEP#130 → c6973)
總管 09-13 實撞:Gitea 握手逾時,hub 票 InkStoneCo#132 本身讀得到、它的 /dependencies 讀不到,
`mainline set` 仍印「 成員 6 張」,底下三張前置全漏;`refresh --members` 零輸出、名單沒變。
雲端工人靠這份名單抓票,漏收=以為抓盡=收工,等於 v0.30.0 換個入口又停。

- collect_members:清單讀取拿不到就重讀(ISEP_MEMBER_RETRIES 預設 2、退避 2s×n);
  仍讀不到的票記進 info["unread"],info["complete"]=沒有 unread 且沒被上限截斷
- set:不完整時印 ⚠️🔴 成員不完整)+點名讀不到的票,主線檔寫 members_complete/members_unread,
  同一條線保留舊快取成員,離開碼 3(主線本身照樣標定)
- refresh --members:完整才蓋快取並印成員張數;不完整保留舊快取並講出來;讀不到里程碑也講
- adopt:不完整時與舊快取取聯集

重現(改之前跑新測試):F1–F5、F9–F11、F13–F14 共 11 條紅——set 印 、arcrun-rag#104 漏收、
refresh --members 零輸出且把快取蓋成少兩張的名單。
修後:mainline-members-by-dependency 70/71(唯一紅的 ㊽ 在 main 上同樣紅)、repo-mirror 17/17、
gitea-fallback 31/31、focus-guard 24/24、ticket-pick 58/58、debt-worklist 47/47。
真 Gitea 唯讀跑(不寫主線檔):InkStoneCo#65 成員 9 張、complete=True、hub #132 底下三張都收到。
沒動 InkStoneCo 的 system-dev/mainline.json。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:57:44 +08:00
Leo 0d49c0dcd7 Merge '雲端工人做完一張就接下一張'(inkstone/ISEP#33)+定版 v0.30.0
總管複驗:queue-drain-guard 26/26、ticket-pick 58/58、逐行審閘(ISEP#33 c6965)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:34:04 +08:00
Leo b2d9141a7d 定版 v0.30.0(雲端工人做完一張就接下一張)
inkstone/ISEP#33(→ c6963):queue-drain-guard(佇列沒抓盡不准收工)、pickable 認得自己帳號的 s/todo、api() GET 連線失敗重試。
數字在這棵樹上實數:62 支閘/87 條註冊/7 位工人/7 支命令/2 支 skill/62 支腳本。
tag 由總管併 main 後打(check-version-consistency 在打 tag 前必然紅,照 v0.29.0 慣例)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:21:23 +08:00
5 changed files with 129 additions and 15 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "isep", "name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(86 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、62 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(87 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、62 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.29.0", "version": "0.30.0",
"keywords": [ "keywords": [
"inkstone", "inkstone",
"guardrails", "guardrails",
+5
View File
@@ -38,6 +38,11 @@
> **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**`ls hooks/*.sh | wc -l`61、 > **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**`ls hooks/*.sh | wc -l`61、
> `grep -c '"command":' hooks/hooks.json`85、`ls -p scripts | grep -v / | wc -l`60)。 > `grep -c '"command":' hooks/hooks.json`85、`ls -p scripts | grep -v / | wc -l`60)。
> 📌 **`0.30.0``inkstone/ISEP#33`2026-09-13)+1 支、+1 條**`queue-drain-guard.sh`F 組,Stop)——
> 唯一一支看「佇列」的收工閘:在接票的 session`ticket pick` 還有票就不准收工。
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**`ls hooks/*.sh | wc -l`62、
> `grep -c '"command":' hooks/hooks.json`87、`ls -p scripts | grep -v / | wc -l`62)。
> 📌 **`inkstone/ISEP#147`2026-09-07)+0 支、+1 條**`line-needs-own-worktree.sh` 多掛 PostToolUse `Agent|Task` > 📌 **`inkstone/ISEP#147`2026-09-07)+0 支、+1 條**`line-needs-own-worktree.sh` 多掛 PostToolUse `Agent|Task`
> ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。 > ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。
> 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117``InkStoneCo-wt-112` > 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117``InkStoneCo-wt-112`
+31 -5
View File
@@ -364,18 +364,42 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
回 (members, info) 回 (members, info)
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們) members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None, info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
"reached": bool, "capped": bool} "reached": bool, "capped": bool, "unread": [...], "complete": bool}
🔴 `reached=False` 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取** 🔴 `reached=False` 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
(讀不到 ≠ 沒有成員)。 (讀不到 ≠ 沒有成員)。
🔴 `unread` 讀不到 `/dependencies` 的票(重讀過仍失敗)。**只要有一張,名單就不完整**:
那張底下的前置全部沒收到(inkstone/ISEP#130 c6973Gitea 握手逾時,hub 票底下三張全漏,
`set` 卻印 ✅)。`complete`reached 且沒有 unread 且沒被上限截斷——呼叫端只准在它為真時
把名單當成完整的寫出去。
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。 判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
""" """
import time
import urllib.parse import urllib.parse
try:
retries = max(0, int(os.environ.get("ISEP_MEMBER_RETRIES", "2")))
except ValueError:
retries = 2
try:
backoff = max(0.0, float(os.environ.get("ISEP_MEMBER_BACKOFF", "2")))
except ValueError:
backoff = 2.0
def read_list(path):
"""讀一頁清單;拿不到 list 就重讀(握手逾時常常下一次就好)。仍拿不到回 None。"""
for n in range(retries + 1):
got = api(path)
if isinstance(got, list):
return got
if n < retries and backoff:
time.sleep(backoff * (n + 1))
return None
owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id") owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id")
title = ms.get("title") or "" title = ms.get("title") or ""
info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms), info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms),
"reached": False, "capped": False} "reached": False, "capped": False, "unread": [], "complete": False}
q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100}) q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100})
rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q)) rows = read_list("/repos/%s/%s/issues?%s" % (owner, repo, q))
if not isinstance(rows, list): if not isinstance(rows, list):
return [], info return [], info
info["reached"] = True info["reached"] = True
@@ -400,9 +424,10 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
parts = split_ref(cur) parts = split_ref(cur)
if not parts: if not parts:
continue continue
deps = api("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts) deps = read_list("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
if not isinstance(deps, list): if not isinstance(deps, list):
continue # 這一張讀不到:跳過它,不當成「沒有相依」以外的事 info["unread"].append(cur) # 讀不到 ≠ 沒有相依:記下來,名單就不算完整
continue
for d in deps: for d in deps:
r = issue_ref(d) r = issue_ref(d)
if not r or r in seen: if not r or r in seen:
@@ -412,6 +437,7 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
break break
seen.add(r); out.append(r); queue.append(r) seen.add(r); out.append(r); queue.append(r)
info["via_deps"].append(r) info["via_deps"].append(r)
info["complete"] = not info["unread"] and not info["capped"]
return sorted(set(out)), info return sorted(set(out)), info
@@ -95,7 +95,14 @@ class H(BaseHTTPRequestHandler):
r = "%s/%s#%s" % m.groups()[:3] r = "%s/%s#%s" % m.groups()[:3]
if r not in w["issues"]: return self._send(404, {"message": "not found"}) if r not in w["issues"]: return self._send(404, {"message": "not found"})
if not m.group(4): return self._send(200, issue_obj(w, r)) if not m.group(4): return self._send(200, issue_obj(w, r))
if m.group(4) == "/dependencies": return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])]) if m.group(4) == "/dependencies":
# inkstone/ISEP#130 c6973Gitea 握手逾時時,票本身讀得到、它的 /dependencies 讀不到
fo = os.path.join(TMP, "depsfail-once"); ff = os.path.join(TMP, "depsfail")
if os.path.exists(fo) and r in open(fo).read().split():
os.remove(fo); return self._send(500, {"message": "handshake timed out (test, once)"})
if os.path.exists(ff) and r in open(ff).read().split():
return self._send(500, {"message": "handshake timed out (test)"})
return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs]) return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs])
return self._send(404, {"message": "not found", "path": p}) return self._send(404, {"message": "not found", "path": p})
def do_POST(self): def do_POST(self):
@@ -299,6 +306,38 @@ OUT=$(ml refresh --members); RC=$?
[ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)" [ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)"
rm -f "$TMP/down" rm -f "$TMP/down"
echo "── F. 讀不到任何一條 /dependencies ⇒ 不准寫出一份看起來完整的名單(inkstone/ISEP#130 → c6973)──"
jget(){ python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get(sys.argv[2]); print(v if not isinstance(v,list) else ' '.join(v))" "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$1"; }
export ISEP_MEMBER_BACKOFF=0
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
[ "$RC" != 0 ] && ok "F1 hub 票的 /dependencies 讀不到 ⇒ set 離開碼非 0(實際 $RC" || no "F1 set 當成成功" "$RC $OUT"
printf '%s' "$OUT" | grep -q '✅' && no "F2 名單不完整卻印 ✅(c6973 重現的就是這個)" "$OUT" || ok "F2 名單不完整時不印 ✅"
case "$OUT" in *"成員不完整"*"inkstone/InkStoneCo#44"*) ok "F3 訊息講出「成員不完整」並點名讀不到的是哪一張" ;; *) no "F3 沒講不完整/沒點名" "$OUT" ;; esac
[ "$(jget members_complete)" = "False" ] && ok "F4 寫出去的主線檔標 members_complete=false(讀檔的人也看得出)" || no "F4 檔案沒標不完整" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
case " $(jget members_unread) " in *" inkstone/InkStoneCo#44 "*) ok "F5 members_unread 列出讀不到的票" ;; *) no "F5 沒列" "$(jget members_unread)" ;; esac
[ "$(jget ref)" = "inkstone/Arcrun#48" ] && ok "F6 主線本身照樣標定(壞的只有成員那一格)" || no "F6 主線沒標上" "$(jget ref)"
OUT=$(python3 "$TICKET" pick --mainline "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" 2>&1); RC=$?
[ "$RC" != 1 ] && ok "F7 同樣讀不到時 ticket pick 不回離開碼 1(不會讓 queue-drain-guard 當成抓盡;實際 $RC" || no "F7 pick 回了「抓盡」" "$OUT"
rm -f "$TMP/depsfail"
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail-once"
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '✅' && ok "F8 只逾時一次 ⇒ 重讀一次就完整,set 照常 ✅" || no "F8 沒重試" "$RC $OUT"
case " $(members) " in *" inkstone/arcrun-rag#104 "*) ok "F9 重讀後 hub 票底下的 arcrun-rag#104 收到了" ;; *) no "F9 還是漏收" "$(members)" ;; esac
[ "$(jget members_complete)" = "True" ] && ok "F10 完整時 members_complete=true" || no "F10 完整卻沒標" "$(jget members_complete)"
OUT=$(ml refresh --members); RC=$?
case "$OUT" in *"成員"*"張"*) ok "F11 refresh --members 讀得到時會印出成員張數(c6973:原本零輸出)" ;; *) no "F11 refresh --members 還是零輸出" "$OUT" ;; esac
BEFORE=$(members)
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
OUT=$(ml refresh --members); RC=$?
[ "$RC" = 0 ] && ok "F12 refresh --members 讀不到也不炸(SessionStart 那條不准炸)" || no "F12 炸了" "$RC $OUT"
[ "$(members)" = "$BEFORE" ] && ok "F13 不完整時保留舊快取,不拿殘缺名單蓋掉" || no "F13 快取被蓋掉" "$BEFORE$(members)"
case "$OUT" in *"成員不完整"*) ok "F14 refresh --members 講出成員不完整" ;; *) no "F14 靜默" "$OUT" ;; esac
rm -f "$TMP/depsfail"
unset ISEP_MEMBER_BACKOFF
echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──" echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──"
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1) OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1)
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT" [ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT"
+51 -7
View File
@@ -252,21 +252,50 @@ def cmd_set(argv):
"members": [], "members": [],
} }
refs, info = collect_members(ms) refs, info = collect_members(ms)
complete = bool(info.get("reached") and info.get("complete"))
if not complete:
# 名單不完整:同一條線重標時,舊快取裡有的成員先留著(讀不到 ≠ 沒有)
prev = ML.load() or {}
if prev.get("ref") == ms["ref"]:
refs = sorted(set(refs) | set(prev.get("members") or []))
ms["members"] = refs ms["members"] = refs
ms["members_complete"] = complete
ms["members_unread"] = incomplete_what(info)
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M") now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
ms["set_at"] = ms["refreshed_at"] = now ms["set_at"] = ms["refreshed_at"] = now
if not ML.save(ms, mirror=True): if not ML.save(ms, mirror=True):
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path()) die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title)) # 🔴 inkstone/ISEP#130 c6973:名單不完整時不准印 ✅——雲端工人靠這份名單抓票,
# 漏收=以為抓盡=收工。主線本身照樣標定,壞的只有成員那一格,所以講清楚是哪一格。
print("%s 主線已標定:%s/%s#%d「%s」%s" % (
"✅" if complete else "⚠️", owner, repo, mid, title, "" if complete else "(🔴 成員不完整)"))
if hub: if hub:
print(" hub 票:%s" % hub) print(" hub 票:%s" % hub)
print(members_line(refs, info) if info.get("reached") print(members_line(refs, info) if info.get("reached")
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)") else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
if info.get("capped"): if not complete:
print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP) print(incomplete_line(info))
print(mirror_line()) print(mirror_line())
print() print()
cmd_show([]) cmd_show([])
if not complete:
sys.exit(3)
def incomplete_what(info):
"""名單不完整的原因,寫進主線檔(讀檔的人不必重跑也看得出)。完整時回 []。"""
what = list(info.get("unread") or [])
if not info.get("reached"):
what.insert(0, "hub 里程碑那一頁)")
if info.get("capped"):
what.append("(相依走到 %d 張上限)" % ML.MEMBER_WALK_CAP)
return what
def incomplete_line(info):
return (" 🔴 成員不完整:讀不到 %s——那底下的前置沒收到。\n"
" 雲端工人靠這份名單抓票;Gitea 恢復後重跑 `mainline refresh --members`(或同一行 set)補齊。"
% "、".join(incomplete_what(info)))
def mirror_line(): def mirror_line():
@@ -328,6 +357,9 @@ def cmd_refresh(argv):
except SystemExit: except SystemExit:
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸 return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
if not isinstance(d, dict) or not d.get("title"): if not isinstance(d, dict) or not d.get("title"):
if "--members" in argv: # 明講要成員的人要看得到結果(c6973:原本零輸出)
print(" ⚠️ 讀不到主線里程碑 %s——成員沒更新,沿用舊快取 %d 張"
% (ML.ref_of(ms), len(ms.get("members") or [])))
return # 讀不到 ≠ 主線不存在 return # 讀不到 ≠ 主線不存在
ms.update({ ms.update({
"title": d.get("title") or ms["title"], "title": d.get("title") or ms["title"],
@@ -342,12 +374,19 @@ def cmd_refresh(argv):
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變); # 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
# `set``adopt` 會重收,`pick``has`focus guard 本來就每次現問 Gitea,快取只餵 `show`。 # `set``adopt` 會重收,`pick``has`focus guard 本來就每次現問 Gitea,快取只餵 `show`。
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。 # 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
# 🔴 inkstone/ISEP#130 c6973:只有**完整**的名單才准蓋掉快取;不完整就留舊的並講出來。
try: try:
refs, info = collect_members(ms) refs, info = collect_members(ms)
except SystemExit: except SystemExit:
refs, info = [], {"reached": False} refs, info = [], {"reached": False}
if info.get("reached"): if info.get("reached") and info.get("complete"):
ms["members"] = refs ms["members"] = refs
ms["members_complete"], ms["members_unread"] = True, []
print(members_line(refs, info))
else:
ms["members_complete"], ms["members_unread"] = False, incomplete_what(info)
print(" ⚠️ 成員不完整,沿用舊快取 %d 張(沒有拿殘缺名單蓋掉)" % len(ms.get("members") or []))
print(incomplete_line(info))
ML.save(ms) ML.save(ms)
@@ -391,13 +430,18 @@ hub 里程碑裡現在有:%s
T.add_dependency(vparts, (owner, repo, num)) T.add_dependency(vparts, (owner, repo, num))
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo) how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
refs, info = collect_members(ms) refs, info = collect_members(ms)
if info.get("reached"): complete = bool(info.get("reached") and info.get("complete"))
if complete:
ms["members"] = sorted(set(refs) | {ref}) ms["members"] = sorted(set(refs) | {ref})
else: else:
ms["members"] = sorted(set(ms.get("members") or []) | {ref}) # 不完整(c6973):新收到的與舊快取取聯集,不拿殘缺名單蓋掉
ms["members"] = sorted(set(ms.get("members") or []) | set(refs) | {ref})
ms["members_complete"], ms["members_unread"] = complete, incomplete_what(info)
ML.save(ms, mirror=True) ML.save(ms, mirror=True)
print("✅ 補收:%s → %s" % (ref, how)) print("✅ 補收:%s → %s" % (ref, how))
print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)") print(members_line(ms["members"], info) if complete else " (成員清單舊快取+這次讀得到的+這一張)")
if not complete:
print(incomplete_line(info))
print(mirror_line()) print(mirror_line())