Files
claude-code 45adc10a52 併進 main(v0.12.0)並接上「主線唯一答案」(inkstone/ISEP#83)
衝突只有 docs/TESTING.md 一處,兩邊都留:
  main 的 A16(gate-ok)/A17(未推警察)/A18(信標)原樣保留,
  我那節改編號成 A19——main 上 A16、A17 已經被 #90#99 各佔一次,不動別人的。

接上 inkstone/ISEP#82 帶進來的 hooks/lib/mainline.py(不自己養第二套判斷):
  · 有標主線 ⇒ 只排除「屬於主線那一條」的票;掛在**別條** milestone 的仍算債
    (那條線現在沒有人在推,正是它躺著的原因)
  · 沒標主線 ⇒ 退回保守做法:掛 open milestone 的一律不領,並在清單上照實說沒標
  · 別條 milestone 已逾期 ⇒ +15「答應過人家的日期已經過了」

測試 40 → 47 條(+7 條全給主線這段),並把 ISEP_COUNTDOWN_STATE_DIR 也隔離:
不然這支測試的結果會取決於「這台機器現在標了哪條主線」。

盤點數字在合併後的樹上重數:
  hooks/*.sh = 54、hooks.json "command": = 71(本 PR 沒新增 hook,兩個都沒變)
  scripts 一層檔案 = 39(本 PR +2)⇒ plugin.json description 37→39
  版本號沒動(0.12.0,總管統一定版)
2026-08-28 00:51:06 +00:00

560 lines
23 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""debt-worklist — 還債線:每天固定撈一次「今天可以領什麼」,整包整包地領。
leo 2026-08-27inkstone/ISEP#83 的來由,他當場訂正總管的誤解):
「這是兩件不同的事,你根本沒搞清楚,距離是在確定有個緊急事件要儘速達成時,
現在每天都在追新的,所以**要用 Routine 去消化舊的**,可以有多個條件」
⇒ **兩條線,各用各的判準**,不是二選一:
緊急線 有事要趕 → 用「距離目標的遠近」排(=掛在 open milestone 上的那些)
還債線 每天消化舊的 → 用多條件排(事故/擋人/承諾/票齡/估工) ← 本工具
病根:全 org 229 張 open 票,每天在動的只有掛在 active milestone 上的那些。
沒掛的不是不重要,是**沒有任何機制會去拿它們**。`worklist-guard.sh` 擋的是
「這一輪已經領到的工作沒做完就收工」——它不會去把舊票撈出來。本工具補的是供給面。
三個動詞:
debt-worklist list 撈一次,排好序印出來(預設動作)
debt-worklist claim <票...> 領走(今天領的,明天就不會再出現在清單上)
debt-worklist forget <票...> 放回去(領錯了、或那條線改方向了)
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
🔴 **不是輪詢。** 這支腳本自己不會醒——是人跑它、或 SessionStart 那一次順手跑。
不准掛 Gitea Actionswebhook fan-outCLAUDE.md 避免再被 flag 的硬規則,
`issue-handle` 明寫「有事才讀」,換成 Gitea 也不放寬)。
離線可測:設 `ISEP_DEBT_FIXTURE=<json 檔>` 就完全不打網路(測試用)。
狀態檔位置可用 `ISEP_DEBT_STATE_DIR` 改(測試用,預設 `~/.claude/isep-debt/`)。
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = os.environ.get("ISEP_DEBT_ORG") or "inkstone"
# ── 「現在的主線是哪一個」只有一份答案(inkstone/ISEP#82 → hooks/lib/mainline.py)──
# 本工具排除的是**緊急線正在管的那些**。在 #82 之前,「哪個是 active」沒有指涉對象
# 14 個 open milestone 同時亮著),所以第一版只能粗暴地「掛 open milestone 就排除」。
# 現在有唯一答案了 ⇒ 接上去,**不要自己養第二套判斷**(兩份必然漂移)。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
import mainline as ML
except Exception: # 拿不到共用零件就退回舊行為(fail-open),不讓還債線整個癱掉
ML = None
# 等 leo 的票不進「可領清單」——不是它們不重要,是**沒有人能替他領**:
# Humanhuman/exec 要他親手批示或動手
# s/stage 已經部到 stage,等他驗(session-start-recall 就是這樣定義的)
# 這些的正確動作是「催」不是「領」,而催辦是 inkstone/ISEP#93 的事。本票是供給面。
WAITING_LEO_LABELS = {"Human", "human/exec", "s/stage"}
# 已經有人在做/已經有 PR 的狀態:這是「答應過人家的」,債比沒開工的還硬。
STARTED_LABELS = {"s/doing", "s/review"}
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
# ── 連線(跟 scripts/ticket 同一套:先找帶憑證的 remote,再退環境變數)────────
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
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]
die(f"""🔴 拿不到 {host} 的 token
擇一:
• 讓某個 remote 帶憑證(多數 repo 的 gitea/origin 本來就有)
• 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""")
def api(path):
url = f"{HOST}/api/v1{path}"
req = urllib.request.Request(url, headers={"Authorization": f"token {token()}"})
try:
return json.load(urllib.request.urlopen(req, timeout=60))
except urllib.error.HTTPError as e:
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}\n path={path}")
except Exception as e:
die(f"🔴 打不到 {HOST}{e}\n path={path}")
# ── 收集:全 org 每個 repo 的 open issue open PR ──────────────────────────
def collect():
"""回 (repos, items)。repos 是「我查了哪些」的證明,items 是原始票。
fixture 模式(測試用):完全不打網路,資料從 json 檔讀。
"""
fx = os.environ.get("ISEP_DEBT_FIXTURE")
if fx:
with open(fx) as f:
data = json.load(f)
return data["repos"], data["items"]
repos, items = [], []
for r in api(f"/orgs/{ORG}/repos?limit=100") or []:
full = r["full_name"]
if not r.get("has_issues", True):
repos.append({"name": full, "issues": 0, "prs": 0, "skipped": "issues 關閉"})
continue
got, page = [], 1
while True:
q = urllib.parse.urlencode({"state": "open", "limit": 50, "page": page})
batch = api(f"/repos/{full}/issues?{q}") or []
got.extend(batch)
if len(batch) < 50:
break
page += 1
if page > 20: # 保險絲:不可能有一千張,有的話是我寫錯了
break
for it in got:
it.setdefault("repository", {"full_name": full})
n_pr = sum(1 for it in got if it.get("pull_request"))
repos.append({"name": full, "issues": len(got) - n_pr, "prs": n_pr})
items.extend(got)
return repos, items
# ── 工具 ───────────────────────────────────────────────────────────────────
def ref_of(it):
return f"{it['repository']['full_name']}#{it['number']}"
def labels_of(it):
return {l["name"] for l in (it.get("labels") or [])}
def is_pr(it):
return bool(it.get("pull_request"))
def parse_ts(s):
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
def now():
"""時鐘可以被定住——測試要能重現,不能今天跑綠明天跑紅。"""
inj = os.environ.get("ISEP_DEBT_NOW")
if inj:
d = parse_ts(inj)
if d:
return d
return datetime.now(timezone.utc)
def days_since(s):
d = parse_ts(s)
if not d:
return 0.0
return max(0.0, (now() - d).total_seconds() / 86400)
REF_RE = re.compile(r"(?:([\w.-]+)/([\w.-]+))?#(\d+)")
def refs_in(it):
"""從標題+內文抓出它指到的別張票。裸 `#12` 算同 repo。
只認得出「文字上指了誰」——這是零成本的訊號(內文本來就在清單裡)。
Gitea 原生的 dependencies 要一張票打一次 API229 張=229 次,不值得。
"""
owner, repo = it["repository"]["full_name"].split("/")
out = set()
text = f"{it.get('title') or ''}\n{it.get('body') or ''}"
for o, r, n in REF_RE.findall(text):
out.add(f"{o or owner}/{r or repo}#{n}")
out.discard(ref_of(it))
return out
# ── 排序:多個條件,每一條都看得見自己貢獻了幾分 ─────────────────────────────
#
# leo 給的順序(票上原文):出事的 → 擋住別人的 → 答應過人家的 → 放最久的 → 最快做完的。
# 分數只是把這個順序寫成可重現的東西;**每一項都會印在票旁邊**,
# 排出來覺得不對可以直接指著說「這一項給太多」,而不是猜它為什麼在第一名。
def score(it, in_degree):
lab = labels_of(it)
parts = []
if "type/bug" in lab:
parts.append(("事故", 40))
if "p/high" in lab:
parts.append(("高優先", 20))
if "p/low" in lab:
parts.append(("低優先", -12))
blocked = in_degree.get(ref_of(it), 0)
if blocked:
parts.append((f"擋住{blocked}張", min(12 * blocked, 36)))
if is_pr(it):
# 躺著沒結論的 PR 擋的是 merge 那條路(inkstone/ISEP#81 是它的另一半)
parts.append(("PR躺著", 25))
started = lab & STARTED_LABELS
if started:
parts.append((f"已開工({'/'.join(sorted(started))})", 18))
if it.get("assignees") or it.get("assignee"):
parts.append(("有人接了", 12))
ms = it.get("milestone") or {}
if ms and (ms.get("state") or "open") == "open" and ML is not None:
due = ML.due_of(ms)
if due and due < now():
# 掛在**別條**(非主線)而且期限已經過去的 milestone ⇒ 這是承諾過的日期,
# 而那個日期已經過了。它之所以還在這份清單上,正是因為沒有人在管那條線。
parts.append((f"逾期{int((now() - due).total_seconds() // 86400)}天的承諾", 15))
age = days_since(it.get("updated_at"))
if age >= 1:
parts.append((f"擱置{int(age)}天", round(min(age * 0.6, 30), 1)))
body_len = len(it.get("body") or "")
if "type/chore" in lab or body_len < 800:
parts.append(("看起來小", 10))
total = round(sum(p[1] for p in parts), 1)
return total, parts
def pick(items, claimed):
"""把原始票分成三堆:可領的/緊急線的/要 leo 動手的。並回統計。"""
open_refs = {ref_of(it) for it in items}
in_degree = {}
for it in items:
for target in refs_in(it):
if target in open_refs:
in_degree[target] = in_degree.get(target, 0) + 1
ml = ML.load() if ML is not None else None
pool, stats = [], {"milestone": 0, "human": 0, "claimed": 0, "total": len(items),
"other_ms": 0, "mainline": ml}
for it in items:
ref = ref_of(it)
ms = it.get("milestone")
if ms and (ms.get("state") or "open") == "open":
if ml is None:
# 現在沒有主線 ⇒ 分不出哪一條是緊急線在管的。退回保守做法:
# 掛 open milestone 的一律不領(寧可少領,不要跟緊急線搶同一張)。
stats["milestone"] += 1
continue
if ML.belongs(ref, ml, ms.get("title")):
stats["milestone"] += 1 # 這張真的在主線上 ⇒ 緊急線在管,不重複領
continue
# 掛在**別條** milestone 上:那條線現在沒有人在推 ⇒ 它就是債
stats["other_ms"] += 1
if labels_of(it) & WAITING_LEO_LABELS:
stats["human"] += 1 # 沒有人能替 leo 領(催辦是 ISEP#93
continue
if ref in claimed:
stats["claimed"] += 1 # 昨天領走的,今天不再出現
continue
s, parts = score(it, in_degree)
pool.append({"ref": ref, "title": it.get("title") or "", "score": s,
"why": parts, "labels": sorted(labels_of(it)),
"pr": is_pr(it), "url": it.get("html_url"),
"age": round(days_since(it.get("updated_at")), 1),
"refs": sorted(refs_in(it) & open_refs)})
pool.sort(key=lambda d: (-d["score"], d["ref"]))
return pool, stats, in_degree
# ── 成包:互相指著的必然同包(票上原文「相關的票要成包一起領,不要拆散」)──────
#
# 🔴 **只認強訊號,寧可少成包**。第一版用「單向文字引用」成包,結果是
# **前 190 張裡有 121 張黏成一包**——因為大家都會順手引用 hub 票。
# 一包 121 張=沒有人領得動=跟沒分包一樣。所以只認這兩種:
# ① 互相指著(A 的內文提 B,且 B 的內文提 A)——單向不算
# ② Gitea 原生相依(`ticket subtask` 建的母子票就是這個)
# 代價:同一條使用者旅程但沒有互指、也沒建相依的兩張票不會成包。
# 這是誠實的取捨——**寧可漏成包,不要成一包沒人領得動的**。
BUNDLE_CAP = 8 # 一包最多印幾張;超過就說「還有幾張」
DEPS_TOP_K = 25 # 只對前 K 名查原生相依(一張一次 API,不對全部查)
def dep_edges(pool, enabled=True):
"""回 [(a, b)…]:前 K 名的原生相依邊,只留兩端都在 pool 裡的。"""
fx = os.environ.get("ISEP_DEBT_FIXTURE")
if fx:
with open(fx) as f:
deps = (json.load(f).get("deps") or {})
in_pool = {d["ref"] for d in pool}
return [(a, b) for a, bs in deps.items() for b in bs
if a in in_pool and b in in_pool]
if not enabled:
return []
in_pool = {d["ref"] for d in pool}
edges = []
for d in pool[:DEPS_TOP_K]:
owner, rest = d["ref"].split("/", 1)
repo, num = rest.split("#")
try:
got = api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
except SystemExit:
continue
for i in got:
other = f"{i['repository']['full_name']}#{i['number']}"
if other in in_pool:
edges.append((d["ref"], other))
return edges
def mutual_edges(pool):
idx = {d["ref"]: set(d["refs"]) for d in pool}
out = []
for ref, targets in idx.items():
for t in targets:
if t in idx and ref in idx[t] and ref < t:
out.append((ref, t))
return out
def bundle(pool, edges):
idx = {d["ref"]: d for d in pool}
parent = {r: r for r in idx}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
for a, b in edges:
if a in idx and b in idx:
union(a, b)
groups = {}
for d in pool:
groups.setdefault(find(d["ref"]), []).append(d)
out = []
for members in groups.values():
members.sort(key=lambda d: (-d["score"], d["ref"]))
out.append({"lead": members[0], "members": members,
"score": members[0]["score"]})
out.sort(key=lambda b: (-b["score"], b["lead"]["ref"]))
return out
# ── 狀態:領走了什麼(明天的清單要看得出昨天領走的不見了)───────────────────
def state_dir():
d = os.environ.get("ISEP_DEBT_STATE_DIR") or os.path.expanduser("~/.claude/isep-debt")
os.makedirs(d, exist_ok=True)
return d
def state_path():
return os.path.join(state_dir(), "claimed.json")
def load_state():
try:
with open(state_path()) as f:
d = json.load(f)
except Exception:
d = {}
d.setdefault("claims", {})
d.setdefault("last_run", None)
return d
def save_state(d):
with open(state_path(), "w") as f:
json.dump(d, f, ensure_ascii=False, indent=1)
# ── 印 ─────────────────────────────────────────────────────────────────────
def print_proof(repos, stats):
scanned = [r for r in repos if not r.get("skipped")]
n_iss = sum(r["issues"] for r in scanned)
n_pr = sum(r["prs"] for r in scanned)
print(f"🔎 查過的 repo{len(repos)} 個({ORG} 全組織)"
f" open issue {n_iss}、open PR {n_pr}")
for r in sorted(repos, key=lambda x: -(x["issues"] + x["prs"])):
mark = f"(跳過:{r['skipped']}" if r.get("skipped") else ""
print(f" · {r['name']} issue {r['issues']}PR {r['prs']} {mark}")
ml = stats.get("mainline")
if ml:
print(f"\n🎯 現在的主線:{ML.ref_of(ml)}{ml.get('title')}」"
f"(唯一答案來自 scripts/mainline,本工具不自己判)")
print(f" 掛在**別條** milestone 上的 {stats['other_ms']} 張仍算債"
"——那條線現在沒有人在推")
else:
print("\n🎯 現在沒有標主線(`scripts/mainline set …`"
"⇒ 分不出哪條是緊急線在管的,保守起見:掛 open milestone 的一律不領")
print(f"\n 扣掉:掛在 open milestone 上的 {stats['milestone']} 張(那些歸緊急線管)"
f"/等 leo 的 {stats['human']} 張"
f"Human・human/exec・s/stage——那些要催不是要領,見 inkstone/ISEP#93"
f"/已經領走的 {stats['claimed']} 張")
def print_carry(state, items):
"""昨天領走的,今天怎麼了——**這一段就是「看得出領走的不在清單上」**。"""
claims = state.get("claims") or {}
if not claims:
return
open_refs = {ref_of(it) for it in items}
done = [r for r in claims if r not in open_refs]
doing = [r for r in claims if r in open_refs]
print(f"\n📦 之前領走的 {len(claims)} 張:已關 {len(done)}、還開著 {len(doing)}")
for r in sorted(doing):
c = claims[r]
print(f" ◻ {r} {c.get('at', '?')[:10]} 領的){c.get('title', '')[:40]}")
for r in sorted(done):
print(f" ✅ {r} 已關")
if doing:
print(" 🔴 還開著的沒有回到可領清單裡——做完就關票,或 "
"`debt-worklist forget <票>` 把它放回去。")
def print_bundle(i, b, verbose):
lead = b["lead"]
kind = "PR" if lead["pr"] else "票"
print(f"\n {i}. [{lead['score']}] {lead['ref']}{kind}")
print(f" {lead['title'][:72]}")
print(" 為什麼排這裡:" + "、".join(f"{n}{v:+g}" for n, v in lead["why"]))
if lead["labels"]:
print(f" 標籤:{lead['labels']}")
others = b["members"][1:]
if others:
print(f" ↳ 同一包(互相指著/原生相依,要一起領)共 {len(b['members'])} 張:")
shown = others if verbose else others[:BUNDLE_CAP]
for m in shown:
print(f" · [{m['score']}] {m['ref']} {m['title'][:56]}")
if len(others) > len(shown):
print(f" · …另外 {len(others) - len(shown)} 張(`--all` 看全部)")
def cmd_list(argv):
top = int(argv[argv.index("--top") + 1]) if "--top" in argv else 6
verbose = "--all" in argv
as_json = "--json" in argv
repos, items = collect()
state = load_state()
pool, stats, _ = pick(items, set(state.get("claims") or {}))
edges = mutual_edges(pool) + dep_edges(pool, enabled="--no-deps" not in argv)
bundles = bundle(pool, edges)
if as_json:
print(json.dumps({
"at": now().isoformat(), "repos": repos, "stats": stats,
"claimed": sorted(state.get("claims") or {}),
"bundles": [{"score": b["score"],
"members": [{"ref": m["ref"], "score": m["score"],
"title": m["title"], "why": m["why"]}
for m in b["members"]]} for b in bundles],
}, ensure_ascii=False, indent=1))
else:
print(f"🧾 還債清單 {now().strftime('%Y-%m-%d %H:%M UTC')}\n")
print_proof(repos, stats)
print_carry(state, items)
print(f"\n── 今天可以領的:{len(pool)} 張,收斂成 {len(bundles)} 包 "
f"────────────────────")
if not pool:
# 🔴 空清單要說話。默默結束=人分不出「查過沒東西」跟「根本沒查」。
print("\n ✅ **查詢跑過了,結果是零。**"
f"\n {len(repos)} 個 repo 都掃過(見上面那份清單),"
"沒有一張是「沒掛 milestone、沒等 leo、還沒被領走」的。"
"\n 這代表舊債清乾淨了——不是查詢壞掉。")
for i, b in enumerate(bundles[:len(bundles) if verbose else top], 1):
print_bundle(i, b, verbose)
if not verbose and len(bundles) > top:
print(f"\n …另外 {len(bundles) - top} 包(`--top N` 或 `--all` 看更多)")
if pool:
first = " ".join(m["ref"] for m in bundles[0]["members"][:BUNDLE_CAP])
print(f"""
── 領走它 ──────────────────────────────────────────────────
debt-worklist claim {first}
領走 = 今天要做它。領了之後它就不會再出現在這份清單上,
直到你把票關掉、或 `debt-worklist forget` 把它放回去。
🔴 **整包一起領**:同一包是互相指著的,拆開做會做到一半發現卡在另一張。""")
state["last_run"] = now().isoformat()
save_state(state)
return 0
def _titles(items):
return {ref_of(it): (it.get("title") or "") for it in items}
def cmd_claim(argv):
refs = [a for a in argv if not a.startswith("-")]
if not refs:
die("用法:debt-worklist claim <owner/repo#N> [更多…]")
for r in refs:
if not re.match(r"^[\w.-]+/[\w.-]+#\d+$", r):
die(f"🔴 票的寫法是 owner/repo#N,你給的是:{r}")
_, items = collect()
titles = _titles(items)
unknown = [r for r in refs if r not in titles]
if unknown:
die("🔴 這幾張不在 open 清單裡(打錯了?已經關了?):" + "、".join(unknown))
state = load_state()
for r in refs:
state["claims"][r] = {"at": now().isoformat(), "title": titles[r]}
save_state(state)
print(f"📦 領走 {len(refs)} 張,明天的清單不會再出現它們:")
for r in refs:
print(f" · {r} {titles[r][:60]}")
print("\n🔴 領了就是今天要做它。做完關票;改方向就 `debt-worklist forget`。")
return 0
def cmd_forget(argv):
refs = [a for a in argv if not a.startswith("-")]
if not refs:
die("用法:debt-worklist forget <owner/repo#N> [更多…]")
state = load_state()
gone = [r for r in refs if state["claims"].pop(r, None) is not None]
save_state(state)
print(f"↩️ 放回清單 {len(gone)} 張:" + ("、".join(gone) or "(本來就沒領過)"))
return 0
CMDS = {"list": cmd_list, "claim": cmd_claim, "forget": cmd_forget}
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(cmd_list([]))
if sys.argv[1] in ("-h", "--help", "help"):
print(__doc__)
sys.exit(0)
if sys.argv[1] not in CMDS:
print(__doc__)
sys.exit(2)
sys.exit(CMDS[sys.argv[1]](sys.argv[2:]))