diff --git a/scripts/milestone-account b/scripts/milestone-account new file mode 100644 index 0000000..27cdc4c --- /dev/null +++ b/scripts/milestone-account @@ -0,0 +1,634 @@ +#!/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()