#!/usr/bin/env python3 """milestone-account — 每次結案都留下「估多久/花多久/差多少/為什麼」(inkstone/ISEP#85)。 leo 的問題(票上原文):「leo 2026-08-27 說今天『**拖時間**』。但拖了多久、 比預計多拖多少、為什麼拖——**沒有任何數字**⋯⋯每次事後只能用『這次比較慢』 這種話帶過,下次還是一樣。」 milestone-account codes 七個代號是哪七個(封閉集合) milestone-account list --org inkstone [--overdue] 現在每個里程碑的三個數字 milestone-account audit inkstone/ISEP#12 從 Gitea 時間軸算證據 milestone-account close inkstone/ISEP#12 --reason idle [--dry-run] milestone-account report 累計:哪一種原因最常發生 milestone-account ledger-path 帳本檔在哪(給閘問的) 🔴 這支工具的三條,全部長在機器上(不是寫在規範裡靠人記得): ① **三個數字自己算,不用手填**:期限與實際都取自 Gitea 的 `created_at`/ `due_on`/`closed_at`。手填的數字沒有人會去對,等於沒有。 ② **差超過 25%(超時或提早都算)就一定要挑一個代號**,挑不出來不准結案。 提早太多不是好消息,是**當初估太鬆**——所以兩個方向同一條線。 ③ **代號要有時間軸撐得住**:`audit` 先從 Gitea 的時間軸算出哪幾個訊號成立, `close` 只接受**訊號成立的那幾個代號**;一個都沒成立時,唯一合法的代號 就是 `misestimate`(=票上那句「以上都沒有,但還是差很多」)。 ⇒ **不採信我自己說的**,這是票上寫死的。 🔴 代號是**封閉集合**,這一點是刻意的——票上原文:「**能統計的前提是分類有限**」。 真的歸不進去才提案加一個,而那要走 leo 核准(開票,不是自己加)。 離線測試:`scripts/test-milestone-account.sh`(走 `MILESTONE_ACCOUNT_FIXTURE`,不打網路)。 """ import json import os import re import subprocess import sys import time import urllib.error import urllib.parse import urllib.request HOST = os.environ.get("MILESTONE_ACCOUNT_HOST", "https://git.uncle6.me") HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) def _resolve_dir(): """帳本住哪裡。 🔴 **不能住在 plugin 目錄裡**:ISEP 是用 marketplace 裝的, `claude plugin update` 會把那個目錄整個換掉 ⇒ 累積幾個月的帳一次歸零, 而這張票的全部價值就在「累積」。 順序:環境變數 → 這個 session 的專案根底下的 `system-dev/`(總管的跨專案知識庫, 帳本本來就屬於那一層)→ 這份原始碼所在 repo 的 `system-dev/` → `~/.claude/`。 """ v = os.environ.get("MILESTONE_ACCOUNT_DIR") if v: return v for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", ROOT): if base and os.path.isdir(os.path.join(base, "system-dev")): return os.path.join(base, "system-dev", "estimates") return os.path.join(os.path.expanduser("~"), ".claude", "isep-estimates") DIR = _resolve_dir() LEDGER = os.path.join(DIR, "ledger.jsonl") FIXTURE = os.environ.get("MILESTONE_ACCOUNT_FIXTURE", "") DRY = False THRESHOLD = 25.0 # 差超過幾 % 就一定要挑代號(票上寫死「四分之一」) # ── 封閉集合:七個代號,一個都不准多、不准少 ────────────────────────────── CODES = [ ("idle", "磨洋工", "小事拖成大事", "時間軸上有長長的空白"), ("sidetrack", "分心跳線", "講到別的就跑去做", "期間出現不屬於這條線的動作"), ("undispatched", "忘了派工", "該派沒派,票停著", "票很久沒有人被指派"), ("cherry-pick", "偏科", "挑喜歡的做,難的擱著", "完成順序跟優先順序倒過來"), ("waiting", "等答案", "問了就原地等,沒去做別的", "等待期間其他票零進度"), ("scope-creep", "範圍變大", "一直塞新東西進來", "補收的次數、票數成長曲線"), ("misestimate", "純粹估錯", "過程沒異常,就是估錯", "以上都沒有,但還是差很多"), ] CODE_IDS = [c[0] for c in CODES] LABEL = {c[0]: c[1] for c in CODES} def die(msg, code=2): print(msg, file=sys.stderr) sys.exit(code) def token(): root = os.environ.get("CLAUDE_PROJECT_DIR") or HERE host = HOST.split("//")[-1].rstrip("/") try: out = subprocess.run(["git", "-C", root, "remote", "-v"], capture_output=True, text=True, timeout=20).stdout except Exception: out = "" for line in out.splitlines(): if host in line: m = re.search(r"//[^:/]+:([^@]+)@", line) if m: return m.group(1) for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"): if os.environ.get(env): return os.environ[env] return "" _FX = None def fx(): global _FX if _FX is None: try: with open(FIXTURE) as f: _FX = json.load(f) except Exception: die("🔴 讀不到 MILESTONE_ACCOUNT_FIXTURE:%s" % FIXTURE) return _FX def api(path, payload=None, method=None): verb = method or ("POST" if payload is not None else "GET") if FIXTURE: if verb == "GET": return (fx().get("api") or {}).get(path) print(" [fixture] %s %s %s" % (verb, path, json.dumps(payload or {}))) return {} if DRY and verb != "GET": print(" [dry-run] %s %s %s" % (verb, path, json.dumps(payload or {}))) return {} tk = token() if not tk: die("🔴 拿不到 %s 的 token(remote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE)" % HOST) data = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request( HOST + "/api/v1" + path, data=data, method=verb, headers={"Authorization": "token " + tk, "Content-Type": "application/json"}) try: raw = urllib.request.urlopen(req, timeout=30).read() return json.loads(raw) if raw.strip() else {} except urllib.error.HTTPError as e: if e.code == 404: return None die("🔴 Gitea %d on %s %s:%s" % (e.code, verb, path, e.read().decode()[:200])) except Exception as e: die("🔴 連不上 %s:%s" % (HOST, e)) # ── 時間 ───────────────────────────────────────────────────────────────── _TS = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})" r"(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$") def ts(s): """Gitea 的時間字串 → epoch 秒。認得 Z 與 +08:00 兩種尾巴;認不得回 None。""" if not s: return None m = _TS.match(str(s).strip()) if not m: return None y, mo, d, h, mi, se, off = m.groups() t = time.mktime((int(y), int(mo), int(d), int(h), int(mi), int(se), 0, 1, 0)) \ - time.timezone if off and off not in ("Z", "z"): sign = 1 if off[0] == "+" else -1 off = off[1:].replace(":", "") t -= sign * (int(off[:2]) * 3600 + int(off[2:]) * 60) return t def now(): v = os.environ.get("MILESTONE_ACCOUNT_NOW") return ts(v) if v else time.time() def days(sec): return sec / 86400.0 def parse_ref(s): m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip()) if not m: die("🔴 寫法是 owner/repo#里程碑編號,你給的是:%s" % s) return m.group(1), m.group(2), int(m.group(3)) # ── 三個數字 ───────────────────────────────────────────────────────────── def numbers(ms): """回 (planned_days, actual_days, deviation_pct)。算不出來的格子回 None。""" c, due = ts(ms.get("created_at")), ts(ms.get("due_on")) end = ts(ms.get("closed_at")) or now() if c is None: return None, None, None actual = days(end - c) if due is None or due <= c: return None, actual, None planned = days(due - c) return planned, actual, (actual - planned) / planned * 100.0 def fmt(v, unit=""): return "—" if v is None else ("%.1f%s" % (v, unit)) def three_lines(ms, planned, actual, dev): return (" 當初訂的期限 %s天 (%s → %s)\n" " 實際花掉的  %s天 (到 %s)\n" " 差了     %s" % (fmt(planned), (ms.get("created_at") or "?")[:10], (ms.get("due_on") or "沒填期限")[:10], fmt(actual), (ms.get("closed_at") or "現在")[:10], "算不出來(期限沒填,或期限不晚於建立日)" if dev is None else "%+.0f%%%s" % (dev, " 🔴 超過 ±25%,要挑代號" if abs(dev) > THRESHOLD else ""))) # ── 從 Gitea 時間軸取證據 ──────────────────────────────────────────────── def fetch_issues(owner, repo): rows, page = [], 1 while page <= 5: r = api("/repos/%s/%s/issues?state=all&type=issues&limit=50&page=%d" % (owner, repo, page)) if not isinstance(r, list) or not r: break rows.extend(r) if len(r) < 50: break page += 1 return rows def fetch_comments(owner, repo, since): q = "?limit=100" if since: q += "&since=" + urllib.parse.quote(since) r = api("/repos/%s/%s/issues/comments%s" % (owner, repo, q)) return r if isinstance(r, list) else [] def prio(issue): """優先度:p/high=3、p/med=2、p/low=1、沒標=0。""" v = 0 for l in issue.get("labels") or []: n = (l.get("name") or "").lower() if n in ("p/high", "p/urgent", "p0", "p1"): v = max(v, 3) elif n in ("p/med", "p/medium", "p2"): v = max(v, 2) elif n in ("p/low", "p3"): v = max(v, 1) return v def evidence(owner, repo, ms): """把 Gitea 時間軸算成七個訊號。回 (signals, detail)。 🔴 這裡**一句話都不讀**:全部只看時間戳、指派欄位、標籤、票的歸屬。 票上寫死「不採信我自己說的」,所以判準不能碰任何自然語言。 """ start = ts(ms.get("created_at")) end = ts(ms.get("closed_at")) or now() span = max(end - start, 1.0) mid = ms.get("id") issues = fetch_issues(owner, repo) if issues is None: issues = [] mine = [i for i in issues if (i.get("milestone") or {}).get("id") == mid] others = [i for i in issues if (i.get("milestone") or {}).get("id") != mid] comments = fetch_comments(owner, repo, ms.get("created_at")) mine_ids = set(i.get("number") for i in mine) def cnum(c): u = c.get("issue_url") or c.get("html_url") or "" m = re.search(r"/(?:issues|pulls)/(\d+)", u) return int(m.group(1)) if m else c.get("issue_number") ev_mine, ev_other = [], [] for i in mine: for k in ("created_at", "closed_at"): t = ts(i.get(k)) if t and start <= t <= end: ev_mine.append(t) for c in comments: t = ts(c.get("created_at")) if not t or not (start <= t <= end): continue (ev_mine if cnum(c) in mine_ids else ev_other).append(t) for i in others: t = ts(i.get("created_at")) if t and start <= t <= end: ev_other.append(t) marks = sorted([start] + ev_mine + [end]) gap, gap_at = 0.0, None for a, b in zip(marks, marks[1:]): if b - a > gap: gap, gap_at = b - a, a sig, det = {}, {} # ① 磨洋工:時間軸上有長長的空白 sig["idle"] = days(gap) >= 2.0 and gap / span >= 0.35 det["idle"] = ("最長空白 %.1f 天(佔整段 %.0f%%),起點 %s" % (days(gap), gap / span * 100, time.strftime("%Y-%m-%d", time.gmtime(gap_at)) if gap_at else "?")) # ② 分心跳線:期間出現不屬於這條線的動作 tot = len(ev_mine) + len(ev_other) ratio = (len(ev_other) / tot) if tot else 0.0 sig["sidetrack"] = len(ev_other) >= 5 and ratio >= 0.30 det["sidetrack"] = ("這段期間 %d 個動作在這條線上、%d 個在別的票上(%.0f%%)" % (len(ev_mine), len(ev_other), ratio * 100)) # ③ 忘了派工:票很久沒有人被指派 naked = [i for i in mine if not (i.get("assignees") or i.get("assignee")) and days(end - (ts(i.get("created_at")) or end)) >= days(span) * 0.5] sig["undispatched"] = len(naked) > 0 det["undispatched"] = ("%d 張票整段期間沒有人被指派:%s" % (len(naked), ", ".join("#%s" % i.get("number") for i in naked[:6])) if naked else "每一張票都有人被指派過") # ④ 偏科:完成順序跟優先順序倒過來 inv = 0 closed = [i for i in mine if ts(i.get("closed_at"))] openv = [i for i in mine if not ts(i.get("closed_at"))] for a in closed: for b in closed: if prio(a) < prio(b) and ts(a["closed_at"]) < ts(b["closed_at"]): inv += 1 for a in closed: for b in openv: if prio(a) < prio(b): inv += 1 sig["cherry-pick"] = inv >= 2 det["cherry-pick"] = ("低優先先收掉、高優先還晾著的配對 %d 組" % inv) # ⑤ 等答案:等待期間其他票零進度 waitset = [i for i in mine if any((l.get("name") or "").lower() == "human" for l in (i.get("labels") or []))] wait_days = 0.0 for i in waitset: s = ts(i.get("created_at")) or start e = ts(i.get("closed_at")) or end wait_days = max(wait_days, days(e - s)) moved = sum(1 for i in mine if ts(i.get("closed_at")) and i not in waitset) sig["waiting"] = bool(waitset) and wait_days >= days(span) * 0.30 and moved == 0 det["waiting"] = ("掛 Human 的票 %d 張、最長等 %.1f 天;期間其他票關掉 %d 張" % (len(waitset), wait_days, moved)) # ⑥ 範圍變大:補收的次數、票數成長曲線 late = [i for i in mine if (ts(i.get("created_at")) or start) > start + span * 0.25] sig["scope-creep"] = len(late) >= 1 and len(mine) and len(late) / len(mine) >= 0.20 det["scope-creep"] = ("開跑之後才補進來的票 %d/%d 張:%s" % (len(late), len(mine), ", ".join("#%s" % i.get("number") for i in late[:6]))) # ⑦ 純粹估錯:以上都沒有 sig["misestimate"] = not any(sig[c] for c in CODE_IDS if c != "misestimate") det["misestimate"] = ("上面六個訊號%s成立" % ("一個都沒有" if sig["misestimate"] else "有")) return sig, det def get_milestone(owner, repo, num): ms = api("/repos/%s/%s/milestones/%d" % (owner, repo, num)) if not isinstance(ms, dict) or not ms.get("id"): die("🔴 抓不到里程碑 %s/%s#%d(編號對嗎?)" % (owner, repo, num)) return ms # ── 指令 ───────────────────────────────────────────────────────────────── def cmd_codes(argv): print("七個代號(封閉集合,inkstone/ISEP#85)——「能統計的前提是分類有限」\n") for cid, label, plain, how in CODES: print(" %-13s %-8s %-22s 從哪看出來:%s" % (cid, label, plain, how)) print("\n真的歸不進去 ⇒ 開一張票請 leo 核准加一個,**不要自己加**。") def _repos(argv): rs = [r for r in (argv[i + 1] for i, a in enumerate(argv) if a == "--repo" and i + 1 < len(argv))] if rs: return rs org = None for i, a in enumerate(argv): if a == "--org" and i + 1 < len(argv): org = argv[i + 1] if not org: die("用法:milestone-account list --org inkstone [--overdue] 或 --repo owner/repo") rows = api("/orgs/%s/repos?limit=100" % org) if not isinstance(rows, list): die("🔴 撈不到 %s 底下的 repo" % org) return [r["full_name"] for r in rows] def cmd_list(argv): overdue = "--overdue" in argv n = 0 for full in _repos(argv): owner, repo = full.split("/", 1) mss = api("/repos/%s/%s/milestones?state=open&limit=50" % (owner, repo)) if not isinstance(mss, list): continue for ms in mss: planned, actual, dev = numbers(ms) late = dev is not None and dev > 0 if overdue and not late: continue n += 1 print("%s %-34s %-30s 估 %s天/已 %s天/%s" % ("🔴" if (dev is not None and abs(dev) > THRESHOLD) else " ", "%s#%s" % (full, ms.get("id")), (ms.get("title") or "")[:28], fmt(planned), fmt(actual), "—" if dev is None else "%+.0f%%" % dev)) print("\n共 %d 個%s里程碑。🔴 = 差超過 ±25%%,結案時一定要挑一個代號。" % (n, "逾期的 " if overdue else "open ")) print("挑代號之前先跑:milestone-account audit ") def cmd_audit(argv): if not argv: die("用法:milestone-account audit ") owner, repo, num = parse_ref(argv[0]) ms = get_milestone(owner, repo, num) planned, actual, dev = numbers(ms) print("【%s/%s#%d】%s\n" % (owner, repo, num, ms.get("title") or "")) print(three_lines(ms, planned, actual, dev)) sig, det = evidence(owner, repo, ms) print("\n【時間軸怎麼說】(全部取自 Gitea 的時間戳/欄位,一句話都沒讀)") for cid, label, _plain, _how in CODES: print(" %s %-13s %-8s %s" % ("🔴" if sig[cid] else " ", cid, label, det[cid])) ok = [c for c in CODE_IDS if sig[c]] print("\n可以挑的代號:%s" % " / ".join(ok)) print("結案:milestone-account close %s/%s#%d --reason %s" % (owner, repo, num, ok[0])) def _ledger(): rows = [] try: with open(LEDGER) as f: for line in f: line = line.strip() if line: try: rows.append(json.loads(line)) except Exception: pass except Exception: pass return rows def cmd_close(argv): if not argv: die("用法:milestone-account close [--reason 代號] [--dry-run]") owner, repo, num = parse_ref(argv[0]) ref = "%s/%s#%d" % (owner, repo, num) reason = None for i, a in enumerate(argv): if a == "--reason" and i + 1 < len(argv): reason = argv[i + 1] ms = get_milestone(owner, repo, num) planned, actual, dev = numbers(ms) print("【%s】%s\n" % (ref, ms.get("title") or "")) print(three_lines(ms, planned, actual, dev)) sig, det = evidence(owner, repo, ms) legal = [c for c in CODE_IDS if sig[c]] need = dev is None or abs(dev) > THRESHOLD if need and not reason: die("\n🚫 **差 %s,超過 ±25%%——挑不出代號不准結案。**\n\n" "【inkstone/ISEP#85】「差超過四分之一(不管是超時還是提早太多,\n" " **提早太多代表當初估太鬆**)就要挑一個原因,而且**只能從固定的幾個裡面挑**,\n" " 不准用一段話帶過。」\n\n" "時間軸撐得住的代號:%s\n" "%s\n" " 再送一次:milestone-account close %s --reason %s\n" " 想看全部七個:milestone-account codes / 看證據:milestone-account audit %s" % ("算不出來(期限沒填)" if dev is None else "%+.0f%%" % dev, " / ".join(legal), "\n".join(" %-13s %s" % (c, det[c]) for c in legal), ref, legal[0], ref)) if reason: if reason not in CODE_IDS: die("\n🚫 `%s` 不是那七個代號之一。\n" " 【inkstone/ISEP#85】代號是**封閉集合**——「能統計的前提是分類有限」。\n" " 七個:%s\n" " 真的歸不進去 ⇒ 開票請 leo 核准加一個,不要自己造。" % (reason, " / ".join(CODE_IDS))) if reason not in legal: die("\n🚫 `%s`(%s)**時間軸撐不住**——不接受。\n\n" " 【inkstone/ISEP#85】「證據一律從 Gitea 的時間軸取" "(留言、指派、標籤、commit 都有時間戳),**不採信我自己說的**。」\n\n" " 這個代號的訊號:%s\n" " 撐得住的是:%s\n" " (訊號真的判錯了 ⇒ 那是 audit 的 bug,去修偵測器," "不是在這裡繞過去——這條線的意義就是「可以統計、可以迭代」。)" % (reason, LABEL[reason], det[reason], " / ".join(legal))) rec = {"ref": ref, "title": ms.get("title") or "", "created_at": ms.get("created_at"), "due_on": ms.get("due_on"), "closed_at": ms.get("closed_at") or time.strftime( "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now())), "planned_days": None if planned is None else round(planned, 2), "actual_days": None if actual is None else round(actual, 2), "deviation_pct": None if dev is None else round(dev, 1), "over_threshold": bool(need), "reason": reason, "reason_label": LABEL.get(reason or "", ""), "signals": {c: bool(sig[c]) for c in CODE_IDS}, "evidence": {c: det[c] for c in CODE_IDS}, "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now()))} if DRY: print("\n[dry-run] 會寫進 %s:" % LEDGER) print(json.dumps(rec, ensure_ascii=False, indent=2)[:900]) print(" [dry-run] PATCH /repos/%s/%s/milestones/%d {\"state\": \"closed\"}" % (owner, repo, num)) print("[dry-run] 一筆都沒有真的寫出去。") return # 🔴 順序:**先記帳、再關**。反過來的話,關成功而記帳失敗, # 那個里程碑就永遠不會出現在統計裡——而那正是這張票要解的病。 os.makedirs(DIR, exist_ok=True) with open(LEDGER, "a") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") print("\n✅ 已記帳:%s 估 %s天/花 %s天/%s%s" % (ref, fmt(planned), fmt(actual), "—" if dev is None else "%+.0f%%" % dev, " 原因=%s(%s)" % (reason, LABEL[reason]) if reason else "")) if (ms.get("state") or "") != "closed": api("/repos/%s/%s/milestones/%d" % (owner, repo, num), {"state": "closed"}, method="PATCH") print("✅ 里程碑已關閉") else: print("ℹ️ 里程碑本來就是 closed,只補記帳") write_report() print("📊 累計統計已更新:%s" % os.path.join(DIR, "REPORT.md")) def write_report(): rows = _ledger() cnt = {} for r in rows: if r.get("reason"): cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1 devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None] lines = ["# 估多久/花多久/差多少——累計統計", "", "> 這份檔案由 `scripts/milestone-account` 自動重寫,**不要手改**。", "> 【inkstone/ISEP#85】「每次事後只能用『這次比較慢』這種話帶過," "下次還是一樣。」", "", "- 累計結案 **%d** 個里程碑" % len(rows)] if devs: lines.append("\t- 平均偏差 **%+.0f%%**(正=超時,負=提早)" % (sum(devs) / len(devs))) lines.append("\t- 超過 ±25%% 的有 **%d** 個" % sum(1 for d in devs if abs(d) > THRESHOLD)) lines.append("- 哪一種原因最常發生(這才是迭代的依據)") if not cnt: lines.append("\t- 目前還沒有任何被挑過的代號。") for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]): lines.append("\t- **%s(%s)** ×%d" % (LABEL.get(cid, cid), cid, n)) lines.append("- 逐筆") for r in sorted(rows, key=lambda r: r.get("recorded_at") or "", reverse=True): lines.append("\t- `%s` %s" % (r["ref"], r.get("title", ""))) lines.append("\t\t- 估 %s 天/花 %s 天/差 %s" % (r.get("planned_days"), r.get("actual_days"), "—" if r.get("deviation_pct") is None else "%+.0f%%" % r["deviation_pct"])) if r.get("reason"): lines.append("\t\t- 原因:**%s(%s)**" % (r.get("reason_label"), r["reason"])) lines.append("\t\t- 時間軸證據:%s" % (r.get("evidence") or {}).get(r["reason"], "—")) try: os.makedirs(DIR, exist_ok=True) with open(os.path.join(DIR, "REPORT.md"), "w") as f: f.write("\n".join(lines) + "\n") except Exception as e: die("🔴 寫不進 %s:%s" % (DIR, e)) def cmd_report(argv): rows = _ledger() if not rows: print("(帳本還是空的:%s)" % LEDGER) print("關掉第一個里程碑就會有第一筆:milestone-account close ") return cnt = {} for r in rows: if r.get("reason"): cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1 devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None] print("累計結案 %d 個里程碑" % len(rows)) if devs: print("平均偏差 %+.0f%%(正=超時,負=提早);超過 ±25%% 的有 %d 個" % (sum(devs) / len(devs), sum(1 for d in devs if abs(d) > THRESHOLD))) print("\n哪一種原因最常發生:") if not cnt: print(" (還沒有任何被挑過的代號)") for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]): print(" %-13s %-8s ×%d %s" % (cid, LABEL.get(cid, cid), n, "█" * n)) write_report() print("\n人看的版本:%s" % os.path.join(DIR, "REPORT.md")) def main(): global DRY argv = sys.argv[1:] if "--dry-run" in argv: DRY = True argv = [a for a in argv if a != "--dry-run"] if not argv: print(__doc__) return cmd, rest = argv[0], argv[1:] table = {"codes": cmd_codes, "list": cmd_list, "audit": cmd_audit, "close": cmd_close, "report": cmd_report, # 給 hooks/milestone-account-guard.sh 問「帳本在哪」用的。 # 讓「怎麼找到帳本」只有一份答案,閘不必自己再實作一次解析。 "ledger-path": lambda a: print(LEDGER)} if cmd not in table: print(__doc__) die("🔴 沒有這個子指令:%s" % cmd) table[cmd](rest) if __name__ == "__main__": main()