Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 770a0ee824 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.5.1",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(57 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.6.0",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
| | 數量 | 是什麼 |
|
||||
|---|---|---|
|
||||
| `hooks/` | 48 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 59 條註冊) |
|
||||
| `hooks/` | 46 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 57 條註冊;`ISEP#60` 移除 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59`) |
|
||||
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
|
||||
| `skills/` | 2 支 | |
|
||||
| `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … |
|
||||
|
||||
+4
-7
@@ -43,15 +43,12 @@ bash scripts/test-release-tag-guard.sh
|
||||
**該看到**:`3/3 通過`(1 個該擋、2 個不該擋)。
|
||||
**失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。
|
||||
|
||||
### A4 — 新增 Gitea 東西的側門閘:24 條
|
||||
### A4 — 開票側門閘:13 條
|
||||
```
|
||||
bash scripts/test-ticket-api-bypass-guard.sh
|
||||
```
|
||||
**該看到**:`24/24 通過`(前 13 條是 v1 的開票案例;後面是 inkstone/ISEP#72
|
||||
擴大範圍後補的:隱式/小寫 POST、milestone/label/PR、org 端點、以及一條打
|
||||
真實 Gitea 網路重演 Arcrun#100 的案例——這台機器的 remote 沒帶憑證時會印
|
||||
`⏭️ SKIP`,不算失敗,但也不算驗過)。
|
||||
**失敗**:任何一條不符,特別看「不該擋」那幾條——誤攔比漏擋更該修。
|
||||
**該看到**:`13/13 通過`。
|
||||
**失敗**:任何一條不符,特別看「不該擋」那 8 條。
|
||||
|
||||
### A5 — 開票前的搜尋是跨 repo 的
|
||||
```
|
||||
@@ -244,7 +241,7 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
||||
| A1 manifest 合法 | 總管 | ✅ |
|
||||
| A2 版本三處一致 | 總管 | ✅ |
|
||||
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
|
||||
| **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/24(2026-08-27,inkstone/ISEP#72) |
|
||||
| A4 開票側門閘 | 總管 | ✅ 13/13 |
|
||||
| A5 搜尋跨 repo | 總管 | ✅ |
|
||||
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
||||
| **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) |
|
||||
|
||||
@@ -274,9 +274,9 @@ s/triage ──驗傷通過──▶ s/backlog ──排進 milestone──▶ s
|
||||
| E9 | 留言變成推理長文 | hook:超長**不 reject**,要求移去 wiki 再留指標 | ❌ 待建(放寬版,見 §8.3) |
|
||||
| E10 | 就地修改治理檔案 | 安裝目錄唯讀 + hook,導向 ISEP 開 issue | ❌ 待建 |
|
||||
| E11 | 本機雲端版本漂移 | SessionStart hook 比對版本,不一致 fail-fast | ◐ 現有 `skill-deploy-drift-guard.sh` 管的是 skill 不是 plugin 版本 |
|
||||
| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` = tag = release 存在;且 milestone open issues > 0 不准打 tag | ◐ 現有 `delivery-police.sh`/`claim-verify-police.sh` 部分覆蓋 |
|
||||
| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` = tag = release 存在;且 milestone open issues > 0 不准打 tag | ◐ 現有 `delivery-police.sh` 部分覆蓋(`claim-verify-police.sh` 已於 `ISEP#60` 移除,見下方腳註) |
|
||||
| E13 | 審核被遺忘 | Stop hook:**我自己開的** `s/review` 存在且**這回合完全沒碰它** → 擋一次 | ◐ 現有 `empty-handed-stop-guard.sh` 判準不同,兩者要合併不要疊加 |
|
||||
| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ✅ **已有** `subagent-claim-worksheet.sh` + `empty-handed-stop-guard.sh` |
|
||||
| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ◐ 原本 `subagent-claim-worksheet.sh` + `empty-handed-stop-guard.sh`;前者已於 `ISEP#60`(2026-08-27)移除,見下方腳註 |
|
||||
| E15 | 代理硬做只有人能做的事 | 代理端無對應 tool 或 token;唯一出口是開 `human/exec` 票 | ◐ 部分(credential 機制已擋一部分,D36) |
|
||||
| E16 | 標籤漂移 | 走 Gitea labels API 依 `labels.yaml` 校正:缺的補、改的還原、多的**只告警不刪** | ❌ 待建(`ISEP#4`) |
|
||||
|
||||
@@ -312,6 +312,21 @@ leo 2026-08-17 實測:文字層的閘那天 **8 次誤攔、0 次正確攔截*
|
||||
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
|
||||
所以 E9 擋的是「長內容放錯地方」這個**動作**,處置是**導引**(移去 wiki 留指標),不是 reject。
|
||||
|
||||
### 8.4 E12/E14:自造的「待驗單」機制已移除,改由 §16 原生三格承接(`ISEP#60`)
|
||||
|
||||
`subagent-claim-worksheet.sh`(SubagentStop 產待驗單)與 `claim-verify-police.sh`
|
||||
(Stop 攔收工,待驗單還在就擋)這一對於 2026-08-27 整組移除。
|
||||
|
||||
- **為什麼不是修它**:症狀是同一份待驗單重生三次、同內容兩個檔名;
|
||||
但真正的問題是 D58——**待驗單是一張 markdown,沒有狀態、沒有持有人、沒有人會回頭看**,
|
||||
必然退化成雜訊。Gitea 的 issue 三格本來就是為這件事做的。
|
||||
- **它守的東西現在由誰守**:見 §16(`ISEP#59`/PR #58)——子票相依回答「這件事存在嗎/做完了嗎」、
|
||||
`s/*` tag 回答「它卡在哪一段」、指派回答「現在誰該動」。三格合起來覆蓋原本 E14
|
||||
想擋的「subagent 空口宣稱」與 E12 想擋的「宣稱交付但沒人跟進」。
|
||||
- **現況誠實標記**:PR #58(§16 的落地)**尚未 merge**,所以 E12/E14 這一格目前是
|
||||
「舊機制已拆、新機制已提案但未生效」的過渡態,不是「已有替代品在跑」。
|
||||
#58 merge 前,這兩條暫時回到 ❌ 待建的實質狀態,即使表格欄位寫的是 ◐。
|
||||
|
||||
---
|
||||
|
||||
## 9. 本規範的迭代
|
||||
|
||||
+14
-7
@@ -16,6 +16,11 @@
|
||||
> 現在的寫法是實際數出來的:
|
||||
> `ls hooks/*.sh | wc -l` = 46;`hooks.json` 展開後的 command 條目 = 56。
|
||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||
>
|
||||
> 📌 **`inkstone/ISEP#60`(2026-08-27)之後這兩個數字各再減 2**:移除了
|
||||
> `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制。
|
||||
> 本票只處理這兩支的落差,**不在本票範圍內重新對帳其餘既有落差**——
|
||||
> 要重新核實整份,跑下面「這份表怎麼跟實況對帳」段落的指令。
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +48,7 @@
|
||||
| `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 東西(開新票/milestone/label/PR,會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POST(urllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 |
|
||||
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 開新 Gitea 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 |
|
||||
| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 |
|
||||
|
||||
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
||||
@@ -111,15 +116,17 @@
|
||||
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
|
||||
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
||||
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
||||
| `claim-verify-police.sh` | Subagent 交回來的「我做完了」宣稱還沒被驗證過(對應的待驗檔案還在),你這邊卻想收工,就擋下。 | 🛑 擋 |
|
||||
|
||||
> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。
|
||||
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58:
|
||||
> 不要硬做平台不支援的機制。它守的情境現在改由 Gitea 原生三格承接
|
||||
> (子票相依/`s/*` tag/指派——見 `inkstone/ISEP#59` 與 PR #58)。
|
||||
|
||||
## G. Subagent 把工作交回來的時候(SubagentStop)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `subagent-claim-worksheet.sh` | Subagent 一交回工作,就自動把它宣稱做了什麼寫成一張「待驗清單」檔案——之後總管收工前,`claim-verify-police.sh` 會檢查這張清單有沒有被處理掉。 | 📝 記錄 |
|
||||
|
||||
> 這個時機還掛了 `worklist-guard.sh`/`self-drive-police.sh`/`self-drive-judge.sh`/`delivery-police.sh`/`wiki-first-police.sh`/`unpushed-police.sh`,行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。
|
||||
`inkstone/ISEP#60` 之後,這個時機**沒有專屬掛載的閘**了(原本唯一的一支 `subagent-claim-worksheet.sh` 已移除)。
|
||||
仍會掛到這個時機的是 `worklist-guard.sh`/`self-drive-police.sh`/`self-drive-judge.sh`/`delivery-police.sh`/`wiki-first-police.sh`/`unpushed-police.sh`,
|
||||
行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。
|
||||
|
||||
## H. 動作做完之後,純粹記一筆(PostToolUse,全部不擋)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -267,25 +267,9 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claim-verify-police.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-claim-worksheet.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
# history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、
|
||||
# not-my-branch-guard.sh、release-tag-guard.sh、skill-deploy-drift-guard.sh、
|
||||
# stage-before-prod-guard.sh、unpushed-police.sh、wiki-first-police.sh。
|
||||
# 另有 claim-verify-police.sh、subagent-claim-worksheet.sh、empty-handed-stop-guard.sh、
|
||||
# issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`(無 `:-` fallback)——
|
||||
# 另有 empty-handed-stop-guard.sh、issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`
|
||||
# (無 `:-` fallback)——
|
||||
# (claim-verify-police.sh/subagent-claim-worksheet.sh 已於 ISEP#60 整組移除,
|
||||
# 它們守的東西改由 Gitea 原生三格承接,見 ISEP#59/PR #58)
|
||||
# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。
|
||||
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
|
||||
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單
|
||||
# (SubagentStop;leo 2026-08-18 立)
|
||||
#
|
||||
# ── leo 的原話(本閘的規格)─────────────────────────────────────────
|
||||
# 「你如何確認說的都是真的?subagent 做完事,你應該在 SubagentStop 掛動作,
|
||||
# **它說可以,你去驗證;它說不行,你想辦法,不行再報告**,
|
||||
# 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,
|
||||
# **它視野小,它說的你有更多資訊去檢驗**。」
|
||||
#
|
||||
# ── 為什麼是「工作單檔案」而不是提醒文字 ─────────────────────────────
|
||||
# leo 2026-08-17 診斷過:文字層封路是打不贏的軍火競賽,要封**動作**。
|
||||
# 所以這支不勸、也不偵測我的措辭——它**在磁碟上放一個必須被處理掉的東西**。
|
||||
# 配套的 Stop 閘(claim-verify-police.sh)看的是「那個檔還在不在」,
|
||||
# 而檔案只能被動作消滅,不能被文案消滅。
|
||||
#
|
||||
# ── 兩類宣稱,處理方式不同(leo 的規格就是這兩條)──────────────────
|
||||
# ✅ 它說可以 → 我去驗證(我有跨 repo 視野、有 token、有瀏覽器,它沒有)
|
||||
# ⛔ 它說不行 → 我想辦法(換路徑/查憑證地圖/自己撞一次),真的不行才報告 leo
|
||||
#
|
||||
# ⚠️ 這裡的關鍵詞比對只用來**列出候選**,不用來判定對錯——
|
||||
# 列多了我自己劃掉(要寫理由),列少了不影響我另外查。
|
||||
# ⇒ 它的失敗模式是「多列幾條」,不是「擋錯事」。
|
||||
#
|
||||
# ── 留痕(InkStoneCo#48:36 支閘只有 2 支記錄自己做了什麼)─────────
|
||||
INPUT=$(cat)
|
||||
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
|
||||
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-claim-worksheet.log"
|
||||
mkdir -p "$DIR" 2>/dev/null
|
||||
|
||||
OUT=$(printf '%s' "$INPUT" | DIR="$DIR" python3 -c '
|
||||
import json, os, re, sys, hashlib
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP:bad-json"); raise SystemExit
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP:no-transcript"); raise SystemExit
|
||||
|
||||
# ── 取 subagent 的交件 ────────────────────────────────────────────
|
||||
# 🔴 2026-08-18 首次實跑就抓到自己的缺陷:`SubagentStop` 給的 `transcript_path`
|
||||
# 指向**主對話**,不是子代理的紀錄 ⇒ 原本「取最後一則 assistant 訊息」
|
||||
# 抓到的是**總管自己寫的句子**,這支 hook 當場退化成它要避免的「文字層自咬」。
|
||||
#
|
||||
# 正解:子代理的交件是以 `<task-notification>…<result>…</result>` 的形式
|
||||
# 進到主對話的 **user 訊息**裡。只認那個區塊,其餘一律不看。
|
||||
# ⇒ 判準回到「這段話是誰說的」,而不是「這段話長什麼樣」。
|
||||
last = ""
|
||||
try:
|
||||
blob = []
|
||||
with open(tp, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
msg = ev.get("message") or {}
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
parts = msg.get("content")
|
||||
if isinstance(parts, str):
|
||||
txt = parts
|
||||
elif isinstance(parts, list):
|
||||
txt = "".join(p.get("text", "") for p in parts
|
||||
if isinstance(p, dict) and p.get("type") == "text")
|
||||
else:
|
||||
txt = ""
|
||||
if "<task-notification>" in txt:
|
||||
blob.append(txt)
|
||||
if blob:
|
||||
m = re.search(r"<result>(.*?)</result>", blob[-1], re.S)
|
||||
last = m.group(1) if m else ""
|
||||
except Exception:
|
||||
print("SKIP:read-fail"); raise SystemExit
|
||||
|
||||
if not last.strip():
|
||||
print("SKIP:no-subagent-report"); raise SystemExit
|
||||
|
||||
if not last.strip():
|
||||
print("SKIP:empty-report"); raise SystemExit
|
||||
|
||||
# ── 抽候選宣稱 ──────────────────────────────────────────────────
|
||||
# 成功類:它說某件事成立/做好了 ⇒ 我去驗
|
||||
OK = ("驗過", "測過", "通過", "全綠", "已部署", "部署完成", "可以用", "成立",
|
||||
"完成", "修好", "已推", "已併", "沒問題", "正常", "一致", "對上",
|
||||
"✅", "green", "passed", "verified", "deployed")
|
||||
# 阻擋類:它說做不到 ⇒ 我想辦法,不行才報告 leo
|
||||
NG = ("無法", "不行", "做不到", "被擋", "擋下", "失敗", "不存在", "找不到",
|
||||
"沒有權限", "權限不足", "需要 leo", "要 leo", "請總管", "交給你",
|
||||
"❌", "blocked", "denied", "cannot", "failed", "not found")
|
||||
|
||||
def lines_of(text):
|
||||
out = []
|
||||
for raw in text.split("\n"):
|
||||
s = raw.strip().lstrip("-*#>| ").strip()
|
||||
if len(s) < 8 or len(s) > 220:
|
||||
continue
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
ls = lines_of(last)
|
||||
ok_hits, ng_hits = [], []
|
||||
for s in ls:
|
||||
low = s.lower()
|
||||
if any(k in s or k.lower() in low for k in NG):
|
||||
ng_hits.append(s)
|
||||
elif any(k in s or k.lower() in low for k in OK):
|
||||
ok_hits.append(s)
|
||||
|
||||
# 兩類都沒有 ⇒ 它交的是純資料/純提問,沒有事實宣稱要驗
|
||||
if not ok_hits and not ng_hits:
|
||||
print("SKIP:no-claims"); raise SystemExit
|
||||
|
||||
sid = (d.get("session_id") or "nosid")[:8]
|
||||
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
|
||||
# 🔴 2026-08-21:stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
|
||||
# 而且不知道總管已經驗過了。實際發作:同兩條 sdd-guard 宣稱連生四張單
|
||||
# (1c97d461/fcb285dc/256de849/394b97ae),驗掉一張下一回合又冒一張。
|
||||
# ⇒ 改成雜湊**宣稱內容本身**:同樣的宣稱=同一個檔名 ⇒ 驗過就不再冒出來。
|
||||
_claims = sorted(set(str(x) for x in (ok_hits + ng_hits)))
|
||||
# 🔴 空清單就退回舊行為 —— 但那等於這支閘沒抓到任何宣稱,本來就會 SKIP,
|
||||
# 所以這個 fallback 實際上不會被用到;留著只是不讓 stamp 變成空字串的雜湊。
|
||||
_basis = "\n".join(_claims) if _claims else (tp + sid + aid)
|
||||
stamp = hashlib.sha1(_basis.encode()).hexdigest()[:8]
|
||||
_name = "claims-%s-%s.md" % (sid, stamp)
|
||||
path = os.path.join(os.environ["DIR"], _name)
|
||||
|
||||
# 已經驗過並移進 verified/ 的,不要再生一次。
|
||||
if os.path.exists(os.path.join(os.environ["DIR"], "verified", _name)):
|
||||
print("SKIP:already-verified"); raise SystemExit
|
||||
|
||||
def block(title, items, howto, cap):
|
||||
if not items:
|
||||
return ""
|
||||
body = "\n".join("- [ ] %s" % x for x in items[:cap])
|
||||
more = ""
|
||||
if len(items) > cap:
|
||||
more = "\n- [ ] (還有 %d 條同類,讀原始交件)" % (len(items) - cap)
|
||||
return "## %s\n\n%s\n%s%s\n\n" % (title, howto, body, more)
|
||||
|
||||
doc = []
|
||||
doc.append("# 待驗工作單(subagent 交回的宣稱)\n")
|
||||
doc.append("> leo 2026-08-18:「**它視野小,它說的你有更多資訊去檢驗。**」\n")
|
||||
doc.append("> 交件全文在:`%s`\n" % tp)
|
||||
doc.append("\n🔴 **這張單子只能用動作消滅,不能用文字消滅。**")
|
||||
doc.append("逐條處理完之後,把本檔**刪掉或移走**;在那之前 Stop 閘會擋。\n\n")
|
||||
doc.append(block(
|
||||
"✅ 它說「可以」——我要自己驗一次",
|
||||
ok_hits,
|
||||
"判準:**我不採信,我自己跑一次**。它只看得到自己那個 repo 與自己那次呼叫;\n"
|
||||
"我有跨 repo 視野、有 token、有瀏覽器。驗完把證據貼進回覆或票裡。\n",
|
||||
12))
|
||||
doc.append(block(
|
||||
"⛔ 它說「不行」——我先想辦法,真的不行才報告 leo",
|
||||
ng_hits,
|
||||
"判準:**它的「不存在/被擋」往往只是「我這裡看不到」**。\n"
|
||||
"先做三件:① 查憑證地圖/wiki ② 換一條路徑 ③ **自己真的撞一次並貼出拒絕原文**。\n"
|
||||
"🔴 沒撞過就不准說「我被 X 擋住」——那是虛構的閘(2026-08-18 已第五次)。\n"
|
||||
"真的是人閘 ⇒ 走三件機械動作:`Human` 標籤 + 指派給 Leo + **一張它自己的票**,\n"
|
||||
"不是在別張票的留言裡寫一段話(leo 08-18:「不是默默塞進錯的地方」)。\n",
|
||||
12))
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("".join(doc))
|
||||
|
||||
print("WROTE:%s:ok=%d:ng=%d" % (os.path.basename(path), len(ok_hits), len(ng_hits)))
|
||||
' 2>/dev/null)
|
||||
|
||||
printf '%s\t%s\n' "$(date +%FT%T)" "${OUT:-SKIP:no-output}" >> "$LOG" 2>/dev/null
|
||||
|
||||
case "$OUT" in
|
||||
WROTE:*)
|
||||
F=$(printf '%s' "$OUT" | cut -d: -f2)
|
||||
N=$(printf '%s' "$OUT" | sed 's/.*ok=\([0-9]*\):ng=\([0-9]*\)/\1 成功宣稱、\2 阻擋宣稱/')
|
||||
cat <<EOF
|
||||
🔍 交件查證:subagent 交回了 $N,已列成待驗工作單。
|
||||
|
||||
【leo 2026-08-18 的規格】
|
||||
「它說可以,你去驗證;它說不行,你想辦法,不行再報告。
|
||||
**它視野小,它說的你有更多資訊去檢驗。**」
|
||||
|
||||
工作單:.claude/pending-verification/$F
|
||||
|
||||
🔴 **不要直接把它說的轉給 leo。** 逐條處理完,把那個檔刪掉;在那之前 Stop 閘會擋一次。
|
||||
· 它說「可以」→ 你自己跑一次(測試/curl/瀏覽器/git,看那條宣稱是什麼)
|
||||
· 它說「不行」→ 先換路徑、查憑證地圖;**要說「被擋」就得自己撞一次並貼原文**
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -1,45 +1,26 @@
|
||||
#!/bin/bash
|
||||
# 管什麼: 用 Gitea API 直接「新增」東西時(開新票/開新 milestone/開新 label/開新 PR),
|
||||
# 要求這一輪有跑過搜尋(/tmp/.ticket-where-ok,30 分鐘內)。
|
||||
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人;而擋側門的第一版只認
|
||||
# 大寫裸字 POST,2026-08-27 總管用 `requests.post(...)`/隱式 POST 的 urllib
|
||||
# 繞了過去,同一天又用 API 直接開了 milestone/label/PR(這版都沒管)。
|
||||
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘所有寫入端點都放行),
|
||||
# 或在指令裡加 `ticket-api-ok` 留痕放行。
|
||||
# ticket-api-bypass-guard.sh — 新增 Gitea 東西的每一道側門,都要經過同一道搜尋閘
|
||||
# 管什麼: 用 Gitea API 直接開新票時,要求這一輪有跑過跨 repo 的搜尋(/tmp/.ticket-where-ok,30 分鐘內)。
|
||||
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人。2026-08-20 總管走 API 側門開了 12 張票,每一張都跟舊票重疊。
|
||||
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘 API 也放行),或在指令裡加 `ticket-api-ok` 留痕放行。
|
||||
# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘
|
||||
#
|
||||
# 來由(leo 2026-08-20 當場問「如何防止」;2026-08-27 leo 把範圍講寬:
|
||||
# 「不只開票前⋯⋯所有新增 gitea 的東西,都要搜尋」):
|
||||
# 來由(leo 2026-08-20 當場問「如何防止」):
|
||||
# `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。
|
||||
# 但 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」
|
||||
# 這組結構性訊號——訊號種類有限、可枚舉,跟關鍵字黑名單不是同一件事。
|
||||
# 但總管當天開了 12 張與舊票重疊的新票——因為他**沒用那支工具,直接打 Gitea API**。
|
||||
# ⇒ 規範有、閘也有,但閘長在「工具」上,而那個動作有兩條路,只封了一條。
|
||||
# leo:「你在讓事情複雜化」/「每張票開以前都要搜尋現有票,你為什麼會開了不搜?」
|
||||
#
|
||||
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的「集合端點」
|
||||
# (issues/milestones/labels/pulls,不帶 ID=新增;帶 ID=改既有的,放行)做寫入,
|
||||
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的 issues 端點做寫入,
|
||||
# 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。
|
||||
# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。
|
||||
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板。)
|
||||
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板;
|
||||
# 所以判準取「端點 + 寫入動詞」兩個都命中才擋,讓純讀取一律放行。)
|
||||
#
|
||||
# 放行(刻意,這些都不是「新增」):
|
||||
# 放行(刻意,這些都不是「開票」):
|
||||
# - 只讀不寫(GET):撈清單、看票、對帳
|
||||
# - 對既有票號的子路徑(/issues/<N>/... //milestones/<N> //labels/<N> //pulls/<N>...):
|
||||
# 留言、改標籤、關票、改狀態、合併 PR——leo 要的是「不要亂開新的」,不是「不准動 Gitea」
|
||||
# - scripts/ticket 自己(它有自己的三道閘,重複擋只會互相打架)
|
||||
# - 對既有票的留言/改標籤/關票(/issues/<N>/... 這種帶票號的子路徑)
|
||||
# - scripts/ticket 自己(它有自己的閘,重複擋只會互相打架)
|
||||
# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上)
|
||||
#
|
||||
# 只對「issues」做主動搜尋+在擋下的訊息裡直接點名可能撞到的舊票(milestones/labels/pulls
|
||||
# 沒有對應的跨 repo 全文搜尋端點可用,一律只要求「搜尋戳記新鮮」,不生產額外訊息——
|
||||
# 這是刻意的簡化,不是漏做;真要幫 milestone/label/PR 做語意重複檢查是另一張票的事)。
|
||||
# 搜尋本身連不上網路 → 不影響擋/放行的判斷,只影響訊息裡有沒有列出候選舊票(fail-open
|
||||
# 在「錦上添花」的部分,不 fail-open 在「該不該擋」這個核心判斷上)。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
@@ -53,54 +34,18 @@ 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"*|*"ticket close"*|*"ticket decide"*) exit 0 ;; esac
|
||||
case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*) exit 0 ;; esac
|
||||
|
||||
# ① 先放行「帶了 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
|
||||
# ① 有沒有打到 Gitea 的 issues 端點(不帶票號的那個=建立新票的路徑)
|
||||
printf '%s' "$CMD" | grep -qE 'repos/[^ "'"'"']*/issues([?"'"'"'`,)\\[:space:]]|$)' || 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
|
||||
# ② 指令裡有沒有 POST 這個詞(純 GET 一律放行)
|
||||
# 刻意只認一個裸字:跳脫引號、heredoc、python、curl、各種包裝的寫法無限多,
|
||||
# 逐個補 pattern 追不完(leo 2026-08-17:「自然語言的變體是無限的,blacklist 永遠追不完」)。
|
||||
# ①已經確定這是「開票那條端點」,純讀取的指令不會出現 POST ⇒ 一個字就夠,而且沒有跳脫的破口。
|
||||
printf '%s' "$CMD" | grep -qw 'POST' || exit 0
|
||||
|
||||
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
|
||||
# 隱式 POST:urllib.request 傳了 data= 卻沒寫明 method——這是 2026-08-27
|
||||
# 實際撞到的寫法,指令裡從頭到尾沒有 "POST" 三個字母,上面兩層都攔不到。
|
||||
WRITE_SIGNAL=yes
|
||||
fi
|
||||
[ "$WRITE_SIGNAL" = yes ] || exit 0
|
||||
|
||||
# ④ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘)
|
||||
# ③ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘)
|
||||
STAMP=/tmp/.ticket-where-ok
|
||||
NOW=$(date +%s)
|
||||
FRESH=no
|
||||
@@ -110,108 +55,30 @@ if [ -f "$STAMP" ]; then
|
||||
fi
|
||||
[ "$FRESH" = "yes" ] && exit 0
|
||||
|
||||
# 只有 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 -d/json.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
|
||||
cat >&2 <<'MSG'
|
||||
🚫 你正在用 Gitea API 直接開新票,而這一輪沒有搜尋紀錄
|
||||
|
||||
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("\\\"", "\"")
|
||||
leo 2026-08-16:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」
|
||||
leo 2026-08-20:「**每張票開以前都要搜尋現有票,你為什麼會開了不搜?這個規範不是早就有 hook 了?**」
|
||||
|
||||
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)
|
||||
規範有,閘也有——但那道閘長在 `scripts/ticket` 這支工具裡,
|
||||
而你走的是 API 這條側門。**本閘就是把那道門也封上。**
|
||||
|
||||
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.post/milestone·label·PR 端點)沒被管到。**本閘把這些側門也封上。**
|
||||
MSG
|
||||
if [ -n "$HITS" ]; then
|
||||
printf '\n%s\n' "$HITS"
|
||||
fi
|
||||
cat <<'MSG'
|
||||
實錯(2026-08-20 同日):總管用 API 開了 12 張票,事後盤點**每一張都跟舊票重疊**,
|
||||
全部只能關掉指回舊票。leo:「**你在讓事情複雜化**」。
|
||||
|
||||
── 怎麼過(擇一)───────────────────────────────
|
||||
1. 先搜(預設,戳記 30 分鐘有效,之後所有寫入端點都放行):
|
||||
1. 先搜(預設,戳記 30 分鐘有效,之後 API 也放行):
|
||||
scripts/ticket where <關鍵字...>
|
||||
🔴 搜到同一條線就貼進那張票,不要開新的:
|
||||
🔴 搜到了就**貼進那張票**,不要開新的:
|
||||
scripts/ticket say <owner/repo#N> -F <內文檔>
|
||||
|
||||
2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
|
||||
2. 真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
|
||||
scripts/ticket new <repo> -F <內文檔> --title <標題>
|
||||
|
||||
3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態)
|
||||
3. 這次確實不是在開新票(例如批次改標籤/關票/留言)
|
||||
→ 指令裡加 `ticket-api-ok` 說明理由,留痕放行。
|
||||
|
||||
放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、
|
||||
scripts/ticket 自己。
|
||||
放行的情況(本閘不管):純讀取(GET)、對既有票 /issues/<N>/ 的留言與標籤、scripts/ticket 自己。
|
||||
MSG
|
||||
} >&2
|
||||
exit 2
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 開票側門閘的測試(docs/TESTING.md A4)
|
||||
# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條(v1,仍須全過,不能為了
|
||||
# 新案例把舊的改壞)+ inkstone/ISEP#72 擴大範圍後新增的案例(隱式/小寫 POST、
|
||||
# milestone/label/PR、org 端點、Arcrun#100 重演)。
|
||||
# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
H=hooks/ticket-api-bypass-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
@@ -35,53 +33,8 @@ 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#72:2026-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:範圍擴大到 milestone/label/PR,該擋 ──"
|
||||
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 的 milestone/label/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:有戳記時,milestone/label/PR 也放行(不是只有 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#72:Arcrun#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)) 通過"
|
||||
|
||||
Reference in New Issue
Block a user