Compare commits

..

15 Commits

Author SHA1 Message Date
Leo 25da674615 定版 v0.30.3(交棒警察不再對剛出發的背景工人說收工了)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:42:04 +08:00
Leo 01dafb01b5 Merge '交棒警察不再對剛出發的背景工人說收工了'(inkstone/ISEP#153)
總管複驗(09-13):scripts/test-baton-handback-guard.sh 24/24 通過;樹上當場數 hooks 62、註冊 88、腳本 65 與 plugin.json 描述一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:41:07 +08:00
Leo 436e30b9d1 交棒警察不再對剛出發的背景工人說「收工了」(inkstone/ISEP#153)
- PostToolUse:Agent 看到 launch ack(isAsync/async_launched/"Async agent launched")就不查票
- 多掛一條 Stop:主 transcript 出現 <task-notification> completed 時,用 tool-use-id
  對回原本那則 Agent tool_use、取【工單】再查三格;同一條線只點名一次(狀態檔)
- 出路改印絕對路徑:InkStoneCo/scripts/ticket 是舊複本,grep -c handback → 0
- 測試 10 → 24 條;新 14 條拿 main 的舊 hook 跑是 7 條紅
- 盤點:62 支、88 條(+1)、65 支腳本,當場數的

待總管定版(plugin.json version 未動)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:30:45 +08:00
Leo 916a6890ff 定版 v0.30.2(isep-doctor 講得出每個 Claude Code 實際有沒有載入 ISEP)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:45:50 +08:00
Leo ba1f2a50f8 Merge 'isep-doctor 判準改成每個 Claude Code 自己回報有沒有載入'(inkstone/ISEP#150)
總管複驗(09-13):test-isep-doctor.sh pass=11 fail=0;test-isep-doctor-live.sh pass=3 fail=0 skip=0;本機實跑 CLI 2.1.220/Desktop 2.1.266 皆  exit 0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:36:54 +08:00
Leo fbc56b9e59 isep-doctor 判準改成「每一個 Claude Code 自己回報有沒有載入」,不再看設定長相(inkstone/ISEP#150 → c6995)
同一台 Mac 有 CLI 與 Desktop 內建兩個 Claude Code,讀同一份 ~/.claude 卻不一定同結論:
settings 多了 path 時 Desktop 2.1.266 忽略 inkstone marketplace、CLI 2.1.220 照常載入(c6991)。
第一版看到 path 就對整台判 ,在 CLI 上是假陽性。

- 逐一找 CLI(PATH)與 Desktop(最新版本,數字排序),各跑 plugin list --json,依 errors 各判各的
- 設定比對只在某一個真的報錯時當解釋+修法,永遠不單獨判 
- 查不了 ⇒ ◐、離開碼 2,不講成 
- test-isep-doctor.sh 離線 11 條(假 claude 可執行檔);舊版跑這份 pass=1 fail=10
- test-isep-doctor-live.sh 實機 3 條:真的 CLI 2.1.220/Desktop 2.1.266,隔離 HOME 重現有/無 path,不動 ~/.claude
- 盤點表與 plugin.json 描述的腳本數 62→65(這棵樹上實數)

版本號待總管定。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:23:40 +08:00
Leo 8eb4c58d2d isep-doctor:講出這台的 ISEP plugin 有沒有真的載入(inkstone/ISEP#150)
根因不是「Desktop 不跑 hook」:Desktop 1.52386.0/CC 2.1.266 內實測專案
PreToolUse hook 照擋。真正斷的是 ~/.claude/settings.json
extraKnownMarketplaces.inkstone.source 多了 path,CC 把它當 git 抓取欄位,
marketplace 被忽略 ⇒ isep@inkstone「Marketplace inkstone not found」,
59 支閘在 CLI 與 Desktop 一起失效,而且沒有任何閘講得出來。

本支從 plugin 外面查(claude plugin list --json + settings 對 known_marketplaces),
點名多出的欄位並印出一行修法。迴歸測試 scripts/test-isep-doctor.sh 5/5。

假設:修 ~/.claude/settings.json 是 leo 的個人設定,由 leo 親手執行,本 commit 不動它。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:55:26 +08:00
Leo c1df3c7a08 定版 v0.30.1(主線成員讀不到就講出來,不再讓雲端工人以為抓盡)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:07:25 +08:00
Leo 544beefc5e Merge '主線成員讀不到任何一條 /dependencies 時不准印出完整名單'(inkstone/ISEP#130)
總管複驗:members-by-dependency 70/71(唯一紅項 ㊽ 與 main 上相同)、gitea-fallback 31/31、repo-mirror 17/17、ticket-pick 58/58

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:07:02 +08:00
Leo c0eec76b52 主線成員讀不到任何一條 /dependencies 時不准印出完整名單:set 不印 並離開碼 3、refresh/adopt 保留舊快取並講出來(inkstone/ISEP#130 → c6973)
總管 09-13 實撞:Gitea 握手逾時,hub 票 InkStoneCo#132 本身讀得到、它的 /dependencies 讀不到,
`mainline set` 仍印「 成員 6 張」,底下三張前置全漏;`refresh --members` 零輸出、名單沒變。
雲端工人靠這份名單抓票,漏收=以為抓盡=收工,等於 v0.30.0 換個入口又停。

- collect_members:清單讀取拿不到就重讀(ISEP_MEMBER_RETRIES 預設 2、退避 2s×n);
  仍讀不到的票記進 info["unread"],info["complete"]=沒有 unread 且沒被上限截斷
- set:不完整時印 ⚠️🔴 成員不完整)+點名讀不到的票,主線檔寫 members_complete/members_unread,
  同一條線保留舊快取成員,離開碼 3(主線本身照樣標定)
- refresh --members:完整才蓋快取並印成員張數;不完整保留舊快取並講出來;讀不到里程碑也講
- adopt:不完整時與舊快取取聯集

重現(改之前跑新測試):F1–F5、F9–F11、F13–F14 共 11 條紅——set 印 、arcrun-rag#104 漏收、
refresh --members 零輸出且把快取蓋成少兩張的名單。
修後:mainline-members-by-dependency 70/71(唯一紅的 ㊽ 在 main 上同樣紅)、repo-mirror 17/17、
gitea-fallback 31/31、focus-guard 24/24、ticket-pick 58/58、debt-worklist 47/47。
真 Gitea 唯讀跑(不寫主線檔):InkStoneCo#65 成員 9 張、complete=True、hub #132 底下三張都收到。
沒動 InkStoneCo 的 system-dev/mainline.json。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:57:44 +08:00
Leo 0d49c0dcd7 Merge '雲端工人做完一張就接下一張'(inkstone/ISEP#33)+定版 v0.30.0
總管複驗:queue-drain-guard 26/26、ticket-pick 58/58、逐行審閘(ISEP#33 c6965)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:34:04 +08:00
Leo b2d9141a7d 定版 v0.30.0(雲端工人做完一張就接下一張)
inkstone/ISEP#33(→ c6963):queue-drain-guard(佇列沒抓盡不准收工)、pickable 認得自己帳號的 s/todo、api() GET 連線失敗重試。
數字在這棵樹上實數:62 支閘/87 條註冊/7 位工人/7 支命令/2 支 skill/62 支腳本。
tag 由總管併 main 後打(check-version-consistency 在打 tag 前必然紅,照 v0.29.0 慣例)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:21:23 +08:00
Leo 26aee6dffb queue-drain-guard:pick 自己 150s 收手,逾時當成讀不到(inkstone/ISEP#33)
09-13 本機實量 Gitea 同一分鐘 curl 60s 逾時、整趟 pick --all 約 15 分鐘;
hooks.json 給本閘 180s,被砍掉=非阻擋錯誤=放行收工。實跑真 Gitea:150s 逾時 → exit 2、log 寫入。
測試 26/26。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:41:43 +08:00
Leo ec9f28d9e8 雲端工人做完一張就接下一張:佇列沒抓盡不准收工;自己帳號的票不再當成別人的(inkstone/ISEP#33 → 6924)
leo 09-13 判定 ISEP 失敗:「不會在一條任務完成後啓動另一個 loop」。實查三個機械根因:

1. 沒有任何 Stop 閘看佇列。Routine 一次觸發=一個 session,模型一停就結束;
   12 支 Stop 閘量的是 tool call 數/宣告句/乾回合/自寫清單,
   「做完一件、pick 還有票、寫完回報就停」全部放行。
   ⇒ 新增 hooks/queue-drain-guard.sh:跑過 ticket pick|claim|handback(或 ISEP_DRAIN=1)
   的 session,pick 有票就 exit 2 交回票號;抓盡放行;同張 3 次/讀不到 3 次/40 次上限放行並留痕。
   log 寫 TMPDIR,不靠雲端沒有的 CLAUDE_PROJECT_DIR。
2. pickable() 把指派給 claude-code 的 s/todo 當成「別人的」,而雲端工人就是 claude-code。
   09-13 實查主線 Arcrun#176=s/todo+指派 claude-code;09-11 run(Arcrun#86 c6917)
   就是以此判定「主線沒票」收工。⇒ 只指派給自己的 s/todo 算可抓;s/doing/Human 照擋。
3. api() 一次 TLS 握手逾時就讓 pick 離開碼 2(09-13 本機實撞,curl 同時 200)。
   ⇒ 只有 GET 對連線層失敗重試(預設 3 次,退避 2s×n);寫入不重試。

測試:queue-drain-guard 24/24、ticket-pick 58/58(+5)、ticket-repo-arg 7/7、
handoff-writeback 49/49;members-by-dependency 56/57,唯一紅的 ㊽ 在 main 上同樣紅(macOS 暫存路徑形狀)。
數字在這棵樹上實數:62 支閘(ls hooks/*.sh)/87 條註冊(grep -c '"command":')。
假設:工人與總管共用 claude-code 帳號維持現狀(改帳號是跨專案結構,交總管)。
版本號留 0.29.0,由總管併 main 後定版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:37:49 +08:00
claude-code 4d27132413 Merge pull request '定版 v0.29.0(主線成員改由相依邊收集,不再靠同名里程碑)' (#152) from chore/v0.29.0 into main 2026-09-07 10:59:41 +00:00
17 changed files with 1148 additions and 107 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "isep", "name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(86 條註冊,白話盤點見 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、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、65 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.29.0", "version": "0.30.3",
"keywords": [ "keywords": [
"inkstone", "inkstone",
"guardrails", "guardrails",
+1 -1
View File
@@ -23,7 +23,7 @@
| | 數量 | 是什麼 | | | 數量 | 是什麼 |
|---|---|---| |---|---|---|
| `hooks/` | 61 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUseUserPromptSubmit 共 86 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | | `hooks/` | 62 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUseUserPromptSubmit 共 88 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** |
| `agents/` | 7 位 | **工人名單**`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | | `agents/` | 7 位 | **工人名單**`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` |
+35 -2
View File
@@ -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 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報 - 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
@@ -1210,6 +1219,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 能跑的(雲端)
+36 -3
View File
@@ -1,4 +1,4 @@
# 61 支閘,白話盤點表 # 62 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,10 +7,16 @@
## 一句話結論 ## 一句話結論
`hooks/` 底下有 **61 個 `.sh` 檔**`hooks.json` 實際掛上 **86 條註冊**(同一支閘常被多種情境同時掛上); `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 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的: > 現在的寫法是實際數出來的:
@@ -38,6 +44,30 @@
> **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**`ls hooks/*.sh | wc -l`61、 > **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**`ls hooks/*.sh | wc -l`61、
> `grep -c '"command":' hooks/hooks.json`85、`ls -p scripts | grep -v / | wc -l`60)。 > `grep -c '"command":' hooks/hooks.json`85、`ls -p scripts | grep -v / | wc -l`60)。
> 📌 **`0.30.0``inkstone/ISEP#33`2026-09-13)+1 支、+1 條**`queue-drain-guard.sh`F 組,Stop)——
> 唯一一支看「佇列」的收工閘:在接票的 session`ticket pick` 還有票就不准收工。
> **62 支、87 條、62 支腳本是在這棵樹上當場數出來的**`ls hooks/*.sh | wc -l`62、
> `grep -c '"command":' hooks/hooks.json`87、`ls -p scripts | grep -v / | wc -l`62)。
> 📌 **`0.30.2``inkstone/ISEP#150`2026-09-13)+0 支、+0 條、+3 支腳本**:新增 `scripts/isep-doctor`——
> 講出這台每一個 Claude Code(本機 CLIDesktop 內建)**實際有沒有載入** 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 setrefreshadopt` 讀不到任何一條 `/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 CodeCLI、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`
@@ -254,6 +284,8 @@
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 | | `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
| `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 pickclaimhandback`,或環境設了 `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
@@ -273,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
@@ -395,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 裡指的是「同一種派工工具呼叫」,
+153 -44
View File
@@ -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,40 +18,130 @@
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串 # tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡 # 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
# #
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ────────────── # ── 背景派工為什麼改到 Stop 查(inkstone/ISEP#15309-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 的 toolUseResult2026-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 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜
# 不掛 SubagentStopparallel-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` → 02026-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:
# 同一則通知會以 enqueueattachmentremove 出現多次,用 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 ─────────────────────
check_one(){ # $1 owner $2 repo $3 num
local OWNER="$1" REPO="$2" NUM="$3" J C REPORT BODY NOW_A NOW_L
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)── # ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。 # BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
# 正式執行時這個變數不存在,一行都不會走到。 # 正式執行時這個變數不存在,一行都不會走到。
@@ -57,19 +150,17 @@ if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
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) || exit 0 "https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || return 0
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \ 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
@@ -80,8 +171,12 @@ 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,9 +195,8 @@ 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 '^__') BODY=$(printf '%s' "$REPORT" | grep -v '^__')
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p') NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
@@ -115,22 +209,37 @@ cat >&2 <<EOF
$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/reviews/stages/pendings/todo> \\ --label <s/reviews/stages/pendings/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
+18
View File
@@ -368,6 +368,24 @@
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh" "command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
} }
] ]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/queue-drain-guard.sh",
"timeout": 180
}
]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh",
"timeout": 60
}
]
} }
], ],
"SubagentStop": [ "SubagentStop": [
+31 -5
View File
@@ -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 c6973Gitea 握手逾時,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
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
# queue-drain-guard.sh — 佇列警察:**在接票的 session,池子還有票就不准收工**inkstone/ISEP#33
#
# ── 補的是哪一格 ────────────────────────────────────────────────
# leo 2026-09-13(判定 ISEP 失敗):
# 「不會在一條任務完成後啓動另一個 loop」「任務完成自己去拿下一個任務⋯⋯它完全做不到,一直停工」
#
# 官方文件(code.claude.com/docs/en/routines):Routine 每次觸發=**一個** cloud session
# 跑完就結束;session 裡沒有 approval prompt。⇒ 「做完一張接下一張」只可能有兩種來源:
# ① 模型自己決定不停 ② Stop hook 以 exit 2 擋下收工(hooks 文件:「Prevents Claude from stopping」)
# ISEP 的 12 支 Stop 閘**沒有一支看佇列**——它們量的是這回合有沒有 tool call、有沒有宣告下一步、
# 連續幾個乾回合、清單檔還剩幾行(那個清單檔要模型自己寫)。所以「做完一張、pick 還有票、
# 模型寫完回報就停」這個形狀,12 支全部放行。09-1009-1109-12 三趟 cloud-worker run
# 都是這樣結束的(arcrun-rag#138 c6913、Arcrun#86 c6917、Arcrun#210 c6920)。
#
# ── 判準:狀態,不是措辭 ────────────────────────────────────────
# 啟動條件(任一):
# · 這個 session 真的**執行過** `ticket pick|claim|handback`Bash tool_use,文字裡提到不算)
# ⇒ 它在當工人。總管跟 leo 聊天的 session 不會被這支咬。
# · 環境變數 ISEP_DRAIN=1(雲端環境可設,讓第一回合就生效)
# 量什麼:跑一次 `ticket pick --json`(跟工人用的是同一支、同一套規則,不另立判準)
# 離開碼 0 = 有票 ⇒ 擋,把那張票號與認領指令交給它
# 離開碼 1 = 真的抓盡 ⇒ 放行(這是正常收工)
# 其他 = 讀不到 ⇒ 擋兩次叫它重跑 pick(讀不到 ≠ 沒票),第三次放行並留痕
#
# ── 為什麼不理 stop_hook_active ─────────────────────────────────
# 別的 Stop 閘擋過一次就讓路,正是「擋一下、寫一段、照樣停」的來源。本閘改用兩道**有限**的保險:
# · 同一張票連續被指名 3 次仍沒被認領 ⇒ 放行(它可能真的做不了,該把理由寫回票、改欄位)
# · 一個 session 最多擋 40 次
# ⇒ 不可能鎖死,而且鎖不住的那一格會留在 log 裡。
#
# ── log 寫在哪 ──────────────────────────────────────────────────
# 不用 $CLAUDE_PROJECT_DIR:雲端沒有它,21 支閘的 log 因此靜默寫進 /.claude/hooks/ISEP#130 c6873)。
# 狀態與 log 一律寫 ${ISEP_DRAIN_STATE_DIR:-${TMPDIR:-/tmp}},那裡在哪台都寫得進去。
#
# 迴歸測試:hooks/tests/queue-drain-guard.test.sh
set -u
PAYLOAD=$(cat 2>/dev/null || echo '{}')
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
STATE_DIR="${ISEP_DRAIN_STATE_DIR:-${TMPDIR:-/tmp}}"
STATE_DIR="${STATE_DIR%/}"
LOG="$STATE_DIR/queue-drain-guard.log"
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
note() { printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOG" 2>/dev/null || true; }
# ① 這個 session 在不在接票?(只看真的執行過的 Bash 指令)
INFO=$(printf '%s' "$PAYLOAD" | python3 -c '
import json, os, re, sys
try:
d = json.load(sys.stdin)
except Exception:
print("SKIP:bad-payload"); raise SystemExit
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64]
if os.environ.get("ISEP_DRAIN") == "1":
print("ON:" + sid); raise SystemExit
tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp):
print("SKIP:no-transcript:" + sid); raise SystemExit
pat = re.compile(r"(^|[\s/\x22\x27])ticket\x22?\s+(pick|claim|handback)\b")
try:
with open(tp) as f:
for line in f:
try:
r = json.loads(line)
except Exception:
continue
if r.get("type") != "assistant":
continue
for b in (r.get("message") or {}).get("content") or []:
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("name") == "Bash":
cmd = (b.get("input") or {}).get("command") or ""
if isinstance(cmd, str) and pat.search(cmd):
print("ON:" + sid); raise SystemExit
except SystemExit:
raise
except Exception:
print("SKIP:unreadable:" + sid); raise SystemExit
print("OFF:" + sid)
' 2>/dev/null) || INFO="SKIP:crash"
case "$INFO" in
ON:*) SID="${INFO#ON:}" ;;
OFF:*) exit 0 ;;
*) note "⚪ 略過" "$INFO"; exit 0 ;;
esac
# ② 問佇列(同一支 pick、同一套規則),**有時限**
# 2026-09-13 本機實量:Gitea 同一分鐘 curl 60s 逾時、urllib 17s0.3s42s 握手逾時,
# 整趟 `pick --all` 跑了約 15 分鐘。hooks.json 給本閘 180s;閘被 Claude Code 砍掉=非阻擋錯誤=放行收工,
# 正好又回到「讀不到就停」。⇒ 自己在 150s 收手,逾時當成「讀不到」(擋,叫它重跑 pick)。
if [ -n "${ISEP_DRAIN_PICK_CMD:-}" ]; then
PICK_ARGV="sh -c \"\$ISEP_DRAIN_PICK_CMD\""
else
PICK_ARGV="python3 \"$PLUGIN_ROOT/scripts/ticket\" pick --json"
fi
OUT=$(PICK_ARGV="$PICK_ARGV" python3 -c '
import os, subprocess, sys
try:
lim = float(os.environ.get("ISEP_DRAIN_PICK_TIMEOUT", "150"))
except ValueError:
lim = 150.0
try:
p = subprocess.run(["sh", "-c", os.environ["PICK_ARGV"]], capture_output=True, text=True, timeout=lim)
sys.stdout.write(p.stdout); sys.exit(p.returncode)
except subprocess.TimeoutExpired:
sys.exit(124)
' 2>/dev/null); RC=$?
STATE="$STATE_DIR/.queue-drain-$SID.json"
VERDICT=$(OUT="$OUT" RC="$RC" STATE="$STATE" python3 -c '
import json, os
rc = int(os.environ["RC"]); out = os.environ.get("OUT", ""); path = os.environ["STATE"]
try:
st = json.load(open(path))
except Exception:
st = {}
blocks = int(st.get("blocks", 0)); same = int(st.get("same", 0))
last = st.get("last_ref"); miss = int(st.get("unreachable", 0))
MAX_BLOCKS, MAX_SAME, MAX_MISS = 40, 3, 2
def save(v):
st.update(blocks=blocks, same=same, last_ref=last, unreachable=miss)
try:
json.dump(st, open(path, "w"))
except Exception:
pass
print(v); raise SystemExit
if rc == 1:
same, last, miss = 0, None, 0
save("ALLOW:queue-empty")
if rc != 0:
miss += 1
if miss > MAX_MISS:
save("ALLOW:unreachable-%d" % miss)
blocks += 1
save("BLOCK_UNREACHABLE:%d" % miss)
miss = 0
try:
row = json.loads(out)
ref, title = row.get("ref"), (row.get("title") or "")[:80]
except Exception:
save("ALLOW:unparsable")
if not ref:
save("ALLOW:no-ref")
same = same + 1 if ref == last else 1
last = ref
if same > MAX_SAME:
save("ALLOW:stuck-on:%s" % ref)
if blocks >= MAX_BLOCKS:
save("ALLOW:max-blocks")
blocks += 1
save("BLOCK:%s\t%s\t%d" % (ref, title, same))
' 2>/dev/null) || VERDICT="ALLOW:crash"
case "$VERDICT" in
BLOCK:*)
body="${VERDICT#BLOCK:}"
REF=$(printf '%s' "$body" | cut -f1); TITLE=$(printf '%s' "$body" | cut -f2); N=$(printf '%s' "$body" | cut -f3)
note "⛔ 擋下" "池子還有 $REF(第 $N 次指名)"
cat >&2 <<MSG
🔁 佇列警察:**這個 session 在接票,而池子裡還有一張現在就能抓的票——還不是收工的時候。**
下一張:$REF $TITLE
認領:python3 "$PLUGIN_ROOT/scripts/ticket" claim $REF --name <你的名字>
leo 2026-09-13:「任務完成自己去拿下一個任務,幫我消化任務」。
回報寫在票上就好,**不必在這裡寫收工摘要**——摘要等 pick 抓盡再寫一次。
這張其實做不了(要 leo 的手/要 Mac/前提不在)?
不要停在這裡:把「為什麼」寫回那張票,並改掉讓它可抓的欄位(s/* 或 Human),
pick 就不會再回它,接著抓下一張。四題人閘命中的票,也是這樣留在票上,**不是停 session**。
(同一張連續指名 3 次仍沒動,本閘會放行並記進 log——那張需要總管看。)
MSG
exit 2 ;;
BLOCK_UNREACHABLE:*)
note "⛔ 擋下" "pick 讀不到佇列(第 ${VERDICT#BLOCK_UNREACHABLE:} 次)"
cat >&2 <<MSG
🔁 佇列警察:**讀不到佇列 ≠ 佇列是空的。**
\`ticket pick\` 這次沒拿到 Gitea(連線或主線檔)。重跑一次:
python3 "$PLUGIN_ROOT/scripts/ticket" pick
抓得到就認領;真的抓盡(離開碼 1)就可以收工。連續讀不到 3 次,本閘放行並留痕。
MSG
exit 2 ;;
*)
note "✅ 放行" "$VERDICT"
exit 0 ;;
esac
@@ -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 c6973Gitea 握手逾時時,票本身讀得到、它的 /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"
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# queue-drain-guard.sh 的迴歸測試(inkstone/ISEP#33
#
# 全程離線:`ticket pick` 換成一支假的(ISEP_DRAIN_PICK_CMD),離開碼與輸出由測試指定。
# 兩個方向都測:該擋的(在接票、池子有票)與不該擋的(聊天 session、抓盡、鎖死保險)。
cd "$(dirname "$0")/../.." || exit 1
H=hooks/queue-drain-guard.sh
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
unset ISEP_DRAIN CLAUDE_PLUGIN_ROOT
cat > "$TMP/pick.sh" <<'SH'
#!/bin/sh
cat "$STUB_DIR/out" 2>/dev/null
exit "$(cat "$STUB_DIR/rc")"
SH
export STUB_DIR="$TMP" ISEP_DRAIN_PICK_CMD="sh $TMP/pick.sh"
stub() { printf '%s' "$1" > "$TMP/rc"; printf '%s' "${2:-}" > "$TMP/out"; }
TICKET='{"ref":"inkstone/Arcrun#176","title":"查一份筆記關係的人","group":"主線"}'
# transcript <檔名> <Bash 指令或空> [純文字]
transcript() {
python3 - "$TMP/$1" "$2" "${3:-}" <<'PY'
import json, sys
path, cmd, text = sys.argv[1], sys.argv[2], sys.argv[3]
rows = [{"type": "user", "message": {"content": "開工"}}]
content = []
if text: content.append({"type": "text", "text": text})
if cmd: content.append({"type": "tool_use", "name": "Bash", "input": {"command": cmd}})
rows.append({"type": "assistant", "message": {"content": content}})
open(path, "w").write("\n".join(json.dumps(r, ensure_ascii=False) for r in rows))
PY
}
run() { # run <說明> <want exit> <session> <transcript 檔> [stop_hook_active]
export ISEP_DRAIN_STATE_DIR="$TMP/state-$3"; mkdir -p "$ISEP_DRAIN_STATE_DIR"
payload=$(printf '{"session_id":"%s","transcript_path":"%s","stop_hook_active":%s}' "$3" "$TMP/$4" "${5:-false}")
printf '%s' "$payload" | bash "$H" > "$TMP/stdout" 2> "$TMP/stderr"; got=$?
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
}
check() { # check <說明> <shell 條件>
if eval "$2"; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf '%s\n' "$1"
}
transcript worker.jsonl 'python3 /x/scripts/ticket claim inkstone/Arcrun#175 --name cloud-worker'
transcript handback.jsonl 'python3 "$CLAUDE_PLUGIN_ROOT/scripts/ticket" handback inkstone/Arcrun#175 --to claude-code'
transcript chat.jsonl 'git status'
transcript textonly.jsonl '' '我等一下會跑 ticket pick 看看'
echo "── ① 該擋:在接票、池子還有票 ──"
stub 0 "$TICKET"
run "★ 跑過 ticket claim pick 有票 → 擋" 2 s1 worker.jsonl
check "訊息點名下一張票號" "grep -q 'inkstone/Arcrun#176' '$TMP/stderr'"
check "訊息給出認領指令(claim" "grep -q 'claim inkstone/Arcrun#176' '$TMP/stderr'"
check "訊息給出「做不了」的出路(寫回票+改欄位),不是叫它停" "grep -q '寫回那張票' '$TMP/stderr'"
run "引號包住路徑的 handback 也算在接票" 2 s2 handback.jsonl
run "★ stop_hook_active=true 仍擋(別的閘擋過一次就讓路,正是停工的來源)" 2 s3 worker.jsonl true
ISEP_DRAIN=1 run "ISEP_DRAIN=1:還沒跑過任何 ticket 指令也擋" 2 s4 chat.jsonl
check "log 寫在狀態目錄(不靠 CLAUDE_PROJECT_DIR" "grep -q '擋下' '$TMP/state-s4/queue-drain-guard.log'"
echo
echo "── ② 不該擋 ──"
stub 0 "$TICKET"
run "★ 沒跑過 ticket 指令的 session(跟 leo 聊天)→ 放行" 0 s5 chat.jsonl
run "文字裡提到 ticket pick、沒真的執行 → 放行(看動作不看措辭)" 0 s6 textonly.jsonl
stub 1 ""
run "★ pick 離開碼 1(真的抓盡)→ 放行" 0 s7 worker.jsonl
printf 'not json' | bash "$H" >/dev/null 2>&1; got=$?
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=0 got=%s 壞掉的 payload → 放行\n' "$got"
echo
echo "── ③ 鎖死保險 ──"
stub 0 "$TICKET"
run "同一張第 1 次 → 擋" 2 s8 worker.jsonl
run "同一張第 2 次 → 擋" 2 s8 worker.jsonl
run "同一張第 3 次 → 擋" 2 s8 worker.jsonl
run "★ 同一張第 4 次仍沒被認領 → 放行(它可能真的做不了)" 0 s8 worker.jsonl
check "放行理由記進 log(那張要總管看)" "grep -q 'stuck-on:inkstone/Arcrun#176' '$TMP/state-s8/queue-drain-guard.log'"
stub 0 '{"ref":"inkstone/Arcrun#197","title":"另一張"}'
run "換了一張 → 計數重來,擋" 2 s8 worker.jsonl
stub 2 ""
run "讀不到佇列第 1 次 → 擋(讀不到 ≠ 沒票)" 2 s9 worker.jsonl
check "訊息叫它重跑 pick" "grep -q 'pick' '$TMP/stderr' && grep -q '讀不到' '$TMP/stderr'"
run "讀不到第 2 次 → 擋" 2 s9 worker.jsonl
run "★ 讀不到第 3 次 → 放行並留痕" 0 s9 worker.jsonl
check "log 有 unreachable" "grep -q 'unreachable' '$TMP/state-s9/queue-drain-guard.log'"
printf '0' > "$TMP/rc"; printf '%s' "$TICKET" > "$TMP/out"
ISEP_DRAIN_PICK_CMD="sleep 5; sh $TMP/pick.sh" ISEP_DRAIN_PICK_TIMEOUT=1 run "★ pick 超過時限(Gitea 慢)→ 當成讀不到而擋,不是被砍掉後放行" 2 s10 worker.jsonl
check "逾時那次訊息是「讀不到 ≠ 沒票」" "grep -q '讀不到' '$TMP/stderr'"
echo
echo "── ④ 真的 pick(不帶假指令)接得上 ──"
unset ISEP_DRAIN_PICK_CMD
export ISEP_DRAIN_STATE_DIR="$TMP/state-real"; mkdir -p "$ISEP_DRAIN_STATE_DIR"
printf '{"session_id":"r1","transcript_path":"%s"}' "$TMP/worker.jsonl" \
| TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x ISEP_API_RETRIES=0 ISEP_MAINLINE_FILE="$TMP/none.json" \
CLAUDE_PLUGIN_ROOT="$PWD" bash "$H" >/dev/null 2>"$TMP/stderr"; got=$?
if [ "$got" = 2 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=2 got=%s 真的 scripts/ticket pick 讀不到主線 → 當成讀不到(擋),不是當成抓盡\n' "$got"
echo
echo "通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" = 0 ] || exit 1
+152
View File
@@ -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 CodeCLI(例:/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)
+51 -7
View File
@@ -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())
+103 -8
View File
@@ -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 acktoolUseResult 真跡的形狀:isAsyncasync_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)
# 真跡裡同一則通知出現三次:enqueueattachmentremove
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 "$?" "通知不是 completedkilled)⇒ 不當成交件"
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)) 通過"
+64
View File
@@ -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.meGitea),不碰 GitHub、不寫任何遠端。
#
# 期望(09-13 在 CLI 2.1.220Desktop 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=$1exit=$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 ]
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# 迴歸測試:scripts/isep-doctorinkstone/ISEP#150)——全離線
# 用假的 claude 可執行檔(shell 腳本)代替真的 CLIDesktop,走的是 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.266isep@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 ]
+5
View File
@@ -143,6 +143,11 @@ p "hubscope 容器,09-07 實查 ISEP#3135 的形狀)→ 不可抓" "Fa
p "PR 不是票 → 不可抓" "False" "m.pickable(I('ISEP',10,['s/todo'],'main',pr=True),'主線 M')[0]" p "PR 不是票 → 不可抓" "False" "m.pickable(I('ISEP',10,['s/todo'],'main',pr=True),'主線 M')[0]"
p "里程碑不是要求的那個 → 不可抓" "False" "m.pickable(I('Arcrun',8,['s/todo'],'future'),'主線 M')[0]" p "里程碑不是要求的那個 → 不可抓" "False" "m.pickable(I('Arcrun',8,['s/todo'],'future'),'主線 M')[0]"
p "里程碑標題只差空白/大小寫 → 仍算同一個(norm 只有 mainline.py 那一份)" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),' 主線 m ')[0]" p "里程碑標題只差空白/大小寫 → 仍算同一個(norm 只有 mainline.py 那一份)" "True" "m.pickable(I('ISEP',1,['s/todo'],'main'),' 主線 m ')[0]"
p "★ 只指派給自己的 s/todo(總管 handback 回池子的 Arcrun#176 形狀)→ 可抓(ISEP#33" "True" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]"
p "指派給自己+別人 → 不可抓" "False" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code','Leo')),'主線 M','claude-code')[0]"
p "指派給自己但 s/doing(真的有人在做)→ 不可抓" "False" "m.pickable(I('Arcrun',197,['s/doing'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]"
p "不知道自己是誰(me=None)→ 照舊不可抓,不猜" "False" "m.pickable(I('Arcrun',176,['s/todo'],'main',assignees=('claude-code',)),'主線 M')[0]"
p "指派給自己但掛 Human → 不可抓(Human 照擋)" "False" "m.pickable(I('ISEP',130,['s/todo','Human'],'main',assignees=('claude-code',)),'主線 M','claude-code')[0]"
p "理由講得出「為什麼不能抓」(不是只回 False)" "True" "'Human' in m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[1]" p "理由講得出「為什麼不能抓」(不是只回 False)" "True" "'Human' in m.pickable(I('ISEP',2,['s/todo','Human'],'main'),'主線 M')[1]"
p "順序:p/high 先於沒標,沒標先於 p/low" "True" \ p "順序:p/high 先於沒標,沒標先於 p/low" "True" \
"m.pick_order(I('a',1,['s/todo','p/high'],'main')) < m.pick_order(I('a',2,['s/todo'],'main')) < m.pick_order(I('a',3,['s/todo','p/low'],'main'))" "m.pick_order(I('a',1,['s/todo','p/high'],'main')) < m.pick_order(I('a',2,['s/todo'],'main')) < m.pick_order(I('a',3,['s/todo','p/low'],'main'))"
+36 -5
View File
@@ -137,10 +137,25 @@ _TOLERATED = object() # api() 吞掉時回的哨兵——不是 None,None
def api(path, payload=None, method=None): def api(path, payload=None, method=None):
"""打一次 Gitea。**只有讀(GET)遇到連線層失敗才重試**inkstone/ISEP#33)。
2026-09-13 實撞:同一分鐘 curl 打 Gitea 回 200,這支的 urllib 卻兩次
`_ssl.c:1063: The handshake operation timed out` ⇒ `pick` 離開碼 2
⇒ 訊息叫工人「先修連線,不要空轉」⇒ 在雲端那就是**這趟 run 收工**。
一次握手逾時不該等於一整天停工。寫入不重試:POST 留言重送會變成兩則。
"""
import time
url = path if path.startswith("http") else f"{HOST}/api/v1{path}" url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None data = json.dumps(payload).encode() if payload is not None else None
verb = method or ("POST" if data else "GET")
try:
retries = max(0, int(os.environ.get("ISEP_API_RETRIES", "3")))
except ValueError:
retries = 3
tries = 1 + (retries if verb == "GET" else 0)
for n in range(tries):
req = urllib.request.Request( req = urllib.request.Request(
url, data=data, method=method or ("POST" if data else "GET"), url, data=data, method=verb,
headers={"Authorization": f"token {token()}", "Content-Type": "application/json"}) headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
try: try:
return json.load(urllib.request.urlopen(req, timeout=40)) return json.load(urllib.request.urlopen(req, timeout=40))
@@ -148,6 +163,10 @@ def api(path, payload=None, method=None):
if e.code in _TOLERATE: if e.code in _TOLERATE:
return _TOLERATED return _TOLERATED
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}") die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}")
except (urllib.error.URLError, OSError):
if n + 1 >= tries:
raise
time.sleep(float(os.environ.get("ISEP_API_BACKOFF", "2")) * (n + 1))
def parse_ref(s): def parse_ref(s):
@@ -1307,10 +1326,16 @@ def human_gated(labels):
return any(n == "Human" or n.startswith("human/") for n in labels) return any(n == "Human" or n.startswith("human/") for n in labels)
def pickable(issue, milestone_title): def pickable(issue, milestone_title, me=None):
"""這張票現在能不能被雲端工人抓走。回 (ok, 理由)。**純函式,不打網路。** """這張票現在能不能被雲端工人抓走。回 (ok, 理由)。**純函式,不打網路。**
`milestone_title`=要求它掛在哪個里程碑底下(主線,或輪到的那個逾期里程碑)。 `milestone_title`=要求它掛在哪個里程碑底下(主線,或輪到的那個逾期里程碑)。
`me`=抓票者自己的登入名。**只指派給自己的 s/todo 票算可抓**inkstone/ISEP#33):
雲端工人與總管用的是同一個機器帳號 `claude-code`,總管把票 handback 回池子時
指派欄就留著 `claude-code`。2026-09-13 實查主線:`Arcrun#176` 是 `s/todo`、
指派 `claude-code` ⇒ 舊規則說「已經指派給 claude-code」⇒ **工人把自己當成別人**,
09-11 那趟 runArcrun#86 c6917)就是用這條理由判定「主線沒有可抓的票」然後收工。
`s/todo` 的意思本來就是「沒人在做」;有人在做的形狀是 `s/doing`,那格照擋。
理由是給人看的:`pick --all` 與 `claim` 擋下時都印它,一張票為什麼不能抓要說得出來。 理由是給人看的:`pick --all` 與 `claim` 擋下時都印它,一張票為什麼不能抓要說得出來。
""" """
if issue.get("pull_request"): if issue.get("pull_request"):
@@ -1322,6 +1347,8 @@ def pickable(issue, milestone_title):
if issue.get("assignees") or issue.get("assignee"): if issue.get("assignees") or issue.get("assignee"):
who = [a.get("login") for a in (issue.get("assignees") or []) if isinstance(a, dict)] \ who = [a.get("login") for a in (issue.get("assignees") or []) if isinstance(a, dict)] \
or [(issue.get("assignee") or {}).get("login")] or [(issue.get("assignee") or {}).get("login")]
others = [w for w in who if w and w != me]
if others or not me:
return False, "已經指派給 %s" % "、".join(str(w) for w in who if w) return False, "已經指派給 %s" % "、".join(str(w) for w in who if w)
if human_gated(labels): if human_gated(labels):
return False, "掛著 %s——要 leo 親手做的" % "、".join(n for n in labels if n == "Human" or n.startswith("human/")) return False, "掛著 %s——要 leo 親手做的" % "、".join(n for n in labels if n == "Human" or n.startswith("human/"))
@@ -1454,13 +1481,17 @@ def pick_candidates(ms, now=None):
owner = ms.get("owner") or ORG owner = ms.get("owner") or ORG
anchor = "%s/%s#%s" % (ms.get("owner"), ms.get("repo"), ms.get("id")) anchor = "%s/%s#%s" % (ms.get("owner"), ms.get("repo"), ms.get("id"))
# 抓票者自己是誰:只指派給自己的 s/todo 票算可抓(inkstone/ISEP#33,見 pickable
# 問不到(舊 token、測試替身沒有 /user)⇒ me=None ⇒ 照舊規則,不因為這一格讓 pick 整個死掉
me = os.environ.get("ISEP_WORKER_LOGIN") or ((api_soft("/user") or {}).get("login"))
refs, info = mainline_members(ms) refs, info = mainline_members(ms)
found = [] found = []
for ref in refs: for ref in refs:
it = _fetch_issue(ref) it = _fetch_issue(ref)
if not it or (it.get("state") or "open") != "open": if not it or (it.get("state") or "open") != "open":
continue continue
ok, _ = pickable(it, None) ok, _ = pickable(it, None, me)
if ok: if ok:
found.append(it) found.append(it)
groups = [("主線", ms["title"], sorted(found, key=pick_order))] groups = [("主線", ms["title"], sorted(found, key=pick_order))]
@@ -1478,7 +1509,7 @@ def pick_candidates(ms, now=None):
for it in _issues_in(owner, r, m.get("title")): for it in _issues_in(owner, r, m.get("title")):
if str((it.get("milestone") or {}).get("id")) != str(m.get("id")): if str((it.get("milestone") or {}).get("id")) != str(m.get("id")):
continue continue
ok, _ = pickable(it, m.get("title")) ok, _ = pickable(it, m.get("title"), me)
if ok: if ok:
found.append(it) found.append(it)
groups.append(("逾期里程碑", "%s%s/%s#%s" % (m.get("title"), owner, r, m.get("id")), groups.append(("逾期里程碑", "%s%s/%s#%s" % (m.get("title"), owner, r, m.get("id")),
@@ -1559,7 +1590,7 @@ def cmd_claim(argv):
ref = "%s/%s#%d" % (owner, repo, num) ref = "%s/%s#%d" % (owner, repo, num)
# 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題 # 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題
on_mainline = ref in set(mainline_members(ms)[0]) on_mainline = ref in set(mainline_members(ms)[0])
ok, why = pickable(issue, None if on_mainline else (ims.get("title") or "")) ok, why = pickable(issue, None if on_mainline else (ims.get("title") or ""), who)
if ok and not on_mainline: if ok and not on_mainline:
due = ML.due_of(ims) due = ML.due_of(ims)
if not (due and due < _now()): if not (due and due < _now()):