新 repo 的工人不再靠人記得:課程工人 course-hand+開場點名「有票卻沒工人」(inkstone/ISEP#118)

- 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>
This commit is contained in:
2026-09-14 13:13:12 +08:00
parent 25da674615
commit d2f706a16c
11 changed files with 304 additions and 19 deletions
+53 -3
View File
@@ -46,7 +46,18 @@ _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(]+)")
# 一個工人可以管**幾個同一件事的 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():
@@ -81,11 +92,13 @@ def load(directory=None):
if not m:
continue
dm = _DESC_RE.search(head)
rm = _REPO_RE.search(body)
repos = _parse_repos(body)
out.append({
"name": m.group(1).strip(),
"description": (dm.group(1).strip() if dm else ""),
"repo": (rm.group(1).strip() if rm else ""),
# `repo` 維持字串(閘的注入、【身份】欄、one_liner 都吃它);比對一律用 `repos`
"repo": "".join(repos),
"repos": repos,
"path": path,
"body": body.strip(),
})
@@ -113,6 +126,43 @@ def find(name, directory=None):
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", ""))