Files
ISEP/hooks/lib/roster.py
T
Leo 5ac06abc95 工人有名字+回覆也是派工+未經調查不寫診斷(inkstone/ISEP#86/#87/#88)
三張票同一族(誰在派、派給誰、派的內容住哪裡),做在同一條分支:

#86 工人名單:agents/ 七位有名字的工人+scripts/roster+hooks/roster-guard.sh
    派工用 Task 的 subagent_type 指名,派工單格式一個字都沒改;
    指對名字就把那位的檔案原文注入(你是誰/先讀什麼/你的紅線)。
    【身份】欄同時吃得下工人名字(原本三個角色照舊)。

#87 未經調查不寫診斷:hooks/diagnosis-evidence-guard.sh + investigate-first-stamp.sh
    三個結構訊號(派過人查沒/有沒有走得過去的出處/有沒有份量),
    一個關鍵字比對都沒有;轉述有出處不會被誤擋。

#88 回覆也是派工:不另造閘,把攔截點加掛上去。
    hooks/lib/dispatch_parse.py 的 tool_channel() 一次列全所有通往 subagent 的路
    (SendMessage/雲端 session・trigger/claude -p);擋下來時把那段內容原文印出來。
    subagent 往上回報(to: "main")=交件不是派工,刻意不管。

順手修掉一個真的會咬人的 flake:dispatch-format-guard 原本開四支 python 各讀一個欄位,
機器忙的時候某個欄位會靜靜變空字串(實測連跑 10 次有 1 次「豁免了卻還是被擋」)。
四個欄位改成一次讀完。

版本號待總管定(plugin.json 只更新了描述裡的數字,版本沒動)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
2026-08-28 01:15:09 +00:00

112 lines
4.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_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", ""))