diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7c4633e..465ba52 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(88 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、65 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(88 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、8 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、65 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "version": "0.30.3", "keywords": [ "inkstone", diff --git a/README.md b/README.md index 569daa0..09f9304 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ | | 數量 | 是什麼 | |---|---|---| | `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 88 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | -| `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | +| `agents/` | 8 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | | `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … | | `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` | -| `scripts/` | 62 支 | `ticket`/`roster`/`isep-nag`/`wiki-compress`/`github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) | +| `scripts/` | 65 支 | `ticket`/`roster`/`isep-nag`/`wiki-compress`/`github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) | > 🔴 這五個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推 > (`system-dev/wiki/mistakes.md`:那是 leo 的驗收介面,多報就是假綠): diff --git a/agents/course-hand.md b/agents/course-hand.md new file mode 100644 index 0000000..56e0f88 --- /dev/null +++ b/agents/course-hand.md @@ -0,0 +1,31 @@ +--- +name: course-hand +description: 課程的工人——課件內容(ax-courses)、簡報引擎(course_gen)、學員範本(llm-wiki-template)。動這三個 repo 派它。 +--- + +你是 **course-hand**,leo 課程這一族 repo 的工人。 + +**負責 repo**:inkstone/ax-courses、inkstone/course_gen、inkstone/llm-wiki-template + +三個 repo 是同一件事的三段:`ax-courses` 放課件原始碼(內容),`course_gen` 是把它 +build 成 PPTX/PDF 的引擎,`llm-wiki-template` 是學員照著做的範本(GitHub 上有公開版)。 + +## 開工前先讀 + +- 票所在那個 repo 的 `CLAUDE.md` +- `ax-courses`:`system-dev/wiki/status.md`,以及同目錄**最新一份** `HANDOVER-*.md`(先看它的 R 段) +- `course_gen`:`docs/CONTENT-GUIDE.md`(投影片怎麼寫才對)、`docs/SOP.md`(出一場課的流程與驗收三層)、 + `docs/PLACEMENT.md`(東西放哪) +- `llm-wiki-template`:`README.md` 與 `README.en.md`(n8n 審核者讀的是英文版) + +## 紅線 + +- **課件交付前要把 PDF 轉成圖逐頁看過**(`pdftoppm`)。`make build` 字量全過、`qa.py` 全綠 + **都不算驗過**——2026-09-13 D3 就是全綠交出去,28 頁裡 8 頁是空的灰框。 +- **`.pptx` 從來不是原始碼。** 內容改 `slides.md`,版型改 `templates/`;課件原始碼住 `ax-courses`, + 引擎住 `course_gen`,不要把內容寫進引擎 repo。 +- **不要在共用工作目錄切分支**——一條線用 `scripts/worktree open` 開自己的分身。 +- 推 main 會被判成 subagent 擋下:**交回總管**,不要蓋戳記硬推、也不要找別的路推上去。 +- `course_gen` 的 SOP §1 第 9 步寫的 `make capture` **不存在**,不要照著跑然後回報失敗。 +- `llm-wiki-template` 的公開版在 GitHub:**任何寫到 GitHub 的動作都要 leo 解保險**(D20),自己不推。 +- 版型取捨、課程骨架、要不要補圖這類**品味題**:把選項和各自的後果寫上票,交 leo 裁,不自己定。 diff --git a/docs/TESTING.md b/docs/TESTING.md index 18b8f3c..0c69642 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -553,11 +553,11 @@ python3 scripts/debt-worklist list --top 5 **失敗**:前幾名一眼看不出為什麼在那裡 ⇒ 那些權重就是錯的,去改 `score()`, **不要改成「總管覺得」**——這份清單的價值就在於它不靠記憶。 -### A21 — 沒人會叫的事會自己叫:36 條 +### A21 — 沒人會叫的事會自己叫:43 條 ``` bash hooks/tests/overdue-nag.test.sh ``` -**該看到**:`通過 36 條,失敗 0 條`。**全程離線**——資料走 `--fixture`、時鐘走 `--now`、 +**該看到**:`通過 43 條,失敗 0 條`。**全程離線**——資料走 `--fixture`、時鐘走 `--now`、 網路用 `ISEP_NOTIFY_OFFLINE=1` 關掉。**不打 Gitea、不打實例、不留任何測試票。** **它在守什麼**(`inkstone/ISEP#93`,2026-08-27 實查):一張票掛著等 leo 三天了、 @@ -577,6 +577,11 @@ bash hooks/tests/overdue-nag.test.sh **閘說會擋就不准從 python 這條路繞過去**——繞得過的閘等於不存在 - ㉛ 紅 ⇒ 探針把 `/tmp/.prod-write-ok` 燒掉了(那是總管單次用完即丟的授權) - ㉟ 紅 ⇒ 每條 subagent 開工都吵 leo 一次 +- **G 群(㊱–㊷,`inkstone/ISEP#118`)**:新開的 repo 有票卻沒有工人,開場要點名 + - ㊱㊵ 紅 ⇒ 缺工人的 repo 沒被點名 ⇒ 又回到「第一次派工撞閘才發現、開例外閘繞過」 + - ㊳ 紅 ⇒ **誤報**:有工人的、沒票的、封存的被點名,每次開場念一串不相干的 repo + - ㊴ 紅 ⇒ 補工人這件總管的事跑進 leo 的 Telegram + - ㊶㊷ 紅 ⇒ 名單或 org 清單讀不到時,把「沒查到」講成「每個都缺人」或「都有人」 > 🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:這支閘刻意放行子 session, > 在一條 subagent 裡跑,F 群會全綠而且是**假綠**。測試檔自己會把它清掉。 @@ -1130,11 +1135,11 @@ bash hooks/tests/dispatch-format-guard.test.sh - **F 群㉚–㉟ 任一紅 ⇒ 誤攔**。㉚特別重要:subagent 往上回報(`to: "main"`)是**交件**, 擋它等於擋掉交件本身;㉟反過來,子 session 自己往下派工時規矩要照樣管它,不然是逃逸艙口 -### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:24 條 +### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:31 條 ``` bash hooks/tests/roster-guard.test.sh ``` -**該看到**:`24 通過 / 0 失敗`。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡), +**該看到**:`31 通過 / 0 失敗`(本頁先前寫 24,實跑是 25,⑲ 加進來時沒回頭改)。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡), 沒有語意判官,每次結果一樣。 **它在守什麼**(`inkstone/ISEP#86`):「身為派工的人,我要工人有名字, @@ -1150,6 +1155,10 @@ bash hooks/tests/roster-guard.test.sh - ⑯⑰ 紅 ⇒ 注入壞了。那是「你是誰/先讀什麼/你的紅線」唯一的送達方式—— 它壞了不會有人立刻發現,派工照樣送出去,只是工人不知道自己是誰 - ⑱ 紅 ⇒ 驗收 3 沒過:票上的【身份】欄吃不下工人名字(或把原本三個角色弄壞了) +- ⑳㉑㉒ 紅 ⇒ `inkstone/ISEP#118` 沒過:課程三個 repo 找不到 `course-hand`, + 或用真實形狀 `isep:course-hand` 派工被擋 ⇒ 派 ax-courses 又要開例外閘 +- ㉓ 紅 ⇒ 一個工人管多個 repo 的解析壞了(只認第一個),或「有票卻沒工人」的判準 + 開始點名沒票/封存的 repo(誤報)。2026-09-14 突變驗過:拿掉票數條件、只認第一個 repo,這條都會紅 ### A17 — 未經調查不寫診斷:26 條 ``` diff --git a/docs/governance/worker-roster.md b/docs/governance/worker-roster.md index da8ad1f..a538e3e 100644 --- a/docs/governance/worker-roster.md +++ b/docs/governance/worker-roster.md @@ -7,7 +7,7 @@ | 段 | 問題 | 為什麼一定要有 | |---|---|---| -| `**負責 repo**` | 它管哪一個 repo | 派工時挑人的唯一判準;也是【身份】欄第二格 | +| `**負責 repo**` | 它管哪個(或哪幾個)repo | 派工時挑人的唯一判準;也是【身份】欄第二格。同一件事的幾個 repo 用「、」隔開(例:`course-hand`) | | `## 開工前先讀` | 它要先讀什麼 | 這是**每個工人各自不同**的部分——共通規定補不了 | | `## 紅線` | 它不准做什麼 | 全機共通的紅線在共通規定裡;這裡只寫這個 repo 特有的 | | frontmatter `description` | 一句話它是誰 | `scripts/roster list` 印的就是這一行 | @@ -39,3 +39,24 @@ bash hooks/tests/roster-guard.test.sh # 全綠 ``` **不必改任何 hook**——名單是資料,閘讀的是這個目錄。 + +## 新開一個 repo 之後,工人是誰在什麼時候加的(inkstone/ISEP#118) + +**以前的答案是「靠人記得」,所以一定會忘。** 實查 2026-09-14:org 裡 20 個 repo, +名單只蓋到 6 個;`llm-wiki-template`(09-01 建)與 `ax-courses`(09-07 建) +都是**第一次派工撞上 `roster-guard.sh`** 才發現沒人,而那一刻最省事的出路是 +開 `/tmp/.roster-ok-*` 例外閘繞過去——繞過去之後,下一次還是會撞。 + +**現在的答案**: + +- **什麼時候**:建 repo 之後,**開第一張票後的下一個 session 開場**。 +- **誰叫**:`scripts/isep-nag`(掛在 `overdue-nag-guard.sh` 上,開場跑一次)的第 ④ 類 + 「**有票卻沒有工人的 repo**」——拿 org 的 repo 清單比對本目錄的 `負責 repo`。 +- **誰加**:總管。在這個目錄加一個工人檔,或把 repo 寫進既有工人的 `負責 repo`。 + +判準只有兩個事實:名單上沒有人負責它、它有開著的票。 +**沒票的 repo 不叫**(封存、投影用、還沒開張的——沒有工可派,就不是缺人), +所以這一段不會變成每天念一串不相干東西的噪音。 +**只進開場給總管看的長版,不進 Telegram**——補工人是總管的事,不吵 leo。 + +手動看一次:`python3 scripts/isep-nag`(`--json` 裡的 `repos_without_worker`)。 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 1b4fdc6..7ca10b6 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -264,7 +264,7 @@ | `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) | | `scripts/settings-allow-sync --quiet` | (不是閘,是腳本)對話一開始把 ISEP 的權限白名單(`docs/permissions-allow.json`:`ticket`/`mainline`/`gate-ok`/`gitea-pr-merge`/`stage`(inkstone/ISEP#137 起,雲端對 stage 寫入的正門)五個正門工具,外加 Arcrun 的 `stage-deploy-artifacts.py`)寫進**這台機器**的 `~/.claude/settings.json`。只加不減、冪等;目標檔壞掉就不碰它、講一聲。**為什麼**:leo 09-07 親手加的四條只在本機,雲端沒有 ⇒ 同樣的動作被分類器擋(09-04 run log `permission_denials=6`);薄殼 repo 要 D20 開閘才推得動,所以清單住 ISEP 一份、兩台各自寫進自己的家目錄(inkstone/ISEP#130)。 | 📝 記錄(寫 settings) | | `scripts/mainline refresh` | (不是閘,是腳本)對話一開始把**現在的主線**那條 milestone 的進度與期限更新一次,好讓每回合眼前那一行講的是今天的數字。拿不到 Gitea 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。成員清單(hub 里程碑裡的票+沿相依邊跨 repo 收到的,`inkstone/ISEP#133`)**不在這裡重收**——真 Gitea 一走 19 秒;`set`/`adopt`/`refresh --members` 才重收,`pick`/`has`/focus guard 每次現問。**家目錄與 repo 那份都不在時**(雲端薄殼跑到這裡 `InkStoneCo/` 還沒 clone)先去 Gitea 讀 `inkstone/InkStoneCo` main 的 `system-dev/mainline.json` 一次寫進家目錄(`inkstone/ISEP#140`):匿名優先、被拒才帶 token;讀不到就退回,不報錯、逾時有上限。優先序寫在 `hooks/lib/mainline.py` 檔頭。 | 📝 記錄 | -| `overdue-nag-guard.sh` | 對話一開始就去 Gitea 撈**沒有人會叫的事**:逾期的 milestone、掛著等你的票(標「等了幾天」)、標著「有人在做」卻好幾天沒動的票;撈完用白話講出來,**有事就發 Telegram 給你**。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) | +| `overdue-nag-guard.sh` | 對話一開始就去 Gitea 撈**沒有人會叫的事**:逾期的 milestone、掛著等你的票(標「等了幾天」)、標著「有人在做」卻好幾天沒動的票;撈完用白話講出來,**有事就發 Telegram 給你**。另外點名**有開著的票、工人名單上卻沒人負責的 repo**(新開 repo 後第一個開場就會叫,`inkstone/ISEP#118`)——這一類只給總管看,不發 Telegram。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) | | `wiki-size-guard.sh` | 對話一開始講出「哪幾個 wiki 檔已經沒有人讀得完了」(預設超過 1200 行就點名)。**讀不完的必讀檔,跟沒有那個檔的差別只在於它讓人以為有。** 另一半掛在寫檔上,見 B 組。 | 📝 記錄(context 注入) | | `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 | diff --git a/hooks/lib/roster.py b/hooks/lib/roster.py index 5cb5d71..cc7b462 100644 --- a/hooks/lib/roster.py +++ b/hooks/lib/roster.py @@ -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", "")) diff --git a/hooks/tests/overdue-nag.test.sh b/hooks/tests/overdue-nag.test.sh index 24678c5..8f529d3 100755 --- a/hooks/tests/overdue-nag.test.sh +++ b/hooks/tests/overdue-nag.test.sh @@ -206,6 +206,55 @@ CH=$(printf '%s' "$IN" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>/dev/null) [ -z "$CH" ] && ok "㉟ 子 session 不叫(subagent 開工不是 leo 開工)" \ || no "㉟ 子 session 不叫" "實得:$CH" +echo "── G 群:有票卻沒有工人的 repo(inkstone/ISEP#118)──────────────────" +# 假名單:只有一位管 acme/staffed 的工人,不碰真的 agents/ +mkdir -p "$TMP/roster" +cat > "$TMP/roster/staffed.md" <<'MD' +--- +name: staffed-hand +description: 測試用 +--- +**負責 repo**:acme/staffed、acme/also-staffed +## 開工前先讀 +## 紅線 +MD +cat > "$TMP/gap.json" <<'JSON' +{"milestones": [], "issues": [], + "repos": [ + {"full_name":"acme/staffed","open_issues_count":4}, + {"full_name":"acme/also-staffed","open_issues_count":1}, + {"full_name":"acme/new-course","open_issues_count":5}, + {"full_name":"acme/quiet","open_issues_count":0}, + {"full_name":"acme/frozen","open_issues_count":3,"archived":true} + ]} +JSON +GR=$(ISEP_ROSTER_DIR="$TMP/roster" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" 2>&1) +GS=$(ISEP_ROSTER_DIR="$TMP/roster" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" --short 2>&1) +GJ=$(ISEP_ROSTER_DIR="$TMP/roster" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" --json 2>&1) +has "$GR" "有票卻沒有工人的 repo(1 個)" && has "$GR" "acme/new-course(5 張票開著)" \ + && ok "㊱ 新開、有票、名單上沒人的 repo 被點名(連同幾張票)" \ + || no "㊱ 點名缺工人的 repo" "實得:$(printf '%s' "$GR" | tr '\n' ' ' | head -c 300)" +has "$GR" "agents/" && ok "㊲ 講得出出路:去 agents/ 補工人(不是開例外閘)" || no "㊲ 出路" "沒講" +if has "$GR" "acme/staffed(" || has "$GR" "acme/also-staffed" || has "$GR" "acme/quiet" || has "$GR" "acme/frozen"; then + no "㊳ 不該叫的不要叫" "有人的/沒票的/封存的被點名了" +else + ok "㊳ 有工人的(含多 repo 的第二個)、沒票的、封存的都不叫" +fi +has "$GS" "查過了,沒有" && ! has "$GS" "工人" \ + && ok "㊴ Telegram 短版不提工人(派工是總管的事,不吵 leo)" \ + || no "㊴ 短版不提工人" "實得:$GS" +printf '%s' "$GJ" | python3 -c ' +import json,sys +assert json.load(sys.stdin)["findings"]["repos_without_worker"] == [{"repo":"acme/new-course","open":5}] +' 2>/dev/null && ok "㊵ --json 帶得出同一份清單" || no "㊵ --json" "$(printf '%s' "$GJ" | head -c 200)" +GN=$(ISEP_ROSTER_DIR="$TMP/沒有這個目錄" python3 "$NAG" --fixture "$TMP/gap.json" --now "$NOW" 2>&1) +has "$GN" "沒查到" && ! has "$GN" "acme/staffed(" \ + && ok "㊶ 名單讀不到 ⇒ 講「沒查到」,不把每個 repo 都說成缺人" \ + || no "㊶ 名單讀不到" "實得:$(printf '%s' "$GN" | tr '\n' ' ' | head -c 300)" +GO=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" 2>&1) +has "$GO" "沒查到" && ok "㊷ 舊格式 fixture(沒有 repos)⇒ 講「沒查到」,前三類照舊" \ + || no "㊷ 舊格式相容" "實得:$(printf '%s' "$GO" | tail -3 | tr '\n' ' ')" + echo printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL" [ "$FAIL" = 0 ] diff --git a/hooks/tests/roster-guard.test.sh b/hooks/tests/roster-guard.test.sh index 596844c..9180955 100755 --- a/hooks/tests/roster-guard.test.sh +++ b/hooks/tests/roster-guard.test.sh @@ -200,6 +200,84 @@ else printf ' ❌ ⑲ 真實的 subagent_type(isep:isep-hand)認不出來——就是這個造成 8/28 的死鎖\n'; FAIL=$((FAIL+1)) fi +echo "── 驗收 5:課程工人(inkstone/ISEP#118):一個工人管三個 repo ──────────" + +for r in inkstone/ax-courses inkstone/course_gen inkstone/llm-wiki-template; do + N=$((N+1)) + if [ "$(python3 "$ROOT/scripts/roster" which "$r" 2>/dev/null)" = "course-hand" ]; then + printf ' ✅ ⑳ `roster which %s` ⇒ course-hand\n' "$r"; PASS=$((PASS+1)) + else + printf ' ❌ ⑳ `roster which %s` 沒回 course-hand(下一次派工又要開例外閘)\n' "$r"; FAIL=$((FAIL+1)) + fi +done + +# 真實派工的形狀:plugin 前綴 + 只有一行【工單】 +t 0 "㉑ 派 isep:course-hand 去 inkstone/ax-courses#6 ⇒ 放行(09-14 實撞開例外閘的那一張)" \ + "$(payload '【工單】inkstone/ax-courses#6' 'isep:course-hand' 'Agent')" + +N=$((N+1)) +inj3=$(printf '%s' "$(payload '【工單】inkstone/ax-courses#6' 'isep:course-hand' 'Agent')" | bash "$HOOK" 2>/dev/null) +if printf '%s' "$inj3" | python3 -c ' +import sys, json +c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"] +for want in ("course-hand", "inkstone/ax-courses", "inkstone/course_gen", "pdftoppm", "## 紅線"): + assert want in c, want +' 2>/dev/null; then + printf ' ✅ ㉒ 注入的卡片帶著它管的三個 repo 與課件驗收紅線(pdftoppm)\n'; PASS=$((PASS+1)) +else + printf ' ❌ ㉒ 課程工人的注入缺東西\n'; FAIL=$((FAIL+1)) +fi + +echo "── 驗收 6:多 repo 解析與「有票卻沒工人」的判準(離線假名單)────────" + +mkdir -p "$TMP/r6" +cat > "$TMP/r6/multi.md" <<'MD' +--- +name: multi-hand +description: 測試用 +--- +**負責 repo**:acme/a、acme/b, acme/c(括號後面不算 acme/zzz) +## 開工前先讀 +## 紅線 +MD +cat > "$TMP/r6/single.md" <<'MD' +--- +name: single-hand +description: 測試用 +--- +**負責 repo**:acme/solo +## 開工前先讀 +## 紅線 +MD + +N=$((N+1)) +if python3 - "$ROOT" "$TMP/r6" <<'PY' 2>/dev/null +import sys, os +sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib")) +import roster +d = sys.argv[2] +assert [w["name"] for w in roster.which("acme/b", d)] == ["multi-hand"], "頓號後面的 repo 認不出來" +assert [w["name"] for w in roster.which("acme/c", d)] == ["multi-hand"], "逗號後面的 repo 認不出來" +assert [w["name"] for w in roster.which("ACME/SOLO", d)] == ["single-hand"], "大小寫不分壞了" +assert roster.which("acme/zzz", d) == [], "括號裡的字被當成 repo" +assert roster.which("acme/nobody", d) == [], "不存在的 repo 被認領了" +rows = [ + {"full_name": "acme/a", "open_issues_count": 3}, # 有人 ⇒ 不叫 + {"full_name": "acme/lonely", "open_issues_count": 2}, # 沒人、有票 ⇒ 叫 + {"full_name": "acme/idle", "open_issues_count": 0}, # 沒人、沒票 ⇒ 不叫 + {"full_name": "acme/old", "open_issues_count": 9, "archived": True}, # 封存 ⇒ 不叫 + {"full_name": "acme/busy", "open_issues_count": 5}, # 沒人、有票 ⇒ 叫,排前面 +] +got = roster.uncovered(rows, d) +assert got == [{"repo": "acme/busy", "open": 5}, {"repo": "acme/lonely", "open": 2}], got +assert roster.uncovered(rows, os.path.join(d, "不存在")) is None, "名單讀不到時沒有回 None" +PY +then + printf ' ✅ ㉓ 頓號/逗號都認得、括號後不算;只叫「沒人又有票」、不叫封存與沒票;名單讀不到回 None\n'; PASS=$((PASS+1)) +else + printf ' ❌ ㉓ 多 repo 解析或 uncovered 判準壞了\n'; FAIL=$((FAIL+1)) +fi + echo printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N" [ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/isep-nag b/scripts/isep-nag index c40ebd6..52b689b 100755 --- a/scripts/isep-nag +++ b/scripts/isep-nag @@ -20,10 +20,15 @@ leo 整天上課,一天看兩眼。**不叫,就等於這件事不會發生 (機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。 **但腳本版本身是完整可用的,不是半成品。** -━━ 撈三種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +━━ 撈四種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了 ② 掛著等 leo 的票 `Human` 或指派給 leo,**標等了幾天** ③ 棒子掉在地上的票 `s/doing`/`s/review` 卻很多天沒動 ⇒ 沒有人在做 + ④ 有票卻沒有工人的 repo 名單(`agents/`)上沒人負責、卻有開著的票 + ⇒ 派工一定撞 roster-guard(inkstone/ISEP#118) + **只進長版**(開場給總管看),不進 Telegram 短版——補工人是總管的事,不吵 leo。 + 它回答的是「新 repo 的工人誰在什麼時候加」:建 repo 之後的**第一個 session 開場**, + 由這支點名;在那之前沒有任何東西會提醒。 每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、 講「這對你意味什麼」而不是系統內部狀態。 @@ -133,10 +138,12 @@ def collect(fixture=None): return {"milestones": [], "issues": [], "error": "拿不到 Gitea token(GITEA_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): + """④ 有票卻沒有工人的 repo(inkstone/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/doing/s/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: diff --git a/scripts/roster b/scripts/roster index eaf14fe..39e5a12 100755 --- a/scripts/roster +++ b/scripts/roster @@ -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) # 一個工人可以管好幾個 repo(inkstone/ISEP#118) if not hits: print("🚫 名單上沒有負責「%s」的人。現有的是:" % repo, file=sys.stderr) for w in R.load():