Files
ISEP/scripts/ticket
T
Leo 2bbcda2ac3 雲端工人會分診、會組里程碑,組了就照里程碑做(inkstone/ISEP#159)
leo 09-18:「它看到票時要能夠組成 milestone 提出版本,所以不只是看票,還要組票。」
雲端只會撿(ISEP#130),沒有任何機制分診或組里程碑;open 302 張裡 74 張沒 s/*、257 張沒掛里程碑。

- ticket triage:沒 s/* 的票 → s/backlog/s/triage/s/pending+票上一則說明(--why 必填),一個動作;
  --dup-of:說明+close/duplicate+關+在留下的那張留一則指回來(留下的那張若是 leo 的就不寫)
- ticket compose:把已分診的票組成 InkStoneCo 里程碑(開工規約 1–7 條):既有載體+前置相依、
  s/backlog → s/todo、名字不准是版本號、期限 1–60 天、不開新票;先全部讀過才寫,理由一次列齊
- 不碰:Human/human/*、指派給別人(含 Leo)——判準是標籤與 assignee 登入名
- pick --pool:還沒到期的 InkStoneCo 里程碑的前置排在散票之前;可做的都抓盡時回待分診的票,
  每列帶 next;queue-drain-guard 照 next 印(待分診是 triage,不是 claim)
- 全局票池只有一個讀法 pool_issues(),接口留給 ISEP#154(global_index 寫卡驗完後只換這一個函式)
- InkStoneCo 掛在 open 里程碑的票=載體(容器):不進票池、不算待分診

假設(寫在這裡等總管 review):
- 分診只收「沒有 s/*」的票;s/triage 仍是總管/leo 的判斷,工人只能把拿不準的丟進 s/triage
- 載體撈不到時,工人用 ticket where+ticket new 補一張 InkStoneCo User Story 當載體(開工規約第 2 條的「真缺口」)
- 期限上限 60 天(9999 與過去日期都擋)

實測:test-ticket-triage 80/80、test-ticket-pick 84/84、queue-drain-guard 28/28;
真 Gitea 代跑一趟:分診 4 張、建 InkStoneCo milestone 68(載體 InkStoneCo#136,前置 ISEP#47、arcrun-rag#199),
pick --pool 第一組即為它的前置;一次違規三格的 compose 真的零寫入擋下。
待總管定版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:43:07 +08:00

2200 lines
117 KiB
Python
Executable File
Raw 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
"""ticket — 讓 Gitea 變成「可追蹤的線」,不是「越積越大的池子」。
leo 2026-08-16 三句話,本工具就是它們的機械化:
「不要每個開新票,現有的票開在它下面的對話裡」
「我希望你把 gitea 變成可以追蹤,不是變成一個池子」
「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
當天實錯(本工具的來由):總管要派人查一個部署擋路石,**沒有搜尋就直接開新票**
arcrun-rag#110),而那條線早就有 hubInkStoneCo#44)。多開一張票 = 池子加大 =
那條線串不起來。⇒ 所以「搜過了」不是 SOP 第一條,是 `new` 的**前置條件**。
四個動詞,各有一道閘:
ticket where <關鍵字...> 搜「這件事該放哪」→ 產生戳記
ticket say <票> -F <檔> 貼進既有票的對話(**預設路徑**)
ticket new <repo> -F <檔> 開新票(要戳記+模板欄位齊全;<repo> 寫 ISEP 或 inkstone/ISEP 都行)
ticket close <票> --deliverable <URL> 關票(要有交付物連結)
ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作)
三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒):
ticket subtask <母票> --title <US> -F <檔> 討論串裡的一件事 → 看得見的子票+相依
ticket handoff <頂層票> --to <repo> … 同一個動作,換個名字:頂層 → 下游 repo
ticket handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作
ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼
ticket loose 撈一次:下游都關了、自己還開著的頂層票
ticket usage <動詞> [--example] 用法的唯一真相源——**閘印的那一行從這裡來**
雲端工人抓票(inkstone/ISEP#131,規則機械可判,不靠 prompt 叮嚀):
ticket pick [--mainline <主線檔>] [--all] [--json] [--pool]
回一張現在可以抓的票(主線 → 逾期里程碑 → --pool 時 open 票池)
ticket claim <票> [--as <登入名>] [--name <身份>] 認領=指派+s/doing+【身份】留言,一個動作
ticket pick --claim … 上面兩件連著做
雲端工人分診+組里程碑(inkstone/ISEP#159:不只撿票,還要組票):
ticket triage --list [--all] [--json] 還沒有 s/* 的票(--all 連已分診的一起列,找重複用)
ticket triage <票> --to <s/backlog|s/triage|s/pending> --why <一句> 給狀態+票上一則說明,一個動作
ticket triage <票> --dup-of <留下的票> --why <一句> 重複:說明+close/duplicate+關+指回去
ticket compose --title … --goal … --due YYYY-MM-DD --carrier inkstone/InkStoneCo#N <前置…>
組一個 InkStoneCo 里程碑(先全部驗過才寫,不開新票)
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
"""
import json
import os
import re
import stat
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
# ── 身份欄:與 hooks/lib/dispatch_parse.py 共用同一份定義 ──────────────────
# leo 2026-08-27:「**subagent 回覆時要表明身份**」
# 實害(同日):多條線並行,票上的留言看不出是誰寫的,
# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
# 規約與側門閘見 docs/governance/dispatch-and-reply-format.md §3。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
from dispatch_parse import parse_identity, IDENTITY_ROLES
except Exception: # 拿不到共用零件就不擋(fail-open)——開票的路不該被一個 import 卡死
parse_identity, IDENTITY_ROLES = None, ("總管", "subagent", "leo")
def check_identity(body, what):
"""貼進票的內文第一行要表明身份。**在打任何 API 之前就擋**,所以離線測得動。"""
if parse_identity is None:
return
ok, detail = parse_identity(body)
if ok:
return
die(f"""🚫 {what}的第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」)
現在的問題:{detail}
第一行照這個寫(角色三選一:{''.join(IDENTITY_ROLES)}):
【身份】subagentinkstone/ISEPfeat/my-branch
【身份】總管/inkstone/InkStoneCo-
**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標。
實害(2026-08-27):多條線並行時票上看不出誰寫的,
總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。
規約全文:docs/governance/dispatch-and-reply-format.md §3""")
# TICKET_HOST 只給測試用(指到一個連不上的位址,就能在不真的開票的前提下
# 驗「閘放行了沒」——閘擋下=離開碼 2 且有 🚫;閘放行=走到網路那一層才炸)。
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = "inkstone"
STAMP_DIR = "/tmp"
STAMP_TTL = 30 * 60 # 戳記 30 分鐘失效——搜過就要趁記憶還熱的時候開
# 模板必填欄位。糊弄的票開不出來;開出來的票就是能用的 spec。
REQUIRED_SECTIONS = ["## 目標", "## 驗收條件", "## deliverable 類型"]
VALID_KINDS = ["code", "research"]
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
# 掃這個 repo 的**所有** remote,找第一個指向本站、且帶憑證的。
# 原本寫死只認名叫 "gitea" 的 remote —— 2026-08-20 實撞:
# ISEP 這個新 repo 的 remote 叫 origin,於是這支腳本在那裡整個跑不起來,
# 「開票前先搜」那道閘在新 repo 等於不存在。閘不該綁在某個 remote 的名字上。
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 not in line:
continue
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
# 退而求其次:環境變數(雲端/CI 沒有帶憑證的 remote 時走這條)
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
v = os.environ.get(env)
if v:
return v
die(f"""🔴 拿不到 {host} 的 token
這個 repo 的 remote 裡沒有一個帶憑證且指向 {host}
{out.strip() or "(沒有任何 remote"}
擇一:
• 讓某個 remote 帶憑證(多數 repo 的 gitea/origin 本來就有)
• 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""")
_TOLERATE = () # 這一次呼叫可以吞掉的 HTTP 狀態碼(見 add_dependency
_TOLERATED = object() # api() 吞掉時回的哨兵——不是 None,None 是「回應是 null」
def api(path, payload=None, method=None):
"""打一次 Gitea。**只有讀(GET)遇到連線層失敗才重試**inkstone/ISEP#33)。
2026-09-13 實撞:同一分鐘 curl 打 Gitea 回 200,這支的 urllib 卻兩次
`_ssl.c:1063: The handshake operation timed out` ⇒ `pick` 離開碼 2
⇒ 訊息叫工人「先修連線,不要空轉」⇒ 在雲端那就是**這趟 run 收工**。
一次握手逾時不該等於一整天停工。寫入不重試:POST 留言重送會變成兩則。
"""
import time
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None
verb = method or ("POST" if data else "GET")
try:
retries = max(0, int(os.environ.get("ISEP_API_RETRIES", "3")))
except ValueError:
retries = 3
tries = 1 + (retries if verb == "GET" else 0)
for n in range(tries):
req = urllib.request.Request(
url, data=data, method=verb,
headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
try:
return json.load(urllib.request.urlopen(req, timeout=40))
except urllib.error.HTTPError as e:
if e.code in _TOLERATE:
return _TOLERATED
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}")
except (urllib.error.URLError, OSError):
if n + 1 >= tries:
raise
time.sleep(float(os.environ.get("ISEP_API_BACKOFF", "2")) * (n + 1))
def parse_ref(s):
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
if not m:
die(f"🔴 票的寫法是 owner/repo#N,你給的是:{s}")
return m.group(1), m.group(2), int(m.group(3))
def repo_arg(s):
"""收件 repo 的參數:`ISEP` 與 `inkstone/ISEP` 都收,回 repo 名(不含 org)。
🔴 2026-09-07 實撞(inkstone/ISEP#130):總管照票號的習慣寫 `ticket new inkstone/ISEP`
這支把它原樣接進 `/repos/inkstone/{repo}/issues` ⇒ 打到 `/repos/inkstone/inkstone/ISEP/issues`
⇒ Gitea 回 **404**,而 404 讀起來像「ISEP 這個 repo 不存在」——
於是四張票改成直接打 API 開的,**正門壞了人就走側門**,那正是 `ticket-api-bypass-guard`
在防的事。票號全稱寫 `owner/repo#N` 是本 repo 的鐵律(CLAUDE.md),
所以 `owner/repo` 這個形狀本來就該被接受,不該是 404 的來源。
org 寫成別的(`Leo/ISEP`)⇒ 當場講清楚,不要讓它變成一個 404 去猜。
"""
s = (s or "").strip()
if "/" not in s:
return s
owner, _, name = s.partition("/")
if owner != ORG:
die(f"🔴 收件 repo 的 org 是 `{ORG}`,你給的是 `{owner}`{s})。\n"
f" `Leo/` 那個 org 2026-08-14 已廢;寫 `{ORG}/{name}` 或直接寫 `{name}`。")
if not name or "/" in name or "#" in name:
die(f"🔴 收件 repo 的寫法是 `<repo>` 或 `{ORG}/<repo>`(不帶票號),你給的是:{s}")
return name
def api_soft(path):
"""讀一筆,失敗就回 None(不 die)。給「錦上添花」的資訊用,不影響任何判斷。"""
try:
url = f"{HOST}/api/v1{path}"
req = urllib.request.Request(
url, headers={"Authorization": f"token {token()}"})
return json.load(urllib.request.urlopen(req, timeout=20))
except SystemExit:
raise
except Exception:
return None
def stamp_path():
return os.path.join(STAMP_DIR, ".ticket-where-ok")
# ── 「跑過」與「看過」的分界(inkstone/ISEP#72 → comment 4873)────────────
#
# 2026-08-27 實錯(本段的來由,leo 當場點破):
# python3 scripts/ticket where 卡片 沒上雲 收檔 佇列 送達 ingest >/dev/null 2>&1
# python3 scripts/ticket new arcrun-rag -F … --title "…" → ✅ #147 已開
# 而重跑同一個搜尋、這次看輸出:**第一名就是 arcrun-rag#104**,同一件事,開了 13 天。
#
# ⇒ 舊戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。
# 而「有沒有看」在 stdout 那一端——閘本來完全碰不到。
#
# 這支函式就是把那一端變成**機械事實**:一個 fd 是不是 /dev/null
# 是 fstat 問得出來的,不是對文字的猜測(本 repo 心法第 1 條:封動作不封文字)。
#
# 🔴 刻意只認 /dev/null 這一種,不擴大到「重導到檔案」「接到 pipe」:
# 寫進檔案還讀得回來、接進 pipe 還有下游,只有 /dev/null 是**物理上找不回來**。
# 多認一種都會開始誤攔(心法第 2 條:永遠在響的警報等於沒有警報)。
def fd_is_devnull(fd):
try:
s = os.fstat(fd)
if not stat.S_ISCHR(s.st_mode):
return False # 一般檔案/pipe/socket:內容至少送到了讀得回來的地方
return s.st_rdev == os.stat(os.devnull).st_rdev
except Exception:
return True # 問不出來就當「沒被看見」——閘不准因為內部錯誤靜默放行
def excerpt(body, n=220):
t = re.sub(r"\s+", " ", body or "").strip()
return (t[:n] + "…") if len(t) > n else (t or "(沒有內文)")
def candidate_lines(detail):
"""把候選票印成同一個樣子——`where` 印它,`new` 擋下來時也印它(同一份東西)。"""
out = []
for d in detail:
out.append(f" [{d.get('score', '?')}] {d['ref']} {d.get('labels', [])}")
out.append(f" {(d.get('title') or '')[:70]}")
if d.get("excerpt"):
out.append(f" ↳ {d['excerpt'][:170]}")
return out
# ── where ────────────────────────────────────────────────────────────────
def cmd_where(argv):
if not argv:
die("用法:ticket where <關鍵字...>\n(用幾個真的會出現在票裡的詞,中英文都行)")
kws = argv
seen, hits = {}, []
for kw in kws:
q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 30})
for it in api(f"/repos/issues/search?{q}") or []:
ref = it["repository"]["full_name"] + "#" + str(it["number"])
if ref in seen:
seen[ref]["score"] += 1
continue
seen[ref] = {"score": 1, "it": it}
hits = sorted(seen.values(), key=lambda x: -x["score"])
detail = []
for h in hits[:12]:
it = h["it"]
detail.append({"ref": it["repository"]["full_name"] + "#" + str(it["number"]),
"score": h["score"], "title": it["title"] or "",
"labels": [l["name"] for l in it.get("labels", [])]})
# 前 3 名補內文摘要。2026-08-27 的實害正是「光看標題看不出是同一條線」:
# arcrun-rag#104 的標題是「它把我整個 repo 的一萬多個檔案排進佇列」,
# 而它票頭第一段講的就是「這些庫都早就萃好了…直接 ingest」——同一件事。
# 拿不到就安靜跳過(純加分,不影響任何判斷)。
for d in detail[:3]:
o, r, n = parse_ref(d["ref"])
got = api_soft(f"/repos/{o}/{r}/issues/{n}")
if got:
d["excerpt"] = excerpt(got.get("body"))
print(f"🔍 搜尋:{' '.join(kws)} → 命中 {len(hits)} 張 open 票\n")
if not hits:
print(" (沒有命中——換幾個講法再試一次。真的沒有,才輪到開新票)")
for line in candidate_lines(detail):
print(line)
# 🔴 這行是本閘的全部:輸出有沒有可能到得了人眼前,是 fstat 問得出來的事實。
shown = not fd_is_devnull(1)
with open(stamp_path(), "w") as f:
json.dump({"at": time.time(), "kws": kws, "n": len(hits), "shown": shown,
"top": [d["ref"] for d in detail], "top_detail": detail}, f)
if not shown:
# stdout 被丟進 /dev/null。訊息改走 stderr(它常常還活著);
# 就算兩邊都被丟掉也沒關係——戳記已經記下 shown=false`new` 那端會擋。
print("\n⚠️ 這次搜尋的輸出被丟進 /dev/null,等於沒有人看過。"
"\n `ticket new` 會擋下來並把上面這份清單再交到你眼前一次。", file=sys.stderr)
print(f"""
── 決定要做什麼 ───────────────────────────────────────────────
命中了、而且是同一條線 → **貼進那張票的對話**(預設,也是 leo 要的)
ticket say <owner/repo#N> -F <內文檔>
真的是新的一條線 → ticket new <repo> -F <內文檔>
(模板要有:## 目標 / ## 驗收條件 / ## deliverable 類型)
🔴 判準不是「這件事夠不夠大」,是「**它跟現有的哪條線是同一條**」。
同一條線就進對話——多開一張票只會讓池子變大、線串不起來。
戳記已寫({STAMP_TTL // 60} 分鐘有效)。""")
# ── say ──────────────────────────────────────────────────────────────────
def cmd_say(argv):
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket say <owner/repo#N> -F <內文檔>")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
check_identity(body, "貼進票的留言")
c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
print(f"✅ 已貼進 {owner}/{repo}#{num}")
print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}")
print(f" {c['html_url']}")
print(f"\n📌 派工時把上面那行「定址」整串寫進【工單】,那條線才接得起來。")
# ── new ──────────────────────────────────────────────────────────────────
def cmd_new(argv):
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket new <repo 或 owner/repo> -F <內文檔> [--title <標題>]")
repo = repo_arg(argv[0])
body = open(argv[2]).read()
title = None
if "--title" in argv:
title = argv[argv.index("--title") + 1]
# 閘一:搜過了沒
try:
st = json.load(open(stamp_path()))
except Exception:
die("""🚫 開新票前要先搜「這件事該放哪」(leo 2026-08-16
leo 原話:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」
實錯(同日):總管沒搜就開 arcrun-rag#110,而那條線早有 hub InkStoneCo#44。
先跑: ticket where <關鍵字...>
搜完它會告訴你該 `say` 進哪張票,還是真的該 `new`。""")
if time.time() - st["at"] > STAMP_TTL:
die(f"🚫 搜尋戳記已過期(超過 {STAMP_TTL // 60} 分鐘)。重跑一次 ticket where")
# 閘一之二:搜尋的結果**到得了人眼前**了沒(inkstone/ISEP#72 → comment 4873
#
# 🔴 這道閘要擋的不是「沒搜」,是「搜了但沒看」——今天實犯:
# `ticket where … >/dev/null 2>&1` 之後直接 `new`,命中的 72 張一眼都沒看,
# 而第一名 arcrun-rag#104 就是同一件事(開了 13 天)。
#
# ⚠️ 這一段刻意放在閘二**之前**:否則第一次就帶 `--not-a-comment "理由"`
# 的人永遠不會看到候選清單——理由是閉著眼睛寫的,那道閘等於沒有。
#
# 成本落在「看」不落在「寫」:擋下來的訊息**本身就是那份被丟掉的輸出**,
# 看完重下同一個指令就會過。不要求多打任何一個字,也不判斷理由寫得好不好
# leo 2026-08-17 已證偽文字層判準:8 次誤攔、0 次正確攔截)。
if st["n"] > 0 and not st.get("shown"):
lines = candidate_lines(st.get("top_detail") or []) or \
[" " + r for r in (st.get("top") or [])]
# 只要 stderr 不是 /dev/null,這一次的擋就已經把清單交到眼前了 → 記進戳記。
# 兩邊都被丟掉時**不記**(fail-closed):下次還是擋,不會靜默放行。
if not fd_is_devnull(2):
st["shown"] = True
with open(stamp_path(), "w") as f:
json.dump(st, f)
die("""🚫 搜尋跑過了,但那份輸出沒有到任何人眼前(stdout 是 /dev/null)。
leo 2026-08-27:「**同一個 session 開兩個一樣的任務就算了,新開票沒搜尋就隨便動手開**」
實錯(同日):`ticket where … >/dev/null` → 開出 arcrun-rag#147
而命中的第一名 arcrun-rag#104 講的就是同一件事,已經開了 13 天。
⇒ **戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。**
命中的 {n} 張,前幾張在這裡——這就是剛才被丟掉的那一份:
{lines}
看完覺得真的都不是同一條線 → **重下一次一模一樣的指令就會過**,不必多打任何字。
(判準是「輸出有沒有進得了 /dev/null 以外的地方」,不看你寫了什麼理由。)""".format(
n=st["n"], lines="\n".join(lines)))
# 閘二:搜到了東西,就要說明為什麼不是貼進去
if st["n"] > 0 and "--not-a-comment" not in argv:
top = "\n".join(candidate_lines((st.get("top_detail") or [])[:8])) or \
"\n".join(" " + t for t in st["top"][:8])
die(f"""🚫 剛才那次搜尋命中 {st['n']} 張 open 票,你卻要開新的。
命中的前幾張:
{top}
**先問一次:這件事跟上面哪一條是同一條線?**
是 → `ticket say <那張票> -F <檔>`(這是預設路徑)
不是 → 重下一次指令,帶上理由:
ticket new {repo} -F <檔> --not-a-comment "為什麼它是獨立的一條線"
理由會被寫進票的內文,往後任何人都看得到你當時怎麼判的。""")
# 閘三:模板欄位非空
missing = [s for s in REQUIRED_SECTIONS if s not in body]
if missing:
die(f"""🚫 票的模板缺欄位:{'、'.join(missing)}
**糊弄的票開不出來,開得出來的票就是能用的 spec。** 必填:
## 目標 要達成什麼(不是要改哪個檔)
## 驗收條件 做完要能證明什麼、怎麼驗
## deliverable 類型 code(→ PR)或 research(→ 貼在票上的結論)""")
m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
kind_line = (m.group(1) if m else "").lower()
if not any(k in kind_line for k in VALID_KINDS):
die(f"🚫 `## deliverable 類型` 底下要明寫 `code` 或 `research`(現在是:{kind_line.strip() or '空的'}\n"
" 關票時會驗這個型別對應的交付物有沒有連上,所以不能含糊。")
if "--not-a-comment" in argv:
why = argv[argv.index("--not-a-comment") + 1]
body += (f"\n\n---\n> 🔎 **為什麼另開一張票而不是貼進既有的**(開票時聲明):{why}\n"
f"> 當時搜尋:`{' '.join(st['kws'])}` → 命中 {st['n']} 張。")
if not title:
die("🚫 缺 --title")
check_title(title)
d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
os.remove(stamp_path()) # 戳記用掉就沒了,一次只開一張
print(f"✅ {ORG}/{repo}#{d['number']} 已開:{d['html_url']}")
# ── 標題規約閘(leo 2026-08-19:「票的寫法不受控制嗎?沒有辦法規範?」)─────────
#
# 實錯(本閘的來由):2026-08-19 一個 session 造了 17 張 `👤 裁決題:…` 與
# 1 張 `【版本】…`。兩種前綴都是 AI 自己發明的分類,都不是 User Story,
# 也都不該是票——**裁決在對話裡講,版本用里程碑**。leo:「亂搞一通」。
#
# 判準跟 empty-handed-stop-guard 同一個哲學:**封形狀,不封措辭**。
# User Story 的形狀是可枚舉的(身為…我要…我才…),自創前綴也是可枚舉的(開頭的方括號/
# 冒號式分類詞)。不做語意判斷,只認形狀。
USER_STORY_RE = re.compile(r"^\s*身為.{2,}?\s*我(要|想要).{2,}?\s*我才.{2,}")
BANNED_PREFIX_RE = re.compile(r"^\s*(?:[\U0001F300-\U0001FAFF\u2600-\u27BF]\s*)*"
r"(?:[【\[(][^】\])]{1,12}[】\])]|[^\s:]{2,10}題)\s*[:]")
def check_title(title):
if BANNED_PREFIX_RE.match(title):
die("🚫 標題不准自創分類前綴(leo 2026-08-19:「亂搞一通」)\n"
f" 你寫的:{title[:60]}\n\n"
" 2026-08-19 實錯:AI 造了『👤 裁決題:』17 張、『【版本】』1 張,\n"
" 兩種都不是 User Story,也都不該是票:\n"
" · 要 leo 裁決 → **在對話裡講**,不要開票\n"
" · 一個版本/sprint → **建里程碑**,把既有 issues 拉進去\n"
" · 真的是一條待辦 → 用 User Story 寫標題(見下)")
if not USER_STORY_RE.match(title):
die("🚫 票名一律 User Storyleo 2026-08-17;規約在 CLAUDE.md\n"
f" 你寫的:{title[:60]}\n\n"
" 格式:身為<誰>,我要<什麼>,我才<為什麼>\n"
" 例: 身為把整台電腦交給 AI 的人,我要它指得出出處,我才敢相信它讀懂了我的東西\n\n"
" 🔴 不要照抄現場的多數——2026-08-17 實查 39 張 open 票只有 6 張合規,\n"
" 照多數抄就會抄到錯的那邊。\n"
" 真的不是一條待辦?那它就不該是票(裁決→對話;版本→里程碑)。")
# ── close ────────────────────────────────────────────────────────────────
def cmd_close(argv):
if not argv:
die("用法:ticket close <owner/repo#N> --deliverable <URL>")
owner, repo, num = parse_ref(argv[0])
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
body = issue.get("body") or ""
comments = api(f"/repos/{owner}/{repo}/issues/{num}/comments") or []
blob = body + "\n" + "\n".join(c.get("body") or "" for c in comments)
deliv = None
if "--deliverable" in argv:
deliv = argv[argv.index("--deliverable") + 1]
m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I)
kind = "code" if m and "code" in m.group(1).lower() else (
"research" if m and "research" in m.group(1).lower() else "unknown")
has_pr = bool(re.search(r"/pulls?/\d+", blob))
has_report = len([c for c in comments if len(c.get("body") or "") > 200]) > 0
ok = bool(deliv) or (has_pr if kind == "code" else has_report if kind == "research"
else (has_pr or has_report))
if not ok:
die(f"""🚫 這張票關不掉——找不到交付物。
票的 deliverable 類型:{kind}
票上有 PR 連結:{'有' if has_pr else '沒有'}
票上有實質回報(>200 字的 comment):{'有' if has_report else '沒有'}
**沒有交付物的票是關不掉的票**——它會一直掛在看板上刺眼,那正是設計意圖。
真的有交付物 → 先貼上去:ticket say {owner}/{repo}#{num} -F <檔>
交付物在別處 → ticket close {owner}/{repo}#{num} --deliverable <URL>""")
if deliv:
api(f"/repos/{owner}/{repo}/issues/{num}/comments",
{"body": f"{_identity(owner, repo)}\n\n✅ 結案。交付物:{deliv}"})
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')}")
# ── 完工回寫(inkstone/ISEP#92 最痛的那一格)─────────────────────────
# 關掉一張下游票之後,**同一個動作**回頭處理它的頂層票。
# 不做這件事的後果票上寫得很清楚:「頂層票會永遠掛著,而 leo 是看頂層的」。
_writeback(owner, repo, num, deliv or ("PR" if has_pr else "票上回報"))
def _writeback(owner, repo, num, deliv):
"""子票關掉 → 回頭處理每一張在等它的頂層票。
🔴 失敗要吵。回寫悄悄失敗 = 這格等於沒做,而沒有人會發現
(那正是本票在治的病)。所以任何一張處理失敗就非零離開。
"""
child = f"{owner}/{repo}#{num}"
try:
parents = _blocks(owner, repo, num)
except SystemExit:
print(f"\n⚠️ 撈不到 {child} 的頂層票(/blocks 讀失敗)——"
f"回寫這一格**沒有做**,請手動確認或跑 `ticket loose`", file=sys.stderr)
sys.exit(1)
if not parents:
print("\n📌 這張票沒有任何頂層票在等它(沒有相依邊)⇒ 不需要回寫。")
return
print(f"\n🔗 有 {len(parents)} 張頂層票在等這張票,逐一回寫:")
failed = []
for pt in parents:
pfull = pt["repository"]["full_name"]
po, pr = pfull.split("/")
pn = pt["number"]
try:
pdeps = _deps(po, pr, pn)
action, open_left = writeback_plan(pt.get("state"), pdeps)
if action == "skip":
print(f" · {pfull}#{pn} 已經是 closed ⇒ 不動")
continue
lines = [_identity(po, pr), "",
f"⬇️ **下游完工回寫**`{child}` 已關(交付物:{deliv}"]
if open_left:
lines += ["", f"這張票還在等 {len(open_left)} 張下游(都關了才輪到它):"]
lines += [f"- ◻ {i['repository']['full_name']}#{i['number']} {i['title'][:60]}"
for i in open_left]
lines += ["", "⇒ 本票維持原狀,不要當成可以收了。"]
else:
# 🔴 這一段是要**貼進 Gitea 留言**的,所以 `ticket close` 刻意寫成相對的
# ——留言會被別台機器讀到,寫死本機的絕對路徑等於寫死一個他們沒有的檔案。
# 絕對路徑那條規約(inkstone/ISEP#112)管的是**印到終端機、要人複製貼上**的字,
# 兩者分界就是這一句:「這行字會被誰複製、在哪台機器上跑?」
lines += ["", "**這是最後一張下游——本票的下游已經全部關閉。**", "",
f"{BATON_MARK} → `{HANDBACK_TO}`", "",
"**下一步**:驗一次頂層要的東西真的到齊了,到齊就 "
f"`ticket close {pfull}#{pn} --deliverable <URL>`"
"沒到齊就開下一張下游票,不要把它留在原地。", "",
"(本則由 `ticket close` 的完工回寫自動貼上,"
"機制見 `inkstone/ISEP#92`"]
api(f"/repos/{po}/{pr}/issues/{pn}/comments", {"body": "\n".join(lines)})
if action == "handback":
# 指派 + 改 tag:**欄位**才撈得到,留言撈不到。
# 08-26 那次掉棒就是話有說、欄位沒動,棒子躺在地上 14 小時。
api(f"/repos/{po}/{pr}/issues/{pn}",
{"assignees": [HANDBACK_TO]}, method="PATCH")
cur = [l["name"] for l in (pt.get("labels") or [])]
keep = [n for n in cur if not n.startswith("s/")] + ["s/review"]
_set_labels(po, pr, pn, keep)
print(f" · {pfull}#{pn} 下游全關 ⇒ 已指派 {HANDBACK_TO}s/review(等人驗)")
else:
print(f" · {pfull}#{pn} 還有 {len(open_left)} 張下游沒關 ⇒ 只記一筆")
except SystemExit:
failed.append(f"{pfull}#{pn}")
except Exception as e:
failed.append(f"{pfull}#{pn}{e}")
if failed:
print(f"\n⚠️ 這幾張頂層票**沒有回寫成功**:{'、'.join(failed)}\n"
f" 回寫失敗要吵——悄悄失敗等於這格沒做。手動補,或稍後跑 `ticket loose` 對帳。",
file=sys.stderr)
sys.exit(1)
# ── decide ───────────────────────────────────────────────────────────────
def cmd_decide(argv):
"""記錄 leo 的裁決+改狀態,**一個動作**。
leo 2026-08-16:「回覆過很多次了,**回覆過的就要記錄下來**」
「這些我答了,來自各地,**問題是你怎麼追蹤**?」
病灶:leo 從對話/手機/Gitea 各處答覆 ⇒ 總管照著做了但沒落到票上
⇒ 下一輪(或下個 session)又問一次同一題。B 題就是實例。
⇒ 所以「寫下答案」與「拿掉 Human」必須是**同一個動作**,不能只做一半。
🔄 2026-08-16 改版(leo):`s/leo` 併入 `Human`,且 **Human 與 s/* 正交**——
「要不要人批」跟「它在流程哪一格」是兩個獨立的軸。
⇒ decide **只拿掉 Human,不動 s/* 狀態**(除非呼叫端明給 --next)。
舊做法把 s/leo 換成 s/todo 會把票的真實流程位置抹掉。
"""
if len(argv) < 3 or argv[1] not in ("-F", "--file"):
die("用法:ticket decide <owner/repo#N> -F <答案檔> [--next <狀態標籤>]\n"
"(預設只拿掉 Human、保留原本的 s/* 狀態;要同時改狀態才加 --next)\n"
"答案檔要寫「leo 原話」與「所以要做什麼」")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
nxt = argv[argv.index("--next") + 1] if "--next" in argv else None
check_identity(body, "裁決紀錄")
if "leo" not in body.lower() and "原話" not in body:
die("🚫 答案檔裡看不到 leo 的原話。\n"
" **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n"
" (今天 `Arcrun#132` 就是把 leo 的「確認」套到錯的提案上,同一張票誤讀兩次。)")
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
ids = {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=60")}
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
cur = [l["name"] for l in issue.get("labels") or []]
# 預設:只拿掉 Human(那是「還在等人批」的標記),流程位置維持不動
keep = [n for n in cur if n != "Human"]
if "--next" in argv:
keep = [n for n in keep if not n.startswith("s/")] + [nxt]
if nxt not in ids:
die(f"🚫 這個 repo 沒有 `{nxt}` 標籤。現有:{[n for n in ids if n.startswith('s/')]}")
api(f"/repos/{owner}/{repo}/issues/{num}/labels",
{"labels": [ids[n] for n in keep if n in ids]}, method="PUT")
# 批完了就不該還掛在 leo 名下——指派給他的清單裡每一張都要是真的在等他
api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": []}, method="PATCH")
print(f" 已拿掉 Human 並取消指派——「指派給 Leo」那份清單保持誠實。")
print(f"✅ {owner}/{repo}#{num}:答案已記進票,狀態 → {nxt}")
print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。")
# ══════════════════════════════════════════════════════════════════════════
# 三個 Gitea 原生欄位(leo 2026-08-27:「子票相依是 gitea 原有機制,改 tag 和指定
# 也是,**這些全部都要**」)。三者正交,各回答一個問題:
#
# 子票相依 這件事存在嗎?做完了嗎? ← 缺了它:任務藏在討論串,撈 open 票看不到
# tag s/* 它現在卡在哪一段? ← 缺了它:撈得到票但要重讀整串才知道在等什麼
# 指派 現在誰該動? ← 缺了它:大家都以為是別人的事
#
# 08-26 那次掉棒(arcrun-rag#136 comment 4267「等雲端那半出貨才驗得了」躺了 14 小時)
# 三格全缺:沒有子票、tag 沒動、沒指派給任何人。
# ══════════════════════════════════════════════════════════════════════════
BATON_MARK = "🏃 **棒子交回**"
# 棒子的預設終點:總管。**這是 Gitea 的 assignee 欄位,不是一句話**——
# 撈「指派給你的」一頁就看得到,撈留言看不到。
HANDBACK_TO = os.environ.get("ISEP_HANDBACK_TO") or "claude-code"
def _labels_of(owner, repo):
return {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=100")}
def _set_labels(owner, repo, num, keep):
ids = _labels_of(owner, repo)
unknown = [n for n in keep if n not in ids]
if unknown:
die(f"🚫 這個 repo 沒有這些標籤:{unknown}\n"
f" (標籤的唯一真相源是 ISEP 的 labels.yaml,改標籤要改那裡再 sync")
api(f"/repos/{owner}/{repo}/issues/{num}/labels",
{"labels": [ids[n] for n in keep]}, method="PUT")
def _deps(owner, repo, num):
return api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
def add_dependency(parent, child):
"""讓 `child` 成為 `parent` 的相依(兩個都是 (owner, repo, num))。**冪等**
先讀 parent 的 `/dependencies`,邊已經在就不 POST、回 False——`mainline adopt`
補收同一張票兩次不該炸。
🔴 冪等**不能靠回應碼**2026-09-07 對真 Gitea 實測,重複相依回的是 **500**(不是想像中的 409
——判準改成「Gitea 現在有沒有這條邊」這個事實;409 仍容忍(別的版本可能這樣回)。
`subtask` 與 `mainline adopt`inkstone/ISEP#133)都走這一支:掛相依只有一條路,
而且走的是 `api()`——離線測試把 `api()` 換成錄音機時才錄得到它。"""
global _TOLERATE
po, pr, pn = parent
co, cr, cn = child
want = f"{co}/{cr}#{cn}"
have = api(f"/repos/{po}/{pr}/issues/{pn}/dependencies?limit=100") or []
for d in have:
full = ((d.get("repository") or {}).get("full_name")) if isinstance(d, dict) else None
if full and f"{full}#{d.get('number')}" == want:
return False # 已經是相依
_TOLERATE = (409,)
try:
r = api(f"/repos/{po}/{pr}/issues/{pn}/dependencies", {"owner": co, "repo": cr, "index": cn})
finally:
_TOLERATE = ()
return r is not _TOLERATED
def _blocks(owner, repo, num):
"""反方向:**誰把這張票當相依**——也就是這張票的頂層票(可能不只一張、可能跨 repo)。
Gitea 原生兩個端點是同一條邊的兩端,方向不可混淆:
/issues/<N>/dependencies 這張票在等誰 → 它的下游
/issues/<N>/blocks 誰在等這張票 → 它的頂層
`subtask` POST 的是**母票的 dependencies**,所以子票這端看到的是 `blocks`。
"""
return api(f"/repos/{owner}/{repo}/issues/{num}/blocks") or []
def _identity(owner, repo):
"""票上的每一則留言第一行要表明身份(規約見 docs/governance/dispatch-and-reply-format.md §3)。
機器自動貼的留言同樣要標——不標的話,回寫留言看起來像某個人寫的,
而下一個讀票的人會去找那個人。
"""
return os.environ.get("ISEP_IDENTITY") or f"【身份】總管/{owner}/{repo}-"
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#92 — 「下游做完時頂層跟著關」的三格
#
# 票上的原話:「開在頂層的票,下游做完了卻沒人回來關⋯⋯頂層票會永遠掛著,
# 而 leo 是看頂層的。」
#
# 缺的三格,各由下面一段承接:
# ① 雙向連結的強制 → cmd_subtask 一個動作同時掛相依 + 在母票留下指回來的留言
# ② journey 標籤 → journey_label()(對不上就不貼,不硬湊)
# ③ 完工回寫 → writeback_plan() cmd_close 關完子票立刻回頭處理母票
# ④ 撈得出來 → is_loose() cmd_loose(③ 走側門漏掉的,這張網補回來)
#
# 🔴 這四格的判準**全部是機械事實**(相依邊在不在、state 是什麼),
# 沒有一格在猜文字。leo 2026-08-17 已證偽關鍵字黑名單那條路
# (8 次誤攔、0 次正確攔截)。
# ══════════════════════════════════════════════════════════════════════════
JOURNEY_PREFIX = "j/"
def journey_label(name):
"""`--journey 收件匣` → `j/收件匣`。只加前綴,不做語意判斷。
標籤存不存在由 `_set_labels` 去問 Gitea(不存在就擋,並指回 labels.yaml)——
**「要求某個東西在場」,不是「猜哪些名字合法」**。
⇒ 新的一條旅程要先加進 labels.yaml 再 sync,這是刻意的:
journey 是**跨票聚類的軸**,隨手造名字會讓同一條旅程長出三個拼法。
"""
n = (name or "").strip()
if not n:
die("🚫 --journey 後面要接旅程名(例:--journey 收件匣)。\n"
" 對不上任何旅程就**不要加這個參數**——票上明講不硬湊。")
return n if n.startswith(JOURNEY_PREFIX) else JOURNEY_PREFIX + n
def is_loose(state, deps):
"""這張票是不是「下游都關了、自己還開著」的頂層票。
純函式(不打網路)⇒ 判準本身測得動,不必開真的票來驗。
三個條件缺一不可:
· 自己還 open 關掉的票不用管
· 曾經有下游 沒有相依邊 ⇒ 它根本不是頂層票,不要拿它來吵
· 沒有任何 open 下游 有一張還開著就還在跑,不是掉在地上
"""
if state != "open":
return False
if not deps:
return False
return not any(d.get("state") == "open" for d in deps)
def writeback_plan(parent_state, parent_deps):
"""下游關掉之後,它的頂層票該被怎麼處理。**只回計畫,不做副作用**⇒ 離線測得動。
回 (動作, 還開著的下游):
"skip" 頂層票自己已經關了 ⇒ 沒事
"note" 還有別的下游沒關 ⇒ 只在頂層票記一筆,別假裝它可以收了
"handback" 下游全關了 ⇒ 這張票現在**只等人驗**:
指派回總管 + s/review,讓它出現在「指派給你的」
🔴 為什麼不自動關母票:關票要有交付物、要有人看過(`ticket close` 的既有閘)。
票上要的是「不能默默留著」,不是「自動消失」——
默默關掉跟默默留著是同一個病的兩面。
"""
open_left = [d for d in (parent_deps or []) if d.get("state") == "open"]
if parent_state != "open":
return "skip", open_left
return ("note" if open_left else "handback"), open_left
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#112 — 閘教的那行指令要真的跑得動
#
# 票上的原話:「**當它教的做法不存在,它就從『幫你』變成『擋你』**——
# 而且被擋的人當下正在做別的事,最可能的反應是找個理由繞過去。」
#
# 那次它為什麼跑不動,兩個獨立的原因(2026-08-31 實測,兩個都會單獨害死它):
#
# ① **相對路徑**。閘印的是 `scripts/ticket subtask …`,而相對路徑是對
# **貼上去那個人的 cwd** 解析的。總管的 cwd 是 `InkStoneCo/`,那裡的
# `scripts/ticket` 是本檔停在 2026-08-27 之前的**舊複本**
# grep -c subtask InkStoneCo/scripts/ticket → 0
# 它的 docstring 只列到「四個動詞」,於是照著貼會印出那段說明、exit 0、
# 什麼都沒發生 —— 正是票上寫的症狀。
# `hooks/lib/beacon_report.py` ② 已經會在 SessionStart 報這個舊複本,
# 但**報告不會改掉閘印出來的那一行**。)
#
# ② **用法各寫一份**。閘手寫了一段用法,`cmd_subtask` 的 die() 又寫了一段。
# 兩份已經漂了:閘那份寫 `--assign <誰做>` 卻**沒有 `--next`**
# 而 cmd_subtask 會因為「🚫 指派了人卻沒寫 --next。」直接 die(實測 exit 2
# 而且是在打任何 API 之前,所以離線也複現得出來)。
# ⇒ 就算路徑對了,那一行照樣跑不完。
#
# 🔴 修法是**拿掉可以漂的那一格**,不是把兩份對齊一次:
# · 路徑 → self_path()`__file__` 的絕對路徑。印指令的人與跑指令的人
# 是同一個檔案,沒有第二種可能。
# · 用法 → USAGE 這一份。閘去 `ticket usage subtask` 拿,不自己抄。
# · 內文模板 → body_template() 由 REQUIRED_SECTIONS 現生。
# 檢查與模板同一個來源 ⇒ 模板不可能少一段。
# ══════════════════════════════════════════════════════════════════════════
def self_path():
"""這支腳本自己的絕對路徑。**閘印出來的指令一律用它。**
不用 `sys.argv[0]`:那是「別人怎麼叫我」,被相對路徑叫進來時它就是相對的。
`__file__` 走 realpath 是「我是誰」——貼到任何 cwd 底下都指得回同一個檔案。
"""
return os.path.realpath(__file__)
# 每一段必填欄位的草稿提示。**鍵一定要是 REQUIRED_SECTIONS 裡的字串**——
# 對不上也不會漏掉那一段(下面會退成 `<填這裡>`),只是提示變笨。
SECTION_HINTS = {
"## 目標": "<這件事要達成什麼。寫目的,不要寫做法>",
"## 驗收條件": "- [ ] <怎麼算做完——別人可以自己跑一次的那種>",
"## deliverable 類型": "code",
}
def body_template():
"""子票內文的草稿,**用 REQUIRED_SECTIONS 現生**。
🔴 不要把這段字面寫死:寫死就變成第二份,而 `cmd_subtask` 檢查的是
REQUIRED_SECTIONS。有人往那個清單加一段,寫死的模板就會生出一張
**照著貼卻過不了自己那道閘**的票 —— 這張票要修的就是這個形狀。
"""
out = []
for sec in REQUIRED_SECTIONS:
out.append(sec)
out.append(SECTION_HINTS.get(sec, "<填這裡>"))
out.append("")
return "\n".join(out).rstrip() + "\n"
def subtask_example(parent=None, draft=None):
"""可以整段複製貼上的那一塊:先把內文草稿寫出來,再開子票。
刻意**不放 `--assign`**`--assign` 沒配 `--next` 會被擋(見 cmd_subtask),
而一行印出來卻跑不完的指令,正是這張票在修的病。要指派的人看 USAGE 那份,
那裡兩個參數是綁在一起寫的。
留在 <> 裡的只有三件**機器不可能知道**的事:標題、目標、驗收條件。
其餘(腳本路徑、母票、必填段落、標籤)全部已經填好。
"""
parent = parent or "<母票 owner/repo#N>"
draft = draft or "/tmp/subtask-body.md"
return (
"cat > %s <<'MD'\n" % draft
+ body_template()
+ "MD\n"
+ "%s subtask %s \\\n" % (self_path(), parent)
+ ' --title "身為<誰>,我要<什麼>,我才<為什麼>" \\\n'
+ " -F %s --label s/todo\n" % draft
)
# 🔴 `{bin}` 一定要留成佔位符、由 usage_text() 現填 self_path()
# 用法裡寫死 `ticket` 或 `scripts/ticket`,讀的人照著貼就又踩回這張票的坑。
USAGE = {
"pick":
"用法:{bin} pick [--mainline <主線 json 檔>] [--all] [--json] [--pool] [--claim [--as <登入名>] [--name <身份>]]\n"
" 回**一張**現在可以抓的票:主線 → 主線抓盡才輪到逾期里程碑 → 帶 --pool(或環境 ISEP_DRAIN=1)時\n"
" 再輪到整個 open 票池的 s/todos/backlog(不限里程碑;Human、指派給別人、hub 照樣不抓,inkstone/ISEP#130)。\n"
" 可抓 0 張時列出池子裡每種「不能抓的理由」各幾張。主線的成員=hub 里程碑裡的票+\n"
" hub 票+它們的相依(跨 repo)——別 repo 的同名里程碑不算主線(inkstone/ISEP#133\n"
" --mainline 不給時依序找:$ISEP_MAINLINE_FILE → ~/.claude/isep-countdown/mainline.json"
" → $CLAUDE_PROJECT_DIR/system-dev/mainline.json\n"
" 離開碼:0 = 有票(印在 stdout);1 = 真的沒有可抓的票;2 = 讀不到(讀不到 ≠ 沒有)",
"claim":
"用法:{bin} claim <owner/repo#N> [--as <登入名,預設本 token 的使用者>] [--name <身份名,預設 subagent>]"
" [--mainline <主線 json 檔>] [--pool]\n"
" 認領=指派給自己 + 換成 s/doing + 第一行【身份】的留言,**一個動作**。\n"
" 抓票規則在這裡再驗一次:不是 s/todo、已有人、掛 Human、不在主線也不逾期 ⇒ 擋下(離開碼 2);\n"
" 帶 --pool(或 ISEP_DRAIN=1)時 open 票池裡的 s/todos/backlog 也認領得了",
"triage":
"用法:{bin} triage --list [--json] [--all]\n"
" {bin} triage <owner/repo#N> --to <s/backlog|s/triage|s/pending> --why \"<一句理由>\" [--name <身份>] [--sign <署名>]\n"
" {bin} triage <owner/repo#N> --dup-of <owner/repo#M> --why \"<一句理由>\" [--name <身份>] [--sign <署名>]\n"
" 分診**還沒有 s/* 的票**inkstone/ISEP#159):給它一個狀態+在票上留一則說明,一個動作;\n"
" 重複的關掉並指向留下的那張(close/duplicate,留下的那張也留一則指回來)。\n"
" --list 列出現在待分診的票;--all 連已分診的 open 票一起列(找重複用)。\n"
" 掛 Humanhuman/*、指派給別人(含 Leo)的票一律不碰;s/todo 要經 compose 排進里程碑才給。",
"compose":
"用法:{bin} compose --title \"<這一輪要讓什麼變成真的>\" --goal \"<一段話>\" --due YYYY-MM-DD\n"
" --carrier inkstone/InkStoneCo#N <前置票 owner/repo#M ...> [--name <身份>] [--sign <署名,例 cloud-worker>]\n"
" 把已分診的票組成一個里程碑(inkstone/ISEP#159CLAUDE.md 開工規約 17 條):\n"
" 里程碑建在 inkstone/InkStoneCo、掛在既有的載體票上;前置票掛成載體票的相依,s/backlog → s/todo\n"
" deliverable=一個總管在 stage 驗過的新版本(版本號由總管定)。**不開任何新票。**\n"
" 先全部驗過才寫:名字像版本號、期限不在 1–60 天內、同名里程碑已在、載體或前置掛 Human/指派別人、\n"
" 前置還沒分診或已在別的 open 里程碑裡 ⇒ 一次列出全部理由,一筆都不寫(離開碼 2)。",
"subtask":
"用法:{bin} subtask <母票 owner/repo#N> --title \"<User Story>\" -F <內文檔>\n"
" [--repo <收件 repo,預設跟母票同一個>] [--label <s/xxx,預設 s/todo>]\n"
" [--journey <旅程名>] ← 這件事服務哪條使用者旅程(對不上就不要加)\n"
" [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 這兩個是一組,"
"指派了就一定要寫下一步\n"
"\n`ticket handoff` 是同一個動作的別名:頂層票 → 下游 repo)",
}
def usage_text(verb):
"""用法的**唯一**一份,路徑現填成絕對路徑(inkstone/ISEP#112)。"""
return USAGE[verb].format(bin=self_path())
def cmd_usage(argv):
"""把「用法」與「可以貼著跑的那一塊」交給同一個地方產生(inkstone/ISEP#112)。
閘的訊息**呼叫這一支**,不自己抄一份 ⇒ 訊息與能跑的指令之間沒有第二份可以漂。
"""
verbs = "|".join(sorted(USAGE))
if not argv or argv[0] not in USAGE:
die("用法:ticket usage <%s> [--example] [--parent <owner/repo#N>] "
"[--draft <內文檔路徑>]\n"
" --example = 印一塊可以整段貼著跑的指令(路徑是絕對路徑,母票已填好)" % verbs)
verb = argv[0]
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
if "--example" in argv:
if verb != "subtask":
die("🚫 `ticket usage %s --example` 還沒有可貼著跑的版本。\n"
" 先看用法:ticket usage %s" % (verb, verb))
sys.stdout.write(subtask_example(opt("--parent"), opt("--draft")))
return
sys.stdout.write(usage_text(verb).rstrip() + "\n")
# ── subtask ──────────────────────────────────────────────────────────────
def cmd_subtask(argv):
"""把「討論串裡的一件事」長成看得見的子票,並掛成母票的 Gitea 原生相依。
為什麼是子票而不是留言(leo 2026-08-27):
「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯
票沒完工可以察覺嗎?」
留言要有人回頭讀才看得見;票的 open/closed 是**狀態**,撈一次就在眼前。
而且相依是**硬的**,不是提醒——實測(2026-08-27Gitea 1.26.4):
子票還開著時 PATCH 母票 state=closed → **HTTP 412
"cannot close this issue or pull request because it still has open dependencies"**
⇒ 子票沒關,母票關不掉。這是平台保證,不是自律。
刻意不要求 `ticket where` 戳記:leaf 屬於一條**已知**的線(它的母票就是那條線),
不是「新開一條線」——那道閘擋的是後者。
"""
if len(argv) < 1:
# 🔴 用法只有 USAGE 那一份(inkstone/ISEP#112)。這裡不准再抄一段:
# 抄一段就是又多一個會漂的副本,而閘印的那一行也是從 USAGE 來的。
die(usage_text("subtask"))
powner, prepo, pnum = parse_ref(argv[0])
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
title = opt("--title")
fpath = opt("-F") or opt("--file")
if not title or not fpath:
die("🚫 缺 --title 或 -F <內文檔>")
check_title(title)
body = open(fpath).read()
missing = [s for s in REQUIRED_SECTIONS if s not in body]
if missing:
die(f"🚫 子票一樣是票,模板欄位不能少:{'、'.join(missing)}\n"
" (子票通常很小,但「小」不等於「可以說不清楚」——\n"
" leo 2026-08-27:『每個事情沒有歷史記錄才是大問題』,\n"
" 而沒寫驗收條件的票,關掉之後就沒有歷史,只剩一個關字)")
# `--to <repo>` 是 `ticket handoff` 那個名字底下比較自然的講法,同一個東西。
# handback 的 `--to` 是「交給誰」,subtask 沒有那個參數,不會撞。)
repo = repo_arg(opt("--repo", opt("--to", prepo)))
label = opt("--label", "s/todo")
assign = opt("--assign")
nxt = opt("--next")
jlabel = journey_label(opt("--journey")) if "--journey" in argv else None
if assign and not nxt:
die("🚫 指派了人卻沒寫 --next。\n"
" **有人被指到這張票,他撈到它的第一件事就是問「所以我要做什麼」**——\n"
" 那句話現在就要寫下來,不是等他重讀整串。\n"
" (沒有要指派給誰就不要加 --assign,那張票會留在票池等人領。)")
body = (f"Parent: {powner}/{prepo}#{pnum}\n"
f"> 這張票是從母票的討論串裡長出來的一件事。\n"
f"> **它沒關,母票關不掉**(Gitea 原生相依,實測 412 硬擋)。\n"
f"> **它關掉時,母票會收到一則回寫**(`ticket close` 自動做,見 `inkstone/ISEP#92`)。\n\n") + body
d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
cnum = d["number"]
print(f"✅ 子票 {ORG}/{repo}#{cnum} 已開:{d['html_url']}")
_set_labels(ORG, repo, cnum, [label])
print(f" tag{label}(它卡在哪一段)")
if assign:
api(f"/repos/{ORG}/{repo}/issues/{cnum}", {"assignees": [assign]}, method="PATCH")
api(f"/repos/{ORG}/{repo}/issues/{cnum}/comments",
{"body": f"{BATON_MARK} → `{assign}`\n\n**下一步**{nxt}\n\n"
f"(這張票是 `{powner}/{prepo}#{pnum}` 的相依——它沒關,母票關不掉)"})
print(f" 指派:{assign}(誰該動)")
print(f" 下一步:{nxt}")
# 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到
add_dependency((powner, prepo, pnum), (ORG, repo, cnum))
deps = _deps(powner, prepo, pnum)
openn = [i for i in deps if i["state"] == "open"]
print(f" 相依:已掛上 {powner}/{prepo}#{pnum}(它存在、且擋著母票)")
# 🔴 雙向連結:相依邊只長在 Gitea 的側欄,**母票的時間軸上什麼都沒有**。
# 票上的原話:「頂層票寫了『轉去某個 repo』,但那邊的票沒有指回來,
# 從下游那張票看不出它從哪來,脈絡就斷了。」
# ⇒ 兩端各留一個看得見的指標:子票內文的 `Parent:`(上面),母票時間軸的這一則。
# 跨 repo 時尤其重要——側欄的相依很容易被當成同 repo 的東西。
plines = [_identity(powner, prepo), "",
f"➡️ **已轉往下游**`{ORG}/{repo}#{cnum}` {title}", "",
f"{d['html_url']}", "",
f"- 收件 repo`{ORG}/{repo}` tag`{label}`"
+ (f" 旅程:`{jlabel}`" if jlabel else "")
+ (f" 指派:`{assign}`" if assign else " (還沒指派,留在票池等人領)")]
if jlabel is None:
plines += ["- 旅程:**沒有貼**(開票時判定對不上任何一條,不硬湊)"]
plines += ["",
"**這張頂層票不會因為轉出去就自動關**——下游那張關掉時,"
"`ticket close` 會回頭在這裡貼一則回寫;"
"全部下游關完,這張票會被指派回總管等驗。(機制:`inkstone/ISEP#92`"]
api(f"/repos/{powner}/{prepo}/issues/{pnum}/comments", {"body": "\n".join(plines)})
print(f" 回指:已在母票 {powner}/{prepo}#{pnum} 的時間軸貼上指向子票的留言")
# journey 標籤:兩端都貼,聚類才聚得起來(只貼一端=撈不到另一端)
if jlabel:
_set_labels(ORG, repo, cnum, [label, jlabel])
pcur = [l["name"] for l in (api(f"/repos/{powner}/{prepo}/issues/{pnum}")
.get("labels") or [])]
if jlabel not in pcur:
_set_labels(powner, prepo, pnum, pcur + [jlabel])
print(f" 旅程:`{jlabel}`(母子兩端都貼——只貼一端就聚不起來)")
else:
print(" 旅程:沒有貼(沒給 --journey)。對得上就補一次:"
f"`{self_path()} subtask … --journey <旅程名>`;對不上就不要硬湊。")
print(f"\n📌 母票 {powner}/{prepo}#{pnum} 目前有 {len(openn)} 張未關的相依:")
for i in deps:
mark = "◻" if i["state"] == "open" else "☑"
print(f" {mark} {i['repository']['full_name']}#{i['number']} {i['title'][:50]}")
print(" 在它們全關之前,母票 PATCH state=closed 會被 Gitea 擋下(412)。")
# ── handback ─────────────────────────────────────────────────────────────
def cmd_handback(argv):
"""收工=把棒子交回去:**指派 + tag + 下一步**,一個動作三件事。
leo 2026-08-27:「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
要說明下一步怎麼做,直到最後交出正確 deliverable 並且驗證」
🔴 關鍵在「指派」是**欄位**不是文字——撈一次就看得到,不必讀 comment。
08-26 那次掉棒,交回的話寫在 comment 裡,票沒指派、tag 沒動 ⇒ 棒子躺在地上 14 小時。
"""
if len(argv) < 1:
die("用法:ticket handback <owner/repo#N> --to <claude-code|Leo> --next \"<下一步一句話>\"\n"
" [--label <s/xxx>] [--evidence <URL 或一行實測>]\n"
"--to claude-code 交回總管;--to Leo = 要 leo 親手做,會自動加 Human")
owner, repo, num = parse_ref(argv[0])
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
to = opt("--to")
nxt = opt("--next")
if not to or not nxt:
die("🚫 缺 --to 或 --next。\n"
" **「下一步」不准省略**——收下棒子的人要能不重讀整串就知道該做什麼。\n"
" (這正是 08-26 掉棒那次缺的:話有說,但沒有欄位在說「這張票在等你」)")
label = opt("--label")
ev = opt("--evidence")
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
cur = [l["name"] for l in issue.get("labels") or []]
deps = _deps(owner, repo, num)
openn = [i for i in deps if i["state"] == "open"]
lines = [f"{BATON_MARK} → `{to}`", "",
f"**下一步**{nxt}"]
if ev:
lines += ["", f"**證據**{ev}"]
if openn:
lines += ["", "**未關的相依**(它們全關之前這張票關不掉):"]
lines += [f"- {i['repository']['full_name']}#{i['number']} {i['title'][:60]}" for i in openn]
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": "\n".join(lines)})
api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": [to]}, method="PATCH")
keep = list(cur)
if label:
keep = [n for n in keep if not n.startswith("s/")] + [label]
if to == "Leo" and "Human" not in keep:
keep.append("Human")
if to != "Leo" and "Human" in keep:
keep = [n for n in keep if n != "Human"]
_set_labels(owner, repo, num, keep)
print(f"✅ {owner}/{repo}#{num} 棒子交回 `{to}`")
print(f" 指派:{to}  tag{[n for n in keep if n.startswith('s/')] or '(未動)'}"
f"{' Human' if 'Human' in keep else ''}")
print(f" 下一步:{nxt}")
if openn:
print(f" ⚠️ 還有 {len(openn)} 張未關的相依 ⇒ 這張票現在關不掉(412),這是對的")
print("\n📌 三件事是同一個動作——不會只留言而忘了指派,也不會指派了而看板還說 s/doing。")
# ── mine ─────────────────────────────────────────────────────────────────
def cmd_mine(argv):
"""撈一次:**哪幾根棒子在誰手上、每根的下一步是什麼**。
不是輪詢——掛在本來就會做的動作上(session 開場、收工對帳)跑一次。
"""
user = argv[argv.index("--user") + 1] if "--user" in argv else None
if user in (None, "", "me"):
who = "總管(本 token 的身份)"
items = api("/repos/issues/search?state=open&type=issues&assigned=true&limit=100") or []
else:
who = user
items = []
for r in api(f"/orgs/{ORG}/repos?limit=100") or []:
q = urllib.parse.urlencode({"state": "open", "assigned_by": user, "limit": 50})
for it in api(f"/repos/{ORG}/{r['name']}/issues?{q}") or []:
if it.get("pull_request"):
continue
it.setdefault("repository", {"full_name": f"{ORG}/{r['name']}"})
items.append(it)
print(f"🏃 在「{who}」手上的棒子:{len(items)}\n")
if not items:
print(" (沒有——每一根都已經交出去或關掉了)")
for it in items:
ref = it["repository"]["full_name"] + "#" + str(it["number"])
st = [l["name"] for l in it.get("labels") or []]
o, rp, n = ref.split("/")[0], ref.split("/")[1].split("#")[0], it["number"]
deps = _deps(o, rp, n)
openn = [i for i in deps if i["state"] == "open"]
print(f" ● {ref} {st}")
print(f" {it['title'][:64]}")
nxt = _last_next(o, rp, n)
print(f" 下一步:{nxt or '❌ 沒有人寫下一步(交棒時漏了 --next)'}")
if openn:
print(f" ◻ 未關相依 {len(openn)}" +
"、".join(f"{i['repository']['full_name']}#{i['number']}" for i in openn))
print("\n📌 終點是 leo 說的「直到最後交出正確 deliverable 並且驗證」——"
"驗完就關票、清指派,這張表才會縮短。")
def _last_next(owner, repo, num):
"""從最後一則交棒留言抓「下一步」。抓不到就誠實回 None,不要編。"""
try:
cs = api(f"/repos/{owner}/{repo}/issues/{num}/comments?limit=100") or []
except SystemExit:
return None
for c in reversed(cs):
b = c.get("body") or ""
if BATON_MARK.strip("*") .replace("**", "") in b or BATON_MARK in b:
m = re.search(r"\*\*下一步\*\*[:]\s*(.+)", b)
if m:
return m.group(1).strip()[:100]
return None
# ── loose ────────────────────────────────────────────────────────────────
def cmd_loose(argv):
"""撈一次:**下游都關了、自己還開著**的頂層票。
票上的驗收條件第 3、4 條(`inkstone/ISEP#92`):
「撈一次『已經沒有 open 下游票、但自己還開著的頂層票』→ 要撈得出來(現在撈不出來)」
「拿現有的頂層票跑一次 → 要能指出哪幾張是這種情況」
這是 `ticket close` 自動回寫的**補網**:回寫只在走正門(`ticket close`)時發生,
而關票的側門(直接 PATCH `state=closed`)是刻意放行的
(見 `hooks/ticket-api-bypass-guard.sh`:「帶了 ID 的子路徑」一律放行)。
⇒ 側門漏掉的,由這張網撈回來。**不是輪詢**——掛在開場/收工對帳跑一次。
"""
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
org = opt("--org", ORG)
only = opt("--repo")
print(f"🔎 掃 `{org}` 的 open 票,找「下游都關了、自己還開著」的頂層票"
+ (f"(只看 {only}" if only else "") + " …")
items, page = [], 1
while page <= 12:
q = urllib.parse.urlencode({"state": "open", "type": "issues",
"limit": 50, "page": page, "owner": org})
got = api(f"/repos/issues/search?{q}") or []
if not got:
break
items += got
page += 1
if only:
items = [i for i in items if i["repository"]["full_name"].split("/")[-1] == only]
print(f" open 票 {len(items)} 張,逐張問它的下游 …")
def probe(it):
full = it["repository"]["full_name"]
o, r = full.split("/")
try:
deps = _deps(o, r, it["number"])
except SystemExit:
return None # 讀不到 ≠ 沒有下游;寧可漏報也不要編一筆出來
except Exception:
return None
return (full, it, deps)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=12) as ex:
probed = [x for x in ex.map(probe, items) if x]
parents = [x for x in probed if x[2]]
loose = [x for x in parents if is_loose("open", x[2])]
print(f"\n 有下游相依的頂層票:{len(parents)} 張")
print(f" 其中「下游都關了、自己還開著」:**{len(loose)} 張**\n")
if not loose:
print("✅ 一張都沒有——每張頂層票都還有下游在跑,或已經跟著關了。")
for full, it, deps in loose:
n = it["number"]
st = [l["name"] for l in it.get("labels") or []]
who = [a["login"] for a in (it.get("assignees") or [])] or ["(沒有人)"]
print(f" ● {full}#{n} {st} 指派:{'、'.join(who)}")
print(f" {it['title'][:64]}")
for d in deps:
print(f" ☑ {d['repository']['full_name']}#{d['number']}"
f" {d['title'][:52]}")
# 絕對路徑(inkstone/ISEP#112):相對的 `scripts/ticket` 會被貼到別的 cwd 底下,
# 在那裡叫到的可能是本檔的舊複本(`InkStoneCo/scripts/ticket` 就是)。
print(f" → 驗過就關:{self_path()} close {full}#{n} --deliverable <URL>")
print(f" 還缺東西:{self_path()} subtask {full}#{n} --title \"<US>\" -F <檔>")
print()
print("📌 這張表撈的是**機械事實**(相依邊的 state),不是猜文字。")
print(" 走 `ticket close` 關下游時會自動回寫、這張表就不會長;")
print(" 會長出來的都是走側門關掉的那些。")
return loose
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#131 — 雲端工人抓票:規則是機械可判的,不靠 prompt 叮嚀
#
# 票上的原話:Routine 08-06 起讀 `journeys.md` 本 sprint 段,08-19 到期沒續,
# **連續兩週讀到殘骸只能空轉**。09-07 把 `cloud-worker.md` 步驟 1 改成從 Gitea 主線
# 里程碑抓票(inkstone/InkStoneCo#118)——但那是一段給人讀的字,讀漏一句就抓錯。
# 這一段把它做成一個指令,規則寫在 `pickable()` 一支純函式裡,離線測得動。
#
# 規則(全部是 Gitea 欄位,沒有一格在猜文字):
# 可抓 s/* 狀態恰好是 {s/todo} 沒有 assignee 沒有 Humanhuman/* 不是 hub 有里程碑
# 且里程碑 = 主線;主線抓盡才抓「逾期的 open 里程碑」
# ⇒ 兩層都抓盡、且是接票的環境(--pool/ISEP_DRAIN=1)⇒ 第三層:整個 open 票池裡
# s/* 恰好是 s/todo 或 s/backlog 的(不限里程碑),其他格一格不放寬(inkstone/ISEP#130 c7801
# 🔴 2026-09-18 實量:org 299 張 open、254 張沒掛里程碑;舊規則只看主線成員與逾期里程碑,
# 實跑可抓 0 張 ⇒ 09-18 六趟 run 全部「可接 0 張、正常收工」。加上票池這一層,同一刻可抓 101 張。
# 主線/逾期是**先抓誰**,不是**只抓誰**。
# 認領 指派 + s/doing +【身份】留言,一個動作(`claim`)
# 完成 分支 證據 s/review `handback`——那半本來就有,不重做
#
# 🔴 為什麼是「s/* 恰好等於 {s/todo}」而不是「有 s/todo」:2026-09-07 實查
# `inkstone/mira#6` 同時掛著 s/doing 與 s/todoexclusive 只擋 UI,不擋 API)。
# 只看「有 s/todo」會把一張別人正在做的票抓走。要求的是**狀態在場且唯一**,
# 仍然是「要求某個東西在場」那個形狀,不是黑名單。
#
# 🔴 Human 那一格用的是**標籤**(唯一識別碼),不是票上有沒有 👤 這個字——
# cloud-worker.md 寫過「票上沒有 👤」,那是文字判準,leo 2026-08-17 已證偽那條路。
# `human/exec`(執行者是人)與 `Human` 同組(labels.yaml「人的介入」),一起算。
# ══════════════════════════════════════════════════════════════════════════
_ML = None
def _mainline_lib():
"""`hooks/lib/mainline.py`——「主線」這件事的唯一存放處。norm() 也只有那一份。"""
global _ML
if _ML is None:
import importlib.machinery
import importlib.util
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib", "mainline.py")
loader = importlib.machinery.SourceFileLoader("isep_mainline_lib", path)
spec = importlib.util.spec_from_file_location("isep_mainline_lib", path, loader=loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
_ML = mod
return _ML
def _now():
"""現在。測試用 ISEP_COUNTDOWN_NOWepoch 秒)把時鐘定住——跟 countdown 同一個開關。"""
from datetime import datetime, timezone
override = os.environ.get("ISEP_COUNTDOWN_NOW", "").strip()
if override:
try:
return datetime.fromtimestamp(float(override), tz=timezone.utc)
except Exception:
pass
return datetime.now(timezone.utc)
def _label_names(issue):
out = []
for l in issue.get("labels") or []:
out.append(l["name"] if isinstance(l, dict) else str(l))
return out
def human_gated(labels):
"""掛著「人的介入」那一組標籤(labels.yaml`Human``human/*`)。"""
return any(n == "Human" or n.startswith("human/") for n in labels)
# 票池那一層接受的狀態(inkstone/ISEP#130 c7801):labels.yaml 裡「決定要做、沒人在做」的只有這兩個。
# s/todo 已排進 milestone,等人領票
# s/backlog 驗過了、確定要做,但還沒排進任何 sprint
# 其他狀態都有人或有事在管:s/triage=還沒決定要不要做(總管/leo 的事)、s/doing=有人在做、
# s/reviews/stage=等總管或 leo、s/pending=在等外部。**沒有 s/* 的票**=還沒被驗傷,一樣不抓,
# 但會被點名計數(見 pool_candidates),不再沉默地消失在「可抓 0 張」後面。
MAINLINE_STATUSES = ("s/todo",)
POOL_STATUSES = ("s/todo", "s/backlog")
def pool_mode(argv=()):
"""要不要往下看到整個 open 票池。`--pool` 或環境 `ISEP_DRAIN=1`(雲端工人的環境本來就設它)。
為什麼不是預設全開:`queue-drain-guard` 在本機只要總管跑過 `ticket handback` 就會問 pick——
票池永遠有票,總管每次收工都會被點名去抓票,那是誤攔。接票的環境自己宣告,不靠猜。"""
return "--pool" in argv or os.environ.get("ISEP_DRAIN") == "1"
def pickable(issue, milestone_title, me=None, statuses=MAINLINE_STATUSES):
"""這張票現在能不能被雲端工人抓走。回 (ok, 理由)。**純函式,不打網路。**
`milestone_title`=要求它掛在哪個里程碑底下(主線,或輪到的那個逾期里程碑)。
`me`=抓票者自己的登入名。**只指派給自己的 s/todo 票算可抓**inkstone/ISEP#33):
雲端工人與總管用的是同一個機器帳號 `claude-code`,總管把票 handback 回池子時
指派欄就留著 `claude-code`。2026-09-13 實查主線:`Arcrun#176` 是 `s/todo`、
指派 `claude-code` ⇒ 舊規則說「已經指派給 claude-code」⇒ **工人把自己當成別人**,
09-11 那趟 runArcrun#86 c6917)就是用這條理由判定「主線沒有可抓的票」然後收工。
`s/todo` 的意思本來就是「沒人在做」;有人在做的形狀是 `s/doing`,那格照擋。
`statuses`=接受哪些 s/*(主線/逾期只收 s/todo;票池另收 s/backlog,見 POOL_STATUSES)。
理由是給人看的:`pick --all` 與 `claim` 擋下時都印它,一張票為什麼不能抓要說得出來。
"""
if issue.get("pull_request"):
return False, "這是 PR,不是票"
labels = _label_names(issue)
st = sorted(n for n in labels if n.startswith("s/"))
if len(st) != 1 or st[0] not in statuses:
return False, "狀態不是單一的 %s(現在是 %s" % ("".join(statuses), st or "沒有 s/*")
if issue.get("assignees") or issue.get("assignee"):
who = [a.get("login") for a in (issue.get("assignees") or []) if isinstance(a, dict)] \
or [(issue.get("assignee") or {}).get("login")]
others = [w for w in who if w and w != me]
if others or not me:
return False, "已經指派給 %s" % "、".join(str(w) for w in who if w)
if human_gated(labels):
return False, "掛著 %s——要 leo 親手做的" % "、".join(n for n in labels if n == "Human" or n.startswith("human/"))
if "hub" in labels:
# labels.yamlhub =「聚合一批 leaf 的 scope 容器,本身不掛 milestone、不對應 PR」
# 2026-09-07 實查:ISEP#3135 五張 hub 掛在逾期里程碑上、s/todo、沒人——
# 照欄位規則會被抓走,但它們不是一件可以做完的事。容器不是任務。
return False, "是 hub(scope 容器,不是一件可以做完的事)"
if milestone_title is None:
# inkstone/ISEP#133:呼叫端已經用相依邊確認它在主線上(別 repo 的票靠相依進來,
# 本來就不掛 hub 里程碑)⇒ 這一格不看里程碑。
return True, "ok"
ms = (issue.get("milestone") or {}).get("title")
if not ms:
return False, "沒有里程碑(backlog/沒排進 sprint 的不抓)"
norm = _mainline_lib().norm
if norm(ms) != norm(milestone_title):
return False, "里程碑是「%s」,不是「%s」" % (ms, milestone_title)
return True, "ok"
def pick_order(issue):
"""同一組裡先抓誰:p/high → 沒標 → p/low;同優先序 s/todo 先於 s/backlog(排進 sprint 的先);
再看開票時間(舊的先);最後票號。結果要**可重現**——同一個池子問兩次要拿到同一張。"""
labels = _label_names(issue)
prio = 0 if "p/high" in labels else (2 if "p/low" in labels else 1)
status = 1 if "s/backlog" in labels else 0
return (prio, status, issue.get("created_at") or "", issue.get("number") or 0)
def _ref_of(issue, owner=None, repo=None):
full = ((issue.get("repository") or {}).get("full_name")) or ("%s/%s" % (owner, repo))
return "%s#%s" % (full, issue.get("number"))
def load_mainline(argv):
"""主線從哪來(依序):--mainline <檔> → $ISEP_MAINLINE_FILE → 本機狀態檔
`scripts/mainline set` 寫的那個)→ $CLAUDE_PROJECT_DIR/system-dev/mainline.json(進 repo 的那份,
雲端 clone 就讀得到,inkstone/InkStoneCo#118)。回 (dict, 來源說明)。"""
def opt(name):
return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else None
ML = _mainline_lib()
tried = []
explicit = opt("--mainline") or os.environ.get("ISEP_MAINLINE_FILE", "").strip()
if explicit:
try:
with open(explicit) as f:
d = json.load(f)
except Exception as e:
die("🔴 讀不到主線檔 %s%s\n 讀不到 ≠ 沒有主線——先把檔案路徑修對,不要因此去抓別的票。" % (explicit, e))
if not isinstance(d, dict) or not d.get("title"):
die("🔴 主線檔 %s 裡沒有 title 欄——那不是 `scripts/mainline set` 寫出來的檔。" % explicit)
return d, explicit
tried.append(ML.path())
d = ML.load()
if d:
return d, ML.path()
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
cand = os.path.join(root, "system-dev", "mainline.json")
tried.append(cand)
try:
with open(cand) as f:
d = json.load(f)
if isinstance(d, dict) and d.get("title"):
return d, cand
except Exception:
pass
die("""🔴 現在沒有主線 ⇒ 沒有「可以抓的票」這回事。
找過:
%s
這不是叫你去抓 backlog 或沒里程碑的票——那是總管排 sprint 的事,不是工人的。
出路(擇一):
• 雲端:clone inkstone/InkStoneCo,用它 repo 裡那份 → `%s pick --mainline <clone>/system-dev/mainline.json`
• 本機:`scripts/mainline list` 看有哪些 → `scripts/mainline set <owner/repo#id>`""" % (
"\n".join(" · " + t for t in tried), self_path()))
def _repos(owner):
return [r["name"] for r in (api("/orgs/%s/repos?limit=100" % owner) or [])
if isinstance(r, dict) and r.get("name")]
def _open_milestones(owner, repos):
"""每個 repo 的 open milestone。回 [(repo, milestone dict)]。"""
out = []
for r in repos:
for m in (api("/repos/%s/%s/milestones?state=open&limit=100" % (owner, r)) or []):
if isinstance(m, dict) and m.get("title"):
out.append((r, m))
return out
def _issues_in(owner, repo, title):
q = urllib.parse.urlencode({"state": "open", "type": "issues", "milestones": title, "limit": 100})
rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q)) or []
for it in rows:
if isinstance(it, dict):
it.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)})
return [it for it in rows if isinstance(it, dict)]
def _fetch_issue(ref):
ML = _mainline_lib()
parts = ML.split_ref(ref)
if not parts:
return None
it = api("/repos/%s/%s/issues/%d" % parts)
if not isinstance(it, dict) or not it.get("number"):
return None
it.setdefault("repository", {"full_name": "%s/%s" % parts[:2]})
return it
def _open_issues(owner, repo):
"""一個 repo 的全部 open 票(分頁讀完;Gitea 每頁上限 50,只讀一頁會漏)。"""
out, page = [], 1
while page <= 40:
q = urllib.parse.urlencode({"state": "open", "type": "issues", "limit": 50, "page": page})
rows = [it for it in (api("/repos/%s/%s/issues?%s" % (owner, repo, q)) or []) if isinstance(it, dict)]
for it in rows:
it.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)})
if not rows: # 讀到空頁才停:伺服器的每頁上限可能比 50 小,看「不滿一頁」會提早收手
break
out += rows
page += 1
return out
def pool_issues(owner):
"""整個 org 的 open 票(不含 PR)——**全局票池的唯一讀法**,pick 的票池層與 triage 共用這一份。
🔌 接口留給 inkstone/ISEP#154:全局票池是 Arcrun 工作流 `global_index`tickets 庫),
寫卡驗完之前先用這裡直打 Gitea。要換成讀 #154 那份時**只換這一個函式**——
呼叫端拿到的形狀不變(Gitea issue dict,帶 `repository.full_name`),不另寫第三套撈法。"""
out = []
for r in _repos(owner):
out += [it for it in _open_issues(owner, r) if not it.get("pull_request")]
return out
def pool_candidates(owner, me, seen=(), issues=None):
"""第三層:整個 org 的 open 票池(inkstone/ISEP#130 c7801)。回 (可抓的票…, 池子大小, {不能抓的理由: 張數})。
leo 09-18:「我看到 Gitea 有 446 張票,要它自己領工作就是持續消化工作,它說沒事做?」
⇒ 主線與逾期里程碑只是**先抓誰**,不是**只抓誰**。它們抓盡之後,池子裡「決定要做、沒人在做」的票
POOL_STATUSES)照樣可抓。不該碰的一格都沒放寬:Human/human/*、指派給別人(含 Leo)、hub、PR。
「要 Mac/要 prod」沒有欄位可判——那是工人讀票後走 cloud-worker.md「做不了的票」:寫回票+改欄位,
pick 下一輪就不再回它(改的是 s/*,不是猜文字)。
`seen`=前兩層已經列過的票號,不重複。理由表一併回傳:**可抓 0 張時要說得出池子去哪了**。"""
found, why_count, total = [], {}, 0
for it in (pool_issues(owner) if issues is None else issues):
if it.get("pull_request"):
continue
total += 1
ref = _ref_of(it)
if ref in seen:
continue
if _is_carrier(it):
# 開工規約第 7 條:InkStoneCo 里程碑裡的票是載體(容器),做的是它的前置(inkstone/ISEP#159
why = "是里程碑「%s」的載體票(容器,做的是它的前置)" % it["milestone"].get("title")
why_count[why] = why_count.get(why, 0) + 1
continue
ok, why = pickable(it, None, me, POOL_STATUSES)
if ok:
found.append(it)
else:
why_count[why] = why_count.get(why, 0) + 1
return sorted(found, key=pick_order), total, why_count
def _me():
"""抓票者自己是誰:只指派給自己的 s/todo 票算可抓(inkstone/ISEP#33,見 pickable
問不到(舊 token、測試替身沒有 /user)⇒ None ⇒ 照舊規則,不因為這一格讓 pick 整個死掉"""
return os.environ.get("ISEP_WORKER_LOGIN") or ((api_soft("/user") or {}).get("login"))
def mainline_members(ms):
"""主線的成員(inkstone/ISEP#133):hub 里程碑裡的票+hub 票+它們的相依(跨 repo)。
判準在 `hooks/lib/mainline.py::collect_members`,這裡只是把本檔的 `api` 遞進去。"""
return _mainline_lib().collect_members(api, ms)
# 里程碑一律住在這個 repo(CLAUDE.md 開工規約第 7 條,leo 2026-09-13):
# 裡面只放載體票,別 repo 的執行票是載體票的**前置**/dependencies)。
CARRIER_REPO = "InkStoneCo"
def carrier_members(owner, m):
"""InkStoneCo 里程碑的**執行票**=載體票沿相依收下去的票,不含載體票本身(載體是容器不是任務)。
判準同主線(`hooks/lib/mainline.py::collect_members`inkstone/ISEP#133),不另寫一份。"""
refs, info = _mainline_lib().collect_members(
api, {"owner": owner, "repo": CARRIER_REPO, "id": m.get("id"), "title": m.get("title")})
roots = set(info.get("milestone") or [])
return [r for r in refs if r not in roots]
def _milestone_group(owner, r, m, me):
"""一個里程碑底下現在可抓的票。InkStoneCo 的里程碑看相依邊;別的 repo 照舊看票上的里程碑欄。"""
found = []
if r == CARRIER_REPO:
for ref in carrier_members(owner, m):
it = _fetch_issue(ref)
if it and (it.get("state") or "open") == "open" and pickable(it, None, me)[0]:
found.append(it)
return sorted(found, key=pick_order)
for it in _issues_in(owner, r, m.get("title")):
if str((it.get("milestone") or {}).get("id")) != str(m.get("id")):
continue
ok, _ = pickable(it, m.get("title"), me)
if ok:
found.append(it)
return sorted(found, key=pick_order)
def pick_candidates(ms, now=None, pool=False):
"""依序回 [(組名, 里程碑標題, [可抓的票…])]:第一組是主線,之後是逾期的 open 里程碑
(最逾期的先)。**每一組裡都已經照 pick_order 排好。**
主線那一組的成員由相依邊決定(`mainline_members`),**不是**同名里程碑:
別 repo 裡叫同一個名字的里程碑,裡面的票不會被當成主線抓走(inkstone/ISEP#133)。
逾期那幾組是**一個里程碑物件一組**(owner/repo#id),同名不合併。"""
ML = _mainline_lib()
now = now or _now()
owner = ms.get("owner") or ORG
anchor = "%s/%s#%s" % (ms.get("owner"), ms.get("repo"), ms.get("id"))
me = _me()
refs, info = mainline_members(ms)
found = []
for ref in refs:
it = _fetch_issue(ref)
if not it or (it.get("state") or "open") != "open":
continue
ok, _ = pickable(it, None, me)
if ok:
found.append(it)
groups = [("主線", ms["title"], sorted(found, key=pick_order))]
repos = _repos(owner)
overdue, upcoming = [], []
for r, m in _open_milestones(owner, repos):
if "%s/%s#%s" % (owner, r, m.get("id")) == anchor:
continue
due = ML.due_of(m)
if due and due < now:
overdue.append((due, r, m))
elif r == CARRIER_REPO:
upcoming.append((due, r, m))
for due, r, m in sorted(overdue, key=lambda x: x[0]):
groups.append(("逾期里程碑", "%s%s/%s#%s" % (m.get("title"), owner, r, m.get("id")),
_milestone_group(owner, r, m, me)))
if pool:
# 第三層(inkstone/ISEP#159):還沒到期的 InkStoneCo 里程碑——雲端工人自己組的里程碑就在這裡,
# 「組了之後照里程碑往下做」靠這一層:它的前置票先於票池裡散的票。期限近的先。
# 只在接票的環境(--poolISEP_DRAIN=1)看:本機總管收工時不會因此被點名。
far = now.replace(year=now.year + 100)
for due, r, m in sorted(upcoming, key=lambda x: x[0] or far):
groups.append(("里程碑", "%s%s/%s#%s" % (m.get("title"), owner, r, m.get("id")),
_milestone_group(owner, r, m, me)))
return groups
def cmd_pick(argv):
"""回一張現在可以抓的票。離開碼:0 有、1 真的沒有、2 讀不到/沒主線。"""
if argv and argv[0] in ("-h", "--help"):
die(usage_text("pick"), 0)
ms, src = load_mainline(argv)
as_json = "--json" in argv
show_all = "--all" in argv
pool = pool_mode(argv)
pool_total, pool_why = None, {}
try:
groups = pick_candidates(ms, pool=pool)
# 票池那一層只在前面都抓盡(或要看全部)時才讀——它要翻整個 org,能省就省
if pool and (show_all or not any(its for _, _, its in groups)):
seen = {_ref_of(it) for _, _, its in groups for it in its}
me = _me()
issues = pool_issues(ms.get("owner") or ORG)
found, pool_total, pool_why = pool_candidates(ms.get("owner") or ORG, me, seen, issues)
groups.append(("票池", "open 票池(%s,不限里程碑)" % "".join(POOL_STATUSES), found))
# 最後一層(inkstone/ISEP#159):沒有可做的票時,還沒驗傷的票就是下一件事——
# 回一張待分診的票,下一步是 `triage` 不是 `claim`(佇列閘照 row 的 next 印)。
if show_all or not found:
groups.append(("待分診", "還沒有 s/* 的 open 票", triage_candidates(issues, me)))
except (urllib.error.URLError, OSError) as e:
die("🔴 拿不到 Gitea%s)。讀不到 ≠ 沒有可抓的票——先修連線,不要空轉也不要抓別的。" % e)
flat = [(g, t, it) for g, t, its in groups for it in its]
if as_json:
rows = [{"ref": _ref_of(it), "title": it.get("title"), "group": g, "milestone": t,
"labels": _label_names(it), "created_at": it.get("created_at"),
"next": _next_cmd(g, _ref_of(it))}
for g, t, it in flat]
print(json.dumps(rows if show_all else (rows[0] if rows else None), ensure_ascii=False, indent=2))
if not rows:
_explain_empty(pool, pool_total, pool_why, sys.stderr)
sys.exit(0 if rows else 1)
print("🎯 主線:「%s」(來源:%s" % (ms["title"], src))
for g, t, its in groups:
if g in ("主線", "票池", "待分診") or its or show_all:
print(" %s%s」:%s %d 張" % (g, t, "待分診" if g == "待分診" else "可抓", len(its)))
print()
if not flat:
_explain_empty(pool, pool_total, pool_why, sys.stdout)
sys.exit(1)
shown = flat if show_all else flat[:1]
for g, t, it in shown:
print(" ● %s %s" % (_ref_of(it), _label_names(it)))
print(" %s" % (it.get("title") or "")[:80])
print(" %s%s」 開票 %s" % (g, t, (it.get("created_at") or "")[:10]))
print()
if flat[0][0] == "待分診":
# 分診不是認領:沒有人要「做」這張票,要的是決定它去哪
print("📌 可做的票都抓盡了,下一件事是分診(還沒驗傷的票不會自己消失):")
print(" " + _next_cmd("待分診", _ref_of(flat[0][2])))
print(" 找重複:%s triage --list --all  組里程碑:%s usage compose" % (self_path(), self_path()))
return
if "--claim" in argv:
top = flat[0][2]
cmd_claim([_ref_of(top)] + [a for a in argv if a != "--claim"])
return
print("📌 認領(指派+s/doing+【身份】留言,一個動作):")
print(" %s claim %s --name <你的名字>%s" % (self_path(), _ref_of(flat[0][2]),
" --pool" if flat[0][0] == "票池" and "--pool" in argv else ""))
print(" 做完:分支+證據 → `%s handback %s --to %s --label s/review --next \"\" --evidence <URL>`"
% (self_path(), _ref_of(flat[0][2]), HANDBACK_TO))
def _next_cmd(group, ref):
"""這一列的下一步,**貼了就能跑的那一行**(佇列閘照這一格印,不自己猜)。"""
if group == "待分診":
return ("%s triage %s --to <s/backlog|s/triage|s/pending> --why \"<一句理由>\""
" (重複的:--dup-of <owner/repo#M>" % (self_path(), ref))
return "%s claim %s --name <你的名字>" % (self_path(), ref)
def _explain_empty(pool, total, why, out):
"""可抓 0 張時講清楚池子去哪了。**不准說成「正常、不是故障」**inkstone/ISEP#130 c7797
09-18 六趟 run 都把「可抓 0 張」寫成正常收工,而池子裡有幾百張 open 票)。"""
w = lambda line="": print(line, file=out)
if not pool:
w("⚪ 主線與逾期里程碑可抓 0 張——**這一趟沒有看票池**,所以這不代表沒事做。")
w(" 要看整個 open 票池:加 `--pool`(雲端工人的環境設了 ISEP_DRAIN=1 就自動看)。")
return
w("⚪ 可抓 0 張:主線、逾期里程碑、open 票池(%d 張)都沒有一張符合抓票規則。" % (total or 0))
w(" 這不是「沒事做」,是池子裡的票全都卡在下面這些格子——把這張表原樣放進收工摘要/載體票,交總管分診:")
for k, n in sorted(why.items(), key=lambda kv: (-kv[1], kv[0]))[:12]:
w(" %4d 張 %s" % (n, k))
w(" 🔴 不要自己改欄位去湊一張可抓的票(把 s/triage 改 s/todo、摘 Human、改指派)——那是總管/leo 的判斷。")
def cmd_claim(argv):
"""認領=指派 s/doing +【身份】留言,**一個動作**。抓票規則在這裡再驗一次。"""
if not argv or argv[0] in ("-h", "--help"):
die(usage_text("claim"), 0 if argv else 2)
owner, repo, num = parse_ref(argv[0])
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else default
name = opt("--name") or "subagent"
ident = "【身份】%s%s/%s%s" % (name, owner, repo, opt("--branch") or "-")
# 身份先驗,**在打任何 API 之前**——名單以外的名字連 Gitea 都不會碰到
check_identity(ident, "認領留言")
ms, _ = load_mainline(argv)
ML = _mainline_lib()
who = opt("--as") or (api("/user") or {}).get("login")
if not who:
die("🔴 拿不到本 token 的使用者名稱,也沒給 --as。")
issue = api("/repos/%s/%s/issues/%d" % (owner, repo, num))
issue.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)})
ims = issue.get("milestone") or {}
ref = "%s/%s#%d" % (owner, repo, num)
# 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題
on_mainline = ref in set(mainline_members(ms)[0])
ok, why = pickable(issue, None if on_mainline else (ims.get("title") or ""), who)
if ok and not on_mainline:
due = ML.due_of(ims)
if not (due and due < _now()):
ok, why = False, "里程碑「%s」既不是主線「%s」也還沒逾期(別 repo 的同名里程碑不算主線)" % (
ims.get("title"), ms["title"])
tier = "(主線)" if on_mainline else "(逾期里程碑,主線已抓盡)"
if not ok and pool_mode(argv):
# 第三層:open 票池(inkstone/ISEP#130)。規則同 pick 那一層,一格不放寬 Human/指派/hub。
pok, pwhy = pickable(issue, None, who, POOL_STATUSES)
if pok:
ok, tier = True, "(open 票池,主線與逾期里程碑已抓盡)"
else:
why = pwhy
if not ok:
die("""🚫 %s/%s#%d 現在不能抓:%s
抓票規則(inkstone/ISEP#131,全部是 Gitea 欄位):
s/* 恰好是 s/todo 沒有 assignee 沒有 Humanhuman/* + 在主線上(hub 里程碑或相依;主線抓盡才抓逾期)
帶 --pool(或環境 ISEP_DRAIN=1)時另收 open 票池裡的 s/todos/backloginkstone/ISEP#130),其他格照舊
出路:`%s pick` 會回一張符合規則的票;這張如果真的該你做,回票上問總管改欄位,不要硬抓。""" % (
owner, repo, num, why, self_path()))
api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"assignees": [who]}, method="PATCH")
before = sorted(n for n in _label_names(issue) if n.startswith("s/"))
keep = [n for n in _label_names(issue) if not n.startswith("s/")] + ["s/doing"]
_set_labels(owner, repo, num, keep)
lines = [ident, "",
"🙋 **認領** → `%s`" % who, "",
"- 里程碑:「%s%s" % (ims.get("title") or ("(無,靠相依在主線上)" if on_mainline else "(無)"), tier),
"- 狀態:%s → s/doing" % "、".join(before),
"- 做完:分支+證據 → `s/review` `ticket handback`(不推 main、不出貨、不改里程碑)"]
api("/repos/%s/%s/issues/%d/comments" % (owner, repo, num), {"body": "\n".join(lines)})
print("✅ 認領 %s/%s#%d → `%s`" % (owner, repo, num, who))
print(" 指派:%s  tag%s" % (who, [n for n in keep if n.startswith("s/")]))
print(" 留言第一行:%s" % ident)
print("\n📌 做完:%s handback %s/%s#%d --to %s --label s/review --next \"\" --evidence <URL>"
% (self_path(), owner, repo, num, HANDBACK_TO))
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#159 — 分診+組里程碑:雲端工人不只「撿」,還要「組」
#
# leo 2026-09-18:「它看到票時要能夠組成 milestone 提出版本,所以不只是看票,還要組票。」
# 「我需要幫忙的 AI,而不是我要一直服務它的 AI。」
# 09-18 實量 inkstone orgopen 302 張,沒有 s/* 的 74 張、沒掛里程碑的 257 張。
#
# 哪張跟哪張重複、哪幾張該組成一輪——那是**判斷**,留給讀票的人(雲端工人);
# 這裡只管判斷寫回 Gitea 的那一步,讓它**一個動作、有說明、碰不到不該碰的**:
# · 判準全是欄位與唯一識別碼(s/* 標籤、assignee 登入名、票號、里程碑 id),沒有一格在猜文字
# · 不該碰的=掛 Human/human/*、指派給別人(含 Leo)——讀可以,一個字都不寫
# · compose 先全部驗過才寫第一筆;驗不過一次列出全部理由(改一格撞一次=閘在懲罰人)
# ══════════════════════════════════════════════════════════════════════════
TRIAGE_TO = ("s/backlog", "s/triage", "s/pending")
COMPOSE_MEMBER_STATUSES = ("s/backlog", "s/todo", "s/doing", "s/review", "s/stage", "s/pending")
COMPOSE_MAX_DAYS = 60
def hands_off(issue, me):
"""這張票能不能寫(留言/改標籤/關)。回 None=可以,否則回理由。**純函式。**"""
if not isinstance(issue, dict) or not issue.get("number"):
return "讀不到這張票"
if issue.get("pull_request"):
return "這是 PR,不是票"
if (issue.get("state") or "open") != "open":
return "已經關了"
labels = _label_names(issue)
if human_gated(labels):
return "掛著 %s——要 leo 親手做的,不碰" % "、".join(
n for n in labels if n == "Human" or n.startswith("human/"))
who = [a.get("login") for a in (issue.get("assignees") or []) if isinstance(a, dict)]
if not who and isinstance(issue.get("assignee"), dict):
who = [issue["assignee"].get("login")]
others = [w for w in who if w and w != me]
if others or (who and not me):
return "指派給 %s——棒子在別人手上,不碰" % "、".join(str(w) for w in who if w)
return None
def _status_of(issue):
return sorted(n for n in _label_names(issue) if n.startswith("s/"))
def triageable(issue, me):
"""待分診=還沒有任何 s/*,而且碰得了。回 (ok, 理由)。**純函式。**"""
why = hands_off(issue, me)
if why:
return False, why
st = _status_of(issue)
if st:
return False, "已經分診過(%s" % "、".join(st)
if _is_carrier(issue):
return False, "是里程碑「%s」的載體票(容器,狀態跟著它的前置走)" % issue["milestone"].get("title")
return True, "ok"
def _is_carrier(issue):
"""InkStoneCo 裡掛在 open 里程碑上的票=載體(開工規約第 7 條)。只看欄位:repo 與里程碑狀態。"""
full = ((issue.get("repository") or {}).get("full_name")) or ""
ims = issue.get("milestone") or {}
return full == "%s/%s" % (ORG, CARRIER_REPO) and bool(ims) and (ims.get("state") or "open") == "open"
def triage_candidates(issues, me):
"""票池裡現在待分診的票,舊的先(放最久的最該有人看一眼)。"""
found = [it for it in issues if triageable(it, me)[0]]
return sorted(found, key=lambda it: (it.get("created_at") or "", _ref_of(it)))
def _opt(argv, name, default=None):
return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else default
def _positional(argv, flags_with_value):
out, skip = [], False
for a in argv:
if skip:
skip = False
continue
if a in flags_with_value:
skip = True
continue
if a.startswith("--"):
continue
out.append(a)
return out
def _fetch(owner, repo, num):
it = api("/repos/%s/%s/issues/%d" % (owner, repo, num))
if isinstance(it, dict):
it.setdefault("repository", {"full_name": "%s/%s" % (owner, repo)})
return it
def _sign(argv):
"""`--sign cloud-worker` ⇒ 留言內文署名 `[cloud-worker]`。身份欄(--name)只收名單上的名字,
雲端工人不在名單上(cloud-worker.md 步驟 1),署名就寫在內文——誰跑的就署誰,不寫死。"""
v = (_opt(argv, "--sign") or "").strip()
return "[%s]" % v if v else ""
def _ident_line(name, owner, repo):
return "【身份】%s%s/%s-" % (name, owner, repo)
# ── triage ───────────────────────────────────────────────────────────────
def cmd_triage(argv):
"""分診一張還沒有 s/* 的票:給狀態+留說明(或當重複關掉並指向留下的那張),一個動作。"""
if not argv or argv[0] in ("-h", "--help"):
die(usage_text("triage"), 0 if argv else 2)
if argv[0] == "--list":
return _triage_list(argv[1:])
ref = argv[0]
owner, repo, num = parse_ref(ref)
to, dup, why = _opt(argv, "--to"), _opt(argv, "--dup-of"), (_opt(argv, "--why") or "").strip()
name = _opt(argv, "--name") or "subagent"
# ── 離線就能判的先判,一通 API 都不打 ──
problems = []
if bool(to) == bool(dup):
problems.append("--to 與 --dup-of 恰好給一個(分到哪個狀態,或跟哪張重複)")
if to and to not in TRIAGE_TO:
extra = ("——s/todo 的意思是「已排進里程碑」,走 `ticket compose` 排進去才給" if to == "s/todo" else "")
problems.append("--to 只收 %s,你給的是 %s%s" % ("".join(TRIAGE_TO), to, extra))
if not why:
problems.append("--why 要寫一句理由——票上那則說明就是它(驗收:每張被動過的票上都說得出分到哪、為什麼)")
dparts = None
if dup:
dparts = parse_ref(dup)
if dparts == (owner, repo, num):
problems.append("--dup-of 指到自己")
if problems:
die("🚫 triage %s 還沒送出,參數要補:\n%s\n\n%s" % (
ref, "\n".join(" · " + p for p in problems), usage_text("triage")))
ident = _ident_line(name, owner, repo)
check_identity(ident, "分診留言")
me = _me()
issue = _fetch(owner, repo, num)
ok, reason = triageable(issue, me)
if not ok:
die("🚫 %s 不分診:%s\n 出路:`%s triage --list` 列得出現在待分診的票;"
"已經有 s/* 的票要改狀態,走 `ticket handback``ticket decide`。" % (ref, reason, self_path()))
before_labels = _label_names(issue)
new_status = "close/duplicate" if dup else to
ids = _labels_of(owner, repo)
if new_status not in ids:
die("🚫 %s/%s 沒有標籤 %s(標籤的唯一真相源是 ISEP labels.yaml,改那裡再跑 gitea-labels-sync"
% (owner, repo, new_status))
target, target_note = None, None
if dup:
target = _fetch(*dparts)
if not isinstance(target, dict) or not target.get("number"):
die("🚫 --dup-of %s 讀不到" % dup)
if target.get("pull_request"):
die("🚫 --dup-of %s 是 PR,不是票" % dup)
if (target.get("state") or "open") != "open":
die("🚫 --dup-of %s 已經關了——跟一張關掉的票「重複」不代表這件事做完了。\n"
" 出路:這張給 --to s/triage 並在 --why 寫「疑似與 %s 重複、那張已關」,交總管看。" % (dup, dup))
target_note = hands_off(target, me)
# ── 寫:說明先落地,再改欄位(改到一半炸掉時,票上至少說得出發生了什麼)──
dref = "%s/%s#%d" % dparts if dparts else None
lines = [ident, "",
"🗂️ **分診**inkstone/ISEP#159%s" % _sign(argv), ""]
if dup:
lines += ["- 與 %s 重複 → 關掉這張,後續在那張" % dref,
"- 為什麼:%s" % why]
else:
lines += ["- 狀態:(沒有 s/*)→ `%s`" % to,
"- 為什麼:%s" % why]
api("/repos/%s/%s/issues/%d/comments" % (owner, repo, num), {"body": "\n".join(lines)})
keep = [n for n in before_labels if not n.startswith("s/")] + [new_status]
api("/repos/%s/%s/issues/%d/labels" % (owner, repo, num), {"labels": [ids[n] for n in keep]}, method="PUT")
if dup:
api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"state": "closed"}, method="PATCH")
if target_note is None:
to_ident = _ident_line(name, dparts[0], dparts[1])
api("/repos/%s/%s/issues/%d/comments" % dparts,
{"body": "\n".join([to_ident, "", "🗂️ %s 與這張重複,已關掉併到這裡(%s%s" % (ref, why, _sign(argv))])})
print("✅ 分診 %s%s" % (ref, ("重複,併入 %s(已關)" % dref) if dup else to))
print(" 票上留了說明:%s" % why)
if dup and target_note:
print(" ⚪ 沒在 %s 留指回來的留言:%s" % (dref, target_note))
def _triage_list(argv):
owner = ORG
me = _me()
issues = pool_issues(owner)
pending = triage_candidates(issues, me)
show = issues if "--all" in argv else pending
if "--json" in argv:
rows = [{"ref": _ref_of(it), "title": it.get("title"), "status": _status_of(it),
"milestone": (it.get("milestone") or {}).get("title"),
"triageable": triageable(it, me)[0], "created_at": it.get("created_at")}
for it in sorted(show, key=lambda it: _ref_of(it))]
print(json.dumps(rows, ensure_ascii=False, indent=2))
return
untouched = sum(1 for it in issues if not _status_of(it) and not triageable(it, me)[0])
print("🗂️ open 票池 %d 張;待分診 %d 張(另有 %d 張沒 s/* 但掛 Human/指派給別人,不碰)"
% (len(issues), len(pending), untouched))
for it in sorted(show, key=lambda it: _ref_of(it)):
st = "、".join(_status_of(it)) or "(沒有 s/*"
print(" %-28s %-12s %s" % (_ref_of(it), st, (it.get("title") or "")[:70]))
if pending:
print("\n📌 %s" % _next_cmd("待分診", _ref_of(pending[0])))
# ── compose ──────────────────────────────────────────────────────────────
def _version_like(title):
"""名字就是版本號(`1.4.52 雲端零件包`)——開工規約第 5 條:版本號是 deliverable,不是名字。"""
return bool(re.match(r"^\s*[vV]?\d+(\.\d+)+", title or ""))
def cmd_compose(argv):
"""把已分診的票組成一個 InkStoneCo 里程碑(載體票+前置),先全部驗過才寫。"""
if not argv or argv[0] in ("-h", "--help"):
die(usage_text("compose"), 0 if argv else 2)
from datetime import datetime, timedelta, timezone
title = (_opt(argv, "--title") or "").strip()
goal = (_opt(argv, "--goal") or "").strip()
due_s = (_opt(argv, "--due") or "").strip()
carrier_s = _opt(argv, "--carrier")
name = _opt(argv, "--name") or "subagent"
members_s = _positional(argv, ("--title", "--goal", "--due", "--carrier", "--name", "--sign"))
problems = []
if not title:
problems.append("--title 要寫:名字回答「這一輪要讓什麼變成真的」(開工規約第 5 條)")
elif _version_like(title):
problems.append("--title「%s」以版本號開頭——版本號是 deliverable 不是名字(開工規約第 5 條,leo 2026-08-24" % title)
if not goal:
problems.append("--goal 要寫一段話:這一輪做完,用戶那邊什麼會變成真的")
due = None
now = _now()
try:
due = datetime.strptime(due_s, "%Y-%m-%d").replace(hour=23, minute=59, second=59, tzinfo=timezone.utc)
except ValueError:
problems.append("--due 要是 YYYY-MM-DD(開工規約第 6 條:期限一律要填),你給的是「%s」" % due_s)
if due and not (now < due <= now + timedelta(days=COMPOSE_MAX_DAYS + 1)):
problems.append("--due %s 不在今天之後 %d 天內——過去的期限等於已逾期,太遠的期限是「沒有期限」穿了期限的衣服"
% (due_s, COMPOSE_MAX_DAYS))
carrier = None
if not carrier_s:
problems.append("--carrier 要給一張 inkstone/InkStoneCo 的既有票當載體(開工規約第 7 條;不開新票)")
else:
carrier = parse_ref(carrier_s)
if carrier[:2] != (ORG, CARRIER_REPO):
problems.append("--carrier %s 不在 %s/%s——里程碑一律建在 InkStoneCo,載體票也在那裡" % (carrier_s, ORG, CARRIER_REPO))
members = []
for m in members_s:
r = parse_ref(m)
if r == carrier:
problems.append("%s 是載體票本身,不用再列成前置" % m)
elif r not in members:
members.append(r)
if not members:
problems.append("至少列一張前置票(別 repo 的執行票)——沒有前置的里程碑交不出版本")
if problems:
die("🚫 compose 還沒送出(一筆都沒寫):\n%s\n\n%s" % (
"\n".join(" · " + p for p in problems), usage_text("compose")))
ident = _ident_line(name, ORG, CARRIER_REPO)
check_identity(ident, "組里程碑留言")
# ── 線上驗:全部讀過一次,理由收齊才決定寫不寫 ──
me = _me()
ML = _mainline_lib()
cref = "%s/%s#%d" % carrier
citem = _fetch(*carrier)
why = hands_off(citem, me)
if why:
problems.append("載體 %s%s" % (cref, why))
elif citem.get("milestone") and (citem["milestone"].get("state") or "open") == "open":
problems.append("載體 %s 已經掛在里程碑「%s」——一張載體只載一輪" % (cref, citem["milestone"].get("title")))
else:
for b in _blocks(*carrier):
bm = (b or {}).get("milestone") or {}
if (b or {}).get("state", "open") == "open" and bm and (bm.get("state") or "open") == "open":
problems.append("載體 %s 已經是 %s(里程碑「%s」)的前置——它那一輪還在跑,不能再載另一輪"
% (cref, ML.issue_ref(b) or "?", bm.get("title")))
break
existing = api("/repos/%s/%s/milestones?state=open&limit=100" % (ORG, CARRIER_REPO)) or []
for m in existing:
if isinstance(m, dict) and ML.norm(m.get("title")) == ML.norm(title):
problems.append("InkStoneCo 已經有 open 里程碑「%s」(id %s)——同名不再建一個" % (m.get("title"), m.get("id")))
rows = []
for mo, mr, mn in members:
mref = "%s/%s#%d" % (mo, mr, mn)
it = _fetch(mo, mr, mn)
why = hands_off(it, me)
if why:
problems.append("前置 %s%s" % (mref, why))
continue
st = _status_of(it)
if len(st) != 1 or st[0] not in COMPOSE_MEMBER_STATUSES:
problems.append("前置 %s 還沒分診(現在 %s)——先 `ticket triage` 決定要不要做" % (mref, st or "沒有 s/*"))
continue
ims = it.get("milestone") or {}
if ims and (ims.get("state") or "open") == "open":
problems.append("前置 %s 已經掛在里程碑「%s」" % (mref, ims.get("title")))
continue
for b in _blocks(mo, mr, mn):
bm = (b or {}).get("milestone") or {}
if (b or {}).get("state", "open") == "open" and bm and (bm.get("state") or "open") == "open":
problems.append("前置 %s 已經是 %s(里程碑「%s」)的前置——一張票同時只排一輪"
% (mref, ML.issue_ref(b) or "?", bm.get("title")))
break
else:
rows.append(((mo, mr, mn), mref, it, st[0]))
if problems:
die("🚫 compose 驗不過(一筆都沒寫):\n%s\n\n"
"出路:把上面每一格換掉(換一張載體、拿掉那張前置、先 triage),再送一次。"
"掛 Human/指派給 Leo 的票不會被組進來——那是 leo 的。" % "\n".join(" · " + p for p in problems))
# ── 寫 ──
member_refs = [r for _, r, _, _ in rows]
desc = "\n".join([
"這一輪要讓什麼變成真的:%s" % goal,
"deliverable=一個總管在 stage 驗過的新版本(版本號由總管定);前置全部關閉=100%=才出版本。",
"載體票:%s;前置:%s" % (cref, "、".join(member_refs)),
"組票:%s %sinkstone/ISEP#159CLAUDE.md 開工規約 17 條)" % (_sign(argv) or name, now.strftime("%Y-%m-%d")),
])
ms = api("/repos/%s/%s/milestones" % (ORG, CARRIER_REPO),
{"title": title, "description": desc, "due_on": due.strftime("%Y-%m-%dT%H:%M:%SZ")})
mid = (ms or {}).get("id")
if not mid:
die("🔴 建里程碑沒拿到 idGitea 回:%s)——後面一筆都沒寫" % str(ms)[:200])
url = "%s/%s/%s/milestone/%s" % (HOST, ORG, CARRIER_REPO, mid)
print("✅ 里程碑「%s」(%s/%s milestone %s,期限 %s" % (title, ORG, CARRIER_REPO, mid, due_s))
api("/repos/%s/%s/issues/%d" % carrier, {"milestone": mid}, method="PATCH")
print(" 載體 %s 掛上里程碑" % cref)
for (mo, mr, mn), mref, it, st in rows:
add_dependency(carrier, (mo, mr, mn))
moved = ""
if st == "s/backlog":
ids = _labels_of(mo, mr)
keep = [n for n in _label_names(it) if not n.startswith("s/")] + ["s/todo"]
if all(n in ids for n in keep):
api("/repos/%s/%s/issues/%d/labels" % (mo, mr, mn), {"labels": [ids[n] for n in keep]}, method="PUT")
moved = "s/backlog → s/todo"
api("/repos/%s/%s/issues/%d/comments" % (mo, mr, mn),
{"body": "\n".join([_ident_line(name, mo, mr), "",
"🧩 排進里程碑「%s」(載體 %s,期限 %s%s %s" % (title, cref, due_s, moved, _sign(argv)),
url])})
print(" 前置 %s%s" % (mref, moved))
api("/repos/%s/%s/issues/%d/comments" % carrier,
{"body": "\n".join([ident, "", "🧩 **組里程碑**inkstone/ISEP#159%s" % _sign(argv), "",
"- 里程碑:「%s」→ %s" % (title, url),
"- 期限:%s" % due_s,
"- 這一輪要讓什麼變成真的:%s" % goal,
"- 前置(全部關閉=100%%=才出版本):%s" % "、".join(member_refs),
"- deliverable:一個總管在 stage 驗過的新版本(版本號由總管定)"])})
print(" %s" % url)
print("\n📌 接下來照里程碑做:`%s pick --pool` 會先回這個里程碑的前置票(還沒到期的里程碑排在散票之前)" % self_path())
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close,
"decide": cmd_decide, "subtask": cmd_subtask, "handoff": cmd_subtask,
"handback": cmd_handback, "mine": cmd_mine, "loose": cmd_loose,
"usage": cmd_usage, "pick": cmd_pick, "claim": cmd_claim,
"triage": cmd_triage, "compose": cmd_compose}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
print(__doc__)
sys.exit(0 if len(sys.argv) < 2 else 2)
CMDS[sys.argv[1]](sys.argv[2:])