Files
ISEP/hooks/lib/roster.py
T
Leo d2f706a16c 新 repo 的工人不再靠人記得:課程工人 course-hand+開場點名「有票卻沒工人」(inkstone/ISEP#118)
- agents/course-hand.md:管 inkstone/ax-courses、course_gen、llm-wiki-template(c7358 leo「你可以製作一個」)
  假設:llm-wiki-template 也歸課程工人(同為課程素材),總管不同意就把它拆出去
- hooks/lib/roster.py:`負責 repo` 可列多個(、或逗號),新增 which()/uncovered();find() 與閘本身未動
- scripts/roster which 改走 roster.which
- scripts/isep-nag 第④類:org 裡有開著的票、名單上卻沒人負責的 repo,只進開場長版、不進 Telegram
- 測試:roster-guard 25→31、overdue-nag 36→43;兩種突變(拿掉票數條件/只認第一個 repo)都會紅
- 文件:worker-roster.md 回答「新 repo 的工人誰在什麼時候加」;README/plugin.json 工人 8、scripts 65(樹上實數)

待總管定版(改了 agents/scripts,要升版才裝得上)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:13:12 +08:00

169 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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
"""hooks/lib/roster.py — 工人名單的**唯一讀取點**inkstone/ISEP#86)。
依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block
判決由呼叫它的閘做(`hooks/roster-guard.sh`、`scripts/roster`、
`hooks/lib/dispatch_parse.py` 的身份欄)。
━━ 規則本身(leoinkstone/ISEP#86)━━━━━━━━━━━━━━━━━━━━━━━━━
「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」
實查(開票當日):`/root/.claude/agents/` 不存在、`InkStoneCo/.claude/agents/`
只有一支內建的說明用 agent ⇒ **派工派給的是一個沒有名字的臨時工**。
後果三件:票上看不出誰做的/每次派工都要重新交代它是誰(而派工單只准寫票號)/
同一個 repo 這次派的跟上次派的沒有任何連續性。
━━ 為什麼名單住在 `agents/`,不是自己造一份 YAML ━━━━━━━━━━━━━━━━
`agents/` 是 Claude Code plugin **原生**的 subagent 目錄,frontmatter 的 `name`
就是 `Task` 工具的 `subagent_type`。⇒ 「指名派給誰」用的是既有欄位,
**派工單格式一個字都不用改**(仍然只有一行 `【工單】`)。
這也是本 repo 的慣例:不要重造輪子(見 CLAUDE.md 2.6「為什麼是 issue 不是 Notion」)。
━━ 檔案格式(四段,缺一不可)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
---
name: isep-hand ← 派工時寫進 subagent_type 的那個字
description: 一句話它是誰 ← `roster list` 印的就是這一行
---
**負責 repo**inkstone/ISEP ← 挑人的判準,也是【身份】欄第二格
## 開工前先讀 ← 每個工人各自不同,共通規定補不了
## 紅線 ← 只寫這個 repo 特有的
用法:
import roster
ws = roster.load() # [{'name','description','repo','path','body'}]
w = roster.find("isep-hand") # 找不到回 None
roster.names() # ['arcrun-hand', ...]
"""
import os
import re
_HERE = os.path.dirname(os.path.abspath(__file__))
# hooks/lib/ → hooks/ → plugin root
_DEFAULT_ROOT = os.path.abspath(os.path.join(_HERE, "..", ".."))
_NAME_RE = re.compile(r"^name:\s*(.+?)\s*$", re.M)
_DESC_RE = re.compile(r"^description:\s*(.+?)\s*$", re.M)
# 「**負責 repo**inkstone/ISEP」——粗體記號與全形冒號都當可選,
# 免得改一次排版就讓名單讀不到 repo(那會靜默失效,比擋錯更難發現)。
# 一個工人可以管**幾個同一件事的 repo**inkstone/ISEP#118:課程工人同時管課件內容
# `ax-courses`、引擎 `course_gen`、學員範本 `llm-wiki-template`)——用「、」或逗號隔開。
# 抓到行尾或第一個括號為止,再切開;只收長得像 `owner/repo` 的片段。
_REPO_RE = re.compile(r"\*{0,2}負責\s*repo\*{0,2}\s*[:]\s*([^\n(]+)")
_REPO_SPLIT = re.compile(r"[、,\s]+")
def _parse_repos(body):
m = _REPO_RE.search(body)
if not m:
return []
return [p for p in _REPO_SPLIT.split(m.group(1).strip()) if "/" in p]
def roster_dir():
"""名單目錄。`ISEP_ROSTER_DIR` 只給測試用(不碰真的名單)。"""
d = os.environ.get("ISEP_ROSTER_DIR")
if d:
return d
root = os.environ.get("CLAUDE_PLUGIN_ROOT") or _DEFAULT_ROOT
return os.path.join(root, "agents")
def load(directory=None):
"""讀出整份名單。讀不到目錄就回空清單(**不丟例外**——
呼叫它的閘要能在名單不在場時 fail-open,不能把 session 鎖死)。"""
d = directory or roster_dir()
out = []
try:
files = sorted(f for f in os.listdir(d) if f.endswith(".md"))
except Exception:
return out
for fn in files:
path = os.path.join(d, fn)
try:
with open(path, encoding="utf-8", errors="ignore") as f:
text = f.read()
except Exception:
continue
head, _, body = text.partition("---\n")[2].partition("\n---")
if not head: # 沒有 frontmatter ⇒ 不是工人檔
continue
m = _NAME_RE.search(head)
if not m:
continue
dm = _DESC_RE.search(head)
repos = _parse_repos(body)
out.append({
"name": m.group(1).strip(),
"description": (dm.group(1).strip() if dm else ""),
# `repo` 維持字串(閘的注入、【身份】欄、one_liner 都吃它);比對一律用 `repos`
"repo": "、".join(repos),
"repos": repos,
"path": path,
"body": body.strip(),
})
return out
def names(directory=None):
return [w["name"] for w in load(directory)]
def find(name, directory=None):
# 🔴 subagent_type 在 plugin 裡帶前綴:Claude Code 給的是 "isep:isep-hand"
# 而 agents/*.md 的 frontmatter name 是 "isep-hand"。原本用完全相等比對
# ⇒ 真實的派工一律認不出來,而名單裡的名字工具又不收 ⇒ 兩種寫法都派不出去。
# 2026-08-28 v0.16.0 上線後實撞:整個環境派不出任何工,只能蓋 solo 戳記自己修。
# 測試沒抓到是因為 24 條全部餵手寫 payload,沒有一條用真實的 subagent_type 形狀。
n = (name or "").strip()
if not n:
return None
# 允許 "<plugin>:<name>";冒號後面那半才是名單上的名字
bare = n.rsplit(":", 1)[-1].strip() if ":" in n else n
for w in load(directory):
if w["name"] == n or w["name"] == bare:
return w
return None
def which(repo, directory=None, workers=None):
"""這個 `owner/repo` 該派給誰 ⇒ 回工人清單(可能是空的)。大小寫不分。"""
want = (repo or "").strip().lower()
if not want:
return []
ws = workers if workers is not None else load(directory)
return [w for w in ws if want in (r.lower() for r in w.get("repos") or [])]
def uncovered(repo_rows, directory=None):
"""給 Gitea `/orgs/{org}/repos` 回來的列 ⇒ 回「**有開著的票、卻沒有工人**」的 repo。
inkstone/ISEP#118:新 repo 的工人從來沒有人負責加——建 repo 的那一刻沒有任何東西提醒,
要等到第一次派工撞上 roster-guard 才發現,而那時最省事的出路是開例外閘繞過去。
判準只看兩個事實,不看名字長相:
· 名單上**沒有**任何工人的 `負責 repo` 包含它(唯一識別碼比對,方向是放行)
· 它**有開著的票**`open_issues_count > 0`Gitea 這個數字不含 PR
⇒ 沒票的 repo(封存、投影用、還沒開張的)不叫:沒有工可派,就不是缺人
封存的 repo 不叫。名單讀不到 ⇒ 回 None(**不是**「全部都缺人」——那是最貴的假情報)。
"""
ws = load(directory)
if not ws:
return None
out = []
for r in repo_rows or []:
full = (r or {}).get("full_name") or ""
if not full or r.get("archived"):
continue
n = int(r.get("open_issues_count") or 0)
if n <= 0 or which(full, workers=ws):
continue
out.append({"repo": full, "open": n})
out.sort(key=lambda x: (-x["open"], x["repo"]))
return out
def one_liner(w):
repo = w.get("repo") or "(跨 repo"
return "%-18s %-26s %s" % (w["name"], repo, w.get("description", ""))