#!/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` 的身份欄)。 ━━ 規則本身(leo,inkstone/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_RE = re.compile(r"\*{0,2}負責\s*repo\*{0,2}\s*[::]\s*([^\s((]+)") 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) rm = _REPO_RE.search(body) out.append({ "name": m.group(1).strip(), "description": (dm.group(1).strip() if dm else ""), "repo": (rm.group(1).strip() if rm else ""), "path": path, "body": body.strip(), }) return out def names(directory=None): return [w["name"] for w in load(directory)] def find(name, directory=None): n = (name or "").strip() if not n: return None for w in load(directory): if w["name"] == n: return w return None def one_liner(w): repo = w.get("repo") or "(跨 repo)" return "%-18s %-26s %s" % (w["name"], repo, w.get("description", ""))