d2f706a16c
- 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>
169 lines
7.6 KiB
Python
169 lines
7.6 KiB
Python
#!/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**(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", ""))
|