Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25da674615 | |||
| 01dafb01b5 | |||
| 436e30b9d1 | |||
| 916a6890ff | |||
| ba1f2a50f8 | |||
| fbc56b9e59 | |||
| 8eb4c58d2d | |||
| c1df3c7a08 | |||
| 544beefc5e | |||
| c0eec76b52 | |||
| 0d49c0dcd7 | |||
| b2d9141a7d | |||
| 26aee6dffb | |||
| ec9f28d9e8 | |||
| 4d27132413 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.29.0",
|
||||
"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.30.3",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
| | 數量 | 是什麼 |
|
||||
|---|---|---|
|
||||
| `hooks/` | 61 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 86 條註冊)。**一支一行的白話盤點在 `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` |
|
||||
| `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … |
|
||||
| `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` |
|
||||
|
||||
+35
-2
@@ -98,12 +98,21 @@ bash scripts/test-comment-carries-task-guard.sh
|
||||
> 記過:訊息壞掉時閘照樣 `exit 2`,只看離開碼完全看不出來——
|
||||
> 而這張票(`inkstone/ISEP#112`)就是那個形狀的極端版:**閘擋對了,但它教的解法根本不存在。**
|
||||
|
||||
### A12 — 收工要把棒子交回來:10 條
|
||||
### A12 — 收工要把棒子交回來:24 條
|
||||
```
|
||||
bash scripts/test-baton-handback-guard.sh
|
||||
```
|
||||
**該看到**:`10/10 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**。
|
||||
**該看到**:`24/24 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**
|
||||
(Stop 那半用 `mktemp` 造的假 transcript;出路那兩條把 `TICKET_HOST` 指到連不上的位址)。
|
||||
**失敗**:
|
||||
- **`inkstone/ISEP#153` 驗收 1**(背景派工送出那一刻)兩條紅 ⇒ 工人剛出發就念「收工了」,
|
||||
會教總管在工人動手前去改票(假交棒)——**誤攔,比漏擋嚴重**
|
||||
- **驗收 2**「完工通知 completed ⇒ 報」紅 ⇒ 背景工人真的交件、票上沒交棒,卻靜靜通過;
|
||||
「只點名那一則通知對到的票」紅 ⇒ 判準從 `tool-use-id` 比對滑成「transcript 裡有通知就全報」
|
||||
- 「同一則通知第二次 Stop」紅 ⇒ 鬼打牆,每個回合都被擋
|
||||
- 「出路印的是絕對路徑」紅 ⇒ 又印回相對的 `scripts/ticket`,總管在 InkStoneCo 貼上去會叫到舊複本(沒有 `handback`)
|
||||
- ★「原樣貼上只填三格」紅 ⇒ 訊息教的那塊指令跑不完;它的對照組(拿掉 `--next`)紅就表示 ★ 沒有鑑別力
|
||||
- 📌 這 14 條拿 `main` 上舊版的 hook 跑是 **7 條紅**(2026-09-13 實跑),不是換了寫法就會綠的測試
|
||||
- 「該報」5 條任一紅 ⇒ 指派/tag/下一步缺哪一格抓不到,08-26 那次掉棒的狀態會靜靜通過
|
||||
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
|
||||
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
|
||||
@@ -1210,6 +1219,30 @@ claude -p '請執行 git tag -a v9.9.9 -m test'
|
||||
> 為什麼挑 `release-tag-guard` 當考題:它**只存在於 ISEP**,舊的 `.claude/` 那份沒有。
|
||||
> 用它才分得出「載到的是 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 能跑的(雲端)
|
||||
|
||||
+36
-3
@@ -1,4 +1,4 @@
|
||||
# 61 支閘,白話盤點表
|
||||
# 62 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **61 個 `.sh` 檔**,`hooks.json` 實際掛上 **86 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **88 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 📌 **`inkstone/ISEP#153`(2026-09-13,待總管定版)+0 支、+1 條**:`baton-handback-guard.sh` 多掛一條 `Stop`。
|
||||
> 背景派工時 `PostToolUse:Agent` 在工人**剛出發**就觸發,舊版當場念「收工了但棒子沒交回來」(09-13 對 ISEP#150、arcrun-rag#14 各一次,工人其實還要跑 17 分鐘)。
|
||||
> 現在送出那一刻看到 launch ack 就不查;工人交件(主對話收到 `<task-notification>` completed)時由 Stop 那半用 `tool-use-id` 對回原派工單再查,同一條線只點名一次。
|
||||
> **62 支、88 條、65 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=62、
|
||||
> `grep -c '"command":' hooks/hooks.json`=88、`ls -p scripts | grep -v / | wc -l`=65)。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
@@ -38,6 +44,30 @@
|
||||
> **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=61、
|
||||
> `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(本機 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`
|
||||
> ——教你開分身的那支閘,在線收工那一刻替你收(分支推了就 remove;沒推就點名分支與路徑,不靜默、不 force)。
|
||||
> 分身的家從「repo 旁邊」改成「repo 裡面的 `.worktrees/`」:leo 在 Finder 看到一排 `ISEP-wt-117`/`InkStoneCo-wt-112`
|
||||
@@ -254,6 +284,8 @@
|
||||
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
|
||||
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
||||
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
||||
| `queue-drain-guard.sh` | **在接票的 session(跑過 `ticket pick/claim/handback`,或環境設了 `ISEP_DRAIN=1`),池子裡還有現在就能抓的票,就不准收工**——它自己跑一次 `ticket pick`,有票就把票號和認領指令交回去。這是 ISEP 唯一一支看「佇列」而不是看「這回合寫了什麼」的收工閘(`inkstone/ISEP#33`:雲端三趟 run 都是做完一件、還有票就停)。做不了的票要把理由寫回票、改欄位,pick 就不再回它。跟 leo 聊天的 session 不會被咬;同一張連續指名 3 次、讀不到佇列 3 次、或一個 session 擋滿 40 次就放行並留痕,不會鎖死。 | 🛑 擋(有上限) |
|
||||
| `baton-handback-guard.sh` | **背景派出去的工人交件時**(主對話收到完工通知),檢查那張工單票有沒有把棒子交回來(指派/標籤/下一步三格),缺就擋一次要 AI 補。工人還在跑的時候一聲都不出(`inkstone/ISEP#153`)。同一條線只點名一次;補完三格就不再出現。前景派工的那一半在下面 H 組。 | 🛑 擋(每條線至多一次) |
|
||||
|
||||
> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。
|
||||
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58:
|
||||
@@ -273,7 +305,7 @@
|
||||
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
|
||||
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
|
||||
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。**只管前景派工**:背景派工送出的那一刻工人才剛出發,這裡不出聲,等它交件時由 F 組的 Stop 那半查(`inkstone/ISEP#153`)。 | 📝 記錄(提醒,不擋) |
|
||||
| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
|
||||
| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 |
|
||||
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
||||
@@ -395,6 +427,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
||||
| `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 |
|
||||
| `worklist-guard.sh` / `self-drive-police.sh` / `self-drive-judge.sh` / `delivery-police.sh` / `wiki-first-police.sh` / `unpushed-police.sh` | 各 2 次 | 一次守「總管自己想收工」(Stop),一次守「subagent 交回工作」(SubagentStop)——同一套判準用在兩種角色身上 |
|
||||
| `search-is-not-proof-guard.sh` | 3 次 | 拿搜尋結果當證據的那個動作,可能是「貼進票裡」(Bash),也可能是「寫進派工單發包出去」(`Task`/`Agent` 兩個名稱各掛一次),三種都要接住 |
|
||||
| `baton-handback-guard.sh` | 2 次 | 同一件事的兩種收工時機:前景派工 tool 回來就是交件(PostToolUse),背景派工要等完工通知進主對話(Stop) |
|
||||
|
||||
❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh`
|
||||
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
|
||||
|
||||
+153
-44
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
||||
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);背景工人交件後同一條線只點名一次,補完三格就不再出現。
|
||||
#
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task)
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去
|
||||
# 同一件事的兩種收工時機,掛兩個事件:
|
||||
# PostToolUse(Agent|Task) 前景派工:tool 回來=工人交件了 ⇒ 當場查
|
||||
# Stop 背景派工:主對話收到 <task-notification> completed ⇒ 回合結束時查
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||
@@ -15,40 +18,130 @@
|
||||
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
||||
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
||||
#
|
||||
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
|
||||
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
|
||||
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
|
||||
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
|
||||
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
|
||||
# ── 背景派工為什麼改到 Stop 查(inkstone/ISEP#153,09-13 兩次實撞)──────────
|
||||
# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線),
|
||||
# 那時 tool_response 是 launch ack:{"isAsync": true, "status": "async_launched", ...}
|
||||
# (同一份物件寫在 transcript 的 toolUseResult,2026-08-24 實跡)。
|
||||
# 舊版在這一刻就查票 ⇒ 對 ISEP#150、arcrun-rag#14 各念一次「收工了但棒子沒交回來」,
|
||||
# 而兩個工人之後各跑了 17 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。
|
||||
#
|
||||
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
|
||||
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
|
||||
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
|
||||
# 真正的交件是主 transcript 裡的 <task-notification>(<status>completed</status>),
|
||||
# 它帶著 <tool-use-id> ——**拿這個唯一識別碼回頭對上當初那一則 Agent tool_use**,
|
||||
# 從它的 prompt 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜。
|
||||
# 不掛 SubagentStop:parallel-lines-cap-guard.sh 檔頭實測過它在主 session 一次都沒觸發。
|
||||
#
|
||||
# ── 只點名一次 ────────────────────────────────────────────────────
|
||||
# Stop 回 exit 2 會讓總管多走一個回合(去補三格)。查過的 tool-use-id 記在狀態檔,之後不再查
|
||||
# ⇒ 補完了自然安靜,沒補也不會鬼打牆。狀態檔在 ${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}。
|
||||
# 前景 PostToolUse 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不擋任何動作。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
# 訊息裡的出路一律印絕對路徑:總管的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是舊複本,
|
||||
# `grep -c handback` → 0(2026-09-13 實查)⇒ 印相對路徑=教一條走不通的路(同 comment-carries-task-guard ⑮)。
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
TICKET="$PLUGIN_ROOT/scripts/ticket"
|
||||
STATE_DIR="${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}"
|
||||
export ISEP_BATON_STATE_DIR="${STATE_DIR%/}"
|
||||
|
||||
# ── 找出這一次要查的票(每行一張:owner repo num)──────────────────────
|
||||
SPECS=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re, os
|
||||
|
||||
TICKET = re.compile(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)")
|
||||
|
||||
def spec_of(prompt):
|
||||
for line in (prompt or "").splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = TICKET.search(line)
|
||||
if m:
|
||||
return "%s %s %s" % m.groups()
|
||||
return None
|
||||
|
||||
def is_async_ack(resp):
|
||||
# launch ack 兩種長相都認:結構化物件(isAsync/status),或只剩文字的 content 陣列
|
||||
if isinstance(resp, dict) and (resp.get("isAsync") is True or resp.get("status") == "async_launched"):
|
||||
return True
|
||||
try:
|
||||
return "Async agent launched" in json.dumps(resp, ensure_ascii=False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
SPEC=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
|
||||
if d.get("hook_event_name") != "Stop":
|
||||
if is_async_ack(d.get("tool_response")):
|
||||
raise SystemExit # 工人才剛出發,還沒有「收工」可言;交件時由 Stop 那半查
|
||||
s = spec_of((d.get("tool_input") or {}).get("prompt"))
|
||||
if s:
|
||||
print(s)
|
||||
raise SystemExit
|
||||
|
||||
# ── Stop:有沒有背景工人交件 ─────────────────────────────────────────
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.isfile(tp):
|
||||
raise SystemExit
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64]
|
||||
state = os.path.join(os.environ["ISEP_BATON_STATE_DIR"], "baton-handback-seen-%s" % sid)
|
||||
try:
|
||||
seen = set(open(state, encoding="utf-8").read().split())
|
||||
except OSError:
|
||||
seen = set()
|
||||
|
||||
NOTE = re.compile(r"<tool-use-id>(toolu_[A-Za-z0-9_-]+)</tool-use-id>.*?<status>([a-z_]+)</status>", re.S)
|
||||
completed = [] # 保留出現順序
|
||||
dispatch = {} # tool_use id → 帶【工單】的派工單
|
||||
try:
|
||||
with open(tp, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
if "<task-notification>" in line:
|
||||
# 同一則通知會以 enqueue/attachment/remove 出現多次,用 id 去重
|
||||
for tuid, st in NOTE.findall(line.replace("\\n", "\n")):
|
||||
if st == "completed" and tuid not in completed:
|
||||
completed.append(tuid)
|
||||
if "【工單】" in line and "tool_use" in line:
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
||||
if m:
|
||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
||||
break
|
||||
msg = e.get("message")
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
for b in msg.get("content") or []:
|
||||
if (isinstance(b, dict) and b.get("type") == "tool_use"
|
||||
and b.get("name") in ("Agent", "Task")):
|
||||
p = (b.get("input") or {}).get("prompt")
|
||||
if isinstance(p, str) and "【工單】" in p:
|
||||
dispatch[b.get("id")] = p
|
||||
except OSError:
|
||||
raise SystemExit
|
||||
|
||||
todo = [t for t in completed if t in dispatch and t not in seen]
|
||||
if not todo:
|
||||
raise SystemExit
|
||||
try:
|
||||
os.makedirs(os.path.dirname(state), exist_ok=True)
|
||||
with open(state, "a", encoding="utf-8") as f:
|
||||
f.write("\n".join(todo) + "\n")
|
||||
except OSError:
|
||||
pass # 記不下來頂多再念一次,不擋
|
||||
out = []
|
||||
for t in todo:
|
||||
s = spec_of(dispatch[t])
|
||||
if s and s not in out:
|
||||
out.append(s)
|
||||
print("\n".join(out))
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "${SPEC:-}" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
[ -n "${SPECS:-}" ] || exit 0
|
||||
|
||||
# ── 查一張票:缺哪一格就寫到 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,不打網路。
|
||||
# 正式執行時這個變數不存在,一行都不會走到。
|
||||
@@ -57,19 +150,17 @@ if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||
else
|
||||
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
URL=""
|
||||
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
local ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" URL="" U dir TOKEN
|
||||
for dir in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||
[ -e "$dir/.git" ] || continue
|
||||
U=$(git -C "$dir" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
[ -n "${U:-}" ] && { URL="$U"; break; }
|
||||
done
|
||||
[ -n "$URL" ] || exit 0
|
||||
[ -n "$URL" ] || return 0
|
||||
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" \
|
||||
"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" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||
fi
|
||||
@@ -80,8 +171,12 @@ try:
|
||||
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
if not isinstance(it, dict) or "state" not in it:
|
||||
raise SystemExit # 讀不到票(404/錯誤回應)⇒ 不瞎報
|
||||
if it.get("state") == "closed":
|
||||
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
||||
if not isinstance(cs, list):
|
||||
cs = []
|
||||
miss = []
|
||||
if not (it.get("assignees") or []):
|
||||
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
|
||||
@@ -100,9 +195,8 @@ print("\n".join(" ❌ " + m for m in miss))
|
||||
print("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or [])))
|
||||
print("__LABELS__" + ",".join(labs))
|
||||
PY
|
||||
) || exit 0
|
||||
|
||||
[ -n "${REPORT:-}" ] || exit 0
|
||||
) || return 0
|
||||
[ -n "${REPORT:-}" ] || return 0
|
||||
|
||||
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
|
||||
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
|
||||
@@ -115,22 +209,37 @@ cat >&2 <<EOF
|
||||
|
||||
$BODY
|
||||
|
||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
||||
直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
||||
|
||||
【一個動作把三格補齊】
|
||||
scripts/ticket handback $OWNER/$REPO#$NUM \\
|
||||
"$TICKET" handback $OWNER/$REPO#$NUM \\
|
||||
--to claude-code \\
|
||||
--next "<收下的人第一件事要做什麼,一句話>" \\
|
||||
--label <s/review|s/stage|s/pending|s/todo> \\
|
||||
--evidence "<實測輸出或 PR 連結>"
|
||||
|
||||
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
|
||||
已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
已經真的做完且驗過了 → "$TICKET" close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
|
||||
【撈一次看棒子在誰手上】 scripts/ticket mine
|
||||
EOF
|
||||
return 2
|
||||
}
|
||||
|
||||
HIT=0
|
||||
while read -r OWNER REPO NUM; do
|
||||
[ -n "${NUM:-}" ] || continue
|
||||
check_one "$OWNER" "$REPO" "$NUM" </dev/null
|
||||
[ $? -eq 2 ] && HIT=1
|
||||
done <<< "$SPECS"
|
||||
|
||||
[ "$HIT" = 1 ] || exit 0
|
||||
|
||||
cat >&2 <<EOF
|
||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
||||
直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
||||
|
||||
【撈一次看棒子在誰手上】 "$TICKET" mine
|
||||
(背景工人交件後,同一條線只點名這一次;補完三格就不會再出現。)
|
||||
EOF
|
||||
exit 2
|
||||
|
||||
@@ -368,6 +368,24 @@
|
||||
"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": [
|
||||
|
||||
+31
-5
@@ -364,18 +364,42 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
回 (members, info):
|
||||
members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們)
|
||||
info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None,
|
||||
"reached": bool, "capped": bool}
|
||||
"reached": bool, "capped": bool, "unread": [...], "complete": bool}
|
||||
🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取**
|
||||
(讀不到 ≠ 沒有成員)。
|
||||
🔴 `unread` = 讀不到 `/dependencies` 的票(重讀過仍失敗)。**只要有一張,名單就不完整**:
|
||||
那張底下的前置全部沒收到(inkstone/ISEP#130 c6973:Gitea 握手逾時,hub 票底下三張全漏,
|
||||
`set` 卻印 ✅)。`complete`=reached 且沒有 unread 且沒被上限截斷——呼叫端只准在它為真時
|
||||
把名單當成完整的寫出去。
|
||||
判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。
|
||||
"""
|
||||
import time
|
||||
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")
|
||||
title = ms.get("title") or ""
|
||||
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})
|
||||
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):
|
||||
return [], info
|
||||
info["reached"] = True
|
||||
@@ -400,9 +424,10 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
parts = split_ref(cur)
|
||||
if not parts:
|
||||
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):
|
||||
continue # 這一張讀不到:跳過它,不當成「沒有相依」以外的事
|
||||
info["unread"].append(cur) # 讀不到 ≠ 沒有相依:記下來,名單就不算完整
|
||||
continue
|
||||
for d in deps:
|
||||
r = issue_ref(d)
|
||||
if not r or r in seen:
|
||||
@@ -412,6 +437,7 @@ def collect_members(api, ms, cap=MEMBER_WALK_CAP):
|
||||
break
|
||||
seen.add(r); out.append(r); queue.append(r)
|
||||
info["via_deps"].append(r)
|
||||
info["complete"] = not info["unread"] and not info["capped"]
|
||||
return sorted(set(out)), info
|
||||
|
||||
|
||||
|
||||
Executable
+188
@@ -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-10/09-11/09-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 17s/0.3s/42s 握手逾時,
|
||||
# 整趟 `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]
|
||||
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 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(404, {"message": "not found", "path": p})
|
||||
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)"
|
||||
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 條)──"
|
||||
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"
|
||||
|
||||
Executable
+109
@@ -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
|
||||
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)
|
||||
+51
-7
@@ -252,21 +252,50 @@ def cmd_set(argv):
|
||||
"members": [],
|
||||
}
|
||||
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_complete"] = complete
|
||||
ms["members_unread"] = incomplete_what(info)
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
ms["set_at"] = ms["refreshed_at"] = now
|
||||
if not ML.save(ms, mirror=True):
|
||||
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:
|
||||
print(" hub 票:%s" % hub)
|
||||
print(members_line(refs, info) if info.get("reached")
|
||||
else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)")
|
||||
if info.get("capped"):
|
||||
print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP)
|
||||
if not complete:
|
||||
print(incomplete_line(info))
|
||||
print(mirror_line())
|
||||
print()
|
||||
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():
|
||||
@@ -328,6 +357,9 @@ def cmd_refresh(argv):
|
||||
except SystemExit:
|
||||
return # 拿不到 token:主線已經在了,進度晚點再更新,不炸
|
||||
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 # 讀不到 ≠ 主線不存在
|
||||
ms.update({
|
||||
"title": d.get("title") or ms["title"],
|
||||
@@ -342,12 +374,19 @@ def cmd_refresh(argv):
|
||||
# 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變);
|
||||
# `set`/`adopt` 會重收,`pick`/`has`/focus guard 本來就每次現問 Gitea,快取只餵 `show`。
|
||||
# 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。
|
||||
# 🔴 inkstone/ISEP#130 c6973:只有**完整**的名單才准蓋掉快取;不完整就留舊的並講出來。
|
||||
try:
|
||||
refs, info = collect_members(ms)
|
||||
except SystemExit:
|
||||
refs, info = [], {"reached": False}
|
||||
if info.get("reached"):
|
||||
if info.get("reached") and info.get("complete"):
|
||||
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)
|
||||
|
||||
|
||||
@@ -391,13 +430,18 @@ hub 里程碑裡現在有:%s
|
||||
T.add_dependency(vparts, (owner, repo, num))
|
||||
how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo)
|
||||
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})
|
||||
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)
|
||||
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())
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/bin/bash
|
||||
# 交棒警察的測試——三格(指派/tag/下一步)各自缺漏時要被抓到
|
||||
# 判準:5 種該報、5 種不該報
|
||||
# 判準:前景 5 種該報、5 種不該報;背景派工(inkstone/ISEP#153)送出時不報、交件時才報、只報一次;
|
||||
# 訊息印的出路照著貼真的跑得完。
|
||||
# 用 fixture 跑,不打網路、不在票池留下任何一張測試票。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
ROOT=$(pwd)
|
||||
H=hooks/baton-handback-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
FX=$(mktemp -d); trap 'rm -rf "$FX"' EXIT
|
||||
FX=$(mktemp -d); ST=$(mktemp -d); trap 'rm -rf "$FX" "$ST"' EXIT
|
||||
export CLAUDE_PLUGIN_ROOT="$ROOT" ISEP_BATON_STATE_DIR="$ST"
|
||||
|
||||
mkfx(){ # $1=state $2=assignees(csv) $3=labels(csv) $4=有沒有交棒留言(y/n)
|
||||
python3 - "$FX" "$1" "$2" "$3" "$4" <<'PY'
|
||||
@@ -22,12 +25,15 @@ json.dump(comments, open(os.path.join(d, "comments.json"), "w"))
|
||||
PY
|
||||
}
|
||||
|
||||
judge(){ # $1=want $2=got $3=說明
|
||||
if [ "$2" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$1" "$2" "$3"
|
||||
}
|
||||
|
||||
run(){ # $1=want $2=說明 (fixture 已備好)
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\n做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$1" "$got" "$2"
|
||||
judge "$1" "$?" "$2"
|
||||
}
|
||||
|
||||
echo "── 該報:三格有缺 ──"
|
||||
@@ -46,9 +52,98 @@ mkfx closed "" "" n ; run 0 "票已關=棒子到
|
||||
echo "── 派工單裡沒有【工單】那行 ⇒ 不歸這支管 ──"
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"隨便做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=0 got=%s 沒有票號的派工單\n' "$got"
|
||||
judge 0 "$?" "沒有票號的派工單"
|
||||
|
||||
# ═══ inkstone/ISEP#153:背景派工 ═══════════════════════════════════════
|
||||
post(){ # $1=tool_response JSON → 離開碼
|
||||
printf '{"hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\\n做點事","run_in_background":true},"tool_response":%s}' "$1" \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "── 驗收 1:背景派工送出的那一刻不報(票上三格全缺也一樣)──"
|
||||
mkfx open "" "s/doing" n
|
||||
post '{"isAsync":true,"status":"async_launched","agentId":"a93f7dc98c50fc671","description":"x"}'
|
||||
judge 0 "$?" "launch ack(toolUseResult 真跡的形狀:isAsync/async_launched)"
|
||||
post '[{"type":"text","text":"Async agent launched successfully. (This tool result is internal metadata)\nagentId: a1"}]'
|
||||
judge 0 "$?" "launch ack 只剩文字 content 陣列時也認得"
|
||||
post '{"status":"completed","content":[{"type":"text","text":"工作做完了"}]}'
|
||||
judge 2 "$?" "驗收 3 對照:前景派工回來(不是 launch ack)照舊要報"
|
||||
|
||||
# 假 transcript:派工 tool_use + launch ack +(可選)完工通知
|
||||
TP="$ST/main.jsonl"
|
||||
mktx(){ # $1=通知對到哪個 tool-use-id(空=還沒交件) $2=通知 status $3=派工單第一行
|
||||
python3 - "$TP" "$1" "$2" "$3" <<'PY'
|
||||
import json, sys
|
||||
tp, note_id, status, first = sys.argv[1:5]
|
||||
L = []
|
||||
def add(o): L.append(json.dumps(o, ensure_ascii=False))
|
||||
for tid, line in (("toolu_AAA", first), ("toolu_BBB", "【工單】inkstone/ISEP#999")):
|
||||
add({"type": "assistant", "message": {"role": "assistant", "content": [
|
||||
{"type": "tool_use", "id": tid, "name": "Agent",
|
||||
"input": {"prompt": line + "\n做點事", "run_in_background": True}}]}})
|
||||
add({"type": "user", "message": {"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": tid,
|
||||
"content": [{"type": "text", "text": "Async agent launched successfully."}]}]},
|
||||
"toolUseResult": {"isAsync": True, "status": "async_launched"}})
|
||||
if note_id:
|
||||
n = ("<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>%s</tool-use-id>\n"
|
||||
"<status>%s</status>\n<summary>Agent finished</summary>\n<result>好了</result>\n"
|
||||
"</task-notification>") % (note_id, status)
|
||||
# 真跡裡同一則通知出現三次:enqueue/attachment/remove
|
||||
add({"type": "queue-operation", "operation": "enqueue", "content": n})
|
||||
add({"type": "attachment", "attachment": {"type": "queued_command", "prompt": n}})
|
||||
add({"type": "queue-operation", "operation": "remove", "content": n})
|
||||
open(tp, "w").write("\n".join(L) + "\n")
|
||||
PY
|
||||
}
|
||||
stop(){ # $1=session id → 離開碼;stderr 存到 $ST/err
|
||||
printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s","stop_hook_active":false}' "$1" "$TP" \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>"$ST/err"
|
||||
}
|
||||
|
||||
echo "── 驗收 2:背景工人真的交件時才報 ──"
|
||||
mkfx open "" "s/doing" n
|
||||
mktx "" "" "【工單】inkstone/arcrun-rag#136"
|
||||
stop s1; judge 0 "$?" "工人還在跑(沒有完工通知)⇒ 不報"
|
||||
mktx toolu_AAA completed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s1; got=$?; judge 2 "$got" "完工通知 completed + 票上三格全缺 ⇒ 報"
|
||||
if grep -q 'inkstone/arcrun-rag#136' "$ST/err" && ! grep -q 'ISEP#999' "$ST/err"; then got=ok; else got=bad; fi
|
||||
judge ok "$got" "只點名那一則通知對到的票(tool-use-id 比對;另一條還在跑的 #999 不點)"
|
||||
cp "$ST/err" "$ST/msg"
|
||||
stop s1; judge 0 "$?" "同一則通知第二次 Stop ⇒ 不鬼打牆(只點名一次)"
|
||||
mktx toolu_AAA killed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s2; judge 0 "$?" "通知不是 completed(killed)⇒ 不當成交件"
|
||||
mkfx open "claude-code" "s/review" y
|
||||
mktx toolu_AAA completed "【工單】inkstone/arcrun-rag#136"
|
||||
stop s3; judge 0 "$?" "交件時三格已經補齊 ⇒ 安靜(出路:補完就不再出現)"
|
||||
mkfx open "" "s/doing" n
|
||||
mktx toolu_AAA completed "隨便做點事,沒有工單"
|
||||
stop s4; judge 0 "$?" "交件的那條派工單沒有【工單】⇒ 不歸這支管"
|
||||
printf '{"hook_event_name":"Stop","session_id":"s5","transcript_path":"/nonexistent"}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
judge 0 "$?" "讀不到 transcript ⇒ 放行"
|
||||
|
||||
echo "── 出路:訊息印的那塊指令照著貼真的跑得完 ──"
|
||||
BLK=$(python3 - "$ST/msg" <<'PY'
|
||||
import sys, re
|
||||
t = open(sys.argv[1], encoding="utf-8").read()
|
||||
m = re.search(r'^\s*("[^"\n]*/scripts/ticket" handback .*?--evidence "[^"\n]*")', t, re.S | re.M)
|
||||
if not m:
|
||||
raise SystemExit
|
||||
b = m.group(1)
|
||||
b = b.replace("<收下的人第一件事要做什麼,一句話>", "去複驗")
|
||||
b = re.sub(r"<s/review|[^>]*>", "s/review", b)
|
||||
b = b.replace("<實測輸出或 PR 連結>", "測試")
|
||||
print(b)
|
||||
PY
|
||||
)
|
||||
case "$BLK" in /*|\"/*) got=abs ;; *) got="${BLK:0:30}" ;; esac
|
||||
judge abs "$got" "出路印的是絕對路徑(InkStoneCo/scripts/ticket 是舊複本,沒有 handback)"
|
||||
OFF="TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN_CLAUDE_CODE=fake ISEP_API_RETRIES=0"
|
||||
env $OFF bash -c "$BLK" >/dev/null 2>&1
|
||||
judge 1 "$?" "★ 原樣貼上只填三格 ⇒ 參數全過、走到網路才停(離開碼 1;2=被自己的用法擋下)"
|
||||
env $OFF bash -c "${BLK/--next/--nope}" >/dev/null 2>&1
|
||||
judge 2 "$?" "對照組:拿掉 --next ⇒ 確實跑不完(上一條才有鑑別力)"
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
|
||||
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 ]
|
||||
@@ -143,6 +143,11 @@ p "hub(scope 容器,09-07 實查 ISEP#31–35 的形狀)→ 不可抓" "Fa
|
||||
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 "里程碑標題只差空白/大小寫 → 仍算同一個(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 "順序: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'))"
|
||||
|
||||
+36
-5
@@ -137,10 +137,25 @@ _TOLERATED = object() # api() 吞掉時回的哨兵——不是 None,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}"
|
||||
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(
|
||||
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"})
|
||||
try:
|
||||
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:
|
||||
return _TOLERATED
|
||||
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):
|
||||
@@ -1307,10 +1326,16 @@ def human_gated(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, 理由)。**純函式,不打網路。**
|
||||
|
||||
`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 那趟 run(Arcrun#86 c6917)就是用這條理由判定「主線沒有可抓的票」然後收工。
|
||||
`s/todo` 的意思本來就是「沒人在做」;有人在做的形狀是 `s/doing`,那格照擋。
|
||||
理由是給人看的:`pick --all` 與 `claim` 擋下時都印它,一張票為什麼不能抓要說得出來。
|
||||
"""
|
||||
if issue.get("pull_request"):
|
||||
@@ -1322,6 +1347,8 @@ def pickable(issue, milestone_title):
|
||||
if issue.get("assignees") or issue.get("assignee"):
|
||||
who = [a.get("login") for a in (issue.get("assignees") or []) if isinstance(a, dict)] \
|
||||
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)
|
||||
if human_gated(labels):
|
||||
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
|
||||
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)
|
||||
found = []
|
||||
for ref in refs:
|
||||
it = _fetch_issue(ref)
|
||||
if not it or (it.get("state") or "open") != "open":
|
||||
continue
|
||||
ok, _ = pickable(it, None)
|
||||
ok, _ = pickable(it, None, me)
|
||||
if ok:
|
||||
found.append(it)
|
||||
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")):
|
||||
if str((it.get("milestone") or {}).get("id")) != str(m.get("id")):
|
||||
continue
|
||||
ok, _ = pickable(it, m.get("title"))
|
||||
ok, _ = pickable(it, m.get("title"), me)
|
||||
if ok:
|
||||
found.append(it)
|
||||
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)
|
||||
# 在不在主線上:看相依邊與 hub 里程碑(inkstone/ISEP#133),不看標題
|
||||
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:
|
||||
due = ML.due_of(ims)
|
||||
if not (due and due < _now()):
|
||||
|
||||
Reference in New Issue
Block a user