From e474b4cb6f8f3865a8ecd4ebbf801485829ec177 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 12:05:53 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=A3=92=E5=AD=90=E4=B8=8D=E6=9C=83?= =?UTF-8?q?=E6=8E=89=EF=BC=9A=E5=AD=90=E7=A5=A8=E7=9B=B8=E4=BE=9D=EF=BC=8B?= =?UTF-8?q?tag=EF=BC=8B=E6=8C=87=E6=B4=BE=E4=B8=89=E6=A0=BC=EF=BC=8C?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=94=A8=20Gitea=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E6=AC=84=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-27:「子票相依是 gitea 原有機制,改 tag 和指定也是,這些全部都要」 08-26 掉的那一次(arcrun-rag#136 comment 4267「等雲端那半出貨才驗得了」躺了 14 小時)三格全缺:沒有子票、tag 沒動、沒有指派給任何人。根因不是誰忘了, 是那件事沒有落在任何一個「撈一次就看得到」的欄位上。 實測(Gitea 1.26.4):子票還開著時關母票 → HTTP 412 "cannot close this issue or pull request because it still has open dependencies" ⇒ 相依是平台保證的硬擋,不是提醒。跨 repo 也成立(201)。 - scripts/ticket 新增三個動詞:subtask(長子票+掛相依)/ handback(指派+改 tag+寫下一步,一個動作)/mine(撈一次看棒子在誰手上) - hooks/comment-carries-task-guard.sh:留言帶未完成的未來式卻沒開子票 → 擋一次 - hooks/baton-handback-guard.sh:一條線收工,三格缺哪一格當場說出來(提醒不擋) - docs/governance §16:三個維度/粒度(傾向多開票)/既有票只從今天起適用/ 與 s/* 的關係/實測輸出 - 測試 20+10 全綠,用 fixture 跑不打網路、不在票池留測試票 📌 踩到並記進閘的檔頭:hook 裡比對中文一律用 python3 的 re,不要用 grep 的字元類 (等[^。]{0,20}出貨 在真實 hook 呼叫路徑下對「等雲端那半出貨」不匹配,閘靜默失效) 票:inkstone/ISEP#30(comment 4334/4335/4346/4347) Co-Authored-By: Claude Opus 5 --- docs/TESTING.md | 20 ++ docs/governance/sdd-gitea-governance.md | 139 ++++++++++++ docs/hooks-inventory.md | 8 +- hooks/baton-handback-guard.sh | 136 ++++++++++++ hooks/comment-carries-task-guard.sh | 122 +++++++++++ hooks/hooks.json | 8 + scripts/test-baton-handback-guard.sh | 55 +++++ scripts/test-comment-carries-task-guard.sh | 60 ++++++ scripts/ticket | 234 ++++++++++++++++++++- 9 files changed, 778 insertions(+), 4 deletions(-) create mode 100755 hooks/baton-handback-guard.sh create mode 100755 hooks/comment-carries-task-guard.sh create mode 100755 scripts/test-baton-handback-guard.sh create mode 100755 scripts/test-comment-carries-task-guard.sh diff --git a/docs/TESTING.md b/docs/TESTING.md index 97ab691..42ded67 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -50,6 +50,26 @@ bash scripts/test-ticket-api-bypass-guard.sh **該看到**:`13/13 通過`。 **失敗**:任何一條不符,特別看「不該擋」那 8 條。 +### A11 — 討論串裡的任務要長成子票:20 條 +``` +bash scripts/test-comment-carries-task-guard.sh +``` +**該看到**:`20/20 通過`。 +**失敗**: +- 「該擋」6 條任一紅 ⇒ 08-26 那則真的掉了的留言形狀(「等雲端那半出貨才驗得了」)會漏抓 +- 「不該擋」10 條任一紅 ⇒ **誤攔,這比漏擋嚴重**——每次留言都被擋,人就學會忽略它 +- 最後兩條(`-F <檔>`)紅 ⇒ 內文放在檔案裡時閘看不到,等於走 `ticket say` 就自動繞過 + +### A12 — 收工要把棒子交回來:10 條 +``` +bash scripts/test-baton-handback-guard.sh +``` +**該看到**:`10/10 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**。 +**失敗**: +- 「該報」5 條任一紅 ⇒ 指派/tag/下一步缺哪一格抓不到,08-26 那次掉棒的狀態會靜靜通過 +- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略 +- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報 + ### A5 — 開票前的搜尋是跨 repo 的 ``` python3 scripts/ticket where 標籤 模組化 diff --git a/docs/governance/sdd-gitea-governance.md b/docs/governance/sdd-gitea-governance.md index b72cb39..8445bb7 100644 --- a/docs/governance/sdd-gitea-governance.md +++ b/docs/governance/sdd-gitea-governance.md @@ -702,3 +702,142 @@ Gitea 的 milestone 只管得到同一個 repo,所以六個群在 `inkstone/IS **機械閘**=`hooks/milestone-due-guard.sh`(PreToolUse `Bash`): 建 milestone 沒有 `due_on`、或 `due_on` 帶 `9999` → 擋。 四向實測:無 due_on 擋/9999 擋/真期限放行/只是讀 milestone 放行。 + +--- + +## 16. 棒子不會掉:三個 Gitea 原生欄位,全部都要(leo 2026-08-27 立) + +> leo 原話:「**不對,子票相依是 gitea 原有機制,改 tag 和指定也是,這些全部都要**」 +> 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯**票沒完工可以察覺嗎?**」 +> 「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,直到最後交出正確 deliverable 並且驗證」 + +### 16.0 這一節在解什麼(可重現的掉棒時序) + +``` +08-26 13:00 一條線交回 arcrun-rag#136 comment 4267: + 「Phase 1 的驗收在雲端那半出貨之前驗不了」 + ← 一根帶著明確解除條件的接力棒,但它只是一段文字 +08-26 18-23 總管把雲端那半併進 main、出 1.4.56、推 prod + ← 解除條件在這裡被滿足了,沒有任何東西知道 +08-27 11:xx leo 問「這個 14 小時前的,你派了還是沒派?」 + ← 14 小時後才發現棒子在地上 +``` + +當時那張票:**沒有子票、tag 沒動、沒有指派給任何人**——三格全缺。 +🔴 **根因不是誰忘了,是那件事沒有落在任何一個「撈一次就看得到」的欄位上。** + +### 16.1 三個維度正交,缺一個就會掉 + +| 欄位 | 它回答的問題 | 缺了它會怎樣 | +|---|---|---| +| **子票 + 相依** | **這件事存在嗎?做完了嗎?** | 任務藏在討論串,撈 open 票看不到它 | +| **tag(`s/*`)** | **它現在卡在哪一段?** | 撈得到票,但每次都要重讀整串才知道在等什麼 | +| **指派(assignee)** | **現在誰該動?** | 知道有事、知道卡在哪,但**沒人認領** | + +三個都是 Gitea 原生欄位,**都不必自造**(D58:充分利用 gitea 的機制)。 + +### 16.2 子票相依是**硬的**,不是提醒(2026-08-27 實測,Gitea 1.26.4) + +``` +POST /repos/inkstone/ISEP/issues/56/dependencies {"owner","repo","index":57} → 201 +GET /repos/inkstone/ISEP/issues/56/dependencies → [(57,'open')] +PATCH /repos/inkstone/ISEP/issues/56 {"state":"closed"} ← 子票還開著 + → HTTP 412 + {"message":"cannot close this issue or pull request because it still has open dependencies"} +關掉 #57 → 再 PATCH #56 state=closed → 201,母票 state=closed +``` + +⇒ **子票沒關,母票關不掉。這是平台保證,不是自律。** +畫面上也看得到:母票側欄出現 `Depends on → #57 …(inkstone/ISEP)`。 + +**跨 repo 也成立**(實測 `ISEP#56` 依賴 `arcrun-rag#136` → 201) +⇒ 這正是跨 repo 的載體:milestone 只管得到同一個 repo,**相依管得到全部**。 + +- **R16.2.1** 順序/阻擋關係一律用相依(同 R2.5),**禁止只寫文字**。 +- **R16.2.2** 相依掛不上去 = 那張子票對母票是隱形的 ⇒ **視為整個動作失敗**,要當場報錯。 + +### 16.3 粒度:**傾向多開票,不是少開票** + +> leo 2026-08-27(當場更正總管寫反的那條): +> 「**小任務做完就關閉,不會讓池子爆掉,每個事情沒有歷史記錄才是大問題**」 + +``` +這件事需要被記得嗎?(做過什麼、為什麼、量到什麼) + 需要 → 開票。做完就關。 + 不需要 → 才不開 +``` + +🔴 **不要用「會不會太多」當判準。** 池子爆掉的前提是「開了不關」—— +那由 **做完就關** 解決,不該由 **不開票** 解決。 + +三條線不衝突,順序是: +1. **要不要有票**(本節)——傾向要。判準:這件事需要被記得嗎。 +2. **要不要拆成獨立票**(既有規約)——判準:「票的刀口不是大小,是**狀態**: + 它會不會需要跟隔壁那條不同的狀態?」 +3. **會不會變成池子**——由「做完就關」解決,不由前兩題解決。 + +📌 leo 2026-08-16「我不要把所有的票都放在一個 issues,不然就難 track 歷史記錄」 +與本節同一個目的:**可追蹤的歷史**。全塞一張 ⇒ 糊成一團;藏在討論串/prompt ⇒ 根本沒有歷史。 + +### 16.4 tag 與相依的關係:**流程狀態 vs 結構關係,兩個軸** + +- `s/*` 是**這張票走到哪一格**(§3.0 的七態,exclusive)。 +- 相依是**這張票和別張票的結構關係**。 +- 兩者不衝突,也不能互相取代: + - 一張票可以 `s/doing` 而且有未關的相依(它自己有事在做,只是還關不掉) + - 一張票可以 `s/pending` 而且沒有相依(它卡的是票池外面的東西,例如等 leo 開閘) +- **R16.4.1** 卡在**別張票**上 ⇒ 掛相依 **並** 轉 `s/pending`。 + 只掛相依不改 tag = 看板上它看起來還在動;只改 tag 不掛相依 = 母票關得掉。 +- **R16.4.2** `Human` 仍是正交 overlay(§6.1),與本節三格互不取代。 + +### 16.5 指派:棒子的持有人,**是欄位不是文字** + +可指派的身份只有兩個(2026-08-27 實查):`Leo`、`claude-code`。 +**總管就是 `claude-code`**(所有票都是這個 token 寫的),不必新增任何身份。 + +- **R16.5.1(收工)** 任何一條線收工 = **指派回 `claude-code` + 改 tag + 寫下一步**, + 三件事是**同一個動作**(`scripts/ticket handback`)。 +- **R16.5.2(要 leo 做)** 指派 `Leo` + 掛 `Human`(§6.1、既有交棒規約)。 +- **R16.5.3(終點)** 棒子有終點——leo 的原話是「**直到最後交出正確 deliverable 並且驗證**」。 + 驗完 ⇒ 關票、清指派 ⇒ 它從所有人的待辦裡消失。 + **不要讓票變成永遠在傳的燙手山芋。** +- **R16.5.4(撈一次)** 「哪幾根棒子在誰手上」用 `scripts/ticket mine` 撈, + 掛在**本來就會做的動作**上(session 開場、收工對帳)。🔴 **禁止排程輪詢**(D20)。 + +### 16.6 既有的幾十張票怎麼辦:**只從今天起適用,不回頭補** + +- **R16.6.1** 不做全面回填。理由:回頭讀幾十張票的每一則 comment 是 O(票數×留言數), + 那正是本節說「人與 AI 都不會做」的那個動作——**用一個不會被執行的規定去補一個漏洞,等於沒補。** +- **R16.6.2** 改成**碰到才補**:任何人(或閘)下一次動到某張票時, + 順手把它討論串裡還活著的待辦長成子票。**動到才補,成本落在本來就要付的那次。** +- **R16.6.3** 例外:`hub` 票(§1)**要補齊**——它們本來就宣稱自己是聚合容器, + 而 §3.2「子票全關才能關 hub」在沒有相依的情況下是**靠自律**的。 + 掛上相依之後,那條規則變成平台保證。 + ✅ **2026-08-27 實查:六張 hub 全部已經掛好了**,且數量與各自內文列的來源票一致—— + `ISEP#30` 6/`#31` 7/`#32` 11/`#33` 5/`#34` 8/`#35` 7(跨 `InkStoneCo`/`Arcrun`/ + `arcrun-rag`/`system-dev-template` 四個 repo)。**這一格不用補,本條是為了讓它不再退化。** + +### 16.7 工具與機械閘 + +``` +scripts/ticket subtask <母票> --title -F <檔> [--label] [--assign] + 討論串裡的一件事 → 子票 + 回鏈 Parent: + Gitea 原生相依(三格一次到位) +scripts/ticket handback <票> --to --next "<一句話>" [--label] [--evidence] + 收工 = 指派 + 改 tag + 寫下一步,一個動作三件事 +scripts/ticket mine [--user <誰>] + 撈一次:棒子在誰手上、每根的下一步、每根還有幾張未關的相依 +``` + +| 閘 | 掛在哪 | 抓什麼 | 測試 | +|---|---|---|---| +| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(20 條) | +| `hooks/baton-handback-guard.sh` | PostToolUse `Agent\|Task` | 一條線收工,票上**指派/tag/下一步**缺哪一格 → 當場說出來(提醒,不擋) | `scripts/test-baton-handback-guard.sh`(10 條) | + +🔴 **兩支閘的誤攔設計**:`comment-carries-task-guard` 同一 session 只擋一次, +關鍵詞刻意取窄,並留 `no-subtask: <理由>` 逃生口(留痕)。 +理由寫在 `.claude/branch-holds.md` 檔頭那句: +**永遠在響的警報,等於訓練人忽略這個警報。** + +🔴 **hook 裡比對中文一律用 `python3` 的 `re`,不要用 `grep` 的字元類** +(2026-08-27 實撞:`等[^。]{0,20}出貨` 在真實 hook 呼叫路徑下對 +「等雲端那半出貨」不匹配,閘靜默失效;換成 python3 後測試由 19/20 變 20/20)。 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 71561c7..605bc5d 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 46 支閘,白話盤點表 +# 48 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,14 +7,14 @@ ## 一句話結論 -`hooks/` 底下有 **46 個 `.sh` 檔**,`hooks.json` 實際掛上 **56 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **48 個 `.sh` 檔**,`hooks.json` 實際掛上 **58 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 46;`hooks.json` 展開後的 command 條目 = 56。 +> `ls hooks/*.sh | wc -l` = 48;`hooks.json` 展開後的 command 條目 = 58。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 --- @@ -44,6 +44,7 @@ | `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 | | `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 | | `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 開新 Gitea 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 | +| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) | ## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit) @@ -126,6 +127,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` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) | ## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit) diff --git a/hooks/baton-handback-guard.sh b/hooks/baton-handback-guard.sh new file mode 100755 index 0000000..99b0214 --- /dev/null +++ b/hooks/baton-handback-guard.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。 +# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。 +# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。 +# +# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task) +# +# ── leo 2026-08-27 的原話(本閘的規格)──────────────────────────────── +# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag, +# 你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**, +# 要說明下一步怎麼做,**直到最後交出正確 deliverable 並且驗證**」 +# +# ── 三格各自回答一個問題(leo:「這些全部都要」)───────────────────── +# 指派 assignee 現在誰該動? 缺了它 → 大家都以為是別人的事 +# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串 +# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡 +# +# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ────────────── +# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。 +# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來 +# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。 +# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。 +# +# ── 只提醒,不阻擋 ──────────────────────────────────────────────── +# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。 +# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。 +set -uo pipefail + +INPUT=$(cat) + +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 +' 2>/dev/null || true) + +[ -n "${SPEC:-}" ] || exit 0 +# shellcheck disable=SC2086 +set -- $SPEC +OWNER="$1"; REPO="$2"; NUM="$3" + +# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)── +# 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 + +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 +import json, sys +try: + it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2]) +except Exception: + raise SystemExit +if it.get("state") == "closed": + raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手 +miss = [] +if not (it.get("assignees") or []): + miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上") +labs = [l["name"] for l in (it.get("labels") or [])] +st = [n for n in labs if n.startswith("s/")] +if not st: + miss.append("tag:沒有任何 s/* ⇒ 看不出它卡在哪一段") +elif st == ["s/doing"]: + miss.append("tag:還停在 s/doing,但這條線已經收工了 ⇒ 它到底做完沒有?" + "(等總管複驗=s/review/已上 stage=s/stage/等別的東西=s/pending)") +if not any("🏃" in (c.get("body") or "") for c in cs): + miss.append("下一步:票上沒有交棒留言 ⇒ 收下棒子的人得自己重讀整串才知道要做什麼") +if not miss: + raise SystemExit +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 + +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 <" \\ + --label \\ + --evidence "<實測輸出或 PR 連結>" + + 要 leo 親手做的 → --to Leo(會自動加 Human 標籤) + 已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable + +【撈一次看棒子在誰手上】 scripts/ticket mine +EOF +exit 2 diff --git a/hooks/comment-carries-task-guard.sh b/hooks/comment-carries-task-guard.sh new file mode 100755 index 0000000..1e1d9c7 --- /dev/null +++ b/hooks/comment-carries-task-guard.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# 管什麼: 往 Gitea 票裡貼「留言裡藏著一件還沒做完的事」(等 X/驗不了/還沒…)卻沒有把它長成子票時,擋一次。 +# 為什麼: 2026-08-26 arcrun-rag#136 comment 4267 就是這樣掉的——「等雲端那半出貨才驗得了」只活在討論串,收端後來真的上線了,卻沒有任何東西讓人回頭把它撿起來,躺了 14 小時。 +# 誤觸時怎麼關: 這則留言真的不含待辦 → 指令裡加 `no-subtask: <一句理由>` 放行(留痕);同一 session 只擋一次。 +# +# comment-carries-task-guard.sh — 討論串裡的任務要長成看得見的子票(PreToolUse: Bash) +# +# ── leo 2026-08-27 的原話(本閘的規格)──────────────────────────────── +# 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工, +# 如果你沒辦法察覺,那不要把任務加在討論串,而是每個任務長出子票做為這張票的相依, +# **票沒完工可以察覺嗎?**」 +# +# ── 為什麼子票比「讓等待會響」更根本 ───────────────────────────────── +# 任務寫在討論串 → 要有人回頭讀 comment 才知道它存在 → 沒人回頭讀 → 掉 +# 任務長成子票 → open/closed 是**狀態** → 撈一次就看得到 → 掉不了 +# ⇒ 不是「讓它會響」,是**讓它本來就在視線裡**。 +# +# 而且相依是硬的:實測(2026-08-27,Gitea 1.26.4)子票還開著時關母票 → +# HTTP 412 `cannot close this issue or pull request because it still has open dependencies`。 +# +# ── 粒度:傾向多開票,不是少開票(leo 2026-08-27 當場更正總管)──────── +# 「**小任務做完就關閉,不會讓池子爆掉,每個事情沒有歷史記錄才是大問題**」 +# ⇒ 判準是「這件事需要被記得嗎」,不是「會不會太多」。 +# 池子爆掉的前提是「開了不關」,那由「做完就關」解決,不該由「不開票」解決。 +# +# ── 誠實邊界 ────────────────────────────────────────────────────── +# PreToolUse 只看得到指令文字,這是這一層的天花板(同 InkStoneCo#36)。 +# 所以關鍵詞刻意取**窄**的一組「未完成的未來式」,寧可漏抓也不要天天誤擋—— +# `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。** +# 漏抓的那些,由 baton-handback-guard.sh(收工那一刻)補第二層。 +set -uo pipefail + +INPUT=$(cat) +CMD=$(printf '%s' "$INPUT" | python3 -c " +import sys,json +try: print(json.load(sys.stdin).get('tool_input',{}).get('command','')) +except Exception: print('') +" 2>/dev/null) +SID=$(printf '%s' "$INPUT" | python3 -c " +import sys,json +try: print(json.load(sys.stdin).get('session_id','') or 'nosid') +except Exception: print('nosid') +" 2>/dev/null || echo nosid) +[ -n "$CMD" ] || exit 0 + +# 逃生口(留痕,會留在指令歷史上) +case "$CMD" in *no-subtask:*) exit 0 ;; esac + +# 這些動詞本來就是在處理「棒子」,不該被自己擋 +case "$CMD" in + *"ticket subtask"*|*"ticket handback"*|*"ticket mine"*|*"ticket close"*|*"ticket where"*) exit 0 ;; +esac + +# ① 這是不是在對「既有票」貼留言?(開新票走 ticket new/ticket-api-bypass-guard) +IS_COMMENT=no +case "$CMD" in *"ticket say"*) IS_COMMENT=yes ;; esac +if printf '%s' "$CMD" | grep -qE 'issues/[0-9]+/comments' && printf '%s' "$CMD" | grep -qw 'POST'; then + IS_COMMENT=yes +fi +[ "$IS_COMMENT" = "yes" ] || exit 0 + +# ② 把「要貼的內文」拿到手:-F <檔> 就讀那個檔,否則就看指令文字本身 +BODY="$CMD" +FARG=$(printf '%s' "$CMD" | sed -nE 's/.*[[:space:]](-F|--file)[[:space:]]+([^[:space:]]+).*/\2/p' | head -1) +if [ -n "${FARG:-}" ] && [ -f "$FARG" ]; then + BODY=$(cat "$FARG" 2>/dev/null || printf '%s' "$CMD") +fi + +# 已經帶著交棒標記的留言 = 走的是 handback 那條路,不重複擋 +case "$BODY" in *"🏃"*) exit 0 ;; esac + +# ③ 「未完成的未來式」——刻意取窄。這一組全部來自 08-26 那則真的掉了的留言的形狀。 +# +# 🔴 比對用 python3 不用 grep——這是**實測逼出來的**,不是偏好(2026-08-27 本閘第一版): +# 第一版寫 `等[^。]{0,20}(出貨|…)`,跑真的 hook 呼叫路徑時對 +# 「這件事等雲端那半出貨才驗得了」**不匹配**,閘靜默失效(測試 19/20,那一條紅)。 +# 同一條 pattern 在別的 shell 手打卻會過 ⇒ **它的行為依賴 locale/grep 實作**, +# 而 hook 的執行環境不由我們決定(本機 `LC_CTYPE=C`,雲端又是另一套)。 +# unpushed-police.sh 檔頭記過同一族的坑:C locale 下處理全形標點會咬掉位元組。 +# ⇒ 規約:**hook 裡凡是要比對中文,一律交給 python3 的 re**(它一定是字元語意), +# 不要賭 grep 在那台機器上是字元模式還是位元組模式。 +HIT=$(BODY="$BODY" python3 -c ' +import os, re, sys +PAT = ("等.{0,20}?(出貨|上線|部署|發佈|合併|修好|回覆|做完|接上)" + "|驗不了|測不了|還沒(驗|測|做|上線|部署|接|補)|之前(驗不|無法|不能)" + "|要等|尚未完成|待辦|\\[blocker\\]|TODO|FIXME") +ms = [m.group(0) for m in re.finditer(PAT, os.environ.get("BODY", ""))] +print(" ".join(ms[:3])) +' 2>/dev/null) +[ -n "${HIT:-}" ] || exit 0 + +WARNED="/tmp/.comment-task-guard-warned-$SID" +[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆 +date +%s > "$WARNED" + +cat >&2 < \\ + --title "身為<誰>,我要<什麼>,我才<為什麼>" \\ + -F <內文檔> --label s/todo --assign <誰做> + + ⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。** + +【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉, + 每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」, + 傾向**多開票**。不要用「會不會太多」當理由不開。 + +【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。 +EOF +exit 2 diff --git a/hooks/hooks.json b/hooks/hooks.json index a4b3f78..44017c1 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -41,6 +41,10 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh" }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/comment-carries-task-guard.sh" + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh" @@ -331,6 +335,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh" } ] } diff --git a/scripts/test-baton-handback-guard.sh b/scripts/test-baton-handback-guard.sh new file mode 100755 index 0000000..345216a --- /dev/null +++ b/scripts/test-baton-handback-guard.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# 交棒警察的測試——三格(指派/tag/下一步)各自缺漏時要被抓到 +# 判準:5 種該報、5 種不該報 +# 用 fixture 跑,不打網路、不在票池留下任何一張測試票。 +cd "$(dirname "$0")/.." || exit 1 +H=hooks/baton-handback-guard.sh +PASS=0; FAIL=0 +FX=$(mktemp -d); trap 'rm -rf "$FX"' EXIT + +mkfx(){ # $1=state $2=assignees(csv) $3=labels(csv) $4=有沒有交棒留言(y/n) + python3 - "$FX" "$1" "$2" "$3" "$4" <<'PY' +import json, sys, os +d, state, asg, labs, baton = sys.argv[1:6] +issue = {"state": state, + "assignees": [{"login": a} for a in asg.split(",") if a], + "labels": [{"name": l} for l in labs.split(",") if l]} +comments = [{"body": "一般進度留言"}] +if baton == "y": + comments.append({"body": "🏃 **棒子交回** → `claude-code`\n\n**下一步**:去驗那一格"}) +json.dump(issue, open(os.path.join(d, "issue.json"), "w")) +json.dump(comments, open(os.path.join(d, "comments.json"), "w")) +PY +} + +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" +} + +echo "── 該報:三格有缺 ──" +mkfx open "" "s/doing" n ; run 2 "三格全缺(=08-26 那次掉棒的狀態)" +mkfx open "" "s/review" y ; run 2 "缺指派:撈 assignee 撈不到它" +mkfx open "claude-code" "" y ; run 2 "缺 tag:看不出它卡在哪一段" +mkfx open "claude-code" "s/review" n ; run 2 "缺下一步:收下的人得重讀整串" +mkfx open "claude-code" "s/doing" y ; run 2 "tag 還停在 s/doing,但這條線收工了" + +echo "── 不該報 ──" +mkfx open "claude-code" "s/review" y ; run 0 "三格都齊(交回總管等 review)" +mkfx open "Leo" "s/stage,Human" y ; run 0 "三格都齊(交給 leo 驗收)" +mkfx open "claude-code" "s/pending,p/high" y ; run 0 "卡在依賴上,但三格都齊" +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" + +echo +echo "$PASS/$((PASS+FAIL)) 通過" +[ "$FAIL" = 0 ] diff --git a/scripts/test-comment-carries-task-guard.sh b/scripts/test-comment-carries-task-guard.sh new file mode 100755 index 0000000..5ff01b1 --- /dev/null +++ b/scripts/test-comment-carries-task-guard.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# 討論串裡的任務要長成子票——這道閘的測試 +# 判準:6 種該擋(留言裡真的藏著待辦)、10 種不該擋 +# 🔴 「不該擋」比「該擋」重要:branch-holds.md 檔頭那句—— +# 永遠在響的警報,等於訓練人忽略這個警報。 +cd "$(dirname "$0")/.." || exit 1 +H=hooks/comment-carries-task-guard.sh +PASS=0; FAIL=0 +SID=test-cctg-$$ +run(){ # $1=want $2=cmd + rm -f "/tmp/.comment-task-guard-warned-$SID" + printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \ + | 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 %.62s\n' "$1" "$got" "$2" +} + +echo "── 該擋:留言裡藏著一件還沒做完的事 ──" +# 這一條就是 2026-08-26 真的掉了的那則的形狀 +run 2 'req("POST", f"{API}/repos/inkstone/arcrun-rag/issues/136/comments", {"body":"Phase 1 的驗收在雲端那半出貨之前驗不了"})' +run 2 'curl -X POST "$API/repos/inkstone/Arcrun/issues/12/comments" -d "{\"body\":\"要等 leo 開閘才能部署\"}"' +run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"[blocker] 收端還沒上線"})' +run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"這條路徑還沒測,先擱著"})' +run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"TODO: 補一條端到端測試"})' +run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"等雲端出貨之後要回頭重驗一次"})' + +echo "── 不該擋 ──" +run 0 'curl -s "$API/repos/inkstone/ISEP/issues/30/comments"' # 純讀 +run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"已驗過,8/8 全綠"})' # 沒有待辦形狀 +run 0 'scripts/ticket subtask inkstone/arcrun-rag#136 --title "身為x,我要y,我才z" -F /tmp/c.md' +run 0 'scripts/ticket handback inkstone/arcrun-rag#136 --to claude-code --next "還沒驗的那格去驗"' +run 0 'scripts/ticket mine' +run 0 'scripts/ticket close inkstone/x#1 --deliverable https://example.invalid/pr/1' +run 0 'grep -n "驗不了" hooks/comment-carries-task-guard.sh' # 只是讀這支閘 +run 0 'echo "這則留言等雲端出貨才驗得了 —— no-subtask: 它已經有子票 #143 了"' # 逃生口 +run 0 'curl -X POST "$API/repos/x/y/issues" -d "{\"title\":\"等出貨\"}"' # 開新票不歸這支管 +run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})' + +echo "── 同一 session 只擋一次(不鬼打牆)──" +rm -f "/tmp/.comment-task-guard-warned-$SID" +C='req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"驗不了"})' +for want in 2 0; do + printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$C")}}" \ + | bash "$H" >/dev/null 2>&1 + got=$? + if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s 第 %s 次同樣的指令\n' "$want" "$got" "$([ "$want" = 2 ] && echo 1 || echo 2)" +done + +echo "── -F <檔>:內文在檔案裡也要看得到 ──" +TMPF=$(mktemp); printf '這件事等雲端那半出貨才驗得了\n' > "$TMPF" +run 2 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF" +printf '已驗完,全部綠燈\n' > "$TMPF" +run 0 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF" +rm -f "$TMPF" "/tmp/.comment-task-guard-warned-$SID" + +echo +echo "$PASS/$((PASS+FAIL)) 通過" +[ "$FAIL" = 0 ] diff --git a/scripts/ticket b/scripts/ticket index cd3320b..bd40252 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -18,6 +18,12 @@ leo 2026-08-16 三句話,本工具就是它們的機械化: ticket close <票> --deliverable 關票(要有交付物連結) ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作) +三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒): + + ticket subtask <母票> --title -F <檔> 討論串裡的一件事 → 看得見的子票+相依 + ticket handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作 + ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼 + 票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44` """ import json @@ -351,7 +357,233 @@ def cmd_decide(argv): print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。") -CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide} +# ══════════════════════════════════════════════════════════════════════════ +# 三個 Gitea 原生欄位(leo 2026-08-27:「子票相依是 gitea 原有機制,改 tag 和指定 +# 也是,**這些全部都要**」)。三者正交,各回答一個問題: +# +# 子票相依 這件事存在嗎?做完了嗎? ← 缺了它:任務藏在討論串,撈 open 票看不到 +# tag s/* 它現在卡在哪一段? ← 缺了它:撈得到票但要重讀整串才知道在等什麼 +# 指派 現在誰該動? ← 缺了它:大家都以為是別人的事 +# +# 08-26 那次掉棒(arcrun-rag#136 comment 4267「等雲端那半出貨才驗得了」躺了 14 小時) +# 三格全缺:沒有子票、tag 沒動、沒指派給任何人。 +# ══════════════════════════════════════════════════════════════════════════ + +BATON_MARK = "🏃 **棒子交回**" + + +def _labels_of(owner, repo): + return {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=100")} + + +def _set_labels(owner, repo, num, keep): + ids = _labels_of(owner, repo) + unknown = [n for n in keep if n not in ids] + if unknown: + die(f"🚫 這個 repo 沒有這些標籤:{unknown}\n" + f" (標籤的唯一真相源是 ISEP 的 labels.yaml,改標籤要改那裡再 sync)") + api(f"/repos/{owner}/{repo}/issues/{num}/labels", + {"labels": [ids[n] for n in keep]}, method="PUT") + + +def _deps(owner, repo, num): + return api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or [] + + +# ── subtask ────────────────────────────────────────────────────────────── +def cmd_subtask(argv): + """把「討論串裡的一件事」長成看得見的子票,並掛成母票的 Gitea 原生相依。 + + 為什麼是子票而不是留言(leo 2026-08-27): + 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯ + 票沒完工可以察覺嗎?」 + 留言要有人回頭讀才看得見;票的 open/closed 是**狀態**,撈一次就在眼前。 + + 而且相依是**硬的**,不是提醒——實測(2026-08-27,Gitea 1.26.4): + 子票還開著時 PATCH 母票 state=closed → **HTTP 412 + "cannot close this issue or pull request because it still has open dependencies"** + ⇒ 子票沒關,母票關不掉。這是平台保證,不是自律。 + + 刻意不要求 `ticket where` 戳記:leaf 屬於一條**已知**的線(它的母票就是那條線), + 不是「新開一條線」——那道閘擋的是後者。 + """ + if len(argv) < 1: + die("用法:ticket subtask <母票 owner/repo#N> --title \"\" -F <內文檔>\n" + " [--repo <收件 repo,預設跟母票同一個>] [--assign <誰做>] [--label ]") + powner, prepo, pnum = parse_ref(argv[0]) + + def opt(name, default=None): + return argv[argv.index(name) + 1] if name in argv else default + + title = opt("--title") + fpath = opt("-F") or opt("--file") + if not title or not fpath: + die("🚫 缺 --title 或 -F <內文檔>") + check_title(title) + body = open(fpath).read() + + missing = [s for s in REQUIRED_SECTIONS if s not in body] + if missing: + die(f"🚫 子票一樣是票,模板欄位不能少:{'、'.join(missing)}\n" + " (子票通常很小,但「小」不等於「可以說不清楚」——\n" + " leo 2026-08-27:『每個事情沒有歷史記錄才是大問題』,\n" + " 而沒寫驗收條件的票,關掉之後就沒有歷史,只剩一個關字)") + + repo = opt("--repo", prepo) + label = opt("--label", "s/todo") + assign = opt("--assign") + + body = (f"Parent: {powner}/{prepo}#{pnum}\n" + f"> 這張票是從母票的討論串裡長出來的一件事。\n" + f"> **它沒關,母票關不掉**(Gitea 原生相依,實測 412 硬擋)。\n\n") + body + + d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body}) + cnum = d["number"] + print(f"✅ 子票 {ORG}/{repo}#{cnum} 已開:{d['html_url']}") + + _set_labels(ORG, repo, cnum, [label]) + print(f" tag:{label}(它卡在哪一段)") + if assign: + api(f"/repos/{ORG}/{repo}/issues/{cnum}", {"assignees": [assign]}, method="PATCH") + print(f" 指派:{assign}(誰該動)") + + # 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到 + api(f"/repos/{powner}/{prepo}/issues/{pnum}/dependencies", + {"owner": ORG, "repo": repo, "index": cnum}) + deps = _deps(powner, prepo, pnum) + openn = [i for i in deps if i["state"] == "open"] + print(f" 相依:已掛上 {powner}/{prepo}#{pnum}(它存在、且擋著母票)") + print(f"\n📌 母票 {powner}/{prepo}#{pnum} 目前有 {len(openn)} 張未關的相依:") + for i in deps: + mark = "◻" if i["state"] == "open" else "☑" + print(f" {mark} {i['repository']['full_name']}#{i['number']} {i['title'][:50]}") + print(" 在它們全關之前,母票 PATCH state=closed 會被 Gitea 擋下(412)。") + + +# ── handback ───────────────────────────────────────────────────────────── +def cmd_handback(argv): + """收工=把棒子交回去:**指派 + tag + 下一步**,一個動作三件事。 + + leo 2026-08-27:「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag, + 你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**, + 要說明下一步怎麼做,直到最後交出正確 deliverable 並且驗證」 + + 🔴 關鍵在「指派」是**欄位**不是文字——撈一次就看得到,不必讀 comment。 + 08-26 那次掉棒,交回的話寫在 comment 裡,票沒指派、tag 沒動 ⇒ 棒子躺在地上 14 小時。 + """ + if len(argv) < 1: + die("用法:ticket handback --to --next \"<下一步一句話>\"\n" + " [--label ] [--evidence ]\n" + "(--to claude-code = 交回總管;--to Leo = 要 leo 親手做,會自動加 Human)") + owner, repo, num = parse_ref(argv[0]) + + def opt(name, default=None): + return argv[argv.index(name) + 1] if name in argv else default + + to = opt("--to") + nxt = opt("--next") + if not to or not nxt: + die("🚫 缺 --to 或 --next。\n" + " **「下一步」不准省略**——收下棒子的人要能不重讀整串就知道該做什麼。\n" + " (這正是 08-26 掉棒那次缺的:話有說,但沒有欄位在說「這張票在等你」)") + label = opt("--label") + ev = opt("--evidence") + + issue = api(f"/repos/{owner}/{repo}/issues/{num}") + cur = [l["name"] for l in issue.get("labels") or []] + + deps = _deps(owner, repo, num) + openn = [i for i in deps if i["state"] == "open"] + + lines = [f"{BATON_MARK} → `{to}`", "", + f"**下一步**:{nxt}"] + if ev: + lines += ["", f"**證據**:{ev}"] + if openn: + lines += ["", "**未關的相依**(它們全關之前這張票關不掉):"] + lines += [f"- {i['repository']['full_name']}#{i['number']} {i['title'][:60]}" for i in openn] + api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": "\n".join(lines)}) + + api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": [to]}, method="PATCH") + + keep = list(cur) + if label: + keep = [n for n in keep if not n.startswith("s/")] + [label] + if to == "Leo" and "Human" not in keep: + keep.append("Human") + if to != "Leo" and "Human" in keep: + keep = [n for n in keep if n != "Human"] + _set_labels(owner, repo, num, keep) + + print(f"✅ {owner}/{repo}#{num} 棒子交回 `{to}`") + print(f" 指派:{to}  tag:{[n for n in keep if n.startswith('s/')] or '(未動)'}" + f"{' +Human' if 'Human' in keep else ''}") + print(f" 下一步:{nxt}") + if openn: + print(f" ⚠️ 還有 {len(openn)} 張未關的相依 ⇒ 這張票現在關不掉(412),這是對的") + print("\n📌 三件事是同一個動作——不會只留言而忘了指派,也不會指派了而看板還說 s/doing。") + + +# ── mine ───────────────────────────────────────────────────────────────── +def cmd_mine(argv): + """撈一次:**哪幾根棒子在誰手上、每根的下一步是什麼**。 + + 不是輪詢——掛在本來就會做的動作上(session 開場、收工對帳)跑一次。 + """ + user = argv[argv.index("--user") + 1] if "--user" in argv else None + + if user in (None, "", "me"): + who = "總管(本 token 的身份)" + items = api("/repos/issues/search?state=open&type=issues&assigned=true&limit=100") or [] + else: + who = user + items = [] + for r in api(f"/orgs/{ORG}/repos?limit=100") or []: + q = urllib.parse.urlencode({"state": "open", "assigned_by": user, "limit": 50}) + for it in api(f"/repos/{ORG}/{r['name']}/issues?{q}") or []: + if it.get("pull_request"): + continue + it.setdefault("repository", {"full_name": f"{ORG}/{r['name']}"}) + items.append(it) + + print(f"🏃 在「{who}」手上的棒子:{len(items)} 根\n") + if not items: + print(" (沒有——每一根都已經交出去或關掉了)") + for it in items: + ref = it["repository"]["full_name"] + "#" + str(it["number"]) + st = [l["name"] for l in it.get("labels") or []] + o, rp, n = ref.split("/")[0], ref.split("/")[1].split("#")[0], it["number"] + deps = _deps(o, rp, n) + openn = [i for i in deps if i["state"] == "open"] + print(f" ● {ref} {st}") + print(f" {it['title'][:64]}") + nxt = _last_next(o, rp, n) + print(f" 下一步:{nxt or '❌ 沒有人寫下一步(交棒時漏了 --next)'}") + if openn: + print(f" ◻ 未關相依 {len(openn)}:" + + "、".join(f"{i['repository']['full_name']}#{i['number']}" for i in openn)) + print("\n📌 終點是 leo 說的「直到最後交出正確 deliverable 並且驗證」——" + "驗完就關票、清指派,這張表才會縮短。") + + +def _last_next(owner, repo, num): + """從最後一則交棒留言抓「下一步」。抓不到就誠實回 None,不要編。""" + try: + cs = api(f"/repos/{owner}/{repo}/issues/{num}/comments?limit=100") or [] + except SystemExit: + return None + for c in reversed(cs): + b = c.get("body") or "" + if BATON_MARK.strip("*") .replace("**", "") in b or BATON_MARK in b: + m = re.search(r"\*\*下一步\*\*[::]\s*(.+)", b) + if m: + return m.group(1).strip()[:100] + return None + + +CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, + "decide": cmd_decide, "subtask": cmd_subtask, "handback": cmd_handback, + "mine": cmd_mine} if __name__ == "__main__": if len(sys.argv) < 2 or sys.argv[1] not in CMDS: -- 2.52.0 From 0b6af15f1a73a71e5ae05383f07977c83dff3c5b Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 12:08:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E8=A3=9C=EF=BC=9A=E5=AF=A6=E6=B8=AC?= =?UTF-8?q?=E6=8E=A2=E9=87=9D=E7=A5=A8=E7=9A=84=E5=8E=BB=E8=99=95=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=20close/*=20=E4=B8=83=E9=A1=9E=E6=B2=92?= =?UTF-8?q?=E6=9C=89=E4=B8=80=E9=A1=9E=E8=A3=9D=E5=BE=97=E4=B8=8B=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- docs/governance/sdd-gitea-governance.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/governance/sdd-gitea-governance.md b/docs/governance/sdd-gitea-governance.md index 8445bb7..6ed08ae 100644 --- a/docs/governance/sdd-gitea-governance.md +++ b/docs/governance/sdd-gitea-governance.md @@ -753,6 +753,12 @@ PATCH /repos/inkstone/ISEP/issues/56 {"state":"closed"} ← 子票還開著 **跨 repo 也成立**(實測 `ISEP#56` 依賴 `arcrun-rag#136` → 201) ⇒ 這正是跨 repo 的載體:milestone 只管得到同一個 repo,**相依管得到全部**。 +📌 探針票留在 `inkstone/ISEP#56`/`#57`(已關,掛 `type/chore`,票上有實測全文)。 + **順帶一個規範缺口**:`close/*` 七類(§5)**沒有一類適合「實測探針」**, + 而 Gitea 的 issue `DELETE` 回 403(只有管理員能刪,已實測) + ⇒ 探針票刪不掉也歸不了類。要嘛補一類 `close/probe`,要嘛規定實測一律開在 + 拋棄式 repo。**這一格本節不裁,留給 §5 的維護者。** + - **R16.2.1** 順序/阻擋關係一律用相依(同 R2.5),**禁止只寫文字**。 - **R16.2.2** 相依掛不上去 = 那張子票對母票是隱形的 ⇒ **視為整個動作失敗**,要當場報錯。 -- 2.52.0 From b46bc5e44c7506322dd6fad9ff51973d3ab91b96 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 12:13:27 +0800 Subject: [PATCH 3/5] =?UTF-8?q?dogfood=20=E6=8A=93=E5=88=B0=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=9A=84=E6=BC=8F=EF=BC=9A=E4=BA=A4=E6=A3=92=E6=A8=99?= =?UTF-8?q?=E8=A8=98=E5=8F=AA=E8=AA=8D=E7=AC=AC=E4=B8=80=E8=A1=8C=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E8=AA=8D=E3=80=8C=E5=85=A7=E6=96=87=E6=8F=90=E9=81=8E?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實跑自己那則交件報告時,閘沒有擋——因為報告裡**引用了一段 ticket mine 的輸出** (那段有 🏃),整則就被豁免掉了,而它裡面真的有「等雲端那半出貨」。 ⇒ 同 inkstone/InkStoneCo#23 那個病:複述關鍵字被當成本人。 ticket handback 寫的交棒留言 🏃 一定在第一行,引用別人的輸出則不會 ⇒ 錨在第一行。 回歸測試 +2(20/20 → 22/22)。 Co-Authored-By: Claude Opus 5 --- docs/TESTING.md | 4 ++-- docs/governance/sdd-gitea-governance.md | 2 +- hooks/comment-carries-task-guard.sh | 14 ++++++++++++-- scripts/test-comment-carries-task-guard.sh | 9 +++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index 42ded67..e784cdb 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -50,11 +50,11 @@ bash scripts/test-ticket-api-bypass-guard.sh **該看到**:`13/13 通過`。 **失敗**:任何一條不符,特別看「不該擋」那 8 條。 -### A11 — 討論串裡的任務要長成子票:20 條 +### A11 — 討論串裡的任務要長成子票:22 條 ``` bash scripts/test-comment-carries-task-guard.sh ``` -**該看到**:`20/20 通過`。 +**該看到**:`22/22 通過`。 **失敗**: - 「該擋」6 條任一紅 ⇒ 08-26 那則真的掉了的留言形狀(「等雲端那半出貨才驗得了」)會漏抓 - 「不該擋」10 條任一紅 ⇒ **誤攔,這比漏擋嚴重**——每次留言都被擋,人就學會忽略它 diff --git a/docs/governance/sdd-gitea-governance.md b/docs/governance/sdd-gitea-governance.md index 6ed08ae..a3a6722 100644 --- a/docs/governance/sdd-gitea-governance.md +++ b/docs/governance/sdd-gitea-governance.md @@ -836,7 +836,7 @@ scripts/ticket mine [--user <誰>] | 閘 | 掛在哪 | 抓什麼 | 測試 | |---|---|---|---| -| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(20 條) | +| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(22 條) | | `hooks/baton-handback-guard.sh` | PostToolUse `Agent\|Task` | 一條線收工,票上**指派/tag/下一步**缺哪一格 → 當場說出來(提醒,不擋) | `scripts/test-baton-handback-guard.sh`(10 條) | 🔴 **兩支閘的誤攔設計**:`comment-carries-task-guard` 同一 session 只擋一次, diff --git a/hooks/comment-carries-task-guard.sh b/hooks/comment-carries-task-guard.sh index 1e1d9c7..e35c584 100755 --- a/hooks/comment-carries-task-guard.sh +++ b/hooks/comment-carries-task-guard.sh @@ -28,6 +28,11 @@ # 所以關鍵詞刻意取**窄**的一組「未完成的未來式」,寧可漏抓也不要天天誤擋—— # `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。** # 漏抓的那些,由 baton-handback-guard.sh(收工那一刻)補第二層。 +# +# 已知極限(誠實標記):這支**認不出「我已經開好子票了」**——它只看得到指令文字。 +# 所以交件報告裡引用自己剛開的子票時仍會被擋一次;那一次用 `no-subtask:` 過, +# 或就當它是一次確認。刻意不去打 API 查「這張票有沒有相依」:那會讓每一個 +# 命中關鍵詞的 Bash 呼叫都多一次網路往返,代價落在錯的地方。 set -uo pipefail INPUT=$(cat) @@ -66,8 +71,13 @@ if [ -n "${FARG:-}" ] && [ -f "$FARG" ]; then BODY=$(cat "$FARG" 2>/dev/null || printf '%s' "$CMD") fi -# 已經帶著交棒標記的留言 = 走的是 handback 那條路,不重複擋 -case "$BODY" in *"🏃"*) exit 0 ;; esac +# 已經帶著交棒標記的留言 = 走的是 handback 那條路,不重複擋。 +# 🔴 只認**第一行**,不認「內文任何地方出現過」——這是 dogfood 當場抓到的漏(2026-08-27): +# 交件報告裡**引用了一段 `ticket mine` 的輸出**(那段有 🏃), +# 整則報告就被豁免掉了,而它裡面真的有「等雲端那半出貨」這種待辦。 +# ⇒ 這正是 `inkstone/InkStoneCo#23` 那個病的同款:**複述關鍵字被當成本人**。 +# `ticket handback` 寫的交棒留言,🏃 一定在第一行;引用別人的輸出則不會。 +case "$(printf '%s' "$BODY" | head -1)" in "🏃"*) exit 0 ;; esac # ③ 「未完成的未來式」——刻意取窄。這一組全部來自 08-26 那則真的掉了的留言的形狀。 # diff --git a/scripts/test-comment-carries-task-guard.sh b/scripts/test-comment-carries-task-guard.sh index 5ff01b1..ba92f94 100755 --- a/scripts/test-comment-carries-task-guard.sh +++ b/scripts/test-comment-carries-task-guard.sh @@ -37,6 +37,15 @@ run 0 'echo "這則留言等雲端出貨才驗得了 —— no-subtask: 它已 run 0 'curl -X POST "$API/repos/x/y/issues" -d "{\"title\":\"等出貨\"}"' # 開新票不歸這支管 run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})' +echo "── 交棒標記只認第一行(2026-08-27 dogfood 抓到的漏)──" +# 真的交棒留言:🏃 在第一行 ⇒ 放行 +run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})' +# 交件報告**引用**了一段有 🏃 的輸出,而報告本身帶著待辦 ⇒ 不准被豁免 +TMPR=$(mktemp) +printf '交件報告\n\n```\n🏃 在總管手上的棒子:2 根\n```\n\n還有一格等雲端那半出貨才驗得了\n' > "$TMPR" +run 2 "scripts/ticket say inkstone/ISEP#30 -F $TMPR" +rm -f "$TMPR" + echo "── 同一 session 只擋一次(不鬼打牆)──" rm -f "/tmp/.comment-task-guard-warned-$SID" C='req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"驗不了"})' -- 2.52.0 From 9d20ef925eec91cecc1c8fce70e3f0d362622e09 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 12:15:20 +0800 Subject: [PATCH 4/5] =?UTF-8?q?subtask=20=E6=8C=87=E6=B4=BE=E4=BA=86?= =?UTF-8?q?=E4=BA=BA=E5=B0=B1=E4=B8=80=E5=AE=9A=E8=A6=81=E5=AF=AB=E4=B8=8B?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E2=80=94=E2=80=94=E4=B8=8D=E7=84=B6=E6=96=B0?= =?UTF-8?q?=E5=AD=90=E7=A5=A8=E4=B8=80=E9=96=8B=E5=87=BA=E4=BE=86=E5=B0=B1?= =?UTF-8?q?=E6=98=AF=E3=80=8C=E6=A3=92=E5=AD=90=E6=B2=92=E6=9C=89=E4=B8=8B?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實跑 ticket mine 時自己抓到:剛用 subtask 開的 #143 顯示 「❌ 沒有人寫下一步」——工具自己造出了它要消滅的那個狀態。 Co-Authored-By: Claude Opus 5 --- docs/governance/sdd-gitea-governance.md | 4 +++- scripts/ticket | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/governance/sdd-gitea-governance.md b/docs/governance/sdd-gitea-governance.md index a3a6722..198596e 100644 --- a/docs/governance/sdd-gitea-governance.md +++ b/docs/governance/sdd-gitea-governance.md @@ -826,8 +826,10 @@ PATCH /repos/inkstone/ISEP/issues/56 {"state":"closed"} ← 子票還開著 ### 16.7 工具與機械閘 ``` -scripts/ticket subtask <母票> --title -F <檔> [--label] [--assign] +scripts/ticket subtask <母票> --title -F <檔> [--label] [--assign X --next "<一句>"] 討論串裡的一件事 → 子票 + 回鏈 Parent: + Gitea 原生相依(三格一次到位) + 🔴 `--assign` 一定要配 `--next`:有人被指到票上,他撈到它的第一件事就是問 + 「所以我要做什麼」——那句話開票當下就要寫,不是等他重讀整串 scripts/ticket handback <票> --to --next "<一句話>" [--label] [--evidence] 收工 = 指派 + 改 tag + 寫下一步,一個動作三件事 scripts/ticket mine [--user <誰>] diff --git a/scripts/ticket b/scripts/ticket index bd40252..bc67b41 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -409,7 +409,8 @@ def cmd_subtask(argv): """ if len(argv) < 1: die("用法:ticket subtask <母票 owner/repo#N> --title \"\" -F <內文檔>\n" - " [--repo <收件 repo,預設跟母票同一個>] [--assign <誰做>] [--label ]") + " [--repo <收件 repo,預設跟母票同一個>] [--label ]\n" + " [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步") powner, prepo, pnum = parse_ref(argv[0]) def opt(name, default=None): @@ -432,6 +433,12 @@ def cmd_subtask(argv): repo = opt("--repo", prepo) label = opt("--label", "s/todo") assign = opt("--assign") + nxt = opt("--next") + if assign and not nxt: + die("🚫 指派了人卻沒寫 --next。\n" + " **有人被指到這張票,他撈到它的第一件事就是問「所以我要做什麼」**——\n" + " 那句話現在就要寫下來,不是等他重讀整串。\n" + " (沒有要指派給誰就不要加 --assign,那張票會留在票池等人領。)") body = (f"Parent: {powner}/{prepo}#{pnum}\n" f"> 這張票是從母票的討論串裡長出來的一件事。\n" @@ -445,7 +452,11 @@ def cmd_subtask(argv): print(f" tag:{label}(它卡在哪一段)") if assign: api(f"/repos/{ORG}/{repo}/issues/{cnum}", {"assignees": [assign]}, method="PATCH") + api(f"/repos/{ORG}/{repo}/issues/{cnum}/comments", + {"body": f"{BATON_MARK} → `{assign}`\n\n**下一步**:{nxt}\n\n" + f"(這張票是 `{powner}/{prepo}#{pnum}` 的相依——它沒關,母票關不掉)"}) print(f" 指派:{assign}(誰該動)") + print(f" 下一步:{nxt}") # 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到 api(f"/repos/{powner}/{prepo}/issues/{pnum}/dependencies", -- 2.52.0 From 770a0ee824af386ee38a93fee80263f016d3388e Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 12:38:31 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=87=AA=E9=80=A0?= =?UTF-8?q?=E7=9A=84=E5=BE=85=E9=A9=97=E5=96=AE=E6=A9=9F=E5=88=B6=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=B1=20Gitea=20=E5=8E=9F=E7=94=9F=E4=B8=89?= =?UTF-8?q?=E6=A0=BC=E6=89=BF=E6=8E=A5=EF=BC=88inkstone/ISEP#60=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subagent-claim-worksheet.sh(SubagentStop 產待驗單)與 claim-verify-police.sh (Stop 攔收工)這一套整組移除。它們守的東西違反 D58(不要硬做平台不支援的機制) ——「待驗單」只是一張沒有狀態、沒有持有人的 markdown,必然退化成雜訊。 改由 inkstone/ISEP#59/PR #58 的三個 Gitea 原生欄位承接同樣的情境: 子票相依(存在嗎/做完了嗎)、s/* tag(卡在哪)、指派(誰該動)。 - hooks/hooks.json:移除 Stop/SubagentStop 兩條註冊 - hooks/lib/path-resolve.sh:拿掉已刪檔案的註解引用 - docs/hooks-inventory.md、README.md:更新閘數量(48→46 檔、59→57 條註冊) - docs/governance/sdd-gitea-governance.md:E12/E14 標記現況,新增 §8.4 說明 移交對象;PR #58 未 merge 前這兩條實質仍是待建,誠實標記 - .claude-plugin/plugin.json:0.5.0 → 0.6.0(版本沒動=plugin update 是 no-op) 未動 docs/governance/DIVERGENCE-v0.5.0-to-v0.6.0.md——那是 2026-08-20 的 時間點快照(意見書),修改它等於竄改歷史記錄,不在本票範圍。 未動 InkStoneCo/.claude/pending-verification/{done,done-20260826,verified}/ 三個歸檔目錄——它們是 InkStoneCo repo 裡的已提交檔案,跨 repo 且需要 InkStoneCo 自己的 git 流程處理,超出本票(ISEP repo)範圍,留給總管判斷。 測試:scripts/test-*.sh 全數 6 支通過;hooks/tests/*.test.sh 與 pristine origin/main 基準比對,失敗特徵完全一致(環境既有問題,非本次改動引入); claude plugin validate . 通過。check-version-consistency.sh 目前會報不一致 (0.6.0 vs tag v0.5.0)——這是預期的,等總管收斂 release 打 v0.6.0 tag 時解決。 Co-Authored-By: Claude Sonnet 5 --- .claude-plugin/plugin.json | 4 +- README.md | 2 +- docs/governance/sdd-gitea-governance.md | 19 ++- docs/hooks-inventory.md | 19 ++- hooks/claim-verify-police.sh | 82 ---------- hooks/hooks.json | 16 -- hooks/lib/path-resolve.sh | 6 +- hooks/subagent-claim-worksheet.sh | 196 ------------------------ 8 files changed, 37 insertions(+), 307 deletions(-) delete mode 100755 hooks/claim-verify-police.sh delete mode 100755 hooks/subagent-claim-worksheet.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e4eea3a..a2e9673 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.5.0", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(57 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "version": "0.6.0", "keywords": [ "inkstone", "guardrails", diff --git a/README.md b/README.md index ee275f4..49e1fff 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | | 數量 | 是什麼 | |---|---|---| -| `hooks/` | 48 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 59 條註冊) | +| `hooks/` | 46 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 57 條註冊;`ISEP#60` 移除 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59`) | | `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | | `skills/` | 2 支 | | | `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … | diff --git a/docs/governance/sdd-gitea-governance.md b/docs/governance/sdd-gitea-governance.md index b72cb39..766bd44 100644 --- a/docs/governance/sdd-gitea-governance.md +++ b/docs/governance/sdd-gitea-governance.md @@ -274,9 +274,9 @@ s/triage ──驗傷通過──▶ s/backlog ──排進 milestone──▶ s | E9 | 留言變成推理長文 | hook:超長**不 reject**,要求移去 wiki 再留指標 | ❌ 待建(放寬版,見 §8.3) | | E10 | 就地修改治理檔案 | 安裝目錄唯讀 + hook,導向 ISEP 開 issue | ❌ 待建 | | E11 | 本機雲端版本漂移 | SessionStart hook 比對版本,不一致 fail-fast | ◐ 現有 `skill-deploy-drift-guard.sh` 管的是 skill 不是 plugin 版本 | -| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` = tag = release 存在;且 milestone open issues > 0 不准打 tag | ◐ 現有 `delivery-police.sh`/`claim-verify-police.sh` 部分覆蓋 | +| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` = tag = release 存在;且 milestone open issues > 0 不准打 tag | ◐ 現有 `delivery-police.sh` 部分覆蓋(`claim-verify-police.sh` 已於 `ISEP#60` 移除,見下方腳註) | | E13 | 審核被遺忘 | Stop hook:**我自己開的** `s/review` 存在且**這回合完全沒碰它** → 擋一次 | ◐ 現有 `empty-handed-stop-guard.sh` 判準不同,兩者要合併不要疊加 | -| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ✅ **已有** `subagent-claim-worksheet.sh` + `empty-handed-stop-guard.sh` | +| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ◐ 原本 `subagent-claim-worksheet.sh` + `empty-handed-stop-guard.sh`;前者已於 `ISEP#60`(2026-08-27)移除,見下方腳註 | | E15 | 代理硬做只有人能做的事 | 代理端無對應 tool 或 token;唯一出口是開 `human/exec` 票 | ◐ 部分(credential 機制已擋一部分,D36) | | E16 | 標籤漂移 | 走 Gitea labels API 依 `labels.yaml` 校正:缺的補、改的還原、多的**只告警不刪** | ❌ 待建(`ISEP#4`) | @@ -312,6 +312,21 @@ leo 2026-08-17 實測:文字層的閘那天 **8 次誤攔、0 次正確攔截* **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。** 所以 E9 擋的是「長內容放錯地方」這個**動作**,處置是**導引**(移去 wiki 留指標),不是 reject。 +### 8.4 E12/E14:自造的「待驗單」機制已移除,改由 §16 原生三格承接(`ISEP#60`) + +`subagent-claim-worksheet.sh`(SubagentStop 產待驗單)與 `claim-verify-police.sh` +(Stop 攔收工,待驗單還在就擋)這一對於 2026-08-27 整組移除。 + +- **為什麼不是修它**:症狀是同一份待驗單重生三次、同內容兩個檔名; + 但真正的問題是 D58——**待驗單是一張 markdown,沒有狀態、沒有持有人、沒有人會回頭看**, + 必然退化成雜訊。Gitea 的 issue 三格本來就是為這件事做的。 +- **它守的東西現在由誰守**:見 §16(`ISEP#59`/PR #58)——子票相依回答「這件事存在嗎/做完了嗎」、 + `s/*` tag 回答「它卡在哪一段」、指派回答「現在誰該動」。三格合起來覆蓋原本 E14 + 想擋的「subagent 空口宣稱」與 E12 想擋的「宣稱交付但沒人跟進」。 +- **現況誠實標記**:PR #58(§16 的落地)**尚未 merge**,所以 E12/E14 這一格目前是 + 「舊機制已拆、新機制已提案但未生效」的過渡態,不是「已有替代品在跑」。 + #58 merge 前,這兩條暫時回到 ❌ 待建的實質狀態,即使表格欄位寫的是 ◐。 + --- ## 9. 本規範的迭代 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 23695ea..b352cd5 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -16,6 +16,11 @@ > 現在的寫法是實際數出來的: > `ls hooks/*.sh | wc -l` = 46;`hooks.json` 展開後的 command 條目 = 56。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 +> +> 📌 **`inkstone/ISEP#60`(2026-08-27)之後這兩個數字各再減 2**:移除了 +> `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制。 +> 本票只處理這兩支的落差,**不在本票範圍內重新對帳其餘既有落差**—— +> 要重新核實整份,跑下面「這份表怎麼跟實況對帳」段落的指令。 --- @@ -111,15 +116,17 @@ | `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 | | `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 | | `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 | -| `claim-verify-police.sh` | Subagent 交回來的「我做完了」宣稱還沒被驗證過(對應的待驗檔案還在),你這邊卻想收工,就擋下。 | 🛑 擋 | + +> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。 +> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58: +> 不要硬做平台不支援的機制。它守的情境現在改由 Gitea 原生三格承接 +> (子票相依/`s/*` tag/指派——見 `inkstone/ISEP#59` 與 PR #58)。 ## G. Subagent 把工作交回來的時候(SubagentStop) -| 閘名 | 對你意味著什麼 | 動作 | -|---|---|---| -| `subagent-claim-worksheet.sh` | Subagent 一交回工作,就自動把它宣稱做了什麼寫成一張「待驗清單」檔案——之後總管收工前,`claim-verify-police.sh` 會檢查這張清單有沒有被處理掉。 | 📝 記錄 | - -> 這個時機還掛了 `worklist-guard.sh`/`self-drive-police.sh`/`self-drive-judge.sh`/`delivery-police.sh`/`wiki-first-police.sh`/`unpushed-police.sh`,行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。 +`inkstone/ISEP#60` 之後,這個時機**沒有專屬掛載的閘**了(原本唯一的一支 `subagent-claim-worksheet.sh` 已移除)。 +仍會掛到這個時機的是 `worklist-guard.sh`/`self-drive-police.sh`/`self-drive-judge.sh`/`delivery-police.sh`/`wiki-first-police.sh`/`unpushed-police.sh`, +行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。 ## H. 動作做完之後,純粹記一筆(PostToolUse,全部不擋) diff --git a/hooks/claim-verify-police.sh b/hooks/claim-verify-police.sh deleted file mode 100755 index 1db3cbe..0000000 --- a/hooks/claim-verify-police.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# claim-verify-police.sh — 待驗工作單還在,就不准收工(Stop;leo 2026-08-18 立) -# -# 這支是 subagent-claim-worksheet.sh 的另一半。 -# 那支在 SubagentStop 時把 subagent 的宣稱寫成一張檔案;這支在我要停下時檢查: -# -# **那張檔還在不在?** -# -# ── 為什麼判準是「檔案存不存在」 ──────────────────────────────────── -# leo 2026-08-17:「你在**文字層**封路⋯⋯自然語言的變體是無限的,blacklist 永遠追不完。 -# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」 -# ⇒ 「我驗過了」這句話可以有無限種寫法,但**檔案只能被動作刪掉**。 -# 所以這支不讀我的措辭,只看磁碟。 -# -# ── 怎麼過這道閘(都是動作,都留痕)───────────────────────────────── -# ① 真的驗了 → 把證據寫進回覆/票,然後刪掉那個檔 -# ② 有幾條不需要驗 → 在檔裡寫下理由,再刪掉(理由留在 git 或票上) -# ③ 是人閘 → 走三件機械動作(Human + 指派 + 它自己的票),再刪掉 -# -# 🔴 **不准為了過閘而直接 rm 掉卻什麼都沒做。** 那等於自己拆掉自己的閘—— -# 而這正是 leo 2026-08-10 說的「規則早就存在,但沒有任何機制驗證有沒有照做」。 -# -# ── 迴圈安全 ──────────────────────────────────────────────────── -# stop_hook_active 為真 ⇒ 已經擋過一次,放行。至多提醒一次,不卡死。 -# -# ── 留痕 ──────────────────────────────────────────────────────── -INPUT=$(cat) -DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification" -LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/claim-verify-police.log" - -ACTIVE=$(printf '%s' "$INPUT" | python3 -c ' -import json,sys -try: print("1" if json.load(sys.stdin).get("stop_hook_active") else "0") -except Exception: print("0") -' 2>/dev/null) - -if [ "$ACTIVE" = "1" ]; then - printf '%s\tSKIP:already-nudged\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null - exit 0 -fi - -# 只算「今天以內」的單子——放太久的(跨 session 殘留)不該無限期卡住新工作, -# 但仍會被列出來提醒。 -FILES=$(ls -1 "$DIR"/claims-*.md 2>/dev/null) -if [ -z "$FILES" ]; then - printf '%s\tSKIP:no-pending\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null - exit 0 -fi - -N=$(printf '%s\n' "$FILES" | grep -c . ) -printf '%s\tBLOCK:%s-pending\n' "$(date +%FT%T)" "$N" >> "$LOG" 2>/dev/null - -{ - echo "🔍 查證警察:有 $N 張**待驗工作單**還沒處理掉,不准收工。" - echo - echo "【leo 2026-08-18 的規格】" - echo " 「subagent 做完事⋯⋯**它說可以,你去驗證;它說不行,你想辦法,不行再報告**," - echo " 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報," - echo " **它視野小,它說的你有更多資訊去檢驗。**」" - echo - echo "━━ 還沒處理的單子 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - printf '%s\n' "$FILES" | while read -r f; do - [ -n "$f" ] || continue - OKN=$(grep -c '^- \[ \]' "$f" 2>/dev/null) - echo " · ${f#"$CLAUDE_PROJECT_DIR"/} ($OKN 條待處理)" - done - echo - echo "━━ 怎麼過這道閘(三選一,都是動作)━━━━━━━━━━━━━━━━━━" - echo " ① **它說可以** → 你自己跑一次(測試/curl/瀏覽器/git),把證據貼出來,再刪掉那個檔" - echo " ② **它說不行** → 先換路徑、查憑證地圖;要說「被擋」就得**自己撞一次並貼拒絕原文**" - echo " ③ **真的是人閘** → 三件機械動作:\`Human\` 標籤 + 指派給 Leo + **一張它自己的票**" - echo " (leo 08-18:「要進入人閘,標示 human 叫我去開,**不是默默塞進錯的地方**」)" - echo - echo "🔴 **不准只是 rm 掉了事。** 那是自己拆掉自己的閘——" - echo " 正是 leo 08-10 講的「規則早就存在,但沒有任何機制驗證有沒有照做」。" - echo - echo "📌 為什麼有這道閘:2026-08-18 總管連續三次把**沒驗過的判斷**當成情報交出去," - echo " 其中第二次還是為了「更正」第一次而發明的新猜測。" - echo " leo:「你給我不正確的資訊⋯⋯subagent 說不通你沒有驗。」" -} >&2 - -exit 2 diff --git a/hooks/hooks.json b/hooks/hooks.json index feded4a..3ac930b 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -267,25 +267,9 @@ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh" } ] - }, - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/claim-verify-police.sh" - } - ] } ], "SubagentStop": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-claim-worksheet.sh" - } - ] - }, { "hooks": [ { diff --git a/hooks/lib/path-resolve.sh b/hooks/lib/path-resolve.sh index ee5f13a..a19663d 100644 --- a/hooks/lib/path-resolve.sh +++ b/hooks/lib/path-resolve.sh @@ -16,8 +16,10 @@ # history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、 # not-my-branch-guard.sh、release-tag-guard.sh、skill-deploy-drift-guard.sh、 # stage-before-prod-guard.sh、unpushed-police.sh、wiki-first-police.sh。 -# 另有 claim-verify-police.sh、subagent-claim-worksheet.sh、empty-handed-stop-guard.sh、 -# issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`(無 `:-` fallback)—— +# 另有 empty-handed-stop-guard.sh、issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR` +# (無 `:-` fallback)—— +# (claim-verify-police.sh/subagent-claim-worksheet.sh 已於 ISEP#60 整組移除, +# 它們守的東西改由 Gitea 原生三格承接,見 ISEP#59/PR #58) # 這批在該變數未設時行為又不一樣,同一個病的另一種長相。 # 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地, # 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。 diff --git a/hooks/subagent-claim-worksheet.sh b/hooks/subagent-claim-worksheet.sh deleted file mode 100755 index 6d3cfe4..0000000 --- a/hooks/subagent-claim-worksheet.sh +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env bash -# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單 -# (SubagentStop;leo 2026-08-18 立) -# -# ── leo 的原話(本閘的規格)───────────────────────────────────────── -# 「你如何確認說的都是真的?subagent 做完事,你應該在 SubagentStop 掛動作, -# **它說可以,你去驗證;它說不行,你想辦法,不行再報告**, -# 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報, -# **它視野小,它說的你有更多資訊去檢驗**。」 -# -# ── 為什麼是「工作單檔案」而不是提醒文字 ───────────────────────────── -# leo 2026-08-17 診斷過:文字層封路是打不贏的軍火競賽,要封**動作**。 -# 所以這支不勸、也不偵測我的措辭——它**在磁碟上放一個必須被處理掉的東西**。 -# 配套的 Stop 閘(claim-verify-police.sh)看的是「那個檔還在不在」, -# 而檔案只能被動作消滅,不能被文案消滅。 -# -# ── 兩類宣稱,處理方式不同(leo 的規格就是這兩條)────────────────── -# ✅ 它說可以 → 我去驗證(我有跨 repo 視野、有 token、有瀏覽器,它沒有) -# ⛔ 它說不行 → 我想辦法(換路徑/查憑證地圖/自己撞一次),真的不行才報告 leo -# -# ⚠️ 這裡的關鍵詞比對只用來**列出候選**,不用來判定對錯—— -# 列多了我自己劃掉(要寫理由),列少了不影響我另外查。 -# ⇒ 它的失敗模式是「多列幾條」,不是「擋錯事」。 -# -# ── 留痕(InkStoneCo#48:36 支閘只有 2 支記錄自己做了什麼)───────── -INPUT=$(cat) -DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification" -LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-claim-worksheet.log" -mkdir -p "$DIR" 2>/dev/null - -OUT=$(printf '%s' "$INPUT" | DIR="$DIR" python3 -c ' -import json, os, re, sys, hashlib - -try: - d = json.load(sys.stdin) -except Exception: - print("SKIP:bad-json"); raise SystemExit - -tp = d.get("transcript_path") or "" -if not tp or not os.path.exists(tp): - print("SKIP:no-transcript"); raise SystemExit - -# ── 取 subagent 的交件 ──────────────────────────────────────────── -# 🔴 2026-08-18 首次實跑就抓到自己的缺陷:`SubagentStop` 給的 `transcript_path` -# 指向**主對話**,不是子代理的紀錄 ⇒ 原本「取最後一則 assistant 訊息」 -# 抓到的是**總管自己寫的句子**,這支 hook 當場退化成它要避免的「文字層自咬」。 -# -# 正解:子代理的交件是以 `` 的形式 -# 進到主對話的 **user 訊息**裡。只認那個區塊,其餘一律不看。 -# ⇒ 判準回到「這段話是誰說的」,而不是「這段話長什麼樣」。 -last = "" -try: - blob = [] - with open(tp, "r", encoding="utf-8", errors="replace") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - ev = json.loads(line) - except Exception: - continue - msg = ev.get("message") or {} - if msg.get("role") != "user": - continue - parts = msg.get("content") - if isinstance(parts, str): - txt = parts - elif isinstance(parts, list): - txt = "".join(p.get("text", "") for p in parts - if isinstance(p, dict) and p.get("type") == "text") - else: - txt = "" - if "" in txt: - blob.append(txt) - if blob: - m = re.search(r"(.*?)", blob[-1], re.S) - last = m.group(1) if m else "" -except Exception: - print("SKIP:read-fail"); raise SystemExit - -if not last.strip(): - print("SKIP:no-subagent-report"); raise SystemExit - -if not last.strip(): - print("SKIP:empty-report"); raise SystemExit - -# ── 抽候選宣稱 ────────────────────────────────────────────────── -# 成功類:它說某件事成立/做好了 ⇒ 我去驗 -OK = ("驗過", "測過", "通過", "全綠", "已部署", "部署完成", "可以用", "成立", - "完成", "修好", "已推", "已併", "沒問題", "正常", "一致", "對上", - "✅", "green", "passed", "verified", "deployed") -# 阻擋類:它說做不到 ⇒ 我想辦法,不行才報告 leo -NG = ("無法", "不行", "做不到", "被擋", "擋下", "失敗", "不存在", "找不到", - "沒有權限", "權限不足", "需要 leo", "要 leo", "請總管", "交給你", - "❌", "blocked", "denied", "cannot", "failed", "not found") - -def lines_of(text): - out = [] - for raw in text.split("\n"): - s = raw.strip().lstrip("-*#>| ").strip() - if len(s) < 8 or len(s) > 220: - continue - out.append(s) - return out - -ls = lines_of(last) -ok_hits, ng_hits = [], [] -for s in ls: - low = s.lower() - if any(k in s or k.lower() in low for k in NG): - ng_hits.append(s) - elif any(k in s or k.lower() in low for k in OK): - ok_hits.append(s) - -# 兩類都沒有 ⇒ 它交的是純資料/純提問,沒有事實宣稱要驗 -if not ok_hits and not ng_hits: - print("SKIP:no-claims"); raise SystemExit - -sid = (d.get("session_id") or "nosid")[:8] -aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10] -# 🔴 2026-08-21:stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名, -# 而且不知道總管已經驗過了。實際發作:同兩條 sdd-guard 宣稱連生四張單 -# (1c97d461/fcb285dc/256de849/394b97ae),驗掉一張下一回合又冒一張。 -# ⇒ 改成雜湊**宣稱內容本身**:同樣的宣稱=同一個檔名 ⇒ 驗過就不再冒出來。 -_claims = sorted(set(str(x) for x in (ok_hits + ng_hits))) -# 🔴 空清單就退回舊行為 —— 但那等於這支閘沒抓到任何宣稱,本來就會 SKIP, -# 所以這個 fallback 實際上不會被用到;留著只是不讓 stamp 變成空字串的雜湊。 -_basis = "\n".join(_claims) if _claims else (tp + sid + aid) -stamp = hashlib.sha1(_basis.encode()).hexdigest()[:8] -_name = "claims-%s-%s.md" % (sid, stamp) -path = os.path.join(os.environ["DIR"], _name) - -# 已經驗過並移進 verified/ 的,不要再生一次。 -if os.path.exists(os.path.join(os.environ["DIR"], "verified", _name)): - print("SKIP:already-verified"); raise SystemExit - -def block(title, items, howto, cap): - if not items: - return "" - body = "\n".join("- [ ] %s" % x for x in items[:cap]) - more = "" - if len(items) > cap: - more = "\n- [ ] (還有 %d 條同類,讀原始交件)" % (len(items) - cap) - return "## %s\n\n%s\n%s%s\n\n" % (title, howto, body, more) - -doc = [] -doc.append("# 待驗工作單(subagent 交回的宣稱)\n") -doc.append("> leo 2026-08-18:「**它視野小,它說的你有更多資訊去檢驗。**」\n") -doc.append("> 交件全文在:`%s`\n" % tp) -doc.append("\n🔴 **這張單子只能用動作消滅,不能用文字消滅。**") -doc.append("逐條處理完之後,把本檔**刪掉或移走**;在那之前 Stop 閘會擋。\n\n") -doc.append(block( - "✅ 它說「可以」——我要自己驗一次", - ok_hits, - "判準:**我不採信,我自己跑一次**。它只看得到自己那個 repo 與自己那次呼叫;\n" - "我有跨 repo 視野、有 token、有瀏覽器。驗完把證據貼進回覆或票裡。\n", - 12)) -doc.append(block( - "⛔ 它說「不行」——我先想辦法,真的不行才報告 leo", - ng_hits, - "判準:**它的「不存在/被擋」往往只是「我這裡看不到」**。\n" - "先做三件:① 查憑證地圖/wiki ② 換一條路徑 ③ **自己真的撞一次並貼出拒絕原文**。\n" - "🔴 沒撞過就不准說「我被 X 擋住」——那是虛構的閘(2026-08-18 已第五次)。\n" - "真的是人閘 ⇒ 走三件機械動作:`Human` 標籤 + 指派給 Leo + **一張它自己的票**,\n" - "不是在別張票的留言裡寫一段話(leo 08-18:「不是默默塞進錯的地方」)。\n", - 12)) - -with open(path, "w", encoding="utf-8") as f: - f.write("".join(doc)) - -print("WROTE:%s:ok=%d:ng=%d" % (os.path.basename(path), len(ok_hits), len(ng_hits))) -' 2>/dev/null) - -printf '%s\t%s\n' "$(date +%FT%T)" "${OUT:-SKIP:no-output}" >> "$LOG" 2>/dev/null - -case "$OUT" in - WROTE:*) - F=$(printf '%s' "$OUT" | cut -d: -f2) - N=$(printf '%s' "$OUT" | sed 's/.*ok=\([0-9]*\):ng=\([0-9]*\)/\1 成功宣稱、\2 阻擋宣稱/') - cat <