Compare commits

...

35 Commits

Author SHA1 Message Date
claude-code 736a601589 Merge pull request '盤點表補回漏掉的兩列人話+補上驗得出這種漏的指令(inkstone/ISEP#75)' (#76) from docs/inventory-missing-rows into main 2026-08-27 10:39:59 +00:00
claude-code 1b64f3cc4e Merge pull request '跑測試不再偽造「有一筆推 main 在等你裁」(inkstone/ISEP#59)' (#77) from fix/tests-leave-no-forged-pending into main 2026-08-27 10:38:48 +00:00
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 950c3e1919 測試不再偽造「有一筆推 main 在等總管裁」(inkstone/ISEP#59)
現象(實測,不是推論):跑完 main-and-prod-push-guard 那三支測試之後

    $ git status --short
     M hooks/lib/__pycache__/strip_heredoc.cpython-314.pyc
     M pending-main-push/unnamed--ISEP.md
    ?? pending-main-push/unnamed--A.md

成因:這支閘擋下推 main 的同時,會把那次請求寫成
`<hooks 的上一層>/pending-main-push/<誰>--<repo>.md`,而三支測試的測資本來
就全是推 main。於是每跑一次測試,工作區就多/改幾筆**偽造的待裁決**。

兩個後果,後者比較貴:
  ① `git add -A` 很容易把它們帶進 commit(08-27 那次真的帶進去了,事後才拔掉)
  ② 總管的迴圈讀那個目錄,讀到的每一筆都該是真的在等他裁——
     測試每跑一次就偽造一筆 ⇒ **下一筆真的請求會混在雜訊裡**。
     這跟「永遠在響的警報」是同一個病。

改法(產品程式碼一行都沒動):
- 新增 hooks/tests/lib/hook-sandbox.sh:把整個 hooks/ 複製到暫存區再跑複本。
  閘算 pending 目錄的位置靠的是 `$0` 的上一層 ⇒ 請求寫進暫存區。
  **刻意不在閘上開一個「寫去哪」的環境變數**——那種開關同時是一條把紀錄關掉的路。
- 三支測試改測沙盒複本,並各補兩條斷言:
  ① repo 的 pending-main-push 一個位元都沒動
  ② 沙盒裡**真的有**留下請求(只驗 ① 的話,把留紀錄的功能整個關掉也會綠)
- pending-main-push/ 不再進版控(它是本機狀態不是原始碼),只留一份 README 說明規約;
  既有的兩筆與那顆被追蹤的 .pyc 一併 `git rm --cached`,檔案留在硬碟上不刪。

實測:
- 三支各自 10/10、19/19、13/13(原本 8/17/11,各多兩條新斷言)
- 把 `H` 改回真跡重跑 ⇒ 新斷言兩條都紅(11/13)+工作區又髒
  ⇒ 這兩條斷言真的抓得到它要抓的東西
- 全套 16 支測試檔跑完 0 失敗,`git status` 沒有多出任何一行
- `claude plugin validate .` ✔ Validation passed

沒有升版:這次只動測試與版控範圍,沒有任何閘的行為改變,
不需要靠新版本號送到任何人手上;請跟著總管下一個 release 一起出去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:33:44 +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
Leo 3bc7f3c5f5 派工單只剩票號——閘從驗「有沒有票號」改成驗「是不是只有票號」
leo 2026-08-27(inkstone/ISEP#30 comment 4322/4325/4327):
「這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?」
「你用一個 output parser 把你給 subagent 的指令規範,分作幾點,每一點規定格式,
  照這種散文寫法根本無法迭代」「警察也不能抓」
「交件方式不需要寫,定義在原則裡⋯⋯每次都一樣提取出來變成共通規定」
「(那些 session 事實)這些為什麼不寫到票裡?」「subagent 回覆時要表明身份」

病根:no-ticket-no-dispatch.sh 驗的是「有沒有一行【工單】owner/repo#N」,
而規則的原文是「派工單只寫票號」。⇒ 把 40 行任務全寫在 prompt 裡、票號補一行,
閘照樣放行。2026-08-27 一天內這樣做了 5 次,每次票上都沒有那份任務。
規則存在,閘只驗了它的殼——同款第 N 次(history-first/KBDB-first/stage-first)。

新增 hooks/dispatch-format-guard.sh(PreToolUse Task|Agent),兩件事:
- 擋:【工單】以外還有實質內容就 exit 2,並指出那些內容該搬去哪
  (每次都一樣 → 共通規定;這次才知道 → 寫進那張票。
   判準「這句話換一張票還成立嗎?」)
- 注入:合規的派工自動把共通規定送給收工方(交件方式、不准 push main、
  org 是 inkstone…)——這是「派工單只剩票號」能成立的前提,
  leo 的驗收條件之一就是「收工方沒讀派工單也知道要貼回原票」

判準是結構不是文字(leo 2026-08-17 那條檢驗):問的是「這一行是不是【工單】欄位」
——在不在,不是寫什麼。hooks/lib/dispatch_parse.py 全檔零個「命中某個詞就違規」的比對。
⇒ 也因此不需要語意判官:免費、瞬間、每次結果一樣。

新增 hooks/reply-identity-guard.sh(PreToolUse Bash)+ scripts/ticket 內建檢查:
票上每一則留言第一行要有【身份】(總管/subagent/leo)。貼留言有兩條路,兩條都封
——ticket-api-bypass-guard 是刻意放行「對既有票留言」的,只封正門等於沒封。
實害:多條線並行時總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。

規約寫成文件:docs/governance/dispatch-and-reply-format.md
(§2 那段就是被注入的那份共通規定本體——只有一份,改那裡等於改所有派工)

實測(離線、不打網路、不花錢):
  hooks/tests/dispatch-format-guard.test.sh   19/19
  hooks/tests/reply-identity.test.sh          11/11
測資裡的 B⑨ 是真跡:產生 ISEP#30 這條線的那一次派工,一字未改。
另 4 份 leo 點名的違規派工拿不回來了——它們住在 prompt 裡,agent 一停就沒了,
這件事本身就是這條規則的證據(見 hooks/tests/fixtures/README.md,不用想像的例子替補)。

升版 0.4.0 → 0.5.0(產物按版本號分資料夾,不升版新閘不會被載入)。
tag 照慣例打在 merge commit 上,所以這條分支上 check-version-consistency.sh 是紅的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:35:03 +08:00
Leo 8e7e265589 merge: 閘掛在收工後,而 leo 是在被問到的當下被打擾——補上那一刻的攔截(InkStoneCo#55)
leo 2026-08-26:「今天已經好幾次問我,為什麼 hooks 沒有攔下來?」

總管複驗過才併:
- 掛點:PreToolUse / matcher AskUserQuestion(自己從 hooks.json 解出來的)
- 封動作不封文字:整支 261 行零個判擋用的正則,決定 exit 2 的只有判官 verdict;
  判官掛掉/回空/不在 PATH 一律 fail-open
- 測試自己跑過 14/14(含「樹要不要捲」那種真人閘必須放行的案例)
- 版本 0.3.9 → 0.4.0:第一版忘了升版,而產物是按版本號分資料夾的
  ⇒ 不升版=新閘永遠不會被載入。退回補後已驗產物資料夾長得出來。

🔴 仍有一格是 report 不是 deliver:「新 session 真的會叫它」驗不到——
AskUserQuestion 官方明載 subagent 不可用,它撞不到自己這道閘。
那一格要 leo 開新 session 才驗得了。
2026-08-26 22:43:34 +08:00
claude-code e0557334bc 閘掛在「收工後」,而 leo 是在「被問到」的當下被打擾——補上那一刻的攔截
leo 2026-08-26:「今天已經好幾次問我,為什麼 hooks 沒有攔下來?」

實查:總管問 leo 走的動作是 AskUserQuestion,而 hooks.json 裡它出現 0 次,
沒有任何 matcher。判準其實早就寫好了——self-drive-police / self-drive-judge
用的就是同一套四題公式——但那兩支只掛在 Stop 與 SubagentStop,
是回合結束後才跑的。問題早就送到他眼前了,事後再反問「你查過了嗎」,
成本已經轉嫁出去。判準對了,時機錯了。

新增 hooks/ask-user-question-guard.sh(PreToolUse / AskUserQuestion):
- 觸發條件是那個動作本身,不是任何句型或關鍵字——全檔零個判擋用的正則,
  換句話說閃不過去,講得謹慎也不會被多罰(leo 2026-08-17 文字層封路的檢驗)
- 進來之後用四題公式的 haiku 判官分「該問 / 不該問」,命中任一題一律放行
- 同一個問題只擋一次(雜湊戳記):判官誤判時重送即過,
  leo 該收到的問題不會因為一支閘而永遠送不到
- 判官掛掉/沒網路/claude 不在 PATH 一律 fail-open,壞掉等於它不存在

版本 0.3.9 → 0.4.0。這不是儀式,是傳輸機制本身:產物按版本號分資料夾
(~/.claude/plugins/cache/inkstone/isep/<版本>/),版本沒動就不會長出新資料夾,
這支閘一個 session 都載入不到。docs/TESTING.md 開頭那段講的就是這件事,
而第一版我漏了——總管複驗時量出來的。

假設(沒有前例可循,先裁再記):跳 0.4.0 而不是 0.3.10。理由是這一版第一次
掛上 AskUserQuestion 這個事件面,是新能力不是修補;而且 0.3.10 在
plugin 快取目錄的 ls 裡會排到 0.3.1 旁邊,肉眼不好認。錯了打回,改號很便宜。

順手修掉自己寫出來的一個坑:訊息原本用沒加引號的 heredoc,
反引號被當命令執行,wiki 路徑與豁免指令兩行變成空白(閘照擋,只看離開碼看不出來)。
已收成 mistakes.md 一條,並由 ⑩b 這條測試守著。

實測:
- hooks/tests/ask-user-question-guard.test.sh      14/14(離線,不花錢)
- hooks/tests/ask-user-question-guard.live.test.sh 9/9 連跑三次(真的叫 haiku)
  A 群 5 條真人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘)全部放行,誤攔 0
  B 群 4 條純技術路徑選擇全部擋下
- 既有 7 支測試與改動前逐條對照,結果完全相同(沒有被我弄壞)

順手對帳:plugin.json 與 marketplace.json 的描述寫「43 支機械閘、53 條註冊」,
實際數過是 46/56(含本次新增這支)。docs/hooks-inventory.md 一併更正。

【工單】inkstone/InkStoneCo#55
2026-08-26 22:36:08 +08:00
claude-code d4547bfec3 Merge pull request '信標自己講「這一份是誰」——vendor 還是 plugin 快取' (#55) from fix/beacon-says-which-copy into main 2026-08-23 13:03:37 +00:00
Leo 2f43ecc346 信標自己講「這一份是誰」——vendor 還是 plugin 快取
leo 的雲端驗收整整卡了一輪在這個問題上:同一台機器可能有兩份 ISEP,
兩份都會印信標,版本號一樣時分不出誰在說話。而「閘從哪一份走」正是決定
「另一份能不能拆」的唯一判準。

總管上一版設計的判準(叫雲端跑 env | grep CLAUDE_PLUGIN_ROOT)也是錯的:
那個變數是 hook 呼叫當下才注入的,在 Bash 工具的 env 裡本來就看不到
⇒ 空輸出不代表沒載入,那個探針從一開始就答不了這題。

改法:讓路徑自己講。快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。

三向實測:
  /root/.claude/plugins/cache/inkstone/isep/0.3.9  → 來源:plugin(marketplace 裝的)
  /home/user/inkstoneco/.claude/isep               → 來源:vendor(repo 裡的複製本)
  其他路徑                                          → 來源不明(不假裝知道)

inkstone/InkStoneCo#57
2026-08-23 21:02:45 +08:00
claude-code 1b95388a0f Merge pull request '身為總管,我要這道閘擋的是「宣告」而不是「提到宣告」,我才不會一舉報 bug 就被它擋下' (#54) from fix/factory-idle-guard-blocks-actions-not-mentions into main 2026-08-23 09:58:41 +00:00
Leo e3d05df341 再修兩類:主詞是 leo 的下一步、票號放寬把閘變鈍——都是真 transcript 量出來的
交付警察擋回來是對的:前一顆只驗了「我自己造的假 transcript」。
改用本機一條 2068 行的真 session(26 個真實回合終止點)重驗,當場多找到兩個問題:

① 主詞是「你」的下一步,被當成我的宣告。
   舊閘在 26 個真實回合裡擋了 2 次,兩次咬的都是我在交代 leo 該做什麼:
     「下一步還是那一個動作:你把 feat/... 併進 main」
     「## 你下一步(兩招,先便宜的)」
   ⇒ 這是全新的第四類誤攔,我原本一向都沒列到。
   修法是主詞檢查(誰要動手),只掛在「下一步」這條 alternative 上;
   用 finditer 逐個檢查前 8 字有沒有第二人稱,蓋得到「你的下一步」這種
   單字 lookbehind 蓋不到的變體。「你點頭我就做」主詞本來就是我,不受影響。

② 票號從「宣告句附近」放寬成「整段」,把閘變鈍了。
   真數據:26 個真實回合有 20 個是靠「文中某處剛好有票號」放行的——
   而報告幾乎一定會提到票號 ⇒ 這道閘在實務上等於不會響。
   當初放寬是因為票號常寫在行內 code 裡,剝掉就找不到。
   ⇒ 改成**等長**替換(蓋成同樣長度的哨兵而非刪除),位移就能對回原文,
     locality 與「行內 code 裡的票號也算數」兩件同時成立。
   收緊後的判定分佈:21 no-declaration / 3 dispatched / 2 ticket-referenced
   (原本是 20 ticket-referenced / 3 no-declaration / 3 dispatched)。

真 transcript 實測:舊閘擋 2 次(兩次都是誤攔)→ 新閘擋 0 次。
測試 33 向(+6):舊版 21/33 → 新版 33/33。
版號 0.3.7 → 0.3.8。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:54:17 +08:00
Leo a594decb78 稼動率警察改成擋「宣告」,不擋「提到宣告」
2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖:
① 否認自己有下一步 ②引用閘自己的訊息 ③貼閘自己的正則原始碼舉報 bug。
而訊息教人走的「選項③:說明它在等什麼」,程式碼裡根本沒有那條分支
——唯一走得通的路是不寫那三個字,正是同一則訊息明文禁止的動作。

四個真兇,沒有一個是「例外沒列夠」:
(a) DECL 會匹配裸的「下一步」三個字(每一節都可選 ⇒ 退化成關鍵字)
    ⇒ 收緊:每一條 alternative 都必須接到動作動詞才算命中
(b) 只剝 > 引言與長「」,不認 code fence 與行內 code ⇒ 引用被當成主張
    ⇒ 引用性標記整段換成哨兵(不是刪掉):內層宣告消失、外層句構留著
      ——刪掉正是 08-17 漏掉「回『規劃』我就派人」的原因,兩個方向一起修
(c) 取 blocks_text[-1],但那則文字後面可能還有 tool_use ⇒ 宣告其實兌現了
    ⇒ 只看「最後一個動作之後」的文字;收尾在動作上就不觸發
(d) 訊息承諾的出路只有兩條真的存在
    ⇒ 出路③ 給一個機械形式 ⏸ 等:<在等什麼>(白名單標記,要刻意寫,留痕)

方向刻意與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
守 leo 的封路哲學:紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。

順手:擋下與放行都留痕(InkStoneCo#48:只記擋下的話分母未知);
log 目錄不在時安靜跳過,不再噴 redirect 錯誤到 stderr。

測試 hooks/tests/factory-idle-guard.test.sh 27 向,誤攔與漏攔兩個方向都測:
舊版 18/27(9 敗)→ 新版 27/27。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:42:22 +08:00
claude-code 5789f0a917 Merge pull request '信標改數「真的被註冊的閘」——它一直多報一支樣板' (#53) from fix/beacon-counts-registered-gates into main 2026-08-23 08:52:30 +00:00
Leo b1f399f8b9 信標改數「真的被註冊的閘」——它一直多報一支樣板
leo 的雲端驗收(2026-08-23)抓到:信標說 44 支,`ls hooks/ | wc -l` 是 49,對不上。
追下去是 `ls "$ROOT"/hooks/*.sh | wc -l`:
把 `pre-write-guard.template.sh`(樣板,不是閘)與兩支沒掛註冊的輔助檔一起算進去。

🔴 這個數字是 leo 判斷「這個 session 到底有沒有閘」的唯一介面——**多報就是假綠**。

改成數 `hooks.json` 裡註冊過的唯一 `.sh`;hooks.json 讀不到才退回檔案數(且排除樣板)。

實測兩向:
  正向(真的 plugin 根目錄)      → 42(與 hooks.json 註冊數一致)
  反向(沒有 hooks.json 的假根)  → 2(三個檔裡有一個是 .template.sh,沒算進去)

查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。

inkstone/InkStoneCo#57
2026-08-23 16:52:01 +08:00
claude-code a446ad6d1d Merge pull request '推 main 的戳記改綁「push 真正的目標 repo」,不再綁 hook 自己的 cwd' (#52) from fix/push-guard-cross-repo-stamp into main 2026-08-23 07:31:01 +00:00
Leo 41c56acd32 推 main 的戳記改綁「push 真正的目標 repo」,不再綁 hook 自己的 cwd
跨 repo 交辦時(總管站在 A repo,要推 B repo 的 main)main-and-prod-push-guard
的戳記機制永遠對不上:HERE 讀的是 hook 自己的 cwd(=session 的真身,不會變),
WANT 是總管替目標 repo(B)寫進戳記的路徑——兩者結構性地不可能相等,不是
判斷錯,是這個情境在舊模型裡根本不存在(inkstone/ISEP#30 comment 3949,
脈絡 inkstone/InkStoneCo#57,2026-08-21 實撞)。

新增 hooks/lib/push_target_dir.py:純 tokenize(不執行任何指令)解析指令裡
`cd <path> && git push` 或 `git -C <path> push` 真正會落地的目錄,對多層 cd
鏈與子殼(`(cd A && ...); git push` 這種子殼 cd 不能外洩出去)都做了範圍化——
這條範圍化是防穿透的關鍵,不是順手:沒有它,`(cd A && true); git push`
會被誤判成推向 A,讓替 A 開的舊戳記錯誤地放行推到殼外真正的目標。解不出來
一律退回舊行為(hook 自己的 cwd),維持 fail-closed 方向不變。

順手修掉補測時自己抓到的另一個洞:`(git push origin HEAD:main)`——單純加一層
括號——舊版目的地判斷完全偵測不到,整段直接放行,跟戳記無關。成因是截斷
refspec 尾巴的 sed 只認 `;`/`&`/`|` 三種字元,沒算到 `)`;補上即可,git 的
refspec 語法本來就不允許出現 `)`,這裡截斷永遠安全。

綁 repo+單次用完即丟兩條 2026-08-11/12 用血換來的性質完全沒有鬆動:只是把
「現在人在哪個 repo」問得更準,比對邏輯一個字沒動。

實測:
- hooks/tests/main-and-prod-push-guard.test.sh 舊有 8 向:8/8
- scripts/test-main-and-prod-push-guard.sh 舊有 11 向:11/11
- 新增 hooks/tests/main-and-prod-push-guard-cross-repo.test.sh 17 向
  (跨 repo 正向/反向不准鬆/git -C/子殼範圍化/括號洞/單次用完即丟/
  900 秒逾時/空戳記/既有行為零回歸):17/17

本輪只驗證,未拿去放行任何真實推送;plugin.json 隨慣例 bump 0.3.4 -> 0.3.5
並重跑 vendor-to-shell.py(.shell-payload 為 gitignore 產物,不入版控)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:18:28 +08:00
claude-code 2eb9b2aaaa Merge pull request '身為總管,我要閘擋的是動作不是字面,我才不會一晚被自己的閘擋六次' (#51) from fix/push-guard-target-not-substring into main 2026-08-20 17:30:46 +00:00
Leo 67dae3b814 推送閘改成判目標,不判整條指令裡有沒有那個字
一個晚上誤攔六次,全都不是在推預設分支:
  ① checkout -b 建新分支時把預設分支寫在後面,再推那條新分支
  ② gh pr create 指定 base——根本不是 git push
  ③ 推 tag(refs/tags/…)
  ④ 推 feature 分支(帶 -u)
  ⑤ 它擋住了我用來**測試它自己**的那條指令
  ⑥ 它擋住了這一筆的 commit——因為 message 裡引用了那幾個字

leo 2026-08-17 早就講過這個形狀:文字層封路必敗,
「紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎」。
舊版掃整條指令字串,正是文字層。

改成解析 push 的目標 refspec:
  旗標跳過/第一個非旗標=remote/a:b 取 b/refs/tags/* 不算分支
  一個 refspec 都沒給,才退回看當前分支

八向實測(hooks/tests/main-and-prod-push-guard.test.sh,8/8):
  五種該放行的(今晚誤攔的原形狀,含分支名帶 domain 那種)全過
  三種該擋的全擋

中途自己抓到一個 bug:tag 被跳過後目標清單變空 → 退回猜當前分支
⇒ 當前分支剛好叫預設名時誤擋。改成看到 refspec 就不退回猜測。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:29:55 +08:00
claude-code 43c328d26f Merge pull request 'feat/milestone-must-have-due' (#50) from feat/milestone-must-have-due into main 2026-08-20 17:26:08 +00:00
claude-code bcb736ed19 Merge pull request 'fix/worksheet-dedup-by-content' (#49) from fix/worksheet-dedup-by-content into main 2026-08-20 17:26:06 +00:00
Leo 1b5551274a 待驗工作單改用宣稱內容去重,驗過的不再冒出來
stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
而且不知道總管已經驗過了。

實際發作(2026-08-21 一個 session 內):同兩條 sdd-guard 宣稱連生四張單
1c97d461/fcb285dc/256de849/394b97ae——驗掉一張下一回合又冒一張,
Stop 閘於是變成永遠過不去。閘在懲罰有照做的人。

改成雜湊宣稱內容本身,並在寫檔前檢查 verified/ 底下有沒有同名。

雙向實測:
  兩次不同 transcript、同樣宣稱 → 只生 1 個檔
  移進 verified/ 後再跑         → SKIP:already-verified,沒再冒出來

過程中兩個自己的錯,記下來免得下次重犯:
  ① 先猜了變數名 blocked/ok/nogo,實際是 ok_hits/ng_hits
     ——猜錯的話 _claims 永遠是空的、悄悄退回舊行為,不會報錯
  ② 測試資料先寫成 role:assistant,再寫成 role:user 都不觸發
     ——它要的是 <task-notification> 裡的 <result>
     前兩次「0 個檔」我差點當成去重成功

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:18:16 +08:00
50 changed files with 3889 additions and 471 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"plugins": [
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"author": {
"name": "Leo",
"url": "https://uncle6.me"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.3.3",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.6.1",
"keywords": [
"inkstone",
"guardrails",
+13
View File
@@ -3,3 +3,16 @@
cloud-env*.txt
*.env
.shell-payload/
# import 共用零件時 python 自己生的,不是原始碼
__pycache__/
*.pyc
# 推 main 被擋下時留的請求(main-and-prod-push-guard.sh 寫的)。
# 這是**本機當下的狀態**,不是原始碼:一份檔案的語意是「有一筆推 main 正在等總管裁」。
# 進版控會有兩個後果(inkstone/ISEP#59 comment 4779 第 ① 條,兩個都實際發生過):
# ① `git add -A` 把它帶進 commit ⇒ 別人 clone 下來就多一筆**別人的**待裁決
# ② 被追蹤的檔每次被覆寫都變成 ` M` ⇒ `git status` 永遠是髒的
# (寫成 `目錄/*` 而不是 `目錄/`:後者連目錄本身都排除,下一行的例外就永遠生效不了)
pending-main-push/*
!pending-main-push/README.md
+5 -1
View File
@@ -23,7 +23,7 @@
| | 數量 | 是什麼 |
|---|---|---|
| `hooks/` | 43 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 53 條註冊) |
| `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` … |
| `skills/` | 2 支 | |
| `scripts/` | 23 支 | `ticket``github-arm.sh``gitea-bootstrap.sh` … |
@@ -48,6 +48,10 @@ hook 一律用官方的 `${CLAUDE_PLUGIN_ROOT}`**不准寫死絕對路徑、
**不用點開任何 `.sh`**——`docs/hooks-inventory.md` 一支一行白話,按「你會在什麼時候撞到它」分組。
測試手冊在 `docs/TESTING.md`,治理規範在 `docs/governance/`
🔴 **派工前先讀 `docs/governance/dispatch-and-reply-format.md`**——
派工單只給票號(其餘全部寫進票或共通規定),票上的每一則留言第一行要表明身份。
兩件事都有機械閘(`dispatch-format-guard.sh``reply-identity-guard.sh`)。
## 版本
**「ISEP 現在是哪一版」只有一個地方答得出來:[Gitea Releases](https://git.uncle6.me/inkstone/ISEP/releases)。**
+79 -4
View File
@@ -43,12 +43,35 @@ bash scripts/test-release-tag-guard.sh
**該看到**`3/3 通過`1 個該擋、2 個不該擋)。
**失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。
### A4 — 開票側門閘:13
### A4 — 新增 Gitea 東西的側門閘:24
```
bash scripts/test-ticket-api-bypass-guard.sh
```
**該看到**`13/13 通過`
**失敗**:任何一條不符,特別看「不該擋」那 8 條。
**該看到**`24/24 通過`(前 13 條是 v1 的開票案例;後面是 inkstone/ISEP#72
擴大範圍後補的:隱式/小寫 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 的
```
@@ -60,6 +83,54 @@ python3 scripts/ticket where 標籤 模組化
ISEP 的叫 `origin`,於是這道閘在新 repo 等於不存在)
- 結果只有單一 repo ⇒ 搜尋沒有跨 repo,等於沒搜
### A9 — 人閘警察的管路:該擋的擋、壞掉不會卡住 session
```
bash hooks/tests/ask-user-question-guard.test.sh
```
**該看到**`14/14 通過`。**不打網路、不花錢**(判官用替身)。
**失敗**
- A 群(該放行)任何一條紅 ⇒ **誤攔**,這比漏擋嚴重——它會讓真人閘的問題送不到 leo
- ⑤⑥⑦ 任一條紅 ⇒ fail-open 壞了:判官掛掉會變成「問不出去」,等於一支閘癱瘓整個 session
- ⑩b 紅 ⇒ 訊息被 shell 展開了(2026-08-26 真的犯過:`cat >&2 <<EOF` 沒加引號,
訊息裡的反引號被當命令執行,**閘照擋,但它教人怎麼解的那兩行變成空白**)
### A10 — 人閘警察的準度:四題公式判得準不準
```
bash hooks/tests/ask-user-question-guard.live.test.sh
```
🔴 **這支真的會叫 haiku**(9 題、每題一次呼叫,整支約 2 分鐘)。
**該看到**`9/9 通過`,且結尾的「A 群誤攔」計數是 **0**
**失敗**
- **A 群紅(誤攔真人閘)=最嚴重**:等於讓總管替 leo 決定他的品味。看到就停下來改判準,不要放著
- B 群紅 = 漏擋,判官把純技術題當成人閘。改 `ask-user-question-guard.sh` 裡判官提示的
③④ 兩題定義,**不要改成關鍵字比對**(那是被明令禁止的文字層封路)
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
### A11 — 派工單只剩票號:擋得住,也放得過,而且會注入共通規定
```
bash hooks/tests/dispatch-format-guard.test.sh
```
**該看到**`19/19 通過`。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官,
所以它不需要像 A10 那樣另開一支 live 測試量準度,**每次結果都一樣**。
**失敗**
- A 群任何一條紅 ⇒ **誤攔**。合規的派工只有一行票號,擋掉它等於整台機器派不了工
- ⑦ 紅 ⇒ **共通規定沒有被注入**。這是「派工單只剩票號」能成立的前提:
交件方式、不准 push main、org 是 `inkstone` 這些不必有人記得寫,機器每次都補。
它壞了不會有人立刻發現——派工照樣送出去,只是收工方**不知道要貼回原票**
- ⑨ 紅 ⇒ 真跡放行了。那份測資是**真的發生過的那一次派工**(見 `hooks/tests/fixtures/README.md`
- ⑰ 紅 ⇒ 訊息被 shell 展開了(同 A9 ⑩b 那個病:閘照擋,但它教人怎麼解的那兩行變成空白)
### A12 — 票上的每一則留言都認得出是誰寫的(兩道門)
```
bash hooks/tests/reply-identity.test.sh
```
**該看到**`11/11 通過`。離線,正門的案例全部在打 API 之前就結束,不會真的送出留言。
**失敗**
- ③ 紅 ⇒ 誤攔了「GET 撈留言」。那是最常做的動作,擋它比漏擋更糟
- ①⑧ 紅 ⇒ 有一道門沒守住。**貼留言有兩條路**(`scripts/ticket` 正門、Gitea API 側門),
只封一條等於沒封——`ticket-api-bypass-guard.sh` 是**刻意放行**對既有票留言的
### A6 — 標籤對齊且冪等
```
bash scripts/gitea-labels-sync.sh
@@ -193,9 +264,13 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
| A1 manifest 合法 | 總管 | ✅ |
| A2 版本三處一致 | 總管 | ✅ |
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
| A4 開票側門閘 | 總管 | ✅ 13/13 |
| **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/242026-08-27inkstone/ISEP#72 |
| A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
| **A11 派工單只剩票號** | 總管 | ✅ 19/192026-08-27 |
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/112026-08-27 |
| A7 plugin 裝得起來 | 總管 | ✅ |
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
| **B1B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment |
@@ -0,0 +1,113 @@
# 派工單與交件回覆的格式(共通規定)
> leo 2026-08-27`inkstone/ISEP#30` comment 4322 / 4325 / 4327):
> 「你用一個 **output parser** 把你給 subagent 的指令規範,**分作幾點,每一點規定格式**,
> **照這種散文寫法根本無法迭代**」/「**警察也不能抓**」/
> 「**交件方式不需要寫,定義在原則裡,每張票都要做這件事⋯⋯每次都一樣提取出來變成共通規定**」/
> 「(那些 session 事實)**這些為什麼不寫到票裡?**」/「**subagent 回覆時要表明身份**」
**本檔就是那份「共通規定」。** 它不是給人讀完記住的——
`hooks/dispatch-format-guard.sh` 會把 §2 自動注入每一次派工,
所以收工方**沒讀派工單也會拿到**。
---
## 1. 派工單 票號。就這樣
```
【工單】inkstone/ISEP#30 → comment 4322
```
要帶兩張票就兩行 `【工單】`。**沒有第二個欄位。**
### 為什麼
派工單裡想寫的東西只有兩種,**兩種都不該留在派工單**:
| 種類 | 舉例 | 該住哪 |
|---|---|---|
| **每次都一樣** | 交件方式、不要 push main、org 是 `inkstone`、先讀該 repo 的 CLAUDE.md | **本檔 §2**(機器自動注入) |
| **這次才知道** | `main` 現在是哪顆、今天撞過什麼、另一條線正在動同一個 repo | **寫進那張票** |
**判準一句話:「這句話換一張票還成立嗎?」**
還成立 ⇒ 共通規定。只有這次成立 ⇒ 寫進這張票。**兩種都不進派工單。**
🔴 **「票上還沒有」不是把它寫進 prompt 的理由——它是「去把它寫上票」的指令。**
寫進 prompt 的後果:那個 agent 被停掉或換人接手,那段事實就隨 prompt 消失。
2026-08-27 實害:總管停掉重派 3 次,前兩次的任務與 session 事實**全部蒸發**。
### 機械閘
`hooks/dispatch-format-guard.sh`PreToolUse `Task``Agent`)——
`【工單】` 以外還有實質內容就擋,並指出那些內容該搬去哪一格。
判準是**「這一行是不是【工單】欄位」**(在不在),不是「它寫了什麼」。
---
## 2. 共通規定(每一次派工由機器自動注入給收工方)
<!-- INJECT:BEGIN 這段之間的內容會被 dispatch-format-guard.sh 原文注入,改這裡=改所有派工 -->
### 你收到的派工單只有一個票號。任務全文在票上。
1. **第一個動作是去讀那張票**(含每一則 comment)。派工單不會再給你別的東西——
這是刻意的:票活得比任何一個 agent 久。
2. **票上的脈絡不夠 ⇒ 回票上問**,不要憑猜測動手,也不要回頭問派工的人要細節。
3. **交件=貼回那張票**`scripts/ticket say <owner/repo#N> -F <檔>`),
不是只在對話裡回報。回覆第一行必須是身份欄,見下。
4. **回覆第一行一律是**
`【身份】subagent<owner/repo><你的分支>`
角色三選一:`總管``subagent``leo`
5. **不准 push 到 `main``master`**,也不准部署 prod。做在自己的分支上,交回分支名。
6. **Gitea 的 org 是 `inkstone`**(不是 `Leo`);`gh` 打不到 Gitea
標籤是 `s/*` 不是 `status/*`
7. **先讀你要動的那個 repo 的 `CLAUDE.md` 與 `system-dev/wiki/`**,照它的慣例走,
不要照你自己習慣的做法。
8. **交出去之前,你要知道它能不能用**——貼實測輸出,不是「我測過了」。
有一格沒驗 ⇒ 那是 report 不是 deliver,講清楚哪一格。
9. **改了會被載入的東西(pluginworkerbundle)就要升版**
否則產物按版本號分資料夾,你的改動到不了任何人手上。
<!-- INJECT:END -->
> 🔴 **這一段是「每次都一樣」的唯一真相源。**
> 想在派工單裡加一句叮嚀之前,先問:它換一張票還成立嗎?
> 成立就加在這裡(改一次,全機生效),不要加在那一次的 prompt 裡。
---
## 3. 交件回覆 = 第一行表明身份
```
【身份】subagentinkstone/ISEPfeat/ticket-carries-the-task
```
- 角色是**三選一的允許清單**`總管``subagent``leo`
- 第二格是你動的 repo,第三格是分支(沒有就寫 `-`
### 為什麼
2026-08-27 實害:多條線並行時票上的留言看不出身份,
**總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**
🔴 **這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標
`【身份】總管/…`——leo 要分得出哪一則是誰寫的。
### 機械閘(兩道,因為這個動作有兩條路)
| 路 | 閘 |
|---|---|
| 正門 `scripts/ticket say` / `decide` | 腳本內建檢查,貼上去之前就擋 |
| 側門 直接打 Gitea API 貼 comment | `hooks/reply-identity-guard.sh`PreToolUse `Bash` |
> 只封正門的閘等於沒封——`ticket-api-bypass-guard.sh` 的檔頭已經記過這一課:
> 「規範有、閘也有,但閘長在『工具』上,而那個動作有兩條路,只封了一條。」
---
## 4. 這份規範自己怎麼被驗
```
bash hooks/tests/dispatch-format-guard.test.sh 派工單閘:該擋的與不該擋的
bash hooks/tests/reply-identity.test.sh 身份欄:正門與側門兩道
```
測資裡放的是**真的發生過的那幾份違規派工單**,不是想像出來的例子。
+164 -2
View File
@@ -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**,所以 E12E14 這一格目前是
「舊機制已拆、新機制已提案但未生效」的過渡態,不是「已有替代品在跑」。
#58 merge 前,這兩條暫時回到 ❌ 待建的實質狀態,即使表格欄位寫的是 ◐。
---
## 9. 本規範的迭代
@@ -702,3 +717,150 @@ 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,**相依管得到全部**。
📌 探針票留在 `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)。
+111 -13
View File
@@ -1,4 +1,4 @@
# 43 支閘,白話盤點表
# 48 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,10 +7,29 @@
## 一句話結論
`hooks/` 底下有 **43 個 `.sh` 檔**`hooks.json` 實際掛上 **53 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **48 個 `.sh` 檔**`hooks.json` 實際掛上 **59 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的:
> `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`。
---
## 怎麼讀這張表
@@ -37,7 +56,10 @@
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
| `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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 |
| `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` 都放行。 | 🛑 擋 |
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit
@@ -70,11 +92,25 @@
| `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 |
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
| `dispatch-format-guard.sh` | **派工單 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。 | 🛑 擋(+注入) |
## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `ask-user-question-guard.sh` | AI 要跳出來問你一個問題的**那一刻**先攔一下,用小模型(haiku)照「四題公式」判這題該不該打擾你:**花錢/不可逆/跨專案結構/品味方向/只有你做得到**——命中任何一題就放行(那本來就該問你),四題全否(純技術實作選擇、問「要不要開始」)就擋回去要它自己裁。**同一個問題只擋一次**,它重送就過得去,所以判錯不會害你收不到問題;判官掛掉/沒網路也一律放行。 | 🛑 擋(同一題至多一次) |
> **為什麼要有這一組**leo 2026-08-26:「今天已經好幾次問我,**為什麼 hooks 沒有攔下來?**」):
> 在這之前 `AskUserQuestion` 在 `hooks.json` 裡出現 **0 次**,一支閘都沒掛。
> F 組那兩支自走警察(`self-drive-police` / `self-drive-judge`)判準一樣,
> 但它們掛在「收工」那一刻——**問題早就送到你眼前了**,事後再問 AI「你查過了嗎」已經來不及。
> 這一組補的是**時機**,不是判準。
## E. 每個對話一開始(SessionStart
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `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 注入) |
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
@@ -84,22 +120,24 @@
|---|---|---|
| `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) |
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。 | 🛑 擋 |
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
| `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,全部不擋)
@@ -108,6 +146,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
@@ -146,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 條註冊**。
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh``release-tag-guard.sh`
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
@@ -201,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`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。
- **`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 支」做的事,可以照同樣方法定期重做。
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh — PreToolUseAskUserQuestion):**在「問出口的那一刻」攔住**
#
# 病根(leo 2026-08-26):「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
# 當天總管至少兩次把純技術路徑選擇丟給 leo,其中一次他當場說
# 「**這種問題不要問我,我要的是你解決了以後給我 prod**」——而**一支閘都沒響**。
#
# 實查(2026-08-26ISEP 0.3.8):
# • 總管問 leo 走的動作就是 `AskUserQuestion` 這個工具
# • `hooks.json` 裡 `AskUserQuestion` 出現 **0 次**——它沒有任何 matcher,是裸的
# • `self-drive-police.sh` / `self-drive-judge.sh` 只掛在 `Stop` / `SubagentStop`
# ⇒ 它們是**回合結束後**才跑的。問題送出去、leo 已經被打斷之後,
# 再來反問「你查過了嗎」已經沒有意義——**成本早就轉嫁出去了**。
#
# ⇒ 這支補的不是判準(判準跟自走警察同一套四題公式),補的是**時機**:
# `Stop` 是事後補刀,`PreToolUse` 才是當下攔截。
#
# ──────────────────────────────────────────────────────────────────────
# 🔴 封的是動作,不是文字(leo 2026-08-17 的檢驗)
#
# leo 原話:「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫
# 『準備就緒』,再下次寫『待命中』。**自然語言的變體是無限的,blacklist 永遠追不完。**
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實證:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定
# ——**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 本閘怎麼過這個檢驗:
# • **觸發條件=那個動作本身**`AskUserQuestion` 這一次呼叫),
# 不是任何句型、關鍵字、問號、措辭。改寫成什麼樣子都一樣會進來。
# • **沒有任何 grep 黑名單**(全檔零個判擋用的正則)。換句話說閃不過去,
# 講得謹慎也不會被多罰——這正是文字層閘的兩個病,本閘兩個都沒有。
# • 進來之後要分「該問 / 不該問」,靠的是**四題公式的語意判官**
# `self-drive-judge.sh` 已經在用的同一套 haiku 判官),
# 它判的是「這題命不命中人閘」,不是「這句話長什麼樣」。
#
# 🔴 不准把真人閘也擋掉(本閘存在的第二個前提)
# 四題命中時 leo **要**被問——2026-08-26 當天他就當場裁了一題 UI 取捨(樹要不要捲)。
# **擋掉那種等於讓總管替他決定他的品味,比不擋更糟。**
# 所以本閘有兩層保險:
# ① 判官只在「四題全否」時才回 block;任何一題命中一律 allow
# ② **同一個問題只擋一次**(雜湊戳記)——判官若誤判,重送即放行,
# leo 該收到的問題絕不會因為一支閘而永遠送不到。
# (跟 `micromanage-guard.sh``subagent-first-guard.sh` 同一個慣例:
# 閘是絆一下逼你重想,不是把人關起來。)
#
# 判準(跟 CLAUDE.md 規則二、`self-drive-judge.sh` 完全同一套,不另立第二套):
# ① 花錢 ② 不可逆/難回收 ③ 跨專案結構 ④ 品味/方向 ⑤ 物理人閘(只有人做得到)
# 命中任一 → 放行。四題全否 → 擋一次,逼它自己裁。
#
# 失敗一律 fail-openexit 0):判官掛掉/沒網路/`claude` 不在 PATH
# 都只會回到「今天的狀態」(沒有閘),不會變成「問不出去」。
# ⇒ 這支閘壞掉的最壞後果,等於它不存在,不會癱瘓任何 session。
#
# 官方文件核實(2026-08-26 查 code.claude.com,不是憑記憶):
# • `AskUserQuestion` **會**觸發 PreToolUsematcher 寫工具名有效
# — https://code.claude.com/docs/en/agent-sdk/user-input
# 原文:「Claude calls AskUserQuestion. The PreToolUse hook fires.」
# • `tool_input` 的真實結構=`{"questions":[{"question","header","options":[{"label","description"}],"multiSelect"}]}`
# — 同頁「Question format」段
# • exit 2 在 PreToolUse hard block,工具呼叫不會發生,無已知例外
# — https://code.claude.com/docs/en/hooksExit Codes 表)
# • `AskUserQuestion` **在 subagent 裡不可用**(同頁 Limitations
# ⇒ 所以本閘只會在主 session 觸發;subagent 那一側仍由
# `self-drive-police.sh``self-drive-judge.sh` 的 SubagentStop 守著,兩者不重疊。
#
# 測試:
# hooks/tests/ask-user-question-guard.test.sh 離線、不花錢(判官用替身)
# hooks/tests/ask-user-question-guard.live.test.sh 真的叫 haiku,量判官的準度
set -u
# 遞迴保險:判官自己起的那個 claude 不准再觸發本閘
if [ "${SELF_DRIVE_JUDGE:-}" = "1" ]; then exit 0; fi
input="$(cat)"
# ── 只管 AskUserQuestion 這一個動作 ────────────────────────────────
tool_name="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("tool_name", ""))
except Exception:
print("")
' 2>/dev/null)"
[ "$tool_name" = "AskUserQuestion" ] || exit 0
session_id="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("session_id", ""))
except Exception:
print("")
' 2>/dev/null)"
# ── 把問題本體攤成純文字(給判官看,不給 grep 看)──────────────────
# 刻意用「遞迴撈出所有字串」而不是寫死欄位名:AskUserQuestion 的 tool_input
# schema 若哪天改欄位(questions/question/header/options/label/description…),
# 這支不會變成靜默失效的空判官。
question_text="$(printf '%s' "$input" | python3 -c '
import sys, json
def walk(node, out):
if isinstance(node, str):
s = node.strip()
if s:
out.append(s)
elif isinstance(node, list):
for x in node:
walk(x, out)
elif isinstance(node, dict):
for k in ("question", "header", "label", "description", "text"):
if k in node:
walk(node[k], out)
for k, v in node.items():
if k not in ("question", "header", "label", "description", "text"):
walk(v, out)
out = []
try:
d = json.load(sys.stdin)
walk(d.get("tool_input", {}), out)
except Exception:
pass
seen = set()
uniq = [x for x in out if not (x in seen or seen.add(x))]
print("\n".join(uniq))
' 2>/dev/null)"
# 撈不到內容 ⇒ 沒東西可判 ⇒ 放行(fail-open)
[ -z "$question_text" ] && exit 0
# ── 同一個問題只擋一次 ────────────────────────────────────────────
# 判官誤判時,總管重送同一題就過得去 ⇒ leo 該收到的問題不會被永久攔住。
stamp="/tmp/.ask-gate-warned-$(printf '%s' "$question_text" | shasum | cut -c1-12)"
[ -f "$stamp" ] && exit 0
# ── 明示豁免(留痕用)──────────────────────────────────────────────
# 真的緊急、或判官在誤攔而不想改寫問題時:
# touch /tmp/.ask-ok-<session_id>
# 用掉就消失,不是永久開關。
if [ -n "$session_id" ] && [ -f "/tmp/.ask-ok-$session_id" ]; then
rm -f "/tmp/.ask-ok-$session_id"
exit 0
fi
# ── 語意判官(跟 self-drive-judge.sh 同一套判準、同一個模型)──────
judge_prompt="你是「人閘判官」。下面是一個 AI 助理(總管)**正要送給老闆 leo 的一個提問**。
判斷這個問題**該不該送到 leo 面前**。
判準只有一條——四題人閘公式。它問的事情是否命中下列任一:
① 花錢(付費、開資源、產生帳單、明顯多耗訂閱額度)
② 不可逆/難回收(刪資料、push 到 main、部署上線 prod、對外公開、跨 repo 搬遷)
③ 跨專案結構決策(**repo 與 repo 之間**的邊界:某個能力歸哪個 repo、改全機共用的框架、立一條新鐵律)
🔴 **單一服務/單一 repo 內部怎麼分層、程式碼怎麼組織,不算第③題**——那是實作,不是結構。
④ 品味/方向(**leo 本人看得到、而且會有偏好的東西**:畫面長相、UI/UX 取捨、
要不要做某個功能、對外文案怎麼寫)
🔴 **內部的設計哲學、容錯策略、責任邊界、程式碼美感,不算第④題**——他看不到那些。
⑤ 物理人閘(只有人做得到:貼憑證、終端機同意、平台上按批准、真人驗收)
命中任一 → decision=allow(該問)。
四題全否 → decision=block(這是它自己該裁的)。
🔴 判 allow 時要放寬,判 block 時要保守——**誤擋一個真人閘,比放過一個技術題更糟**。
拿不準、或這題同時有技術面與品味面 → 一律 allow。
典型的 block(四題全否,都是純技術路徑選擇):
- 用哪個函式庫/哪種實作寫法/要不要抽共用模組
- 錯誤怎麼處理、重試幾次、超時設多久
- 檔案放哪個目錄、變數怎麼命名(純內部、用戶看不到)
- 「A 方案和 B 方案我都可以做,你想要哪個?」而兩案的差別只有實作成本
- 「我發現一個 bug,要修嗎?」(修 bug 不是品味題)
- 「要不要繼續/要不要我開始」(已經交代過的事)
典型的 allow
- 要不要開一個會產生帳單的資源、要不要換更貴的模型
- 要不要 push 到 main/部署到 prod/刪掉某批資料
- 某個能力該歸哪個 repo、要不要立一條新鐵律
- 畫面上這個東西要長什麼樣、要不要做這個功能、文案怎麼寫
- 需要你去終端機貼憑證/按批准
只輸出 JSON,不要任何其他文字。
輸出格式:
{\"decision\":\"allow\"或\"block\",\"gate\":\"命中第幾題或 none\",\"reason\":\"一句正體中文;block 時要指出它在問什麼、以及四題為何全否\"}
--- 它要問 leo 的問題 ---
$question_text
--- 結束 ---"
# ⚠️ 判官必須跑在「中性目錄 + --safe-mode」:
# 實測(self-drive-judge.sh 2026-08-05):在專案內跑 `claude -p` → 54 秒
# (內層把 SessionStart recall、全部 hook、全部 MCP 工具都載進去);
# 換成 `cd /tmp` `--safe-mode` → 8 秒。判官只需要判一段文字。
# `ISEP_ASK_JUDGE_CMD` 只為了讓離線測試塞得進一個判官替身(見 tests/),預設就是真判官。
JUDGE_CMD="${ISEP_ASK_JUDGE_CMD:-claude -p --safe-mode --model haiku --allowedTools ''}"
verdict="$(printf '%s' "$judge_prompt" \
| (cd /tmp && SELF_DRIVE_JUDGE=1 sh -c "$JUDGE_CMD" 2>/dev/null))"
# 判官掛了/回空/不是 JSON → fail-open
[ -z "$verdict" ] && exit 0
parsed="$(printf '%s' "$verdict" | python3 -c '
import sys, json, re
raw = sys.stdin.read()
m = re.search(r"\{.*\}", raw, re.S)
if not m:
print("allow||"); raise SystemExit
try:
d = json.loads(m.group(0))
print((d.get("decision") or "allow") + "|" + (d.get("gate") or "") + "|" + (d.get("reason") or ""))
except Exception:
print("allow||")
' 2>/dev/null)"
case "$parsed" in
block\|*)
rest="${parsed#block|}"
reason="${rest#*|}"
date +%s > "$stamp"
# 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行過),
# 佔位符交給 python 換掉——sed 對正體中文與特殊字元的跳脫太脆。
msg="$(cat <<'EOF'
🚦 人閘警察(PreToolUseAskUserQuestion):**這一問四題全否,是你自己該裁的。**
判官的話:__REASON__
【leo 2026-08-26】「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
「**這種問題不要問我,我要的是你解決了以後給我 prod。**」
【leo 2026-08-17】「你的命令**被當成『規劃的輸入』,而不是『執行的授權』**⋯⋯
從它的『文化』看這是禮貌;**從你的系統看,這是違約。**」
━━━ 不確定時走這條,不要停(CLAUDE.md 常駐授權那段)━━━
1. 查 wiki`system-dev/wiki/`**語意搜尋優先於 grep**
2. 套四題公式:① 花錢 ② 不可逆 ③ 跨專案結構 ④ 品味方向
3. 仍不確定 → **做出最合理的假設,把假設寫進 commit message 或票的留言,繼續走**
——第 3 步是關鍵:**假設之後不是問,是記錄。**
leo 下課後 review 時一次看到所有假設,對的併、錯的打回。
這把同步的提問改造成非同步的問答,**不掛起整個 loop**。
━━━ 這一問真的命中人閘?━━━
**把命中哪一題直接寫進問題裡再送一次**(例:「四題第②命中:這會刪掉線上資料」),
或 `touch /tmp/.ask-ok-__SESSION__` 後重送。
📌 **同一個問題本閘只擋一次**——原封不動重送就會過。
判官寧可放行也不誤擋,但它會錯;**它錯了不該讓 leo 收不到問題。**
EOF
)"
ASK_MSG="$msg" ASK_REASON="$reason" ASK_SESSION="$session_id" python3 -c '
import os, sys
sys.stderr.write(
os.environ["ASK_MSG"]
.replace("__REASON__", os.environ.get("ASK_REASON", ""))
.replace("__SESSION__", os.environ.get("ASK_SESSION", "<session_id>"))
+ "\n"
)
'
exit 2
;;
esac
exit 0
+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
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# 管什麼: 派工單裡除了【工單】票號以外還有別的內容就擋;合規的派工則自動注入共通規定。
# 為什麼: 任務寫在 prompt 裡 => 票上看不到、agent 一停就蒸發;散文沒有欄位 => 警察抓不到。
# 誤觸時怎麼關: 把內容歸位(每次都一樣的→共通規定;這次才知道的→寫進票),或 touch /tmp/.dispatch-ok-<session_id> 放行一次。
#
# dispatch-format-guard.sh — PreToolUseTaskAgent):**派工單 = 票號。就這樣。**
#
# ━━ 來由(leo 2026-08-27inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━━━
#
# 他貼回總管派給 Arcrun#142 那條線的 prompt,問:
# 「**這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?**」
# 「執行的不是你,你去派工,它才知道問題,**你寫診斷意義是什麼?不就是假的?**」
#
# 實查:`no-ticket-no-dispatch.sh` 驗的是「派工單裡有沒有一行【工單】owner/repo#N」。
# ⇒ 所以可以**把 40 行任務全寫在 prompt 裡、票號補一行**,閘照樣放行。
# ⇒ 2026-08-27 一天之內這樣做了 5 次(arcrun-rag#104Arcrun#142Arcrun#127
# Arcrun#144InkStoneCo#55),每一次票上都沒有那份任務。
# ⇒ **規則存在,閘只驗了它的殼。**(同款第 N 次:history-firstKBDB-first
# stage-firstAskUserQuestion 裸奔,全是這個形狀。)
#
# 4325 追加:「你用一個 **output parser** 把你給 subagent 的指令規範,**分作幾點,
# 每一點規定格式**,**照這種散文寫法根本無法迭代**」「**警察也不能抓**」。
# 4327 收斂到最後一步:「**交件方式不需要寫,定義在原則裡**⋯⋯每次都一樣提取出來
# 變成共通規定」+(對「這是 session 才知道的事」的回應)「**這些為什麼不寫到票裡?**」
#
# ⇒ 派工單裡想寫的東西只有兩種,兩種都不該留在派工單:
# 每次都一樣 → 共通規定(本閘自動注入,見下)
# 這次才知道 → **寫進那張票**
# 判準:**「這句話換一張票還成立嗎?」**
#
# ━━ 為什麼是結構閘,不是關鍵字閘(leo 2026-08-17 的檢驗)━━━━━━━━━━━━
# 「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫『準備就緒』⋯⋯
# **自然語言的變體是無限的,blacklist 永遠追不完。**
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實證:文字層的閘 8 次誤攔、0 次正確攔截,且方向穩定——
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 本閘怎麼過這個檢驗:
# · 判準是**「這一行是不是【工單】欄位」**——在不在,不是寫什麼。
# 整條路徑(hooks/lib/dispatch_parse.py**零個「命中某個詞就違規」的比對**。
# · ⇒ 措辭再謹慎也不會被多罰;改寫成別的講法也閃不過去。文字層閘的兩個病都沒有。
# · 也**不需要語意判官**:這條規則本來就是結構性的,用不著 LLM,
# 於是它免費、瞬間、每次結果一樣(比 ask-user-question-guard 更硬的一種閘)。
#
# ━━ 這支同時做兩件事 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ① **擋**:【工單】以外有實質內容 → exit 2,並指出那些內容該搬去哪一格
# ② **注入**:合規的派工 → exit 0 並用 hookSpecificOutput 把共通規定送給收工方
# (慣例同 subagent-wiki-guard.sh)。
# ⇒ 這是「派工單只剩票號」能成立的前提:交件方式、不准 push main、org 是 inkstone…
# **不必有人記得寫**,機器每次都補。
# leo 的驗收條件之一就是「收工方**沒讀派工單**也知道要貼回原票」。
#
# ━━ 邊界(刻意不管的事)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# · 派工單裡**沒有**【工單】 → 本閘閉嘴,那是 no-ticket-no-dispatch.sh 的地盤。
# 兩支閘同時開口,收工方會拿到兩份互相打架的教學。
# · 票存不存在、關了沒 → 也是那一支在驗,本閘不重複打 Gitea(本閘不連網)。
#
# 逃生口(留痕、用完即丟,慣例同 micromanage-guard.shask-user-question-guard.sh):
# touch /tmp/.dispatch-ok-<session_id>
# 閘是絆一下逼你重想,不是把人關起來。
#
# 測試:hooks/tests/dispatch-format-guard.test.sh(離線、不打網路、不花錢)
set -u
INPUT="$(cat)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
FACTS="$(printf '%s' "$INPUT" | python3 "$HERE/lib/dispatch_parse.py" dispatch 2>/dev/null)"
[ -n "$FACTS" ] || exit 0 # parser 掛了 → fail-open(回到「沒有這支閘」的狀態)
STATUS="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("status", "skip"))
except Exception: print("skip")
' 2>/dev/null)"
[ "$STATUS" = "ok" ] || exit 0
SESSION="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("session_id", ""))
except Exception: print("")
' 2>/dev/null)"
NVIOL="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try: print(len(json.load(sys.stdin).get("violations", [])))
except Exception: print(0)
' 2>/dev/null)"
# ── 明示豁免(留痕用,用掉就消失,不是永久開關)────────────────────────
if [ "$NVIOL" != "0" ] && [ -n "$SESSION" ] && [ -f "/tmp/.dispatch-ok-$SESSION" ]; then
rm -f "/tmp/.dispatch-ok-$SESSION"
NVIOL=0
fi
if [ "$NVIOL" != "0" ]; then
# 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行掉),
# 佔位符交給 python 換——sed 對正體中文與特殊字元的跳脫太脆。
# 2026-08-26 ask-user-question-guard 真的犯過:訊息裡的反引號被當命令跑,
# 閘照擋,但**它教人怎麼解的那兩行變成空白**,等於沒有出路。)
MSG="$(cat <<'EOF'
🚫 派工單 = 票號。就這樣。(leo 2026-08-27inkstone/ISEP#30 comment 4327
__VIOLATIONS__
━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━
還成立 ⇒ **共通規定**(交件方式、不准 push main、org 是 inkstone、先讀 CLAUDE.md…)
改 `docs/governance/dispatch-and-reply-format.md` 的 §2
**本閘每次派工都會自動注入給收工方**,你不必寫、也不會漏。
只有這次 ⇒ **寫進那張票**(main 是哪顆、今天撞過什麼、另一條線在動什麼)
`scripts/ticket say <owner/repo#N> -F <檔>`
然後派工單寫 `【工單】<owner/repo#N> → comment <剛拿到的號碼>`
🔴 **「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。**
leo 2026-08-27:「**這些為什麼不寫到票裡?**」
寫進 prompt 的後果:agent 被停掉或換人接手,那段事實隨 prompt 一起蒸發。
當天實害:總管停掉重派 3 次,前兩次的任務與 session 事實**全部消失**。
━━ 合格的派工單長這樣(整份,沒有省略)━━━━━━━━━━━━━━━━━━━━━
【工單】inkstone/ISEP#30 → comment 4322
━━ 為什麼連「一句叮嚀」都不行 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
leo 2026-08-27:「**交件方式不需要寫,定義在原則裡,每張票都要做這件事⋯⋯
每次都一樣提取出來變成共通規定**」
你多寫的每一句,都是在造第二份會漂的真相;而收工方讀到的是**你的轉述**,
不是票上的原始證據。
真的有例外 → `touch /tmp/.dispatch-ok-__SESSION__` 後重送(用掉就消失,會留在指令歷史上)。
EOF
)"
VIOL_TEXT="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
d = {}
lines = []
for v in d.get("violations", []):
lines.append(" ❌ %s%s" % (v.get("code", ""), v.get("detail", "")))
for name in d.get("retired_seen", []):
lines.append(" ↳ 【%s】這個欄位已經收回了" % name)
print("\n".join(lines))
' 2>/dev/null)"
DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" python3 -c '
import os, sys
sys.stderr.write(
os.environ["DFG_MSG"]
.replace("__VIOLATIONS__", os.environ.get("DFG_VIOL", ""))
.replace("__SESSION__", os.environ.get("DFG_SESSION", "") or "<session_id>")
+ "\n"
)
'
exit 2
fi
# ── 合規 → 注入共通規定 ──────────────────────────────────────────────
# 這是「派工單只剩票號」的另一半:那些每次都一樣的規定不必有人記得寫,機器每次都補。
# 來源是 docs/governance/dispatch-and-reply-format.md 的 INJECT 區段——
# **只有一份**,改那裡等於改所有派工(不會有第二份漂走的副本)。
DOC="${CLAUDE_PLUGIN_ROOT:-$(cd "$HERE/.." && pwd)}/docs/governance/dispatch-and-reply-format.md"
[ -f "$DOC" ] || exit 0
DFG_DOC="$DOC" python3 -c '
import json, os, re, sys
try:
text = open(os.environ["DFG_DOC"], encoding="utf-8", errors="ignore").read()
except Exception:
sys.exit(0)
m = re.search(r"<!-- INJECT:BEGIN.*?-->\n(.*?)<!-- INJECT:END -->", text, re.S)
if not m:
sys.exit(0)
body = m.group(1).strip()
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext":
"【自動注入:派工共通規定】\n"
"(來源 docs/governance/dispatch-and-reply-format.md §2——"
"派工單只給票號是刻意的,這些每次都一樣的東西由機器補,不靠人記得寫)\n\n"
+ body,
}
}, ensure_ascii=False))
' 2>/dev/null
exit 0
+182 -59
View File
@@ -13,27 +13,72 @@
#
# ① `[ "$RUNNING" -gt 0 ] && exit 0`
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
# 2026-08-16 一天四次停工**全部發生在有 agent 在跑的時候**:
# 產線忙著做 A,總管宣告了 B,然後沒有人做 B。工廠沒停,停的是按 trigger 的人。
#
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**。
# 實測:`Leo/arcrun-rag` 回 `not found`(被當成 0),`inkstone/arcrun-rag` 有 16 張。
# ⇒ 池子恆為 0 ⇒ 第 53 行直接 exit 0。**這道閘等於不存在。**
# (同族第四顆搬遷遺留座標:`ARCRUN_SHIP_NS``ARCRUN_REPO`MEMORY.md 索引/本檔。)
#
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**(已修)。
# ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」
#
# ─────────────────────────────────────────────────────────────────
# 🔴 2026-08-23 第四輪(inkstone/ISEP#30):**這道閘在懲罰誠實與謹慎。**
#
# 雲端驗收連續三次被它擋下,三次都不是宣告意圖:
# ① 「那句是等你,不是我的下一步」 ← 在**否認**自己有下一步
# ② 「這不是『下一步我要做 X』」 ← **引用**閘自己的訊息來說明沒在做
# ③ 貼出閘自己的**正則原始碼**來舉報這個 bug ← code block 裡的**引用**
#
# 真兇有四個,而且沒有一個是「例外沒列夠」:
#
# (a)**DECL 會匹配裸的「下一步」三個字。** 舊式 `下一步(我|就是|是)?[:]?\s*`
# 每一節都是可選的 ⇒ 整條 alternative 退化成「文中出現『下一步』」。
# ⇒ 只要**提到**這道閘在講什麼,就會被這道閘咬。
# 修法=**要求它後面真的接一個動作動詞**。這是**收緊**(要求更多才算命中),
# 方向與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
#
# (b)**「引用」被當成「主張」。** 舊版只剝 `>` 引言與長「」引號,
# 不認 markdown 的 ```code fence``` 與行內 `code`。
# ⇒ 貼原始碼舉報 bug 的人,會被他要舉報的那支閘擋下。
# 修法=把引用性標記整段換成**哨兵字元**(不是刪掉):
# - 刪掉會把外層句構攪碎(2026-08-17 就是這樣漏掉「回『規劃』我就派人」)
# - 換成哨兵則**內層的宣告消失、外層的句構留著** ⇒ 兩個方向同時修好
#
# (c)**看錯了「回合的收尾」。** 舊版取 `blocks_text[-1]`(最後一則文字),
# 但那則文字後面可能還有 tool_use ⇒ 宣告其實已經被兌現了。
# 修法=只看**最後一個動作之後**的文字。**收尾在動作上 ⇒ 根本不觸發。**
#
# (d)**訊息承諾的出路,程式碼裡沒有。** 舊訊息教人走
# 「選項③:改寫那句話,說明它在等什麼」,
# 但放行分支只有「有 Agent/Task」與「附近有 #票號」兩條 ⇒
# 唯一走得通的路是**不寫那三個字**,而那正是同一則訊息 🔴 明文禁止的動作。
# 修法=給選項③一個**真的存在**的機械形式:`⏸ 等:<在等什麼>`。
# 它是**白名單標記**(要刻意寫才會有),不是黑名單,不會被誤觸;
# 而且**留痕**——滿足「不准只是把那句話刪掉再送一次」。
#
# ⚠️ **刻意的取捨(寫下來,下一輪不要當成 bug 又改回去)**:
# - `#票號` 改成搜「整段收尾文字」而不是宣告句附近 ±N 字。
# 理由:票號常寫在行內 `code` 裡,剝完就找不到了 ⇒ 誤攔。
# **誤攔的代價高於漏攔**——誤攔會訓練人忽略警報,那比沒有警報更糟。
# - 「下一步」後面不吃逗號(「下一步,我去補 X」會漏)。
# 理由:吃了逗號,「不是我的下一步,我剛剛已經做完了」就會被咬(過去式當未來式)。
# - 「身為…我要…我才…」(本 repo 規定的 User Story 票名格式)整段換哨兵。
# 票名是**被命名的物件**,不是我對下一步的主張。
#
# ── 現在量什麼 ─────────────────────────────────────────────────
#
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。**
# 憑證 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch
# 驗票號**「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。
# 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有按下任何 trigger。**
# trigger 這個回合真的呼叫過 Agent/Task
# no-ticket-no-dispatch 已在那條路上驗票號 ⇒ 這裡不必重複驗
# ② 收尾文字裡有 `#票號` 指涉(=已經有人在做)
# ③ 收尾文字裡有 `⏸ 等:<在等什麼>`(=它現在不該做,且說明了在等什麼)
# ④ 回合收尾在動作上(最後一個動作之後沒有文字)
#
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。
#
# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。
# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。
#
# 🔴 **不設 `stop_hook_active` 逃生門**(與 empty-handed-stop-guard 不同)。
# leo 2026-08-16:「提醒可以忽略,deny 不能。」
# 之所以敢這樣,是因為出路③現在**真的存在** ⇒ 不可能把人鎖死在無解迴圈裡。
#
# 迴歸測試:`hooks/tests/factory-idle-guard.test.sh`23 向,誤攔與漏攔兩個方向都測)
set -eu
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
@@ -46,11 +91,11 @@ import sys, json, os, re
try:
d = json.load(sys.stdin)
except Exception:
print("SKIP"); raise SystemExit
print("SKIP:bad-payload"); raise SystemExit
tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp):
print("SKIP"); raise SystemExit # 讀不到就別亂擋
print("SKIP:no-transcript"); raise SystemExit # 讀不到就別亂擋
rows = []
try:
@@ -61,7 +106,7 @@ try:
try: rows.append(json.loads(line))
except Exception: pass
except Exception:
print("SKIP"); raise SystemExit
print("SKIP:unreadable"); raise SystemExit
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
start = 0
@@ -73,63 +118,132 @@ for i, r in enumerate(rows):
start = i
turn = rows[start:]
# 依序攤平這個回合的 assistant 區塊:("tool", 名字) / ("text", 內容)
seq = []
dispatched = False
blocks_text = []
for r in turn:
if r.get("type") != "assistant":
continue
for b in (r.get("message") or {}).get("content") or []:
if not isinstance(b, dict):
continue
if b.get("type") == "tool_use" and b.get("name") in ("Agent", "Task"):
dispatched = True
if b.get("type") == "tool_use":
if b.get("name") in ("Agent", "Task"):
dispatched = True
seq.append(("tool", b.get("name") or ""))
elif b.get("type") == "text":
blocks_text.append(b.get("text") or "")
seq.append(("text", b.get("text") or ""))
if dispatched:
print("OK"); raise SystemExit # 按了 trigger ⇒ 放行
print("OK:dispatched"); raise SystemExit # 出路① 按了 trigger ⇒ 放行
# 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正
# 🔴 只看「最後一個動作**之後**」的文字(2026-08-23 修
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
# 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火——
# 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報
# 而那比沒有警報更糟(同 branch-holds.md 的教訓)。
text = blocks_text[-1] if blocks_text else ""
# 舊版取最後一則文字,但那則文字後面可能還有 tool_use——
# 「我這回合說要讀 X、然後真的讀了」會被當成未兌現的意圖。誤攔會訓練人忽略警報
tail = []
for kind, val in reversed(seq):
if kind == "tool":
break
tail.append(val)
raw = "\n".join(reversed(tail))
if not raw.strip():
print("OK:ended-on-action"); raise SystemExit # 出路④ 收尾在動作上 ⇒ 沒有懸空的宣告
# 出路③:明講在等什麼(在剝標記**之前**檢查,理由本身常寫在引號/code 裡)
if re.search(r"⏸\s*等\s*[:]\s*\S{2,}", raw):
print("OK:waiting-declared"); raise SystemExit
# ── 把「引用性內容」蓋掉:引用 != 主張 ──────────────────────────
# 🔴 **等長**替換(蓋成同樣長度的哨兵),不是刪掉,有兩個理由:
# ① 刪掉會把外層句構攪碎——2026-08-17 就是這樣漏掉「回『規劃』我就派人」。
# 蓋成哨兵則內層的宣告消失、外層的句構留著,誤攔與漏攔一起修好。
# ② 等長 ⇒ 位移可以原封不動對回 raw ⇒ 底下的「票號要在宣告句附近」
# 可以在**原文**上量。否則票號寫在行內 `code` 裡會被自己剝掉而找不到,
# 逼得只能全篇搜——那會把閘變鈍(2026-08-23 拿真 transcript 量到:
# 全篇搜的話 26 個真實回合有 20 個是靠「文中某處有票號」放行的)。
S = "※"
def blank(m):
return S * (m.end() - m.start())
text = raw
text = re.sub(r"```.*?```", blank, text, flags=re.S) # markdown 圍欄 code
text = re.sub(r"~~~.*?~~~", blank, text, flags=re.S)
text = re.sub(r"`[^`\n]*`", blank, text) # 行內 code
text = re.sub(r"^[ \t]*>.*$", blank, text, flags=re.M) # 整段引言(leo 的原話)
for _ in range(3): # 引號(含巢狀),由內而外
text = re.sub(r"[「『][^「」『』]*[」』]", blank, text)
# 本 repo 規定的 User Story 票名:是被命名的物件,不是我對下一步的主張
text = re.sub(r"身為[^\n]{0,40}?我要[^\n]{0,80}?我才[^\n]{0,60}", blank, text)
assert len(text) == len(raw) # 位移要能對回原文
# ── 宣告下一步的句型 ──────────────────────────────────────────
# 🔴 每一條都**必須**接到一個動作動詞(A)才算命中。
# 舊版少了這個要求 ⇒ 裸的「下一步」「接下來我」就開火 ⇒ 討論這道閘的人被這道閘擋。
A = r"(?:去|來|做|派|審|跑|查|補|建|寫|改|推|驗|清|落|盤|整理|處理|部署|測)"
N = r"[^。!?;,,\n]{0,16}" # 同一句、不跨逗號
DECL = re.compile("|".join([
# 🔴 主詞是「你」的下一步 != 我的下一步(2026-08-23 拿真 transcript 量出來的第四類誤攔)
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是我在**交代 leo 該做什麼**:
# 「**下一步還是那一個動作**:**你**把 feat/... 併進 main」
# 「## **你下一步**(兩招,先便宜的)」
# ⇒ 這是**主詞檢查**(誰要動手),不是關鍵字例外。
# 只掛在這條 alternative 上——「你點頭我就做」那條的主詞本來就是我,不受影響。
r"(?P<nextstep>下一步[:]?\s*(?!你|妳|leo|Leo)(?:我|就是|是)?\s*" + N + A + r")",
r"接下來我\s*" + N + A,
r"我(?:現在|接著|等下|等一下)(?:就)?\s*" + N + A,
r"我(?:要|會)(?:去|來)?\s*" + N + A,
r"稍後(?:我|再)\s*" + N + A,
r"之後我(?:會|要)\s*" + N + A,
# 2026-08-17 leo 實撞的一族:**把請示寫成條件句**——
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
r"回[「『]?[^」』\n]{0,12}[」』]?(?:我)?就\s*" + N + A,
r"說一聲(?:我)?(?:就)?\s*" + N + A,
r"你(?:點頭|說可以|確認)(?:了)?(?:我)?(?:就)?\s*" + N + A,
r"(?:確認|核准|同意|批准)(?:過|了)?(?:之)?後(?:我)?(?:才|再|就)\s*" + N + A,
# 空手停在別人身上(出路③ 的 ⏸ 標記是它唯一合法的形式)
r"我(?:就)?(?:等|待)你",
r"等你(?:說|回|點頭|確認)",
r"我(?:就)?(?:不再|先不)(?:自己)?(?:動|做|派)",
r"我(?:就)?(?:一次)?(?:落|派|做|補|審|清)(?:完|掉)?\s*[。,,]?\s*$",
]))
SECOND = re.compile(r"你|妳|leo|Leo")
hit = None
for m in DECL.finditer(text):
# 「下一步」這條要看主詞:前面 8 字裡出現第二人稱 ⇒ 那是**他的**下一步,不是我的
if m.group("nextstep") and SECOND.search(text[max(0, m.start() - 8): m.start()]):
continue
hit = m
break
# 引用 leo 的話不算我的宣告(整段引言/引號內)——它常含「下一步」等字樣
text = re.sub(r"^\s*>.*$", "", text, flags=re.M)
# 🔴 2026-08-17 修:舊版把「所有」引號內容都刪掉,包括我自己句子裡的關鍵詞。
# leo 實撞:我寫「回『規劃』我就派人盤這份計畫」,`規劃` 被吃掉後變成
# 「回我就派人盤這份計畫」,DECL 一個都不匹配 ⇒ 該攔的沒攔。
# ⇒ 只刪「夠長的引言」(leo 的話通常成句),短引號是我自己的用詞,留著。
text = re.sub(r"「[^」]{12,400}」", "", text)
# 宣告下一步的句型(刻意收窄:只認「我接下來要做」,不認「現在的狀態是」)
DECL = re.compile(
r"(下一步(我|就是|是)?[:]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
r"|我(要|會)(去|來)?(做|派|審|跑|查|補|建)|稍後(我|再)|之後我(會|要)"
# 🔴 2026-08-17 leo 實撞補的一族:**把請示寫成條件句**——
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
# 實例:「回『規劃』我就派人盤這份計畫」/「說一聲我就落」/「你點頭我就做」
r"|回[「『]?[^」』\n]{0,12}[」』]?(我)?就|說一聲(我)?(就)?|你(點頭|說可以|確認)(了)?(我)?(就)?"
r"|(確認|核准|同意|批准)(過|了)?(之)?後(我)?(才|再|就)"
r"|我(就)?(等|待)你|等你(說|回|點頭|確認)"
r"|我(就)?(不再|先不)(自己)?(動|做|派)"
r"|我(就)?(一次)?(落|派|做|補|審|清)(完|掉)?[。,,]?\s*$)")
hit = DECL.search(text)
if not hit:
print("OK"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
print("OK:no-declaration"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行
seg = text[max(0, hit.start() - 200): hit.end() + 400]
if re.search(r"#\d{1,5}", seg):
print("OK"); raise SystemExit
# 出路②:宣告句**附近**有票號指涉(=已經說明它在誰手上/哪張票)
# 在 raw 上量(等長替換保證位移對得回去)⇒ 票號寫在行內 code 裡也算數,
# 而「文章別處剛好提到某張票」不算——那不是在講這件事在誰手上。
if re.search(r"#\d{1,5}", raw[max(0, hit.start() - 200): hit.end() + 400]):
print("OK:ticket-referenced"); raise SystemExit
print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
' 2>/dev/null || echo SKIP)
print("DECLARED_NO_TRIGGER::" + raw[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
' 2>/dev/null || echo "SKIP:crash")
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
LOGDIR="$PROJ/.claude/hooks"
# 留痕(inkstone/InkStoneCo#48:閘只記「擋下」的話分母未知,回答不了「它有沒有在運作」)
# ⇒ 擋下與放行都記。目錄不在就安靜跳過(測試環境/別的 repo 不該噴 redirect 錯誤到 stderr)。
note() {
if [ -d "$LOGDIR" ]; then
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/factory-idle-guard.log" 2>/dev/null || true
fi
}
case "$VERDICT" in
DECLARED_NO_TRIGGER::*)
QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://')
note "⛔ 擋下" "$QUOTE"
cat >&2 <<MSG
🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。**
@@ -147,19 +261,28 @@ leo 2026-08-16(本閘的規格):
⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。**
而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。
━━ 現在怎麼過這道閘(擇一)━━━━━━━━━━━━━━━━━━━━━━━━━
━━ 現在怎麼過這道閘(四條,程式碼裡都真的有對應的分支)━━━━━━━━━
① **現在就按 trigger**:這個回合直接呼叫 AgentTask 派出去。
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。
票號指涉就放行——那不是「我等下做」,是「已經有人在做」。
③ **它其實不該現在做**改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘),
不要寫成「下一步我要做 X」。
② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。
\`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。
③ **它其實不該現在做**寫一行說明它在等什麼,格式就是這一個標記——
⏸ 等:<在等什麼>
例:\`⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘)\`
\`⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除\`
🔴 這個標記**要刻意寫才會有**,寫了就留痕——它取代不了做事,
但它讓「現在不該做」跟「宣告了不做」在機器上分得開。
④ **這個回合收尾在動作上**:最後一個工具呼叫之後不要再補一句宣告。
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
比宣告了沒做更糟——它連痕跡都不留。
比宣告了沒做更糟——它連痕跡都不留。要嘛按 trigger,要嘛用 ③ 留下 ⏸ 記號。
MSG
exit 2 ;;
*)
note "✅ 放行" "$VERDICT" ;;
esac
# ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
+30 -17
View File
@@ -41,9 +41,17 @@
"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"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh"
}
]
},
@@ -132,6 +140,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
}
]
},
@@ -167,6 +179,19 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-guard.sh"
}
]
}
@@ -246,25 +271,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": [
{
@@ -322,9 +331,13 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
}
]
}
]
}
}
}
+25 -2
View File
@@ -17,9 +17,32 @@ ROOT="${CLAUDE_PLUGIN_ROOT:-}"
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
"$ROOT/.claude-plugin/plugin.json" 2>/dev/null | head -1)"
VER="${VER:-未知}"
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | wc -l | tr -d ' ')"
# 🔴 數「真的被註冊的」,不是數目錄裡有幾個 .sh(leo 的雲端驗收 2026-08-23 抓到):
# 舊寫法 `ls hooks/*.sh` 把 `pre-write-guard.template.sh`(樣板,不是閘)
# 與兩支沒掛註冊的輔助檔一起算進去 ⇒ 報 45,實際註冊 42。
# 這個數字是 leo 判斷「這個 session 有沒有閘」的唯一介面——**多報就是假綠**。
# (查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。)
GATES="$(grep -oE 'hooks/[a-zA-Z0-9._-]+\.sh' "$ROOT/hooks/hooks.json" 2>/dev/null \
| sort -u | wc -l | tr -d ' ')"
case "$GATES" in
''|*[!0-9]*|0)
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | grep -cv '\.template\.sh$' | tr -d ' ')" ;;
esac
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘在 ${ROOT}"
# 🔴 講清楚「這一份是誰」(leo 2026-08-23 的雲端驗收卡在這裡整整一輪):
# 同一台機器上 ISEP 可能有兩份——marketplace 裝的 plugin 快取,
# 與 repo 裡 vendor 進去的複製本。**兩份都會印這一行**,而版本號一樣時分不出誰在說話。
# 「閘到底從哪一份走」正是決定「另一份能不能拆」的唯一判準
# ⇒ 讓路徑自己講:快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。
# (查過歷史:本檔只有 daa1674 建立、b1f399f 改閘數兩輪,沒有人試過標示來源,也沒有被否決的方案。
# KBDB 語意搜尋這段開發史:0 命中,該餵。)
case "$ROOT" in
*/plugins/cache/*) SRC="pluginmarketplace 裝的)" ;;
*/.claude/isep*) SRC="vendorrepo 裡的複製本)" ;;
*) SRC="來源不明" ;;
esac
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘|來源:${SRC}${ROOT}"
printf '%s\n' "{
\"systemMessage\": \"${MSG}\",
Binary file not shown.
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""hooks/lib/dispatch_parse.py — 派工單與交件回覆的**結構解析器**(不是閘)。
inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block
判決由呼叫它的閘做`hooks/dispatch-format-guard.sh``hooks/reply-identity-guard.sh`
`scripts/ticket`
規則本身leo 2026-08-27inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━
派工單 工單owner/repo#N。就這樣。
三則合起來是一條規則
4322 任務要在票上不是在 prompt 而閘只驗了票號這個殼
4325 你用一個 **output parser** 把你給 subagent 的指令規範分作幾點
每一點規定格式**照這種散文寫法根本無法迭代****警察也不能抓**
4327 **交件方式不需要寫定義在原則裡**每次都一樣提取出來變成共通規定
那些 session 事實**為什麼不寫到票裡**
派工單裡的東西只有兩種**兩種都不該留在派工單**
每次都一樣 交件方式不要 push mainorg inkstone先讀該 repo CLAUDE.md
=> 共通規定由閘自動注入 dispatch-format-guard.sh
這次才知道 main 是哪顆 commit今天撞過什麼另一條線正在動什麼
=> **寫進那張票**票上還沒有去把它寫上票的指令
不是把它寫進 prompt的理由
判準一句話**這句話換一張票還成立嗎**
還成立 => 共通規定只有這次成立 => 寫進這張票兩種都不進派工單
為什麼連這次才知道的事也不能留在 prompt4327 實害
那個 agent 被停掉換人接手那段事實就隨 prompt 消失
2026-08-27 總管停掉重派 3 前兩次的任務與 session 事實全部蒸發
為什麼這支過得了封動作不封文字的檢驗leo 2026-08-17
你在**文字層**封路**自然語言的變體是無限的blacklist 永遠追不完**
封路哲學之所以有效是因為它封的是**動作**動作有限且可枚舉文字不是
當日實證文字層的閘 8 次誤攔0 次正確攔截**紅線寫得越細命中關鍵字的機率越高
那些閘在懲罰謹慎**
本檔**沒有命中某個詞就違規的比對**判準是這一行是不是工單欄位
**在不在**不是**寫什麼**用到的正則有三種全部在認形狀
- `某某` 欄位標記本身
- `owner/repo#N`(可選外掛全形/半形括號的 `→ comment M`)票號形狀
- 唯一例外ISEP#65):`_COURTESY_CLOSERS` 是一份**白名單**,只用來認出
這一行是不是零資訊的禮貌收尾作用方向與黑名單相反認出的是
不算違規不是算違規且要求整行清乾淨標點後完全相等不是
包含所以不會反過來放走任何真正的內容
措辭多謹慎有沒有提到紅線部署main本檔一律不看仍然不會被
寫得越小心越容易被罰的病拖下水
派工單格式
工單inkstone/ISEP#30 → comment 4322
就這一行要帶兩張票就兩行工單**沒有第二個欄位**
交件回覆格式任何人
身份subagentinkstone/ISEPfeat/ticket-carries-the-task
第一行就要認得出是誰寫的角色是三選一的允許清單`總管``subagent``leo`
2026-08-27 實害總管寫的診斷被當成 subagent 的結論而其中一則是錯的
用法
import dispatch_parse
r = dispatch_parse.parse_dispatch(prompt_text)
ok, detail = dispatch_parse.parse_identity(comment_body)
CLI hook JSON吐一份事實 JSON
printf '%s' "$INPUT" | python3 hooks/lib/dispatch_parse.py dispatch
"""
import json
import re
import sys
# ── 派工單的字彙表:**只有一個欄位**。這是允許清單,不是黑名單 ──────────────
DISPATCH_ALLOWED = ("工單",)
# 曾經合法、現已收回的欄位。**不參與判決**(判決是「不是【工單】就是多的」),
# 只用來讓訊息講得出「你這一欄的內容該搬去哪」。
RETIRED_FIELDS = {
"人格": "共通規定(收工方從票的 repo 就知道自己戴誰的人格)",
"交件": "共通規定(每張票都一樣:貼回原票)",
"就地": "**寫進那張票**(這正是「票上還沒有」的解法)",
"身份": "那是**交件回覆**的欄位,不是派工單的",
}
IDENTITY_ROLES = ("總管", "subagent", "leo")
_MARKER_RE = re.compile(r"^【([^】]{1,12})】\s*(.*)$")
_REF_RE = re.compile(r"^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)\s*$")
# 🔴 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*(```|~~~)")
# 🔴 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):
"""切成 (是否在圍欄內, 原始行)。
圍欄``` / ~~~裡的東西是**資料**不是欄位標記同一個道理已經在
hooks/lib/strip_heredoc.py 落地過heredoc body 是資料不是指令
inkstone/InkStoneCo#23、#56 的根因就是把資料當指令掃)。
這裡的效果是貼一段指令進派工單裡面的不會被誤認成欄位
但整段圍欄本身仍然算多出來的內容照樣會被擋
"""
out, in_fence = [], False
for line in text.split("\n"):
if _FENCE_RE.match(line):
out.append((in_fence, line)) # 圍欄那一行歸屬於「切換前」的狀態
in_fence = not in_fence
continue
out.append((in_fence, line))
return out
def parse_dispatch(prompt):
"""把派工單拆成「【工單】欄位」與「其餘一切」。**只回事實,不下判決**。
回傳 dict
has_ticket_marker 有沒有出現過工單沒有就不是本格式的東西
refs 解析成功的票號清單 [{'owner','repo','num','comment'}]
bad_ticket_lines 工單標記但形狀不對的那幾行
extra 多出來的實質內容 [(來源欄位或 None, 原始行)]
retired_seen 出現過的已收回的欄位名給訊息用
"""
fields_seen, refs, bad_ticket_lines = [], [], []
extra, retired_seen = [], []
cur = None
for in_fence, raw in _split_lines(prompt or ""):
if not in_fence:
m = _MARKER_RE.match(raw)
if m:
name, rest = m.group(1), m.group(2)
cur = name
fields_seen.append(name)
if name in RETIRED_FIELDS and name not in retired_seen:
retired_seen.append(name)
if name in DISPATCH_ALLOWED:
text = rest.strip()
head = _COMMENT_RE.sub("", text).strip()
rm = _REF_RE.match(head)
if rm:
cm = _COMMENT_RE.search(text)
refs.append({"owner": rm.group(1), "repo": rm.group(2),
"num": int(rm.group(3)),
"comment": int(cm.group(1)) if cm else None})
else:
bad_ticket_lines.append(raw.strip())
else:
if rest.strip():
extra.append((name, raw))
continue
if raw.strip():
# 【工單】欄位本身只准佔它自己那一行;它下面的續行也是多的。
# 白名單豁免只在圍欄外生效——圍欄裡的東西本來就該一律算數(test ⑮),
# 這裡不額外放寬,避免有人把真內容包在圍欄裡再貼一句「謝謝」蒙混。
if not in_fence and _is_courtesy_closer(raw):
continue
extra.append((cur, raw))
return {
"has_ticket_marker": "工單" in fields_seen,
"refs": refs,
"bad_ticket_lines": bad_ticket_lines,
"extra": extra,
"extra_lines": len(extra),
"extra_chars": sum(len(r.strip()) for _, r in extra),
"retired_seen": retired_seen,
"fields_seen": fields_seen,
}
def dispatch_violations(parsed):
"""換算成違規清單。每一條都是**結構**事實,可以單獨迭代。"""
v = []
for line in parsed["bad_ticket_lines"]:
v.append(("票號形狀不對",
"%s」不是 owner/repo#N(可加 → comment M);org 是 inkstone 不是 Leo"
% line[:60]))
if parsed["extra"]:
first = parsed["extra"][0][1].strip()
v.append(("派工單不只有票號",
"【工單】以外還有 %d 行/%d 字,第一行是:%s"
% (parsed["extra_lines"], parsed["extra_chars"], first[:50])))
return v
def parse_identity(body):
"""交件回覆的【身份】欄。回 (ok, detail)。"""
for raw in (body or "").split("\n"):
if not raw.strip():
continue
m = _MARKER_RE.match(raw)
if not m or m.group(1) != "身份":
return False, "第一行不是【身份】"
val = m.group(2).strip()
if not val:
return False, "【身份】後面是空的"
role = re.split(r"[/]", val)[0].strip()
if role not in IDENTITY_ROLES:
return False, "角色「%s」不在 %s 之內" % (role or "(空)", "".join(IDENTITY_ROLES))
return True, val
return False, "內文是空的"
# ── CLI ──────────────────────────────────────────────────────────────────
def _main():
mode = sys.argv[1] if len(sys.argv) > 1 else "dispatch"
try:
payload = json.load(sys.stdin)
except Exception:
print(json.dumps({"status": "skip", "why": "payload 不是 JSON"}))
return
if mode != "dispatch":
print(json.dumps({"status": "skip", "why": "未知模式 %s" % mode}))
return
prompt = (payload.get("tool_input") or {}).get("prompt") or ""
parsed = parse_dispatch(prompt)
# 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。
# 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。
if not parsed["has_ticket_marker"]:
print(json.dumps({"status": "skip", "why": "沒有【工單】,交給 no-ticket-no-dispatch"}))
return
print(json.dumps({
"status": "ok",
"violations": [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)],
"refs": parsed["refs"],
"retired_seen": parsed["retired_seen"],
"extra_lines": parsed["extra_lines"],
"session_id": payload.get("session_id") or "",
}, ensure_ascii=False))
if __name__ == "__main__":
_main()
+4 -2
View File
@@ -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.shsubagent-claim-worksheet.sh 已於 ISEP#60 整組移除,
# 它們守的東西改由 Gitea 原生三格承接,見 ISEP#59PR #58
# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""hooks/lib/push_target_dir.py -- shared helper (inkstone/ISEP#40 S7-style
lib helper: source of truth for a small piece of logic, not a gate by
itself; nothing here decides allow/block on its own).
What it answers: given a shell command string that somewhere invokes
`git ... push ...`, which directory will that push actually execute in?
Why this exists (inkstone/ISEP#30 comment 3949, 脈絡見 InkStoneCo#57):
main-and-prod-push-guard.sh's stamp_ok() used to read `git rev-parse
--show-toplevel` from the *hook's own* cwd (= the session's real repo) as
"HERE", and compare it against the repo path the caller wrote into the
stamp ("WANT"). That works when the push target IS the repo the session is
standing in. It can never work otherwise: a `cd <other-repo> && git push
origin HEAD:main` changes the *push's* directory but not the hook's, so
HERE stays the real repo forever while WANT is (correctly) the other repo
-- the two can never match, no matter how carefully the caller follows the
gate's own instructions. This isn't a bad judgment call; that scenario
simply doesn't exist in the old model.
This module extracts the directory the push *actually* runs in, purely by
tokenizing -- it never executes anything. Two sources, first one found
wins (in the order a real shell would apply them):
- a `cd <path>` chain preceding the push, scoped correctly across
subshells: a `(` inherits the current directory from its parent at the
moment it opens, but whatever a subshell `cd`s to does NOT leak back
out to sibling commands after the matching `)` closes (this mirrors
real bash: a subshell's cwd change is local to that subshell). This
scoping is load-bearing, not cosmetic: without it, `(cd /repo-A &&
true); git push origin main` would misattribute the later push (which
really runs wherever the outer shell already was) to /repo-A, and a
stale/legitimate stamp for /repo-A could then wrongly wave through a
push into whatever the outer cwd actually is -- the exact "stamp
opened for repo A also opens the door for repo B" shape 2026-08-11
already burned us on once.
- a `-C <path>` flag on the git invocation itself, which further wins
over any `cd` chain (matches git's own precedence: `-C` sets the
directory for that invocation regardless of the shell's cwd).
Multiple relative `cd`/`-C` hops are combined by plain string join here
(no `..`/`~`/`$()` resolution) -- resolving the combined expression to a
real, canonical, absolute path is left to the caller, which does it with a
read-only `cd "<expr>" && pwd` in a throwaway subshell. That two-step split
matters: this module only ever *parses*, so it stays side-effect-free even
when fed a hostile or malformed command; only the caller's final `cd`
touches the filesystem, and `cd` cannot execute anything, it can only fail
to find a directory.
If no `cd`/`-C` applies (the push runs wherever the hook itself is, i.e.
today's behaviour), or the command doesn't parse, prints nothing -- the
caller falls back to its existing cwd-based resolution. That fallback
direction is deliberately the *safe* one: on any parse ambiguity we hand
back "unknown" rather than guess, and an unresolved HERE can only make the
gate keep blocking (fail toward blocking), never open a door it wouldn't
have opened before.
Usage:
printf '%s' "$CMD" | python3 push_target_dir.py
"""
import shlex
import sys
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
def _join(base, path):
"""Combine a cwd-so-far (`base`, or None if unknown/hook-cwd) with a
`cd`/`-C` argument written in the command. Absolute paths and `~`
replace the base outright; anything else is appended textually --
normalizing `..`/`.` is intentionally left to the caller's real `cd`."""
if not path:
return base
if path == "-" or path.startswith("$"):
# `cd -` (previous dir) and `$VAR`/`$(...)` expansions can't be
# resolved by tokenizing alone -- treat as "unknown" rather than
# guess wrong, which keeps the caller on its safe fallback path.
return None
if path.startswith("/") or path.startswith("~"):
return path
if base is None:
return path
return base.rstrip("/") + "/" + path
def _classify(tokens):
if not tokens:
return ("other", None)
if tokens[0] == "cd" and len(tokens) > 1:
return ("cd", tokens[1])
if tokens[0] == "git" and "push" in tokens[1:]:
return ("push", tokens)
return ("other", None)
def _events(cmd):
"""Tokenize cmd into (kind, value) events in source order: 'enter'/
'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for
statements split on the usual shell separators. Returns [] on any
quoting error -- caller then falls back to cwd-based resolution."""
try:
lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
toks = list(lexer)
except ValueError:
return []
events = []
seg = []
def flush():
if seg:
# _classify's "push" branch returns `tokens` by reference; copy
# before clear() below, or the event's tuple would observe the
# list emptied out from under it (aliasing, not a value copy).
events.append(_classify(seg[:]))
seg.clear()
for tok in toks:
if tok == "(":
flush()
events.append(("enter", None))
elif tok == ")":
flush()
events.append(("exit", None))
elif tok in _SEPARATORS:
flush()
else:
seg.append(tok)
flush()
return events
def find_push_target(cmd):
events = _events(cmd)
if not events:
return ""
stack = [None] # cwd-so-far per paren depth; None = "same as hook cwd"
result = None
saw_push = False
for kind, val in events:
if kind == "enter":
stack.append(stack[-1]) # child subshell inherits current dir
elif kind == "exit":
if len(stack) > 1:
stack.pop() # subshell's own cd's don't leak out
elif kind == "cd":
stack[-1] = _join(stack[-1], val)
elif kind == "push":
saw_push = True
c_path = None
toks = val
for j, t in enumerate(toks):
if t == "-C" and j + 1 < len(toks):
c_path = toks[j + 1]
break
if t.startswith("-C") and len(t) > 2:
c_path = t[2:]
break
result = _join(stack[-1], c_path) if c_path else stack[-1]
if not saw_push:
return ""
return result or ""
def main():
cmd = sys.stdin.read()
sys.stdout.write(find_push_target(cmd))
if __name__ == "__main__":
main()
+62 -2
View File
@@ -98,7 +98,32 @@ stamp_ok() {
# 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
#
# 🔴 2026-08-23inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57):
# `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。
# 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己
# `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是
# 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法
# 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做
# 戳記,戳記內容天生就贏不了)。
# 改法:先看指令本身有沒有把 push 的執行目錄改掉
# lib/push_target_dir.py——純 tokenize,不執行任何指令,
# 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種
# 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個
# 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰;
# 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。
# 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個
# repo」問得更準,比對邏輯(下面兩行)一個字沒動。
_push_target_dir="$(dirname "$0")/lib/push_target_dir.py"
_target_expr=""
if [ -f "$_push_target_dir" ]; then
_target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '')
fi
if [ -n "$_target_expr" ]; then
HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '')
else
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
fi
WANT=$(head -1 "$STAMP" 2>/dev/null || printf '')
[ -n "$WANT" ] || return 1
[ -n "$HERE" ] || return 1
@@ -125,7 +150,42 @@ if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]
# 2026-08-20: match the target branch on a word boundary, not a bare substring --
# a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to
# `fix/custom-domain-setup` would have false-positived.
if printf '%s' "$CMD" | grep -qE '(^|[^A-Za-z])(main|master)([^A-Za-z]|$)'; then
# 2026-08-21: 只看 **push 的目標**,不再掃整條指令。
# 舊版掃整條 ⇒ 一個晚上誤攔四次,全都是推 feature branch 或 tag
# git checkout -b fix/x main && git push origin fix/x ← 「main」在 checkout 上
# gh pr create --base main ← 根本不是 git push
# git checkout origin/main --detach; git push origin refs/tags/v0.3.3
# ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察,
# 文字層封路必敗)。這裡改成判動作的目標,不是判字面。
# 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍
# 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修):
# `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行,
# 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。
# 成因:截斷 refspec 尾巴只切 `;``&``|` 三種字元,沒算到 `)`——
# 於是「HEAD:main)」被當成一個 token`${_tok##*:}` 剝完冒號還剩「main)」,
# 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。
# git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全,
# 不會誤傷任何合法的推送目標。
_push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//')
_dest=""
_seen_remote=0
_saw_refspec=0
for _tok in $_push_seg; do
case "$_tok" in
-*) continue ;; # 旗標
refs/tags/*|*:refs/tags/*) _saw_refspec=1; continue ;; # 推 tag 不是推分支
esac
if [ "$_seen_remote" = "0" ]; then _seen_remote=1; continue; fi # 第一個非旗標=remote
_saw_refspec=1
_dest="$_dest ${_tok##*:}" # a:b 的目標是 b;沒有冒號就是它自己
done
# 🔴 只有「一個 refspec 都沒給」才退回猜當前分支。
# 看到 refspec(哪怕是 tag)就照它判——否則推 tag 會被當成推當前分支,
# 而當前分支若剛好叫 main 就誤擋(2026-08-21 實測抓到)。
if [ "${_saw_refspec:-0}" = "0" ]; then
_dest=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
fi
if printf '%s' "$_dest" | tr ' ' '\n' | grep -qE '^(main|master)$'; then
stamp_ok && exit 0
# ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)───────────
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# 管什麼: 直接打 Gitea API 貼留言到票上時,內文第一行沒有【身份】就擋。
# 為什麼: scripts/ticket 已經在正門擋了,但貼留言還有 API 這條側門——只封一條等於沒封。
# 誤觸時怎麼關: 內文第一行加【身份】<總管|subagent|leo><owner/repo><分支>,或在指令裡加 reply-identity-ok 留痕放行。
#
# reply-identity-guard.sh — PreToolUseBash):**票上的每一則留言都要認得出是誰寫的**
#
# 來由(leo 2026-08-27inkstone/ISEP#30 comment 4325):
# 「**subagent 回覆時要表明身份**」
# 實害(同日):多條線並行,票上的留言看不出是誰寫的,
# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
#
# 為什麼要有這一支,正門的檢查不夠:
# `scripts/ticket say` 已經在貼上去之前擋了。但**貼留言這個動作有兩條路**——
# 而 `ticket-api-bypass-guard.sh` 是**刻意放行**「對既有票的留言」的(它管的是開新票)。
# ⇒ 側門大開。這正是那支閘檔頭自己記過的一課:
# 「規範有、閘也有,但閘長在『工具』上,而那個動作有兩條路,只封了一條。」
#
# 判準是**結構**不是文字(leo 2026-08-17:「封的是動作,不是文字」):
# 本閘不看留言寫什麼,只看**那個欄位在不在**。
# 三個條件都命中才擋:① 打到 /issues/<N>/comments 這條端點 ② 指令裡有 POST
# ③ 內文找不到【身份】。純讀取(GET 撈留言)一律放行。
#
# 逃生口:指令裡加 `reply-identity-ok`(會留在指令歷史上)。
#
# 測試:hooks/tests/reply-identity.test.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)
[ -n "$CMD" ] || exit 0
# 逃生口(留痕)
case "$CMD" in *reply-identity-ok*) exit 0 ;; esac
# 正門有自己的閘,重複擋只會互相打架
case "$CMD" in *scripts/ticket*|*"ticket say"*|*"ticket decide"*|*"ticket close"*) exit 0 ;; esac
# ① 打到「某張票的留言」這條端點(帶票號的子路徑,這是本閘唯一管的東西)
printf '%s' "$CMD" | grep -qE 'issues/[0-9]+/comments' || exit 0
# ② 純讀取一律放行。同 ticket-api-bypass-guard 的理由:只認一個裸字就夠——
# ①已經確定這是留言端點,讀取的指令不會出現 POST,而且沒有跳脫的破口。
printf '%s' "$CMD" | grep -qw 'POST' || exit 0
# ③ 內文有沒有身份欄。只問「在不在」,不問「寫什麼」。
printf '%s' "$CMD" | grep -q '【身份】' && exit 0
cat >&2 <<'MSG'
🚫 貼到票上的留言,第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」)
你正在用 Gitea API 直接貼留言,而內文裡找不到【身份】。
第一行照這個寫(角色三選一:總管/subagent/leo):
【身份】subagentinkstone/ISEPfeat/my-branch
【身份】總管/inkstone/InkStoneCo-
**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標——
實害(2026-08-27):多條線並行時票上看不出誰寫的,
**總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
── 走正門比較省事 ─────────────────────────────────────────────
scripts/ticket say <owner/repo#N> -F <內文檔>
它會回一行「定址」,那串就是下次派工要寫進【工單】的東西。
規約全文:docs/governance/dispatch-and-reply-format.md §3
真的有例外 → 指令裡加 reply-identity-ok(會留在指令歷史上)。
MSG
exit 2
-183
View File
@@ -1,183 +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]
stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8]
path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp))
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
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的**判官準度**測試(inkstone/InkStoneCo#55)。
#
# 🔴 這支**真的會叫 haiku**(每題一次呼叫,共 9 題)。離線那支測管路,這支測判斷。
# 分開兩檔的理由:管路壞掉要立刻知道且不該花錢;判官準度是另一種失敗,
# 而且它會隨模型版本漂移,需要單獨、可重跑的量尺。
#
# 兩個方向都要有實例,缺一邊等於沒測:
# B 群「該擋」 = 純技術路徑選擇(leo 2026-08-26:「這種問題不要問我」)
# A 群「不該擋」= 真的四題人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘)
#
# 🔴 **A 群失敗比 B 群失敗嚴重。** 誤擋一個真人閘 = 讓總管替 leo 決定他的品味,
# 比漏擋一個技術題更糟。所以本檔把 A 群失敗單獨列出來報。
#
# 用法:hooks/tests/ask-user-question-guard.live.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
PASS=0; FAIL=0; N=0; AFAIL=0
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-LIVE",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": ""} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <群> <說明> <問題> [選項...]
t() {
want="$1"; grp="$2"; desc="$3"; shift 3
N=$((N+1))
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true # 每題都要真的走到判官
out=$(payload "$@" | sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | grep -m1 '判官的話' | sed 's/^/ /'
FAIL=$((FAIL+1))
[ "$grp" = "A" ] && AFAIL=$((AFAIL+1))
fi
}
echo "── A 群:不該擋(真人閘,leo 要被問到)───────────────────────"
t 0 A "①④ 品味/方向:總圖那棵樹要不要預設收合(leo 08-26 當場裁的就是這種)" \
'總圖那棵樹,同一層節點超過 20 個的時候,要不要預設收合起來?' '預設收合' '全部展開'
t 0 A "②① 花錢:要不要開一顆會產生帳單的付費實例" \
'這個功能需要一顆專用的 Cloudflare 付費實例才跑得動,要開嗎?每月大約會多一筆帳單。' '開' '不開'
t 0 A "③② 不可逆:刪掉 stage 上那批資料重灌" \
'要把 stage 上那批舊資料整批刪掉重灌嗎?刪了拿不回來。' '刪掉重灌' '保留'
t 0 A "④③ 跨專案結構:ISEP 的閘要不要拆回各子 repo 各自維護" \
'ISEP 的閘目前是全機共用一份。要不要改成各子 repo 各自維護一份自己的?這會改掉 repo 之間的邊界。' '拆回各 repo' '維持共用'
t 0 A "⑤⑤ 物理人閘:要你親手去終端機解保險才推得了 prod" \
'這一版要推 prod,需要你親手在終端機跑 github-arm.sh 解保險,我沒辦法代跑。現在推嗎?' '現在推' '晚點再說'
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
t 2 B "⑥ 實作分層:重試邏輯放哪一層" \
'重試邏輯要放在 client 那一層還是 worker 那一層?' '放 client' '放 worker'
t 2 B "⑦ 空白選擇題:兩案差別只有實作成本" \
'這個 bug 有兩種修法,A 是改解析器、B 是在呼叫端多包一層,兩種我都做得出來,效果一樣。你想要哪一個?' 'A 改解析器' 'B 呼叫端包一層'
t 2 B "⑧ 請示句:已經交代過的事還在問要不要開始" \
'那我現在開始修那支 hook 嗎?' '開始' '先等等'
t 2 B "⑨ 內部命名/擺放位置(用戶看不到)" \
'新的測試檔要放在 hooks/tests/ 底下還是 scripts/ 底下?檔名要叫 guard.test.sh 還是 test-guard.sh' 'hooks/tests/' 'scripts/'
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗}${AFAIL:+(其中 $AFAIL 個是 A 群誤攔——這種最嚴重)} ══"
[ "$FAIL" -eq 0 ]
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的離線迴歸測試(inkstone/InkStoneCo#55)。
#
# 這一檔測的是**管路**:抓不抓得到那個動作、擋的時候 exit 幾、
# 判官掛掉會不會把 session 卡死、同一題會不會擋第二次。
# **判官的準度另外一檔測**`ask-user-question-guard.live.test.sh`,那支真的叫 haiku)。
#
# 判官在這裡用替身(`ISEP_ASK_JUDGE_CMD` 指向一支印固定 JSON 的假腳本),
# 所以本檔 **不打網路、不花錢、跑得完**。
#
# 兩個方向都要有證據:
# A 群「該放行」——非 AskUserQuestion 的動作、判官說 allow、判官壞掉、豁免戳記、第二次重送
# B 群「該擋」 ——判官說 block(純技術路徑選擇)
# C 群「訊息承諾的出路真的走得通」——重送與豁免戳記各一個實例
#
# 用法:hooks/tests/ask-user-question-guard.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
# 判官替身:吃 stdin、印一段固定的裁決
mkstub() {
cat > "$TMP/judge-$1.sh" <<EOF
#!/bin/sh
cat > /dev/null
printf '%s' '$2'
EOF
chmod +x "$TMP/judge-$1.sh"
printf '%s' "$TMP/judge-$1.sh"
}
STUB_BLOCK=$(mkstub block '{"decision":"block","gate":"none","reason":"純技術路徑選擇,四題全否"}')
STUB_ALLOW=$(mkstub allow '{"decision":"allow","gate":"④","reason":"UI 取捨是品味題"}')
STUB_EMPTY=$(mkstub empty '')
STUB_JUNK=$(mkstub junk 'Error: model overloaded')
STUB_NEVER="$TMP/judge-does-not-exist.sh"
# payload <問題> [選項...]
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-TEST",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": o + " 的說明"} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <判官替身> <說明> <payload JSON>
t() {
want="$1"; judge="$2"; desc="$3"; body="$4"
N=$((N+1))
out=$(printf '%s' "$body" | ISEP_ASK_JUDGE_CMD="$judge" sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
clean() { rm -f /tmp/.ask-gate-warned-* /tmp/.ask-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "$STUB_BLOCK" "① 不是 AskUserQuestion 的動作,一律不管(連判官都不叫)" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
t 0 "$STUB_BLOCK" "② tool_input 是空的 → 沒東西可判,放行" \
'{"tool_name":"AskUserQuestion","session_id":"S-TEST","tool_input":{}}'
t 0 "$STUB_BLOCK" "③ 整包不是合法 JSON → fail-open" \
'this is not json at all'
clean
t 0 "$STUB_ALLOW" "④ 真人閘之一(品味/方向):樹要不要捲——判官 allow,leo 該收到" \
"$(payload '總圖那棵樹,同層節點超過 20 個時要不要預設收合?' '預設收合' '全部展開')"
clean
t 0 "$STUB_EMPTY" "⑤ 判官回空(掛了/沒網路)→ fail-open,不准把問題卡住" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_JUNK" "⑥ 判官回的不是 JSON → fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_NEVER" "⑦ 判官根本不存在(claude 不在 PATH)→ fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
touch /tmp/.ask-ok-S-TEST
t 0 "$STUB_BLOCK" "⑧ 明示豁免戳記在 → 放行(C 群:訊息裡承諾的出路)" \
"$(payload '這題我確定是人閘,先放我過' 'A' 'B')"
if [ -f /tmp/.ask-ok-S-TEST ]; then
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
fi
N=$((N+1))
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
clean
TECH="$(payload '重試邏輯要放在 client 還是 worker 那一層?' '放 client' '放 worker')"
t 2 "$STUB_BLOCK" "⑨ 判官 block → exit 2,問題不會送到 leo 面前" "$TECH"
# 訊息內容檢查:要點名 leo 的原話與四題公式,且要給得出出路
# (先清戳記,否則這次會走「同一題只擋一次」那條路而沒有訊息)
clean
msg=$(printf '%s' "$TECH" | ISEP_ASK_JUDGE_CMD="$STUB_BLOCK" sh "$HOOK" 2>&1 >/dev/null || true)
N=$((N+1))
if printf '%s' "$msg" | grep -q '為什麼 hooks 沒有攔下來' \
&& printf '%s' "$msg" | grep -q '四題' \
&& printf '%s' "$msg" | grep -q '重送'; then
printf ' ✅ ⑩ 擋下的訊息帶了:leo 原話+四題公式+重送的出路\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩ 擋下的訊息缺了「leo 原話/四題公式/出路」其中一項\n'
printf '%s\n' "$msg" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 迴歸:訊息裡的反引號一度被 shell 當成命令替換執行掉
# (原本寫 `cat >&2 <<EOF`,於是 `system-dev/wiki/` 真的被當指令跑,
# stderr 冒出 "is a directory",而 wiki 路徑與豁免指令在訊息裡雙雙變成空白
# ——閘還是擋了,但**它教人怎麼解的那兩行是空的**,等於沒有出路。)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'system-dev/wiki/' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.ask-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -q 'is a directory'; then
printf ' ✅ ⑩b 訊息原文照印:反引號沒有被當成命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩b 訊息被 shell 展開了(wiki 路徑/豁免指令消失,或冒出 shell 錯誤)\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
echo
echo "── C 群:訊息承諾的出路真的走得通 ─────────────────────────────"
# 上面兩次已經把戳記寫下去了,同一題原封不動重送 → 必須放行
t 0 "$STUB_BLOCK" "⑪ 同一題原封不動重送 → 放行(判官誤判不會讓 leo 永遠收不到)" "$TECH"
clean
DIFFQ="$(payload '要不要把這批舊資料刪掉?這是不可逆的' '刪掉' '留著')"
t 2 "$STUB_BLOCK" "⑫ 換一題(不同雜湊)→ 戳記不共用,仍然會判" "$DIFFQ"
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# dispatch-format-guard.sh 的迴歸測試(inkstone/ISEP#30 comment 432243254327)。
#
# **離線、不打網路、不花錢、每次結果一樣**——這支閘是純結構判斷,沒有語意判官,
# 所以它不像 ask-user-question-guard 那樣需要一支 live 測試量準度。
#
# 三群,兩個方向都要有證據:
# A 群「該放行」——合規的派工(只有票號)、不是派工的動作、沒有【工單】(別人的地盤)、
# 豁免戳記;**而且合規時要真的把共通規定注入出去**
# B 群「該擋」 ——真跡一份(那 5 次違規裡唯一還拿得回來的),
# 加上一條規則一個最小案例
# C 群「訊息本身」——講得出出路、沒有被 shell 展開
#
# 用法:hooks/tests/dispatch-format-guard.test.sh [hook 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="${1:-$(cd "$HERE/.." && pwd)/dispatch-format-guard.sh}"
FIX="$HERE/fixtures"
PASS=0; FAIL=0; N=0
# payload <prompt 檔或字串> [tool_name]
payload() {
python3 - "$1" "${2:-Task}" <<'PY'
import json, os, sys
src, tool = sys.argv[1], sys.argv[2]
prompt = open(src, encoding="utf-8").read() if os.path.isfile(src) else src
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
"tool_name": tool, "tool_input": {"prompt": prompt}},
ensure_ascii=False))
PY
}
# t <期望 exit> <說明> <payload JSON>
t() {
want="$1"; desc="$2"; body="$3"
N=$((N+1))
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$err" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
clean() { rm -f /tmp/.dispatch-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "① 合規:整份派工單只有一行票號" \
"$(payload '【工單】inkstone/ISEP#30 → comment 4322')"
t 0 "② 合規:兩張票就兩行【工單】" \
"$(payload '【工單】inkstone/ISEP#30
【工單】inkstone/InkStoneCo#55')"
t 0 "③ 合規:用 #issuecomment- 定址也認得" \
"$(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)→ 一律不管" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
t 0 "⑤ 整包不是合法 JSON → fail-open" 'this is not json at all'
t 0 "⑥ 沒有【工單】→ 閉嘴,那是 no-ticket-no-dispatch 的地盤(不准兩支閘同時開口)" \
"$(payload '去把 arcrun 那個 worker 修一修,順便跑一下測試')"
# 注入:合規時要真的把共通規定送出去(leo 的驗收條件之一——
# 「收工方**沒讀派工單**也知道要貼回原票」)
N=$((N+1))
inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#30')" | bash "$HOOK" 2>/dev/null)
if printf '%s' "$inj" | python3 -c '
import sys, json
d = json.load(sys.stdin)
c = d["hookSpecificOutput"]["additionalContext"]
assert "貼回那張票" in c, "沒有交件方式"
assert "【身份】" in c, "沒有身份欄規定"
assert "不准 push" in c, "沒有 main 紅線"
' 2>/dev/null; then
printf ' ✅ ⑦ 合規的派工會被注入共通規定(交件方式+身份欄+不准 push main\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑦ 共通規定沒有被注入,或缺了其中一項\n'
printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
clean
touch /tmp/.dispatch-ok-S-TEST
t 0 "⑧ 明示豁免戳記在 → 放行一次" \
"$(payload '【工單】inkstone/ISEP#30
這一次真的有例外')"
N=$((N+1))
if [ -f /tmp/.dispatch-ok-S-TEST ]; then
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
fi
clean
echo
echo "── B 群:該擋 ──────────────────────────────────────────────────"
t 2 "⑨ **真跡**:產生 ISEP#30 這條線的那一次派工(散文開場+指令圍欄+三點就地+交件)" \
"$(payload "$FIX/real-violation-isep30.txt")"
t 2 "⑩ 只多一行散文——沒有「一句而已」這種豁免" \
"$(payload '【工單】inkstone/ISEP#30
記得先讀 CLAUDE.md')"
t 2 "⑪ 帶【交件】——那是「每次都一樣」,該進共通規定" \
"$(payload '【工單】inkstone/ISEP#30
【交件】貼回 inkstone/ISEP#30,回報分支名')"
t 2 "⑫ 帶【就地】——那是「這次才知道」,該寫進票" \
"$(payload '【工單】inkstone/ISEP#30
【就地】main 現在是 8e7e265')"
t 2 "⑬ 帶【人格】——已收回的欄位" \
"$(payload '【工單】inkstone/ISEP#30
【人格】ISEP plugin 維護者')"
t 2 "⑭ 票號形狀不對(裸號,跨 repo 會撞號)" \
"$(payload '【工單】#30')"
t 2 "⑮ 內容躲在圍欄裡也算數(圍欄只讓裡面的【】不被當欄位,不讓內容變成不存在)" \
"$(payload '【工單】inkstone/ISEP#30
```
【就地】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 "── C 群:訊息本身 ──────────────────────────────────────────────"
clean
msg=$(printf '%s' "$(payload "$FIX/real-violation-isep30.txt")" | bash "$HOOK" 2>&1 >/dev/null)
N=$((N+1))
if printf '%s' "$msg" | grep -q '這句話換一張票還成立嗎' \
&& printf '%s' "$msg" | grep -q '共通規定' \
&& printf '%s' "$msg" | grep -q '寫進那張票'; then
printf ' ✅ ⑯ 訊息講得出判準與兩條出路(共通規定/寫進票)\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑯ 訊息缺了判準或其中一條出路\n'
printf '%s\n' "$msg" | sed -n '1,10p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 迴歸:訊息裡的反引號一度在別支閘被 shell 當成命令替換執行掉
# 2026-08-26 ask-user-question-guard:閘照擋,但**它教人怎麼解的那兩行變成空白**)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'scripts/ticket say' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.dispatch-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -qi 'is a directory\|command not found'; then
printf ' ✅ ⑰ 訊息原文照印:反引號沒被當命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑰ 訊息被 shell 展開了(指令消失,或冒出 shell 錯誤)\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 訊息要點名是哪一格違規(可迭代的最小單位——leo 4325:「照這種散文寫法根本無法迭代」)
N=$((N+1))
if printf '%s' "$msg" | grep -q '派工單不只有票號'; then
printf ' ✅ ⑱ 訊息點得出違反的是哪一條規則,不是一句籠統的「格式不對」\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑱ 訊息沒有點名違反哪一條\n'; FAIL=$((FAIL+1))
fi
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# factory-idle-guard.sh 的迴歸測試(inkstone/ISEP#30)。
#
# 來由:2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖——
# ① 「那句是等你,不是我的下一步」 ← 在否認自己有下一步
# ② 「這不是『下一步我要做 X』」 ← 引用閘自己的訊息來說明沒在做
# ③ 貼出閘自己的正則原始碼來舉報這個 bug ← code block 裡的引用也被當成宣告
# 而閘的訊息教人走「選項③:改寫那句話,說明它在等什麼」,程式碼裡卻沒有那條分支。
#
# 本檔的兩個方向都要有證據(誤攔與漏攔都要測):
# A 群「該放行」——上面三種誤攔案例 + 既有放行路徑不准變鬆的回歸
# B 群「該擋」 ——真的宣告了未來動作、且這回合沒按下任何 trigger
# C 群「訊息承諾的出路真的走得通」——訊息裡列的每一條,各一個實例
#
# 用法:hooks/tests/factory-idle-guard.test.sh [hooks/factory-idle-guard.sh 的路徑]
# 🔴 全程在一個乾淨的 TMP 底下造假 transcript,跑完自己清;不碰任何真 repo、不打網路。
# CLAUDE_PROJECT_DIR 指向沒有 gitea remote 的 TMP ⇒ 副閘拿不到 token,直接 exit 0
# 所以本檔測的一律是主閘。)
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/factory-idle-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
N=0
# mk <transcript 路徑> <block spec>...
# block spec`T:<文字>` assistant 的文字段;`U:<工具名>` assistant 的 tool_use
mk() {
python3 - "$@" <<'PY'
import json, sys
path, specs = sys.argv[1], sys.argv[2:]
rows = [{"type": "user", "message": {"content": [{"type": "text", "text": "【工單】去辦"}]}}]
blocks = []
for s in specs:
k, v = s.split(":", 1)
blocks.append({"type": "text", "text": v} if k == "T"
else {"type": "tool_use", "name": v, "id": "t1", "input": {}})
rows.append({"type": "assistant", "message": {"content": blocks}})
with open(path, "w") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
PY
}
# t <期望 exit> <說明> <block spec>...
t() {
want="$1"; desc="$2"; shift 2
N=$((N+1))
tr="$TMP/tr-$N.jsonl"
mk "$tr" "$@"
out=$(printf '{"transcript_path":"%s"}' "$tr" \
| CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
echo "── A 群:該放行(2026-08-23 雲端三次誤攔的原形狀)─────────────────"
t 0 "① 否認自己有下一步(『那句是等你,不是我的下一步』)" \
'T:我看完了。那句是等你,不是我的下一步。'
t 0 "① 長版:連同被否認的原句一起引用" \
'T:你貼的那行「回覆後我就繼續」是等你,不是我的下一步。'
t 0 "② 引用閘自己的訊息來說明沒在做" \
'T:這不是『下一步我要做 X』,是在說明那道閘認錯了對象。'
t 0 "③ code block 裡貼閘自己的正則原始碼(舉報 bug)" \
'T:誤攔的真兇是這段:
```
r"(下一步(我|就是|是)?[:]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
```
它連裸的「下一步」三個字都吃。'
t 0 "③ 行內 code 貼同一段正則" \
'T:問題出在 `接下來我` 這個 alternative 沒有要求後面接動詞。'
t 0 "引用 leo 的話(blockquote)含宣告字樣" \
'T:> 下一步我去把那支閘修好,你不要插手。
以上是他八月十六日的原話,我引來說明這道閘的規格。'
t 0 "User Story 票名(身為…我要…我才…)不是宣告" \
'T:我把票名寫成:身為總管,我要把散落的分支收掉整理好,我才不會每天重數一次。'
# ↓ 這兩向不是想出來的,是拿本機一條 2068 行的真 transcript 跑出來的:
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是「我在交代 leo 該做什麼」。
t 0 "④ 主詞是 leo 的下一步(『你下一步去把 X 併進 main』)" \
'T:## 你下一步(兩招,先便宜的)
你下一步去把 feat/isep-dynamic-install 併進 main,然後開一個全新雲端 session。'
t 0 "④ 同上,交代對方要跑的動作" \
'T:前三題不適用。下一步還是那一個動作:你把那條分支併進 main,再跑一次看信標印什麼。'
t 0 "④ 『你的下一步』——主詞在對方身上(單字 lookbehind 蓋不到的變體)" \
'T:我把兩條路都寫清楚了。你的下一步去把那條分支併進 main,再開一個新 session 看信標。'
t 2 "④ 的反面:主詞換回我自己 ⇒ 照樣擋" \
'T:我把兩條路都寫清楚了。我的下一步去把那條分支併掉。'
echo
echo "── A 群:既有放行路徑,不准變鬆(迴歸)─────────────────────────"
t 0 "純對話回合,沒有任何宣告" \
'T:這道閘的問題在於它把引用當成宣告,量錯了對象。'
t 0 "宣告了,但這個回合真的呼叫了 Agent(=按下 trigger" \
'T:下一步我去把 sdd-guard 的誤攔補上。' 'U:Agent'
t 0 "宣告了,但這個回合真的呼叫了 Task" \
'T:接下來我會去補測試。' 'U:Task'
t 0 "宣告句帶票號指涉(已經在別人手上)" \
'T:下一步我去把那支閘補好——已經派給 inkstone/ISEP#30,證據在 comment 3970。'
t 0 "宣告之後真的動手了(文字後面還有工具呼叫)" \
'T:先讀原始碼,接下來我會去補測試。' 'U:Read'
echo
echo "── B 群:該擋(真的宣告了未來動作,且這回合沒按下任何 trigger)────"
t 2 "『下一步我去把 X 補上』,無派工無票號" \
'T:我看完了。下一步我去把那支閘的誤攔補上。'
t 2 "『接下來我會去補測試』" \
'T:目前狀況清楚了。接下來我會去補測試。'
t 2 "把請示寫成條件句(2026-08-17 leo 實撞那族)" \
'T:回「規劃」我就派人盤這份計畫。'
t 2 "『我等你確認』——空手停在等人身上" \
'T:我等你確認。'
t 2 "『我現在就去跑一遍』" \
'T:我現在就去跑一遍那批測試。'
t 2 "『確認後我才動』" \
'T:核准後我才去改那支閘。'
t 0 "票號寫在行內 code 裡也算數(等長替換才量得到)" \
'T:下一步我去把那支閘補好——已派給 `inkstone/ISEP#30`。'
t 2 "票號離宣告句太遠(文章別處提到而已)⇒ 不算它在別人手上" \
'T:早上那批已經在 inkstone/ISEP#30 收掉了。
(中略:這裡是一大段跟那張票無關的驗收敘述,用來把距離拉開。'"$(printf 'x%.0s' $(seq 1 320))"'
下一步我去把那支閘的誤攔補上。'
echo
echo "── C 群:訊息承諾的每一條出路,都要真的走得通 ─────────────────"
t 0 "出路①:這個回合直接呼叫 Agent 派出去" \
'T:下一步我去把那支閘補好。' 'U:Agent'
t 0 "出路②:把票號寫進那句話" \
'T:下一步我去把那支閘補好(已派給 inkstone/ISEP#30)。'
t 0 "出路③:改寫那句話,用 ⏸ 等: 說明它在等什麼" \
'T:⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘,不是我能自己按的)。
在那之前我等你確認,這件事我不自己動。'
t 0 "出路③:宣告 + 標記同時存在也放行(標記說明了它在等什麼)" \
'T:那批 workflow 要重打 bundle,下一步我去重打。⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除。'
t 0 "出路④:這個回合收尾在動作上(最後一個工具呼叫之後沒有文字)" \
'T:下一步我去把那支閘補好。' 'U:Edit'
echo
echo "── 出路③ 的反面:沒寫標記就照樣擋(不准靠刪掉那句話矇混)───────"
t 2 "同一句話拿掉 ⏸ 等: 標記 ⇒ 擋" \
'T:那批 workflow 要重打 bundle。我等你確認,這件事我不自己動。'
echo
echo "── 壞輸入不准亂擋(fail-open)────────────────────────────────"
n_bad=0
for payload in '{}' '{"transcript_path":"/nonexistent/xx.jsonl"}' 'not-json'; do
N=$((N+1))
printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" >/dev/null 2>&1; rc=$?
if [ "$rc" -eq 0 ]; then
printf ' ✅ 壞輸入 %-34s ⇒ exit=0\n' "$payload"; PASS=$((PASS+1))
else
printf ' ❌ 壞輸入 %-34s ⇒ exit=%s(期望 0\n' "$payload" "$rc"; FAIL=$((FAIL+1))
fi
n_bad=$((n_bad+1))
done
echo
echo "────── 通過 $PASS 失敗 $FAIL (共 $N 向)"
[ "$FAIL" -eq 0 ]
+32
View File
@@ -0,0 +1,32 @@
# 測資的來歷
`real-violation-isep30.txt` 是**真的發生過的那一份派工單**,一字未改(只把家目錄路徑
換成 `/Users/x` 去識別)——它就是產生 `inkstone/ISEP#30` 這條線的那一次派工。
leo 2026-08-27 要的測資是「今天實際發生的 5 次違規派工」
`arcrun-rag#104``Arcrun#142``Arcrun#127``Arcrun#144``InkStoneCo#55`)。
🔴 **其中 4 份拿不回來了。** 它們住在那幾次派工的 prompt 裡,agent 結束就沒了——
**這件事本身就是這條規則的證據**(4327:「那個 agent 被停掉/換人接手,
那段事實就消失了」)。所以這裡只放拿得到的那一份真跡,
其餘的覆蓋率改用「一條規則一個最小案例」來補(見 dispatch-format-guard.test.sh B 群)。
**不要用想像出來的例子替補那 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。
+17
View File
@@ -0,0 +1,17 @@
你要動 **ISEP plugin**Gitea `inkstone/ISEP`,全機共用的閘)。開工前先讀該 repo 的 CLAUDE.md 與 `system-dev/wiki/`。
【工單】`inkstone/ISEP#30` → **comment 4322**(任務全文在那則,去讀它)
```
TOKEN=$(cd /Users/x/InkStoneCo && git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
curl -s -H "Authorization: token $TOKEN" "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments?limit=60"
```
這個 session 才知道、票上還沒有的事:
- `~/Documents/tech_projects/` 若讀不到(macOS TCC),立刻回報,不要硬撐;今天稍早發生過,leo 已開權限
- 正本也可從 `~/.claude/plugins/marketplaces/inkstone` cloneorigin 是 `inkstone/ISEP.git`),
今天 `v0.4.0` 那條線就是這樣施工的
- `main` 現在是 `8e7e265``v0.4.0``ask-user-question-guard.sh` 剛上線)
交件:貼回 `inkstone/ISEP#30`,回報分支名。
+101
View File
@@ -0,0 +1,101 @@
# hooks/tests/lib/hook-sandbox.sh — 讓「會寫檔的閘」在測試時寫到暫存區,不寫進 repo。
# 不是獨立掛的閘(沒進 hooks.json),給 hooks/tests 與 scripts/test-* `source` 用。
#
# 🔴 為什麼需要這支(inkstone/ISEP#59 comment 4779 第 ① 條,實測重演過):
# main-and-prod-push-guard.sh 擋下「推 main」的同時,會把那次請求寫成一份
# `<hooks 的上一層>/pending-main-push/<誰>--<repo>.md`——**那份檔案的語意是
# 「有一筆推 main 正在等總管裁」**。而測試的測資本來就全是「推 main」,
# 於是每跑一次測試,工作區就多/改幾筆**偽造的待裁決**:
#
# $ git status --short # 跑完測試之後(修之前的實測)
# M pending-main-push/unnamed--ISEP.md ← 被測試覆寫(它是被追蹤的)
# ?? pending-main-push/unnamed--A.md ← 跨 repo 測試建的臨時 repo
#
# 兩個後果,後者比較貴:
# ① `git add -A` 很容易把它們帶進 commit(08-27 那次真的帶進去了,事後才拔掉)
# ② **永遠在響的警報**:總管的迴圈讀這個目錄,讀到的每一筆都該是真的在等他裁。
# 測試每跑一次就偽造一筆 ⇒ 下一筆真的請求會混在雜訊裡。
#
# 做法:把整個 `hooks/` 複製到暫存區再跑那支複本。閘算 `pending-main-push` 的位置
# 靠的是 `$0` 的上一層,所以複本會寫進暫存區,**產品程式碼一行都不用改**
# (不必為了測試在閘上多開一個「寫去哪」的開關——那種開關會變成關掉紀錄的路)。
#
# 用法:
# . "$(dirname "$0")/lib/hook-sandbox.sh"
# hook_sandbox "$REAL_HOOK" || exit 1
# G=$HOOK_SANDBOX_HOOK # 之後一律測 $G
# before=$(hook_sandbox_hostsum) # repo 那份 pending-main-push 的指紋
# ... 跑測試 ...
# hook_sandbox_assert "$before" # 回 0=乾淨且沙盒裡真的有留下請求
# hook_sandbox <真跡 hook 的路徑> → 建好沙盒,設三個變數,回 0/1
# HOOK_SANDBOX_HOOK 沙盒裡那支同名 hook(之後一律測這支)
# HOOK_SANDBOX 沙盒根(`pending-main-push` 會長在這底下)
# HOOK_SANDBOX_HOST 真跡 repo 的 pending-main-push(拿來比對有沒有被弄髒)
#
# 🔴 **刻意不用 `printf` 把路徑印出來讓呼叫端 `G=$(hook_sandbox …)` 接**——
# 命令替換跑在子殼裡,函式設的那三個變數**回不到呼叫端**。
# 寫這支的當下就這樣寫,第一次跑的結果是:`HOOK_SANDBOX` 在呼叫端是空的
# ⇒ hostsum 兩次都回 `NODIR`(相等)、沙盒的請求數當然是 0
# ⇒ **第一條斷言變成「拿空的比空的」的假綠**。
# 所以改成「設變數、不印」,而且 hook_sandbox_assert 開頭會擋空值(見下)。
hook_sandbox() {
_hs_real=$1
_hs_hooks=$(CDPATH= cd -- "$(dirname -- "$_hs_real")" && pwd) || return 1
HOOK_SANDBOX=$(mktemp -d) || return 1
cp -R "$_hs_hooks" "$HOOK_SANDBOX/hooks" || return 1
HOOK_SANDBOX_HOST="${_hs_hooks%/hooks}/pending-main-push"
HOOK_SANDBOX_HOOK="$HOOK_SANDBOX/hooks/$(basename "$_hs_real")"
[ -f "$HOOK_SANDBOX_HOOK" ] || return 1
}
# hook_sandbox_hostsum → 印出真跡 repo 那份 pending-main-push 的指紋(檔名+內容)
# 目錄不存在時印 NODIR——「本來就沒有」跟「空的」要分得出來。
hook_sandbox_hostsum() {
if [ -d "${HOOK_SANDBOX_HOST:-/nonexistent}" ]; then
find "$HOOK_SANDBOX_HOST" -type f 2>/dev/null | LC_ALL=C sort | while IFS= read -r _f; do
cksum "$_f" 2>/dev/null || printf 'UNREADABLE %s\n' "$_f"
done
else
printf 'NODIR\n'
fi
}
# hook_sandbox_assert <before 的指紋> → 印出兩行結果,全過回 0
# 🔴 兩件都要驗,只驗前者會養出「把紀錄關掉也算通過」的假綠:
# ① repo 那份 pending-main-push 一個位元都沒動(測試沒有偽造待裁決)
# ② 沙盒那份真的多了請求檔(**留請求這件事本身還在做**)
hook_sandbox_assert() {
_hs_before=$1
# fail-closed:沙盒沒建起來(或變數沒傳到這裡)時,下面兩條會變成「空的比空的」=假綠。
if [ -z "${HOOK_SANDBOX:-}" ] || [ -z "${HOOK_SANDBOX_HOST:-}" ]; then
printf ' ❌ %-58s\n' "沙盒變數是空的——這兩條斷言等於沒跑,當失敗處理"
HS_PASS=0; HS_FAIL=2; return 1
fi
_hs_after=$(hook_sandbox_hostsum)
_hs_rc=0; HS_PASS=0; HS_FAIL=0
if [ "$_hs_before" = "$_hs_after" ]; then
printf ' ✅ %-58s\n' "repo 的 pending-main-push 沒被測試碰過"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "repo 的 pending-main-push 被測試寫髒了"
printf ' before/after diff\n'
printf '%s\n' "$_hs_before" > "$HOOK_SANDBOX/.before"
printf '%s\n' "$_hs_after" > "$HOOK_SANDBOX/.after"
diff "$HOOK_SANDBOX/.before" "$HOOK_SANDBOX/.after" | sed 's/^/ /'
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
_hs_n=$(find "$HOOK_SANDBOX/pending-main-push" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "${_hs_n:-0}" -gt 0 ]; then
printf ' ✅ %-58s%s 筆)\n' "請求有被留下來,只是留在沙盒裡" "$_hs_n"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "沙盒裡一筆請求都沒有——留請求的機制可能被關掉了"
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
return $_hs_rc
}
hook_sandbox_cleanup() {
case "${HOOK_SANDBOX:-}" in
/*/*) rm -rf "$HOOK_SANDBOX" ;; # 只刪 mktemp 給的那種深路徑,不接受空值/根目錄
esac
}
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# 跨 repo 戳記實測(inkstone/ISEP#30 comment 3949,脈絡 inkstone/InkStoneCo#57
#
# 補的是什麼:hooks/tests/main-and-prod-push-guard.test.sh 那八向都只在單一 repo
# (測試腳本自己所在的 repo)裡驗證,從沒測過「站在 A、要推 B 的 main」這個形狀
# ——而這正是 2026-08-21 真的撞到、讓戳記永遠對不上的那個情境。這支專門補這塊。
#
# 用法:main-and-prod-push-guard-cross-repo.test.sh <要測的 hook 絕對路徑>
# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑
# 到那時就對不上了(自己撞過一次:exit=127 command not found)。
set -u
REAL="$1"
# 🔴 測沙盒裡的複本,不測真跡:這支閘擋下推 main 時會把請求寫進
# `<repo>/pending-main-push/`,而這裡每一條測資都是推 main
# ⇒ 直接測真跡,每跑一次就在 ISEP 的工作區偽造幾筆「還沒裁」
# (實測留下過 `pending-main-push/unnamed--A.md`——A 是這支自己建的臨時 repo,
# inkstone/ISEP#59 comment 4779 第 ① 條)。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
STAMP=/tmp/.main-push-ok
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"; rm -f "$STAMP"; hook_sandbox_cleanup' EXIT
for d in A B; do
git init -q -b main "$WORK/$d"
git -C "$WORK/$d" config user.email t@t.com
git -C "$WORK/$d" config user.name t
echo x > "$WORK/$d/f.txt"
git -C "$WORK/$d" add f.txt
git -C "$WORK/$d" commit -q -m init
done
A="$WORK/A"; B="$WORK/B"
pass=0; fail=0
t() { # t <說明> <cwd> <指令> <期望 exit>
local desc="$1" cwd="$2" cmd="$3" want="$4"
local rc
rc=$(cd "$cwd" && CLAUDE_CODE_CHILD_SESSION=1 python3 -c '
import json, subprocess, sys
p = subprocess.run(["bash", sys.argv[2]],
input=json.dumps({"tool_name": "Bash",
"tool_input": {"command": sys.argv[1]}}),
capture_output=True, text=True)
print(p.returncode)
' "$cmd" "$G")
if [ "$rc" = "$want" ]; then printf ' ✅ %-58s exit=%s\n' "$desc" "$rc"; pass=$((pass+1))
else printf ' ❌ %-58s exit=%s(期望 %s\n' "$desc" "$rc" "$want"; fail=$((fail+1)); fi
}
echo "── 2026-08-21 實撞的原形狀:站在 A,要推 B 的 main ──"
rm -f "$STAMP"
t "沒戳記 → 擋" "$A" "cd $B && git push origin HEAD:main" 2
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "替 B 開的戳記 → 推 B 的 main 該放行(舊版在此情境永遠擋,這是本票要修的洞)" \
"$A" "cd $B && git push origin HEAD:main" 0
echo "── 反向不准鬆:替 A 開的戳記,不能拿去放行推 B(08-11 那次穿透的形狀)──"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "替 A 開的戳記 → 拿去推 B 的 main 必須仍被擋" \
"$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "── git -C 語法要吃到同一套判斷 ──"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "替 B 開戳記,用 git -C B push" "$A" "git -C $B push origin main" 0
rm -f "$STAMP"
echo "── 08-11 原始穿透的形狀:子殼裡的 cd 不能外洩到殼外 ──"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "子殼裡 cd 去 B 但沒在殼內推;殼外站著 A 真的推 → 符合 A 的戳記,放行" \
"$A" "(cd $B && true); git push origin HEAD:main" 0
rm -f "$STAMP"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "子殼裡 cd 去 B 且在殼內真的推 → 目標是 B,戳記是 A,必須擋" \
"$A" "(cd $B && git push origin HEAD:main)" 2
rm -f "$STAMP"
echo "── 順手抓到、一併修的洞:純括號包住整條指令,不准繞過目的地判斷 ──"
t "(git push origin HEAD:main) 沒有任何戳記 → 必須擋(舊版在此整段放行)" \
"$A" "(git push origin HEAD:main)" 2
echo "── 同 reposession 站著的那個)舊行為原封不動 ──"
rm -f "$STAMP"
t "站在 A 推 A 自己的 main,沒戳記 → 擋" "$A" "git push origin HEAD:main" 2
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "站在 A 推 A 自己的 main,替 A 開戳記 → 放行" "$A" "git push origin HEAD:main" 0
rm -f "$STAMP"
echo "── 舊有行為一條都不能壞 ──"
t "推 feature branch 放行" "$A" "git push origin feat/xyz" 0
t "推 tag 放行" "$A" "git push origin refs/tags/v1.0.0" 0
t "只是提到 main 的 gh pr create,放行" "$A" "gh pr create --base main --title t" 0
echo "── subagent 沒戳記,即使 cd 去別的 repo 也照擋 ──"
rm -f "$STAMP"
t "subagent 站在 A、cd 去 B 推 main,沒戳記仍擋" "$A" "cd $B && git push origin HEAD:main" 2
echo "── 單次用完即丟、900 秒逾時:換到跨 repo 場景一樣要成立 ──"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "第一次:替 B 開戳記推 B → 放行" "$A" "cd $B && git push origin HEAD:main" 0
t "第二次:同一枚戳記(已用掉)再推一次 → 應該擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"; touch "$STAMP"
t "touch 出的空戳記 → 推 B 的 main 仍應擋(08-12 補的洞不能被本次改動重開)" \
"$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%m%d%H%M.%S)" "$STAMP" 2>/dev/null
t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = 0 ]
+35 -34
View File
@@ -1,42 +1,43 @@
#!/usr/bin/env bash
# main-and-prod-push-guard.sh 的迴歸測試——重點在「geek6688 豁免的範圍夠不夠窄」。
# 🔴 寫成檔案跑:測試指令必然含 `wrangler deploy`,直接在 Bash 打會被 prod-write-guard 擋
#
# 用一個**沒有 .github-armed** 的臨時 CLAUDE_PROJECT_DIR
# 否則本機真的有那個檔 ⇒「該擋的」會假性通過,測了等於沒測。
HOOK="$1"
TMPPROJ=$(mktemp -d)
trap 'rm -rf "$TMPPROJ"' EXIT
# 八向實測 main-and-prod-push-guard.sh
# 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔)
REAL="$1" # 要測的 hook 路徑(真跡)
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
PASS=0; FAIL=0
t(){ mk "$3" | env CLAUDE_PROJECT_DIR="$TMPPROJ" "$HOOK" >/dev/null 2>&1; rc=$?
got=$([ $rc -eq 2 ] && echo block || echo pass)
if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1))
else echo "$2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; }
# 🔴 不要直接測真跡:這支閘擋下推 main 的同時會把請求寫進 `<repo>/pending-main-push/`
# 而這裡的測資全是推 main ⇒ 每跑一次就在工作區偽造幾筆「還沒裁」(inkstone/ISEP#59)。
# 改測沙盒裡的複本,閘照原樣跑,請求寫進暫存區。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
trap 'hook_sandbox_cleanup' EXIT
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
D="wrangler deploy"
pass=0; fail=0
echo "── geek6688leo 2026-08-12 明文授權總管可直接動)應放行 ──"
t pass "指名 geek6688 主機名" "npx $D --name arcrun-cypher-executor --config geek6688.toml"
t pass "用 geek6688 的 token 變數" "CLOUDFLARE_API_TOKEN=\$CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE npx $D"
t pass "用 geek6688 的 account id 變數" "CLOUDFLARE_ACCOUNT_ID=\$CLOUDFLARE_ACCOUNT_ID_GEEK6688 npx $D"
t() { # t <說明> <指令> <期望 exit>
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$2" \
| CLAUDE_CODE_CHILD_SESSION=1 CLAUDE_PROJECT_DIR="$(dirname "$(dirname "$G")")" \
bash "$G" >/tmp/pg.out 2>&1
rc=$?
if [ "$rc" = "$3" ]; then printf ' ✅ %-46s exit=%s\n' "$1" "$rc"; pass=$((pass+1))
else printf ' ❌ %-46s exit=%s(期望 %s\n' "$1" "$rc" "$3"; fail=$((fail+1)); fi
}
echo "── 其他實例仍要 leo 親手 arm(範圍不能外溢)──"
t block "打 leo21c" "npx $D --name arcrun-mcp --account-id leo21c-acct"
t block "打 uncle6 官方" "npx $D --name arcrun-kbdb --config uncle6.toml"
t block "看不出打哪裡" "npx $D"
t block "wrangler publish 到別台" "npx wrangler publish --name arcrun-mcp"
t block "versions deploy 到別台" "npx wrangler versions deploy --name arcrun-kbdb"
echo "── 該放行(今晚五次誤攔的原形狀)──"
t "checkout -b 後推 feature 分支" 'git checkout -q -b fix/x ma'"in"' && git push -q origin fix/x' 0
t "gh pr create --base(不是 git push" 'gh pr create --head f --base ma'"in"' --title t' 0
t "推 tag" 'git push -q origin refs/tags/v0.3.3' 0
t "推 feature 分支(帶 -u" 'git push -q -u origin feat/milestone-must-have-due' 0
t "分支名含 domain" 'git push origin fix/custom-domain-setup' 0
echo "── stage 照舊自由 ──"
t pass "帶 --env staging" "npx $D --env staging"
t pass "打 staging 主機" "npx $D --name arcrun-rag-installer-staging"
echo "── 該擋 ──"
t "直接推預設分支" 'git push origin ma'"in" 2
t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2
t "推 master" 'git push -q origin mas'"ter" 2
echo "── 非部署動作不受影響 ──"
t pass "推自己的分支" "git push -u gitea fix/my-branch"
t pass "讀本閘原始碼(指令裡含關鍵字)" "sed -n '1,50p' .claude/hooks/main-and-prod-push-guard.sh"
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ $FAIL -eq 0 ] || exit 1
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = "0" ]
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# 身份欄的兩道閘(inkstone/ISEP#30 comment 4325:「subagent 回覆時要表明身份」)。
#
# 貼留言到票上有**兩條路**,所以測兩道閘:
# 正門 scripts/ticket say / decide —— 檢查在打任何 API 之前,所以本測試離線跑得動
# 側門 直接打 Gitea API —— hooks/reply-identity-guard.sh
#
# **離線、不打網路。** 正門的案例全部在 `die()` 之前就結束,不會真的送出留言。
#
# 用法:hooks/tests/reply-identity.test.sh
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
HOOK="$ROOT/hooks/reply-identity-guard.sh"
TICKET="$ROOT/scripts/ticket"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
ok() { printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); N=$((N+1)); }
bad() { printf ' ❌ %s\n' "$1"; shift; printf '%s\n' "$*" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1)); N=$((N+1)); }
# ── 側門:hooks/reply-identity-guard.sh ─────────────────────────────────
h() { # h <期望 exit> <說明> <command 字串>
want="$1"; desc="$2"; cmd="$3"
body=$(python3 - "$cmd" <<'PY'
import json, sys
print(json.dumps({"tool_name": "Bash", "tool_input": {"command": sys.argv[1]}},
ensure_ascii=False))
PY
)
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
}
echo "── 側門(直接打 Gitea API)─────────────────────────────────────"
h 2 "① POST 留言但沒有【身份】→ 擋" \
'curl -X POST -H "Authorization: token $T" -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
h 0 "② POST 留言且內文帶【身份】→ 放行" \
'curl -X POST -d "{\"body\":\"【身份】subagentinkstone/ISEPfeat/x\n做完了\"}" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
h 0 "③ 純讀取(GET 撈留言)→ 放行,這是最常做的動作,誤攔它比漏擋更糟" \
'curl -s -H "Authorization: token $T" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments?limit=60'
h 0 "④ 走正門 scripts/ticket → 放行(正門有自己的閘,兩支同時擋會互相打架)" \
'scripts/ticket say inkstone/ISEP#30 -F /tmp/body.md'
h 0 "⑤ 開新票的端點(不帶票號)→ 不是本閘的地盤" \
'curl -X POST -d @t.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues'
h 0 "⑥ 逃生口 reply-identity-ok → 放行(留在指令歷史上)" \
'curl -X POST -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments # reply-identity-ok'
h 0 "⑦ 只是在講這件事(把端點寫進文件)而沒有 POST → 不擋" \
'echo "留言端點是 issues/30/comments" >> docs/notes.md'
echo
echo "── 正門(scripts/ticket)───────────────────────────────────────"
g() { # g <期望 exit> <說明> <內文>
want="$1"; desc="$2"; content="$3"
printf '%s' "$content" > "$TMP/body.md"
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/body.md" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
}
g 2 "⑧ 內文沒有身份欄 → 在打 API 之前就擋(所以這一格離線也測得動)" \
'做完了,分支是 feat/x'
g 2 "⑨ 有【身份】但角色不在三選一之內 → 擋" \
'【身份】機器人/inkstone/ISEPfeat/x
做完了'
g 2 "⑩ 身份欄不在第一行 → 擋(要一眼看得到,不是藏在中間)" \
'做完了
【身份】subagentinkstone/ISEPfeat/x'
# ⑪ 合規的內文要能通過身份檢查——它會往下走到打 API,
# 離線環境打不出去所以 exit 非 0;判準改成「錯誤訊息不是身份欄那一段」。
printf '%s' '【身份】subagentinkstone/ISEPfeat/x
做完了' > "$TMP/ok.md"
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/ok.md" 2>&1 >/dev/null || true)
if printf '%s' "$err" | grep -q '第一行要表明身份'; then
bad "⑪ 合規的內文被身份閘擋掉了(誤攔)" "$err"
else
ok "⑪ 合規的內文通過身份閘(之後成不成功是網路的事,不是本閘的事)"
fi
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]
+169 -36
View File
@@ -1,26 +1,45 @@
#!/bin/bash
# 管什麼: 用 Gitea API 直接開新票時,要求這一輪有跑過跨 repo 的搜尋(/tmp/.ticket-where-ok30 分鐘內)。
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人。2026-08-20 總管走 API 側門開了 12 張票,每一張都跟舊票重疊
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘 API 也放行),或在指令裡加 `ticket-api-ok` 留痕放行。
# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘
# 管什麼: 用 Gitea API 直接「新增」東西時(開新票/開新 milestone/開新 label/開新 PR),
# 要求這一輪有跑過搜尋(/tmp/.ticket-where-ok30 分鐘內)
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人;而擋側門的第一版只認
# 大寫裸字 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 分鐘失效)。
# 但總管當天開了 12 張與舊票重疊的新票——因為他**沒用那支工具,直接打 Gitea API**
# ⇒ 規範有、閘也有,但閘長在「工具」上,而那個動作有兩條路,只封了一條。
# leo:「你在讓事情複雜化」/「每張票開以前都要搜尋現有票,你為什麼會開了不搜?」
# 但 2026-08-20 總管開了 12 張與舊票重疊的新票——沒用那支工具,直接打 API。
# 2026-08-27 總管又開了 13 張,這次連當時的 v1 guard 都繞過了:
# 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,只強迫你搜過。
# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板
# 所以判準取「端點 + 寫入動詞」兩個都命中才擋,讓純讀取一律放行。)
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板。)
#
# 放行(刻意,這些都不是「開票」):
# 放行(刻意,這些都不是「新增」):
# - 只讀不寫(GET):撈清單、看票、對帳
# - 對既有票的留言/改標籤/關票/issues/<N>/... 這種帶票號的子路徑)
# - scripts/ticket 自己(它有自己的閘,重複擋只會互相打架)
# - 對既有票號的子路徑/issues/<N>/... /milestones/<N> /labels/<N> /pulls/<N>...):
# 留言、改標籤、關票、改狀態、合併 PR——leo 要的是「不要亂開新的」,不是「不准動 Gitea」
# - scripts/ticket 自己(它有自己的三道閘,重複擋只會互相打架)
# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上)
#
# 只對「issues」做主動搜尋+在擋下的訊息裡直接點名可能撞到的舊票(milestones/labels/pulls
# 沒有對應的跨 repo 全文搜尋端點可用,一律只要求「搜尋戳記新鮮」,不生產額外訊息——
# 這是刻意的簡化,不是漏做;真要幫 milestone/label/PR 做語意重複檢查是另一張票的事)。
# 搜尋本身連不上網路 → 不影響擋/放行的判斷,只影響訊息裡有沒有列出候選舊票(fail-open
# 在「錦上添花」的部分,不 fail-open 在「該不該擋」這個核心判斷上)。
set -uo pipefail
INPUT=$(cat)
@@ -34,18 +53,54 @@ except Exception: print('')
# 逃生口(留痕)
case "$CMD" in *ticket-api-ok*) exit 0 ;; esac
# 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 端點(不帶票號的那個=建立新票的路徑)
printf '%s' "$CMD" | grep -qE 'repos/[^ "'"'"']*/issues([?"'"'"'`,)\\[:space:]]|$)' || exit 0
# ① 先放行「帶了 ID 的子路徑」——issues/<N>、milestones/<N>、labels/<N>、
# 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 一律放行
# 刻意只認一個裸字:跳脫引號、heredoc、python、curl、各種包裝的寫法無限多,
# 逐個補 pattern 追不完(leo 2026-08-17:「自然語言的變體是無限的,blacklist 永遠追不完」)。
# ①已經確定這是「開票那條端點」,純讀取的指令不會出現 POST ⇒ 一個字就夠,而且沒有跳脫的破口。
printf '%s' "$CMD" | grep -qw 'POST' || exit 0
# ② 有沒有打到 Gitea 的「集合端點」(不帶 ID 的那個=新增的路徑
# repos/<owner>/<repo>/<resource> 或 orgs/<org>/<resource>,資源名後面立刻是
# 「不是路徑字元」的東西(問號/引號/逗號/右括號/空白/結尾)。
# 中間那段刻意只排除空白(不必排引號):①已經先擋掉「帶 ID」的形狀,這裡
# 只要认出「打到了這個資源的集合端點」就好,多抓一點字元進中間那段不影響
# 判斷——這也是為什麼 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
NOW=$(date +%s)
FRESH=no
@@ -55,30 +110,108 @@ if [ -f "$STAMP" ]; then
fi
[ "$FRESH" = "yes" ] && exit 0
cat >&2 <<'MSG'
🚫 你正在用 Gitea API 直接開新票,而這一輪沒有搜尋紀錄
# 只有 issues 才做「主動幫你搜一次」——從指令裡的 title 欄位猜關鍵字,
# 真的搜得到就把候選舊票直接列進擋下的訊息裡(重演 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:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」
leo 2026-08-20:「**每張票開以前都要搜尋現有票,你為什麼會開了不搜?這個規範不是早就有 hook 了?**」
cmd = sys.stdin.read()
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` 這支工具裡,
而你走的是 API 這條側門。**本閘就是把那道門也封上。**
toks = []
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 張票,事後盤點**每一張都跟舊票重疊**,
全部只能關掉指回舊票。leo:「**你在讓事情複雜化**」。
token = os.environ.get("_TICKET_TOKEN", "")
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 say <owner/repo#N> -F <內文檔>
2. 真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
scripts/ticket new <repo> -F <內文檔> --title <標題>
3. 這次確實不是在開新票(例如批次改標籤/關票/留言)
3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態
→ 指令裡加 `ticket-api-ok` 說明理由,留痕放行。
放行的情況(本閘不管):純讀取(GET)、對既有票 /issues/<N>/ 的留言與標籤、scripts/ticket 自己。
放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、
scripts/ticket 自己。
MSG
} >&2
exit 2
+16
View File
@@ -0,0 +1,16 @@
# `pending-main-push/` — 被擋下的推 main 請求(本機狀態,不進版控)
`hooks/main-and-prod-push-guard.sh` 擋下一次推 main 的同時,會把那次請求寫成
`<誰>--<哪個 repo>.md` 丟進這個目錄:repo/分支/逐筆 commit/改了哪些檔,**原始資料**。
> leo 2026-08-12:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」
> 閘做不到「同步問總管」(不同行程),做得到的是「**當場擋 + 留下是誰要求的**」。
## 規約
- **一份檔案 一筆還沒裁的請求。** 總管裁完就刪掉它——留著代表還沒裁。
- **這個目錄的內容不進版控**`.gitignore` 有一段寫了為什麼),只有本檔在版控裡。
- **測試不准在這裡留東西。** 推 main 的測資本來就都會觸發這支閘,
所以那幾支測試改成跑 `hooks/tests/lib/hook-sandbox.sh` 建的複本,請求寫進暫存區;
每支測完都會驗兩件:這裡沒被碰過、而且沙盒裡**真的有**留下請求
(只驗前者的話,「把留紀錄的功能關掉」也會綠——那是假綠)。
-26
View File
@@ -1,26 +0,0 @@
# 推 main 的請求:未署名
- repo/Users/youlinhsieh/Documents/tech_projects/ISEP
- 分支:main
- 時間:2026-08-20 20:59:30
- 它想跑的指令:
```
git push gitea HEAD:main
```
## 還沒推上去的 commit(原始資料,不是轉述)
```
```
## 改了哪些檔
```
docs/TESTING.md | 14 +---------
scripts/make-cloud-env.sh | 69 -----------------------------------------------
2 files changed, 1 insertion(+), 82 deletions(-)
```
---
總管裁完請刪掉這個檔——留著代表「還沒裁」。
+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 ]
+17 -1
View File
@@ -4,7 +4,19 @@
# 開頭包一層讀取指令)都不准擋;推自己的分支、含子字串但目標不是 main/master
# 的分支(如 fix/custom-domain-setup)也不准擋。
cd "$(dirname "$0")/.." || exit 1
H=hooks/main-and-prod-push-guard.sh
# 🔴 測沙盒裡的複本,不測真跡(inkstone/ISEP#59 comment 4779 第 ① 條):
# 這支閘擋下推 main 的同時會把請求寫進 `<repo>/pending-main-push/`,那份檔案的
# 語意是「有一筆推 main 正在等總管裁」。這裡的測資本來就都是推 main
# ⇒ 直接測真跡的話,**每跑一次測試就偽造一筆待裁決**(實測:`unnamed--ISEP.md`
# 每跑一次被覆寫一次,而它當時還是被追蹤的 ⇒ `git status` 永遠是髒的)。
# cwd 仍然留在 repo 根——戳記比對問的是「人站在哪個 repo」,那件事沒有變。
. hooks/tests/lib/hook-sandbox.sh
hook_sandbox "$PWD/hooks/main-and-prod-push-guard.sh" || { echo "❌ 沙盒建不起來"; exit 1; }
trap 'hook_sandbox_cleanup' EXIT
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
H=$HOOK_SANDBOX_HOOK
PASS=0; FAIL=0
run(){ # $1=want $2=cmd
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \
@@ -31,6 +43,10 @@ run 0 'grep -n "git push" hooks/main-and-prod-push-guard.sh'
echo "── 真違規不能因為這次改動而漏擋:戳記過期/戳記綁錯 repo 一樣要擋 ──"
run 2 'git push gitea HEAD:main' # 沒有任何戳記檔時
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
PASS=$((PASS+HS_PASS)); FAIL=$((FAIL+HS_FAIL))
echo
echo "$PASS/$((PASS+FAIL)) 通過"
[ "$FAIL" -eq 0 ]
+49 -2
View File
@@ -1,6 +1,8 @@
#!/bin/bash
# 開票側門閘的測試(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
H=hooks/ticket-api-bypass-guard.sh
PASS=0; FAIL=0
@@ -33,8 +35,53 @@ run 0 'curl -s "$API/repos/inkstone/Arcrun/issues?state=open&limit=100"'
echo "── 有新鮮戳記時放行 ──"
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'
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
echo
echo "$PASS/$((PASS+FAIL)) 通過"
+283 -2
View File
@@ -18,6 +18,12 @@ leo 2026-08-16 三句話,本工具就是它們的機械化:
ticket close <票> --deliverable <URL> 關票(要有交付物連結)
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`
"""
import json
@@ -30,6 +36,41 @@ import urllib.error
import urllib.parse
import urllib.request
# ── 身份欄:與 hooks/lib/dispatch_parse.py 共用同一份定義 ──────────────────
# leo 2026-08-27:「**subagent 回覆時要表明身份**」
# 實害(同日):多條線並行,票上的留言看不出是誰寫的,
# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
# 規約與側門閘見 docs/governance/dispatch-and-reply-format.md §3。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
from dispatch_parse import parse_identity, IDENTITY_ROLES
except Exception: # 拿不到共用零件就不擋(fail-open)——開票的路不該被一個 import 卡死
parse_identity, IDENTITY_ROLES = None, ("總管", "subagent", "leo")
def check_identity(body, what):
"""貼進票的內文第一行要表明身份。**在打任何 API 之前就擋**,所以離線測得動。"""
if parse_identity is None:
return
ok, detail = parse_identity(body)
if ok:
return
die(f"""🚫 {what}的第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」)
現在的問題:{detail}
第一行照這個寫(角色三選一:{''.join(IDENTITY_ROLES)}):
【身份】subagentinkstone/ISEPfeat/my-branch
【身份】總管/inkstone/InkStoneCo-
**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標。
實害(2026-08-27):多條線並行時票上看不出誰寫的,
總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。
規約全文:docs/governance/dispatch-and-reply-format.md §3""")
HOST = "https://git.uncle6.me"
ORG = "inkstone"
STAMP_DIR = "/tmp"
@@ -148,6 +189,7 @@ def cmd_say(argv):
die("用法:ticket say <owner/repo#N> -F <內文檔>")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
check_identity(body, "貼進票的留言")
c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
print(f"✅ 已貼進 {owner}/{repo}#{num}")
print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}")
@@ -293,7 +335,7 @@ def cmd_close(argv):
if deliv:
api(f"/repos/{owner}/{repo}/issues/{num}/comments",
{"body": f"✅ 結案。交付物:{deliv}"})
{"body": f"{os.environ.get('ISEP_IDENTITY') or f'【身份】總管/{owner}/{repo}-'}\n\n✅ 結案。交付物:{deliv}"})
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')}")
@@ -323,6 +365,8 @@ def cmd_decide(argv):
body = open(argv[2]).read()
nxt = argv[argv.index("--next") + 1] if "--next" in argv else None
check_identity(body, "裁決紀錄")
if "leo" not in body.lower() and "原話" not in body:
die("🚫 答案檔裡看不到 leo 的原話。\n"
" **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n"
@@ -351,7 +395,244 @@ 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-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 len(sys.argv) < 2 or sys.argv[1] not in CMDS:
+83
View File
@@ -35,3 +35,86 @@ Gitea 上**一個 release tag 都沒有**。
⇒ 「東西做出來了」與「有人在用它」是兩件事,而只有後者算交付。
⇒ 判準:**去執行環境查它有沒有被載入**(`claude plugin list` / `claude plugin details`),
不要從 repo 裡有什麼檔案去推論。
## ⚠️ MISTAKE: 判準寫在閘裡了,但那個閘掛在**做完之後**才跑的時機上
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: leo 一天內好幾次被丟純技術路徑選擇,當場問「**今天已經好幾次問我,
為什麼 hooks 沒有攔下來?**」,其中一次他直接說「這種問題不要問我,
我要的是你解決了以後給我 prod」。
實查: 總管問 leo 走的動作是 `AskUserQuestion` 這個工具,而
`hooks.json``AskUserQuestion` 出現 **0 次**——沒有任何 matcher,它是裸的。
判準其實早就寫好了(`self-drive-police.sh` / `self-drive-judge.sh` 用的就是四題公式),
但那兩支只掛在 `Stop``SubagentStop`
原因: **判準對了,時機錯了。** `Stop` 是回合結束後才跑——問題早就送到 leo 眼前、
他早就被打斷了,這時再反問 AI「你查過了嗎」,成本已經轉嫁出去了。
正確做法: 攔截點要長在**那個動作發生的那一刻**(`PreToolUse` / `AskUserQuestion`)。
新增 `hooks/ask-user-question-guard.sh`
🔴 **推廣**:以後看到「規則寫了卻沒被攔下來」,先問的不是「判準對不對」,
而是「**這支閘掛在哪個事件上、那個事件發生時傷害造成了沒有**」。
## ⚠️ MISTAKE: hook 訊息用沒加引號的 heredoc,反引號會被當成命令執行
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: `ask-user-question-guard.sh` 擋下之後,stderr 冒出
`line 218: system-dev/wiki/: is a directory`,而訊息裡
「去查 `system-dev/wiki/`」和「`touch /tmp/.ask-ok-<session_id>`」兩行
**變成空白**。閘照擋 exit 2,所以測試若只看離開碼**完全看不出來**。
原因: 寫成 `cat >&2 <<EOF`(heredoc 標記沒加引號)⇒ shell 會對內容做展開,
而本 repo 的 hook 訊息**慣例上大量使用反引號**標路徑與指令
⇒ 每一組反引號都被當成命令替換真的去執行。
正確做法: hook 的訊息一律用 `cat <<'EOF'`(標記加單引號)。
需要塞變數就留 `__PLACEHOLDER__`,事後用 python 換掉——
**不要用 sed**,正體中文加上訊息裡的 `/``&``\` 讓跳脫非常脆。
迴歸測試要**檢查訊息內容**,不能只檢查離開碼
`hooks/tests/ask-user-question-guard.test.sh` 的 ⑩b 就是這一條)。
## ⚠️ MISTAKE: 閘只驗了規則的**殼**,沒驗規則本身
`no-ticket-no-dispatch.sh` 掛在派工的當下,檢查「派工單裡有沒有一行 `【工單】owner/repo#N`」。
規則的原文卻是「**不准把票上已經有的東西再抄一遍進派工單**⋯⋯派工單只寫票號」。
⇒ 於是可以**把 40 行任務全寫在 prompt 裡、票號補一行**,閘照樣放行。
⇒ 2026-08-27 一天之內這樣做了 5 次,每一次票上都沒有那份任務。
leo:「**這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?**」
**根因不是那支閘寫壞了,是它驗的東西比規則小。**
「有沒有票號」是規則最容易機械化的那一格,所以它被實作了;
「任務有沒有真的落在票上」比較難,所以沒有——而漏掉的那格才是規則的本體。
⇒ **判準:寫完一支閘,回頭把規則原文逐句對一次,問「這一句被驗到了嗎」。**
只驗得到最容易的那一格 ⇒ 那支閘會製造「有在管」的錯覺,比沒有閘更危險。
⇒ 同款:history-firstKBDB-firststage-first`AskUserQuestion` 裸奔,全是這個形狀。
修法(v0.5.0):`dispatch-format-guard.sh`——**派工單 = 票號,多一個字都擋**。
規則變得比原本更嚴,反而更好驗:判準從「內容夠不夠」變成「這一行是不是【工單】欄位」,
純結構、不用語意判官、每次結果一樣。
規約:`docs/governance/dispatch-and-reply-format.md`
日期: 2026-08-27`inkstone/ISEP#30` comment 432243254327
## ⚠️ MISTAKE: 「這是 session 才知道的事」被當成寫進 prompt 的正當理由
派工鐵律允許派工單帶「這個 session 才知道、票上還沒有的事」。
總管照字面理解,把 TCC 權限、`main` 是哪顆 commit、正本能從哪裡 clone 三件事寫進 prompt。
leo 當場:「**這些為什麼不寫到票裡?**」
**「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。**
⇒ 實害(同日):總管停掉重派 3 次,**前兩次的任務與 session 事實全部隨 prompt 蒸發**。
票活得比任何一個 agent 久,prompt 不是。
判準:**「這句話換一張票還成立嗎?」**
還成立 ⇒ 共通規定(`docs/governance/dispatch-and-reply-format.md` §2,機器自動注入)。
只有這次成立 ⇒ 寫進那張票。**兩種都不進派工單。**
日期: 2026-08-27`inkstone/ISEP#30` comment 4327