工人有名字+回覆也是派工+未經調查不寫診斷(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:
+169
-10
@@ -59,6 +59,37 @@
|
||||
第一行就要認得出是誰寫的。角色是三選一的允許清單:`總管`/`subagent`/`leo`。
|
||||
2026-08-27 實害:總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。
|
||||
|
||||
━━ 通往 subagent 的路不只一條(inkstone/ISEP#88)━━━━━━━━━━━━━━━━
|
||||
|
||||
第一版的閘只掛在 `PreToolUse(Task|Agent)`——那只涵蓋「**新開**一個 subagent」。
|
||||
**回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。**
|
||||
實況(2026-08-27 本 session):第一次派工乾乾淨淨只有票號,
|
||||
中途回覆時又把一長串修改要求直接丟過去,**那些話票上一個字都沒有**。
|
||||
那條線被停掉或換人接手,它們就消失——正是壓縮派工單原本要解決的問題。
|
||||
|
||||
⇒ 本檔不再只認一種 payload 形狀。`tool_channel()` 把所有通往 subagent 的路
|
||||
收成一張表(見該函式),一次列全:
|
||||
|
||||
Task / Agent 新開一個 subagent → prompt
|
||||
SendMessage 回覆正在跑的那個 → message
|
||||
*Claude_Code_Remote__create_session 開一個新的雲端 session → prompt
|
||||
*Claude_Code_Remote__send_message 送訊息進某個 session → text / message
|
||||
*Claude_Code_Remote__create_trigger 排程派工(未來會醒) → prompt
|
||||
*Claude_Code_Remote__update_trigger 改排程派工的內容 → prompt
|
||||
*Claude_Code_Remote__fire_trigger 當場點燃排程派工 → text
|
||||
*Claude_Code_Remote__send_later 排一則訊息給自己 → message
|
||||
Bash `claude -p <prompt>` 用 CLI 開一個新 session → 指令裡那段 prompt
|
||||
|
||||
🔴 這張表就是 `ticket-api-bypass-guard.sh` 檔頭記過的那一課的同款:
|
||||
「認動作的方式漏了一條路」是這一族的通病(那支第一版只認大寫裸字 `POST`,
|
||||
於是 `requests.post()` 與 urllib 的隱式 POST 全部漏掉)。
|
||||
⇒ 新增一條路要加在 `tool_channel()`,**不要另造一支平行的閘**。
|
||||
|
||||
兩條路的分界只有一個(其餘判準完全共用,這是刻意的):
|
||||
· `dispatch`(Task/Agent)沒有【工單】 ⇒ **skip**,那是 no-ticket-no-dispatch 的地盤
|
||||
· `reply`(其餘全部)沒有【工單】 ⇒ **違規**,因為那支閘沒有掛在這些路上,
|
||||
不擋就等於這條路整條裸奔
|
||||
|
||||
用法:
|
||||
import dispatch_parse
|
||||
r = dispatch_parse.parse_dispatch(prompt_text)
|
||||
@@ -69,6 +100,7 @@
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
# ── 派工單的字彙表:**只有一個欄位**。這是允許清單,不是黑名單 ──────────────
|
||||
@@ -206,6 +238,22 @@ def dispatch_violations(parsed):
|
||||
return v
|
||||
|
||||
|
||||
def identity_roles():
|
||||
"""合法的身份角色 = 三個固定角色 + **名單上的工人名字**(inkstone/ISEP#86)。
|
||||
|
||||
leo:「票上的紀錄要看得出是哪個工人做的。」`subagent` 這個字回答不了「是誰」——
|
||||
多條線並行時,三個 subagent 的留言長得一模一樣。
|
||||
名單讀不到就退回三個固定角色(fail-open:名單壞掉不該讓人貼不了留言)。
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import roster
|
||||
return tuple(IDENTITY_ROLES) + tuple(roster.names())
|
||||
except Exception:
|
||||
return tuple(IDENTITY_ROLES)
|
||||
|
||||
|
||||
def parse_identity(body):
|
||||
"""交件回覆的【身份】欄。回 (ok, detail)。"""
|
||||
for raw in (body or "").split("\n"):
|
||||
@@ -218,12 +266,105 @@ def parse_identity(body):
|
||||
if not val:
|
||||
return False, "【身份】後面是空的"
|
||||
role = re.split(r"[//]", val)[0].strip()
|
||||
if role not in IDENTITY_ROLES:
|
||||
return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(IDENTITY_ROLES))
|
||||
allowed = identity_roles()
|
||||
if role not in allowed:
|
||||
return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(allowed))
|
||||
return True, val
|
||||
return False, "內文是空的"
|
||||
|
||||
|
||||
# ── 通往 subagent 的路:一張表,一次列全(inkstone/ISEP#88)────────────────
|
||||
# 新增一條路加在這裡。**不要另造一支平行的閘**——票上寫死了:
|
||||
# 「要嘛把攔截點加掛上去,要嘛讓兩條路共用同一個判斷函式。」
|
||||
#
|
||||
# 值是「這個工具的哪幾個欄位裝著要送給 subagent 的話」,依序取第一個有內容的。
|
||||
_DISPATCH_TOOLS = {
|
||||
"Task": ("prompt",),
|
||||
"Agent": ("prompt",),
|
||||
}
|
||||
_REPLY_TOOLS = {
|
||||
"SendMessage": ("message",),
|
||||
"create_session": ("prompt",),
|
||||
"send_message": ("text", "message"),
|
||||
"create_trigger": ("prompt",),
|
||||
"update_trigger": ("prompt",),
|
||||
"fire_trigger": ("text",),
|
||||
"send_later": ("message",),
|
||||
}
|
||||
# `claude -p <prompt>` = 用 CLI 開一個新 session。判準是**指令名**(結構),
|
||||
# 不是指令內容裡有沒有某個詞——所以一段剛好含有「【工單】」字樣的 echo 不會被誤認。
|
||||
_CLAUDE_CLI_RE = re.compile(r"(?:^|[;&|]\s*|\s)claude\s")
|
||||
_CLI_PRINT_FLAGS = ("-p", "--print")
|
||||
# 這些旗標後面跟的是它自己的值,不是 prompt
|
||||
_CLI_VALUE_FLAGS = {"--model", "-m", "--append-system-prompt", "--system-prompt",
|
||||
"--allowedTools", "--permission-mode", "--output-format",
|
||||
"--input-format", "--session-id", "--resume", "--add-dir",
|
||||
"--mcp-config", "--settings", "--agents"}
|
||||
|
||||
|
||||
def _claude_cli_prompt(command):
|
||||
"""從一段 shell 指令裡把 `claude -p <prompt>` 的 prompt 取出來。取不到回 ""。"""
|
||||
if not command or not _CLAUDE_CLI_RE.search(command):
|
||||
return ""
|
||||
try:
|
||||
toks = shlex.split(command)
|
||||
except Exception:
|
||||
return ""
|
||||
for i, t in enumerate(toks):
|
||||
if t.rsplit("/", 1)[-1] != "claude":
|
||||
continue
|
||||
rest = toks[i + 1:]
|
||||
if not any(f in rest for f in _CLI_PRINT_FLAGS):
|
||||
return "" # 沒有 -p ⇒ 不是一次性的 prompt 執行
|
||||
skip = False
|
||||
for j, tok in enumerate(rest):
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if tok in _CLI_VALUE_FLAGS:
|
||||
skip = True
|
||||
continue
|
||||
if tok.startswith("-"):
|
||||
continue
|
||||
return tok # 第一個位置參數 = prompt
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def tool_channel(payload):
|
||||
"""回 (channel, text)。channel ∈ {"dispatch", "reply", ""}。
|
||||
|
||||
"" = 這個工具呼叫不是在驅動任何 subagent,本閘管不到。
|
||||
"""
|
||||
name = payload.get("tool_name") or ""
|
||||
ti = payload.get("tool_input") or {}
|
||||
if not isinstance(ti, dict):
|
||||
return "", ""
|
||||
short = name.rsplit("__", 1)[-1]
|
||||
|
||||
if name == "Bash" or short == "Bash":
|
||||
return ("reply", _claude_cli_prompt(ti.get("command") or ""))
|
||||
|
||||
# 🔴 方向很重要:本規則管的是「**派工的人**送出去的話」。
|
||||
# subagent 往上回報(`to: "main"`)是**交件**,不是派工——
|
||||
# 擋它等於擋掉交件本身。交件的規矩由 baton-handback-guard/
|
||||
# reply-identity-guard 管,不在這裡。
|
||||
if (name == "SendMessage" or short == "SendMessage") and \
|
||||
str(ti.get("to") or "").strip().lower() == "main":
|
||||
return "", ""
|
||||
|
||||
for table, channel in ((_DISPATCH_TOOLS, "dispatch"), (_REPLY_TOOLS, "reply")):
|
||||
keys = table.get(name) or table.get(short)
|
||||
if not keys:
|
||||
continue
|
||||
for k in keys:
|
||||
v = ti.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return channel, v
|
||||
return channel, ""
|
||||
return "", ""
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────
|
||||
def _main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "dispatch"
|
||||
@@ -237,21 +378,39 @@ def _main():
|
||||
print(json.dumps({"status": "skip", "why": "未知模式 %s" % mode}))
|
||||
return
|
||||
|
||||
prompt = (payload.get("tool_input") or {}).get("prompt") or ""
|
||||
parsed = parse_dispatch(prompt)
|
||||
|
||||
# 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。
|
||||
# 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。
|
||||
if not parsed["has_ticket_marker"]:
|
||||
print(json.dumps({"status": "skip", "why": "沒有【工單】,交給 no-ticket-no-dispatch"}))
|
||||
channel, text = tool_channel(payload)
|
||||
if not channel:
|
||||
print(json.dumps({"status": "skip", "why": "不是通往 subagent 的路"}))
|
||||
return
|
||||
if channel == "reply" and not (text or "").strip():
|
||||
print(json.dumps({"status": "skip", "why": "這條路這次沒有帶任何話"}))
|
||||
return
|
||||
|
||||
parsed = parse_dispatch(text)
|
||||
|
||||
if not parsed["has_ticket_marker"]:
|
||||
if channel == "dispatch":
|
||||
# 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。
|
||||
# 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。
|
||||
print(json.dumps({"status": "skip",
|
||||
"why": "沒有【工單】,交給 no-ticket-no-dispatch"}))
|
||||
return
|
||||
# reply 這條路上**沒有**那支閘(它只掛 Task|Agent)⇒ 不擋就整條裸奔。
|
||||
violations = [{"code": "回覆沒有票號",
|
||||
"detail": "回覆也是派工:內容寫進票,訊息只給【工單】owner/repo#N"}]
|
||||
extra_text = (text or "").strip()
|
||||
else:
|
||||
violations = [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)]
|
||||
extra_text = "\n".join(r.rstrip() for _, r in parsed["extra"]).strip()
|
||||
|
||||
print(json.dumps({
|
||||
"status": "ok",
|
||||
"violations": [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)],
|
||||
"channel": channel,
|
||||
"violations": violations,
|
||||
"refs": parsed["refs"],
|
||||
"retired_seen": parsed["retired_seen"],
|
||||
"extra_lines": parsed["extra_lines"],
|
||||
"extra_text": extra_text,
|
||||
"session_id": payload.get("session_id") or "",
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hooks/lib/evidence.py — 「這段要寫上票的內容,帶不帶得出出處?」(inkstone/ISEP#87)
|
||||
|
||||
依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block,
|
||||
判決由呼叫它的閘做(`hooks/diagnosis-evidence-guard.sh`)。
|
||||
|
||||
━━ 規則本身(leo,inkstone/ISEP#87)━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
「身為派人去查的人,我要自己在拿到調查結果前寫不出診斷,
|
||||
我才不會用猜的結論把工人的判斷力關掉。」
|
||||
|
||||
票上原文的兩個壞法:
|
||||
· 診斷寫完了,**工人就不知道自己要做什麼**——它會照著我的結論去驗證,而不是去查
|
||||
· **我不是那個 repo 的專家,它才是**。我的結論通常是猜的,
|
||||
但因為寫在票上,看起來就像事實
|
||||
|
||||
⇒ 順序倒過來:**先派人查 → 拿到查的結果 → 才可以寫診斷。**
|
||||
|
||||
━━ 🔴 為什麼判準不能是「找出診斷的句型」━━━━━━━━━━━━━━━━━━━━
|
||||
票上寫死:「『根因』『因為』『應該是』這類詞**一律不准當判準**」。
|
||||
leo 2026-08-17 已證偽文字層封路:當日 **8 次誤攔、0 次正確攔截**,而且方向穩定——
|
||||
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
|
||||
|
||||
所以本檔**沒有任何「命中某個詞就違規」的比對**。它只做一件事:
|
||||
**認出「出處」在不在**——而且是**往放行的方向**做字面比對
|
||||
(認出來 ⇒ 放行;認不出來 ⇒ 交給閘去看別的結構訊號)。
|
||||
這是 `pr-verdict-guard.sh` ③ 用過的形狀:識別碼有限且唯一,
|
||||
比對錯了只會**少擋一次**,不會多罰一次。
|
||||
|
||||
━━ 什麼算「出處」(七種,全部是可以走過去看的東西)━━━━━━━━━━━━━━
|
||||
檔案:行號 / commit sha / comment 號 / 票號 / 網址 /
|
||||
檔案路徑(帶目錄與副檔名)/ 圍欄裡的實測輸出
|
||||
|
||||
共同點:**讀的人可以自己走過去確認**。這正是「轉述別人查到的東西」與
|
||||
「我猜的」之間唯一機械分得出來的差別(票上驗收條件 4:有出處的轉述不能被誤擋)。
|
||||
|
||||
用法:
|
||||
import evidence
|
||||
hits = evidence.sources(text) # [(種類, 命中的字串), ...]
|
||||
body = evidence.body_of(command) # 從一段 shell 指令裡把「要貼上去的內文」挖出來
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
# ── 七種出處。**方向是放行**:認出來就代表這段話走得回它的來源 ──────────────
|
||||
_SOURCE_PATTERNS = [
|
||||
("檔案:行號", re.compile(r"[\w./-]+\.\w{1,6}:\d+")),
|
||||
("commit", re.compile(r"\b[0-9a-f]{7,40}\b")),
|
||||
("comment 號", re.compile(r"(?:#issuecomment-|comment\s*)\d{2,}", re.I)),
|
||||
("票號", re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+")),
|
||||
("網址", re.compile(r"https?://\S{4,}")),
|
||||
("檔案路徑", re.compile(r"(?<![\w/])[\w.-]+/[\w./-]*\w\.\w{1,6}(?![\w])")),
|
||||
("實測輸出", re.compile(r"^\s*(?:```|~~~)", re.M)),
|
||||
]
|
||||
|
||||
# 身份欄本身不算出處——它說的是「誰寫的」,不是「這句話哪來的」。
|
||||
_IDENTITY_LINE_RE = re.compile(r"^【身份】.*$", re.M)
|
||||
|
||||
|
||||
def sources(text):
|
||||
"""回 [(種類, 命中的字串)]。空清單 = 這段話裡沒有任何走得過去的出處。"""
|
||||
if not text:
|
||||
return []
|
||||
body = _IDENTITY_LINE_RE.sub("", text)
|
||||
out = []
|
||||
for kind, pat in _SOURCE_PATTERNS:
|
||||
m = pat.search(body)
|
||||
if m:
|
||||
out.append((kind, m.group(0)[:60]))
|
||||
return out
|
||||
|
||||
|
||||
def substance(text):
|
||||
"""扣掉身份欄與空白之後還剩多少字。
|
||||
|
||||
**長度是結構訊號,不是措辭**:一句「收到,我來看」不可能是診斷;
|
||||
診斷天生是一段有因果的敘述。用它把閘收窄,是為了不要天天誤擋
|
||||
——`.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
body = _IDENTITY_LINE_RE.sub("", text)
|
||||
return len(re.sub(r"\s+", "", body))
|
||||
|
||||
|
||||
# ── 從一段 shell 指令裡把「要貼上去的內文」挖出來 ────────────────────────────
|
||||
# 做法與 comment-carries-task-guard.sh 相同:有 `-F <檔>` 就讀那個檔,
|
||||
# 否則就拿指令文字本身當內文(PreToolUse 只看得到指令,這是這一層的天花板)。
|
||||
# 🔴 一定要把「真正要貼上去的那段字」跟指令的鷹架分開。第一版沒分,
|
||||
# 結果 `curl … https://git.uncle6.me/api/v1/…/comments -d '{"body":"<沒出處的診斷>"}'`
|
||||
# **永遠放行**——因為指令裡的**那個 URL 自己就被當成出處了**。
|
||||
# 同一個病 `ticket-api-bypass-guard.sh` 檔頭也記過:認錯了要看的東西,
|
||||
# 閘就在驗一個跟規則無關的欄位。
|
||||
_FARG_RE = re.compile(r"(?:^|\s)(?:-F|--file)\s+([^\s'\"]+)")
|
||||
_BODY_FLAG_RE = re.compile(r"(?:^|\s)--body[\s=]+(['\"])(.*?)\1", re.S)
|
||||
_JSON_BODY_RE = re.compile(r"""['\"]body['\"]\s*:\s*['\"](.*?)['\"]\s*[,}]""", re.S)
|
||||
|
||||
|
||||
def body_of(command):
|
||||
"""回 (內文, 來源)。來源 ∈ {"file", "--body", "json", "command"}。
|
||||
|
||||
依序試四種:`-F <檔>` → `--body <字串>` → payload 裡的 `body` 欄 → 整段指令。
|
||||
前三種都是「這段字才是要貼上票的東西」,指令的其餘部分(網址、token、旗標)
|
||||
**不算內文**——它們不是誰查來的,把它們算成出處等於這道閘自己拆自己。
|
||||
"""
|
||||
if not command:
|
||||
return "", "command"
|
||||
m = _FARG_RE.search(command)
|
||||
if m:
|
||||
path = m.group(1)
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
return f.read(), "file"
|
||||
except Exception:
|
||||
pass
|
||||
m = _BODY_FLAG_RE.search(command)
|
||||
if m:
|
||||
return m.group(2), "--body"
|
||||
m = _JSON_BODY_RE.search(command)
|
||||
if m:
|
||||
return m.group(1).replace("\\n", "\n"), "json"
|
||||
return command, "command"
|
||||
@@ -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` 的身份欄)。
|
||||
|
||||
━━ 規則本身(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", ""))
|
||||
Reference in New Issue
Block a user