Compare commits

...

15 Commits

Author SHA1 Message Date
Leo fa2f76177e docs(inventory): 反向那半補上真正驗得到幽靈的那一格(總管複驗退回)
總管複驗指出反向 comm 有 3 個輸出。實跑重現了,但逐個查過之後:
**三個都不是幽靈,三個都不該刪。**

  claim-verify-police.sh / subagent-claim-worksheet.sh
    → 全部 5 處都在內文,講的是「已於 ISEP#60 移除」。表格列(第一欄)
      命中數 = 0。刪掉它們等於刪掉「G 組為什麼是空的」這段歷史。
  github-arm.sh
    → 它存在,在 scripts/ 不在 hooks/;出現在 github-contact-guard 那一列的
      描述裡,是告訴人「怎麼解鎖」的逃生口。刪掉那一列就不再說得出怎麼過閘。

成因是那組 grep 拿掉了 `^\| \`` 錨點。有錨點=「這一行的第一欄就是這支閘」=
表格真的列了它;沒錨點=全頁所有反引號。**0 次正確攔截、3 次誤攔。**

但「頁面上出現根本不存在的檔名」確實該被抓到,只是不能靠文字判斷。
本次補上第三格 C:頁面提到的每個 *.sh,要嘛現在存在,要嘛 git 歷史裡存在過。
**「現在沒有但曾經有」=歷史;「從來沒有過」=幽靈。** 它問的是 git 不是用字。

順帶:標頭區那個 43 是 2026-08-20 快照段落裡的歷史數字,不是現況宣稱,
但原文用現在式讀起來像現況 ⇒ 加上「這一段每個數字都是那天的」標記。

實測(含兩個反向對照,證明新那格不是橡皮圖章):
  現況              A1[] A2[] C[]                48 檔 / 48 列
  表格種一支假閘    A2 與 C 都叫
  散文種一支假閘    A1/A2 抓不到、C 抓到          ← 只有 C 守得住這種
18 個測試檔 0 失敗;claude plugin validate 通過。

inkstone/ISEP#75(總管複驗 comment 4822)
2026-08-27 18:35:49 +08:00
Leo a671948fa5 docs(inventory): 補回表格漏掉的兩列人話,並補上驗得出這種漏的那組指令
標頭寫 48 支,下面的表格只列得出 46 支——缺的是
`isep-presence-beacon.sh`(E 組 SessionStart)與
`milestone-due-guard.sh`(A 組 PreToolUse/Bash)這兩列。
不是本次合併造成的,`origin/main` 上數同一個指令也是這兩支。

為什麼這個漏會活這麼久:本頁「落差偵測」只有一組 comm,比的是
`hooks.json`(驗有沒有註冊),驗不了「有沒有寫進這張人話表」。
所以本次同時補上第二組 comm(fs 檔名 vs 表格列出的閘名),
並把「怎麼跟實況對帳」第 1 條指到那一組。

順手修掉同一頁另外兩處自己會騙人的地方:
- 標頭的複驗指令 `grep -c '"command"'` 會連 `"type": "command"` 一起數,
  實跑回 118 不是 59;正確寫法要帶冒號。
- 「其餘 41 支」是 43 支閘那一版數的。改成拿掉數字而不是改成 46——
  改成 46 等於宣稱逐支重讀過源碼,而這次沒有。

版本 0.6.0 → 0.6.1(v0.6.0 已出 release,不升版沒有人吃得到這頁)。

實測:48 檔 vs 48 列,兩個方向的 comm 都空;
18 個測試檔全綠(0 失敗);claude plugin validate 通過。

inkstone/ISEP#59(comment 4779 第二件)
2026-08-27 18:24:20 +08:00
claude-code 7795cd705a Merge pull request '第二波:棒子三格+移除自造待驗單,版本收斂 0.6.0(inkstone/ISEP#58 #62)' (#74) from integrate/isep59-wave2 into main 2026-08-27 10:13:17 +00:00
Leo 577c736b74 merge: 移除自造的待驗單機制,改由 Gitea 原生三格承接(inkstone/ISEP#62)
順序刻意是先 #58 後 #62:新機制(comment-carries-task-guard/baton-handback-guard/
scripts/ticket 的 subtask)先到位,舊機制才拆,main 上不存在「舊的拆了、新的沒到」的真空。

衝突只有 .claude-plugin/plugin.json:
- version 依 inkstone/ISEP#59 comment 4763 的裁決收斂成 0.6.0(四張 PR 各自宣告的作廢)
- description 的數字不採信任何一邊,改成併完後當場數出來的 48 支/59 條

一併把 docs/hooks-inventory.md 與 README.md 的同一組數字改成實數結果
(原本各寫 58/57,都是用加減兜出來的)。
2026-08-27 18:02:24 +08:00
Leo 7e1b762cbe merge: 棒子不會掉——子票相依+tag+指派三格全用 Gitea 原生欄位(inkstone/ISEP#58)
衝突只有 docs/hooks-inventory.md 的 A 組表格,解法照 ISEP#59 comment 4763 指定的
「兩列都留」:保留 main 上 #73 更新過的 ticket-api-bypass 敘述與 reply-identity-guard,
再加上本分支新增的 comment-carries-task-guard。標頭數字留到 #62 併完後一次實數。
2026-08-27 17:56:11 +08:00
claude-code e32957e72b Merge pull request '封「新增 Gitea 東西」的每一道側門,不是只有開票(closes inkstone/ISEP#72)' (#73) from fix/gitea-write-search-guard into main 2026-08-27 09:50:21 +00:00
claude-code e7e60fff89 Merge pull request 'dispatch-format-guard 修兩處:合格範例被自己擋、禮貌收尾不該算違規(closes #65)' (#70) from fix/dispatch-guard-parens-and-wiring into main 2026-08-27 09:49:13 +00:00
Leo 23e4472715 封「新增 Gitea 東西」的每一道側門,不是只有開票(inkstone/ISEP#72)
leo 2026-08-27:「開票不查是否有現成的,這是什麼問題?」「不只開票前,
所有新增 gitea 的東西,都要搜尋」。

v1 的 ticket-api-bypass-guard 只認裸字 `grep -qw POST`(大小寫敏感),
只管 issues 端點。2026-08-27 總管一天開 13 張票,v1 一次都沒攔到——
用的是 `urllib.request.Request(url, data=...)`(靠傳 data= 隱式變 POST,
指令裡從頭到尾沒有 "POST" 三個字)和 `requests.post(...)`(小寫)。

本次改動:
- 寫入訊號從單一裸字換成一組結構性訊號(curl 資料類旗標/`.post(`/
  `method=post`/urllib 隱式 POST),且大小寫不敏感,同時保留 v1 的裸字比對
- 端點擴大到 milestones/labels/pulls(含 org 層級的 labels),不再只管 issues
- 用「resource/數字」特徵先放行帶 ID 的既有資源子路徑(貼留言、改標籤、
  合併 PR 等),避免貪婪比對把 `/issues/5/labels` 誤判成頂層 `/labels` 端點
- 擋下 issues 新增時,順手用標題猜關鍵字查一次跨 repo 搜尋,把可能撞到的
  舊票(例如 Arcrun#100)直接列進擋下的訊息裡

測試:scripts/test-ticket-api-bypass-guard.sh 24/24 通過(v1 的 13 條 + 本次
新增 11 條,含一條打真實 Gitea 網路重演 Arcrun#100 重複主題偵測)。

plugin.json 0.5.0 → 0.5.1;version-consistency 會在合併打 tag 那一刻才對齊
(release-tag-guard.sh 的既定分工:打 tag 是總管驗過整個 milestone 之後的事)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:19:06 +08:00
Leo b1830053e5 dispatch-format-guard 修兩處:規則自己的合格範例會被自己擋,禮貌收尾不該算違規
inkstone/ISEP#65:leo 發現「寫完 ISEP#30 那條規則之後,同一天總管又犯了 8 次」,
派來查「現行閘為什麼抓不到」。查證結果分兩層:

━━ 真正在跑的閘其實是 dispatch-format-guard.sh,不是 no-ticket-no-dispatch.sh ━━
no-ticket-no-dispatch.sh 的殼驗證早就是已知行為(只驗有沒有一行【工單】),
但 ISEP#30 已經為此新增了 dispatch-format-guard.sh 做內容判定,測試 19/19 通過。
問題是它有兩個沒被那 19 條測資蓋到的洞:

1. **regex 洞(本體 bug)**:頂層 CLAUDE.md 規定的合格格式帶全形括號——
   「【工單】owner/repo#N(→ comment M)」。_COMMENT_RE 只吃「→ comment M」
   本體,兩側括號沒被算進去,殘留括號讓 _REF_RE 比不過,於是**規則自己定義
   的合格範例會被自己的閘擋下**(半形括號 `(...)` 同樣會中)。用今天派我這張
   票的那份派工單原句實測,改之前 exit=2「票號形狀不對」。
2. **零容忍過頭**:純禮貌收尾(「謝謝」)跟「記得先讀 CLAUDE.md」這種真內容
   一樣被當「派工單不只有票號」擋下。ISEP#65 test 4 明講這四種不該擋
   (只有票號/空行/「謝謝」/括號包住的 comment 格式),優先做成低誤鎖。

判斷:不改 no-ticket-no-dispatch.sh、不另立第三支閘——dispatch-format-guard.sh
已經是「另立一支」的正確位置,這兩個洞在它自己的地盤上補。

修法:
- _COMMENT_RE 兩側括號(全形/半形)都設可選
- 新增 _COURTESY_CLOSERS 白名單(謝謝/多謝/感謝/辛苦了…)+ _is_courtesy_closer(),
  只在圍欄外生效,判準仍是「整行清乾淨標點後完全相等」不是「包含」——
  白名單不是黑名單,猜漏頂多誤鎖一次,不會反過來放走真內容(③g 測資證明)

測試:hooks/tests/dispatch-format-guard.test.sh 19→33 條,全過。新增:
- ③b/③c 全形/半形括號格式(規則自己的例句)
- ③d/③e/③f ISEP#65 test 4 的三種不該擋
- ③g 白名單邊界(禮貌詞混真內容裡照樣算數,防止白名單被誤用成漏洞)
- ⑮b–⑮i:leo 點名的「寫完規則後又犯的 8 次」當回歸樣本,內容是從
  ISEP#60/#61/#64、Arcrun#142/#144/#165、arcrun-rag#104、InkStoneCo#102
  的真實票內文摘錄(見 hooks/tests/fixtures/README.md 記載來歷),
  不是想像出來的例子;8 種形狀全部驗證會被擋
reply-identity.test.sh 11/11 仍全過(共用 dispatch_parse.py 沒有回歸)

升版 0.5.0 → 0.5.1(改完不升版沒人吃得到;check-version-consistency.sh
在本分支照慣例是紅的,tag 於 merge 時打)。

另查:.shell-payload/ 整個被 gitignore(scripts/vendor-to-shell.py 產物),
不是 git 分發的一部分——「這支閘會不會被吃到」取決於消費端有沒有重跑
plugin update/vendor-to-shell.py,不是這個 repo 委交的內容缺漏,故不在
本票改動範圍內,僅記錄供總管排查用。

未動 no-ticket-no-dispatch.sh(判斷見上,職責保持不重疊,兩支閘互斥見
dispatch_parse.py 的 has_ticket_marker 分岔)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:25:17 +08:00
Leo 770a0ee824 移除自造的待驗單機制,改由 Gitea 原生三格承接(inkstone/ISEP#60)
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 <noreply@anthropic.com>
2026-08-27 12:38:31 +08:00
Leo 9d20ef925e subtask 指派了人就一定要寫下一步——不然新子票一開出來就是「棒子沒有下一步」
實跑 ticket mine 時自己抓到:剛用 subtask 開的 #143 顯示
「 沒有人寫下一步」——工具自己造出了它要消滅的那個狀態。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:15:20 +08:00
Leo b46bc5e44c dogfood 抓到自己的漏:交棒標記只認第一行,不認「內文提過」
實跑自己那則交件報告時,閘沒有擋——因為報告裡**引用了一段 ticket mine 的輸出**
(那段有 🏃),整則就被豁免掉了,而它裡面真的有「等雲端那半出貨」。
⇒ 同 inkstone/InkStoneCo#23 那個病:複述關鍵字被當成本人。
ticket handback 寫的交棒留言 🏃 一定在第一行,引用別人的輸出則不會 ⇒ 錨在第一行。

回歸測試 +2(20/20 → 22/22)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:13:27 +08:00
Leo 0b6af15f1a 補:實測探針票的去處,以及 close/* 七類沒有一類裝得下它
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:08:48 +08:00
Leo e474b4cb6f 棒子不會掉:子票相依+tag+指派三格,全部用 Gitea 原生欄位
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 <noreply@anthropic.com>
2026-08-27 12:05:53 +08:00
Leo 8e28041ba4 merge: 派工單只剩票號——閘從驗「有沒有票號」改成驗「是不是只有票號」(ISEP#30)
leo 2026-08-27:「這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?」
四條追加:output parser 分點規定格式/散文警察抓不到/描述寫進票留歷史/subagent 回覆要表明身份。

總管複驗過才併(自己跑的):
- dispatch-format-guard.test.sh  19/19
- reply-identity.test.sh         11/11
- 兩支都涵蓋「該擋」與「不該擋」兩側;訊息點得出違反哪一條規則,不是籠統的「格式不對」

版本 0.3.9 → 0.5.0(產物按版本號分資料夾,不升版新閘不會被載入)。

🔴 仍是 report 不是 deliver 的一格:「新 session 真的會叫它」要併版後開新 session 才驗得了。
2026-08-27 11:55:24 +08:00
27 changed files with 1301 additions and 363 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "isep", "name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.5.0", "version": "0.6.1",
"keywords": [ "keywords": [
"inkstone", "inkstone",
"guardrails", "guardrails",
+1 -1
View File
@@ -23,7 +23,7 @@
| | 數量 | 是什麼 | | | 數量 | 是什麼 |
|---|---|---| |---|---|---|
| `hooks/` | 48 支 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 59 條註冊) | | `hooks/` | 48 支 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 59 條註冊`ISEP#60` 移除 `claim-verify-police.sh``subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59` |
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | | `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
| `skills/` | 2 支 | | | `skills/` | 2 支 | |
| `scripts/` | 23 支 | `ticket``github-arm.sh``gitea-bootstrap.sh` … | | `scripts/` | 23 支 | `ticket``github-arm.sh``gitea-bootstrap.sh` … |
+27 -4
View File
@@ -43,12 +43,35 @@ bash scripts/test-release-tag-guard.sh
**該看到**`3/3 通過`1 個該擋、2 個不該擋)。 **該看到**`3/3 通過`1 個該擋、2 個不該擋)。
**失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。 **失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。
### A4 — 開票側門閘:13 ### A4 — 新增 Gitea 東西的側門閘:24
``` ```
bash scripts/test-ticket-api-bypass-guard.sh bash scripts/test-ticket-api-bypass-guard.sh
``` ```
**該看到**`13/13 通過` **該看到**`24/24 通過`(前 13 條是 v1 的開票案例;後面是 inkstone/ISEP#72
**失敗**:任何一條不符,特別看「不該擋」那 8 條。 擴大範圍後補的:隱式/小寫 POST、milestonelabelPR、org 端點、以及一條打
真實 Gitea 網路重演 Arcrun#100 的案例——這台機器的 remote 沒帶憑證時會印
`⏭️ SKIP`,不算失敗,但也不算驗過)。
**失敗**:任何一條不符,特別看「不該擋」那幾條——誤攔比漏擋更該修。
### A11 — 討論串裡的任務要長成子票:22 條
```
bash scripts/test-comment-carries-task-guard.sh
```
**該看到**`22/22 通過`
**失敗**
- 「該擋」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 的 ### A5 — 開票前的搜尋是跨 repo 的
``` ```
@@ -241,7 +264,7 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
| A1 manifest 合法 | 總管 | ✅ | | A1 manifest 合法 | 總管 | ✅ |
| A2 版本三處一致 | 總管 | ✅ | | A2 版本三處一致 | 總管 | ✅ |
| A3 打 tag 閘 | 總管 | ✅ 3/3 | | A3 打 tag 閘 | 總管 | ✅ 3/3 |
| A4 開票側門閘 | 總管 | ✅ 13/13 | | **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/242026-08-27inkstone/ISEP#72 |
| A5 搜尋跨 repo | 總管 | ✅ | | A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 | | A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 | | **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
+164 -2
View File
@@ -274,9 +274,9 @@ s/triage ──驗傷通過──▶ s/backlog ──排進 milestone──▶ s
| E9 | 留言變成推理長文 | hook:超長**不 reject**,要求移去 wiki 再留指標 | ❌ 待建(放寬版,見 §8.3) | | E9 | 留言變成推理長文 | hook:超長**不 reject**,要求移去 wiki 再留指標 | ❌ 待建(放寬版,見 §8.3) |
| E10 | 就地修改治理檔案 | 安裝目錄唯讀 + hook,導向 ISEP 開 issue | ❌ 待建 | | E10 | 就地修改治理檔案 | 安裝目錄唯讀 + hook,導向 ISEP 開 issue | ❌ 待建 |
| E11 | 本機雲端版本漂移 | SessionStart hook 比對版本,不一致 fail-fast | ◐ 現有 `skill-deploy-drift-guard.sh` 管的是 skill 不是 plugin 版本 | | 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` 判準不同,兩者要合併不要疊加 | | 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 | | E15 | 代理硬做只有人能做的事 | 代理端無對應 tool 或 token;唯一出口是開 `human/exec` 票 | ◐ 部分(credential 機制已擋一部分,D36 |
| E16 | 標籤漂移 | 走 Gitea labels API 依 `labels.yaml` 校正:缺的補、改的還原、多的**只告警不刪** | ❌ 待建(`ISEP#4` | | E16 | 標籤漂移 | 走 Gitea labels API 依 `labels.yaml` 校正:缺的補、改的還原、多的**只告警不刪** | ❌ 待建(`ISEP#4` |
@@ -312,6 +312,21 @@ leo 2026-08-17 實測:文字層的閘那天 **8 次誤攔、0 次正確攔截*
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。** **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
所以 E9 擋的是「長內容放錯地方」這個**動作**,處置是**導引**(移去 wiki 留指標),不是 reject。 所以 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**,所以 E12E14 這一格目前是
「舊機制已拆、新機制已提案但未生效」的過渡態,不是「已有替代品在跑」。
#58 merge 前,這兩條暫時回到 ❌ 待建的實質狀態,即使表格欄位寫的是 ◐。
--- ---
## 9. 本規範的迭代 ## 9. 本規範的迭代
@@ -702,3 +717,150 @@ Gitea 的 milestone 只管得到同一個 repo,所以六個群在 `inkstone/IS
**機械閘**`hooks/milestone-due-guard.sh`PreToolUse `Bash`): **機械閘**`hooks/milestone-due-guard.sh`PreToolUse `Bash`):
建 milestone 沒有 `due_on`、或 `due_on` 帶 `9999` → 擋。 建 milestone 沒有 `due_on`、或 `due_on` 帶 `9999` → 擋。
四向實測:無 due_on 擋/9999 擋/真期限放行/只是讀 milestone 放行。 四向實測:無 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,**相依管得到全部**。
📌 探針票留在 `inkstone/ISEP#56``#57`(已關,掛 `type/chore`,票上有實測全文)。
**順帶一個規範缺口**`close/*` 七類(§5)**沒有一類適合「實測探針」**,
而 Gitea 的 issue `DELETE` 回 403(只有管理員能刪,已實測)
⇒ 探針票刪不掉也歸不了類。要嘛補一類 `close/probe`,要嘛規定實測一律開在
拋棄式 repo。**這一格本節不裁,留給 §5 的維護者。**
- **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 <User Story> -F <檔> [--label] [--assign X --next "<一句>"]
討論串裡的一件事 → 子票 + 回鏈 Parent: + Gitea 原生相依(三格一次到位)
🔴 `--assign` 一定要配 `--next`:有人被指到票上,他撈到它的第一件事就是問
「所以我要做什麼」——那句話開票當下就要寫,不是等他重讀整串
scripts/ticket handback <票> --to <claude-code|Leo> --next "<一句話>" [--label] [--evidence]
收工 = 指派 + 改 tag + 寫下一步,一個動作三件事
scripts/ticket mine [--user <誰>]
撈一次:棒子在誰手上、每根的下一步、每根還有幾張未關的相依
```
| 閘 | 掛在哪 | 抓什麼 | 測試 |
|---|---|---|---|
| `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 只擋一次,
關鍵詞刻意取窄,並留 `no-subtask: <理由>` 逃生口(留痕)。
理由寫在 `.claude/branch-holds.md` 檔頭那句:
**永遠在響的警報,等於訓練人忽略這個警報。**
🔴 **hook 裡比對中文一律用 `python3` 的 `re`,不要用 `grep` 的字元類**
2026-08-27 實撞:`等[^。]{0,20}出貨` 在真實 hook 呼叫路徑下對
「等雲端那半出貨」不匹配,閘靜默失效;換成 python3 後測試由 19/20 變 20/20)。
+92 -14
View File
@@ -1,4 +1,4 @@
# 46 支閘,白話盤點表 # 48 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,15 +7,28 @@
## 一句話結論 ## 一句話結論
`hooks/` 底下有 **46 個 `.sh` 檔**`hooks.json` 實際掛上 **56 條註冊**(同一支閘常被多種情境同時掛上); `hooks/` 底下有 **48 個 `.sh` 檔**`hooks.json` 實際掛上 **59 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。 下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的: > 現在的寫法是實際數出來的:
> `ls hooks/*.sh | wc -l` 46`hooks.json` 展開後的 command 條目 56 > `ls hooks/*.sh | wc -l` 48`grep -c '"command":' hooks/hooks.json` 59
> ⚠️ **冒號不能省**`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **118**。
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
>
> 📌 **`0.6.0``inkstone/ISEP#59`2026-08-27)這兩個數字是併完之後當場數出來的**:
> `ISEP#60` 移除了 `claim-verify-police.sh``subagent-claim-worksheet.sh` 這對自造機制(−2 支、−16 條),
> `ISEP#58` 補進 `comment-carries-task-guard.sh``baton-handback-guard.sh`(+2 支、+8 條),
> 加上 `ISEP#72` 的 `reply-identity-guard.sh` 已在 main 上。
> 一加一減之後檔數不變、註冊條數落在 59——**這正是「不能靠加減推算、只能實數」的例子**:
> 上一版寫 58,就是把兩批改動的加減兜出來、沒有真的數過。
>
> 🔴 **`0.6.1`2026-08-27)補回表格漏掉的兩列**`isep-presence-beacon.sh`E 組)、
> `milestone-due-guard.sh`A 組)。**標頭寫 48 支的同時,下面的表格只列得出 46 支**
> ——數字是對的,人話少了兩行。發現的方法與防它再犯的指令,見下面「落差偵測」第二組 `comm`。
--- ---
@@ -43,8 +56,10 @@
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 | | `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 | | `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
| `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 | | `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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 | | `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新 Gitea 東西(開新票/milestonelabelPR會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POSTurllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 |
| `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 |
| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】``總管``subagent``leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | | `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】``總管``subagent``leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 |
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit ## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit
@@ -77,7 +92,7 @@
| `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 | | `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 |
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 | | `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 | | `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
| `dispatch-format-guard.sh` | **派工單 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。 | 🛑 擋(+注入) | | `dispatch-format-guard.sh` | **派工單 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。 | 🛑 擋(+注入) |
## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion ## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion
@@ -95,6 +110,7 @@
| 閘名 | 對你意味著什麼 | 動作 | | 閘名 | 對你意味著什麼 | 動作 |
|---|---|---| |---|---|---|
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。 | 📝 記錄(context 注入) |
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) | | `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 | | `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
@@ -111,15 +127,17 @@
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 | | `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 | | `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 | | `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
| `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 ## G. Subagent 把工作交回來的時候(SubagentStop
| 閘名 | 對你意味著什麼 | 動作 | `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`
| `subagent-claim-worksheet.sh` | Subagent 一交回工作,就自動把它宣稱做了什麼寫成一張「待驗清單」檔案——之後總管收工前,`claim-verify-police.sh` 會檢查這張清單有沒有被處理掉。 | 📝 記錄 | 行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。
> 這個時機還掛了 `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,全部不擋) ## H. 動作做完之後,純粹記一筆(PostToolUse,全部不擋)
@@ -128,6 +146,7 @@
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 | | `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 | | `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) | | `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit ## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit
@@ -166,7 +185,62 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
(無輸出) (無輸出)
``` ```
**這次順便抓到的另一個落差**`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」, 🔴 **上面那兩行驗不出「表格漏列」**——它們比的是 `hooks.json`,不是這張表。
「有閘、但這頁沒有它那一行人話」要另外一組指令才看得見,就是下面這組
2026-08-27 就是靠它抓到本頁漏了 `isep-presence-beacon.sh``milestone-due-guard.sh` 兩列):
```
$ ls hooks/*.sh | xargs -n1 basename | sort > /tmp/inv_fs.txt
$ grep -oE '^\| `[a-zA-Z0-9_.-]+\.sh`' docs/hooks-inventory.md \
| grep -oE '[a-zA-Z0-9_.-]+\.sh' | sort -u > /tmp/inv_table.txt
# 有這支閘、但這頁沒有它那一列 → 要補人話
$ comm -23 /tmp/inv_fs.txt /tmp/inv_table.txt
(無輸出)
# 這頁列了、但檔案已經不在了 → 要刪那一列
$ comm -13 /tmp/inv_fs.txt /tmp/inv_table.txt
(無輸出)
```
🔴 **`^\| \`` 這個錨點不能拿掉**——它的意思是「**這一行的第一欄就是這支閘**」,
也就是「表格真的列了它」。把錨點拿掉、改成抓全頁所有反引號裡的 `*.sh`
反向那半會吐出 **3 個假警報**(2026-08-27 實跑):
```
claim-verify-police.sh ← 出現在「已於 ISEP#60 移除」那段**移除紀錄**裡
subagent-claim-worksheet.sh ← 同上
github-arm.sh ← 它在 `scripts/`,不在 `hooks/`;是 github-contact-guard
那一列裡告訴你「怎麼解鎖」的逃生口
```
**三個都不是幽靈,三個都不該刪**:刪掉前兩個等於刪掉「G 組為什麼是空的」這段歷史,
刪掉第三個等於讓那一列不再告訴你怎麼過閘。
⇒ **0 次正確攔截、3 次誤攔**——照這個訊號去改,會把好東西改壞。
那「頁面上出現了一個根本不存在的檔名」怎麼辦?**那要另外一格量,而且判準不是文字是事實**:
```
# C. 頁面提到的每個 *.sh,現在存在,或者 git 歷史裡存在過
$ for n in $(grep -oE '`[a-zA-Z0-9._-]+\.sh`' docs/hooks-inventory.md | tr -d '`' | sort -u); do
[ -f "hooks/$n" ] || [ -f "scripts/$n" ] && continue
[ -n "$(git log --all --oneline --diff-filter=AD -- "hooks/$n" "scripts/$n")" ] && continue
echo "$n"
done
(無輸出)
```
**「現在沒有、但曾經有」=歷史,合法;「從來沒有過」=打錯字或憑空捏造,才是幽靈。**
這一格問的是 git,不是問這頁的用字,所以它不會因為誰換個寫法就失效。
📌 **三格都要跑,而且它們量的是不同東西**:
A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
**標頭的 48/59 對不代表這頁沒漏**——補進 2 支真的、同時留著 2 支假的,數字照樣湊得到 48。
**數量相等不等於集合相等**,所以驗的一律是集合(`comm`),不是 `wc -l`。
**這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**——
留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭):
`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
`README.md` 也寫「42 支 `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。 `README.md` 也寫「42 支 `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh``release-tag-guard.sh` 差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh``release-tag-guard.sh`
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。 `ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
@@ -221,12 +295,16 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
- **`micromanage-guard.sh``irreversible-dispatch-guard.sh``no-ticket-no-dispatch.sh` 的雙重矩比對(`Task` + `Agent`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。 - **`micromanage-guard.sh``irreversible-dispatch-guard.sh``no-ticket-no-dispatch.sh` 的雙重矩比對(`Task` + `Agent`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。
- **`stage-before-prod-guard.sh` 跟 `main-and-prod-push-guard.sh` 的分工邊界**`main-and-prod-push-guard.sh` 的檔頭明講自己是在「補 `stage-before-prod-guard.sh` 的破口」(那支只認 3 個關鍵字,抓不到 `wrangler deploy`),但兩支都還掛著、都還在管「prod 出貨」這件事。這是「新的補洞、舊的continua」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。 - **`stage-before-prod-guard.sh` 跟 `main-and-prod-push-guard.sh` 的分工邊界**`main-and-prod-push-guard.sh` 的檔頭明講自己是在「補 `stage-before-prod-guard.sh` 的破口」(那支只認 3 個關鍵字,抓不到 `wrangler deploy`),但兩支都還掛著、都還在管「prod 出貨」這件事。這是「新的補洞、舊的continua」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。
除了以上兩點,其餘 41 支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。 除了以上兩點,其餘支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
> 📌 這句話原本寫「其餘 **41** 支」——那是 43 支閘那一版(`v0.2.1`)數的,之後沒有人重跑過。
> 2026-08-27 把數字拿掉而不是改成 46:**改成 46 等於宣稱我逐支重讀過源碼,而我沒有。**
> 要恢復一個數字,就重跑一次上面「抽驗」那件事,然後把當天的日期寫上去。
--- ---
## 這份表怎麼跟實況對帳(半年後怎麼發現漂移) ## 這份表怎麼跟實況對帳(半年後怎麼發現漂移)
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落的兩行 `comm` 指令,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單(不是跟 `hooks.json`,那個只驗證有沒有註冊,驗不了有沒有寫進這張人話表 1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落**第二組**`inv_fs` vs `inv_table` 的兩行 `comm`**加第三格 C**,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單。**不是第一組**——第一組比的是 `hooks.json`只驗證有沒有註冊,驗不了有沒有寫進這張人話表。2026-08-27 漏掉的那兩列,第一組是綠的、第二組才紅。**抓表格列的那組 `grep` 一定要帶 `^\| \`` 錨點**,理由與拿掉之後會冒出的 3 個假警報,就寫在那一段裡
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。 2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。 3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
+136
View File
@@ -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/已上 stages/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 <<EOF
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
$BODY
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
直到最後交出正確 deliverable 並且驗證」
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
leo 自己就是用 Gitea 原生的「指派給您的」在看。
【一個動作把三格補齊】
scripts/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>
【撈一次看棒子在誰手上】 scripts/ticket mine
EOF
exit 2
-82
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
#!/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-27Gitea 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(收工那一刻)補第二層。
#
# 已知極限(誠實標記):這支**認不出「我已經開好子票了」**——它只看得到指令文字。
# 所以交件報告裡引用自己剛開的子票時仍會被擋一次;那一次用 `no-subtask:` 過,
# 或就當它是一次確認。刻意不去打 API 查「這張票有沒有相依」:那會讓每一個
# 命中關鍵詞的 Bash 呼叫都多一次網路往返,代價落在錯的地方。
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 newticket-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 那條路,不重複擋。
# 🔴 只認**第一行**,不認「內文任何地方出現過」——這是 dogfood 當場抓到的漏(2026-08-27):
# 交件報告裡**引用了一段 `ticket mine` 的輸出**(那段有 🏃),
# 整則報告就被豁免掉了,而它裡面真的有「等雲端那半出貨」這種待辦。
# ⇒ 這正是 `inkstone/InkStoneCo#23` 那個病的同款:**複述關鍵字被當成本人**。
# `ticket handback` 寫的交棒留言,🏃 一定在第一行;引用別人的輸出則不會。
case "$(printf '%s' "$BODY" | head -1)" in "🏃"*) exit 0 ;; esac
# ③ 「未完成的未來式」——刻意取窄。這一組全部來自 08-26 那則真的掉了的留言的形狀。
#
# 🔴 比對用 python3 不用 grep——這是**實測逼出來的**,不是偏好(2026-08-27 本閘第一版):
# 第一版寫 `等[^。]{0,20}(出貨|…)`,跑真的 hook 呼叫路徑時對
# 「這件事等雲端那半出貨才驗得了」**不匹配**,閘靜默失效(測試 19/20,那一條紅)。
# 同一條 pattern 在別的 shell 手打卻會過 ⇒ **它的行為依賴 localegrep 實作**
# 而 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 <<EOF
🎫 這則留言裡藏著一件**還沒做完的事**,而它現在只是文字。
命中的詞:$HIT
【leo 2026-08-27】「**每個任務長出子票做為這張票的相依⋯⋯票沒完工可以察覺嗎?**」
【它掉過一次,時序如下】
08-26 13:00 arcrun-rag#136 comment 4267:「等雲端那半出貨才驗得了」 ← 只是一段文字
08-26 18-23 總管真的出貨了 ← 條件滿足,沒人知道
08-27 11:xx leo 問「這 14 小時前的,你派了還是沒派?」 ← 才被撿起來
【怎麼做】把它長成子票,Gitea 原生相依會**硬擋**母票關閉(實測 412):
scripts/ticket subtask <母票 owner/repo#N> \\
--title "身為<誰>,我要<什麼>,我才<為什麼>" \\
-F <內文檔> --label s/todo --assign <誰做>
⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。**
【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉,
每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」,
傾向**多開票**。不要用「會不會太多」當理由不開。
【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。
EOF
exit 2
+8 -16
View File
@@ -41,6 +41,10 @@
"type": "command", "type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh" "command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh"
}, },
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/comment-carries-task-guard.sh"
},
{ {
"type": "command", "type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh" "command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
@@ -267,25 +271,9 @@
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh" "command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
} }
] ]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claim-verify-police.sh"
}
]
} }
], ],
"SubagentStop": [ "SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-claim-worksheet.sh"
}
]
},
{ {
"hooks": [ "hooks": [
{ {
@@ -343,6 +331,10 @@
{ {
"type": "command", "type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh" "command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
} }
] ]
} }
+37 -6
View File
@@ -37,12 +37,16 @@
當日實證:文字層的閘 8 次誤攔、0 次正確攔截,**紅線寫得越細,命中關鍵字的機率越高 當日實證:文字層的閘 8 次誤攔、0 次正確攔截,**紅線寫得越細,命中關鍵字的機率越高
⇒ 那些閘在懲罰謹慎**。 ⇒ 那些閘在懲罰謹慎**。
本檔**全檔零個「命中某個詞就違規」的比對**。判準是「這一行是不是【工單】欄位」—— 本檔**沒有「命中某個詞就違規」的比對**。判準是「這一行是不是【工單】欄位」——
**在不在**,不是**寫什麼**。用到的正則只有兩種,都在認形狀: **在不在**,不是**寫什麼**。用到的正則有三種,全部在認形狀:
- `【某某】` 欄位標記本身 - `【某某】` 欄位標記本身
- `owner/repo#N` 票號形狀 - `owner/repo#N`(可選外掛全形/半形括號的 `→ comment M`票號形狀
措辭多謹慎、有沒有提到「紅線」「部署」「main」,本檔一律不看。 - 唯一例外(ISEP#65):`_COURTESY_CLOSERS` 是一份**白名單**,只用來認出
⇒ 也因此它不會像文字層的閘那樣「寫得越小心越容易被罰」。 「這一行是不是零資訊的禮貌收尾」,作用方向與黑名單相反——認出的是
「不算違規」,不是「算違規」;且要求整行清乾淨標點後完全相等,不是
「包含」,所以不會反過來放走任何真正的內容。
措辭多謹慎、有沒有提到「紅線」「部署」「main」,本檔一律不看,仍然不會被
「寫得越小心越容易被罰」的病拖下水。
━━ 派工單格式 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━ 派工單格式 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【工單】inkstone/ISEP#30 → comment 4322 【工單】inkstone/ISEP#30 → comment 4322
@@ -83,9 +87,32 @@ IDENTITY_ROLES = ("總管", "subagent", "leo")
_MARKER_RE = re.compile(r"^【([^】]{1,12})】\s*(.*)$") _MARKER_RE = re.compile(r"^【([^】]{1,12})】\s*(.*)$")
_REF_RE = re.compile(r"^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)\s*$") _REF_RE = re.compile(r"^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)\s*$")
_COMMENT_RE = re.compile(r"(?:#issuecomment-|→\s*comment\s*)(\d+)") # 🔴 inkstone/ISEP#65:頂層 CLAUDE.md 規定的合格格式帶著全形括號——
# 「【工單】owner/repo#N(→ comment M)」。舊版正則只吃「→ comment M」本體,
# 兩側的括號(全形/半形都要吃)沒被算進去,殘留的括號讓 _REF_RE 比不過,
# 於是**規則自己定義的合格範例會被自己的閘擋下**。這裡把左右括號都收進來,
# 兩種都設成可選(沒有括號的舊寫法、純 #issuecomment- 連結都還要能過)。
_COMMENT_RE = re.compile(r"[(]?\s*(?:#issuecomment-|→\s*comment\s*)(\d+)\s*[)]?")
_FENCE_RE = re.compile(r"^\s*(```|~~~)") _FENCE_RE = re.compile(r"^\s*(```|~~~)")
# 🔴 inkstone/ISEP#65:票號後面只掛一句純禮貌收尾(例:「謝謝」),不該被當成
# 「派工單不只有票號」的違規——它不含任何要搬去票上或共通規定的實質內容。
# 這是**白名單,不是黑名單**:判準仍是結構(整行清乾淨標點後「等於」清單裡的
# 固定字串,不是「包含」),漏收某個禮貌講法頂多誤鎖一次(代價小,見 ISEP#65
# 的「優先做成低誤鎖」),但不會反過來放走任何真正的內容——真內容不會剛好
# 打出一個字不差的空話。全檔仍然沒有「命中某個詞就違規」的比對,這裡是唯一
# 一處字面比對,且作用方向相反:認出「不是內容」,不是認出「是違規」。
_COURTESY_CLOSERS = frozenset({
"謝謝", "謝謝你", "謝謝您", "謝謝囉", "多謝", "感謝", "感恩", "辛苦了", "收到",
"thanks", "thank you", "thx",
})
_COURTESY_STRIP_RE = re.compile(r"[\s。!!.,~,、]+$")
def _is_courtesy_closer(line):
s = _COURTESY_STRIP_RE.sub("", line.strip())
return s.lower() in _COURTESY_CLOSERS
def _split_lines(text): def _split_lines(text):
"""切成 (是否在圍欄內, 原始行)。 """切成 (是否在圍欄內, 原始行)。
@@ -146,6 +173,10 @@ def parse_dispatch(prompt):
continue continue
if raw.strip(): if raw.strip():
# 【工單】欄位本身只准佔它自己那一行;它下面的續行也是多的。 # 【工單】欄位本身只准佔它自己那一行;它下面的續行也是多的。
# 白名單豁免只在圍欄外生效——圍欄裡的東西本來就該一律算數(test ⑮),
# 這裡不額外放寬,避免有人把真內容包在圍欄裡再貼一句「謝謝」蒙混。
if not in_fence and _is_courtesy_closer(raw):
continue
extra.append((cur, raw)) extra.append((cur, raw))
return { return {
+4 -2
View File
@@ -16,8 +16,10 @@
# history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、 # 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、 # 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。 # 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、 # 另有 empty-handed-stop-guard.sh、issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`
# issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`(無 `:-` fallback)—— # (無 `:-` fallback)——
# claim-verify-police.shsubagent-claim-worksheet.sh 已於 ISEP#60 整組移除,
# 它們守的東西改由 Gitea 原生三格承接,見 ISEP#59PR #58
# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。 # 這批在該變數未設時行為又不一樣,同一個病的另一種長相。
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地, # 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。 # 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/env bash
# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單
# SubagentStopleo 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 當場退化成它要避免的「文字層自咬」。
#
# 正解:子代理的交件是以 `<task-notification>…<result>…</result>` 的形式
# 進到主對話的 **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 "<task-notification>" in txt:
blob.append(txt)
if blob:
m = re.search(r"<result>(.*?)</result>", 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-21stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
# 而且不知道總管已經驗過了。實際發作:同兩條 sdd-guard 宣稱連生四張單
# 1c97d461fcb285dc256de849394b97ae),驗掉一張下一回合又冒一張。
# ⇒ 改成雜湊**宣稱內容本身**:同樣的宣稱=同一個檔名 ⇒ 驗過就不再冒出來。
_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 <<EOF
🔍 交件查證:subagent 交回了 $N,已列成待驗工作單。
【leo 2026-08-18 的規格】
「它說可以,你去驗證;它說不行,你想辦法,不行再報告。
**它視野小,它說的你有更多資訊去檢驗。**」
工作單:.claude/pending-verification/$F
🔴 **不要直接把它說的轉給 leo。** 逐條處理完,把那個檔刪掉;在那之前 Stop 閘會擋一次。
· 它說「可以」→ 你自己跑一次(測試/curl/瀏覽器/git,看那條宣稱是什麼)
· 它說「不行」→ 先換路徑、查憑證地圖;**要說「被擋」就得自己撞一次並貼原文**
EOF
;;
esac
exit 0
+53
View File
@@ -61,6 +61,32 @@ t 0 "② 合規:兩張票就兩行【工單】" \
t 0 "③ 合規:用 #issuecomment- 定址也認得" \ t 0 "③ 合規:用 #issuecomment- 定址也認得" \
"$(payload '【工單】inkstone/ISEP#30#issuecomment-4327')" "$(payload '【工單】inkstone/ISEP#30#issuecomment-4327')"
# ── inkstone/ISEP#65CLAUDE.md 規定的合格格式(全形/半形括號包住 → comment M)
# 曾經被本閘自己擋下——這裡把它釘成迴歸測試,不再走回頭路。
t 0 "③b 合規:全形括號包住 → comment M(CLAUDE.md 標準寫法,曾被自己的閘擋下)" \
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515')"
t 0 "③c 合規:半形括號變體" \
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515)')"
# ── inkstone/ISEP#65 test 4:不該擋的三種雜訊 ────────────────────────
t 0 "③d 不該擋:票號後面只有空行" \
"$(payload '【工單】inkstone/ISEP#65
')"
t 0 "③e 不該擋:票號後面只有一句「謝謝」(零資訊禮貌收尾,白名單豁免)" \
"$(payload '【工單】inkstone/ISEP#65
謝謝')"
t 0 "③f 不該擋:禮貌收尾帶標點也認得(「謝謝!」)" \
"$(payload '【工單】inkstone/ISEP#65
謝謝!')"
t 2 "③g 白名單不是漏洞:禮貌詞混在真內容裡照樣算數" \
"$(payload '【工單】inkstone/ISEP#65
辛苦了,順便告訴你 main 現在是 8e28041')"
t 0 "④ 不是派工的動作(Bash)→ 一律不管" \ t 0 "④ 不是派工的動作(Bash)→ 一律不管" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}' '{"tool_name":"Bash","tool_input":{"command":"ls"}}'
@@ -132,6 +158,33 @@ t 2 "⑮ 內容躲在圍欄裡也算數(圍欄只讓裡面的【】不被當
【就地】main 現在是 8e7e265 【就地】main 現在是 8e7e265
```')" ```')"
# ── inkstone/ISEP#65 test 5:拿「寫完規則之後總管又犯的 8 次」當回歸樣本 ──
# 內容是從各自票的真實內文摘錄(hooks/tests/fixtures/README.md 記著來歷),
# 不是想像出來的例子。8 種形狀都要被擋,證明這不是只補了一個特例。
t 2 "⑮b 回歸:ISEP#60 那次的內容(自造第四套機制的診斷)" \
"$(payload "$FIX/real-content-isep60.txt")"
t 2 "⑮c 回歸:ISEP#61 那次的內容(push-guard 戳記機制失敗史)" \
"$(payload "$FIX/real-content-isep61.txt")"
t 2 "⑮d 回歸:ISEP#64 那次的內容(PR 分診現況數字)" \
"$(payload "$FIX/real-content-isep64.txt")"
t 2 "⑮e 回歸:Arcrun#142 那次的內容(庫索引從沒被產生過)" \
"$(payload "$FIX/real-content-arcrun142.txt")"
t 2 "⑮f 回歸:Arcrun#144 那次的內容(樹狀圖驗收記錄)" \
"$(payload "$FIX/real-content-arcrun144.txt")"
t 2 "⑮g 回歸:Arcrun#165 那次的內容(MCP 徹查現場)" \
"$(payload "$FIX/real-content-arcrun165.txt")"
t 2 "⑮h 回歸:arcrun-rag#104 那次的內容(收檔實測數字)" \
"$(payload "$FIX/real-content-arcrunrag104.txt")"
t 2 "⑮i 回歸:InkStoneCo#102 那次的內容(milestone 打假背景)" \
"$(payload "$FIX/real-content-inkstoneco102.txt")"
echo echo
echo "── C 群:訊息本身 ──────────────────────────────────────────────" echo "── C 群:訊息本身 ──────────────────────────────────────────────"
clean clean
+18
View File
@@ -12,3 +12,21 @@ leo 2026-08-27 要的測資是「今天實際發生的 5 次違規派工」
其餘的覆蓋率改用「一條規則一個最小案例」來補(見 dispatch-format-guard.test.sh B 群)。 其餘的覆蓋率改用「一條規則一個最小案例」來補(見 dispatch-format-guard.test.sh B 群)。
**不要用想像出來的例子替補那 4 份**,那會讓測試看起來比實際更綠。 **不要用想像出來的例子替補那 4 份**,那會讓測試看起來比實際更綠。
---
## 第二批:`real-content-*.txt`inkstone/ISEP#65
leo 2026-08-27 又在同一天抓到總管**寫完規則之後又犯了 8 次**(八條線的派工單,
每一條都帶了「這個 session 才知道的事」):`ISEP#60``#61``#64`
`Arcrun#142``#144``#165``arcrun-rag#104``InkStoneCo#102`
🔴 **原始的違規派工 prompt 本身一樣拿不回來**(同一個道理:agent 一停就蒸發),
但這 8 次的**內容已經被總管補寫回各自的票**(ISEP#65 body 明講)。
這裡的 8 份 `real-content-*.txt` 是**從那些票的真實內文摘錄**(一字未改地複製,
只是截取一段,並在最前面加回 `【工單】owner/repo#N` 讓它符合派工單的殼)——
不是想像出來的例子,是真的存在於 Gitea 上、總管真的寫過的句子,只是原本的位置
錯了(在 prompt 裡,不在票上)。
用途:驗證 dispatch-format-guard.sh 修完 ISEP#65 之後,**這 8 種「票號 + 這個
session 才知道的事」的形狀全部會被擋下**——不是只驗一個最小案例。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/Arcrun#142
實查結論:這兩樣東西(庫索引與全庫摘要)從沒被產生過,不是壞掉,是根本沒做。
於是「開場就知道有哪些子庫、各裝了什麼」這件事永遠靠現查,查不到就當作沒有。
相關:inkstone/Arcrun#81、#87、#100(總圖說 0 條關聯,實際 1854 條)。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/Arcrun#144
leo 明講「不要現在執行,記下來」,本票只是把它接住,開工前要先確認優先序。
測試 OK,出現了樹狀圖,但不符合需求——達成的是 mindmap 向右向上分佈,
上下間距很大、展開字很小,用戶要擁有觸控板否則很難閱讀。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/Arcrun#165
不要先修,先查清楚再說——現在連斷在哪都還沒人講得出來。
已知的現場(總管 2026-08-26 用 MCP 實查,不是讀 code 推的):同一個知識庫
youlinyuga3bse),同一套 MCP 工具,四次查詢分數與筆數都對不上。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/arcrun-rag#104
現況(總管 2026-08-14 實測,非推測):leo 把 5 個資料夾接上重裝後的 leo21c,
App 顯示你的檔案 12022 共幾份、819 已送上去、11183 排隊中、20 送不上去。
逐資料夾實數(find 計 .md/.pdf/.txt,已排除 node_modules.git)。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/InkStoneCo#102
背景(同一天稍早,leo 對同一件事說過的重話):昨天要完成 51,你現在還有沒派的,
去看看 milestone 一大堆沒有一個完成,現在整理,不要詐騙,去看清楚哪些完成的。
Gitea 的百分比只數票的開關,它不知道票裡的東西通了沒。
+6
View File
@@ -0,0 +1,6 @@
【工單】inkstone/ISEP#60
`subagent-claim-worksheet.sh``SubagentStop` 產待驗單)與 `claim-verify-police.sh`
(`Stop` 擋收工)這一套自造的第四套機制整個移除,它守的東西改由 Gitea 原生的三格承接
(子票相依/tag/指派——見 `inkstone/ISEP#59` 與 PR `#58`)。
`#issuecomment-4289` 量到同一份待驗單重生三次、同內容兩個檔名。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/ISEP#61
`main-and-prod-push-guard.sh` 的戳記機制已經走進死路:原設計用
`CLAUDE_CODE_CHILD_SESSION=1` 判斷 subagent,實測結果總管主 session 也是 1,
那個變數不是身分標記,誰都擋。改成戳記(`/tmp/.main-push-ok`,總管手動寫)也失敗。
+6
View File
@@ -0,0 +1,6 @@
【工單】inkstone/ISEP#64
現況(總管實查,2026-08-27 12:40):open PR 總數 23,總管已關掉 8 個「內容早就在
main」的空 PR,剩 15 個要分診。已關的 8 個實測 git merge-base --is-ancestor 證明
HEAD 已是 main 的祖先,diff +0/-0Arcrun#166#161#158#155#153、
arcrun-rag#142#141#139。
+169 -36
View File
@@ -1,26 +1,45 @@
#!/bin/bash #!/bin/bash
# 管什麼: 用 Gitea API 直接開新票時,要求這一輪有跑過跨 repo 的搜尋(/tmp/.ticket-where-ok30 分鐘內)。 # 管什麼: 用 Gitea API 直接「新增」東西時(開新票/開新 milestone/開新 label/開新 PR),
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人。2026-08-20 總管走 API 側門開了 12 張票,每一張都跟舊票重疊 # 要求這一輪有跑過搜尋(/tmp/.ticket-where-ok30 分鐘內)
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘 API 也放行),或在指令裡加 `ticket-api-ok` 留痕放行。 # 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人;而擋側門的第一版只認
# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘 # 大寫裸字 POST2026-08-27 總管用 `requests.post(...)`/隱式 POST 的 urllib
# 繞了過去,同一天又用 API 直接開了 milestone/label/PR(這版都沒管)。
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘所有寫入端點都放行),
# 或在指令裡加 `ticket-api-ok` 留痕放行。
# ticket-api-bypass-guard.sh — 新增 Gitea 東西的每一道側門,都要經過同一道搜尋閘
# #
# 來由(leo 2026-08-20 當場問「如何防止」 # 來由(leo 2026-08-20 當場問「如何防止」2026-08-27 leo 把範圍講寬
# 「不只開票前⋯⋯所有新增 gitea 的東西,都要搜尋」):
# `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。 # `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。
# 但總管當天開了 12 張與舊票重疊的新票——因為他**沒用那支工具,直接打 Gitea API** # 但 2026-08-20 總管開了 12 張與舊票重疊的新票——沒用那支工具,直接打 API。
# ⇒ 規範有、閘也有,但閘長在「工具」上,而那個動作有兩條路,只封了一條。 # 2026-08-27 總管又開了 13 張,這次連當時的 v1 guard 都繞過了:
# leo:「你在讓事情複雜化」/「每張票開以前都要搜尋現有票,你為什麼會開了不搜?」 # v1 判準只認一個裸字 `grep -qw POST`leo 2026-08-17 定的哲學:封動作不封文字,
# 一個裸字夠了),但那天實際指令是 `req = urllib.request.Request(url, data=...)`
# (靠傳 data= 隱式變成 POST,指令裡從頭到尾沒有 "POST" 三個字母),或
# `requests.post(...)`(小寫 post`grep -qw POST` 大小寫敏感照樣漏)。
# ⇒ 這不是「該封文字改封動作」的問題(v1 本來就在封動作:打了寫入端點),
# 是「認動作的方式」本身漏了 HTTP 語意裡的隱式 POST。本版把「寫入訊號」從
# 單一裸字,換成「curl 的資料類旗標/`.post(``method=post`urllib 隱式 POST」
# 這組結構性訊號——訊號種類有限、可枚舉,跟關鍵字黑名單不是同一件事。
# #
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的 issues 端點做寫入, # 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的「集合端點」
# issuesmilestoneslabelspulls,不帶 ID=新增;帶 ID=改既有的,放行)做寫入,
# 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。 # 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。
# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。 # (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板 # 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板。)
# 所以判準取「端點 + 寫入動詞」兩個都命中才擋,讓純讀取一律放行。)
# #
# 放行(刻意,這些都不是「開票」): # 放行(刻意,這些都不是「新增」):
# - 只讀不寫(GET):撈清單、看票、對帳 # - 只讀不寫(GET):撈清單、看票、對帳
# - 對既有票的留言/改標籤/關票/issues/<N>/... 這種帶票號的子路徑) # - 對既有票號的子路徑/issues/<N>/... /milestones/<N> /labels/<N> /pulls/<N>...):
# - scripts/ticket 自己(它有自己的閘,重複擋只會互相打架) # 留言、改標籤、關票、改狀態、合併 PR——leo 要的是「不要亂開新的」,不是「不准動 Gitea」
# - scripts/ticket 自己(它有自己的三道閘,重複擋只會互相打架)
# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上) # - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上)
#
# 只對「issues」做主動搜尋+在擋下的訊息裡直接點名可能撞到的舊票(milestones/labels/pulls
# 沒有對應的跨 repo 全文搜尋端點可用,一律只要求「搜尋戳記新鮮」,不生產額外訊息——
# 這是刻意的簡化,不是漏做;真要幫 milestone/label/PR 做語意重複檢查是另一張票的事)。
# 搜尋本身連不上網路 → 不影響擋/放行的判斷,只影響訊息裡有沒有列出候選舊票(fail-open
# 在「錦上添花」的部分,不 fail-open 在「該不該擋」這個核心判斷上)。
set -uo pipefail set -uo pipefail
INPUT=$(cat) INPUT=$(cat)
@@ -34,18 +53,54 @@ except Exception: print('')
# 逃生口(留痕) # 逃生口(留痕)
case "$CMD" in *ticket-api-ok*) exit 0 ;; esac case "$CMD" in *ticket-api-ok*) exit 0 ;; esac
# scripts/ticket 有自己的閘 # scripts/ticket 有自己的閘
case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*) exit 0 ;; esac case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*|*"ticket close"*|*"ticket decide"*) exit 0 ;; esac
# ① 有沒有打到 Gitea 的 issues 端點(不帶票號的那個=建立新票的路徑) # ① 先放行「帶了 ID 的子路徑」——issues/<N>、milestones/<N>、labels/<N>、
printf '%s' "$CMD" | grep -qE 'repos/[^ "'"'"']*/issues([?"'"'"'`,)\\[:space:]]|$)' || exit 0 # pulls/<N>(後面不管接留言/labels/合併都算),這些是在動既有的東西,
# 不是新增。獨立判斷、排在最前面,不要留給下面那條「集合端點」regex 用
# 貪婪比對去猜——第一版把 owner/repo 寫成「允許斜線的萬用字元」時,
# `/repos/{REPO}/issues/5/labels`(既有票 #5 底下的 labels,不該擋)會被
# 貪婪比對成「owner/repo 吃掉 {REPO}/issues/5,資源名是 labels」,跟真正
# 的頂層 /labels 端點混在一起——先用「resource/數字」這個特徵擋在前面,
# 後面那條就不必再猜。
printf '%s' "$CMD" | grep -qE '(issues|milestones|labels|pulls)/[0-9]+' && exit 0
# ② 指令裡有沒有 POST 這個詞(純 GET 一律放行 # ② 有沒有打到 Gitea 的「集合端點」(不帶 ID 的那個=新增的路徑
# 刻意只認一個裸字:跳脫引號、heredoc、python、curl、各種包裝的寫法無限多, # repos/<owner>/<repo>/<resource> 或 orgs/<org>/<resource>,資源名後面立刻是
# 逐個補 pattern 追不完(leo 2026-08-17:「自然語言的變體是無限的,blacklist 永遠追不完」)。 # 「不是路徑字元」的東西(問號/引號/逗號/右括號/空白/結尾)。
# ①已經確定這是「開票那條端點」,純讀取的指令不會出現 POST ⇒ 一個字就夠,而且沒有跳脫的破口。 # 中間那段刻意只排除空白(不必排引號):①已經先擋掉「帶 ID」的形狀,這裡
printf '%s' "$CMD" | grep -qw 'POST' || exit 0 # 只要认出「打到了這個資源的集合端點」就好,多抓一點字元進中間那段不影響
# 判斷——這也是為什麼 f-string 占位符 `f"{API}/repos/{REPO}/issues"`
# owner/repo 被壓成一個不含斜線的 token)跟真實網址 `repos/inkstone/ISEP/issues`
# owner/repo 是兩段)都吃得下,不必為了兩種形狀各寫一條 pattern。
ENDPOINT_RE='(repos|orgs)/[^[:space:]]+/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)'
printf '%s' "$CMD" | grep -qE "$ENDPOINT_RE" || exit 0
# ③ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘) RESOURCE=$(printf '%s' "$CMD" | grep -oE '/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)' | head -1 | grep -oE 'issues|milestones|labels|pulls')
# ③ 有沒有寫入訊號(純 GET 一律放行)。刻意取「結構性訊號」而非單一裸字:
# - curl:顯式 -X/--request POST,或帶了資料類旗標(curl 帶 -d/--data* 時,
# 沒寫 -X 也會自動變 POST——這正是舊版漏掉的其中一種隱式 POST)
# - requests 系函式庫:`.post(`(大小寫都收)
# - 任何函式庫的 `method=` 參數值是 post(大小寫都收)
# - urllib.request`Request(``urlopen(` 與 `data=` 同時出現(不要求同一行——
# 多行 heredoc 裡兩者常常不在同一行)=隱式 POST,這是 2026-08-27 實際撞到的那種寫法
WRITE_SIGNAL=no
if printf '%s' "$CMD" | grep -qiw 'POST'; then
# 保留 v1 的裸字比對,但**大小寫不敏感**——v1 只認大寫 POST,
# 2026-08-27 `requests.post(...)` 小寫照樣繞過,這裡補上不分大小寫的字。
WRITE_SIGNAL=yes
elif printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*POST|--request[[:space:]=]*POST|--data(-raw|-binary|-urlencode)?([[:space:]=]|$)|--json([[:space:]=]|$)|\.post\(|method[[:space:]]*=[[:space:]]*.{0,2}post'; then
WRITE_SIGNAL=yes
elif printf '%s' "$CMD" | grep -qE 'urllib\.request\.(Request|urlopen)\(' \
&& printf '%s' "$CMD" | grep -qE '\bdata[[:space:]]*='; then
# 隱式 POSTurllib.request 傳了 data= 卻沒寫明 method——這是 2026-08-27
# 實際撞到的寫法,指令裡從頭到尾沒有 "POST" 三個字母,上面兩層都攔不到。
WRITE_SIGNAL=yes
fi
[ "$WRITE_SIGNAL" = yes ] || exit 0
# ④ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘)
STAMP=/tmp/.ticket-where-ok STAMP=/tmp/.ticket-where-ok
NOW=$(date +%s) NOW=$(date +%s)
FRESH=no FRESH=no
@@ -55,30 +110,108 @@ if [ -f "$STAMP" ]; then
fi fi
[ "$FRESH" = "yes" ] && exit 0 [ "$FRESH" = "yes" ] && exit 0
cat >&2 <<'MSG' # 只有 issues 才做「主動幫你搜一次」——從指令裡的 title 欄位猜關鍵字,
🚫 你正在用 Gitea API 直接開新票,而這一輪沒有搜尋紀錄 # 真的搜得到就把候選舊票直接列進擋下的訊息裡(重演 2026-08-27 的 Arcrun#100)。
# 任何一步失敗(拿不到 token、連不上網、payload 裡沒有 title)都安靜放棄,
# 不影響「擋下來」這個判斷本身。
HITS=""
if [ "$RESOURCE" = "issues" ]; then
REMOTES=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote -v 2>/dev/null || true)
CRED_LINE=$(printf '%s' "$REMOTES" | grep -m1 'git\.uncle6\.me' | grep -m1 '@' || true)
if [ -n "$CRED_LINE" ]; then
TOKEN=$(printf '%s' "$CRED_LINE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
# 只認雙引號的 "title": "..."curl -djson.dumps 輸出的實際格式,也是
# 2026-08-27 那 13 張票真實用的寫法,見 scripts/test-ticket-api-bypass-guard.sh
# 既有的模擬指令)。刻意不支援單引號版本——這段 python 要塞進 bash 單引號
# 參數字串(`python3 -c '...'`),bash 的單引號字串裡放不進一個字面單引號
# (沒有跳脫機制),另外分岔一段處理單引號只會逼自己走回「兩層語言的引號
# 疊在一起」那個坑。找不到 title 就安靜放棄——不影響「擋下來」這個判斷本身,
# 只是訊息裡少列候選舊票。
HITS=$(printf '%s' "$CMD" | _TICKET_TOKEN="$TOKEN" python3 -c '
import os, re, sys, json, urllib.request, urllib.parse
leo 2026-08-16:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」 cmd = sys.stdin.read()
leo 2026-08-20:「**每張票開以前都要搜尋現有票,你為什麼會開了不搜?這個規範不是早就有 hook 了?**」 pat = "\"title\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\""
m = re.search(pat, cmd)
if not m:
sys.exit(0)
title = m.group(1)
title = title.replace("\\n", " ").replace("\\\"", "\"")
規範有,閘也有——但那道閘長在 `scripts/ticket` 這支工具裡, toks = []
而你走的是 API 這條側門。**本閘就是把那道門也封上。** for t in re.findall(r"[A-Za-z0-9]+|[一-鿿]{2,}", title):
if t not in toks:
toks.append(t)
toks = toks[:3]
if not toks:
sys.exit(0)
實錯(2026-08-20 同日):總管用 API 開了 12 張票,事後盤點**每一張都跟舊票重疊**, token = os.environ.get("_TICKET_TOKEN", "")
全部只能關掉指回舊票。leo:「**你在讓事情複雜化**」。 seen = {}
for kw in toks:
q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 20})
url = "https://git.uncle6.me/api/v1/repos/issues/search?" + q
try:
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
data = json.load(urllib.request.urlopen(req, timeout=6))
except Exception:
continue
for it in (data or []):
ref = it["repository"]["full_name"] + "#" + str(it["number"])
row = seen.setdefault(ref, {"score": 0, "title": it["title"]})
row["score"] = row["score"] + 1
hits = sorted(seen.items(), key=lambda kv: -kv[1]["score"])[:6]
if not hits:
sys.exit(0)
joined = " ".join(toks)
print("(用猜出來的關鍵字「" + joined + "」順手搜了一次,命中:)")
for ref, info in hits:
score = info["score"]
t2 = info["title"][:56]
print(" [" + str(score) + "] " + ref + " " + t2)
' 2>/dev/null || true)
fi
fi
case "$RESOURCE" in
issues) VERB="開新票" ;;
milestones) VERB="開新 milestone" ;;
labels) VERB="開新 label" ;;
pulls) VERB="開新 PR" ;;
*) VERB="新增東西" ;;
esac
{
cat <<MSG
🚫 你正在用 Gitea API 直接${VERB},而這一輪沒有搜尋紀錄
leo 2026-08-16:「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
leo 2026-08-27:「開票不查是否有現成的,這是什麼問題?」「不只開票前,
所有新增 gitea 的東西,都要搜尋」
規範有,閘也有——但上一版只認裸字 POST,你這次用的寫法(隱式 POST/小寫
requests.postmilestone·label·PR 端點)沒被管到。**本閘把這些側門也封上。**
MSG
if [ -n "$HITS" ]; then
printf '\n%s\n' "$HITS"
fi
cat <<'MSG'
── 怎麼過(擇一)─────────────────────────────── ── 怎麼過(擇一)───────────────────────────────
1. 先搜(預設,戳記 30 分鐘有效,之後 API 也放行): 1. 先搜(預設,戳記 30 分鐘有效,之後所有寫入端點都放行):
scripts/ticket where <關鍵字...> scripts/ticket where <關鍵字...>
🔴 搜到了就**貼進那張票**,不要開新的: 🔴 搜到同一條線就貼進那張票,不要開新的:
scripts/ticket say <owner/repo#N> -F <內文檔> scripts/ticket say <owner/repo#N> -F <內文檔>
2. 真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完: 2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
scripts/ticket new <repo> -F <內文檔> --title <標題> scripts/ticket new <repo> -F <內文檔> --title <標題>
3. 這次確實不是在開新票(例如批次改標籤/關票/留言) 3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態
→ 指令裡加 `ticket-api-ok` 說明理由,留痕放行。 → 指令裡加 `ticket-api-ok` 說明理由,留痕放行。
放行的情況(本閘不管):純讀取(GET)、對既有票 /issues/<N>/ 的留言與標籤、scripts/ticket 自己。 放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、
scripts/ticket 自己。
MSG MSG
} >&2
exit 2 exit 2
+55
View File
@@ -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 ]
+69
View File
@@ -0,0 +1,69 @@
#!/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 "── 交棒標記只認第一行(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":"驗不了"})'
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 ]
+49 -2
View File
@@ -1,6 +1,8 @@
#!/bin/bash #!/bin/bash
# 開票側門閘的測試(docs/TESTING.md A4 # 開票側門閘的測試(docs/TESTING.md A4
# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條 # 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條v1,仍須全過,不能為了
# 新案例把舊的改壞)+ inkstone/ISEP#72 擴大範圍後新增的案例(隱式/小寫 POST、
# milestonelabelPR、org 端點、Arcrun#100 重演)。
cd "$(dirname "$0")/.." || exit 1 cd "$(dirname "$0")/.." || exit 1
H=hooks/ticket-api-bypass-guard.sh H=hooks/ticket-api-bypass-guard.sh
PASS=0; FAIL=0 PASS=0; FAIL=0
@@ -33,8 +35,53 @@ run 0 'curl -s "$API/repos/inkstone/Arcrun/issues?state=open&limit=100"'
echo "── 有新鮮戳記時放行 ──" echo "── 有新鮮戳記時放行 ──"
python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))" python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))"
run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues' run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues'
rm -f /tmp/.ticket-where-ok rm -f /tmp/.ticket-where-ok
echo
echo "── ISEP#722026-08-27 實際撞到的繞法(v1 grep -qw POST 漏掉的),該擋 ──"
run 2 'url = "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues"; req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=h); urllib.request.urlopen(req)'
run 2 'python3 -c "requests.post(f\"{API}/repos/{REPO}/issues\", json={\"title\":\"x\"})"'
echo
echo "── ISEP#72:範圍擴大到 milestonelabelPR,該擋 ──"
run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones -d @m.json'
run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/labels -d @l.json'
run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/pulls -d @p.json'
run 2 'curl -X POST https://git.uncle6.me/api/v1/orgs/inkstone/labels -d @l.json'
echo
echo "── ISEP#72:帶 ID 的 milestonelabel/PR 子路徑,不該擋(改狀態/合併,不是新增)──"
run 0 'curl -X PATCH https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/3 -d @m.json'
run 0 'curl -X PATCH https://git.uncle6.me/api/v1/repos/inkstone/ISEP/labels/9 -d @l.json'
run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/pulls/12/merge'
echo
echo "── ISEP#72:有戳記時,milestonelabelPR 也放行(不是只有 issues)──"
python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))"
run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones -d @m.json'
rm -f /tmp/.ticket-where-ok
echo
echo "── ISEP#72Arcrun#100 重演——標題撞同主題舊票,擋下來還要點名 ──"
echo " (這條打真實 Gitea 網路;離線環境會印 SKIP,不算失敗。標題故意用"
echo " 裸引號的 python dict 寫法,跟 2026-08-27 那 13 張票實際的寫法一致——"
echo " curl -d 那種「JSON 被跳脫成字串塞進另一層引號」的寫法,本閘的猜題功能"
echo " 刻意不支援,見 hook 檔頭「只認雙引號」那段註解,訊息裡不會列候選舊票,"
echo " 但擋下來這件事不受影響,另有獨立案例覆蓋。)"
if git remote -v 2>/dev/null | grep 'git\.uncle6\.me' | grep -q '@'; then
OUT=$(printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' 'req("POST", f"{API}/repos/{REPO}/issues", {"title":"總圖說我一條關聯都沒有,三元組 graph 全部消失"})')}}" \
| bash "$H" 2>&1 >/dev/null)
RC=$?
if [ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'Arcrun#100'; then
PASS=$((PASS+1)); echo " ✅ 擋下來(rc=2)且點名 Arcrun#100"
else
FAIL=$((FAIL+1)); echo " ❌ rc=$RC,訊息裡有沒有 Arcrun#100"; printf '%s\n' "$OUT" | grep -q 'Arcrun#100' && echo "有" || echo "沒有"
printf '%s\n' "$OUT"
fi
else
echo " ⏭️ SKIP(這台機器的 git remote 沒帶 git.uncle6.me 的憑證,連不上就不算數)"
fi
[ -n "$SAVED" ] && printf '%s' "$SAVED" > /tmp/.ticket-where-ok [ -n "$SAVED" ] && printf '%s' "$SAVED" > /tmp/.ticket-where-ok
echo echo
echo "$PASS/$((PASS+FAIL)) 通過" echo "$PASS/$((PASS+FAIL)) 通過"
+244 -1
View File
@@ -18,6 +18,12 @@ leo 2026-08-16 三句話,本工具就是它們的機械化:
ticket close <票> --deliverable <URL> 關票(要有交付物連結) ticket close <票> --deliverable <URL> 關票(要有交付物連結)
ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作) ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作)
三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒):
ticket subtask <母票> --title <US> -F <檔> 討論串裡的一件事 → 看得見的子票+相依
ticket handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作
ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44` 票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
""" """
import json import json
@@ -389,7 +395,244 @@ def cmd_decide(argv):
print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。") 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-27Gitea 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 \"<User Story>\" -F <內文檔>\n"
" [--repo <收件 repo,預設跟母票同一個>] [--label <s/xxx,預設 s/todo>]\n"
" [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步")
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")
nxt = opt("--next")
if assign and not nxt:
die("🚫 指派了人卻沒寫 --next。\n"
" **有人被指到這張票,他撈到它的第一件事就是問「所以我要做什麼」**——\n"
" 那句話現在就要寫下來,不是等他重讀整串。\n"
" (沒有要指派給誰就不要加 --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")
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",
{"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 <owner/repo#N> --to <claude-code|Leo> --next \"<下一步一句話>\"\n"
" [--label <s/xxx>] [--evidence <URL 或一行實測>]\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 __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in CMDS: if len(sys.argv) < 2 or sys.argv[1] not in CMDS: