工人有名字+回覆也是派工+未經調查不寫診斷(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
This commit is contained in:
2026-08-28 01:15:09 +00:00
parent bebbbd2c11
commit 5ac06abc95
25 changed files with 1853 additions and 47 deletions
+111
View File
@@ -0,0 +1,111 @@
#!/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", ""))