Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee4af97765 | |||
| d2f706a16c | |||
| 25da674615 | |||
| 01dafb01b5 | |||
| 436e30b9d1 | |||
| 916a6890ff | |||
| ba1f2a50f8 | |||
| fbc56b9e59 | |||
| 8eb4c58d2d | |||
| c1df3c7a08 | |||
| 544beefc5e | |||
| c0eec76b52 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "isep",
|
"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)、62 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
"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.0",
|
"version": "0.30.4",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"inkstone",
|
"inkstone",
|
||||||
"guardrails",
|
"guardrails",
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
|
|
||||||
| | 數量 | 是什麼 |
|
| | 數量 | 是什麼 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 87 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** |
|
| `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` … |
|
| `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … |
|
||||||
| `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` |
|
| `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 的驗收介面,多報就是假綠):
|
> (`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 裁,不自己定。
|
||||||
+48
-6
@@ -98,12 +98,21 @@ bash scripts/test-comment-carries-task-guard.sh
|
|||||||
> 記過:訊息壞掉時閘照樣 `exit 2`,只看離開碼完全看不出來——
|
> 記過:訊息壞掉時閘照樣 `exit 2`,只看離開碼完全看不出來——
|
||||||
> 而這張票(`inkstone/ISEP#112`)就是那個形狀的極端版:**閘擋對了,但它教的解法根本不存在。**
|
> 而這張票(`inkstone/ISEP#112`)就是那個形狀的極端版:**閘擋對了,但它教的解法根本不存在。**
|
||||||
|
|
||||||
### A12 — 收工要把棒子交回來:10 條
|
### A12 — 收工要把棒子交回來:24 條
|
||||||
```
|
```
|
||||||
bash scripts/test-baton-handback-guard.sh
|
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 那次掉棒的狀態會靜靜通過
|
- 「該報」5 條任一紅 ⇒ 指派/tag/下一步缺哪一格抓不到,08-26 那次掉棒的狀態會靜靜通過
|
||||||
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
|
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
|
||||||
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
|
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
|
||||||
@@ -544,11 +553,11 @@ python3 scripts/debt-worklist list --top 5
|
|||||||
**失敗**:前幾名一眼看不出為什麼在那裡 ⇒ 那些權重就是錯的,去改 `score()`,
|
**失敗**:前幾名一眼看不出為什麼在那裡 ⇒ 那些權重就是錯的,去改 `score()`,
|
||||||
**不要改成「總管覺得」**——這份清單的價值就在於它不靠記憶。
|
**不要改成「總管覺得」**——這份清單的價值就在於它不靠記憶。
|
||||||
|
|
||||||
### A21 — 沒人會叫的事會自己叫:36 條
|
### A21 — 沒人會叫的事會自己叫:43 條
|
||||||
```
|
```
|
||||||
bash hooks/tests/overdue-nag.test.sh
|
bash hooks/tests/overdue-nag.test.sh
|
||||||
```
|
```
|
||||||
**該看到**:`通過 36 條,失敗 0 條`。**全程離線**——資料走 `--fixture`、時鐘走 `--now`、
|
**該看到**:`通過 43 條,失敗 0 條`。**全程離線**——資料走 `--fixture`、時鐘走 `--now`、
|
||||||
網路用 `ISEP_NOTIFY_OFFLINE=1` 關掉。**不打 Gitea、不打實例、不留任何測試票。**
|
網路用 `ISEP_NOTIFY_OFFLINE=1` 關掉。**不打 Gitea、不打實例、不留任何測試票。**
|
||||||
|
|
||||||
**它在守什麼**(`inkstone/ISEP#93`,2026-08-27 實查):一張票掛著等 leo 三天了、
|
**它在守什麼**(`inkstone/ISEP#93`,2026-08-27 實查):一張票掛著等 leo 三天了、
|
||||||
@@ -568,6 +577,11 @@ bash hooks/tests/overdue-nag.test.sh
|
|||||||
**閘說會擋就不准從 python 這條路繞過去**——繞得過的閘等於不存在
|
**閘說會擋就不准從 python 這條路繞過去**——繞得過的閘等於不存在
|
||||||
- ㉛ 紅 ⇒ 探針把 `/tmp/.prod-write-ok` 燒掉了(那是總管單次用完即丟的授權)
|
- ㉛ 紅 ⇒ 探針把 `/tmp/.prod-write-ok` 燒掉了(那是總管單次用完即丟的授權)
|
||||||
- ㉟ 紅 ⇒ 每條 subagent 開工都吵 leo 一次
|
- ㉟ 紅 ⇒ 每條 subagent 開工都吵 leo 一次
|
||||||
|
- **G 群(㊱–㊷,`inkstone/ISEP#118`)**:新開的 repo 有票卻沒有工人,開場要點名
|
||||||
|
- ㊱㊵ 紅 ⇒ 缺工人的 repo 沒被點名 ⇒ 又回到「第一次派工撞閘才發現、開例外閘繞過」
|
||||||
|
- ㊳ 紅 ⇒ **誤報**:有工人的、沒票的、封存的被點名,每次開場念一串不相干的 repo
|
||||||
|
- ㊴ 紅 ⇒ 補工人這件總管的事跑進 leo 的 Telegram
|
||||||
|
- ㊶㊷ 紅 ⇒ 名單或 org 清單讀不到時,把「沒查到」講成「每個都缺人」或「都有人」
|
||||||
|
|
||||||
> 🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:這支閘刻意放行子 session,
|
> 🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:這支閘刻意放行子 session,
|
||||||
> 在一條 subagent 裡跑,F 群會全綠而且是**假綠**。測試檔自己會把它清掉。
|
> 在一條 subagent 裡跑,F 群會全綠而且是**假綠**。測試檔自己會把它清掉。
|
||||||
@@ -1121,11 +1135,11 @@ bash hooks/tests/dispatch-format-guard.test.sh
|
|||||||
- **F 群㉚–㉟ 任一紅 ⇒ 誤攔**。㉚特別重要:subagent 往上回報(`to: "main"`)是**交件**,
|
- **F 群㉚–㉟ 任一紅 ⇒ 誤攔**。㉚特別重要:subagent 往上回報(`to: "main"`)是**交件**,
|
||||||
擋它等於擋掉交件本身;㉟反過來,子 session 自己往下派工時規矩要照樣管它,不然是逃逸艙口
|
擋它等於擋掉交件本身;㉟反過來,子 session 自己往下派工時規矩要照樣管它,不然是逃逸艙口
|
||||||
|
|
||||||
### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:24 條
|
### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:31 條
|
||||||
```
|
```
|
||||||
bash hooks/tests/roster-guard.test.sh
|
bash hooks/tests/roster-guard.test.sh
|
||||||
```
|
```
|
||||||
**該看到**:`24 通過 / 0 失敗`。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡),
|
**該看到**:`31 通過 / 0 失敗`(本頁先前寫 24,實跑是 25,⑲ 加進來時沒回頭改)。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡),
|
||||||
沒有語意判官,每次結果一樣。
|
沒有語意判官,每次結果一樣。
|
||||||
|
|
||||||
**它在守什麼**(`inkstone/ISEP#86`):「身為派工的人,我要工人有名字,
|
**它在守什麼**(`inkstone/ISEP#86`):「身為派工的人,我要工人有名字,
|
||||||
@@ -1141,6 +1155,10 @@ bash hooks/tests/roster-guard.test.sh
|
|||||||
- ⑯⑰ 紅 ⇒ 注入壞了。那是「你是誰/先讀什麼/你的紅線」唯一的送達方式——
|
- ⑯⑰ 紅 ⇒ 注入壞了。那是「你是誰/先讀什麼/你的紅線」唯一的送達方式——
|
||||||
它壞了不會有人立刻發現,派工照樣送出去,只是工人不知道自己是誰
|
它壞了不會有人立刻發現,派工照樣送出去,只是工人不知道自己是誰
|
||||||
- ⑱ 紅 ⇒ 驗收 3 沒過:票上的【身份】欄吃不下工人名字(或把原本三個角色弄壞了)
|
- ⑱ 紅 ⇒ 驗收 3 沒過:票上的【身份】欄吃不下工人名字(或把原本三個角色弄壞了)
|
||||||
|
- ⑳㉑㉒ 紅 ⇒ `inkstone/ISEP#118` 沒過:課程三個 repo 找不到 `course-hand`,
|
||||||
|
或用真實形狀 `isep:course-hand` 派工被擋 ⇒ 派 ax-courses 又要開例外閘
|
||||||
|
- ㉓ 紅 ⇒ 一個工人管多個 repo 的解析壞了(只認第一個),或「有票卻沒工人」的判準
|
||||||
|
開始點名沒票/封存的 repo(誤報)。2026-09-14 突變驗過:拿掉票數條件、只認第一個 repo,這條都會紅
|
||||||
|
|
||||||
### A17 — 未經調查不寫診斷:26 條
|
### A17 — 未經調查不寫診斷:26 條
|
||||||
```
|
```
|
||||||
@@ -1210,6 +1228,30 @@ claude -p '請執行 git tag -a v9.9.9 -m test'
|
|||||||
> 為什麼挑 `release-tag-guard` 當考題:它**只存在於 ISEP**,舊的 `.claude/` 那份沒有。
|
> 為什麼挑 `release-tag-guard` 當考題:它**只存在於 ISEP**,舊的 `.claude/` 那份沒有。
|
||||||
> 用它才分得出「載到的是 plugin」還是「載到的是舊的」。
|
> 用它才分得出「載到的是 plugin」還是「載到的是舊的」。
|
||||||
|
|
||||||
|
### A8b — 這台**每一個** Claude Code 都真的載入了 ISEP:離線 11 條+實機 3 條
|
||||||
|
```
|
||||||
|
python3 scripts/isep-doctor # 看這台現況
|
||||||
|
bash scripts/test-isep-doctor.sh # 離線:假的 claude 可執行檔
|
||||||
|
bash scripts/test-isep-doctor-live.sh # 實機:真的 CLI 與 Desktop 內建那一份,隔離 HOME,不動 ~/.claude
|
||||||
|
```
|
||||||
|
**該看到**:`isep-doctor` 逐一列出 CLI 與 Desktop,各一行 ✅;離線 `pass=11 fail=0`;實機 `pass=3 fail=0 skip=0`。
|
||||||
|
|
||||||
|
**它在守什麼**(`inkstone/ISEP#150`):plugin 沒載入時,**會講話的信標本身也沒載入**,
|
||||||
|
所以只能從 plugin 外面問。而同一台 Mac 有**不只一個** Claude Code,它們讀同一份 `~/.claude` 卻不一定同一個結論:
|
||||||
|
`extraKnownMarketplaces.inkstone.source` 多了 `path` 時,**Desktop 內建的 2.1.266 整個忽略 marketplace、CLI 2.1.220 照常載入**
|
||||||
|
(c6991 兩份 debug log;本支實機測試在隔離 HOME 重現)。第一版 doctor 看設定長相就對整台判 ❌——在 CLI 上是假陽性。
|
||||||
|
⇒ 判準是**每一個 Claude Code 自己 `plugin list --json` 回報的 errors**;設定比對只當解釋,永遠不單獨判 ❌。
|
||||||
|
|
||||||
|
**失敗**:
|
||||||
|
- ① 紅 ⇒ **誤攔**,又回到看設定長相判 ❌。鑑別力實跑過:拿 8eb4c58 那版跑這份測試是 `pass=1 fail=10`,
|
||||||
|
大多是輸出格式不同;**真正分得出判準對錯的是 ①**——舊版在「CLI 實際載入」時照樣 exit 1 並點名 path
|
||||||
|
- ② 紅 ⇒ 分不出哪一個 surface 壞了,或又把整台機器講成同一個結論
|
||||||
|
- ④ 紅 ⇒ 別的原因失敗時編了一個 path 原因出來
|
||||||
|
- ⑥⑦ 紅 ⇒ 「查不了」被講成 ✅ 或 ❌(讀不到不等於沒載入)
|
||||||
|
- ⑧⑧b 紅 ⇒ 印出來的修法照著貼走不通
|
||||||
|
- ⑨ 紅 ⇒ Desktop 版本用字串排序,`2.1.99` 會被當成比 `2.1.266` 新
|
||||||
|
- 實機測試印 SKIP ⇒ 這台的版本不是 2.1.220/2.1.266,行為可能不同,那一格**沒驗過**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B. 只有 leo 能跑的(雲端)
|
## B. 只有 leo 能跑的(雲端)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
| 段 | 問題 | 為什麼一定要有 |
|
| 段 | 問題 | 為什麼一定要有 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `**負責 repo**` | 它管哪一個 repo | 派工時挑人的唯一判準;也是【身份】欄第二格 |
|
| `**負責 repo**` | 它管哪個(或哪幾個)repo | 派工時挑人的唯一判準;也是【身份】欄第二格。同一件事的幾個 repo 用「、」隔開(例:`course-hand`) |
|
||||||
| `## 開工前先讀` | 它要先讀什麼 | 這是**每個工人各自不同**的部分——共通規定補不了 |
|
| `## 開工前先讀` | 它要先讀什麼 | 這是**每個工人各自不同**的部分——共通規定補不了 |
|
||||||
| `## 紅線` | 它不准做什麼 | 全機共通的紅線在共通規定裡;這裡只寫這個 repo 特有的 |
|
| `## 紅線` | 它不准做什麼 | 全機共通的紅線在共通規定裡;這裡只寫這個 repo 特有的 |
|
||||||
| frontmatter `description` | 一句話它是誰 | `scripts/roster list` 印的就是這一行 |
|
| frontmatter `description` | 一句話它是誰 | `scripts/roster list` 印的就是這一行 |
|
||||||
@@ -39,3 +39,24 @@ bash hooks/tests/roster-guard.test.sh # 全綠
|
|||||||
```
|
```
|
||||||
|
|
||||||
**不必改任何 hook**——名單是資料,閘讀的是這個目錄。
|
**不必改任何 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`)。
|
||||||
|
|||||||
+30
-3
@@ -7,10 +7,16 @@
|
|||||||
|
|
||||||
## 一句話結論
|
## 一句話結論
|
||||||
|
|
||||||
`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **87 條註冊**(同一支閘常被多種情境同時掛上);
|
`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **88 條註冊**(同一支閘常被多種情境同時掛上);
|
||||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
其中 **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 條註冊」,
|
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||||
> 現在的寫法是實際數出來的:
|
> 現在的寫法是實際數出來的:
|
||||||
@@ -43,6 +49,25 @@
|
|||||||
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||||
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | 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 票底下三張整批漏收)。
|
||||||
|
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||||
|
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=62)。
|
||||||
|
|
||||||
|
> 📌 **`inkstone/ISEP#150`(2026-09-13)+0 支、+0 條、腳本 62→65**:沒加閘。`scripts/isep-doctor` 從 plugin **外面**
|
||||||
|
> 問這台每一個 Claude Code(CLI、Desktop 內建那一份)有沒有真的載入 ISEP——plugin 沒載入時信標自己也講不出話。
|
||||||
|
> 判準是各自 `plugin list --json` 回報的 errors,不是設定長相(`path` 在 Desktop 2.1.266 會讓 marketplace 被忽略、CLI 2.1.220 不會)。
|
||||||
|
> 另加兩支測試:`test-isep-doctor.sh`(離線)、`test-isep-doctor-live.sh`(真的 Claude Code、隔離 HOME)。
|
||||||
|
> **62 支、87 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||||
|
> `grep -c '"command":' hooks/hooks.json`=87、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||||
|
|
||||||
> 📌 **`inkstone/ISEP#147`(2026-09-07)+0 支、+1 條**:`line-needs-own-worktree.sh` 多掛 PostToolUse `Agent|Task`
|
> 📌 **`inkstone/ISEP#147`(2026-09-07)+0 支、+1 條**:`line-needs-own-worktree.sh` 多掛 PostToolUse `Agent|Task`
|
||||||
> ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。
|
> ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。
|
||||||
> 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117`/`InkStoneCo-wt-112`
|
> 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117`/`InkStoneCo-wt-112`
|
||||||
@@ -239,7 +264,7 @@
|
|||||||
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
| `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/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` 檔頭。 | 📝 記錄 |
|
| `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 注入) |
|
| `wiki-size-guard.sh` | 對話一開始講出「哪幾個 wiki 檔已經沒有人讀得完了」(預設超過 1200 行就點名)。**讀不完的必讀檔,跟沒有那個檔的差別只在於它讓人以為有。** 另一半掛在寫檔上,見 B 組。 | 📝 記錄(context 注入) |
|
||||||
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
||||||
|
|
||||||
@@ -260,6 +285,7 @@
|
|||||||
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
||||||
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
| `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 次就放行並留痕,不會鎖死。 | 🛑 擋(有上限) |
|
| `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`)。
|
> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。
|
||||||
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58:
|
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58:
|
||||||
@@ -279,7 +305,7 @@
|
|||||||
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
|
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
|
||||||
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
|
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
|
||||||
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
|
| `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 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
|
| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
|
||||||
| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 |
|
| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 |
|
||||||
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
||||||
@@ -401,6 +427,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
|||||||
| `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 |
|
| `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)——同一套判準用在兩種角色身上 |
|
| `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` 兩個名稱各掛一次),三種都要接住 |
|
| `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`
|
❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh`
|
||||||
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
|
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
|
||||||
|
|||||||
+169
-60
@@ -1,9 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
||||||
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
|
# 為什麼: 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 的原話(本閘的規格)────────────────────────────────
|
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||||
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||||
@@ -15,73 +18,165 @@
|
|||||||
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
||||||
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
||||||
#
|
#
|
||||||
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
|
# ── 背景派工為什麼改到 Stop 查(inkstone/ISEP#153,09-13 兩次實撞)──────────
|
||||||
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
|
# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線),
|
||||||
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
|
# 那時 tool_response 是 launch ack:{"isAsync": true, "status": "async_launched", ...}
|
||||||
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
|
# (同一份物件寫在 transcript 的 toolUseResult,2026-08-24 實跡)。
|
||||||
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
|
# 舊版在這一刻就查票 ⇒ 對 ISEP#150、arcrun-rag#14 各念一次「收工了但棒子沒交回來」,
|
||||||
|
# 而兩個工人之後各跑了 17 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。
|
||||||
#
|
#
|
||||||
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
|
# 真正的交件是主 transcript 裡的 <task-notification>(<status>completed</status>),
|
||||||
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
|
# 它帶著 <tool-use-id> ——**拿這個唯一識別碼回頭對上當初那一則 Agent tool_use**,
|
||||||
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
|
# 從它的 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
|
set -uo pipefail
|
||||||
|
|
||||||
INPUT=$(cat)
|
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:
|
try:
|
||||||
d = json.load(sys.stdin)
|
d = json.load(sys.stdin)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise SystemExit
|
raise SystemExit
|
||||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
|
||||||
for line in p.splitlines():
|
if d.get("hook_event_name") != "Stop":
|
||||||
if "【工單】" not in line:
|
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
|
continue
|
||||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
msg = e.get("message")
|
||||||
if m:
|
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
continue
|
||||||
break
|
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)
|
' 2>/dev/null || true)
|
||||||
|
|
||||||
[ -n "${SPEC:-}" ] || exit 0
|
[ -n "${SPECS:-}" ] || exit 0
|
||||||
# shellcheck disable=SC2086
|
|
||||||
set -- $SPEC
|
|
||||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
|
||||||
|
|
||||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
# ── 查一張票:缺哪一格就寫到 stderr 並回 2,齊了回 0 ─────────────────────
|
||||||
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
check_one(){ # $1 owner $2 repo $3 num
|
||||||
# 正式執行時這個變數不存在,一行都不會走到。
|
local OWNER="$1" REPO="$2" NUM="$3" J C REPORT BODY NOW_A NOW_L
|
||||||
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||||
|
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
||||||
|
# 正式執行時這個變數不存在,一行都不會走到。
|
||||||
|
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||||
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
||||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||||
else
|
else
|
||||||
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
||||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
local ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" URL="" U dir TOKEN
|
||||||
URL=""
|
for dir in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||||
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
[ -e "$dir/.git" ] || continue
|
||||||
[ -e "$d/.git" ] || continue
|
U=$(git -C "$dir" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
|
||||||
[ -n "${U:-}" ] && { URL="$U"; break; }
|
[ -n "${U:-}" ] && { URL="$U"; break; }
|
||||||
done
|
done
|
||||||
[ -n "$URL" ] || exit 0
|
[ -n "$URL" ] || return 0
|
||||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||||
[ "$TOKEN" != "$URL" ] || exit 0
|
[ "$TOKEN" != "$URL" ] || return 0
|
||||||
|
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||||
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
|
||||||
"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" \
|
||||||
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='[]'
|
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
||||||
import json, sys
|
import json, sys
|
||||||
try:
|
try:
|
||||||
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
||||||
except Exception:
|
except Exception:
|
||||||
raise SystemExit
|
raise SystemExit
|
||||||
|
if not isinstance(it, dict) or "state" not in it:
|
||||||
|
raise SystemExit # 讀不到票(404/錯誤回應)⇒ 不瞎報
|
||||||
if it.get("state") == "closed":
|
if it.get("state") == "closed":
|
||||||
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
||||||
|
if not isinstance(cs, list):
|
||||||
|
cs = []
|
||||||
miss = []
|
miss = []
|
||||||
if not (it.get("assignees") or []):
|
if not (it.get("assignees") or []):
|
||||||
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
|
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("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or [])))
|
||||||
print("__LABELS__" + ",".join(labs))
|
print("__LABELS__" + ",".join(labs))
|
||||||
PY
|
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 '^__')
|
cat >&2 <<EOF
|
||||||
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
|
|
||||||
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
|
|
||||||
|
|
||||||
cat >&2 <<EOF
|
|
||||||
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
|
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
|
||||||
|
|
||||||
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
|
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
|
||||||
|
|
||||||
$BODY
|
$BODY
|
||||||
|
|
||||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
|
||||||
直到最後交出正確 deliverable 並且驗證」
|
|
||||||
|
|
||||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
|
||||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
|
||||||
|
|
||||||
【一個動作把三格補齊】
|
【一個動作把三格補齊】
|
||||||
scripts/ticket handback $OWNER/$REPO#$NUM \\
|
"$TICKET" handback $OWNER/$REPO#$NUM \\
|
||||||
--to claude-code \\
|
--to claude-code \\
|
||||||
--next "<收下的人第一件事要做什麼,一句話>" \\
|
--next "<收下的人第一件事要做什麼,一句話>" \\
|
||||||
--label <s/review|s/stage|s/pending|s/todo> \\
|
--label <s/review|s/stage|s/pending|s/todo> \\
|
||||||
--evidence "<實測輸出或 PR 連結>"
|
--evidence "<實測輸出或 PR 連結>"
|
||||||
|
|
||||||
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
|
要 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
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
|
|||||||
@@ -377,6 +377,15 @@
|
|||||||
"timeout": 180
|
"timeout": 180
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh",
|
||||||
|
"timeout": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SubagentStop": [
|
"SubagentStop": [
|
||||||
|
|||||||
+31
-5
@@ -364,18 +364,42 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
|||||||
回 (members, info):
|
回 (members, info):
|
||||||
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
|
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
|
||||||
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
|
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
|
||||||
"reached": bool, "capped": bool}
|
"reached": bool, "capped": bool, "unread": [...], "complete": bool}
|
||||||
🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
|
🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
|
||||||
(讀不到 ≠ 沒有成員)。
|
(讀不到 ≠ 沒有成員)。
|
||||||
|
🔴 `unread` = 讀不到 `/dependencies` 的票(重讀過仍失敗)。**只要有一張,名單就不完整**:
|
||||||
|
那張底下的前置全部沒收到(inkstone/ISEP#130 c6973:Gitea 握手逾時,hub 票底下三張全漏,
|
||||||
|
`set` 卻印 ✅)。`complete`=reached 且沒有 unread 且沒被上限截斷——呼叫端只准在它為真時
|
||||||
|
把名單當成完整的寫出去。
|
||||||
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
|
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
|
||||||
"""
|
"""
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
try:
|
||||||
|
retries = max(0, int(os.environ.get("ISEP_MEMBER_RETRIES", "2")))
|
||||||
|
except ValueError:
|
||||||
|
retries = 2
|
||||||
|
try:
|
||||||
|
backoff = max(0.0, float(os.environ.get("ISEP_MEMBER_BACKOFF", "2")))
|
||||||
|
except ValueError:
|
||||||
|
backoff = 2.0
|
||||||
|
|
||||||
|
def read_list(path):
|
||||||
|
"""讀一頁清單;拿不到 list 就重讀(握手逾時常常下一次就好)。仍拿不到回 None。"""
|
||||||
|
for n in range(retries + 1):
|
||||||
|
got = api(path)
|
||||||
|
if isinstance(got, list):
|
||||||
|
return got
|
||||||
|
if n < retries and backoff:
|
||||||
|
time.sleep(backoff * (n + 1))
|
||||||
|
return None
|
||||||
|
|
||||||
owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id")
|
owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id")
|
||||||
title = ms.get("title") or ""
|
title = ms.get("title") or ""
|
||||||
info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms),
|
info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms),
|
||||||
"reached": False, "capped": False}
|
"reached": False, "capped": False, "unread": [], "complete": False}
|
||||||
q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100})
|
q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100})
|
||||||
rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q))
|
rows = read_list("/repos/%s/%s/issues?%s" % (owner, repo, q))
|
||||||
if not isinstance(rows, list):
|
if not isinstance(rows, list):
|
||||||
return [], info
|
return [], info
|
||||||
info["reached"] = True
|
info["reached"] = True
|
||||||
@@ -400,9 +424,10 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
|||||||
parts = split_ref(cur)
|
parts = split_ref(cur)
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
deps = api("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
|
deps = read_list("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts)
|
||||||
if not isinstance(deps, list):
|
if not isinstance(deps, list):
|
||||||
continue # 這一張讀不到:跳過它,不當成「沒有相依」以外的事
|
info["unread"].append(cur) # 讀不到 ≠ 沒有相依:記下來,名單就不算完整
|
||||||
|
continue
|
||||||
for d in deps:
|
for d in deps:
|
||||||
r = issue_ref(d)
|
r = issue_ref(d)
|
||||||
if not r or r in seen:
|
if not r or r in seen:
|
||||||
@@ -412,6 +437,7 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
|||||||
break
|
break
|
||||||
seen.add(r); out.append(r); queue.append(r)
|
seen.add(r); out.append(r); queue.append(r)
|
||||||
info["via_deps"].append(r)
|
info["via_deps"].append(r)
|
||||||
|
info["complete"] = not info["unread"] and not info["capped"]
|
||||||
return sorted(set(out)), info
|
return sorted(set(out)), info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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)
|
_DESC_RE = re.compile(r"^description:\s*(.+?)\s*$", re.M)
|
||||||
# 「**負責 repo**:inkstone/ISEP」——粗體記號與全形冒號都當可選,
|
# 「**負責 repo**:inkstone/ISEP」——粗體記號與全形冒號都當可選,
|
||||||
# 免得改一次排版就讓名單讀不到 repo(那會靜默失效,比擋錯更難發現)。
|
# 免得改一次排版就讓名單讀不到 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():
|
def roster_dir():
|
||||||
@@ -81,11 +92,13 @@ def load(directory=None):
|
|||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
dm = _DESC_RE.search(head)
|
dm = _DESC_RE.search(head)
|
||||||
rm = _REPO_RE.search(body)
|
repos = _parse_repos(body)
|
||||||
out.append({
|
out.append({
|
||||||
"name": m.group(1).strip(),
|
"name": m.group(1).strip(),
|
||||||
"description": (dm.group(1).strip() if dm else ""),
|
"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,
|
"path": path,
|
||||||
"body": body.strip(),
|
"body": body.strip(),
|
||||||
})
|
})
|
||||||
@@ -113,6 +126,43 @@ def find(name, directory=None):
|
|||||||
return 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):
|
def one_liner(w):
|
||||||
repo = w.get("repo") or "(跨 repo)"
|
repo = w.get("repo") or "(跨 repo)"
|
||||||
return "%-18s %-26s %s" % (w["name"], repo, w.get("description", ""))
|
return "%-18s %-26s %s" % (w["name"], repo, w.get("description", ""))
|
||||||
|
|||||||
@@ -95,7 +95,14 @@ class H(BaseHTTPRequestHandler):
|
|||||||
r = "%s/%s#%s" % m.groups()[:3]
|
r = "%s/%s#%s" % m.groups()[:3]
|
||||||
if r not in w["issues"]: return self._send(404, {"message": "not found"})
|
if r not in w["issues"]: return self._send(404, {"message": "not found"})
|
||||||
if not m.group(4): return self._send(200, issue_obj(w, r))
|
if not m.group(4): return self._send(200, issue_obj(w, r))
|
||||||
if m.group(4) == "/dependencies": return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
if m.group(4) == "/dependencies":
|
||||||
|
# inkstone/ISEP#130 c6973:Gitea 握手逾時時,票本身讀得到、它的 /dependencies 讀不到
|
||||||
|
fo = os.path.join(TMP, "depsfail-once"); ff = os.path.join(TMP, "depsfail")
|
||||||
|
if os.path.exists(fo) and r in open(fo).read().split():
|
||||||
|
os.remove(fo); return self._send(500, {"message": "handshake timed out (test, once)"})
|
||||||
|
if os.path.exists(ff) and r in open(ff).read().split():
|
||||||
|
return self._send(500, {"message": "handshake timed out (test)"})
|
||||||
|
return self._send(200, [issue_obj(w, c) for c in w["deps"].get(r, [])])
|
||||||
return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs])
|
return self._send(200, [issue_obj(w, pr) for pr, cs in w["deps"].items() if r in cs])
|
||||||
return self._send(404, {"message": "not found", "path": p})
|
return self._send(404, {"message": "not found", "path": p})
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
@@ -299,6 +306,38 @@ OUT=$(ml refresh --members); RC=$?
|
|||||||
[ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)"
|
[ "$RC" = 0 ] && [ "$(members)" = "$BEFORE" ] && ok "㊻ Gitea 回 500 ⇒ refresh --members 不炸、成員一張不少(讀不到 ≠ 沒有成員)" || no "㊻ 成員被清掉或炸了" "$RC $OUT / $(members)"
|
||||||
rm -f "$TMP/down"
|
rm -f "$TMP/down"
|
||||||
|
|
||||||
|
echo "── F. 讀不到任何一條 /dependencies ⇒ 不准寫出一份看起來完整的名單(inkstone/ISEP#130 → c6973)──"
|
||||||
|
jget(){ python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get(sys.argv[2]); print(v if not isinstance(v,list) else ' '.join(v))" "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$1"; }
|
||||||
|
export ISEP_MEMBER_BACKOFF=0
|
||||||
|
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||||
|
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||||
|
[ "$RC" != 0 ] && ok "F1 hub 票的 /dependencies 讀不到 ⇒ set 離開碼非 0(實際 $RC)" || no "F1 set 當成成功" "$RC $OUT"
|
||||||
|
printf '%s' "$OUT" | grep -q '✅' && no "F2 名單不完整卻印 ✅(c6973 重現的就是這個)" "$OUT" || ok "F2 名單不完整時不印 ✅"
|
||||||
|
case "$OUT" in *"成員不完整"*"inkstone/InkStoneCo#44"*) ok "F3 訊息講出「成員不完整」並點名讀不到的是哪一張" ;; *) no "F3 沒講不完整/沒點名" "$OUT" ;; esac
|
||||||
|
[ "$(jget members_complete)" = "False" ] && ok "F4 寫出去的主線檔標 members_complete=false(讀檔的人也看得出)" || no "F4 檔案沒標不完整" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
|
||||||
|
case " $(jget members_unread) " in *" inkstone/InkStoneCo#44 "*) ok "F5 members_unread 列出讀不到的票" ;; *) no "F5 沒列" "$(jget members_unread)" ;; esac
|
||||||
|
[ "$(jget ref)" = "inkstone/Arcrun#48" ] && ok "F6 主線本身照樣標定(壞的只有成員那一格)" || no "F6 主線沒標上" "$(jget ref)"
|
||||||
|
OUT=$(python3 "$TICKET" pick --mainline "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" 2>&1); RC=$?
|
||||||
|
[ "$RC" != 1 ] && ok "F7 同樣讀不到時 ticket pick 不回離開碼 1(不會讓 queue-drain-guard 當成抓盡;實際 $RC)" || no "F7 pick 回了「抓盡」" "$OUT"
|
||||||
|
rm -f "$TMP/depsfail"
|
||||||
|
|
||||||
|
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail-once"
|
||||||
|
OUT=$(ml set inkstone/Arcrun#48 --hub inkstone/InkStoneCo#44); RC=$?
|
||||||
|
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '✅' && ok "F8 只逾時一次 ⇒ 重讀一次就完整,set 照常 ✅" || no "F8 沒重試" "$RC $OUT"
|
||||||
|
case " $(members) " in *" inkstone/arcrun-rag#104 "*) ok "F9 重讀後 hub 票底下的 arcrun-rag#104 收到了" ;; *) no "F9 還是漏收" "$(members)" ;; esac
|
||||||
|
[ "$(jget members_complete)" = "True" ] && ok "F10 完整時 members_complete=true" || no "F10 完整卻沒標" "$(jget members_complete)"
|
||||||
|
|
||||||
|
OUT=$(ml refresh --members); RC=$?
|
||||||
|
case "$OUT" in *"成員"*"張"*) ok "F11 refresh --members 讀得到時會印出成員張數(c6973:原本零輸出)" ;; *) no "F11 refresh --members 還是零輸出" "$OUT" ;; esac
|
||||||
|
BEFORE=$(members)
|
||||||
|
printf 'inkstone/InkStoneCo#44\n' > "$TMP/depsfail"
|
||||||
|
OUT=$(ml refresh --members); RC=$?
|
||||||
|
[ "$RC" = 0 ] && ok "F12 refresh --members 讀不到也不炸(SessionStart 那條不准炸)" || no "F12 炸了" "$RC $OUT"
|
||||||
|
[ "$(members)" = "$BEFORE" ] && ok "F13 不完整時保留舊快取,不拿殘缺名單蓋掉" || no "F13 快取被蓋掉" "$BEFORE → $(members)"
|
||||||
|
case "$OUT" in *"成員不完整"*) ok "F14 refresh --members 講出成員不完整" ;; *) no "F14 靜默" "$OUT" ;; esac
|
||||||
|
rm -f "$TMP/depsfail"
|
||||||
|
unset ISEP_MEMBER_BACKOFF
|
||||||
|
|
||||||
echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──"
|
echo "── E. milestone-account 的帳本落在會被 clone 走的地方(票上驗法第 3 條)──"
|
||||||
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1)
|
OUT=$(CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$ACCOUNT" ledger-path 2>&1)
|
||||||
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT"
|
[ "$OUT" = "$HUB/system-dev/estimates/ledger.jsonl" ] && ok "㊼ 雲端薄殼形狀(\$CLAUDE_PROJECT_DIR 是薄殼根、真身在 InkStoneCo/)⇒ 帳本在 InkStoneCo/system-dev/estimates/" || no "㊼ 帳本路徑不對" "$OUT"
|
||||||
|
|||||||
@@ -206,6 +206,55 @@ CH=$(printf '%s' "$IN" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>/dev/null)
|
|||||||
[ -z "$CH" ] && ok "㉟ 子 session 不叫(subagent 開工不是 leo 開工)" \
|
[ -z "$CH" ] && ok "㉟ 子 session 不叫(subagent 開工不是 leo 開工)" \
|
||||||
|| no "㉟ 子 session 不叫" "實得:$CH"
|
|| 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
|
echo
|
||||||
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
|
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
|
||||||
[ "$FAIL" = 0 ]
|
[ "$FAIL" = 0 ]
|
||||||
|
|||||||
@@ -200,6 +200,84 @@ else
|
|||||||
printf ' ❌ ⑲ 真實的 subagent_type(isep:isep-hand)認不出來——就是這個造成 8/28 的死鎖\n'; FAIL=$((FAIL+1))
|
printf ' ❌ ⑲ 真實的 subagent_type(isep:isep-hand)認不出來——就是這個造成 8/28 的死鎖\n'; FAIL=$((FAIL+1))
|
||||||
fi
|
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
|
echo
|
||||||
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||||
[ "$FAIL" -eq 0 ] || exit 1
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
|
|||||||
Executable
+152
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""isep-doctor — 這台機器上每一個 Claude Code,到底有沒有把 ISEP 載進來(inkstone/ISEP#150)
|
||||||
|
|
||||||
|
為什麼要有這支:
|
||||||
|
plugin 載入失敗時,ISEP 的閘**一支都不會跑**,而且**沒有任何閘能講出來**——
|
||||||
|
講話的 isep-presence-beacon 本身就在那個沒載入的 plugin 裡。
|
||||||
|
|
||||||
|
為什麼一台機器要分開問(09-13 總管複驗 c6991 推翻了第一版):
|
||||||
|
同一台 Mac 上有**不只一個** Claude Code:CLI(例:/opt/homebrew/bin/claude 2.1.220)
|
||||||
|
與 Claude Desktop 內建的那一份(~/Library/Application Support/Claude/claude-code/<版本>/…,例:2.1.266)。
|
||||||
|
它們讀同一份 ~/.claude,**卻不一定得出同一個結論**:
|
||||||
|
settings.json 的 extraKnownMarketplaces.inkstone.source 多了 path 時——
|
||||||
|
2.1.266:整個 marketplace 被忽略 ⇒ isep@inkstone 報 "Marketplace inkstone not found"
|
||||||
|
2.1.220:照常載入
|
||||||
|
第一版只看「設定長得像會出事」就對整台機器判 ❌,在 2.1.220 上是假陽性。
|
||||||
|
|
||||||
|
判準(實際狀態,不是設定長相):
|
||||||
|
對每一個找得到的 Claude Code 各跑一次 `plugin list --json`,看它**自己**回報的 isep@inkstone:
|
||||||
|
沒有這一筆 ⇒ ❌ 沒安裝
|
||||||
|
帶 errors ⇒ ❌ 沒載入(照實轉述;若 settings 比已安裝的 marketplace 多了抓取欄位,附上原因與修法)
|
||||||
|
沒有 errors ⇒ ✅(即使設定裡有 path——那台實際載得進來就是載得進來)
|
||||||
|
跑不動/看不懂 ⇒ ◐ 查不了(讀不到不等於沒載入,也不等於有)
|
||||||
|
設定比對**只當解釋**,永遠不單獨判 ❌。
|
||||||
|
|
||||||
|
離開碼:0 全部 ✅|1 至少一個 ❌|2 沒有 ❌、但至少一個查不了(或一個 Claude Code 都找不到)
|
||||||
|
|
||||||
|
測試用環境變數:
|
||||||
|
ISEP_DOCTOR_CLAUDE_BINS 用這份清單取代自動尋找;以 os.pathsep 分隔,每項 `名稱=可執行檔路徑`
|
||||||
|
ISEP_DOCTOR_CLAUDE_HOME 代替 ~/.claude
|
||||||
|
迴歸測試:scripts/test-isep-doctor.sh(離線)/scripts/test-isep-doctor-live.sh(真的 Claude Code、隔離 HOME)
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PLUGIN_ID = "isep@inkstone"
|
||||||
|
MARKET = "inkstone"
|
||||||
|
FETCH_FIELDS = ("path", "ref", "headers", "sparsePaths")
|
||||||
|
DESKTOP_GLOB = "~/Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude"
|
||||||
|
|
||||||
|
home = os.environ.get("ISEP_DOCTOR_CLAUDE_HOME") or os.path.expanduser("~/.claude")
|
||||||
|
|
||||||
|
|
||||||
|
def _ver_key(path):
|
||||||
|
m = re.search(r"/claude-code/([0-9][0-9.]*)/", path)
|
||||||
|
return tuple(int(x) for x in m.group(1).split(".") if x) if m else ()
|
||||||
|
|
||||||
|
|
||||||
|
def find_claudes():
|
||||||
|
"""回傳 [(名稱, 路徑)]。Desktop 只取最新版本那一份(它啟動的就是那一份;舊資料夾可能殘留)。"""
|
||||||
|
env = os.environ.get("ISEP_DOCTOR_CLAUDE_BINS")
|
||||||
|
if env is not None:
|
||||||
|
out = []
|
||||||
|
for item in env.split(os.pathsep):
|
||||||
|
if "=" in item:
|
||||||
|
name, path = item.split("=", 1)
|
||||||
|
out.append((name, path))
|
||||||
|
return out
|
||||||
|
out = []
|
||||||
|
cli = shutil.which("claude")
|
||||||
|
if cli:
|
||||||
|
out.append(("CLI", cli))
|
||||||
|
desk = sorted(glob.glob(os.path.expanduser(DESKTOP_GLOB)), key=_ver_key) # 數字排序:2.1.99 < 2.1.266
|
||||||
|
if desk:
|
||||||
|
out.append(("Desktop", desk[-1]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def version_of(exe):
|
||||||
|
try:
|
||||||
|
r = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=30)
|
||||||
|
return (r.stdout.strip().split() or ["?"])[0]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def ask(exe):
|
||||||
|
"""回傳 (狀態, 內容)。狀態:'list' 內容=plugin list 陣列;'err' 內容=查不了的原因。"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run([exe, "plugin", "list", "--json"], capture_output=True, text=True, timeout=90)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return "err", f"執行失敗({e})"
|
||||||
|
try:
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError("不是陣列")
|
||||||
|
return "list", data
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
tail = (r.stdout or r.stderr or "").strip().splitlines()[-1:] or ["(沒有輸出)"]
|
||||||
|
return "err", f"plugin list --json 看不懂(exit={r.returncode}:{tail[0][:200]})"
|
||||||
|
|
||||||
|
|
||||||
|
def load(p):
|
||||||
|
try:
|
||||||
|
return json.load(open(p))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
declared = (load(os.path.join(home, "settings.json")).get("extraKnownMarketplaces") or {}).get(MARKET, {}).get("source")
|
||||||
|
known = (load(os.path.join(home, "plugins", "known_marketplaces.json")).get(MARKET) or {}).get("source")
|
||||||
|
extra = []
|
||||||
|
if isinstance(declared, dict):
|
||||||
|
extra = [k for k in FETCH_FIELDS if k in declared and (not isinstance(known, dict) or declared.get(k) != known.get(k))]
|
||||||
|
|
||||||
|
claudes = find_claudes()
|
||||||
|
if not claudes:
|
||||||
|
print("◐ 查不了:這台找不到任何 Claude Code 可執行檔(CLI 與 Desktop 都沒有)")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
bad, unknown, extra_hit = [], [], False
|
||||||
|
for name, exe in claudes:
|
||||||
|
label = f"{name} {version_of(exe)}"
|
||||||
|
state, data = ask(exe)
|
||||||
|
if state == "err":
|
||||||
|
unknown.append(name)
|
||||||
|
print(f"◐ {label}:查不了——{data}(讀不到不等於沒載入)")
|
||||||
|
print(f" {exe}")
|
||||||
|
continue
|
||||||
|
entry = next((p for p in data if p.get("id") == PLUGIN_ID), None)
|
||||||
|
if entry is None:
|
||||||
|
bad.append(name)
|
||||||
|
print(f"❌ {label}:{PLUGIN_ID} 沒有安裝——用這個 Claude Code 開的 session 一支 ISEP 閘都沒有")
|
||||||
|
print(" 修法:claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git && claude plugin install isep@inkstone")
|
||||||
|
continue
|
||||||
|
errors = entry.get("errors") or []
|
||||||
|
if not errors:
|
||||||
|
print(f"✅ {label}:{PLUGIN_ID} {entry.get('version')} 已載入(enabled={entry.get('enabled')})")
|
||||||
|
continue
|
||||||
|
bad.append(name)
|
||||||
|
print(f"❌ {label}:{PLUGIN_ID} {entry.get('version')} 沒有載入——用這個 Claude Code 開的 session 一支 ISEP 閘都沒有")
|
||||||
|
for e in errors:
|
||||||
|
print(f" Claude Code 回報:{e}")
|
||||||
|
if extra:
|
||||||
|
extra_hit = True
|
||||||
|
print(f" 可能原因:~/.claude/settings.json 的 extraKnownMarketplaces.{MARKET}.source 多了 {', '.join(extra)},"
|
||||||
|
"這一版把它當 git 抓取欄位,跟已安裝的 marketplace 對不上,整個 marketplace 被忽略")
|
||||||
|
|
||||||
|
if extra_hit:
|
||||||
|
fields = ", ".join(repr(k) for k in extra)
|
||||||
|
print("修法(一行,只拿掉上面點名的欄位;改完完全關掉 Claude Desktop/重開 CLI session 再跑一次本指令):")
|
||||||
|
print(f" python3 -c \"import json,os;p=os.path.expanduser('~/.claude/settings.json');d=json.load(open(p));"
|
||||||
|
f"s=d['extraKnownMarketplaces']['{MARKET}']['source'];[s.pop(k,None) for k in ({fields},)];"
|
||||||
|
"json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)\"")
|
||||||
|
|
||||||
|
if bad:
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(2 if unknown else 0)
|
||||||
+53
-6
@@ -20,10 +20,15 @@ leo 整天上課,一天看兩眼。**不叫,就等於這件事不會發生
|
|||||||
(機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。
|
(機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。
|
||||||
**但腳本版本身是完整可用的,不是半成品。**
|
**但腳本版本身是完整可用的,不是半成品。**
|
||||||
|
|
||||||
━━ 撈三種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━ 撈四種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了
|
① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了
|
||||||
② 掛著等 leo 的票 `Human` 或指派給 leo,**標等了幾天**
|
② 掛著等 leo 的票 `Human` 或指派給 leo,**標等了幾天**
|
||||||
③ 棒子掉在地上的票 `s/doing`/`s/review` 卻很多天沒動 ⇒ 沒有人在做
|
③ 棒子掉在地上的票 `s/doing`/`s/review` 卻很多天沒動 ⇒ 沒有人在做
|
||||||
|
④ 有票卻沒有工人的 repo 名單(`agents/`)上沒人負責、卻有開著的票
|
||||||
|
⇒ 派工一定撞 roster-guard(inkstone/ISEP#118)
|
||||||
|
**只進長版**(開場給總管看),不進 Telegram 短版——補工人是總管的事,不吵 leo。
|
||||||
|
它回答的是「新 repo 的工人誰在什麼時候加」:建 repo 之後的**第一個 session 開場**,
|
||||||
|
由這支點名;在那之前沒有任何東西會提醒。
|
||||||
|
|
||||||
每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、
|
每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、
|
||||||
講「這對你意味什麼」而不是系統內部狀態。
|
講「這對你意味什麼」而不是系統內部狀態。
|
||||||
@@ -133,10 +138,12 @@ def collect(fixture=None):
|
|||||||
return {"milestones": [], "issues": [], "error":
|
return {"milestones": [], "issues": [], "error":
|
||||||
"拿不到 Gitea token(GITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"}
|
"拿不到 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()
|
repos = os.environ.get("ISEP_NAG_REPOS", "").split()
|
||||||
if not repos:
|
if not repos:
|
||||||
rows = api("/orgs/%s/repos?limit=50" % ORG, tok) or []
|
repos = [r.get("full_name") for r in org_rows if r.get("full_name")]
|
||||||
repos = [r.get("full_name") for r in rows if r.get("full_name")]
|
|
||||||
|
|
||||||
# 🔴 平行撈,因為這支掛在 SessionStart 上——那是 leo 每次開工的關鍵路徑。
|
# 🔴 平行撈,因為這支掛在 SessionStart 上——那是 leo 每次開工的關鍵路徑。
|
||||||
# 循序版實測 16 秒(16 個 repo + 5 頁票),**開場等 16 秒沒有人會忍受它,
|
# 循序版實測 16 秒(16 個 repo + 5 頁票),**開場等 16 秒沒有人會忍受它,
|
||||||
@@ -175,7 +182,7 @@ def collect(fixture=None):
|
|||||||
issues.extend(rows)
|
issues.extend(rows)
|
||||||
if len(rows) < 50:
|
if len(rows) < 50:
|
||||||
break
|
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"])
|
waiting.sort(key=lambda x: -x["quiet_days"])
|
||||||
dropped.sort(key=lambda x: -x["quiet_days"])
|
dropped.sort(key=lambda x: -x["quiet_days"])
|
||||||
return {"overdue_milestones": over, "waiting_on_leo": waiting,
|
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)
|
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:
|
if n == 0:
|
||||||
out += ["", "✅ **查過了,沒有。** 三類都乾淨:",
|
out += ["", "✅ **查過了,沒有。** 三類都乾淨:",
|
||||||
" · 沒有逾期還開著票的 milestone",
|
" · 沒有逾期還開著票的 milestone",
|
||||||
" · 沒有掛著等你的票",
|
" · 沒有掛著等你的票",
|
||||||
" · 沒有超過 %d 天沒動的 s/doing/s/review" % STALE_DAYS]
|
" · 沒有超過 %d 天沒動的 s/doing/s/review" % STALE_DAYS]
|
||||||
|
out += worker_section(f)
|
||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
if f["overdue_milestones"]:
|
if f["overdue_milestones"]:
|
||||||
@@ -295,9 +321,30 @@ def full_report(f, now, err=None):
|
|||||||
_clip(i["title"], 38)))
|
_clip(i["title"], 38)))
|
||||||
out += _tail(f["dropped_batons"], MAX_ROWS)
|
out += _tail(f["dropped_batons"], MAX_ROWS)
|
||||||
out.append(" ⇒ 對你意味著:這些不是在做,是沒人接。要嘛派人,要嘛改回 s/todo。")
|
out.append(" ⇒ 對你意味著:這些不是在做,是沒人接。要嘛派人,要嘛改回 s/todo。")
|
||||||
|
out += worker_section(f)
|
||||||
return "\n".join(out)
|
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):
|
def short_report(f, now, err=None):
|
||||||
"""Telegram 版:leo 在手機上一眼掃完、回一個詞。**寧短勿長。**"""
|
"""Telegram 版:leo 在手機上一眼掃完、回一個詞。**寧短勿長。**"""
|
||||||
if err:
|
if err:
|
||||||
|
|||||||
+51
-7
@@ -252,21 +252,50 @@ def cmd_set(argv):
|
|||||||
"members": [],
|
"members": [],
|
||||||
}
|
}
|
||||||
refs, info = collect_members(ms)
|
refs, info = collect_members(ms)
|
||||||
|
complete = bool(info.get("reached") and info.get("complete"))
|
||||||
|
if not complete:
|
||||||
|
# 名單不完整:同一條線重標時,舊快取裡有的成員先留著(讀不到 ≠ 沒有)
|
||||||
|
prev = ML.load() or {}
|
||||||
|
if prev.get("ref") == ms["ref"]:
|
||||||
|
refs = sorted(set(refs) | set(prev.get("members") or []))
|
||||||
ms["members"] = refs
|
ms["members"] = refs
|
||||||
|
ms["members_complete"] = complete
|
||||||
|
ms["members_unread"] = incomplete_what(info)
|
||||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||||
ms["set_at"] = ms["refreshed_at"] = now
|
ms["set_at"] = ms["refreshed_at"] = now
|
||||||
if not ML.save(ms, mirror=True):
|
if not ML.save(ms, mirror=True):
|
||||||
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
|
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
|
||||||
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title))
|
# 🔴 inkstone/ISEP#130 c6973:名單不完整時不准印 ✅——雲端工人靠這份名單抓票,
|
||||||
|
# 漏收=以為抓盡=收工。主線本身照樣標定,壞的只有成員那一格,所以講清楚是哪一格。
|
||||||
|
print("%s 主線已標定:%s/%s#%d「%s」%s" % (
|
||||||
|
"✅" if complete else "⚠️", owner, repo, mid, title, "" if complete else "(🔴 成員不完整)"))
|
||||||
if hub:
|
if hub:
|
||||||
print(" hub 票:%s" % hub)
|
print(" hub 票:%s" % hub)
|
||||||
print(members_line(refs, info) if info.get("reached")
|
print(members_line(refs, info) if info.get("reached")
|
||||||
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
|
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
|
||||||
if info.get("capped"):
|
if not complete:
|
||||||
print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP)
|
print(incomplete_line(info))
|
||||||
print(mirror_line())
|
print(mirror_line())
|
||||||
print()
|
print()
|
||||||
cmd_show([])
|
cmd_show([])
|
||||||
|
if not complete:
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
|
||||||
|
def incomplete_what(info):
|
||||||
|
"""名單不完整的原因,寫進主線檔(讀檔的人不必重跑也看得出)。完整時回 []。"""
|
||||||
|
what = list(info.get("unread") or [])
|
||||||
|
if not info.get("reached"):
|
||||||
|
what.insert(0, "(hub 里程碑那一頁)")
|
||||||
|
if info.get("capped"):
|
||||||
|
what.append("(相依走到 %d 張上限)" % ML.MEMBER_WALK_CAP)
|
||||||
|
return what
|
||||||
|
|
||||||
|
|
||||||
|
def incomplete_line(info):
|
||||||
|
return (" 🔴 成員不完整:讀不到 %s——那底下的前置沒收到。\n"
|
||||||
|
" 雲端工人靠這份名單抓票;Gitea 恢復後重跑 `mainline refresh --members`(或同一行 set)補齊。"
|
||||||
|
% "、".join(incomplete_what(info)))
|
||||||
|
|
||||||
|
|
||||||
def mirror_line():
|
def mirror_line():
|
||||||
@@ -328,6 +357,9 @@ def cmd_refresh(argv):
|
|||||||
except SystemExit:
|
except SystemExit:
|
||||||
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
|
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
|
||||||
if not isinstance(d, dict) or not d.get("title"):
|
if not isinstance(d, dict) or not d.get("title"):
|
||||||
|
if "--members" in argv: # 明講要成員的人要看得到結果(c6973:原本零輸出)
|
||||||
|
print(" ⚠️ 讀不到主線里程碑 %s——成員沒更新,沿用舊快取 %d 張"
|
||||||
|
% (ML.ref_of(ms), len(ms.get("members") or [])))
|
||||||
return # 讀不到 ≠ 主線不存在
|
return # 讀不到 ≠ 主線不存在
|
||||||
ms.update({
|
ms.update({
|
||||||
"title": d.get("title") or ms["title"],
|
"title": d.get("title") or ms["title"],
|
||||||
@@ -342,12 +374,19 @@ def cmd_refresh(argv):
|
|||||||
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
|
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
|
||||||
# `set`/`adopt` 會重收,`pick`/`has`/focus guard 本來就每次現問 Gitea,快取只餵 `show`。
|
# `set`/`adopt` 會重收,`pick`/`has`/focus guard 本來就每次現問 Gitea,快取只餵 `show`。
|
||||||
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
|
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
|
||||||
|
# 🔴 inkstone/ISEP#130 c6973:只有**完整**的名單才准蓋掉快取;不完整就留舊的並講出來。
|
||||||
try:
|
try:
|
||||||
refs, info = collect_members(ms)
|
refs, info = collect_members(ms)
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
refs, info = [], {"reached": False}
|
refs, info = [], {"reached": False}
|
||||||
if info.get("reached"):
|
if info.get("reached") and info.get("complete"):
|
||||||
ms["members"] = refs
|
ms["members"] = refs
|
||||||
|
ms["members_complete"], ms["members_unread"] = True, []
|
||||||
|
print(members_line(refs, info))
|
||||||
|
else:
|
||||||
|
ms["members_complete"], ms["members_unread"] = False, incomplete_what(info)
|
||||||
|
print(" ⚠️ 成員不完整,沿用舊快取 %d 張(沒有拿殘缺名單蓋掉)" % len(ms.get("members") or []))
|
||||||
|
print(incomplete_line(info))
|
||||||
ML.save(ms)
|
ML.save(ms)
|
||||||
|
|
||||||
|
|
||||||
@@ -391,13 +430,18 @@ hub 里程碑裡現在有:%s
|
|||||||
T.add_dependency(vparts, (owner, repo, num))
|
T.add_dependency(vparts, (owner, repo, num))
|
||||||
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
|
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
|
||||||
refs, info = collect_members(ms)
|
refs, info = collect_members(ms)
|
||||||
if info.get("reached"):
|
complete = bool(info.get("reached") and info.get("complete"))
|
||||||
|
if complete:
|
||||||
ms["members"] = sorted(set(refs) | {ref})
|
ms["members"] = sorted(set(refs) | {ref})
|
||||||
else:
|
else:
|
||||||
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
|
# 不完整(c6973):新收到的與舊快取取聯集,不拿殘缺名單蓋掉
|
||||||
|
ms["members"] = sorted(set(ms.get("members") or []) | set(refs) | {ref})
|
||||||
|
ms["members_complete"], ms["members_unread"] = complete, incomplete_what(info)
|
||||||
ML.save(ms, mirror=True)
|
ML.save(ms, mirror=True)
|
||||||
print("✅ 補收:%s → %s" % (ref, how))
|
print("✅ 補收:%s → %s" % (ref, how))
|
||||||
print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)")
|
print(members_line(ms["members"], info) if complete else " (成員清單=舊快取+這次讀得到的+這一張)")
|
||||||
|
if not complete:
|
||||||
|
print(incomplete_line(info))
|
||||||
print(mirror_line())
|
print(mirror_line())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ def cmd_show(name):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_which(repo):
|
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:
|
if not hits:
|
||||||
print("🚫 名單上沒有負責「%s」的人。現有的是:" % repo, file=sys.stderr)
|
print("🚫 名單上沒有負責「%s」的人。現有的是:" % repo, file=sys.stderr)
|
||||||
for w in R.load():
|
for w in R.load():
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 交棒警察的測試——三格(指派/tag/下一步)各自缺漏時要被抓到
|
# 交棒警察的測試——三格(指派/tag/下一步)各自缺漏時要被抓到
|
||||||
# 判準:5 種該報、5 種不該報
|
# 判準:前景 5 種該報、5 種不該報;背景派工(inkstone/ISEP#153)送出時不報、交件時才報、只報一次;
|
||||||
|
# 訊息印的出路照著貼真的跑得完。
|
||||||
# 用 fixture 跑,不打網路、不在票池留下任何一張測試票。
|
# 用 fixture 跑,不打網路、不在票池留下任何一張測試票。
|
||||||
cd "$(dirname "$0")/.." || exit 1
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
ROOT=$(pwd)
|
||||||
H=hooks/baton-handback-guard.sh
|
H=hooks/baton-handback-guard.sh
|
||||||
PASS=0; FAIL=0
|
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)
|
mkfx(){ # $1=state $2=assignees(csv) $3=labels(csv) $4=有沒有交棒留言(y/n)
|
||||||
python3 - "$FX" "$1" "$2" "$3" "$4" <<'PY'
|
python3 - "$FX" "$1" "$2" "$3" "$4" <<'PY'
|
||||||
@@ -22,12 +25,15 @@ json.dump(comments, open(os.path.join(d, "comments.json"), "w"))
|
|||||||
PY
|
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 已備好)
|
run(){ # $1=want $2=說明 (fixture 已備好)
|
||||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\n做點事"}}' \
|
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\n做點事"}}' \
|
||||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||||
got=$?
|
judge "$1" "$?" "$2"
|
||||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
|
||||||
printf 'want=%s got=%s %s\n' "$1" "$got" "$2"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "── 該報:三格有缺 ──"
|
echo "── 該報:三格有缺 ──"
|
||||||
@@ -46,9 +52,98 @@ mkfx closed "" "" n ; run 0 "票已關=棒子到
|
|||||||
echo "── 派工單裡沒有【工單】那行 ⇒ 不歸這支管 ──"
|
echo "── 派工單裡沒有【工單】那行 ⇒ 不歸這支管 ──"
|
||||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"隨便做點事"}}' \
|
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"隨便做點事"}}' \
|
||||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||||
got=$?
|
judge 0 "$?" "沒有票號的派工單"
|
||||||
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
|
||||||
printf 'want=0 got=%s 沒有票號的派工單\n' "$got"
|
# ═══ 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
|
||||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||||
|
|||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 實測:scripts/isep-doctor 對**真的** Claude Code(這台的 CLI 與 Desktop 內建那一份)判得對不對(inkstone/ISEP#150)
|
||||||
|
#
|
||||||
|
# 不動 ~/.claude:把 inkstone marketplace 與 isep 安裝紀錄複製到 mktemp 的隔離 HOME,
|
||||||
|
# 分別放「有 path」「沒有 path」兩份 settings.json,用真的可執行檔跑 doctor。
|
||||||
|
# 會打網路:只有 Claude Code 自己可能匿名讀 git.uncle6.me(Gitea),不碰 GitHub、不寫任何遠端。
|
||||||
|
#
|
||||||
|
# 期望(09-13 在 CLI 2.1.220/Desktop 2.1.266 上實測過的行為):
|
||||||
|
# 沒有 path ⇒ 兩個都 ✅,離開碼 0 (真陰性)
|
||||||
|
# 有 path、Desktop 2.1.266 ⇒ ❌ 並點名 path (真陽性)
|
||||||
|
# 有 path、CLI 2.1.220 ⇒ ✅,不因設定長相被判 ❌ (舊版 doctor 的假陽性)
|
||||||
|
# 版本不是這兩個時,該版本那一格印 SKIP(行為可能不同,不假裝驗過)。
|
||||||
|
set -u
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
R="$HOME/.claude/plugins"
|
||||||
|
[ -d "$R/cache/inkstone" ] && [ -d "$R/marketplaces/inkstone" ] || { echo "SKIP:這台沒有安裝 isep@inkstone,無從複製"; exit 0; }
|
||||||
|
|
||||||
|
CLI=$(command -v claude || true)
|
||||||
|
DESK=$(ls -d "$HOME/Library/Application Support/Claude/claude-code/"*/claude.app/Contents/MacOS/claude 2>/dev/null \
|
||||||
|
| python3 -c 'import sys,re;L=[l.strip() for l in sys.stdin if l.strip()];k=lambda p:tuple(int(x) for x in re.search(r"/claude-code/([0-9.]+)/",p).group(1).split(".") if x);print(sorted(L,key=k)[-1] if L else "")')
|
||||||
|
BINS=""; [ -n "$CLI" ] && BINS="CLI=$CLI"; [ -n "$DESK" ] && BINS="${BINS:+$BINS:}Desktop=$DESK"
|
||||||
|
[ -n "$BINS" ] || { echo "SKIP:找不到任何 Claude Code"; exit 0; }
|
||||||
|
vcli=$([ -n "$CLI" ] && "$CLI" --version | awk '{print $1}'); vdesk=$([ -n "$DESK" ] && "$DESK" --version | awk '{print $1}')
|
||||||
|
echo "CLI=${CLI:-無} ${vcli:-}|Desktop=${DESK:-無} ${vdesk:-}"
|
||||||
|
|
||||||
|
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||||
|
C="$T/home/.claude"; mkdir -p "$C/plugins/cache" "$C/plugins/marketplaces"
|
||||||
|
cp -R "$R/cache/inkstone" "$C/plugins/cache/" && cp -R "$R/marketplaces/inkstone" "$C/plugins/marketplaces/"
|
||||||
|
python3 - "$R" "$C" <<'EOF'
|
||||||
|
import json, sys
|
||||||
|
R, C = sys.argv[1], sys.argv[2]
|
||||||
|
km = json.load(open(R + "/known_marketplaces.json"))
|
||||||
|
json.dump({"inkstone": json.loads(json.dumps(km["inkstone"]).replace(R, C + "/plugins"))}, open(C + "/plugins/known_marketplaces.json", "w"))
|
||||||
|
ip = json.load(open(R + "/installed_plugins.json"))
|
||||||
|
ip["plugins"] = {"isep@inkstone": ip["plugins"]["isep@inkstone"]}
|
||||||
|
json.dump(json.loads(json.dumps(ip).replace(R, C + "/plugins")), open(C + "/plugins/installed_plugins.json", "w"))
|
||||||
|
src = {"source": "git", "url": "https://git.uncle6.me/inkstone/ISEP.git"}
|
||||||
|
json.dump({"enabledPlugins": {"isep@inkstone": True}, "extraKnownMarketplaces": {"inkstone": {"source": src}}}, open(C + "/settings.nopath.json", "w"))
|
||||||
|
src["path"] = "/Users/youlinhsieh/Documents/tech_projects/ISEP"
|
||||||
|
json.dump({"enabledPlugins": {"isep@inkstone": True}, "extraKnownMarketplaces": {"inkstone": {"source": src}}}, open(C + "/settings.path.json", "w"))
|
||||||
|
EOF
|
||||||
|
cp -R "$C" "$T/pristine"
|
||||||
|
|
||||||
|
pass=0; fail=0; skip=0
|
||||||
|
doctor() { # settings 變體
|
||||||
|
rm -rf "$C" && cp -R "$T/pristine" "$C" && cp "$C/settings.$1.json" "$C/settings.json"
|
||||||
|
OUT=$(cd "$T" && env -u CLAUDE_CODE_CHILD_SESSION HOME="$T/home" ISEP_DOCTOR_CLAUDE_BINS="$BINS" python3 "$HERE/isep-doctor" 2>&1); CODE=$?
|
||||||
|
echo "── settings=$1(exit=$CODE)"; printf '%s\n' "$OUT" | sed 's/^/ /'
|
||||||
|
}
|
||||||
|
has() { printf '%s' "$OUT" | grep -q -- "$1"; }
|
||||||
|
judge() { if eval "$2"; then pass=$((pass+1)); echo "ok $1"; else fail=$((fail+1)); echo "FAIL $1"; fi; }
|
||||||
|
|
||||||
|
doctor nopath
|
||||||
|
judge "真陰性:沒有 path → 離開碼 0、沒有 ❌" '[ "$CODE" = 0 ] && ! has "❌"'
|
||||||
|
|
||||||
|
doctor path
|
||||||
|
if [ "$vdesk" = 2.1.266 ]; then
|
||||||
|
judge "真陽性:Desktop 2.1.266 有 path → ❌ 並點名 path、離開碼 1" '[ "$CODE" = 1 ] && has "❌ Desktop 2.1.266" && has "多了 path"'
|
||||||
|
else skip=$((skip+1)); echo "SKIP 真陽性:Desktop 是 ${vdesk:-無},不是 2.1.266"; fi
|
||||||
|
if [ "$vcli" = 2.1.220 ]; then
|
||||||
|
judge "假陽性:CLI 2.1.220 有 path 但實際載入 → ✅" 'has "✅ CLI 2.1.220"'
|
||||||
|
else skip=$((skip+1)); echo "SKIP 假陽性:CLI 是 ${vcli:-無},不是 2.1.220"; fi
|
||||||
|
|
||||||
|
echo "pass=$pass fail=$fail skip=$skip"; [ "$fail" = 0 ]
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 迴歸測試:scripts/isep-doctor(inkstone/ISEP#150)——全離線
|
||||||
|
# 用假的 claude 可執行檔(shell 腳本)代替真的 CLI/Desktop,走的是 doctor 真正的 subprocess 那條路。
|
||||||
|
# 真的 Claude Code 版本的實測在 scripts/test-isep-doctor-live.sh。
|
||||||
|
set -u
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||||
|
pass=0; fail=0
|
||||||
|
URL=https://git.uncle6.me/inkstone/ISEP.git
|
||||||
|
|
||||||
|
mkfake() { # 路徑 版本 plugin-list 輸出(原樣印出)
|
||||||
|
mkdir -p "$(dirname "$1")"
|
||||||
|
printf '%s' "$3" > "$1.out"
|
||||||
|
cat > "$1" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
if [ "\$1" = "--version" ]; then echo "$2 (Claude Code)"; exit 0; fi
|
||||||
|
cat "$1.out"
|
||||||
|
EOF
|
||||||
|
chmod +x "$1"
|
||||||
|
}
|
||||||
|
run() { OUT=$(ISEP_DOCTOR_CLAUDE_HOME="$T/home" python3 "$HERE/isep-doctor" 2>&1); CODE=$?; }
|
||||||
|
ok() { pass=$((pass+1)); echo "ok $1"; }
|
||||||
|
bad() { fail=$((fail+1)); echo "FAIL $1 (exit=$CODE)"; printf '%s\n' "$OUT" | sed 's/^/ /'; }
|
||||||
|
expect() { # 名稱 離開碼 要有的樣式(| 分隔,可空) 不准有的樣式(可空)
|
||||||
|
local p miss=""
|
||||||
|
[ "$CODE" = "$2" ] || miss="exit"
|
||||||
|
local IFS='|'
|
||||||
|
for p in $3; do printf '%s' "$OUT" | grep -q -- "$p" || miss="$miss 缺[$p]"; done
|
||||||
|
if [ -n "${4:-}" ] && printf '%s' "$OUT" | grep -q -- "$4"; then miss="$miss 多了[$4]"; fi
|
||||||
|
[ -z "$miss" ] && ok "$1" || { bad "$1 →$miss"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$T/home/plugins"
|
||||||
|
echo "{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\"}}}" > "$T/home/plugins/known_marketplaces.json"
|
||||||
|
SET_PATH="{\"extraKnownMarketplaces\":{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\",\"path\":\"/Users/x/ISEP\"}}},\"model\":\"keep\"}"
|
||||||
|
SET_CLEAN="{\"extraKnownMarketplaces\":{\"inkstone\":{\"source\":{\"source\":\"git\",\"url\":\"$URL\"}}}}"
|
||||||
|
LOADED='[{"id":"isep@inkstone","version":"0.30.1","enabled":true}]'
|
||||||
|
NOTFOUND='[{"id":"isep@inkstone","version":"0.30.1","enabled":true,"errors":["Marketplace inkstone not found"]}]'
|
||||||
|
|
||||||
|
# ① 假陽性(c6991):設定有 path,但這個 Claude Code 自己回報載入正常 → ✅、不准點名 path
|
||||||
|
echo "$SET_PATH" > "$T/home/settings.json"
|
||||||
|
mkfake "$T/b/cli" 2.1.220 "$LOADED"
|
||||||
|
export ISEP_DOCTOR_CLAUDE_BINS="CLI=$T/b/cli"; run
|
||||||
|
expect "① 有 path 但實際載入(2.1.220 形狀)→ ✅ 不點名 path" 0 "✅ CLI 2.1.220" "path"
|
||||||
|
|
||||||
|
# ② 真陽性(c6990):同一份設定,CLI 載得進、Desktop 載不進 → 1;兩邊各講各的;點名 path;給修法
|
||||||
|
mkfake "$T/b/desk" 2.1.266 "$NOTFOUND"
|
||||||
|
export ISEP_DOCTOR_CLAUDE_BINS="CLI=$T/b/cli:Desktop=$T/b/desk"; run
|
||||||
|
expect "② CLI 載得進、Desktop 載不進 → 1,逐一講、點名 path" 1 "✅ CLI 2.1.220|❌ Desktop 2.1.266|Marketplace inkstone not found|多了 path|^ python3 "
|
||||||
|
printf '%s' "$OUT" | grep -q "CLI 與 Desktop 都一樣" && bad "② 不准再說 CLI 與 Desktop 都一樣" || ok "② 沒有把整台機器講成同一個結論"
|
||||||
|
|
||||||
|
# ③ 真陰性:拿掉 path,兩邊都載入 → 0
|
||||||
|
echo "$SET_CLEAN" > "$T/home/settings.json"
|
||||||
|
mkfake "$T/b/desk" 2.1.266 "$LOADED"; run
|
||||||
|
expect "③ 沒有 path、兩邊都載入 → 0" 0 "✅ CLI 2.1.220|✅ Desktop 2.1.266" "❌"
|
||||||
|
|
||||||
|
# ④ 別的原因失敗(設定沒有多餘欄位)→ 1,照實轉述,不編原因、不給 path 修法
|
||||||
|
mkfake "$T/b/desk" 2.1.266 '[{"id":"isep@inkstone","version":"0.30.1","enabled":true,"errors":["Plugin directory missing"]}]'; run
|
||||||
|
expect "④ 其他錯誤照實轉述、不編原因" 1 "❌ Desktop|Plugin directory missing" "修法(一行"
|
||||||
|
|
||||||
|
# ⑤ 沒安裝 → 1
|
||||||
|
mkfake "$T/b/desk" 2.1.266 '[]'; run
|
||||||
|
expect "⑤ 沒安裝 → 1" 1 "❌ Desktop 2.1.266:isep@inkstone 沒有安裝"
|
||||||
|
|
||||||
|
# ⑥ 查不了(輸出不是 JSON)+另一個正常 → 2,◐,不准講成 ❌ 或 ✅
|
||||||
|
mkfake "$T/b/desk" 2.1.266 'Error: something broke'; run
|
||||||
|
expect "⑥ 一個查不了、一個正常 → 2" 2 "◐ Desktop|讀不到不等於沒載入|✅ CLI" "❌"
|
||||||
|
|
||||||
|
# ⑦ 一個 Claude Code 都找不到 → 2
|
||||||
|
export ISEP_DOCTOR_CLAUDE_BINS=""; run
|
||||||
|
expect "⑦ 找不到任何 Claude Code → 2" 2 "◐ 查不了"
|
||||||
|
|
||||||
|
# ⑧ 修法那一行真的修得好(在假 HOME 上原封不動執行印給 leo 的那一行)
|
||||||
|
echo "$SET_PATH" > "$T/home/settings.json"
|
||||||
|
mkfake "$T/b/desk" 2.1.266 "$NOTFOUND"
|
||||||
|
export ISEP_DOCTOR_CLAUDE_BINS="Desktop=$T/b/desk"; run
|
||||||
|
fixline=$(printf '%s\n' "$OUT" | grep '^ python3 ' | sed 's/^ //')
|
||||||
|
mkdir -p "$T/fake/.claude" && cp "$T/home/settings.json" "$T/fake/.claude/settings.json"
|
||||||
|
HOME="$T/fake" bash -c "$fixline"
|
||||||
|
if [ -n "$fixline" ] && python3 -c "import json,sys;d=json.load(open('$T/fake/.claude/settings.json'));s=d['extraKnownMarketplaces']['inkstone']['source'];sys.exit(0 if 'path' not in s and d['model']=='keep' and s['url'].endswith('ISEP.git') else 1)"; then
|
||||||
|
ok "⑧ 修法一行拿掉 path、其他設定不動"
|
||||||
|
else fail=$((fail+1)); echo "FAIL ⑧ 修法一行"; cat "$T/fake/.claude/settings.json"; fi
|
||||||
|
# ⑧b 修完再問一次(Desktop 改回報正常)→ 0:出路走得通
|
||||||
|
cp "$T/fake/.claude/settings.json" "$T/home/settings.json"; mkfake "$T/b/desk" 2.1.266 "$LOADED"; run
|
||||||
|
expect "⑧b 照修法修完再跑 → ✅" 0 "✅ Desktop" "多了 path"
|
||||||
|
|
||||||
|
# ⑨ 自動尋找:Desktop 取數字上最新的那一份(2.1.99 < 2.1.266,字串排序會選錯)
|
||||||
|
unset ISEP_DOCTOR_CLAUDE_BINS
|
||||||
|
D="$T/h9/Library/Application Support/Claude/claude-code"
|
||||||
|
mkfake "$D/2.1.99/claude.app/Contents/MacOS/claude" 2.1.99 "$NOTFOUND"
|
||||||
|
mkfake "$D/2.1.266/claude.app/Contents/MacOS/claude" 2.1.266 "$LOADED"
|
||||||
|
OUT=$(HOME="$T/h9" PATH="/usr/bin:/bin" ISEP_DOCTOR_CLAUDE_HOME="$T/home" /usr/bin/env python3 "$HERE/isep-doctor" 2>&1); CODE=$?
|
||||||
|
expect "⑨ Desktop 取最新版本(數字排序)" 0 "✅ Desktop 2.1.266" "2.1.99"
|
||||||
|
|
||||||
|
echo "pass=$pass fail=$fail"; [ "$fail" = 0 ]
|
||||||
Reference in New Issue
Block a user