Files
ISEP/scripts/ticket
T
Leo 58c3689e4b 閘教的那條出路要真的走得通(inkstone/ISEP#112)
comment-carries-task-guard 擋下留言時教人跑 `scripts/ticket subtask …`,
而那一行跑不動。兩個獨立的原因,各自都足以害死它:

① 相對路徑對「貼上去那個人的 cwd」解析。總管的 cwd 是 InkStoneCo/,
   那裡的 scripts/ticket 是停在 08-27 之前的舊複本(grep -c subtask → 0),
   照著貼只印出它「四個動詞」的說明然後 exit 0。
② 用法是手抄的第二份,而它已經漂了:寫了 --assign 卻沒有 --next,
   正本會因為「指派了人卻沒寫 --next」直接擋下(exit 2,在打 API 之前)。

修法是拿掉可以漂的那一格,不是把兩份對齊一次:
- 路徑     scripts/ticket 的 self_path()(__file__ 的絕對路徑)
           ⇒ 印指令的人與跑指令的人是同一個檔案
- 用法     USAGE / usage_text() 一份,閘去 `ticket usage subtask --example` 現要
- 內文模板 body_template() 由 REQUIRED_SECTIONS 現生,跟檢查同源

新增動詞 `ticket usage <verb> [--example] [--parent] [--draft]`。
閘的訊息改成整塊可貼可跑:腳本絕對路徑、母票已填、內文草稿附在同一塊裡,
只剩標題/目標/驗收條件三格是機器不可能知道的。
正本叫不動時 fail-open 的只有「訊息內容」(只指路,不補一份手抄用法),
擋不擋不受影響。

測試 scripts/test-comment-carries-task-guard.sh 22 → 29 條。
新增的 ⑮–㉑ 驗的是「出路走不走得通」,不是「有沒有擋」——
⑱ 把閘 stderr 裡那塊指令原樣抽出來、只填三格、真的執行一次,
離開碼必須是 1(走到網路才停);是 2 就代表這個病復發。
六個變異全部驗過會紅(相對路徑/閘自己抄用法/--assign 沒配 --next/
模板寫死少一段/fallback 補回手抄用法)。

版本:待總管定版(plugin update 比的是版本號不是內容)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SP7DiGMfSkWjZ15CHMbPFE
2026-08-31 11:01:45 +00:00

1183 lines
61 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 <檔> 開新票(要戳記+模板欄位齊全)
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] 用法的唯一真相源——**閘印的那一行從這裡來**
票的寫法:`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""")
def api(path, payload=None, method=None):
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
url, data=data, method=method or ("POST" if data else "GET"),
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:
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}")
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 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> -F <內文檔> [--title <標題>]")
repo = 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 _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 = {
"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 = 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}")
# 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到
api(f"/repos/{powner}/{prepo}/issues/{pnum}/dependencies",
{"owner": ORG, "repo": repo, "index": 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
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}
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:])