diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e467f26..4bf5863 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "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 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "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.2", "keywords": [ "inkstone", diff --git a/README.md b/README.md index b5f9bfa..569daa0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | | 數量 | 是什麼 | |---|---|---| -| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 87 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | +| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 88 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | | `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | | `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … | | `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` | diff --git a/docs/TESTING.md b/docs/TESTING.md index 727fc1e..18b8f3c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略 - 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index d70dd63..1b4fdc6 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -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 就不查;工人交件(主對話收到 `` 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 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: @@ -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 裡指的是「同一種派工工具呼叫」, diff --git a/hooks/baton-handback-guard.sh b/hooks/baton-handback-guard.sh index 99b0214..36a0731 100755 --- a/hooks/baton-handback-guard.sh +++ b/hooks/baton-handback-guard.sh @@ -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 背景派工:主對話收到 completed ⇒ 回合結束時查 # # ── leo 2026-08-27 的原話(本閘的規格)──────────────────────────────── # 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag, @@ -15,73 +18,165 @@ # tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串 # 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡 # -# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ────────────── -# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。 -# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來 -# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。 -# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。 +# ── 背景派工為什麼改到 Stop 查(inkstone/ISEP#153,09-13 兩次實撞)────────── +# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線), +# 那時 tool_response 是 launch ack:{"isAsync": true, "status": "async_launched", ...} +# (同一份物件寫在 transcript 的 toolUseResult,2026-08-24 實跡)。 +# 舊版在這一刻就查票 ⇒ 對 ISEP#150、arcrun-rag#14 各念一次「收工了但棒子沒交回來」, +# 而兩個工人之後各跑了 17 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。 # -# ── 只提醒,不阻擋 ──────────────────────────────────────────────── -# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。 -# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。 +# 真正的交件是主 transcript 裡的 completed), +# 它帶著 ——**拿這個唯一識別碼回頭對上當初那一則 Agent tool_use**, +# 從它的 prompt 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜。 +# 不掛 SubagentStop:parallel-lines-cap-guard.sh 檔頭實測過它在主 session 一次都沒觸發。 +# +# ── 只點名一次 ──────────────────────────────────────────────────── +# Stop 回 exit 2 會讓總管多走一個回合(去補三格)。查過的 tool-use-id 記在狀態檔,之後不再查 +# ⇒ 補完了自然安靜,沒補也不會鬼打牆。狀態檔在 ${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}。 +# 前景 PostToolUse 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不擋任何動作。 set -uo pipefail INPUT=$(cat) +# 訊息裡的出路一律印絕對路徑:總管的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是舊複本, +# `grep -c handback` → 0(2026-09-13 實查)⇒ 印相對路徑=教一條走不通的路(同 comment-carries-task-guard ⑮)。 +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +TICKET="$PLUGIN_ROOT/scripts/ticket" +STATE_DIR="${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}" +export ISEP_BATON_STATE_DIR="${STATE_DIR%/}" + +# ── 找出這一次要查的票(每行一張:owner repo num)────────────────────── +SPECS=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json, re, os + +TICKET = re.compile(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)") + +def spec_of(prompt): + for line in (prompt or "").splitlines(): + if "【工單】" not in line: + continue + m = TICKET.search(line) + if m: + return "%s %s %s" % m.groups() + return None + +def is_async_ack(resp): + # launch ack 兩種長相都認:結構化物件(isAsync/status),或只剩文字的 content 陣列 + if isinstance(resp, dict) and (resp.get("isAsync") is True or resp.get("status") == "async_launched"): + return True + try: + return "Async agent launched" in json.dumps(resp, ensure_ascii=False) + except Exception: + return False -SPEC=$(printf '%s' "$INPUT" | python3 -c ' -import sys, json, re try: d = json.load(sys.stdin) except Exception: raise SystemExit -p = (d.get("tool_input") or {}).get("prompt") or "" -for line in p.splitlines(): - if "【工單】" not in line: - continue - m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line) - if m: - print(f"{m.group(1)} {m.group(2)} {m.group(3)}") - break + +if d.get("hook_event_name") != "Stop": + if is_async_ack(d.get("tool_response")): + raise SystemExit # 工人才剛出發,還沒有「收工」可言;交件時由 Stop 那半查 + s = spec_of((d.get("tool_input") or {}).get("prompt")) + if s: + print(s) + raise SystemExit + +# ── Stop:有沒有背景工人交件 ───────────────────────────────────────── +tp = d.get("transcript_path") or "" +if not tp or not os.path.isfile(tp): + raise SystemExit +sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64] +state = os.path.join(os.environ["ISEP_BATON_STATE_DIR"], "baton-handback-seen-%s" % sid) +try: + seen = set(open(state, encoding="utf-8").read().split()) +except OSError: + seen = set() + +NOTE = re.compile(r"(toolu_[A-Za-z0-9_-]+).*?([a-z_]+)", re.S) +completed = [] # 保留出現順序 +dispatch = {} # tool_use id → 帶【工單】的派工單 +try: + with open(tp, encoding="utf-8", errors="replace") as f: + for line in f: + if "" in line: + # 同一則通知會以 enqueue/attachment/remove 出現多次,用 id 去重 + for tuid, st in NOTE.findall(line.replace("\\n", "\n")): + if st == "completed" and tuid not in completed: + completed.append(tuid) + if "【工單】" in line and "tool_use" in line: + try: + e = json.loads(line) + except Exception: + continue + msg = e.get("message") + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + for b in msg.get("content") or []: + if (isinstance(b, dict) and b.get("type") == "tool_use" + and b.get("name") in ("Agent", "Task")): + p = (b.get("input") or {}).get("prompt") + if isinstance(p, str) and "【工單】" in p: + dispatch[b.get("id")] = p +except OSError: + raise SystemExit + +todo = [t for t in completed if t in dispatch and t not in seen] +if not todo: + raise SystemExit +try: + os.makedirs(os.path.dirname(state), exist_ok=True) + with open(state, "a", encoding="utf-8") as f: + f.write("\n".join(todo) + "\n") +except OSError: + pass # 記不下來頂多再念一次,不擋 +out = [] +for t in todo: + s = spec_of(dispatch[t]) + if s and s not in out: + out.append(s) +print("\n".join(out)) ' 2>/dev/null || true) -[ -n "${SPEC:-}" ] || exit 0 -# shellcheck disable=SC2086 -set -- $SPEC -OWNER="$1"; REPO="$2"; NUM="$3" +[ -n "${SPECS:-}" ] || exit 0 -# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)── -# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。 -# 正式執行時這個變數不存在,一行都不會走到。 -if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then - J=$(cat "$BATON_GUARD_FIXTURE/issue.json") - C=$(cat "$BATON_GUARD_FIXTURE/comments.json") -else -# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法) -ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" -URL="" -for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do - [ -e "$d/.git" ] || continue - U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true - [ -n "${U:-}" ] && { URL="$U"; break; } -done -[ -n "$URL" ] || exit 0 -TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') -[ "$TOKEN" != "$URL" ] || exit 0 +# ── 查一張票:缺哪一格就寫到 stderr 並回 2,齊了回 0 ───────────────────── +check_one(){ # $1 owner $2 repo $3 num + local OWNER="$1" REPO="$2" NUM="$3" J C REPORT BODY NOW_A NOW_L + # ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)── + # BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。 + # 正式執行時這個變數不存在,一行都不會走到。 + if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then + J=$(cat "$BATON_GUARD_FIXTURE/issue.json") + C=$(cat "$BATON_GUARD_FIXTURE/comments.json") + else + # token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法) + local ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" URL="" U dir TOKEN + for dir in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do + [ -e "$dir/.git" ] || continue + U=$(git -C "$dir" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true + [ -n "${U:-}" ] && { URL="$U"; break; } + done + [ -n "$URL" ] || return 0 + TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') + [ "$TOKEN" != "$URL" ] || return 0 + J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \ + "https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || return 0 + C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \ + "https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]' + fi -J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \ - "https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || exit 0 -C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \ - "https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]' -fi - -REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null + REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null import json, sys try: it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2]) except Exception: raise SystemExit +if not isinstance(it, dict) or "state" not in it: + raise SystemExit # 讀不到票(404/錯誤回應)⇒ 不瞎報 if it.get("state") == "closed": raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手 +if not isinstance(cs, list): + cs = [] miss = [] if not (it.get("assignees") or []): miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上") @@ -100,37 +195,51 @@ print("\n".join(" ❌ " + m for m in miss)) print("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or []))) print("__LABELS__" + ",".join(labs)) PY -) || exit 0 +) || return 0 + [ -n "${REPORT:-}" ] || return 0 -[ -n "${REPORT:-}" ] || exit 0 + BODY=$(printf '%s' "$REPORT" | grep -v '^__') + NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p') + NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p') -BODY=$(printf '%s' "$REPORT" | grep -v '^__') -NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p') -NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p') - -cat >&2 <&2 <" \\ --label \\ --evidence "<實測輸出或 PR 連結>" 要 leo 親手做的 → --to Leo(會自動加 Human 標籤) - 已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable + 已經真的做完且驗過了 → "$TICKET" close $OWNER/$REPO#$NUM --deliverable -【撈一次看棒子在誰手上】 scripts/ticket mine +EOF + return 2 +} + +HIT=0 +while read -r OWNER REPO NUM; do + [ -n "${NUM:-}" ] || continue + check_one "$OWNER" "$REPO" "$NUM" &2 </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 = ("\na1\n%s\n" + "%s\nAgent finished\n好了\n" + "") % (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", 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)) 通過"