Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f706a16c | |||
| 25da674615 | |||
| 01dafb01b5 | |||
| 436e30b9d1 | |||
| 916a6890ff | |||
| ba1f2a50f8 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(87 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、65 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.30.1",
|
||||
"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",
|
||||
"guardrails",
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
|
||||
| | 數量 | 是什麼 |
|
||||
|---|---|---|
|
||||
| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 87 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** |
|
||||
| `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` |
|
||||
| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 88 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.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 的驗收介面,多報就是假綠):
|
||||
|
||||
@@ -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 裁,不自己定。
|
||||
+24
-6
@@ -98,12 +98,21 @@ bash scripts/test-comment-carries-task-guard.sh
|
||||
> 記過:訊息壞掉時閘照樣 `exit 2`,只看離開碼完全看不出來——
|
||||
> 而這張票(`inkstone/ISEP#112`)就是那個形狀的極端版:**閘擋對了,但它教的解法根本不存在。**
|
||||
|
||||
### A12 — 收工要把棒子交回來:10 條
|
||||
### A12 — 收工要把棒子交回來:24 條
|
||||
```
|
||||
bash scripts/test-baton-handback-guard.sh
|
||||
```
|
||||
**該看到**:`10/10 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**。
|
||||
**該看到**:`24/24 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**
|
||||
(Stop 那半用 `mktemp` 造的假 transcript;出路那兩條把 `TICKET_HOST` 指到連不上的位址)。
|
||||
**失敗**:
|
||||
- **`inkstone/ISEP#153` 驗收 1**(背景派工送出那一刻)兩條紅 ⇒ 工人剛出發就念「收工了」,
|
||||
會教總管在工人動手前去改票(假交棒)——**誤攔,比漏擋嚴重**
|
||||
- **驗收 2**「完工通知 completed ⇒ 報」紅 ⇒ 背景工人真的交件、票上沒交棒,卻靜靜通過;
|
||||
「只點名那一則通知對到的票」紅 ⇒ 判準從 `tool-use-id` 比對滑成「transcript 裡有通知就全報」
|
||||
- 「同一則通知第二次 Stop」紅 ⇒ 鬼打牆,每個回合都被擋
|
||||
- 「出路印的是絕對路徑」紅 ⇒ 又印回相對的 `scripts/ticket`,總管在 InkStoneCo 貼上去會叫到舊複本(沒有 `handback`)
|
||||
- ★「原樣貼上只填三格」紅 ⇒ 訊息教的那塊指令跑不完;它的對照組(拿掉 `--next`)紅就表示 ★ 沒有鑑別力
|
||||
- 📌 這 14 條拿 `main` 上舊版的 hook 跑是 **7 條紅**(2026-09-13 實跑),不是換了寫法就會綠的測試
|
||||
- 「該報」5 條任一紅 ⇒ 指派/tag/下一步缺哪一格抓不到,08-26 那次掉棒的狀態會靜靜通過
|
||||
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
|
||||
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
|
||||
@@ -544,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 三天了、
|
||||
@@ -568,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 群會全綠而且是**假綠**。測試檔自己會把它清掉。
|
||||
@@ -1121,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`):「身為派工的人,我要工人有名字,
|
||||
@@ -1141,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 條
|
||||
```
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
+17
-3
@@ -7,10 +7,16 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **87 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **88 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 📌 **`inkstone/ISEP#153`(2026-09-13,待總管定版)+0 支、+1 條**:`baton-handback-guard.sh` 多掛一條 `Stop`。
|
||||
> 背景派工時 `PostToolUse:Agent` 在工人**剛出發**就觸發,舊版當場念「收工了但棒子沒交回來」(09-13 對 ISEP#150、arcrun-rag#14 各一次,工人其實還要跑 17 分鐘)。
|
||||
> 現在送出那一刻看到 launch ack 就不查;工人交件(主對話收到 `<task-notification>` completed)時由 Stop 那半用 `tool-use-id` 對回原派工單再查,同一條線只點名一次。
|
||||
> **62 支、88 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=88、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
@@ -43,6 +49,12 @@
|
||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||
|
||||
> 📌 **`0.30.2`(`inkstone/ISEP#150`,2026-09-13)+0 支、+0 條、+3 支腳本**:新增 `scripts/isep-doctor`——
|
||||
> 講出這台每一個 Claude Code(本機 CLI/Desktop 內建)**實際有沒有載入** ISEP,判準讀 `claude plugin list --json`,不再看 settings 長相
|
||||
> (09-13 實撞:settings 的 marketplace 多了 `path` ⇒ Desktop 2.1.266 整個忽略 inkstone、CLI 2.1.220 照載入)。
|
||||
> **62 支、87 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||
|
||||
> 📌 **`0.30.1`(`inkstone/ISEP#130`,2026-09-13)+0 支、+0 條**:修補 0.30.0 的抓票入口——
|
||||
> `mainline set/refresh/adopt` 讀不到任何一條 `/dependencies` 時,名單標成不完整、`set` 離開碼 3 不印 ✅、
|
||||
> 保留舊快取;否則雲端工人會拿殘缺名單 `pick` 到「抓盡」而收工(09-13 實撞:hub 票底下三張整批漏收)。
|
||||
@@ -252,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 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
||||
|
||||
@@ -273,6 +285,7 @@
|
||||
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
||||
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
||||
| `queue-drain-guard.sh` | **在接票的 session(跑過 `ticket pick/claim/handback`,或環境設了 `ISEP_DRAIN=1`),池子裡還有現在就能抓的票,就不准收工**——它自己跑一次 `ticket pick`,有票就把票號和認領指令交回去。這是 ISEP 唯一一支看「佇列」而不是看「這回合寫了什麼」的收工閘(`inkstone/ISEP#33`:雲端三趟 run 都是做完一件、還有票就停)。做不了的票要把理由寫回票、改欄位,pick 就不再回它。跟 leo 聊天的 session 不會被咬;同一張連續指名 3 次、讀不到佇列 3 次、或一個 session 擋滿 40 次就放行並留痕,不會鎖死。 | 🛑 擋(有上限) |
|
||||
| `baton-handback-guard.sh` | **背景派出去的工人交件時**(主對話收到完工通知),檢查那張工單票有沒有把棒子交回來(指派/標籤/下一步三格),缺就擋一次要 AI 補。工人還在跑的時候一聲都不出(`inkstone/ISEP#153`)。同一條線只點名一次;補完三格就不再出現。前景派工的那一半在下面 H 組。 | 🛑 擋(每條線至多一次) |
|
||||
|
||||
> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。
|
||||
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58:
|
||||
@@ -292,7 +305,7 @@
|
||||
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
|
||||
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
|
||||
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。**只管前景派工**:背景派工送出的那一刻工人才剛出發,這裡不出聲,等它交件時由 F 組的 Stop 那半查(`inkstone/ISEP#153`)。 | 📝 記錄(提醒,不擋) |
|
||||
| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
|
||||
| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 |
|
||||
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
||||
@@ -414,6 +427,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
||||
| `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 |
|
||||
| `worklist-guard.sh` / `self-drive-police.sh` / `self-drive-judge.sh` / `delivery-police.sh` / `wiki-first-police.sh` / `unpushed-police.sh` | 各 2 次 | 一次守「總管自己想收工」(Stop),一次守「subagent 交回工作」(SubagentStop)——同一套判準用在兩種角色身上 |
|
||||
| `search-is-not-proof-guard.sh` | 3 次 | 拿搜尋結果當證據的那個動作,可能是「貼進票裡」(Bash),也可能是「寫進派工單發包出去」(`Task`/`Agent` 兩個名稱各掛一次),三種都要接住 |
|
||||
| `baton-handback-guard.sh` | 2 次 | 同一件事的兩種收工時機:前景派工 tool 回來就是交件(PostToolUse),背景派工要等完工通知進主對話(Stop) |
|
||||
|
||||
❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh`
|
||||
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
|
||||
|
||||
+174
-65
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
||||
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);背景工人交件後同一條線只點名一次,補完三格就不再出現。
|
||||
#
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task)
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去
|
||||
# 同一件事的兩種收工時機,掛兩個事件:
|
||||
# PostToolUse(Agent|Task) 前景派工:tool 回來=工人交件了 ⇒ 當場查
|
||||
# Stop 背景派工:主對話收到 <task-notification> completed ⇒ 回合結束時查
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||
@@ -15,73 +18,165 @@
|
||||
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
||||
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
||||
#
|
||||
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
|
||||
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
|
||||
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
|
||||
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
|
||||
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
|
||||
# ── 背景派工為什麼改到 Stop 查(inkstone/ISEP#153,09-13 兩次實撞)──────────
|
||||
# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線),
|
||||
# 那時 tool_response 是 launch ack:{"isAsync": true, "status": "async_launched", ...}
|
||||
# (同一份物件寫在 transcript 的 toolUseResult,2026-08-24 實跡)。
|
||||
# 舊版在這一刻就查票 ⇒ 對 ISEP#150、arcrun-rag#14 各念一次「收工了但棒子沒交回來」,
|
||||
# 而兩個工人之後各跑了 17 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。
|
||||
#
|
||||
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
|
||||
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
|
||||
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
|
||||
# 真正的交件是主 transcript 裡的 <task-notification>(<status>completed</status>),
|
||||
# 它帶著 <tool-use-id> ——**拿這個唯一識別碼回頭對上當初那一則 Agent tool_use**,
|
||||
# 從它的 prompt 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜。
|
||||
# 不掛 SubagentStop:parallel-lines-cap-guard.sh 檔頭實測過它在主 session 一次都沒觸發。
|
||||
#
|
||||
# ── 只點名一次 ────────────────────────────────────────────────────
|
||||
# Stop 回 exit 2 會讓總管多走一個回合(去補三格)。查過的 tool-use-id 記在狀態檔,之後不再查
|
||||
# ⇒ 補完了自然安靜,沒補也不會鬼打牆。狀態檔在 ${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}。
|
||||
# 前景 PostToolUse 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不擋任何動作。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
# 訊息裡的出路一律印絕對路徑:總管的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是舊複本,
|
||||
# `grep -c handback` → 0(2026-09-13 實查)⇒ 印相對路徑=教一條走不通的路(同 comment-carries-task-guard ⑮)。
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
TICKET="$PLUGIN_ROOT/scripts/ticket"
|
||||
STATE_DIR="${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}"
|
||||
export ISEP_BATON_STATE_DIR="${STATE_DIR%/}"
|
||||
|
||||
# ── 找出這一次要查的票(每行一張:owner repo num)──────────────────────
|
||||
SPECS=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re, os
|
||||
|
||||
TICKET = re.compile(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)")
|
||||
|
||||
def spec_of(prompt):
|
||||
for line in (prompt or "").splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = TICKET.search(line)
|
||||
if m:
|
||||
return "%s %s %s" % m.groups()
|
||||
return None
|
||||
|
||||
def is_async_ack(resp):
|
||||
# launch ack 兩種長相都認:結構化物件(isAsync/status),或只剩文字的 content 陣列
|
||||
if isinstance(resp, dict) and (resp.get("isAsync") is True or resp.get("status") == "async_launched"):
|
||||
return True
|
||||
try:
|
||||
return "Async agent launched" in json.dumps(resp, ensure_ascii=False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
SPEC=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
||||
if m:
|
||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
||||
break
|
||||
|
||||
if d.get("hook_event_name") != "Stop":
|
||||
if is_async_ack(d.get("tool_response")):
|
||||
raise SystemExit # 工人才剛出發,還沒有「收工」可言;交件時由 Stop 那半查
|
||||
s = spec_of((d.get("tool_input") or {}).get("prompt"))
|
||||
if s:
|
||||
print(s)
|
||||
raise SystemExit
|
||||
|
||||
# ── Stop:有沒有背景工人交件 ─────────────────────────────────────────
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.isfile(tp):
|
||||
raise SystemExit
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64]
|
||||
state = os.path.join(os.environ["ISEP_BATON_STATE_DIR"], "baton-handback-seen-%s" % sid)
|
||||
try:
|
||||
seen = set(open(state, encoding="utf-8").read().split())
|
||||
except OSError:
|
||||
seen = set()
|
||||
|
||||
NOTE = re.compile(r"<tool-use-id>(toolu_[A-Za-z0-9_-]+)</tool-use-id>.*?<status>([a-z_]+)</status>", re.S)
|
||||
completed = [] # 保留出現順序
|
||||
dispatch = {} # tool_use id → 帶【工單】的派工單
|
||||
try:
|
||||
with open(tp, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if "<task-notification>" in line:
|
||||
# 同一則通知會以 enqueue/attachment/remove 出現多次,用 id 去重
|
||||
for tuid, st in NOTE.findall(line.replace("\\n", "\n")):
|
||||
if st == "completed" and tuid not in completed:
|
||||
completed.append(tuid)
|
||||
if "【工單】" in line and "tool_use" in line:
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
msg = e.get("message")
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
for b in msg.get("content") or []:
|
||||
if (isinstance(b, dict) and b.get("type") == "tool_use"
|
||||
and b.get("name") in ("Agent", "Task")):
|
||||
p = (b.get("input") or {}).get("prompt")
|
||||
if isinstance(p, str) and "【工單】" in p:
|
||||
dispatch[b.get("id")] = p
|
||||
except OSError:
|
||||
raise SystemExit
|
||||
|
||||
todo = [t for t in completed if t in dispatch and t not in seen]
|
||||
if not todo:
|
||||
raise SystemExit
|
||||
try:
|
||||
os.makedirs(os.path.dirname(state), exist_ok=True)
|
||||
with open(state, "a", encoding="utf-8") as f:
|
||||
f.write("\n".join(todo) + "\n")
|
||||
except OSError:
|
||||
pass # 記不下來頂多再念一次,不擋
|
||||
out = []
|
||||
for t in todo:
|
||||
s = spec_of(dispatch[t])
|
||||
if s and s not in out:
|
||||
out.append(s)
|
||||
print("\n".join(out))
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "${SPEC:-}" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
[ -n "${SPECS:-}" ] || exit 0
|
||||
|
||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
||||
# 正式執行時這個變數不存在,一行都不會走到。
|
||||
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||
else
|
||||
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
URL=""
|
||||
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
[ -n "${U:-}" ] && { URL="$U"; break; }
|
||||
done
|
||||
[ -n "$URL" ] || exit 0
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$TOKEN" != "$URL" ] || exit 0
|
||||
# ── 查一張票:缺哪一格就寫到 stderr 並回 2,齊了回 0 ─────────────────────
|
||||
check_one(){ # $1 owner $2 repo $3 num
|
||||
local OWNER="$1" REPO="$2" NUM="$3" J C REPORT BODY NOW_A NOW_L
|
||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
||||
# 正式執行時這個變數不存在,一行都不會走到。
|
||||
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||
else
|
||||
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
||||
local ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" URL="" U dir TOKEN
|
||||
for dir in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||
[ -e "$dir/.git" ] || continue
|
||||
U=$(git -C "$dir" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
[ -n "${U:-}" ] && { URL="$U"; break; }
|
||||
done
|
||||
[ -n "$URL" ] || return 0
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$TOKEN" != "$URL" ] || return 0
|
||||
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || return 0
|
||||
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||
fi
|
||||
|
||||
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || exit 0
|
||||
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||
fi
|
||||
|
||||
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
||||
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
||||
import json, sys
|
||||
try:
|
||||
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
if not isinstance(it, dict) or "state" not in it:
|
||||
raise SystemExit # 讀不到票(404/錯誤回應)⇒ 不瞎報
|
||||
if it.get("state") == "closed":
|
||||
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
||||
if not isinstance(cs, list):
|
||||
cs = []
|
||||
miss = []
|
||||
if not (it.get("assignees") or []):
|
||||
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
|
||||
@@ -100,37 +195,51 @@ print("\n".join(" ❌ " + m for m in miss))
|
||||
print("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or [])))
|
||||
print("__LABELS__" + ",".join(labs))
|
||||
PY
|
||||
) || exit 0
|
||||
) || return 0
|
||||
[ -n "${REPORT:-}" ] || return 0
|
||||
|
||||
[ -n "${REPORT:-}" ] || exit 0
|
||||
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
|
||||
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
|
||||
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
|
||||
|
||||
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
|
||||
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
|
||||
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
|
||||
|
||||
cat >&2 <<EOF
|
||||
cat >&2 <<EOF
|
||||
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
|
||||
|
||||
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
|
||||
|
||||
$BODY
|
||||
|
||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
||||
直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
||||
|
||||
【一個動作把三格補齊】
|
||||
scripts/ticket handback $OWNER/$REPO#$NUM \\
|
||||
"$TICKET" handback $OWNER/$REPO#$NUM \\
|
||||
--to claude-code \\
|
||||
--next "<收下的人第一件事要做什麼,一句話>" \\
|
||||
--label <s/review|s/stage|s/pending|s/todo> \\
|
||||
--evidence "<實測輸出或 PR 連結>"
|
||||
|
||||
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
|
||||
已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
已經真的做完且驗過了 → "$TICKET" close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
|
||||
【撈一次看棒子在誰手上】 scripts/ticket mine
|
||||
EOF
|
||||
return 2
|
||||
}
|
||||
|
||||
HIT=0
|
||||
while read -r OWNER REPO NUM; do
|
||||
[ -n "${NUM:-}" ] || continue
|
||||
check_one "$OWNER" "$REPO" "$NUM" </dev/null
|
||||
[ $? -eq 2 ] && HIT=1
|
||||
done <<< "$SPECS"
|
||||
|
||||
[ "$HIT" = 1 ] || exit 0
|
||||
|
||||
cat >&2 <<EOF
|
||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
||||
直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
||||
|
||||
【撈一次看棒子在誰手上】 "$TICKET" mine
|
||||
(背景工人交件後,同一條線只點名這一次;補完三格就不會再出現。)
|
||||
EOF
|
||||
exit 2
|
||||
|
||||
@@ -377,6 +377,15 @@
|
||||
"timeout": 180
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
|
||||
+53
-3
@@ -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", ""))
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
+53
-6
@@ -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:
|
||||
|
||||
+1
-1
@@ -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():
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/bin/bash
|
||||
# 交棒警察的測試——三格(指派/tag/下一步)各自缺漏時要被抓到
|
||||
# 判準:5 種該報、5 種不該報
|
||||
# 判準:前景 5 種該報、5 種不該報;背景派工(inkstone/ISEP#153)送出時不報、交件時才報、只報一次;
|
||||
# 訊息印的出路照著貼真的跑得完。
|
||||
# 用 fixture 跑,不打網路、不在票池留下任何一張測試票。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
ROOT=$(pwd)
|
||||
H=hooks/baton-handback-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
FX=$(mktemp -d); trap 'rm -rf "$FX"' EXIT
|
||||
FX=$(mktemp -d); ST=$(mktemp -d); trap 'rm -rf "$FX" "$ST"' EXIT
|
||||
export CLAUDE_PLUGIN_ROOT="$ROOT" ISEP_BATON_STATE_DIR="$ST"
|
||||
|
||||
mkfx(){ # $1=state $2=assignees(csv) $3=labels(csv) $4=有沒有交棒留言(y/n)
|
||||
python3 - "$FX" "$1" "$2" "$3" "$4" <<'PY'
|
||||
@@ -22,12 +25,15 @@ json.dump(comments, open(os.path.join(d, "comments.json"), "w"))
|
||||
PY
|
||||
}
|
||||
|
||||
judge(){ # $1=want $2=got $3=說明
|
||||
if [ "$2" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$1" "$2" "$3"
|
||||
}
|
||||
|
||||
run(){ # $1=want $2=說明 (fixture 已備好)
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\n做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$1" "$got" "$2"
|
||||
judge "$1" "$?" "$2"
|
||||
}
|
||||
|
||||
echo "── 該報:三格有缺 ──"
|
||||
@@ -46,9 +52,98 @@ mkfx closed "" "" n ; run 0 "票已關=棒子到
|
||||
echo "── 派工單裡沒有【工單】那行 ⇒ 不歸這支管 ──"
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"隨便做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=0 got=%s 沒有票號的派工單\n' "$got"
|
||||
judge 0 "$?" "沒有票號的派工單"
|
||||
|
||||
# ═══ inkstone/ISEP#153:背景派工 ═══════════════════════════════════════
|
||||
post(){ # $1=tool_response JSON → 離開碼
|
||||
printf '{"hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\\n做點事","run_in_background":true},"tool_response":%s}' "$1" \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "── 驗收 1:背景派工送出的那一刻不報(票上三格全缺也一樣)──"
|
||||
mkfx open "" "s/doing" n
|
||||
post '{"isAsync":true,"status":"async_launched","agentId":"a93f7dc98c50fc671","description":"x"}'
|
||||
judge 0 "$?" "launch ack(toolUseResult 真跡的形狀:isAsync/async_launched)"
|
||||
post '[{"type":"text","text":"Async agent launched successfully. (This tool result is internal metadata)\nagentId: a1"}]'
|
||||
judge 0 "$?" "launch ack 只剩文字 content 陣列時也認得"
|
||||
post '{"status":"completed","content":[{"type":"text","text":"工作做完了"}]}'
|
||||
judge 2 "$?" "驗收 3 對照:前景派工回來(不是 launch ack)照舊要報"
|
||||
|
||||
# 假 transcript:派工 tool_use + launch ack +(可選)完工通知
|
||||
TP="$ST/main.jsonl"
|
||||
mktx(){ # $1=通知對到哪個 tool-use-id(空=還沒交件) $2=通知 status $3=派工單第一行
|
||||
python3 - "$TP" "$1" "$2" "$3" <<'PY'
|
||||
import json, sys
|
||||
tp, note_id, status, first = sys.argv[1:5]
|
||||
L = []
|
||||
def add(o): L.append(json.dumps(o, ensure_ascii=False))
|
||||
for tid, line in (("toolu_AAA", first), ("toolu_BBB", "【工單】inkstone/ISEP#999")):
|
||||
add({"type": "assistant", "message": {"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": tid, "name": "Agent",
|
||||
"input": {"prompt": line + "\n做點事", "run_in_background": True}}]}})
|
||||
add({"type": "user", "message": {"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": tid,
|
||||
"content": [{"type": "text", "text": "Async agent launched successfully."}]}]},
|
||||
"toolUseResult": {"isAsync": True, "status": "async_launched"}})
|
||||
if note_id:
|
||||
n = ("<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>%s</tool-use-id>\n"
|
||||
"<status>%s</status>\n<summary>Agent finished</summary>\n<result>好了</result>\n"
|
||||
"</task-notification>") % (note_id, status)
|
||||
# 真跡裡同一則通知出現三次:enqueue/attachment/remove
|
||||
add({"type": "queue-operation", "operation": "enqueue", "content": n})
|
||||
add({"type": "attachment", "attachment": {"type": "queued_command", "prompt": n}})
|
||||
add({"type": "queue-operation", "operation": "remove", "content": n})
|
||||
open(tp, "w").write("\n".join(L) + "\n")
|
||||
PY
|
||||
}
|
||||
stop(){ # $1=session id → 離開碼;stderr 存到 $ST/err
|
||||
printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s","stop_hook_active":false}' "$1" "$TP" \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>"$ST/err"
|
||||
}
|
||||
|
||||
echo "── 驗收 2:背景工人真的交件時才報 ──"
|
||||
mkfx open "" "s/doing" n
|
||||
mktx "" "" "【工單】inkstone/arcrun-rag#136"
|
||||
stop s1; judge 0 "$?" "工人還在跑(沒有完工通知)⇒ 不報"
|
||||
mktx toolu_AAA completed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s1; got=$?; judge 2 "$got" "完工通知 completed + 票上三格全缺 ⇒ 報"
|
||||
if grep -q 'inkstone/arcrun-rag#136' "$ST/err" && ! grep -q 'ISEP#999' "$ST/err"; then got=ok; else got=bad; fi
|
||||
judge ok "$got" "只點名那一則通知對到的票(tool-use-id 比對;另一條還在跑的 #999 不點)"
|
||||
cp "$ST/err" "$ST/msg"
|
||||
stop s1; judge 0 "$?" "同一則通知第二次 Stop ⇒ 不鬼打牆(只點名一次)"
|
||||
mktx toolu_AAA killed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s2; judge 0 "$?" "通知不是 completed(killed)⇒ 不當成交件"
|
||||
mkfx open "claude-code" "s/review" y
|
||||
mktx toolu_AAA completed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s3; judge 0 "$?" "交件時三格已經補齊 ⇒ 安靜(出路:補完就不再出現)"
|
||||
mkfx open "" "s/doing" n
|
||||
mktx toolu_AAA completed "隨便做點事,沒有工單"
|
||||
stop s4; judge 0 "$?" "交件的那條派工單沒有【工單】⇒ 不歸這支管"
|
||||
printf '{"hook_event_name":"Stop","session_id":"s5","transcript_path":"/nonexistent"}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
judge 0 "$?" "讀不到 transcript ⇒ 放行"
|
||||
|
||||
echo "── 出路:訊息印的那塊指令照著貼真的跑得完 ──"
|
||||
BLK=$(python3 - "$ST/msg" <<'PY'
|
||||
import sys, re
|
||||
t = open(sys.argv[1], encoding="utf-8").read()
|
||||
m = re.search(r'^\s*("[^"\n]*/scripts/ticket" handback .*?--evidence "[^"\n]*")', t, re.S | re.M)
|
||||
if not m:
|
||||
raise SystemExit
|
||||
b = m.group(1)
|
||||
b = b.replace("<收下的人第一件事要做什麼,一句話>", "去複驗")
|
||||
b = re.sub(r"<s/review|[^>]*>", "s/review", b)
|
||||
b = b.replace("<實測輸出或 PR 連結>", "測試")
|
||||
print(b)
|
||||
PY
|
||||
)
|
||||
case "$BLK" in /*|\"/*) got=abs ;; *) got="${BLK:0:30}" ;; esac
|
||||
judge abs "$got" "出路印的是絕對路徑(InkStoneCo/scripts/ticket 是舊複本,沒有 handback)"
|
||||
OFF="TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN_CLAUDE_CODE=fake ISEP_API_RETRIES=0"
|
||||
env $OFF bash -c "$BLK" >/dev/null 2>&1
|
||||
judge 1 "$?" "★ 原樣貼上只填三格 ⇒ 參數全過、走到網路才停(離開碼 1;2=被自己的用法擋下)"
|
||||
env $OFF bash -c "${BLK/--next/--nope}" >/dev/null 2>&1
|
||||
judge 2 "$?" "對照組:拿掉 --next ⇒ 確實跑不完(上一條才有鑑別力)"
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
|
||||
Reference in New Issue
Block a user