Compare commits

...

3 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
7 changed files with 310 additions and 80 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(87 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、65 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.30.2",
"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",
+1 -1
View File
@@ -23,7 +23,7 @@
| | 數量 | 是什麼 |
|---|---|---|
| `hooks/` | 62 支 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUseUserPromptSubmit 共 87 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** |
| `hooks/` | 62 支 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUseUserPromptSubmit 共 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` |
+11 -2
View File
@@ -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 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
+10 -2
View File
@@ -7,10 +7,16 @@
## 一句話結論
`hooks/` 底下有 **62 個 `.sh` 檔**`hooks.json` 實際掛上 **87 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **62 個 `.sh` 檔**`hooks.json` 實際掛上 **88 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 📌 **`inkstone/ISEP#153`2026-09-13,待總管定版)+0 支、+1 條**`baton-handback-guard.sh` 多掛一條 `Stop`。
> 背景派工時 `PostToolUse:Agent` 在工人**剛出發**就觸發,舊版當場念「收工了但棒子沒交回來」(09-13 對 ISEP#150、arcrun-rag#14 各一次,工人其實還要跑 17 分鐘)。
> 現在送出那一刻看到 launch ack 就不查;工人交件(主對話收到 `<task-notification>` completed)時由 Stop 那半用 `tool-use-id` 對回原派工單再查,同一條線只點名一次。
> **62 支、88 條、65 支腳本是在這棵樹上當場數出來的**`ls hooks/*.sh | wc -l`62、
> `grep -c '"command":' hooks/hooks.json`88、`ls -p scripts | grep -v / | wc -l`65)。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的:
@@ -279,6 +285,7 @@
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
| `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`)。
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58
@@ -298,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
@@ -420,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
View File
@@ -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#15309-13 兩次實撞)──────────
# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線),
# 那時 tool_response 是 launch ack{"isAsync": true, "status": "async_launched", ...}
# 同一份物件寫在 transcript 的 toolUseResult2026-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 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜
# 不掛 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
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:
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:
# 同一則通知會以 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
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/reviews/stages/pendings/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
+9
View File
@@ -377,6 +377,15 @@
"timeout": 180
}
]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh",
"timeout": 60
}
]
}
],
"SubagentStop": [
+103 -8
View File
@@ -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 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 "$PASS/$((PASS+FAIL)) 通過"