Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bebbbd2c11 | |||
| eb7c3656be | |||
| 16c03e46c1 | |||
| 99abdf725b | |||
| accedb5898 | |||
| 77a2032858 | |||
| be09353330 | |||
| 81368f451f | |||
| a9e30fc6e7 | |||
| 17a74d7d3a | |||
| e0ac81b2e3 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:51 支機械閘(64 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.9.0",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:53 支機械閘(68 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、38 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.11.0",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -91,6 +91,80 @@ bash scripts/test-baton-handback-guard.sh
|
||||
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
|
||||
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
|
||||
|
||||
### A14 — PR 一定要有結論:52 條
|
||||
```
|
||||
bash hooks/tests/pr-verdict-guard.test.sh
|
||||
```
|
||||
**該看到**:`52 通過 / 0 失敗`。全程離線(走 `PR_VERDICT_FIXTURE`),
|
||||
**不打 Gitea、不開測試 PR、不留任何測試票**;狀態檔走 `PR_VERDICT_STATE_DIR`。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#81`,2026-08-27 實查):最久的三個 open PR 躺了兩星期
|
||||
(`inkstone/arcrun-rag#91` 14 天、`inkstone/Arcrun#116` 14 天、`inkstone/Arcrun#104` 15 天),
|
||||
而 v0.9.0 的 46 支閘**一支都沒有在管 PR**。票看起來是「已交付」,
|
||||
東西卻沒進 main ⇒ 沒進版本 ⇒ **leo 手上永遠不會出現它**。
|
||||
|
||||
**失敗**:
|
||||
- B 群(⑨⑩⑪,票上那三個真跡)任一紅 ⇒ 今天這個形狀會再發生一次
|
||||
- C 群 ⑭⑰ 紅 ⇒ 驗收條件 1/3 沒過:「放著不管」抓不到,或「merge 了分支還在」放行
|
||||
- **A 群任何一條紅 ⇒ 誤攔,這比漏擋嚴重**。②③特別重要:
|
||||
指派給人/掛 Human 之後還在點名,就是 ISEP v0.6.0 divergence §B4 記過的那個坑
|
||||
(「`s/review` 佇列非空即 block」⇒ 佇列本來就不會空 ⇒ **總管永遠停不下來**)
|
||||
- E 群 ㉙ 紅 ⇒ 閘訊息裡承諾的出路(「把它指派給那個人,本閘立刻不再點名它」)是假的
|
||||
- D 群 ㉓ 紅 ⇒ 判準從「識別碼比對」滑成「只要提到票號就放行」,閘會被任何一句話關掉
|
||||
|
||||
> 📌 **真跡不只在 fixture 裡跑過**:同一支閘打真實 Gitea 也驗過,
|
||||
> 一次點名 7 個沒有結論的 PR,含票上那三個(15/14/14 天)。
|
||||
> 指令:`printf '{"session_id":"x","transcript_path":"/nonexistent"}' | bash hooks/pr-verdict-guard.sh`
|
||||
> ——**唯讀,不改動任何 PR**。
|
||||
|
||||
### A15 — 給結論的那支工具自己說得出要打哪幾通 API
|
||||
```
|
||||
python3 scripts/pr-verdict list
|
||||
python3 scripts/pr-verdict merge inkstone/ISEP#71 --dry-run
|
||||
```
|
||||
**該看到**:`list` 印出 open PR 與「有沒有人被指派」;`merge --dry-run` 印出
|
||||
`[dry-run] POST …/pulls/71/merge` 與 `[dry-run] DELETE …/branches/<分支>`,**一通都不真的送出**。
|
||||
**失敗**:`--dry-run` 底下出現真實的寫入結果 ⇒ 這支工具沒辦法在按下去之前被檢查。
|
||||
### A14 — 每一則回覆都自己說出拖了多久:20 條
|
||||
```
|
||||
bash hooks/tests/countdown-guard.test.sh
|
||||
```
|
||||
**該看到**:`通過 20 條,失敗 0 條`。**全程離線**:時鐘用 `ISEP_COUNTDOWN_NOW` 定住、
|
||||
狀態走 `ISEP_COUNTDOWN_STATE_DIR`,不打網路、不碰 `$HOME`。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#63`,leo 2026-08-27):
|
||||
「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」
|
||||
「這應該寫在 ISEP,隨時看自己拖了多久」——重點在後面那句:
|
||||
**不是要總管記得戴,是要它長在機器上。** 總管當時答「現在開始戴」,
|
||||
而那正是這條規則第一次失效的方式。
|
||||
|
||||
**判準**:一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。
|
||||
|
||||
**失敗**:
|
||||
- A 群(注入)任何一條紅 ⇒ 那一行算錯了。**④ 特別看**:期望「已過 2 小時 13 分」,
|
||||
實得就要是同一個字串——這格就是票上驗收第 2 條「數字對得上真實經過的時間」
|
||||
- ⑤ 紅 ⇒ 連續幾則不是遞增(歸零或亂跳),票上驗收第 3 條
|
||||
- ⑦ 紅 ⇒ 過了收工線卻悄悄換算成明天。**超時消失 = 這個東西的意義沒了**
|
||||
- ⑨ 紅 ⇒ 沒有 milestone 快取時編了一個日期出來(寧可少一段,不准編)
|
||||
- B 群(該擋)紅 ⇒ 「注入了但模型沒照做」這個主要失效模式沒有被接住
|
||||
- **C 群(不該擋)任何一條紅 ⇒ 誤攔,這比漏擋嚴重**——每一回合都被念一次,
|
||||
人就學會忽略它,那它就等於不存在(本 repo 心法第 2 條)
|
||||
|
||||
🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:本閘刻意放行子 session
|
||||
(subagent 的回覆不是給 leo 看的),所以在一條 subagent 裡跑,B 群會**全綠而且是假綠**。
|
||||
測試檔自己會把它清掉——2026-08-28 第一次跑就撞到這個,才補上去的。
|
||||
|
||||
### A15 — 發通知不等於部署:37 條
|
||||
```
|
||||
bash hooks/tests/prod-write-guard.test.sh hooks/prod-write-guard.sh
|
||||
```
|
||||
**該看到**:`通過 37 / 失敗 0`(原本 29 條,`inkstone/ISEP#63` 補 8 條)。
|
||||
**失敗**:
|
||||
- 「卡點一」6 條任一紅 ⇒ 要嘛 leo 收不到 Telegram(`notify_leo` 被當成部署擋掉),
|
||||
要嘛白名單放太寬——**「別的 named webhook」「部署端點」兩條若變綠,等於這道閘被拆了**
|
||||
- 「卡點二」2 條任一紅 ⇒ 「談論它」又被當成「執行它」(同款第八次),
|
||||
或是剝了內文之後連真的部署都放行了
|
||||
|
||||
### A5 — 開票前的搜尋是跨 repo 的
|
||||
```
|
||||
python3 scripts/ticket where 標籤 模組化
|
||||
@@ -284,6 +358,10 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
||||
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
|
||||
| **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/24(2026-08-27,inkstone/ISEP#72) |
|
||||
| **A13 戳記證明看過不是跑過** | 總管 | ✅ 17/17(2026-08-27,inkstone/ISEP#72→4873) |
|
||||
| **A14 PR 一定要有結論** | 總管 | ✅ 52/52(2026-08-28,inkstone/ISEP#81)+真實 Gitea 唯讀重演 |
|
||||
| **A15 pr-verdict --dry-run** | 總管 | ✅(2026-08-28) |
|
||||
| **A14 回覆自己說出拖了多久** | 總管 | ✅ 20/20(2026-08-28,inkstone/ISEP#63) |
|
||||
| **A15 發通知不等於部署** | 總管 | ✅ 37/37(2026-08-28,inkstone/ISEP#63) |
|
||||
| A5 搜尋跨 repo | 總管 | ✅ |
|
||||
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
||||
| **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) |
|
||||
|
||||
+18
-3
@@ -1,4 +1,4 @@
|
||||
# 51 支閘,白話盤點表
|
||||
# 52 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
@@ -7,14 +7,16 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **51 個 `.sh` 檔**,`hooks.json` 實際掛上 **64 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **53 個 `.sh` 檔**,`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **53 個 `.sh` 檔**,`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
> `ls hooks/*.sh | wc -l` = 51;`grep -c '"command":' hooks/hooks.json` = 64。
|
||||
> `ls hooks/*.sh | wc -l` = 53;`grep -c '"command":' hooks/hooks.json` = 68。
|
||||
> `ls hooks/*.sh | wc -l` = 53;`grep -c '"command":' hooks/hooks.json` = 68。
|
||||
> ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。
|
||||
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
|
||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||
@@ -26,12 +28,23 @@
|
||||
> 一加一減之後檔數不變、註冊條數落在 59——**這正是「不能靠加減推算、只能實數」的例子**:
|
||||
> 上一版寫 58,就是把兩批改動的加減兜出來、沒有真的數過。
|
||||
>
|
||||
> 📌 **`0.10.0`(`inkstone/ISEP#63`,2026-08-28)+1 支、+3 條**:
|
||||
> `countdown-guard.sh` 一支同時掛在 `UserPromptSubmit`(注入倒數)與 `Stop`(查核有沒有戴),
|
||||
> 加上 SessionStart 多掛一條 `scripts/countdown-milestone-refresh.sh`(更新期限快取,不是閘)。
|
||||
> **兩個事件是同一件事的兩半**:只注入 = 又一個會被忽略的提醒;只查核 = 罰它做一件拿不到資料的事。
|
||||
> 這兩個數字是併之前當場數出來的,不是從上一版加減推出來的。
|
||||
|
||||
> 🔴 **`0.6.1`(2026-08-27)補回表格漏掉的兩列**:`isep-presence-beacon.sh`(E 組)、
|
||||
> `milestone-due-guard.sh`(A 組)。**標頭寫 48 支的同時,下面的表格只列得出 46 支**
|
||||
> ——數字是對的,人話少了兩行。發現的方法與防它再犯的指令,見下面「落差偵測」第二組 `comm`。
|
||||
>
|
||||
> 📌 **`0.7.0`(`inkstone/ISEP#30`,2026-08-27)進來一支**:`mainline-idle-guard.sh`(F 組,Stop)。
|
||||
> 48→**49** 支、59→**60** 條,兩個數字都是加完之後當場數出來的(指令同上)。
|
||||
>
|
||||
> 📌 **`0.10.0`(`inkstone/ISEP#81`,2026-08-28)進來一支**:`pr-verdict-guard.sh`(F 組,Stop)。
|
||||
> 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。
|
||||
> 順手改掉一個過期的數字:描述欄長期寫「27 支腳本」,實數是 **34**
|
||||
> (`ls -p scripts | grep -v / | wc -l`)——**同一個病,只是換一欄。**
|
||||
|
||||
---
|
||||
|
||||
@@ -126,6 +139,8 @@
|
||||
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
|
||||
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23(inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
|
||||
| `mainline-idle-guard.sh` | AI **連續 4 個回合一直在查、一直在讀,卻什麼都沒交出去**(沒派工、沒改檔、沒寫回票)就擋一次。補的是上面兩支中間那一格:「沒動作」空手警察管、「說了下一步卻沒派」稼動率警察管,**而「有動作、但就是不說下一步」以前兩支都放行**。這支**一個字都不讀**,只看有沒有派工/產出/工作區變動。響過一次門檻就加倍(4→8→16),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) |
|
||||
| `pr-verdict-guard.sh` | 收工時清點「還有哪些 PR 沒有結論」——open、**沒有人被指派**、也沒有「要求修改」review 的 PR,就擋一次並**點名是哪幾個**(含開了幾天與網址);已經 merge 但**分支還留著**的也一起點名(票上寫死「併完當場刪掉那條 branch」)。背景:2026-08-27 實查,最久的三個 open PR 躺了兩星期,而當時 46 支閘沒有一支在管 PR——票看起來「已交付」,東西卻沒進 main、沒進版本,**leo 手上永遠不會出現它**。🔴 **擋的是遺忘,不是等待**:指派給誰(或掛 Human)就立刻不再點名它;響過門檻就加倍(1→4→8→16,每個 PR 各自算)。搭配 `scripts/pr-verdict`(merge/reject/changes 各一個動作)。 | 🛑 擋(響過就退讓) |
|
||||
| `countdown-guard.sh` | **每一則回覆都自己說出「這件事已經花了多久/距今天收工線還剩多久」**,而且不必有人記得。一支閘掛兩個事件:你送出訊息時它先把算好的那一行放到 AI 眼前(`UserPromptSubmit`),AI 想收工時它再看這一回合的回覆裡到底有沒有那一行,沒有就擋一次要它補(`Stop`)。**只做前半 = 又一個會被忽略的提醒**,所以兩半綁在一起。已過時間從**這段對話的第一則訊息**算起(=一個 release 的起點);收工線是台北 16:00,過線後四小時內顯示「已超過」,不會把超時悄悄換算掉。子 session、純工具回合、已經提醒過一次的,全部放行。 | 🛑 擋(至多攔一次) |
|
||||
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
|
||||
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
|
||||
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
|
||||
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
# countdown-guard.sh — 每一則回覆都自己說出「已經花了多久/還剩多久」
|
||||
# (inkstone/ISEP#63,leo 2026-08-27+08-28 兩次下令)
|
||||
#
|
||||
# ── 為什麼是一支閘,不是一句叮嚀 ──────────────────────────────────────
|
||||
# leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」
|
||||
# 「這應該寫在 ISEP,隨時看自己拖了多久」
|
||||
# 總管當時答「我沒做,現在開始戴」——**而那正是這條規則第一次失效的方式**:
|
||||
# 靠記性的規則,下一個 session 就沒有了(history-first/KBDB-first/stage-first
|
||||
# 全是同一個形狀)。⇒ 判準:**一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。**
|
||||
#
|
||||
# ── 兩段,缺一不可(票上點名的失效模式就在這裡)───────────────────────
|
||||
# 票上寫得很清楚:「**注入了但模型沒照做**是這類機制的主要失效模式」。
|
||||
# 所以這支同時掛兩個事件,是**同一件事的兩半**:
|
||||
#
|
||||
# ① UserPromptSubmit ── 注入:把算好的那一行送到眼前(模型不必自己算、也算不準)
|
||||
# ② Stop ── 查核:這一回合的回覆裡到底有沒有那一行;沒有就擋一次
|
||||
#
|
||||
# 只有 ① = 又一個會被忽略的提醒(本 repo 心法第 2 條明令不要做這種東西)。
|
||||
# 只有 ② = 罰它做一件它拿不到資料的事。兩段合起來才是一條走得通的路。
|
||||
#
|
||||
# ── 🔴 這不是關鍵字黑名單(leo 2026-08-17 的診斷)────────────────────
|
||||
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
|
||||
# 是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
|
||||
# 當日實測:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定。
|
||||
#
|
||||
# 本支查的**不是「有沒有講某些不該講的話」,而是「那個被要求的輸出元素在不在」**:
|
||||
# 一個由機器產生、只有一個字元的標記(`⏱`),值是機器算的,模型抄過去。
|
||||
# ⇒ 沒有「換個講法就閃過」的空間(換講法照樣要帶標記),
|
||||
# 也沒有「講到某個詞就被咬」的誤攔(多寫什麼都不會觸發)。
|
||||
# ⇒ 這是 whitelist-of-one:**要求一個東西在場**,不是**猜哪些東西不該在場**。
|
||||
#
|
||||
# ── 誤攔的出口都先關掉(誤攔比漏擋更該修)────────────────────────────
|
||||
# · 子 session(subagent)不查——它的回覆不是給 leo 看的
|
||||
# · 這一回合沒有任何文字輸出(純工具回合)不查——沒有東西可以戴
|
||||
# · `stop_hook_active` 為真=已經提醒過一次 ⇒ 放行,**至多擋一次,不會卡死**
|
||||
# · 讀不到 transcript/內部出錯 ⇒ 放行(這支是節拍器,不是安全閘,
|
||||
# 它壞掉不該讓整個 session 停下來)
|
||||
set -uo pipefail
|
||||
|
||||
INPUT="$(cat)"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LIB="$HERE/lib/countdown.py"
|
||||
[ -f "$LIB" ] || exit 0
|
||||
|
||||
EVENT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("hook_event_name", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
|
||||
# ══ ① 注入模式(UserPromptSubmit)═════════════════════════════════════
|
||||
if [ "$EVENT" = "UserPromptSubmit" ]; then
|
||||
printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c '
|
||||
import json, os, sys, importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"])
|
||||
cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd)
|
||||
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: d = {}
|
||||
try:
|
||||
stamp = cd.line(d.get("session_id") or "", d.get("transcript_path") or "")
|
||||
except Exception:
|
||||
raise SystemExit(0) # 算不出來就不出聲,不要讓它變成噪音
|
||||
|
||||
ctx = (
|
||||
stamp + "\n\n"
|
||||
"🔴 這一行是 ISEP 的倒數(inkstone/ISEP#63)。**把它原樣放在你這則回覆的最前面**,"
|
||||
"數字不要自己重算、也不要改寫措辭——它是機器算的,你算的會漂。\n"
|
||||
"leo 整天上課、一天只看兩眼:沒有這行,他就不知道『這件事還來得及嗎』。\n"
|
||||
"收工前這一回合若沒有戴上它,Stop 那一半會擋一次要你補。"
|
||||
)
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": ctx,
|
||||
}
|
||||
}, ensure_ascii=False))
|
||||
' 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ══ ② 查核模式(Stop)═════════════════════════════════════════════════
|
||||
# 子 session 不查:subagent 的回覆是交給總管的,不是給 leo 看的那一則。
|
||||
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
|
||||
|
||||
LOG="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/hooks/countdown-guard.log"
|
||||
|
||||
VERDICT=$(printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c '
|
||||
import json, os, sys, importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"])
|
||||
cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd)
|
||||
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP:bad-json"); raise SystemExit
|
||||
|
||||
if d.get("stop_hook_active"):
|
||||
print("SKIP:already-nudged"); raise SystemExit # 至多擋一次
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP:no-transcript"); raise SystemExit
|
||||
|
||||
rows = []
|
||||
try:
|
||||
with open(tp) as f:
|
||||
for ln in f:
|
||||
ln = ln.strip()
|
||||
if ln:
|
||||
try: rows.append(json.loads(ln))
|
||||
except Exception: pass
|
||||
except Exception:
|
||||
print("SKIP:unreadable"); raise SystemExit
|
||||
|
||||
# 這一回合=最後一則「真的來自使用者」的訊息之後(工具結果不算使用者說話)
|
||||
start = 0
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("type") == "user":
|
||||
c = (r.get("message") or {}).get("content")
|
||||
blocks = c if isinstance(c, list) else [{"type": "text"}]
|
||||
if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks):
|
||||
start = i
|
||||
turn = rows[start:]
|
||||
|
||||
texts = []
|
||||
for r in turn:
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
c = (r.get("message") or {}).get("content")
|
||||
if isinstance(c, str):
|
||||
texts.append(c); continue
|
||||
if not isinstance(c, list):
|
||||
continue
|
||||
for b in c:
|
||||
if isinstance(b, dict) and b.get("type") == "text":
|
||||
texts.append(b.get("text") or "")
|
||||
|
||||
body = "\n".join(t for t in texts if t and t.strip())
|
||||
if not body:
|
||||
print("SKIP:no-text"); raise SystemExit # 純工具回合,沒有東西可以戴
|
||||
|
||||
if cd.MARKER in body:
|
||||
print("OK"); raise SystemExit
|
||||
|
||||
try:
|
||||
stamp = cd.line(d.get("session_id") or "", tp)
|
||||
except Exception:
|
||||
print("SKIP:no-stamp"); raise SystemExit
|
||||
print("MISSING:" + stamp)
|
||||
' 2>/dev/null || echo "SKIP:crash")
|
||||
|
||||
STAMP_LINE="${VERDICT#MISSING:}"
|
||||
NOW=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
|
||||
|
||||
case "$VERDICT" in
|
||||
MISSING:*)
|
||||
printf '| %s | ⛔ 擋下 | 回覆沒有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true
|
||||
{
|
||||
printf '⏱ 倒數警察:**這則回覆沒有戴上倒數**(inkstone/ISEP#63)\n\n'
|
||||
printf 'leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」\n'
|
||||
printf ' 08-28:「先完成倒數計時器,**每一次回覆都發倒數**」\n\n'
|
||||
printf '把這一行原樣放在回覆最前面,再送一次(數字已經幫你算好,不要自己重算):\n\n'
|
||||
printf ' %s\n\n' "$STAMP_LINE"
|
||||
printf '為什麼非戴不可:leo 整天上課、一天只看兩眼。沒有這行,他看不出\n'
|
||||
printf '「這件事花了不該花的時間」——而那正是他要這個東西的全部理由。\n\n'
|
||||
printf '本閘**至多擋一次**(stop_hook_active 之後放行),不會卡死。\n'
|
||||
} >&2
|
||||
exit 2
|
||||
;;
|
||||
OK)
|
||||
printf '| %s | ✅ 放行 | 有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf '| %s | ⚪ 略過 | %s |\n' "$NOW" "$VERDICT" >> "$LOG" 2>/dev/null || true
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -223,6 +223,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/isep-presence-beacon.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/countdown-milestone-refresh.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -249,6 +253,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-idle-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pr-verdict-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -287,6 +295,14 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
@@ -363,6 +379,16 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hooks/lib/countdown.py —— 算「這件事已經花了多久/還剩多久」的那一行。
|
||||
|
||||
這是 helper,不是閘(inkstone/ISEP#40 S7:lib/ 底下的東西不算一支手寫的閘)。
|
||||
兩個呼叫者共用它,所以時間的算法只有一份:
|
||||
· UserPromptSubmit(`countdown-guard.sh` 注入模式)—— 把這一行送到模型眼前
|
||||
· Stop(同一支的查核模式)—— 擋下來時要把同一行原樣印出來給它抄
|
||||
|
||||
── 基準從哪一刻算起(inkstone/ISEP#63 要我自己判斷並說明理由)──────────
|
||||
leo 的原話是「**隨時看自己拖了多久**」。「拖」指向的是**任務**,不是 session,
|
||||
但「任務」在機器上沒有起點——沒有任何欄位記著「我什麼時候接手這條線」。
|
||||
可機械取得、又真的對應到一個交付的起點,只有一個:**這段對話的第一則訊息**。
|
||||
|
||||
而那正好就是總管的交貨單位:CLAUDE.md 規則三點七「**一段對話 = 一個 release**」。
|
||||
⇒ 已過時間 = 這個 release 已經燒掉多久。**這個數字不需要任何人維護,也不會說謊。**
|
||||
|
||||
resume 的 session 取「transcript 第一列的時間」而不是「hook 第一次被叫到的時間」:
|
||||
接關不是重新開始,昨天燒掉的兩小時仍然算在這個交付上。兩者都拿得到時取較早的。
|
||||
|
||||
── 第二個數字:今天的收工線 ──────────────────────────────────────────
|
||||
leo 2026-08-28 07:40:「今天我整天上課……**下午四點左右**希望已經都完成了」。
|
||||
⇒ 台北 16:00。過了 16:00 之後**不立刻換算到明天**——那會讓超時消失,
|
||||
而超時正是他要看的東西。過線後四小時內顯示「已超過 X」,
|
||||
台北 20:00 之後才滾到隔天(那時已經是下一個工作日的倒數了)。
|
||||
|
||||
── 第三個數字:主線 milestone 的期限(可選)────────────────────────────
|
||||
從快取檔讀,**這支從不打網路**:UserPromptSubmit 走在每一則訊息的關鍵路徑上,
|
||||
在那裡打網路 = 每一句話都先等一次 HTTP。快取由 SessionStart 那支負責更新
|
||||
(`scripts/countdown-milestone-refresh.sh`),拿不到就整段不顯示,不編數字。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
TAIPEI = timezone(timedelta(hours=8))
|
||||
MARKER = "⏱"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
"""現在。測試用 ISEP_COUNTDOWN_NOW(epoch 秒)把時鐘定住。"""
|
||||
override = os.environ.get("ISEP_COUNTDOWN_NOW", "").strip()
|
||||
if override:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(override), tz=timezone.utc)
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_ts(raw) -> "datetime | None":
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
s = raw.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
except Exception:
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def transcript_start(path: str) -> "datetime | None":
|
||||
"""transcript 第一列帶時間戳的那一列 = 這段對話真正的起點。"""
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
dt = _parse_ts(row.get("timestamp"))
|
||||
if dt:
|
||||
return dt
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def state_dir() -> str:
|
||||
d = os.environ.get("ISEP_COUNTDOWN_STATE_DIR", "").strip()
|
||||
if not d:
|
||||
d = os.path.join(os.path.expanduser("~"), ".claude", "isep-countdown")
|
||||
try:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def session_start(session_id: str, transcript_path: str, now: datetime) -> datetime:
|
||||
"""起點 = transcript 第一列與「第一次看到這個 session」兩者取較早。
|
||||
|
||||
第一次看到就落一個檔;transcript 沒有時間戳(或讀不到)時它就是唯一的憑據。
|
||||
"""
|
||||
cands = []
|
||||
ts = transcript_start(transcript_path)
|
||||
if ts:
|
||||
cands.append(ts)
|
||||
|
||||
sid = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")[:80]
|
||||
if sid:
|
||||
p = os.path.join(state_dir(), sid + ".start")
|
||||
seen = None
|
||||
try:
|
||||
with open(p) as f:
|
||||
seen = datetime.fromtimestamp(float(f.read().strip()), tz=timezone.utc)
|
||||
except Exception:
|
||||
seen = None
|
||||
if seen is None:
|
||||
seen = min(cands) if cands else now
|
||||
try:
|
||||
with open(p, "w") as f:
|
||||
f.write("%d" % seen.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
cands.append(seen)
|
||||
|
||||
return min(cands) if cands else now
|
||||
|
||||
|
||||
def deadline(now: datetime) -> datetime:
|
||||
"""今天的收工線(台北 HH:MM)。過線後四小時內不滾,讓超時看得見。"""
|
||||
hhmm = os.environ.get("ISEP_COUNTDOWN_DEADLINE", "16:00").strip() or "16:00"
|
||||
try:
|
||||
hh, mm = (int(x) for x in hhmm.split(":", 1))
|
||||
except Exception:
|
||||
hh, mm = 16, 0
|
||||
local = now.astimezone(TAIPEI)
|
||||
d = local.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
||||
if local >= d + timedelta(hours=4):
|
||||
d += timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
def milestone() -> "tuple[str, datetime] | None":
|
||||
"""主線 milestone 的期限。快取檔一行:`YYYY-MM-DD|<名稱>`。沒有就沒有。"""
|
||||
raw = os.environ.get("ISEP_MILESTONE_DUE", "").strip()
|
||||
name = os.environ.get("ISEP_MILESTONE_NAME", "").strip()
|
||||
if not raw:
|
||||
p = os.path.join(state_dir(), "milestone-due")
|
||||
try:
|
||||
with open(p) as f:
|
||||
raw = f.read().strip()
|
||||
except Exception:
|
||||
return None
|
||||
if "|" in raw:
|
||||
raw, name = raw.split("|", 1)
|
||||
raw, name = raw.strip(), name.strip()
|
||||
if not raw:
|
||||
return None
|
||||
dt = _parse_ts(raw) or _parse_ts(raw + "T23:59:59+08:00")
|
||||
if not dt:
|
||||
return None
|
||||
return (name or "主線", dt)
|
||||
|
||||
|
||||
def human(delta: timedelta) -> str:
|
||||
mins = int(abs(delta).total_seconds()) // 60
|
||||
h, m = divmod(mins, 60)
|
||||
if h and m:
|
||||
return "%d 小時 %d 分" % (h, m)
|
||||
if h:
|
||||
return "%d 小時" % h
|
||||
return "%d 分" % m
|
||||
|
||||
|
||||
def line(session_id: str, transcript_path: str) -> str:
|
||||
now = _now()
|
||||
start = session_start(session_id, transcript_path, now)
|
||||
dl = deadline(now)
|
||||
|
||||
parts = ["%s 已過 %s" % (MARKER, human(now - start))]
|
||||
left = dl - now
|
||||
if left.total_seconds() >= 0:
|
||||
parts.append("距收工線(台北 %s)剩 %s" % (dl.strftime("%H:%M"), human(left)))
|
||||
else:
|
||||
parts.append("🔴 已超過收工線(台北 %s)%s" % (dl.strftime("%H:%M"), human(left)))
|
||||
|
||||
ms = milestone()
|
||||
if ms:
|
||||
name, due = ms
|
||||
gap = due - now
|
||||
if gap.total_seconds() >= 0:
|
||||
parts.append("主線 %s 剩 %s" % (name, human(gap)))
|
||||
else:
|
||||
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
|
||||
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
payload = {}
|
||||
print(line(payload.get("session_id") or "", payload.get("transcript_path") or ""))
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 收工那一刻清點「我這邊還有沒有 PR 沒給結論」——open 且沒人在管的 PR、
|
||||
# 以及已經 merge 但**分支還留著**的 PR。有就擋一次並點名是哪幾個。
|
||||
# 為什麼: 2026-08-27 實查,最久的三個 open PR 躺了兩星期(inkstone/arcrun-rag#91、
|
||||
# inkstone/Arcrun#116、inkstone/Arcrun#104),而 v0.9.0 的 46 支閘沒有一支在管 PR。
|
||||
# 票看起來「已交付」,但東西沒進 main ⇒ 沒進版本 ⇒ leo 手上永遠不會出現它。
|
||||
# 誤觸時怎麼關: 直接再送一次即可(擋完就歸零,而且那幾個 PR 的門檻已加倍)。
|
||||
# 真的在等別人 ⇒ 把 PR 指派給他(或掛 Human),本閘立刻不再點名它。
|
||||
#
|
||||
# pr-verdict-guard.sh — PR 收斂警察(Stop;inkstone/ISEP#81)
|
||||
#
|
||||
# ── 為什麼是 Stop 而不是 PreToolUse ──────────────────────────────────
|
||||
# PR 堆積不是「某個動作做錯了」,是「某個動作從來沒發生」。
|
||||
# 沒有發生的事情攔不到,只能在收工那一刻清點。同 worklist-guard.sh 的形狀。
|
||||
# **刻意只掛 Stop,不掛 SubagentStop**:給 PR 結論是總管的事,
|
||||
# subagent 手上沒有那個權(它連 main 都不准推)⇒ 掛上去只會變成對它的誤攔。
|
||||
#
|
||||
# ── 這支跟 baton-handback-guard.sh 是接力,不是重複 ────────────────
|
||||
# baton 管「棒子有沒有交回來」(票的指派+tag+下一步三格)。
|
||||
# 本支管「交回來之後,總管有沒有動它」——PR 是那根棒子的實體。
|
||||
#
|
||||
# ── 🔴 已知的坑:這支很容易長成「讓總管永遠停不下來」的那一支 ──────
|
||||
# ISEP v0.6.0 的 divergence 文件 §B4 記過同款:
|
||||
# 「`s/review` 佇列非空即 block 總管 stop」⇒ 佇列本來就不會空 ⇒ 永遠擋。
|
||||
# ⇒ **要擋的是遺忘,不是等待。** 本閘用三層把「等待」全部放掉:
|
||||
#
|
||||
# ① 有人被指派(或掛 Human)⇒ 不點名。
|
||||
# 指派是 Gitea 原生欄位、也是本 repo 既有的「現在誰該動」真相
|
||||
# (見 baton-handback-guard 檔頭那張三格表)。棒子在誰手上是**事實**,
|
||||
# 不是措辭 ⇒ 有人拿著 = 在等,不是忘了。
|
||||
# ② 有一則 REQUEST_CHANGES 的 review ⇒ 不點名。
|
||||
# 「要求修改」這個結論已經給了,球在對方腳下。
|
||||
# ③ 這一回合碰過它 ⇒ 不點名(見下面「怎麼算碰過」)。
|
||||
# ④ 點名過就退讓:那個 PR 的門檻 1 → 4 → 8 → 16…(每 PR 各自算)。
|
||||
# ⇒ 不可能鎖死:擋完歸零,再送一次就過;真的長期不理才會再響。
|
||||
#
|
||||
# ── 怎麼算「碰過」:不讀任何一句話 ──────────────────────────────
|
||||
# ① `updated_at` 跟上一次收工比變了 ⇒ 碰過(留言/改標籤/推 commit/submit review
|
||||
# 全部會動它)。這是**外部系統的事實**,跟我在對話裡怎麼寫無關。
|
||||
# ② 上一次收工時還不存在的 PR ⇒ 剛開出來 ⇒ 碰過(不在它誕生的那一回合就開罵)。
|
||||
# ③ 這一回合的 tool call 裡出現它的**識別碼**(`owner/repo#N`/`/pulls/N`/它的網址)
|
||||
# ⇒ 碰過。這一條是為了接住「結論寫在票上」——票被改了、PR 沒被改,
|
||||
# ①②都看不到,但那正是本票規定的其中一種合法結論。
|
||||
#
|
||||
# 🔴 ③ 不是關鍵字黑名單(leo 2026-08-17 已證偽那條路:當日 8 次誤攔、0 次正確攔截)。
|
||||
# 差別在方向與集合:黑名單拿**措辭**來**擋**(措辭無限,永遠追不完);
|
||||
# 這裡拿**識別碼**來**放行**(識別碼是有限且唯一的,而且比對錯了只會少擋一次)。
|
||||
# 本 repo 心法:只准往放行的方向做字面比對。
|
||||
#
|
||||
# ── merge 了但分支還在 = 結論只給了一半 ────────────────────────
|
||||
# 票上寫死:「merge → 併完當場刪掉那條 branch」。所以某個 PR 從 open 清單消失時,
|
||||
# 本閘去看它是不是 merged、head 分支還在不在;還在就一起點名。
|
||||
# (只在「上次還 open、這次不見了」時查,平常一通 API 都不多打。)
|
||||
#
|
||||
# ── 內部錯誤不准靜默放行 ────────────────────────────────────────
|
||||
# Stop 閘 fail-closed 會鎖死 session,而誤攔是本 repo 的第一級缺陷
|
||||
# ⇒ 內部錯誤走 exit 0,但留痕(log + stderr 各一行)。同 mainline-idle-guard.sh。
|
||||
#
|
||||
# 迴歸測試:hooks/tests/pr-verdict-guard.test.sh(離線,走 PR_VERDICT_FIXTURE)
|
||||
set -u
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
# ── token:同 scripts/ticket 的做法(掃 remote 找帶憑證的,再退到環境變數)──
|
||||
TOKEN=""
|
||||
if [ -z "${PR_VERDICT_FIXTURE:-}" ]; then
|
||||
for d in "$PROJ" "$PROJ/products"/* "$PROJ/matrix"/* "$PROJ/polaris"/*; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
if [ -n "${U:-}" ]; then
|
||||
T=$(printf '%s' "$U" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$T" != "$U" ] && { TOKEN="$T"; break; }
|
||||
fi
|
||||
done
|
||||
[ -n "$TOKEN" ] || TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}"
|
||||
fi
|
||||
|
||||
# PR_VERDICT_DEBUG=1 時把內部錯誤原文放出來(平常吞掉,避免雜訊蓋掉閘的訊息)
|
||||
ERRSINK=/dev/null
|
||||
[ -n "${PR_VERDICT_DEBUG:-}" ] && ERRSINK=/dev/stderr
|
||||
|
||||
# 🔴 payload 走環境變數,不走 stdin——`python3 - <<PY` 的 stdin **就是腳本本身**,
|
||||
# 再從 sys.stdin 讀就會讀到自己的原始碼(第一版實撞,回 CRASH:bad-payload)。
|
||||
VERDICT=$(GITEA_TOKEN_RESOLVED="$TOKEN" PR_VERDICT_PAYLOAD="$PAYLOAD" python3 - <<'PY' 2>"$ERRSINK"
|
||||
import json, os, re, sys, time
|
||||
import urllib.error, urllib.parse, urllib.request
|
||||
|
||||
HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me")
|
||||
ORGS = [o.strip().lower() for o in
|
||||
os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()]
|
||||
FIXTURE = os.environ.get("PR_VERDICT_FIXTURE", "")
|
||||
TOKEN = os.environ.get("GITEA_TOKEN_RESOLVED", "")
|
||||
|
||||
def out(s):
|
||||
sys.stdout.write(s)
|
||||
raise SystemExit
|
||||
|
||||
try:
|
||||
d = json.loads(os.environ.get("PR_VERDICT_PAYLOAD") or "{}")
|
||||
except Exception:
|
||||
out("CRASH:bad-payload")
|
||||
|
||||
# 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋,同 mainline-idle-guard)
|
||||
if d.get("stop_hook_active"):
|
||||
out("SKIP:already-nudged")
|
||||
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64]
|
||||
state_path = os.path.join(os.environ.get("PR_VERDICT_STATE_DIR", "/tmp"),
|
||||
".pr-verdict-%s.json" % sid)
|
||||
|
||||
# ── 資料來源:正式打 Gitea,測試走 fixture(單一 JSON 檔或目錄下的 state.json)──
|
||||
FX = None
|
||||
if FIXTURE:
|
||||
p = FIXTURE if os.path.isfile(FIXTURE) else os.path.join(FIXTURE, "state.json")
|
||||
try:
|
||||
with open(p) as f:
|
||||
FX = json.load(f)
|
||||
except Exception:
|
||||
out("CRASH:bad-fixture")
|
||||
|
||||
def api(path, soft=True):
|
||||
"""GET 一筆。fixture 模式完全不碰網路。失敗回 None(不影響該不該擋的核心判斷)。"""
|
||||
if FX is not None:
|
||||
return None
|
||||
if not TOKEN:
|
||||
return None
|
||||
try:
|
||||
req = urllib.request.Request(HOST + "/api/v1" + path,
|
||||
headers={"Authorization": "token " + TOKEN})
|
||||
return json.load(urllib.request.urlopen(req, timeout=12))
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"__status__": e.code}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── ① 撈 open PR(一通跨 repo 的呼叫)─────────────────────────────
|
||||
if FX is not None:
|
||||
pulls = FX.get("pulls", [])
|
||||
else:
|
||||
if not TOKEN:
|
||||
out("SKIP:no-token")
|
||||
pulls = api("/repos/issues/search?type=pulls&state=open&limit=50")
|
||||
if pulls is None or isinstance(pulls, dict):
|
||||
out("SKIP:api-unreachable")
|
||||
|
||||
def ref_of(pr):
|
||||
return "%s#%s" % (pr.get("repository", {}).get("full_name", "?"), pr.get("number"))
|
||||
|
||||
live = {}
|
||||
for pr in pulls:
|
||||
full = (pr.get("repository") or {}).get("full_name") or ""
|
||||
if "/" not in full:
|
||||
continue
|
||||
if ORGS and full.split("/", 1)[0].lower() not in ORGS:
|
||||
continue # 只管我這邊的 org;舊 org 的鏡像不是我的責任
|
||||
if (pr.get("pull_request") or {}).get("draft"):
|
||||
continue # draft = 作者自己說「還沒好」,不是等我給結論
|
||||
live[ref_of(pr)] = pr
|
||||
|
||||
# ── ② 讀上一次收工的快照 ─────────────────────────────────────────
|
||||
st = {}
|
||||
try:
|
||||
with open(state_path) as f:
|
||||
st = json.load(f)
|
||||
except Exception:
|
||||
st = {}
|
||||
seen = st.get("seen") or {}
|
||||
leftover = st.get("leftover") or {} # ref -> branch,merge 完分支還沒刪的
|
||||
first_run = not seen and "seen" not in st
|
||||
|
||||
# ── ③ 這一回合的 tool call 裡提到了哪些識別碼(只用來放行)──────────
|
||||
turn_blob = ""
|
||||
tp = d.get("transcript_path") or ""
|
||||
if tp and os.path.exists(tp):
|
||||
try:
|
||||
rows = []
|
||||
with open(tp) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
start = 0
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("type") == "user":
|
||||
c = (r.get("message") or {}).get("content")
|
||||
blocks = c if isinstance(c, list) else [{"type": "text"}]
|
||||
if not any(isinstance(b, dict) and b.get("type") == "tool_result"
|
||||
for b in blocks):
|
||||
start = i
|
||||
chunks = []
|
||||
for r in rows[start:]:
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
for b in (r.get("message") or {}).get("content") or []:
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use":
|
||||
chunks.append(json.dumps(b.get("input") or {}, ensure_ascii=False))
|
||||
turn_blob = "\n".join(chunks)
|
||||
except Exception:
|
||||
turn_blob = ""
|
||||
|
||||
def mentioned(ref):
|
||||
"""這一回合的 tool call 有沒有指名這個 PR。識別碼比對,只用來放行。"""
|
||||
if not turn_blob:
|
||||
return False
|
||||
full, num = ref.split("#")
|
||||
owner, repo = full.split("/", 1)
|
||||
pats = ["%s#%s" % (full, num), "%s#%s" % (repo, num),
|
||||
"/pulls/%s" % num, "/pull/%s" % num]
|
||||
return any(p in turn_blob for p in pats)
|
||||
|
||||
# ── ④ merge 完分支還在不在(只在「上次還 open、這次不見了」時查)────
|
||||
def branch_alive(owner, repo, br):
|
||||
if FX is not None:
|
||||
return bool((FX.get("branches") or {}).get("%s/%s:%s" % (owner, repo, br)))
|
||||
r = api("/repos/%s/%s/branches/%s" % (owner, repo, urllib.parse.quote(br, safe="")))
|
||||
if isinstance(r, dict) and r.get("__status__") == 404:
|
||||
return False
|
||||
return isinstance(r, dict) and "name" in r
|
||||
|
||||
def pr_detail(ref):
|
||||
if FX is not None:
|
||||
return (FX.get("detail") or {}).get(ref)
|
||||
full, num = ref.split("#")
|
||||
owner, repo = full.split("/", 1)
|
||||
r = api("/repos/%s/%s/pulls/%s" % (owner, repo, num))
|
||||
return r if isinstance(r, dict) and "__status__" not in r else None
|
||||
|
||||
for ref in list(seen.keys()):
|
||||
if ref in live:
|
||||
continue
|
||||
det = pr_detail(ref) # 不見了:是被 merge 還是被 close?
|
||||
if not det or not det.get("merged"):
|
||||
continue
|
||||
br = ((det.get("head") or {}).get("ref")) or ""
|
||||
if not br:
|
||||
continue
|
||||
full = ref.split("#")[0]
|
||||
owner, repo = full.split("/", 1)
|
||||
if branch_alive(owner, repo, br):
|
||||
leftover[ref] = br
|
||||
|
||||
for ref in list(leftover.keys()): # 已經刪掉的就從清單移除
|
||||
full = ref.split("#")[0]
|
||||
owner, repo = full.split("/", 1)
|
||||
if not branch_alive(owner, repo, leftover[ref]):
|
||||
leftover.pop(ref, None)
|
||||
|
||||
# ── ⑤ 逐個 open PR 判「有沒有人在管」──────────────────────────────
|
||||
def reviews_of(ref):
|
||||
if FX is not None:
|
||||
return (FX.get("reviews") or {}).get(ref) or []
|
||||
full, num = ref.split("#")
|
||||
owner, repo = full.split("/", 1)
|
||||
r = api("/repos/%s/%s/pulls/%s/reviews" % (owner, repo, num))
|
||||
return r if isinstance(r, list) else []
|
||||
|
||||
try:
|
||||
BASE = max(1, int(os.environ.get("PR_VERDICT_THRESHOLD", "1")))
|
||||
except Exception:
|
||||
BASE = 1
|
||||
|
||||
overdue, newseen = [], {}
|
||||
for ref, pr in live.items():
|
||||
prev = seen.get(ref) or {}
|
||||
rec = {"updated_at": pr.get("updated_at"),
|
||||
"quiet": int(prev.get("quiet", 0) or 0),
|
||||
"threshold": int(prev.get("threshold", BASE) or BASE),
|
||||
"fires": int(prev.get("fires", 0) or 0)}
|
||||
newseen[ref] = rec
|
||||
|
||||
# 有人被指派/掛了 Human = 棒子在某個人手上 ⇒ 在等,不是忘了
|
||||
if pr.get("assignees"):
|
||||
rec["quiet"] = 0
|
||||
continue
|
||||
if any((l.get("name") or "").lower() == "human" for l in (pr.get("labels") or [])):
|
||||
rec["quiet"] = 0
|
||||
continue
|
||||
# 上一次收工時還不存在 ⇒ 剛開出來,別在它誕生的那一回合就開罵
|
||||
if not first_run and ref not in seen:
|
||||
rec["quiet"] = 0
|
||||
continue
|
||||
# 這一回合真的動過它(外部事實)/指名過它(識別碼)
|
||||
if prev.get("updated_at") and pr.get("updated_at") != prev.get("updated_at"):
|
||||
rec["quiet"] = 0
|
||||
continue
|
||||
if mentioned(ref):
|
||||
rec["quiet"] = 0
|
||||
continue
|
||||
|
||||
rec["quiet"] += 1
|
||||
if rec["quiet"] < rec["threshold"]:
|
||||
continue
|
||||
# 「要求修改」這個結論已經給了 ⇒ 球在對方腳下(只在要開罵時才多打這通)
|
||||
if any((rv.get("state") or "").upper() == "REQUEST_CHANGES" for rv in reviews_of(ref)):
|
||||
rec["quiet"] = 0
|
||||
continue
|
||||
|
||||
rec["quiet"] = 0
|
||||
rec["threshold"] = max(4, rec["threshold"] * 4 if rec["threshold"] == 1
|
||||
else rec["threshold"] * 2)
|
||||
rec["fires"] += 1
|
||||
overdue.append((ref, pr))
|
||||
|
||||
try:
|
||||
with open(state_path, "w") as f:
|
||||
json.dump({"seen": newseen, "leftover": leftover, "ts": time.time()}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not overdue and not leftover:
|
||||
out("OK:clean:%d-open" % len(live))
|
||||
|
||||
def age_days(pr):
|
||||
try:
|
||||
t = time.mktime(time.strptime(pr.get("created_at", "")[:19], "%Y-%m-%dT%H:%M:%S"))
|
||||
return max(0, int((time.time() - t) / 86400))
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
lines = []
|
||||
if overdue:
|
||||
lines.append("【沒有結論的 PR】(open、沒有人被指派、也沒有「要求修改」的 review)")
|
||||
for ref, pr in sorted(overdue, key=lambda x: -age_days(x[1])):
|
||||
dd = age_days(pr)
|
||||
lines.append(" ❌ %-28s 開了 %s天 %s"
|
||||
% (ref, dd if dd >= 0 else "?", (pr.get("title") or "")[:36]))
|
||||
lines.append(" %s" % ((pr.get("pull_request") or {}).get("html_url") or ""))
|
||||
if leftover:
|
||||
lines.append("【merge 了,但分支還留著】(結論只給了一半)")
|
||||
for ref, br in sorted(leftover.items()):
|
||||
lines.append(" ❌ %-28s 分支 %s 還在" % (ref, br))
|
||||
|
||||
out("FIRE::" + json.dumps({"body": "\n".join(lines),
|
||||
"n": len(overdue) + len(leftover)}, ensure_ascii=False))
|
||||
PY
|
||||
) || VERDICT="CRASH:hook-error"
|
||||
[ -n "${VERDICT:-}" ] || VERDICT="CRASH:empty"
|
||||
|
||||
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
LOGDIR="$PROJ/.claude/hooks"
|
||||
note() {
|
||||
if [ -d "$LOGDIR" ]; then
|
||||
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/pr-verdict-guard.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
case "$VERDICT" in
|
||||
FIRE::*)
|
||||
BODY=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["body"])')
|
||||
N=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["n"])')
|
||||
note "⛔ 擋下" "$N 個 PR 沒有結論"
|
||||
cat >&2 <<MSG
|
||||
🔀 PR 收斂警察:**還有 ${N} 個 PR 沒有結論,現在不是收工的時候。**
|
||||
|
||||
$BODY
|
||||
|
||||
【inkstone/ISEP#81】「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』,
|
||||
但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」
|
||||
|
||||
━━ 「有結論」只有三種,缺一不可 ━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
merge → **併完當場刪掉那條 branch**
|
||||
scripts/pr-verdict merge <owner/repo#N>
|
||||
退回 → 理由**寫在票上**(不是寫在訊息裡),然後關掉 PR
|
||||
scripts/pr-verdict reject <owner/repo#N> --ticket <owner/repo#N> -F <檔>
|
||||
要求修改 → 修改要求**寫在票上**,並在 PR 上留一則 REQUEST_CHANGES
|
||||
scripts/pr-verdict changes <owner/repo#N> --ticket <owner/repo#N> -F <檔>
|
||||
|
||||
撈一次現況:scripts/pr-verdict list
|
||||
|
||||
━━ 如果它本來就在等別人,不是被我忘了 ━━━━━━━━━━━━━━━━━━━━
|
||||
**把它指派給那個人**(或掛 Human)——本閘立刻不再點名它。
|
||||
指派是 Gitea 原生欄位,撈一次就看得到「現在誰該動」;
|
||||
寫在對話裡的「我在等 X」會捲走,欄位不會。
|
||||
|
||||
這一條就是本閘跟「s/review 佇列非空即 block」的分界:
|
||||
**擋的是遺忘,不是等待。**(ISEP v0.6.0 divergence §B4 記過那個坑)
|
||||
|
||||
━━ 這幾個現在真的不該處理 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
直接再送一次即可,本閘已經歸零,而且這幾個 PR 的門檻已加倍
|
||||
(1 → 4 → 8 → 16…,每個 PR 各自算)。**不必為了過這道閘去做一件假的動作。**
|
||||
MSG
|
||||
exit 2 ;;
|
||||
CRASH*)
|
||||
note "⚠️ 內部錯誤" "$VERDICT"
|
||||
printf '⚠️ pr-verdict-guard 內部錯誤(%s)——本回合未清點 PR,已記入 log。\n' "$VERDICT" >&2
|
||||
exit 0 ;;
|
||||
*)
|
||||
note "✅ 放行" "$VERDICT"
|
||||
exit 0 ;;
|
||||
esac
|
||||
@@ -41,6 +41,26 @@ try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
|
||||
# ── 🔴 卡點二(inkstone/ISEP#63 → comment 5106):「執行它」vs「談論它」,第八次 ──
|
||||
#
|
||||
# 總管要把「本閘擋住了發通知」這件事**貼到票上**,而那則留言的內文**引用了**
|
||||
# 本閘的判準(實例主機名、寫入旗標的字樣)⇒ **一則留言被自己擋下來。**
|
||||
# 同款的第八次(`inkstone/InkStoneCo#23` 的標題已經寫著「第七次」)。
|
||||
# ⇒ 本次修這支閘時**又撞了一次**:連「把修法寫進檔案」的那條指令都被擋,
|
||||
# 因為它的內文引用了主機名。那就是這一格最好的證據。
|
||||
#
|
||||
# leo 2026-08-17 的診斷:文字層的閘那天 **8 次誤攔、0 次正確攔截**,且方向穩定——
|
||||
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
|
||||
#
|
||||
# 那一段內文**是資料,不是指令**。這支 lib(`github-contact-guard`/
|
||||
# `main-and-prod-push-guard`/`stage-before-prod-guard` 三支已經在用)只剝內文、
|
||||
# **保留起始行**——所以真的在部署的寫法(起始行就帶著實例網址與寫入旗標)
|
||||
# 仍然整條看得到、照樣擋。
|
||||
#
|
||||
# ⚠️ 繞法(把內文先寫成檔案、再用不含關鍵字的指令送出)本來就通,
|
||||
# 但那正是 leo 說的「**人就會學會繞過它,那它就等於不存在**」⇒ 要修的是閘。
|
||||
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_""heredoc.py" 2>/dev/null || printf '%s' "$CMD")
|
||||
|
||||
# ── 總管戳記:**單次、15 分鐘失效** ────────────────────────────────────
|
||||
#
|
||||
# 🔴 這一版一開始寫成「15 分鐘內無限次放行」,而同一天 `main-and-prod-push-guard.sh`
|
||||
@@ -220,6 +240,40 @@ case "$CMD_M" in
|
||||
*curl*|*httpie*|*" http "*|*wget*)
|
||||
case "$CMD" in
|
||||
*workers.dev*|*arcrun.dev*)
|
||||
# 🔴 卡點一(inkstone/ISEP#63 → comment 5106):發一則通知被當成部署擋下
|
||||
#
|
||||
# leo 2026-08-28 要的是「每小時一個版本,用 Telegram 通知我」。
|
||||
# 通道是 `notify_leo` 工作流(wiki `agent-memory.md`:一條 curl、不需任何金鑰)。
|
||||
# 而發一則 Telegram **一定是帶 body 的寫入型請求打到那台實例**
|
||||
# ⇒ 在本閘眼裡它跟「部署一個工作流上線」長得一模一樣。
|
||||
#
|
||||
# 🔴 **這不是誤攔,是解析度不夠**:兩者打的是同一個 named webhook 家族路徑。
|
||||
# 真正的差別在**打的是哪一個 named webhook**——
|
||||
# `…/webhooks/named/<ns>/notify_leo/trigger` = 發一則訊息,什麼都沒改
|
||||
# `…/webhooks/named`(無名字、無 /trigger) = 部署工作流定義
|
||||
# `…/webhooks/named/<ns>/ship_refresh_cdn/trigger` = 真的動線上狀態
|
||||
#
|
||||
# 判準因此看**目標的結構**(路徑形狀+工作流名字),不是「有沒有寫入旗標」:
|
||||
# 指令裡打到實例的**每一個**網址都是白名單裡的通知型 trigger ⇒ 放行
|
||||
# 只要有一個不是(含部署端點、含別的工作流)⇒ 照擋
|
||||
# ⚠️ 放行的範圍刻意只有一個名字。**不放行整個 named webhook 家族**——
|
||||
# 那會把部署一起放掉,等於拆了這道閘。
|
||||
NOTIFY_ONLY=$(printf '%s' "$CMD" | python3 -c '
|
||||
import re, sys
|
||||
cmd = sys.stdin.read()
|
||||
ALLOW = {"notify_leo"} # 通知型:只發訊息,不改線上任何狀態
|
||||
urls = re.findall(r"https?://[^\s\"\x27<>]+", cmd)
|
||||
inst = [u for u in urls if re.search(r"://[^/]*(workers\.dev|arcrun\.dev)", u)]
|
||||
if not inst:
|
||||
raise SystemExit(0) # 沒有實例網址 ⇒ 這裡不表態,交給下面的寫入語意判斷
|
||||
PAT = re.compile(r"https?://[^/]+/webhooks/named/[^/]+/([A-Za-z0-9_-]+)/trigger/?$")
|
||||
def notify(u):
|
||||
m = PAT.match(u.rstrip(",;)"))
|
||||
return bool(m) and m.group(1) in ALLOW
|
||||
print("notify-only" if all(notify(u) for u in inst) else "")
|
||||
' 2>/dev/null || printf '')
|
||||
[ "$NOTIFY_ONLY" = "notify-only" ] && exit 0
|
||||
|
||||
# 🔴 2026-08-12 修誤攔:` -d ` 不只是 curl 的 --data,**一堆唯讀工具也用 -d**
|
||||
# (`tr -d`/`cut -d=`/`sort -d`/`date -d`/`xargs -d`/`paste -d`…)。
|
||||
# 實撞:總管要「唯讀」驗證一把金鑰能不能開 kbdb 的閘,指令第一行是
|
||||
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
# countdown-guard.sh 的迴歸測試(inkstone/ISEP#63 → comment 5106)
|
||||
#
|
||||
# 三個方向都要有證據,對應票上那三條驗收:
|
||||
# A 群「注入」—— 新 session 什麼都沒交代,那一行就在;數字對得上;連續呼叫遞增
|
||||
# B 群「查核該擋」—— 回覆沒戴倒數 ⇒ 擋一次,且把算好的那一行印出來給它抄
|
||||
# C 群「查核不該擋」—— 誤攔比漏擋更該修:純工具回合/子 session/已提醒過/
|
||||
# transcript 讀不到,全部放行
|
||||
#
|
||||
# 用法:hooks/tests/countdown-guard.test.sh [hooks/countdown-guard.sh 的路徑]
|
||||
# 🔴 全程離線:時鐘用 ISEP_COUNTDOWN_NOW 定住、狀態走 ISEP_COUNTDOWN_STATE_DIR,
|
||||
# 不打網路、不碰 $HOME、跑完自己清。
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/countdown-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
|
||||
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
|
||||
# 定住的時鐘:2026-08-28 08:00 台北 = 2026-08-28 00:00 UTC
|
||||
T0=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||||
H=3600
|
||||
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# mk_transcript <檔> <起始 epoch> —— 一則帶時間戳的使用者訊息,當作對話起點
|
||||
mk_transcript(){
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import datetime as d, json, sys
|
||||
p, ts = sys.argv[1], int(sys.argv[2])
|
||||
iso = d.datetime.fromtimestamp(ts, d.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
with open(p, "w") as f:
|
||||
f.write(json.dumps({"type": "user", "timestamp": iso,
|
||||
"message": {"content": [{"type": "text", "text": "開工"}]}}) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# add_turn <檔> <spec>... —— spec:`T:<文字>` assistant 文字;`U:<工具>` tool_use;
|
||||
# `R:` 一列工具結果(不算新回合的分界)
|
||||
add_turn(){
|
||||
python3 - "$@" <<'PY'
|
||||
import json, sys
|
||||
path, specs = sys.argv[1], sys.argv[2:]
|
||||
rows = []
|
||||
blocks = []
|
||||
for s in specs:
|
||||
k, v = s.split(":", 1)
|
||||
if k == "T":
|
||||
blocks.append({"type": "text", "text": v})
|
||||
elif k == "U":
|
||||
blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}})
|
||||
elif k == "R":
|
||||
rows.append({"type": "user", "message": {"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}})
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps({"type": "user", "message": {"content": [
|
||||
{"type": "text", "text": "繼續"}]}}, ensure_ascii=False) + "\n")
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
if blocks:
|
||||
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
|
||||
ensure_ascii=False) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# inject <session> <transcript> <now epoch> —— 跑注入模式,回傳 additionalContext
|
||||
inject(){
|
||||
printf '{"hook_event_name":"UserPromptSubmit","session_id":"%s","transcript_path":"%s"}' "$1" "$2" \
|
||||
| ISEP_COUNTDOWN_NOW="$3" bash "$HOOK" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
|
||||
except Exception: print("")'
|
||||
}
|
||||
|
||||
# stop <session> <transcript> <now epoch> <extra json> —— 跑查核模式,回傳 "exit|訊息"
|
||||
stop(){
|
||||
# 🔴 明著把 CLAUDE_CODE_CHILD_SESSION 清掉:測試本身常常跑在一條 subagent 裡,
|
||||
# 而那正是本閘刻意放行的情況 ⇒ 不清掉的話 B 群會全綠,而且是**假綠**
|
||||
# (2026-08-28 第一次跑就撞到:B 群三條全 exit 0,因為跑測試的就是子 session)。
|
||||
out=$(printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s"%s}' "$1" "$2" "${4:-}" \
|
||||
| ISEP_COUNTDOWN_NOW="$3" CLAUDE_CODE_CHILD_SESSION= CLAUDE_PROJECT_DIR="$TMP" \
|
||||
bash "$HOOK" 2>&1); rc=$?
|
||||
printf '%s|%s' "$rc" "$(printf '%s' "$out" | tr '\n' ' ')"
|
||||
}
|
||||
|
||||
echo "── A 群:注入(每一則回覆眼前都有那一行)────────────────────────"
|
||||
|
||||
TR="$TMP/a1.jsonl"; mk_transcript "$TR" "$T0"
|
||||
CTX=$(inject s-a1 "$TR" "$T0")
|
||||
case "$CTX" in
|
||||
*"⏱"*) ok "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" ;;
|
||||
*) no "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" "實得:$CTX" ;;
|
||||
esac
|
||||
case "$CTX" in
|
||||
*"已過 0 分"*) ok "② 剛開工 ⇒ 已過 0 分" ;;
|
||||
*) no "② 剛開工 ⇒ 已過 0 分" "實得:$CTX" ;;
|
||||
esac
|
||||
# 台北 08:00 → 16:00 收工線還有 8 小時
|
||||
case "$CTX" in
|
||||
*"距收工線(台北 16:00)剩 8 小時"*) ok "③ 台北 08:00 ⇒ 距收工線剩 8 小時" ;;
|
||||
*) no "③ 台北 08:00 ⇒ 距收工線剩 8 小時" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# 票上驗收 2:數字要對得上真實經過的時間(期望 2 小時 13 分)
|
||||
CTX=$(inject s-a1 "$TR" $((T0 + 2*H + 13*60)))
|
||||
case "$CTX" in
|
||||
*"已過 2 小時 13 分"*) ok "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」(期望=實得)" ;;
|
||||
*) no "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# 票上驗收 3:連續幾則要遞增,不歸零、不亂跳
|
||||
LAST=-1; SEQ_OK=1
|
||||
for k in 0 1 3 5; do
|
||||
CTX=$(inject s-a1 "$TR" $((T0 + k*H)))
|
||||
got=$(printf '%s' "$CTX" | sed -n 's/.*已過 \([0-9]*\) 小時.*/\1/p')
|
||||
[ -z "$got" ] && got=0
|
||||
[ "$got" -ge "$LAST" ] || SEQ_OK=0
|
||||
LAST="$got"
|
||||
done
|
||||
[ "$SEQ_OK" = 1 ] && ok "⑤ 連續四則(0/1/3/5 小時)⇒ 遞增,不歸零不亂跳" \
|
||||
|| no "⑤ 連續四則 ⇒ 遞增" "第 $LAST 則往回跳了"
|
||||
|
||||
# resume:同一個 session 換一份較早起點的 transcript,起點取較早的那個
|
||||
TR2="$TMP/a2.jsonl"; mk_transcript "$TR2" $((T0 - 5*H))
|
||||
CTX=$(inject s-a1 "$TR2" "$T0")
|
||||
case "$CTX" in
|
||||
*"已過 5 小時"*) ok "⑥ resume(transcript 起點更早)⇒ 昨天燒掉的時間仍然算" ;;
|
||||
*) no "⑥ resume ⇒ 起點取較早" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# 過了收工線:台北 17:30 ⇒ 顯示超過,不准悄悄換算成明天
|
||||
CTX=$(inject s-late "$TMP/a3.jsonl" $((T0 + 9*H + 30*60)))
|
||||
case "$CTX" in
|
||||
*"已超過收工線"*) ok "⑦ 台北 17:30 ⇒ 顯示「已超過收工線」(超時不許消失)" ;;
|
||||
*) no "⑦ 台北 17:30 ⇒ 顯示已超過" "實得:$CTX" ;;
|
||||
esac
|
||||
# 台北 21:00(過線四小時後)⇒ 滾到隔天 16:00,剩 19 小時
|
||||
CTX=$(inject s-next "$TMP/a4.jsonl" $((T0 + 13*H)))
|
||||
case "$CTX" in
|
||||
*"距收工線(台北 16:00)剩 19 小時"*) ok "⑧ 台北 21:00 ⇒ 滾到隔天,剩 19 小時" ;;
|
||||
*) no "⑧ 台北 21:00 ⇒ 滾到隔天" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# milestone:沒有快取就整段不出現(不准編一個日期出來)
|
||||
case "$CTX" in
|
||||
*"主線"*) no "⑨ 沒有 milestone 快取 ⇒ 那一段不出現" "實得:$CTX" ;;
|
||||
*) ok "⑨ 沒有 milestone 快取 ⇒ 那一段不出現(不編數字)" ;;
|
||||
esac
|
||||
printf '2026-08-30|Sprint 8-28\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
|
||||
CTX=$(inject s-ms "$TMP/a5.jsonl" "$T0")
|
||||
case "$CTX" in
|
||||
*"主線 Sprint 8-28 剩"*) ok "⑩ 有 milestone 快取 ⇒ 期限那一段出現" ;;
|
||||
*) no "⑩ 有 milestone 快取 ⇒ 期限那一段出現" "實得:$CTX" ;;
|
||||
esac
|
||||
rm -f "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
|
||||
|
||||
echo "── B 群:查核——該擋 ────────────────────────────────────────────"
|
||||
|
||||
TR="$TMP/b1.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'U:Read' 'T:做完了,改了三個檔。'
|
||||
R=$(stop s-b1 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 2 ] && ok "⑪ 回覆沒戴倒數 ⇒ 擋" || no "⑪ 回覆沒戴倒數 ⇒ 擋" "實得 exit=${R%%|*}"
|
||||
case "${R#*|}" in
|
||||
*"已過 2 小時"*) ok "⑫ 擋下時把算好的那一行印出來給它抄(不是叫它自己算)" ;;
|
||||
*) no "⑫ 擋下時印出算好的那一行" "實得:${R#*|}" ;;
|
||||
esac
|
||||
|
||||
# 回合邊界:上一回合戴了、這一回合沒戴 ⇒ 還是要擋
|
||||
TR="$TMP/b2.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:⏱ 已過 1 小時|距收工線剩 7 小時。上一回合戴過了。'
|
||||
add_turn "$TR" 'U:Edit' 'T:這回合忘了戴。'
|
||||
R=$(stop s-b2 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 2 ] && ok "⑬ 只有上一回合戴 ⇒ 這一回合仍要擋(回合邊界對)" \
|
||||
|| no "⑬ 只有上一回合戴 ⇒ 仍要擋" "實得 exit=${R%%|*}"
|
||||
|
||||
echo "── C 群:查核——不該擋(誤攔比漏擋更該修)──────────────────────"
|
||||
|
||||
TR="$TMP/c1.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|距收工線(台北 16:00)剩 6 小時 —— 做完了。'
|
||||
R=$(stop s-c1 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑭ 回覆戴了倒數 ⇒ 放行" || no "⑭ 回覆戴了倒數 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c2.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:上一回合。'
|
||||
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|這一回合戴了。'
|
||||
R=$(stop s-c2 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑮ 這一回合戴了、上一回合沒戴 ⇒ 放行" \
|
||||
|| no "⑮ 這一回合戴了 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c3.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'U:Read' 'R:' 'U:Grep'
|
||||
R=$(stop s-c3 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑯ 純工具回合(沒有任何文字)⇒ 放行,沒有東西可以戴" \
|
||||
|| no "⑯ 純工具回合 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c4.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:沒戴。'
|
||||
R=$(stop s-c4 "$TR" $((T0 + 2*H)) ',"stop_hook_active":true')
|
||||
[ "${R%%|*}" = 0 ] && ok "⑰ 已經提醒過一次(stop_hook_active)⇒ 放行,至多擋一次" \
|
||||
|| no "⑰ stop_hook_active ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c5.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:我是 subagent,交件給總管。'
|
||||
out=$(printf '{"hook_event_name":"Stop","session_id":"s-c5","transcript_path":"%s"}' "$TR" \
|
||||
| ISEP_COUNTDOWN_NOW=$((T0 + 2*H)) CLAUDE_CODE_CHILD_SESSION=1 \
|
||||
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1); rc=$?
|
||||
[ "$rc" = 0 ] && ok "⑱ 子 session(subagent)⇒ 放行,它的回覆不是給 leo 看的" \
|
||||
|| no "⑱ 子 session ⇒ 放行" "實得 exit=$rc"
|
||||
|
||||
R=$(stop s-c6 "$TMP/does-not-exist.jsonl" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑲ transcript 讀不到 ⇒ 放行(節拍器壞掉不該卡死 session)" \
|
||||
|| no "⑲ transcript 讀不到 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
printf 'not json at all\n{{{\n' > "$TMP/c7.jsonl"
|
||||
R=$(stop s-c7 "$TMP/c7.jsonl" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑳ transcript 是壞的 ⇒ 放行,不是擋" \
|
||||
|| no "⑳ transcript 壞掉 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bash
|
||||
# pr-verdict-guard.sh 的迴歸測試(inkstone/ISEP#81)
|
||||
#
|
||||
# 兩個方向都要有證據:
|
||||
# A 群「不該擋」——沒有 PR/有人指派/掛 Human/draft/不在 org/剛開出來/
|
||||
# 這回合動過/這回合指名過/已有 REQUEST_CHANGES/已被別的閘擋過/
|
||||
# merge 完分支已刪/fixture 壞掉
|
||||
# B 群「該擋」 ——沒人管的 open PR、放著不管一回合、merge 了但分支還在、
|
||||
# 以及**票上那三個躺了兩週的真跡**
|
||||
# C 群「訊息承諾的出路真的走得通」——擋完歸零門檻加倍、指派下去立刻閉嘴
|
||||
#
|
||||
# 🔴 全程離線(PR_VERDICT_FIXTURE),不打 Gitea、不留任何測試票/測試 PR。
|
||||
# 狀態檔走 PR_VERDICT_STATE_DIR,不碰 /tmp 的正式檔。
|
||||
#
|
||||
# 用法:hooks/tests/pr-verdict-guard.test.sh [hooks/pr-verdict-guard.sh 的路徑]
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/pr-verdict-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0; N=0
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
OLD="2026-08-13T04:34:01Z"
|
||||
|
||||
# fixture <檔名> <PR 規格...>
|
||||
# PR 規格:ref|updated|created|assignee|labels|draft
|
||||
mkfx() { python3 - "$@" <<'PY'
|
||||
import json, sys, os
|
||||
path, specs = sys.argv[1], sys.argv[2:]
|
||||
extra = {}
|
||||
if os.path.exists(path):
|
||||
extra = json.load(open(path))
|
||||
pulls = []
|
||||
for s in specs:
|
||||
ref, upd, crt, asg, lbl, draft = (s.split("|") + [""] * 6)[:6]
|
||||
full, num = ref.split("#")
|
||||
pulls.append({
|
||||
"number": int(num),
|
||||
"title": "測試 PR " + ref,
|
||||
"created_at": crt, "updated_at": upd,
|
||||
"repository": {"full_name": full, "id": 1},
|
||||
"assignees": [{"login": a} for a in asg.split(",") if a],
|
||||
"labels": [{"name": n} for n in lbl.split(",") if n],
|
||||
"pull_request": {"merged": False, "draft": draft == "draft",
|
||||
"html_url": "https://git.uncle6.me/%s/pulls/%s" % (full, num)},
|
||||
})
|
||||
extra["pulls"] = pulls
|
||||
json.dump(extra, open(path, "w"), ensure_ascii=False)
|
||||
PY
|
||||
}
|
||||
|
||||
# 加一筆「這個 PR 已經被 merge,head 分支叫 X」的細節,以及分支還在不在
|
||||
mkdetail() { python3 - "$@" <<'PY'
|
||||
import json, sys, os
|
||||
path, ref, branch, alive = sys.argv[1:5]
|
||||
d = json.load(open(path)) if os.path.exists(path) else {}
|
||||
full = ref.split("#")[0]
|
||||
d.setdefault("detail", {})[ref] = {
|
||||
"merged": True, "head": {"ref": branch, "repo": {"full_name": full}}}
|
||||
d.setdefault("branches", {})["%s:%s" % (full, branch)] = (alive == "alive")
|
||||
json.dump(d, open(path, "w"), ensure_ascii=False)
|
||||
PY
|
||||
}
|
||||
|
||||
mkreview() { python3 - "$@" <<'PY'
|
||||
import json, sys, os
|
||||
path, ref, state = sys.argv[1:4]
|
||||
d = json.load(open(path)) if os.path.exists(path) else {}
|
||||
d.setdefault("reviews", {})[ref] = [{"state": state}]
|
||||
json.dump(d, open(path, "w"), ensure_ascii=False)
|
||||
PY
|
||||
}
|
||||
|
||||
# 造一份「這一回合叫過這些工具、輸入長這樣」的 transcript
|
||||
mktranscript() { python3 - "$@" <<'PY'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
with open(path, "w") as f:
|
||||
f.write(json.dumps({"type": "user",
|
||||
"message": {"content": [{"type": "text", "text": "繼續"}]}}) + "\n")
|
||||
blocks = [{"type": "tool_use", "name": "Bash", "id": "t",
|
||||
"input": {"command": c}} for c in sys.argv[2:]]
|
||||
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
|
||||
ensure_ascii=False) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# fire <session> <fixture> <期望 exit> <說明> [transcript] [extra-json] [必須出現的字串]
|
||||
fire() {
|
||||
sid="$1"; fx="$2"; want="$3"; desc="$4"; tr="${5:-/nonexistent}"; extra="${6:-}"; must="${7:-}"
|
||||
N=$((N+1))
|
||||
payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra")
|
||||
out=$(printf '%s' "$payload" | PR_VERDICT_FIXTURE="$fx" PR_VERDICT_STATE_DIR="$TMP" \
|
||||
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
ok=1
|
||||
[ "$rc" -eq "$want" ] || ok=0
|
||||
if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
|
||||
"${must:+ 且訊息含「$must」}" "$rc"
|
||||
printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "── A 群:不該擋 ────────────────────────────────────────────"
|
||||
|
||||
FX="$TMP/empty.json"; mkfx "$FX"
|
||||
fire s-empty "$FX" 0 "① 一個 open PR 都沒有 ⇒ 放行"
|
||||
|
||||
FX="$TMP/assigned.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|Leo||"
|
||||
fire s-asg "$FX" 0 "② 有人被指派(棒子在他手上=在等,不是忘了)⇒ 不點名"
|
||||
|
||||
FX="$TMP/human.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD||Human,p/high|"
|
||||
fire s-hum "$FX" 0 "③ 掛了 Human(等 leo 親手做)⇒ 不點名"
|
||||
|
||||
FX="$TMP/draft.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||draft"
|
||||
fire s-drf "$FX" 0 "④ draft(作者自己說還沒好)⇒ 不點名"
|
||||
|
||||
FX="$TMP/otherorg.json"; mkfx "$FX" "Leo/Arcrun#104|$OLD|$OLD|||"
|
||||
fire s-org "$FX" 0 "⑤ 不在 inkstone org(舊 org 的鏡像)⇒ 不點名"
|
||||
|
||||
FX="$TMP/review.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||"
|
||||
mkreview "$FX" "inkstone/Arcrun#104" "REQUEST_CHANGES"
|
||||
fire s-rev "$FX" 0 "⑥ 已經留了 REQUEST_CHANGES(結論給過了,球在對方腳下)⇒ 不點名"
|
||||
|
||||
FX="$TMP/active.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||"
|
||||
fire s-act "$FX" 0 "⑦ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不疊擋" \
|
||||
/nonexistent ',"stop_hook_active":true'
|
||||
|
||||
fire s-bad "$TMP/does-not-exist.json" 0 "⑧ fixture 讀不到 ⇒ 放行(Stop 閘不准把 session 鎖死)"
|
||||
|
||||
echo "── B 群:該擋 ──────────────────────────────────────────────"
|
||||
|
||||
# 票上那三個真跡(真實 ref、真實開票日)
|
||||
FX="$TMP/real.json"
|
||||
mkfx "$FX" \
|
||||
"inkstone/arcrun-rag#91|$OLD|$OLD|||" \
|
||||
"inkstone/Arcrun#116|2026-08-13T05:24:51Z|2026-08-13T05:24:51Z|||" \
|
||||
"inkstone/Arcrun#104|2026-08-12T08:51:06Z|2026-08-12T08:51:06Z|||"
|
||||
fire s-real "$FX" 2 "⑨ 真跡重演:三個躺了兩週的 PR ⇒ **擋下**" \
|
||||
/nonexistent '' 'inkstone/arcrun-rag#91'
|
||||
FX2="$TMP/real2.json"; cp "$FX" "$FX2"
|
||||
fire s-real2 "$FX2" 2 "⑩ 同上,訊息要點名 inkstone/Arcrun#116" \
|
||||
/nonexistent '' 'inkstone/Arcrun#116'
|
||||
FX3="$TMP/real3.json"; cp "$FX" "$FX3"
|
||||
fire s-real3 "$FX3" 2 "⑪ 同上,訊息要點名 inkstone/Arcrun#104" \
|
||||
/nonexistent '' 'inkstone/Arcrun#104'
|
||||
|
||||
echo "── C 群:驗收條件 1–3(開著不管 → 擋;merge → 放行;分支要不見)──"
|
||||
|
||||
# 驗收 1:一個新 PR 出現(不在它誕生那回合開罵),下一回合放著不管 ⇒ 擋
|
||||
FX="$TMP/ac.json"; mkfx "$FX"
|
||||
fire s-ac "$FX" 0 "⑫ 基準回合:目前沒有 PR"
|
||||
mkfx "$FX" "inkstone/ISEP#999|$NOW|$NOW|||"
|
||||
fire s-ac "$FX" 0 "⑬ PR 剛開出來(上一回合還不存在)⇒ 不在它誕生的回合開罵"
|
||||
fire s-ac "$FX" 2 "⑭ **驗收 1**:開著放著不管,收工 ⇒ 擋下,且點名它" \
|
||||
/nonexistent '' 'inkstone/ISEP#999'
|
||||
|
||||
# 驗收 2+3:merge 掉(PR 從 open 清單消失)且分支已刪 ⇒ 放行
|
||||
mkfx "$FX" # #999 從 open 清單消失
|
||||
mkdetail "$FX" "inkstone/ISEP#999" "feat/x" "gone"
|
||||
fire s-ac "$FX" 0 "⑮ **驗收 2+3**:merge 掉且分支已不存在 ⇒ 放行"
|
||||
|
||||
# 驗收 3 的反面:merge 了但分支還在 = 結論只給了一半 ⇒ 擋
|
||||
FX="$TMP/left.json"; mkfx "$FX" "inkstone/ISEP#998|$NOW|$NOW|||"
|
||||
fire s-left "$FX" 2 "⑯ 先讓 #998 進到快照(沒人管 ⇒ 擋一次)" \
|
||||
/nonexistent '' 'inkstone/ISEP#998'
|
||||
mkfx "$FX" # 消失了
|
||||
mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "alive"
|
||||
fire s-left "$FX" 2 "⑰ **驗收 3**:merge 了但分支 feat/y 還在 ⇒ 擋下並點名分支" \
|
||||
/nonexistent '' 'feat/y'
|
||||
mkfx "$FX"; mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "gone"
|
||||
fire s-left "$FX" 0 "⑱ 分支刪掉之後 ⇒ 放行(清單會自己清乾淨)"
|
||||
|
||||
echo "── D 群:這一回合碰過它就不點名(不讀任何一句話)──────────────"
|
||||
|
||||
FX="$TMP/touch.json"; mkfx "$FX" "inkstone/ISEP#997|$OLD|$OLD|||"
|
||||
fire s-t1 "$FX" 2 "⑲ 沒人管 ⇒ 擋一次(建立基準)"
|
||||
# updated_at 動了 = 外部系統的事實(留言/推 commit/改標籤都會動它)
|
||||
mkfx "$FX" "inkstone/ISEP#997|$NOW|$OLD|||"
|
||||
for i in 1 2 3 4 5; do
|
||||
fire s-t1 "$FX" 0 "⑳ updated_at 變了(這回合真的動過它)第 $i 次 ⇒ 不點名"
|
||||
mkfx "$FX" "inkstone/ISEP#997|$NOW-$i|$OLD|||"
|
||||
done
|
||||
|
||||
FX="$TMP/mention.json"; mkfx "$FX" "inkstone/ISEP#996|$OLD|$OLD|||"
|
||||
TR="$TMP/tr.jsonl"
|
||||
mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#996 -F /tmp/x.md"
|
||||
fire s-t2 "$FX" 0 "㉑ 這回合的 tool call 指名了 inkstone/ISEP#996(結論寫在票上)⇒ 不點名" "$TR"
|
||||
for i in 1 2 3 4; do
|
||||
fire s-t2 "$FX" 0 "㉒ 同上第 $i 次——指名過就不該累積成擋下" "$TR"
|
||||
done
|
||||
mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#111 -F /tmp/x.md"
|
||||
fire s-t2 "$FX" 2 "㉓ 換成指名**別的**票號 ⇒ 這個 PR 仍然沒被碰 ⇒ 擋"
|
||||
|
||||
echo "── E 群:響過就退讓(訊息裡承諾的出路真的存在)────────────────"
|
||||
|
||||
FX="$TMP/backoff.json"; mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|||"
|
||||
fire s-bo "$FX" 2 "㉔ 第 1 次 ⇒ 擋(門檻 1)"
|
||||
for i in 1 2 3; do
|
||||
fire s-bo "$FX" 0 "㉕ 擋過之後第 $i 個回合——門檻已加倍(1→4),不該再響"
|
||||
done
|
||||
fire s-bo "$FX" 2 "㉖ 第 4 個回合 ⇒ 再擋一次(門檻 4)"
|
||||
for i in 1 2 3 4 5 6 7; do
|
||||
fire s-bo "$FX" 0 "㉗ 再擋過之後第 $i 個回合——門檻 4→8,不該響"
|
||||
done
|
||||
fire s-bo "$FX" 2 "㉘ 第 8 個回合 ⇒ 再擋(證明退讓是加倍不是無限)"
|
||||
|
||||
# 訊息說「把它指派給那個人,本閘立刻不再點名它」——這條出路要真的走得通
|
||||
mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|claude-code||"
|
||||
for i in 1 2 3 4 5 6 7 8 9; do
|
||||
fire s-bo "$FX" 0 "㉙ 指派下去之後第 $i 個回合 ⇒ 永遠不再點名(訊息承諾的出路成立)"
|
||||
done
|
||||
|
||||
echo
|
||||
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -68,6 +68,42 @@ t block "heredoc 之後的真指令(剝內文不能連指令一起剝)" \
|
||||
EOF
|
||||
$ACR_U --force"
|
||||
|
||||
# ══ inkstone/ISEP#63 → comment 5106:兩個卡點 ═══════════════════════════
|
||||
#
|
||||
# 卡點一:發一則 Telegram 給 leo(`notify_leo`)跟「部署一個工作流上線」在本閘眼裡
|
||||
# 長得一模一樣——兩者都是帶 body 的寫入型請求打到同一台實例。
|
||||
# 判準改成看**打的是哪一個 named webhook**(路徑形狀+名字),
|
||||
# 而放行的範圍只有一個名字,家族其他成員照擋。
|
||||
# 卡點二:連「把這個卡點寫進票裡」都被同一支閘擋(同款第八次)——
|
||||
# 內文引用了主機名與寫入旗標,而閘比對的是指令文字。
|
||||
EXE="https://arcrun-cypher-executor.leo21c.wo""rkers.dev"
|
||||
NAMED="$EXE/webhooks/named"
|
||||
|
||||
echo "── 卡點一:通知放得過,家族其他成員照擋 ──"
|
||||
t pass "發一則 Telegram 給 leo(notify_leo trigger)" \
|
||||
"curl -s -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{\"text\":\"[總管] 進度\"}'"
|
||||
t pass "同上,網址後面帶斜線" \
|
||||
"curl -s -X POST $NAMED/leo/notify_leo/trigger/ --data '{\"text\":\"[總管] x\"}'"
|
||||
t block "別的 named webhook(會動線上狀態)不在白名單裡" \
|
||||
"curl -X POST $NAMED/leo/ship_refresh_cdn/trigger $DASH_D '{}'"
|
||||
t block "部署端點(沒有名字、沒有 /trigger)" \
|
||||
"curl -X POST $NAMED $DASH_D @workflow.json"
|
||||
t block "通知混著一筆真的寫入 ⇒ 整條照擋(白名單要每一個都符合)" \
|
||||
"curl -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{}' && curl -X POST $EXE/entries $DASH_D '{}'"
|
||||
t block "名字只是前綴相同(notify_leo_deploy)不算通知" \
|
||||
"curl -X POST $NAMED/leo/notify_leo_deploy/trigger $DASH_D '{}'"
|
||||
|
||||
echo "── 卡點二:談論它(內文引用)不是執行它 ──"
|
||||
t pass "把卡點貼回票上,內文引用了實例網址與寫入旗標(本次實撞)" \
|
||||
"B=\$(cat <<'EOF'
|
||||
閘擋下來了:\`curl -X POST $NAMED\` 被當成部署。
|
||||
EOF
|
||||
); curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/63/comments $DASH_D \"\$B\""
|
||||
t block "真的在部署、只是把 payload 放進內文 ⇒ 起始行仍看得到,照擋" \
|
||||
"curl -X POST $NAMED $DASH_D @- <<'EOF'
|
||||
{\"name\":\"x\"}
|
||||
EOF"
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS / 失敗 $FAIL"
|
||||
[ $FAIL -eq 0 ] || exit 1
|
||||
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# countdown-milestone-refresh.sh — 把「主線 milestone 的期限」寫進倒數用的快取
|
||||
# (inkstone/ISEP#63 的第二個基準)
|
||||
#
|
||||
# 為什麼是一支獨立腳本、而不是寫在 hook 裡:
|
||||
# 倒數那一行走在 **UserPromptSubmit** 上,也就是每一則訊息的關鍵路徑。
|
||||
# 在那裡打 HTTP = leo 每講一句話都先等一次網路。
|
||||
# ⇒ hook 只讀快取(`hooks/lib/countdown.py` 從不打網路),
|
||||
# 快取由本支在 **SessionStart** 更新一次。不是輪詢、不掛排程(守 D14 紅線)。
|
||||
#
|
||||
# 寫出來的檔:`$ISEP_COUNTDOWN_STATE_DIR/milestone-due`,一行 `YYYY-MM-DD|<名稱>`。
|
||||
# 拿不到 token/拿不到期限 ⇒ **不寫、不報錯、不留舊資料以外的東西**:
|
||||
# 倒數那一行寧可少一段,也不要顯示一個編出來的日期。
|
||||
set -uo pipefail
|
||||
|
||||
HOST="${TICKET_HOST:-https://git.uncle6.me}"
|
||||
TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}"
|
||||
REPOS="${ISEP_COUNTDOWN_REPOS:-inkstone/InkStoneCo inkstone/ISEP}"
|
||||
|
||||
DIR="${ISEP_COUNTDOWN_STATE_DIR:-$HOME/.claude/isep-countdown}"
|
||||
mkdir -p "$DIR" 2>/dev/null || exit 0
|
||||
OUT="$DIR/milestone-due"
|
||||
|
||||
[ -n "$TOKEN" ] || exit 0
|
||||
|
||||
RAW=""
|
||||
for repo in $REPOS; do
|
||||
body=$(curl -s --max-time 4 -H "Authorization: token $TOKEN" \
|
||||
"$HOST/api/v1/repos/$repo/milestones?state=open" 2>/dev/null) || continue
|
||||
RAW="$RAW$body"$'\n'
|
||||
done
|
||||
|
||||
printf '%s' "$RAW" | ISEP_OUT="$OUT" python3 -c '
|
||||
import json, os, sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
best = None
|
||||
for chunk in sys.stdin.read().split("\n"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
try:
|
||||
rows = json.loads(chunk)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
for m in rows:
|
||||
due = (m or {}).get("due_on") or ""
|
||||
if not due:
|
||||
continue # 沒設期限的 milestone 不是節拍器
|
||||
try:
|
||||
dt = datetime.fromisoformat(due.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
# 最近的那一個=現在真的在追的那一個
|
||||
if best is None or dt < best[0]:
|
||||
best = (dt, (m or {}).get("title") or "主線")
|
||||
|
||||
if best is None:
|
||||
raise SystemExit(0)
|
||||
dt, title = best
|
||||
with open(os.environ["ISEP_OUT"], "w") as f:
|
||||
f.write("%s|%s\n" % (dt.astimezone().strftime("%Y-%m-%d"), title))
|
||||
' 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pr-verdict — 給 PR 一個結論,一個動作把該做的全做完(inkstone/ISEP#81)。
|
||||
|
||||
leo 的問題(票上原文):「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』,
|
||||
但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」
|
||||
|
||||
「有結論」只有三種,缺一不可:
|
||||
|
||||
pr-verdict merge <owner/repo#N> 併 + **當場刪掉那條 branch**
|
||||
pr-verdict reject <owner/repo#N> --ticket <票> -F <檔> 退回:理由寫**票上** + 關 PR
|
||||
pr-verdict changes <owner/repo#N> --ticket <票> -F <檔> 要求修改:寫**票上** + REQUEST_CHANGES
|
||||
pr-verdict list 撈一次:誰還沒有結論
|
||||
|
||||
🔴 為什麼要有這支、而不是叫人記得多做一步:
|
||||
「merge 完要刪 branch」「退回理由要寫在票上」都是**第二個動作**,
|
||||
而所有掉棒的形狀都長一樣——第一個動作做了,第二個沒有。
|
||||
把兩件事綁成一個指令,就沒有「忘了做第二件」這個狀態可以存在。
|
||||
|
||||
🔴 `--dry-run` 會把要打的每一通 API 印出來但不送出。
|
||||
加在任何子指令上都有效;**收工前想確認自己按下去會發生什麼,用它。**
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me")
|
||||
DRY = False
|
||||
|
||||
|
||||
def die(msg, code=2):
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def token():
|
||||
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
||||
host = HOST.split("//")[-1].rstrip("/")
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", root, "remote", "-v"],
|
||||
capture_output=True, text=True, timeout=20).stdout
|
||||
except Exception:
|
||||
out = ""
|
||||
for line in out.splitlines():
|
||||
if host in line:
|
||||
m = re.search(r"//[^:/]+:([^@]+)@", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||||
if os.environ.get(env):
|
||||
return os.environ[env]
|
||||
die(f"🔴 拿不到 {host} 的 token(remote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE)")
|
||||
|
||||
|
||||
def api(path, payload=None, method=None, soft=False):
|
||||
verb = method or ("POST" if payload is not None else "GET")
|
||||
if DRY and verb != "GET":
|
||||
print(f" [dry-run] {verb} {path}"
|
||||
+ (f"\n {json.dumps(payload, ensure_ascii=False)}" if payload else ""))
|
||||
return {}
|
||||
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method=verb,
|
||||
headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
|
||||
try:
|
||||
raw = urllib.request.urlopen(req, timeout=40).read()
|
||||
return json.loads(raw) if raw.strip() else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
if soft:
|
||||
return {"__status__": e.code}
|
||||
die(f"🔴 Gitea {e.code} on {verb} {path}:{e.read().decode()[:300]}")
|
||||
|
||||
|
||||
def parse_ref(s):
|
||||
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
|
||||
if not m:
|
||||
die(f"🔴 寫法是 owner/repo#N,你給的是:{s}")
|
||||
return m.group(1), m.group(2), int(m.group(3))
|
||||
|
||||
|
||||
def read_body(argv, what):
|
||||
if "-F" not in argv:
|
||||
die(f"🚫 缺 -F <檔>:{what}要**寫在票上**,不是寫在訊息裡。\n"
|
||||
f" (inkstone/ISEP#81:「退回理由寫在票上(寫票,不是寫訊息)」)")
|
||||
p = argv[argv.index("-F") + 1]
|
||||
try:
|
||||
with open(p) as f:
|
||||
return f.read().strip()
|
||||
except Exception as e:
|
||||
die(f"🔴 讀不到 {p}:{e}")
|
||||
|
||||
|
||||
def opt(argv, name, default=None):
|
||||
return argv[argv.index(name) + 1] if name in argv else default
|
||||
|
||||
|
||||
def say_on_ticket(ref, body):
|
||||
"""把結論貼進**票**(不是貼進 PR 的討論串——PR 會隨自己沉掉,票才是那條線的家)。
|
||||
|
||||
內文第一行帶【總管】身份欄,對齊 reply-identity-guard 的規約
|
||||
(leo 2026-08-27:「subagent 回覆時要表明身份」)。
|
||||
"""
|
||||
owner, repo, num = parse_ref(ref)
|
||||
if DRY:
|
||||
print(f" [dry-run] POST /repos/{owner}/{repo}/issues/{num}/comments")
|
||||
print(f" {body[:120]}…")
|
||||
return
|
||||
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
|
||||
|
||||
|
||||
# ── merge ────────────────────────────────────────────────────────────────
|
||||
def cmd_merge(argv):
|
||||
if not argv:
|
||||
die("用法:pr-verdict merge <owner/repo#N> [--style merge|squash|rebase] [--dry-run]")
|
||||
owner, repo, num = parse_ref(argv[0])
|
||||
style = opt(argv, "--style", "merge")
|
||||
|
||||
pr = api(f"/repos/{owner}/{repo}/pulls/{num}")
|
||||
branch = ((pr.get("head") or {}).get("ref")) or ""
|
||||
same_repo = ((pr.get("head") or {}).get("repo") or {}).get("full_name") == f"{owner}/{repo}"
|
||||
|
||||
if not pr.get("merged"):
|
||||
if pr.get("state") != "open":
|
||||
die(f"🔴 {owner}/{repo}#{num} 不是 open(state={pr.get('state')})——沒得 merge。")
|
||||
api(f"/repos/{owner}/{repo}/pulls/{num}/merge",
|
||||
{"Do": style, "delete_branch_after_merge": True})
|
||||
if not DRY:
|
||||
print(f"✅ {owner}/{repo}#{num} 已 merge({style})")
|
||||
else:
|
||||
print(f"ℹ️ {owner}/{repo}#{num} 本來就是 merged,只補刪分支")
|
||||
|
||||
# 🔴 第二件事:**當場複驗分支真的不見了**。
|
||||
# Gitea 的 delete_branch_after_merge 在某些設定下不會生效(保護分支、
|
||||
# 跨 fork),而「我下了刪除的指令」不等於「它被刪掉了」——
|
||||
# 本 repo 心法:交出去之前,我知不知道它能不能用。
|
||||
if not branch:
|
||||
print("⚠️ 拿不到 head 分支名,無法複驗——請自己確認分支已刪。")
|
||||
return
|
||||
if not same_repo:
|
||||
print(f"ℹ️ head 在別的 repo(fork),本指令不跨 repo 刪分支:{branch}")
|
||||
return
|
||||
if DRY:
|
||||
print(f" [dry-run] DELETE /repos/{owner}/{repo}/branches/{branch}(若還在)")
|
||||
return
|
||||
if _branch_alive(owner, repo, branch):
|
||||
api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(branch, safe='')}",
|
||||
method="DELETE", soft=True)
|
||||
if _branch_alive(owner, repo, branch):
|
||||
die(f"🔴 分支 {branch} 刪不掉(保護分支?權限?)——**結論只給了一半**,"
|
||||
f"pr-verdict-guard 會繼續點名它。")
|
||||
print(f"✅ 分支 {branch} 已刪除(複驗過:查不到了)")
|
||||
|
||||
|
||||
def _branch_alive(owner, repo, br):
|
||||
r = api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(br, safe='')}", soft=True)
|
||||
return isinstance(r, dict) and "name" in r
|
||||
|
||||
|
||||
# ── reject / changes ─────────────────────────────────────────────────────
|
||||
def _verdict_with_reason(argv, kind):
|
||||
if not argv:
|
||||
die(f"用法:pr-verdict {kind} <owner/repo#N> --ticket <owner/repo#N> -F <檔> [--dry-run]")
|
||||
owner, repo, num = parse_ref(argv[0])
|
||||
ticket = opt(argv, "--ticket")
|
||||
if not ticket:
|
||||
die("🚫 缺 --ticket <owner/repo#N>:結論要寫在**票**上。\n"
|
||||
" PR 的討論串會隨 PR 一起沉掉;票才是那條線的家。")
|
||||
body = read_body(argv, "退回理由" if kind == "reject" else "修改要求")
|
||||
|
||||
pr = api(f"/repos/{owner}/{repo}/pulls/{num}")
|
||||
head = f"{owner}/{repo}#{num}"
|
||||
url = (pr.get("html_url") or "")
|
||||
title = "退回" if kind == "reject" else "要求修改"
|
||||
say_on_ticket(ticket, f"【總管】**PR {title}**:{head} {url}\n\n{body}")
|
||||
if not DRY:
|
||||
print(f"✅ {title}理由已寫進票 {ticket}")
|
||||
|
||||
if kind == "reject":
|
||||
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
|
||||
if not DRY:
|
||||
print(f"✅ {head} 已關閉")
|
||||
br = ((pr.get("head") or {}).get("ref")) or ""
|
||||
same = ((pr.get("head") or {}).get("repo") or {}).get("full_name") == f"{owner}/{repo}"
|
||||
if br and same and not DRY and _branch_alive(owner, repo, br):
|
||||
api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(br, safe='')}",
|
||||
method="DELETE", soft=True)
|
||||
print(f"✅ 分支 {br} 已刪除")
|
||||
else:
|
||||
# REQUEST_CHANGES:**讓「已經給過結論」變成 Gitea 查得到的事實**,
|
||||
# 這樣 pr-verdict-guard 就不會再把它當成被遺忘的 PR(=擋等待)。
|
||||
api(f"/repos/{owner}/{repo}/pulls/{num}/reviews",
|
||||
{"event": "REQUEST_CHANGES", "body": f"修改要求寫在 {ticket}"})
|
||||
if not DRY:
|
||||
print(f"✅ {head} 已留下 REQUEST_CHANGES review(閘看得到,不會再點名它)")
|
||||
if DRY:
|
||||
print("\n[dry-run] 以上一通都沒有送出。")
|
||||
return
|
||||
print("\n📌 兩件事是同一個動作——不會只寫了理由而 PR 還開著"
|
||||
"(reject),也不會留了 review 而票上查不到理由(changes)。")
|
||||
|
||||
|
||||
# ── list ─────────────────────────────────────────────────────────────────
|
||||
def cmd_list(argv):
|
||||
orgs = [o.strip().lower() for o in
|
||||
os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()]
|
||||
rows = api("/repos/issues/search?type=pulls&state=open&limit=50")
|
||||
import time
|
||||
now = time.time()
|
||||
n = 0
|
||||
for pr in rows:
|
||||
full = (pr.get("repository") or {}).get("full_name") or ""
|
||||
if "/" not in full or (orgs and full.split("/", 1)[0].lower() not in orgs):
|
||||
continue
|
||||
try:
|
||||
t = time.mktime(time.strptime(pr["created_at"][:19], "%Y-%m-%dT%H:%M:%S"))
|
||||
age = int((now - t) / 86400)
|
||||
except Exception:
|
||||
age = -1
|
||||
who = [a["login"] for a in (pr.get("assignees") or [])]
|
||||
mark = "🕒" if who else "❌"
|
||||
print(f"{mark} {full}#{pr['number']:<5} 開了 {age:>3} 天 "
|
||||
f"{'指派=' + ','.join(who) if who else '沒有人被指派'} {pr['title'][:34]}")
|
||||
n += 1
|
||||
print(f"\n共 {n} 個 open PR(org:{'/'.join(orgs) or '全部'})。"
|
||||
f" ❌=沒有人在管,pr-verdict-guard 會點名它")
|
||||
|
||||
|
||||
def main():
|
||||
global DRY
|
||||
argv = sys.argv[1:]
|
||||
if "--dry-run" in argv:
|
||||
DRY = True
|
||||
argv.remove("--dry-run")
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
sys.exit(0)
|
||||
cmd, rest = argv[0], argv[1:]
|
||||
if cmd == "merge":
|
||||
cmd_merge(rest)
|
||||
elif cmd == "reject":
|
||||
_verdict_with_reason(rest, "reject")
|
||||
elif cmd == "changes":
|
||||
_verdict_with_reason(rest, "changes")
|
||||
elif cmd == "list":
|
||||
cmd_list(rest)
|
||||
else:
|
||||
print(__doc__)
|
||||
die(f"🔴 沒有這個子指令:{cmd}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user