新 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 -6
View File
@@ -20,10 +20,15 @@ leo 整天上課,一天看兩眼。**不叫,就等於這件事不會發生
(機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。
**但腳本版本身是完整可用的,不是半成品。**
━━ 撈種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
━━ 撈種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了
② 掛著等 leo 的票 `Human` 或指派給 leo**標等了幾天**
③ 棒子掉在地上的票 `s/doing``s/review` 卻很多天沒動 ⇒ 沒有人在做
④ 有票卻沒有工人的 repo 名單(`agents/`)上沒人負責、卻有開著的票
⇒ 派工一定撞 roster-guardinkstone/ISEP#118
**只進長版**(開場給總管看),不進 Telegram 短版——補工人是總管的事,不吵 leo。
它回答的是「新 repo 的工人誰在什麼時候加」:建 repo 之後的**第一個 session 開場**
由這支點名;在那之前沒有任何東西會提醒。
每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、
講「這對你意味什麼」而不是系統內部狀態。
@@ -133,10 +138,12 @@ def collect(fixture=None):
return {"milestones": [], "issues": [], "error":
"拿不到 Gitea tokenGITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"}
# org 的 repo 清單**一律撈一次**:④「有票卻沒工人」要看整個 org,
# 而且這一通本來就要打(沒設 ISEP_NAG_REPOS 時),不多花 API。
org_rows = api("/orgs/%s/repos?limit=50" % ORG, tok) or []
repos = os.environ.get("ISEP_NAG_REPOS", "").split()
if not repos:
rows = api("/orgs/%s/repos?limit=50" % ORG, tok) or []
repos = [r.get("full_name") for r in rows if r.get("full_name")]
repos = [r.get("full_name") for r in org_rows if r.get("full_name")]
# 🔴 平行撈,因為這支掛在 SessionStart 上——那是 leo 每次開工的關鍵路徑。
# 循序版實測 16 秒(16 個 repo + 5 頁票),**開場等 16 秒沒有人會忍受它,
@@ -175,7 +182,7 @@ def collect(fixture=None):
issues.extend(rows)
if len(rows) < 50:
break
return {"milestones": milestones, "issues": issues}
return {"milestones": milestones, "issues": issues, "repos": org_rows}
# ══ 二、找出「該叫的事」═══════════════════════════════════════════════════
@@ -227,7 +234,25 @@ def findings(data, now):
waiting.sort(key=lambda x: -x["quiet_days"])
dropped.sort(key=lambda x: -x["quiet_days"])
return {"overdue_milestones": over, "waiting_on_leo": waiting,
"dropped_batons": dropped}
"dropped_batons": dropped,
"repos_without_worker": no_worker(data)}
def no_worker(data):
"""④ 有票卻沒有工人的 repoinkstone/ISEP#118)。
回 list(可能是空的);**名單讀不到或 org 清單沒撈到 ⇒ 回 None**
報告裡講「沒查到」,不講成「每個 repo 都缺人」或「都有人」。
"""
rows = data.get("repos")
if not rows:
return None
try:
sys.path.insert(0, os.path.join(HERE, "..", "hooks", "lib"))
import roster
except Exception:
return None
return roster.uncovered(rows)
# ══ 三、講出來(白話)═════════════════════════════════════════════════════
@@ -260,12 +285,13 @@ def full_report(f, now, err=None):
" ⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。兩件事不要講成同一句。"]
return "\n".join(out)
n = sum(len(v) for v in f.values())
n = sum(len(f[k]) for k in ("overdue_milestones", "waiting_on_leo", "dropped_batons"))
if n == 0:
out += ["", "✅ **查過了,沒有。** 三類都乾淨:",
" · 沒有逾期還開著票的 milestone",
" · 沒有掛著等你的票",
" · 沒有超過 %d 天沒動的 s/doings/review" % STALE_DAYS]
out += worker_section(f)
return "\n".join(out)
if f["overdue_milestones"]:
@@ -295,9 +321,30 @@ def full_report(f, now, err=None):
_clip(i["title"], 38)))
out += _tail(f["dropped_batons"], MAX_ROWS)
out.append(" ⇒ 對你意味著:這些不是在做,是沒人接。要嘛派人,要嘛改回 s/todo。")
out += worker_section(f)
return "\n".join(out)
def worker_section(f):
"""④ 只進長版(開場注入給總管看的那份),**不進 Telegram 短版**
派工是總管的事,不是 leo 的事——拿它去吵 leo 就是把總管的活推給他。"""
rows = f.get("repos_without_worker")
if rows is None:
return ["", "👷 有票卻沒有工人的 repo:**沒查到**(org 清單或工人名單讀不到)"
"——這不等於「每個 repo 都有人」。"]
if not rows:
return ["", "👷 每個有開著票的 repo,名單上都有工人。"]
out = ["", "👷 **有票卻沒有工人的 repo(%d 個)**——派工到這裡會被 roster-guard 擋,"
"只剩開例外閘繞過去:" % len(rows)]
for r in rows[:MAX_ROWS]:
out.append(" · %s%d 張票開著)" % (r["repo"], r["open"]))
out += _tail(rows, MAX_ROWS)
out.append(" ⇒ 對總管意味著:**先補工人再派工**——在 ISEP `agents/` 加一個 `.md`"
"(規約 docs/governance/worker-roster.md),或把它寫進既有工人的 `負責 repo`。"
"不要開 `/tmp/.roster-ok-*` 了事,那樣下一次還是會撞。")
return out
def short_report(f, now, err=None):
"""Telegram 版:leo 在手機上一眼掃完、回一個詞。**寧短勿長。**"""
if err:
+1 -1
View File
@@ -53,7 +53,7 @@ def cmd_show(name):
def cmd_which(repo):
hits = [w for w in R.load() if w["repo"].lower() == (repo or "").strip().lower()]
hits = R.which(repo) # 一個工人可以管好幾個 repoinkstone/ISEP#118
if not hits:
print("🚫 名單上沒有負責「%s」的人。現有的是:" % repo, file=sys.stderr)
for w in R.load():