Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 278f00a227 | |||
| 53328bfbe9 | |||
| d4547bfec3 | |||
| 2f43ecc346 | |||
| 1b95388a0f | |||
| e3d05df341 | |||
| a594decb78 | |||
| 5789f0a917 | |||
| b1f399f8b9 | |||
| a446ad6d1d | |||
| 41c56acd32 | |||
| 2eb9b2aaaa | |||
| 67dae3b814 | |||
| 43c328d26f | |||
| bcb736ed19 | |||
| 6772ca67d3 | |||
| 1b5551274a | |||
| 1920d4cb06 | |||
| 47ed778cc4 | |||
| 03d9782f22 | |||
| 36d8e05a32 | |||
| 135637291c | |||
| d3061585e8 | |||
| 9099c3f533 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.9",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
# 含金鑰真身的雲端設定,永遠不進版控(2026-08-20 實際差點被 git add)
|
||||
cloud-env*.txt
|
||||
*.env
|
||||
.shell-payload/
|
||||
|
||||
+7
-2
@@ -160,10 +160,15 @@ bash scripts/make-cloud-env.sh
|
||||
### B4 — 閘真的會擋(用有鑑別力的動作)
|
||||
|
||||
```
|
||||
請把這段寫進 /tmp/x.md:__GITEA_TOKEN__=abc
|
||||
請把這段寫進 /tmp/wf.yaml:auth: __GITEA_TOKEN__
|
||||
```
|
||||
|
||||
**該看到**:被 `credential-only-guard` 擋下,訊息提到金鑰佔位符。
|
||||
**該看到**:被擋下,訊息開頭是 `🔒 credential 鐵律攔截(leo 2026-07-29 立)`。
|
||||
|
||||
🔴 **副檔名不能改成 `.md`。** `credential-only-guard` **刻意豁免** `.md`/`docs/`/`wiki/`
|
||||
(文件本來就要能談論這些字串,本頁自己就寫滿了)。
|
||||
2026-08-21 實撞:舊寫法用 `/tmp/x.md` ⇒ **exit 0,閘完全沒反應**——
|
||||
那是沒撞過就寫進來的探針,跟它要取代的假綠是同一個病。
|
||||
|
||||
**失敗**:
|
||||
- 真的寫進去了 ⇒ 雲端仍然沒有閘。
|
||||
|
||||
+26
-60
@@ -1,72 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# 貼進 code-on-web「Cloud environments → 你的環境 → Setup script」欄位的內容。
|
||||
# 不是 ISEP 的一部分(不會被 Claude Code 當 hook/command/skill 掃描),
|
||||
# 純粹是給 leo 複製貼上的參考檔,見 docs/cloud-session-bootstrap.md。
|
||||
# 貼進 claude.ai → Cloud environments → 你的環境 → Setup script 欄位。
|
||||
#
|
||||
# 前提(要先在同一個 Cloud environment 的 Environment variables 欄位加好):
|
||||
# GITEA_TOKEN_CLAUDE_CODE ← 既有機器帳號 token,名字沿用 InkStoneCo#14 已建立的那把,
|
||||
# 不要新造一把。值本身不寫在這支腳本或任何檔案裡。
|
||||
# 🔴 2026-08-21:這支腳本從「要 token」變成「不要 token」,因為查文件查出兩件事——
|
||||
#
|
||||
# 這支腳本做兩件事:
|
||||
# 1. 設定 git URL 重寫,讓任何對 git.uncle6.me 的 clone 都能用 GITEA_TOKEN_CLAUDE_CODE 認證
|
||||
# (官方文件對「CI/CD 裝私有 marketplace」建議的寫法,見 references 段)。
|
||||
# 2. 直接把 ISEP 裝成 user-scope plugin ——不是「複製一份」,是跟本機一樣走
|
||||
# `claude plugin marketplace add` + `claude plugin install`,裝的東西
|
||||
# 100% 來自 inkstone/ISEP 這個 repo 本身,沒有第二份內容。
|
||||
# ① **setup script 讀不到 Environment variables。**
|
||||
# 官方原文:「Each session copies the environment's values once, **at startup**,
|
||||
# into ordinary environment variables」,而 setup script 是
|
||||
# 「**before Claude Code launches**」跑的 ⇒ 注入發生在它之後。
|
||||
# ⇒ 舊版把 token 放進 Environment variables 再要這裡讀,永遠讀不到。
|
||||
#
|
||||
# 何時跑:只在「這個 Cloud environment 第一次開 session」時跑一次,
|
||||
# 跑完 Anthropic 會把整個檔案系統拍成快照,之後的 session 直接沿用快照
|
||||
# (不重跑,除非改了這支腳本本身、改了 allowed network hosts、或快照滿 7 天過期)。
|
||||
# ⇒ 這是唯一會讓「ISEP 改了但雲端還是舊的」重新出現的地方,
|
||||
# 緩解法見 docs/cloud-session-bootstrap.md「已知限制」段。
|
||||
# ② **非零結束會讓整個 session 開不起來。**
|
||||
# 官方原文:「**Exit zero**: if the script exits non-zero, the session fails to start.」
|
||||
# ⇒ 這裡一律 exit 0。任何失敗只記錄,不擋門。
|
||||
#
|
||||
# 解法是拿掉憑證需求本身:`inkstone` org 與 `inkstone/ISEP` 都改成 Public
|
||||
# ⇒ 匿名 clone 得到(2026-08-21 實測:匿名 git-upload-pack HTTP 200、
|
||||
# 真隔離環境(無憑證、GIT_CONFIG_NOSYSTEM=1)ls-remote 成功)
|
||||
# ⇒ **雲端不再需要任何金鑰就能裝 ISEP。**
|
||||
#
|
||||
# 真正讓 plugin 生效的是薄殼 repo 的 .claude/settings.json
|
||||
# (extraKnownMarketplaces + enabledPlugins)——官方文件:
|
||||
# 「Installed at session start from the marketplace you declared.」
|
||||
# 下面兩行是備援,讓 marketplace 在 session 啟動前就已經在快照裡。
|
||||
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
|
||||
if [ -z "${GITEA_TOKEN_CLAUDE_CODE:-}" ]; then
|
||||
echo "❌ 找不到 GITEA_TOKEN_CLAUDE_CODE —— 去 Cloud environment 的 Environment variables 加這個名字" >&2
|
||||
exit 1
|
||||
fi
|
||||
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>&1 || true
|
||||
claude plugin install isep@inkstone --scope user 2>&1 || true
|
||||
|
||||
# ── git 認證:三個機制都設,因為它們失效的方式不同 ────────────────
|
||||
# ① URL 重寫(global) ② 憑證存檔(global) ③ 系統層(HOME 無關,best effort)
|
||||
# 2026-08-20 雲端實測:session 裡 git 是「匿名」的 ⇒ setup 設的東西沒生效到 session。
|
||||
# 原因未定(沒跑到/HOME 不同/快照沒帶),所以三條都設,並在下面自我驗證。
|
||||
git config --global url."https://claude-code:${GITEA_TOKEN_CLAUDE_CODE}@git.uncle6.me/".insteadOf \
|
||||
"https://git.uncle6.me/"
|
||||
git config --global credential.helper store
|
||||
printf 'https://claude-code:%s@git.uncle6.me\n' "$GITEA_TOKEN_CLAUDE_CODE" > "$HOME/.git-credentials"
|
||||
chmod 600 "$HOME/.git-credentials"
|
||||
git config --system url."https://claude-code:${GITEA_TOKEN_CLAUDE_CODE}@git.uncle6.me/".insteadOf \
|
||||
"https://git.uncle6.me/" 2>/dev/null || echo "(system 層設不了,只靠 global——非致命)"
|
||||
|
||||
# ── 🔴 自我驗證一:認證真的通了嗎 ──────────────────────────
|
||||
# 這一步是 2026-08-20 事故的直接產物:舊版設完就結束,token 沒生效也不出聲,
|
||||
# 於是雲端 session 開起來才發現 marketplace 拉不下來,而 setup log 一片綠。
|
||||
echo "── 驗證 git 認證 ──"
|
||||
if GIT_TERMINAL_PROMPT=0 git ls-remote https://git.uncle6.me/inkstone/ISEP.git >/dev/null 2>&1; then
|
||||
echo "✅ git 認證通:拉得到 inkstone/ISEP"
|
||||
else
|
||||
echo "❌ git 認證不通——marketplace 一定裝不起來,後面不用往下做了。" >&2
|
||||
echo " 檢查:GITEA_TOKEN_CLAUDE_CODE 的值對不對、那把 token 有沒有被撤銷。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 安裝(user scope 只是備援)─────────────────────────────
|
||||
# 🔴 官方文件(cloud-environments 的 what-carries-over 表)明文:
|
||||
# 「Plugins enabled only in your user settings」→ **不會**帶到雲端 session。
|
||||
# 真正生效的是薄殼 repo 的 .claude/settings.json 裡的 enabledPlugins + extraKnownMarketplaces。
|
||||
# 這兩行留著當本地備援,不是主要路徑——不要以為裝完就等於雲端有了。
|
||||
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>/dev/null || true
|
||||
claude plugin install isep@inkstone --scope user 2>/dev/null || true
|
||||
|
||||
# ── 🔴 自我驗證二:marketplace 真的拉下來了嗎 ────────────────
|
||||
echo "── 驗證 marketplace ──"
|
||||
if claude plugin marketplace list 2>/dev/null | grep -q "inkstone"; then
|
||||
echo "✅ marketplace inkstone 已就位"
|
||||
else
|
||||
echo "❌ marketplace 沒就位——session 啟動時 enabledPlugins 會是一張跳票的支票。" >&2
|
||||
exit 1
|
||||
echo "⚠️ marketplace 沒就位——session 啟動時會再試一次(薄殼 settings.json 宣告的那條路)。"
|
||||
echo " 若 session 開頭看不到「🟢 ISEP v… 已載入」,就是這條也失敗了。"
|
||||
fi
|
||||
|
||||
echo "✅ setup 完成。session 啟動後請用「有鑑別力的探針」驗閘,"
|
||||
echo " 不要用 git tag(它在三支閘的白名單裡,閘死了也會過)。"
|
||||
exit 0
|
||||
|
||||
@@ -677,3 +677,28 @@ Gitea 的 milestone 只管得到同一個 repo,所以六個群在 `inkstone/IS
|
||||
結構(正交) hub ← tracking issue 標記
|
||||
封存不刪 duplicate ← 由 close/duplicate 取代,保留在歷史票上
|
||||
```
|
||||
|
||||
### M4.8 每個里程碑都要有真的期限(leo 2026-08-21 立)
|
||||
|
||||
> 「**以後所有的 milestone 限制時間**」/「**你根本沒有時間概念,浪費一整天**」
|
||||
|
||||
🔴 **`9999-01-01` 不算期限。** 立這條的當下實查七個 open milestone,
|
||||
**六個的期限是 `9999-01-01`**——那是「沒有期限」穿了一件期限的衣服,
|
||||
比空白更糟:盤點時每一格看起來都有值,於是沒有人發現這裡從來沒有時間壓力。
|
||||
|
||||
**怎麼定**:里程碑的 deliverable 是**一個可測的版本**(M4.0)。
|
||||
問一句「**這個版本幾號要能給 leo 打開?**」,那天就是期限。
|
||||
|
||||
| 剩幾張未結 | 期限 |
|
||||
|---|---|
|
||||
| 本週要收 | 三天 |
|
||||
| 1–3 張 | 一週 |
|
||||
| 4 張以上 | 兩週 |
|
||||
|
||||
**過期了怎麼辦**:不自動關、不自動打 tag(M4.3 已否決那條)。
|
||||
過期只做兩件事——**對帳**(哪幾張沒動)與**通知**。
|
||||
期限的用途是製造節奏,不是製造假完成。
|
||||
|
||||
**機械閘**=`hooks/milestone-due-guard.sh`(PreToolUse `Bash`):
|
||||
建 milestone 沒有 `due_on`、或 `due_on` 帶 `9999` → 擋。
|
||||
四向實測:無 due_on 擋/9999 擋/真期限放行/只是讀 milestone 放行。
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
|---|---|---|
|
||||
| `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) |
|
||||
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
|
||||
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。 | 🛑 擋 |
|
||||
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23(inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
|
||||
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
|
||||
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
|
||||
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
|
||||
|
||||
+182
-59
@@ -13,27 +13,72 @@
|
||||
#
|
||||
# ① `[ "$RUNNING" -gt 0 ] && exit 0`
|
||||
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
|
||||
# 2026-08-16 一天四次停工**全部發生在有 agent 在跑的時候**:
|
||||
# 產線忙著做 A,總管宣告了 B,然後沒有人做 B。工廠沒停,停的是按 trigger 的人。
|
||||
#
|
||||
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**。
|
||||
# 實測:`Leo/arcrun-rag` 回 `not found`(被當成 0),`inkstone/arcrun-rag` 有 16 張。
|
||||
# ⇒ 池子恆為 0 ⇒ 第 53 行直接 exit 0。**這道閘等於不存在。**
|
||||
# (同族第四顆搬遷遺留座標:`ARCRUN_SHIP_NS`/`ARCRUN_REPO`/MEMORY.md 索引/本檔。)
|
||||
#
|
||||
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**(已修)。
|
||||
# ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 🔴 2026-08-23 第四輪(inkstone/ISEP#30):**這道閘在懲罰誠實與謹慎。**
|
||||
#
|
||||
# 雲端驗收連續三次被它擋下,三次都不是宣告意圖:
|
||||
# ① 「那句是等你,不是我的下一步」 ← 在**否認**自己有下一步
|
||||
# ② 「這不是『下一步我要做 X』」 ← **引用**閘自己的訊息來說明沒在做
|
||||
# ③ 貼出閘自己的**正則原始碼**來舉報這個 bug ← code block 裡的**引用**
|
||||
#
|
||||
# 真兇有四個,而且沒有一個是「例外沒列夠」:
|
||||
#
|
||||
# (a)**DECL 會匹配裸的「下一步」三個字。** 舊式 `下一步(我|就是|是)?[::]?\s*`
|
||||
# 每一節都是可選的 ⇒ 整條 alternative 退化成「文中出現『下一步』」。
|
||||
# ⇒ 只要**提到**這道閘在講什麼,就會被這道閘咬。
|
||||
# 修法=**要求它後面真的接一個動作動詞**。這是**收緊**(要求更多才算命中),
|
||||
# 方向與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
|
||||
#
|
||||
# (b)**「引用」被當成「主張」。** 舊版只剝 `>` 引言與長「」引號,
|
||||
# 不認 markdown 的 ```code fence``` 與行內 `code`。
|
||||
# ⇒ 貼原始碼舉報 bug 的人,會被他要舉報的那支閘擋下。
|
||||
# 修法=把引用性標記整段換成**哨兵字元**(不是刪掉):
|
||||
# - 刪掉會把外層句構攪碎(2026-08-17 就是這樣漏掉「回『規劃』我就派人」)
|
||||
# - 換成哨兵則**內層的宣告消失、外層的句構留著** ⇒ 兩個方向同時修好
|
||||
#
|
||||
# (c)**看錯了「回合的收尾」。** 舊版取 `blocks_text[-1]`(最後一則文字),
|
||||
# 但那則文字後面可能還有 tool_use ⇒ 宣告其實已經被兌現了。
|
||||
# 修法=只看**最後一個動作之後**的文字。**收尾在動作上 ⇒ 根本不觸發。**
|
||||
#
|
||||
# (d)**訊息承諾的出路,程式碼裡沒有。** 舊訊息教人走
|
||||
# 「選項③:改寫那句話,說明它在等什麼」,
|
||||
# 但放行分支只有「有 Agent/Task」與「附近有 #票號」兩條 ⇒
|
||||
# 唯一走得通的路是**不寫那三個字**,而那正是同一則訊息 🔴 明文禁止的動作。
|
||||
# 修法=給選項③一個**真的存在**的機械形式:`⏸ 等:<在等什麼>`。
|
||||
# 它是**白名單標記**(要刻意寫才會有),不是黑名單,不會被誤觸;
|
||||
# 而且**留痕**——滿足「不准只是把那句話刪掉再送一次」。
|
||||
#
|
||||
# ⚠️ **刻意的取捨(寫下來,下一輪不要當成 bug 又改回去)**:
|
||||
# - `#票號` 改成搜「整段收尾文字」而不是宣告句附近 ±N 字。
|
||||
# 理由:票號常寫在行內 `code` 裡,剝完就找不到了 ⇒ 誤攔。
|
||||
# **誤攔的代價高於漏攔**——誤攔會訓練人忽略警報,那比沒有警報更糟。
|
||||
# - 「下一步」後面不吃逗號(「下一步,我去補 X」會漏)。
|
||||
# 理由:吃了逗號,「不是我的下一步,我剛剛已經做完了」就會被咬(過去式當未來式)。
|
||||
# - 「身為…我要…我才…」(本 repo 規定的 User Story 票名格式)整段換哨兵。
|
||||
# 票名是**被命名的物件**,不是我對下一步的主張。
|
||||
#
|
||||
# ── 現在量什麼 ─────────────────────────────────────────────────
|
||||
#
|
||||
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。**
|
||||
# 憑證 = 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch
|
||||
# 在驗票號)⇒ **「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。
|
||||
# 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。
|
||||
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有按下任何 trigger。**
|
||||
# trigger = ① 這個回合真的呼叫過 Agent/Task
|
||||
# (no-ticket-no-dispatch 已在那條路上驗票號 ⇒ 這裡不必重複驗)
|
||||
# ② 收尾文字裡有 `#票號` 指涉(=已經有人在做)
|
||||
# ③ 收尾文字裡有 `⏸ 等:<在等什麼>`(=它現在不該做,且說明了在等什麼)
|
||||
# ④ 回合收尾在動作上(最後一個動作之後沒有文字)
|
||||
#
|
||||
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。
|
||||
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。
|
||||
#
|
||||
# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。
|
||||
# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。
|
||||
#
|
||||
# 🔴 **不設 `stop_hook_active` 逃生門**(與 empty-handed-stop-guard 不同)。
|
||||
# leo 2026-08-16:「提醒可以忽略,deny 不能。」
|
||||
# 之所以敢這樣,是因為出路③現在**真的存在** ⇒ 不可能把人鎖死在無解迴圈裡。
|
||||
#
|
||||
# 迴歸測試:`hooks/tests/factory-idle-guard.test.sh`(23 向,誤攔與漏攔兩個方向都測)
|
||||
set -eu
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
@@ -46,11 +91,11 @@ import sys, json, os, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP"); raise SystemExit
|
||||
print("SKIP:bad-payload"); raise SystemExit
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP"); raise SystemExit # 讀不到就別亂擋
|
||||
print("SKIP:no-transcript"); raise SystemExit # 讀不到就別亂擋
|
||||
|
||||
rows = []
|
||||
try:
|
||||
@@ -61,7 +106,7 @@ try:
|
||||
try: rows.append(json.loads(line))
|
||||
except Exception: pass
|
||||
except Exception:
|
||||
print("SKIP"); raise SystemExit
|
||||
print("SKIP:unreadable"); raise SystemExit
|
||||
|
||||
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
|
||||
start = 0
|
||||
@@ -73,63 +118,132 @@ for i, r in enumerate(rows):
|
||||
start = i
|
||||
turn = rows[start:]
|
||||
|
||||
# 依序攤平這個回合的 assistant 區塊:("tool", 名字) / ("text", 內容)
|
||||
seq = []
|
||||
dispatched = False
|
||||
blocks_text = []
|
||||
for r in turn:
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
for b in (r.get("message") or {}).get("content") or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
if b.get("type") == "tool_use" and b.get("name") in ("Agent", "Task"):
|
||||
dispatched = True
|
||||
if b.get("type") == "tool_use":
|
||||
if b.get("name") in ("Agent", "Task"):
|
||||
dispatched = True
|
||||
seq.append(("tool", b.get("name") or ""))
|
||||
elif b.get("type") == "text":
|
||||
blocks_text.append(b.get("text") or "")
|
||||
seq.append(("text", b.get("text") or ""))
|
||||
|
||||
if dispatched:
|
||||
print("OK"); raise SystemExit # 按了 trigger ⇒ 放行
|
||||
print("OK:dispatched"); raise SystemExit # 出路① 按了 trigger ⇒ 放行
|
||||
|
||||
# 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正)
|
||||
# 🔴 只看「最後一個動作**之後**」的文字(2026-08-23 修)
|
||||
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
|
||||
# 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火——
|
||||
# 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報,
|
||||
# 而那比沒有警報更糟(同 branch-holds.md 的教訓)。
|
||||
text = blocks_text[-1] if blocks_text else ""
|
||||
# 舊版取最後一則文字,但那則文字後面可能還有 tool_use——
|
||||
# 「我這回合說要讀 X、然後真的讀了」會被當成未兌現的意圖。誤攔會訓練人忽略警報。
|
||||
tail = []
|
||||
for kind, val in reversed(seq):
|
||||
if kind == "tool":
|
||||
break
|
||||
tail.append(val)
|
||||
raw = "\n".join(reversed(tail))
|
||||
|
||||
if not raw.strip():
|
||||
print("OK:ended-on-action"); raise SystemExit # 出路④ 收尾在動作上 ⇒ 沒有懸空的宣告
|
||||
|
||||
# 出路③:明講在等什麼(在剝標記**之前**檢查,理由本身常寫在引號/code 裡)
|
||||
if re.search(r"⏸\s*等\s*[::]\s*\S{2,}", raw):
|
||||
print("OK:waiting-declared"); raise SystemExit
|
||||
|
||||
# ── 把「引用性內容」蓋掉:引用 != 主張 ──────────────────────────
|
||||
# 🔴 **等長**替換(蓋成同樣長度的哨兵),不是刪掉,有兩個理由:
|
||||
# ① 刪掉會把外層句構攪碎——2026-08-17 就是這樣漏掉「回『規劃』我就派人」。
|
||||
# 蓋成哨兵則內層的宣告消失、外層的句構留著,誤攔與漏攔一起修好。
|
||||
# ② 等長 ⇒ 位移可以原封不動對回 raw ⇒ 底下的「票號要在宣告句附近」
|
||||
# 可以在**原文**上量。否則票號寫在行內 `code` 裡會被自己剝掉而找不到,
|
||||
# 逼得只能全篇搜——那會把閘變鈍(2026-08-23 拿真 transcript 量到:
|
||||
# 全篇搜的話 26 個真實回合有 20 個是靠「文中某處有票號」放行的)。
|
||||
S = "※"
|
||||
def blank(m):
|
||||
return S * (m.end() - m.start())
|
||||
text = raw
|
||||
text = re.sub(r"```.*?```", blank, text, flags=re.S) # markdown 圍欄 code
|
||||
text = re.sub(r"~~~.*?~~~", blank, text, flags=re.S)
|
||||
text = re.sub(r"`[^`\n]*`", blank, text) # 行內 code
|
||||
text = re.sub(r"^[ \t]*>.*$", blank, text, flags=re.M) # 整段引言(leo 的原話)
|
||||
for _ in range(3): # 引號(含巢狀),由內而外
|
||||
text = re.sub(r"[「『][^「」『』]*[」』]", blank, text)
|
||||
# 本 repo 規定的 User Story 票名:是被命名的物件,不是我對下一步的主張
|
||||
text = re.sub(r"身為[^\n]{0,40}?我要[^\n]{0,80}?我才[^\n]{0,60}", blank, text)
|
||||
assert len(text) == len(raw) # 位移要能對回原文
|
||||
|
||||
# ── 宣告下一步的句型 ──────────────────────────────────────────
|
||||
# 🔴 每一條都**必須**接到一個動作動詞(A)才算命中。
|
||||
# 舊版少了這個要求 ⇒ 裸的「下一步」「接下來我」就開火 ⇒ 討論這道閘的人被這道閘擋。
|
||||
A = r"(?:去|來|做|派|審|跑|查|補|建|寫|改|推|驗|清|落|盤|整理|處理|部署|測)"
|
||||
N = r"[^。!?;,,\n]{0,16}" # 同一句、不跨逗號
|
||||
DECL = re.compile("|".join([
|
||||
# 🔴 主詞是「你」的下一步 != 我的下一步(2026-08-23 拿真 transcript 量出來的第四類誤攔)
|
||||
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是我在**交代 leo 該做什麼**:
|
||||
# 「**下一步還是那一個動作**:**你**把 feat/... 併進 main」
|
||||
# 「## **你下一步**(兩招,先便宜的)」
|
||||
# ⇒ 這是**主詞檢查**(誰要動手),不是關鍵字例外。
|
||||
# 只掛在這條 alternative 上——「你點頭我就做」那條的主詞本來就是我,不受影響。
|
||||
r"(?P<nextstep>下一步[::]?\s*(?!你|妳|leo|Leo)(?:我|就是|是)?\s*" + N + A + r")",
|
||||
r"接下來我\s*" + N + A,
|
||||
r"我(?:現在|接著|等下|等一下)(?:就)?\s*" + N + A,
|
||||
r"我(?:要|會)(?:去|來)?\s*" + N + A,
|
||||
r"稍後(?:我|再)\s*" + N + A,
|
||||
r"之後我(?:會|要)\s*" + N + A,
|
||||
# 2026-08-17 leo 實撞的一族:**把請示寫成條件句**——
|
||||
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
|
||||
r"回[「『]?[^」』\n]{0,12}[」』]?(?:我)?就\s*" + N + A,
|
||||
r"說一聲(?:我)?(?:就)?\s*" + N + A,
|
||||
r"你(?:點頭|說可以|確認)(?:了)?(?:我)?(?:就)?\s*" + N + A,
|
||||
r"(?:確認|核准|同意|批准)(?:過|了)?(?:之)?後(?:我)?(?:才|再|就)\s*" + N + A,
|
||||
# 空手停在別人身上(出路③ 的 ⏸ 標記是它唯一合法的形式)
|
||||
r"我(?:就)?(?:等|待)你",
|
||||
r"等你(?:說|回|點頭|確認)",
|
||||
r"我(?:就)?(?:不再|先不)(?:自己)?(?:動|做|派)",
|
||||
r"我(?:就)?(?:一次)?(?:落|派|做|補|審|清)(?:完|掉)?\s*[。,,]?\s*$",
|
||||
]))
|
||||
|
||||
SECOND = re.compile(r"你|妳|leo|Leo")
|
||||
hit = None
|
||||
for m in DECL.finditer(text):
|
||||
# 「下一步」這條要看主詞:前面 8 字裡出現第二人稱 ⇒ 那是**他的**下一步,不是我的
|
||||
if m.group("nextstep") and SECOND.search(text[max(0, m.start() - 8): m.start()]):
|
||||
continue
|
||||
hit = m
|
||||
break
|
||||
|
||||
# 引用 leo 的話不算我的宣告(整段引言/引號內)——它常含「下一步」等字樣
|
||||
text = re.sub(r"^\s*>.*$", "", text, flags=re.M)
|
||||
# 🔴 2026-08-17 修:舊版把「所有」引號內容都刪掉,包括我自己句子裡的關鍵詞。
|
||||
# leo 實撞:我寫「回『規劃』我就派人盤這份計畫」,`規劃` 被吃掉後變成
|
||||
# 「回我就派人盤這份計畫」,DECL 一個都不匹配 ⇒ 該攔的沒攔。
|
||||
# ⇒ 只刪「夠長的引言」(leo 的話通常成句),短引號是我自己的用詞,留著。
|
||||
text = re.sub(r"「[^」]{12,400}」", "", text)
|
||||
# 宣告下一步的句型(刻意收窄:只認「我接下來要做」,不認「現在的狀態是」)
|
||||
DECL = re.compile(
|
||||
r"(下一步(我|就是|是)?[::]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
|
||||
r"|我(要|會)(去|來)?(做|派|審|跑|查|補|建)|稍後(我|再)|之後我(會|要)"
|
||||
# 🔴 2026-08-17 leo 實撞補的一族:**把請示寫成條件句**——
|
||||
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
|
||||
# 實例:「回『規劃』我就派人盤這份計畫」/「說一聲我就落」/「你點頭我就做」
|
||||
r"|回[「『]?[^」』\n]{0,12}[」』]?(我)?就|說一聲(我)?(就)?|你(點頭|說可以|確認)(了)?(我)?(就)?"
|
||||
r"|(確認|核准|同意|批准)(過|了)?(之)?後(我)?(才|再|就)"
|
||||
r"|我(就)?(等|待)你|等你(說|回|點頭|確認)"
|
||||
r"|我(就)?(不再|先不)(自己)?(動|做|派)"
|
||||
r"|我(就)?(一次)?(落|派|做|補|審|清)(完|掉)?[。,,]?\s*$)")
|
||||
hit = DECL.search(text)
|
||||
if not hit:
|
||||
print("OK"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
|
||||
print("OK:no-declaration"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
|
||||
|
||||
# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行
|
||||
seg = text[max(0, hit.start() - 200): hit.end() + 400]
|
||||
if re.search(r"#\d{1,5}", seg):
|
||||
print("OK"); raise SystemExit
|
||||
# 出路②:宣告句**附近**有票號指涉(=已經說明它在誰手上/哪張票)
|
||||
# 在 raw 上量(等長替換保證位移對得回去)⇒ 票號寫在行內 code 裡也算數,
|
||||
# 而「文章別處剛好提到某張票」不算——那不是在講這件事在誰手上。
|
||||
if re.search(r"#\d{1,5}", raw[max(0, hit.start() - 200): hit.end() + 400]):
|
||||
print("OK:ticket-referenced"); raise SystemExit
|
||||
|
||||
print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
|
||||
' 2>/dev/null || echo SKIP)
|
||||
print("DECLARED_NO_TRIGGER::" + raw[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
|
||||
' 2>/dev/null || echo "SKIP:crash")
|
||||
|
||||
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
LOGDIR="$PROJ/.claude/hooks"
|
||||
|
||||
# 留痕(inkstone/InkStoneCo#48:閘只記「擋下」的話分母未知,回答不了「它有沒有在運作」)
|
||||
# ⇒ 擋下與放行都記。目錄不在就安靜跳過(測試環境/別的 repo 不該噴 redirect 錯誤到 stderr)。
|
||||
note() {
|
||||
if [ -d "$LOGDIR" ]; then
|
||||
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/factory-idle-guard.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
case "$VERDICT" in
|
||||
DECLARED_NO_TRIGGER::*)
|
||||
QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://')
|
||||
note "⛔ 擋下" "$QUOTE"
|
||||
cat >&2 <<MSG
|
||||
🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。**
|
||||
|
||||
@@ -147,19 +261,28 @@ leo 2026-08-16(本閘的規格):
|
||||
⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。**
|
||||
而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。
|
||||
|
||||
━━ 現在怎麼過這道閘(擇一)━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
━━ 現在怎麼過這道閘(四條,程式碼裡都真的有對應的分支)━━━━━━━━━
|
||||
① **現在就按 trigger**:這個回合直接呼叫 Agent/Task 派出去。
|
||||
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
|
||||
再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
|
||||
② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。
|
||||
有票號指涉就放行——那不是「我等下做」,是「已經有人在做」。
|
||||
③ **它其實不該現在做**:改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘),
|
||||
不要寫成「下一步我要做 X」。
|
||||
② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。
|
||||
有 \`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。
|
||||
③ **它其實不該現在做**:寫一行說明它在等什麼,格式就是這一個標記——
|
||||
|
||||
⏸ 等:<在等什麼>
|
||||
|
||||
例:\`⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘)\`
|
||||
\`⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除\`
|
||||
🔴 這個標記**要刻意寫才會有**,寫了就留痕——它取代不了做事,
|
||||
但它讓「現在不該做」跟「宣告了不做」在機器上分得開。
|
||||
④ **這個回合收尾在動作上**:最後一個工具呼叫之後不要再補一句宣告。
|
||||
|
||||
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
|
||||
比宣告了沒做更糟——它連痕跡都不留。
|
||||
比宣告了沒做更糟——它連痕跡都不留。要嘛按 trigger,要嘛用 ③ 留下 ⏸ 記號。
|
||||
MSG
|
||||
exit 2 ;;
|
||||
*)
|
||||
note "✅ 放行" "$VERDICT" ;;
|
||||
esac
|
||||
|
||||
# ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
|
||||
|
||||
+5
-1
@@ -40,6 +40,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -323,4 +327,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,32 @@ ROOT="${CLAUDE_PLUGIN_ROOT:-}"
|
||||
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||
"$ROOT/.claude-plugin/plugin.json" 2>/dev/null | head -1)"
|
||||
VER="${VER:-未知}"
|
||||
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | wc -l | tr -d ' ')"
|
||||
# 🔴 數「真的被註冊的」,不是數目錄裡有幾個 .sh(leo 的雲端驗收 2026-08-23 抓到):
|
||||
# 舊寫法 `ls hooks/*.sh` 把 `pre-write-guard.template.sh`(樣板,不是閘)
|
||||
# 與兩支沒掛註冊的輔助檔一起算進去 ⇒ 報 45,實際註冊 42。
|
||||
# 這個數字是 leo 判斷「這個 session 有沒有閘」的唯一介面——**多報就是假綠**。
|
||||
# (查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。)
|
||||
GATES="$(grep -oE 'hooks/[a-zA-Z0-9._-]+\.sh' "$ROOT/hooks/hooks.json" 2>/dev/null \
|
||||
| sort -u | wc -l | tr -d ' ')"
|
||||
case "$GATES" in
|
||||
''|*[!0-9]*|0)
|
||||
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | grep -cv '\.template\.sh$' | tr -d ' ')" ;;
|
||||
esac
|
||||
|
||||
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘在 ${ROOT})"
|
||||
# 🔴 講清楚「這一份是誰」(leo 2026-08-23 的雲端驗收卡在這裡整整一輪):
|
||||
# 同一台機器上 ISEP 可能有兩份——marketplace 裝的 plugin 快取,
|
||||
# 與 repo 裡 vendor 進去的複製本。**兩份都會印這一行**,而版本號一樣時分不出誰在說話。
|
||||
# 「閘到底從哪一份走」正是決定「另一份能不能拆」的唯一判準
|
||||
# ⇒ 讓路徑自己講:快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。
|
||||
# (查過歷史:本檔只有 daa1674 建立、b1f399f 改閘數兩輪,沒有人試過標示來源,也沒有被否決的方案。
|
||||
# KBDB 語意搜尋這段開發史:0 命中,該餵。)
|
||||
case "$ROOT" in
|
||||
*/plugins/cache/*) SRC="plugin(marketplace 裝的)" ;;
|
||||
*/.claude/isep*) SRC="vendor(repo 裡的複製本)" ;;
|
||||
*) SRC="來源不明" ;;
|
||||
esac
|
||||
|
||||
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘|來源:${SRC}|${ROOT})"
|
||||
|
||||
printf '%s\n' "{
|
||||
\"systemMessage\": \"${MSG}\",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hooks/lib/push_target_dir.py -- shared helper (inkstone/ISEP#40 S7-style
|
||||
lib helper: source of truth for a small piece of logic, not a gate by
|
||||
itself; nothing here decides allow/block on its own).
|
||||
|
||||
What it answers: given a shell command string that somewhere invokes
|
||||
`git ... push ...`, which directory will that push actually execute in?
|
||||
|
||||
Why this exists (inkstone/ISEP#30 comment 3949, 脈絡見 InkStoneCo#57):
|
||||
main-and-prod-push-guard.sh's stamp_ok() used to read `git rev-parse
|
||||
--show-toplevel` from the *hook's own* cwd (= the session's real repo) as
|
||||
"HERE", and compare it against the repo path the caller wrote into the
|
||||
stamp ("WANT"). That works when the push target IS the repo the session is
|
||||
standing in. It can never work otherwise: a `cd <other-repo> && git push
|
||||
origin HEAD:main` changes the *push's* directory but not the hook's, so
|
||||
HERE stays the real repo forever while WANT is (correctly) the other repo
|
||||
-- the two can never match, no matter how carefully the caller follows the
|
||||
gate's own instructions. This isn't a bad judgment call; that scenario
|
||||
simply doesn't exist in the old model.
|
||||
|
||||
This module extracts the directory the push *actually* runs in, purely by
|
||||
tokenizing -- it never executes anything. Two sources, first one found
|
||||
wins (in the order a real shell would apply them):
|
||||
- a `cd <path>` chain preceding the push, scoped correctly across
|
||||
subshells: a `(` inherits the current directory from its parent at the
|
||||
moment it opens, but whatever a subshell `cd`s to does NOT leak back
|
||||
out to sibling commands after the matching `)` closes (this mirrors
|
||||
real bash: a subshell's cwd change is local to that subshell). This
|
||||
scoping is load-bearing, not cosmetic: without it, `(cd /repo-A &&
|
||||
true); git push origin main` would misattribute the later push (which
|
||||
really runs wherever the outer shell already was) to /repo-A, and a
|
||||
stale/legitimate stamp for /repo-A could then wrongly wave through a
|
||||
push into whatever the outer cwd actually is -- the exact "stamp
|
||||
opened for repo A also opens the door for repo B" shape 2026-08-11
|
||||
already burned us on once.
|
||||
- a `-C <path>` flag on the git invocation itself, which further wins
|
||||
over any `cd` chain (matches git's own precedence: `-C` sets the
|
||||
directory for that invocation regardless of the shell's cwd).
|
||||
|
||||
Multiple relative `cd`/`-C` hops are combined by plain string join here
|
||||
(no `..`/`~`/`$()` resolution) -- resolving the combined expression to a
|
||||
real, canonical, absolute path is left to the caller, which does it with a
|
||||
read-only `cd "<expr>" && pwd` in a throwaway subshell. That two-step split
|
||||
matters: this module only ever *parses*, so it stays side-effect-free even
|
||||
when fed a hostile or malformed command; only the caller's final `cd`
|
||||
touches the filesystem, and `cd` cannot execute anything, it can only fail
|
||||
to find a directory.
|
||||
|
||||
If no `cd`/`-C` applies (the push runs wherever the hook itself is, i.e.
|
||||
today's behaviour), or the command doesn't parse, prints nothing -- the
|
||||
caller falls back to its existing cwd-based resolution. That fallback
|
||||
direction is deliberately the *safe* one: on any parse ambiguity we hand
|
||||
back "unknown" rather than guess, and an unresolved HERE can only make the
|
||||
gate keep blocking (fail toward blocking), never open a door it wouldn't
|
||||
have opened before.
|
||||
|
||||
Usage:
|
||||
printf '%s' "$CMD" | python3 push_target_dir.py
|
||||
"""
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
|
||||
|
||||
|
||||
def _join(base, path):
|
||||
"""Combine a cwd-so-far (`base`, or None if unknown/hook-cwd) with a
|
||||
`cd`/`-C` argument written in the command. Absolute paths and `~`
|
||||
replace the base outright; anything else is appended textually --
|
||||
normalizing `..`/`.` is intentionally left to the caller's real `cd`."""
|
||||
if not path:
|
||||
return base
|
||||
if path == "-" or path.startswith("$"):
|
||||
# `cd -` (previous dir) and `$VAR`/`$(...)` expansions can't be
|
||||
# resolved by tokenizing alone -- treat as "unknown" rather than
|
||||
# guess wrong, which keeps the caller on its safe fallback path.
|
||||
return None
|
||||
if path.startswith("/") or path.startswith("~"):
|
||||
return path
|
||||
if base is None:
|
||||
return path
|
||||
return base.rstrip("/") + "/" + path
|
||||
|
||||
|
||||
def _classify(tokens):
|
||||
if not tokens:
|
||||
return ("other", None)
|
||||
if tokens[0] == "cd" and len(tokens) > 1:
|
||||
return ("cd", tokens[1])
|
||||
if tokens[0] == "git" and "push" in tokens[1:]:
|
||||
return ("push", tokens)
|
||||
return ("other", None)
|
||||
|
||||
|
||||
def _events(cmd):
|
||||
"""Tokenize cmd into (kind, value) events in source order: 'enter'/
|
||||
'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for
|
||||
statements split on the usual shell separators. Returns [] on any
|
||||
quoting error -- caller then falls back to cwd-based resolution."""
|
||||
try:
|
||||
lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
toks = list(lexer)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
events = []
|
||||
seg = []
|
||||
|
||||
def flush():
|
||||
if seg:
|
||||
# _classify's "push" branch returns `tokens` by reference; copy
|
||||
# before clear() below, or the event's tuple would observe the
|
||||
# list emptied out from under it (aliasing, not a value copy).
|
||||
events.append(_classify(seg[:]))
|
||||
seg.clear()
|
||||
|
||||
for tok in toks:
|
||||
if tok == "(":
|
||||
flush()
|
||||
events.append(("enter", None))
|
||||
elif tok == ")":
|
||||
flush()
|
||||
events.append(("exit", None))
|
||||
elif tok in _SEPARATORS:
|
||||
flush()
|
||||
else:
|
||||
seg.append(tok)
|
||||
flush()
|
||||
return events
|
||||
|
||||
|
||||
def find_push_target(cmd):
|
||||
events = _events(cmd)
|
||||
if not events:
|
||||
return ""
|
||||
|
||||
stack = [None] # cwd-so-far per paren depth; None = "same as hook cwd"
|
||||
result = None
|
||||
saw_push = False
|
||||
|
||||
for kind, val in events:
|
||||
if kind == "enter":
|
||||
stack.append(stack[-1]) # child subshell inherits current dir
|
||||
elif kind == "exit":
|
||||
if len(stack) > 1:
|
||||
stack.pop() # subshell's own cd's don't leak out
|
||||
elif kind == "cd":
|
||||
stack[-1] = _join(stack[-1], val)
|
||||
elif kind == "push":
|
||||
saw_push = True
|
||||
c_path = None
|
||||
toks = val
|
||||
for j, t in enumerate(toks):
|
||||
if t == "-C" and j + 1 < len(toks):
|
||||
c_path = toks[j + 1]
|
||||
break
|
||||
if t.startswith("-C") and len(t) > 2:
|
||||
c_path = t[2:]
|
||||
break
|
||||
result = _join(stack[-1], c_path) if c_path else stack[-1]
|
||||
|
||||
if not saw_push:
|
||||
return ""
|
||||
return result or ""
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.stdin.read()
|
||||
sys.stdout.write(find_push_target(cmd))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -87,7 +87,9 @@ STAMP="/tmp/.main-push-ok"
|
||||
stamp_ok() {
|
||||
[ -f "$STAMP" ] || return 1
|
||||
NOW=$(date +%s 2>/dev/null || echo 0)
|
||||
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||||
# 🔴 GNU (Linux) 先、BSD (macOS) 後——順序反了戳記在雲端永遠失效(見 prod-write-guard.sh 同段註解):
|
||||
# GNU 的 `stat -f` 是 --file-system,`%m` 被當成檔名 ⇒ 印出檔案系統表且 exit 1 ⇒ MT 非數字 ⇒ arm 章在 Linux 永不生效。
|
||||
MT=$(stat -c %Y "$STAMP" 2>/dev/null || stat -f %m "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
|
||||
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
|
||||
[ $((NOW - MT)) -lt 900 ] || return 1
|
||||
@@ -98,7 +100,32 @@ stamp_ok() {
|
||||
# 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。
|
||||
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
|
||||
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。
|
||||
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||||
#
|
||||
# 🔴 2026-08-23(inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57):
|
||||
# `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。
|
||||
# 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己
|
||||
# `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是
|
||||
# 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法
|
||||
# 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做
|
||||
# 戳記,戳記內容天生就贏不了)。
|
||||
# 改法:先看指令本身有沒有把 push 的執行目錄改掉
|
||||
# (lib/push_target_dir.py——純 tokenize,不執行任何指令,
|
||||
# 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種
|
||||
# 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個
|
||||
# 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰;
|
||||
# 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。
|
||||
# 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個
|
||||
# repo」問得更準,比對邏輯(下面兩行)一個字沒動。
|
||||
_push_target_dir="$(dirname "$0")/lib/push_target_dir.py"
|
||||
_target_expr=""
|
||||
if [ -f "$_push_target_dir" ]; then
|
||||
_target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '')
|
||||
fi
|
||||
if [ -n "$_target_expr" ]; then
|
||||
HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||||
else
|
||||
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||||
fi
|
||||
WANT=$(head -1 "$STAMP" 2>/dev/null || printf '')
|
||||
[ -n "$WANT" ] || return 1
|
||||
[ -n "$HERE" ] || return 1
|
||||
@@ -125,7 +152,42 @@ if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]
|
||||
# 2026-08-20: match the target branch on a word boundary, not a bare substring --
|
||||
# a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to
|
||||
# `fix/custom-domain-setup` would have false-positived.
|
||||
if printf '%s' "$CMD" | grep -qE '(^|[^A-Za-z])(main|master)([^A-Za-z]|$)'; then
|
||||
# 2026-08-21: 只看 **push 的目標**,不再掃整條指令。
|
||||
# 舊版掃整條 ⇒ 一個晚上誤攔四次,全都是推 feature branch 或 tag:
|
||||
# git checkout -b fix/x main && git push origin fix/x ← 「main」在 checkout 上
|
||||
# gh pr create --base main ← 根本不是 git push
|
||||
# git checkout origin/main --detach; git push origin refs/tags/v0.3.3
|
||||
# ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察,
|
||||
# 文字層封路必敗)。這裡改成判動作的目標,不是判字面。
|
||||
# 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍
|
||||
# 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修):
|
||||
# `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行,
|
||||
# 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。
|
||||
# 成因:截斷 refspec 尾巴只切 `;`/`&`/`|` 三種字元,沒算到 `)`——
|
||||
# 於是「HEAD:main)」被當成一個 token,`${_tok##*:}` 剝完冒號還剩「main)」,
|
||||
# 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。
|
||||
# git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全,
|
||||
# 不會誤傷任何合法的推送目標。
|
||||
_push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//')
|
||||
_dest=""
|
||||
_seen_remote=0
|
||||
_saw_refspec=0
|
||||
for _tok in $_push_seg; do
|
||||
case "$_tok" in
|
||||
-*) continue ;; # 旗標
|
||||
refs/tags/*|*:refs/tags/*) _saw_refspec=1; continue ;; # 推 tag 不是推分支
|
||||
esac
|
||||
if [ "$_seen_remote" = "0" ]; then _seen_remote=1; continue; fi # 第一個非旗標=remote
|
||||
_saw_refspec=1
|
||||
_dest="$_dest ${_tok##*:}" # a:b 的目標是 b;沒有冒號就是它自己
|
||||
done
|
||||
# 🔴 只有「一個 refspec 都沒給」才退回猜當前分支。
|
||||
# 看到 refspec(哪怕是 tag)就照它判——否則推 tag 會被當成推當前分支,
|
||||
# 而當前分支若剛好叫 main 就誤擋(2026-08-21 實測抓到)。
|
||||
if [ "${_saw_refspec:-0}" = "0" ]; then
|
||||
_dest=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||
fi
|
||||
if printf '%s' "$_dest" | tr ' ' '\n' | grep -qE '^(main|master)$'; then
|
||||
stamp_ok && exit 0
|
||||
|
||||
# ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)───────────
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# milestone-due-guard.sh — 建 milestone 一定要有真的期限(leo 2026-08-21 立)
|
||||
#
|
||||
# leo 原話:「**以後所有的 milestone 限制時間**」
|
||||
# 「**你根本沒有時間概念,浪費一整天**」
|
||||
#
|
||||
# 🔴 為什麼連 9999 也要擋:立這條的當下實查七個 open milestone,
|
||||
# 六個的期限是 `9999-01-01`——那是「沒有期限」穿了一件期限的衣服,
|
||||
# 比空白更糟,因為它讓盤點時看起來每一格都有值。
|
||||
set -uo pipefail
|
||||
INPUT=$(cat)
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# 只管「建 milestone」這個動作
|
||||
printf '%s' "$CMD" | grep -qE 'milestones' || exit 0
|
||||
printf '%s' "$CMD" | grep -qE '\-X *POST|--request *POST' || exit 0
|
||||
|
||||
DUE=$(printf '%s' "$CMD" | grep -oE '"due_on"[^,}]*' | head -1)
|
||||
BAD=""
|
||||
[ -z "$DUE" ] && BAD="沒有 due_on"
|
||||
printf '%s' "$DUE" | grep -q '9999' && BAD="due_on 是 9999(等於沒有期限)"
|
||||
|
||||
[ -z "$BAD" ] && exit 0
|
||||
|
||||
cat >&2 <<MSG
|
||||
⏱️ 里程碑期限閘:$BAD
|
||||
|
||||
【leo 2026-08-21】「**以後所有的 milestone 限制時間**」
|
||||
「**你根本沒有時間概念,浪費一整天**」
|
||||
|
||||
🔴 9999-01-01 也算違規。立這條的當下實查七個 open milestone,
|
||||
六個是 9999——那是「沒有期限」穿了一件期限的衣服,
|
||||
比空白更糟,因為盤點時每一格看起來都有值。
|
||||
|
||||
怎麼定:這個里程碑的 deliverable 是**一個可測的版本**。
|
||||
問「這個版本幾號要能給 leo 打開?」,那天就是期限。
|
||||
剩 1–3 張票 → 一週;4 張以上 → 兩週;本週要收 → 三天。
|
||||
|
||||
加上去再送一次: "due_on": "YYYY-MM-DDT23:59:59Z"
|
||||
MSG
|
||||
exit 2
|
||||
@@ -52,7 +52,11 @@ STAMP="/tmp/.prod-write-ok"
|
||||
stamp_ok() {
|
||||
[ -f "$STAMP" ] || return 1
|
||||
NOW=$(date +%s 2>/dev/null || echo 0)
|
||||
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||||
# 🔴 GNU (Linux) 先、BSD (macOS) 後——順序反了戳記在雲端永遠失效:
|
||||
# GNU 的 `stat -f` 是 --file-system,`%m` 被當成檔名 ⇒ 把整張檔案系統表印到
|
||||
# stdout(被 $() 收進去)且 exit 1 ⇒ MT 變非數字 ⇒ 這道閘的釋放章在 Linux 永不生效。
|
||||
# dev 在 macOS(BSD `-f %m` 正常)所以沒被抓到,一到雲端 Linux 就咬(實測 2026-08-23)。
|
||||
MT=$(stat -c %Y "$STAMP" 2>/dev/null || stat -f %m "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
|
||||
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
|
||||
[ $((NOW - MT)) -lt 900 ] || return 1
|
||||
|
||||
@@ -172,7 +172,8 @@ for STAMP in /tmp/.stage-ok-by-leo /tmp/.stage-verified; do
|
||||
# 抽數字會得到天文數字 ⇒ `now - t` 是**負數** ⇒ `< 21600` 成立 ⇒ **永遠放行**。
|
||||
# ⇒ 只有「不晚於現在」的時間戳才算數;不合理就退回 mtime,別當成通過。
|
||||
if [ "$t" -eq 0 ] || [ "$t" -gt "$now" ]; then
|
||||
t=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||||
# 🔴 GNU (Linux) 先、BSD (macOS) 後(見 prod-write-guard.sh 同段註解):反了 stat -f %m 會在 Linux 印檔案系統表 ⇒ t 非數字 ⇒ 退回機制失效。
|
||||
t=$(stat -c %Y "$STAMP" 2>/dev/null || stat -f %m "$STAMP" 2>/dev/null || echo 0)
|
||||
fi
|
||||
[ "$t" -gt 0 ] && [ "$((now - t))" -lt 21600 ] && exit 0 # 6 小時內驗過 stage → 放行
|
||||
done
|
||||
|
||||
@@ -119,8 +119,21 @@ if not ok_hits and not ng_hits:
|
||||
|
||||
sid = (d.get("session_id") or "nosid")[:8]
|
||||
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
|
||||
stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8]
|
||||
path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp))
|
||||
# 🔴 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:
|
||||
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env bash
|
||||
# factory-idle-guard.sh 的迴歸測試(inkstone/ISEP#30)。
|
||||
#
|
||||
# 來由:2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖——
|
||||
# ① 「那句是等你,不是我的下一步」 ← 在否認自己有下一步
|
||||
# ② 「這不是『下一步我要做 X』」 ← 引用閘自己的訊息來說明沒在做
|
||||
# ③ 貼出閘自己的正則原始碼來舉報這個 bug ← code block 裡的引用也被當成宣告
|
||||
# 而閘的訊息教人走「選項③:改寫那句話,說明它在等什麼」,程式碼裡卻沒有那條分支。
|
||||
#
|
||||
# 本檔的兩個方向都要有證據(誤攔與漏攔都要測):
|
||||
# A 群「該放行」——上面三種誤攔案例 + 既有放行路徑不准變鬆的回歸
|
||||
# B 群「該擋」 ——真的宣告了未來動作、且這回合沒按下任何 trigger
|
||||
# C 群「訊息承諾的出路真的走得通」——訊息裡列的每一條,各一個實例
|
||||
#
|
||||
# 用法:hooks/tests/factory-idle-guard.test.sh [hooks/factory-idle-guard.sh 的路徑]
|
||||
# 🔴 全程在一個乾淨的 TMP 底下造假 transcript,跑完自己清;不碰任何真 repo、不打網路。
|
||||
# (CLAUDE_PROJECT_DIR 指向沒有 gitea remote 的 TMP ⇒ 副閘拿不到 token,直接 exit 0,
|
||||
# 所以本檔測的一律是主閘。)
|
||||
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/factory-idle-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
N=0
|
||||
|
||||
# mk <transcript 路徑> <block spec>...
|
||||
# block spec:`T:<文字>` = assistant 的文字段;`U:<工具名>` = assistant 的 tool_use
|
||||
mk() {
|
||||
python3 - "$@" <<'PY'
|
||||
import json, sys
|
||||
path, specs = sys.argv[1], sys.argv[2:]
|
||||
rows = [{"type": "user", "message": {"content": [{"type": "text", "text": "【工單】去辦"}]}}]
|
||||
blocks = []
|
||||
for s in specs:
|
||||
k, v = s.split(":", 1)
|
||||
blocks.append({"type": "text", "text": v} if k == "T"
|
||||
else {"type": "tool_use", "name": v, "id": "t1", "input": {}})
|
||||
rows.append({"type": "assistant", "message": {"content": blocks}})
|
||||
with open(path, "w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# t <期望 exit> <說明> <block spec>...
|
||||
t() {
|
||||
want="$1"; desc="$2"; shift 2
|
||||
N=$((N+1))
|
||||
tr="$TMP/tr-$N.jsonl"
|
||||
mk "$tr" "$@"
|
||||
out=$(printf '{"transcript_path":"%s"}' "$tr" \
|
||||
| CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" 2>&1); rc=$?
|
||||
if [ "$rc" -eq "$want" ]; then
|
||||
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
|
||||
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "── A 群:該放行(2026-08-23 雲端三次誤攔的原形狀)─────────────────"
|
||||
|
||||
t 0 "① 否認自己有下一步(『那句是等你,不是我的下一步』)" \
|
||||
'T:我看完了。那句是等你,不是我的下一步。'
|
||||
|
||||
t 0 "① 長版:連同被否認的原句一起引用" \
|
||||
'T:你貼的那行「回覆後我就繼續」是等你,不是我的下一步。'
|
||||
|
||||
t 0 "② 引用閘自己的訊息來說明沒在做" \
|
||||
'T:這不是『下一步我要做 X』,是在說明那道閘認錯了對象。'
|
||||
|
||||
t 0 "③ code block 裡貼閘自己的正則原始碼(舉報 bug)" \
|
||||
'T:誤攔的真兇是這段:
|
||||
```
|
||||
r"(下一步(我|就是|是)?[::]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
|
||||
```
|
||||
它連裸的「下一步」三個字都吃。'
|
||||
|
||||
t 0 "③ 行內 code 貼同一段正則" \
|
||||
'T:問題出在 `接下來我` 這個 alternative 沒有要求後面接動詞。'
|
||||
|
||||
t 0 "引用 leo 的話(blockquote)含宣告字樣" \
|
||||
'T:> 下一步我去把那支閘修好,你不要插手。
|
||||
|
||||
以上是他八月十六日的原話,我引來說明這道閘的規格。'
|
||||
|
||||
t 0 "User Story 票名(身為…我要…我才…)不是宣告" \
|
||||
'T:我把票名寫成:身為總管,我要把散落的分支收掉整理好,我才不會每天重數一次。'
|
||||
|
||||
# ↓ 這兩向不是想出來的,是拿本機一條 2068 行的真 transcript 跑出來的:
|
||||
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是「我在交代 leo 該做什麼」。
|
||||
t 0 "④ 主詞是 leo 的下一步(『你下一步去把 X 併進 main』)" \
|
||||
'T:## 你下一步(兩招,先便宜的)
|
||||
|
||||
你下一步去把 feat/isep-dynamic-install 併進 main,然後開一個全新雲端 session。'
|
||||
|
||||
t 0 "④ 同上,交代對方要跑的動作" \
|
||||
'T:前三題不適用。下一步還是那一個動作:你把那條分支併進 main,再跑一次看信標印什麼。'
|
||||
|
||||
t 0 "④ 『你的下一步』——主詞在對方身上(單字 lookbehind 蓋不到的變體)" \
|
||||
'T:我把兩條路都寫清楚了。你的下一步去把那條分支併進 main,再開一個新 session 看信標。'
|
||||
|
||||
t 2 "④ 的反面:主詞換回我自己 ⇒ 照樣擋" \
|
||||
'T:我把兩條路都寫清楚了。我的下一步去把那條分支併掉。'
|
||||
|
||||
echo
|
||||
echo "── A 群:既有放行路徑,不准變鬆(迴歸)─────────────────────────"
|
||||
|
||||
t 0 "純對話回合,沒有任何宣告" \
|
||||
'T:這道閘的問題在於它把引用當成宣告,量錯了對象。'
|
||||
|
||||
t 0 "宣告了,但這個回合真的呼叫了 Agent(=按下 trigger)" \
|
||||
'T:下一步我去把 sdd-guard 的誤攔補上。' 'U:Agent'
|
||||
|
||||
t 0 "宣告了,但這個回合真的呼叫了 Task" \
|
||||
'T:接下來我會去補測試。' 'U:Task'
|
||||
|
||||
t 0 "宣告句帶票號指涉(已經在別人手上)" \
|
||||
'T:下一步我去把那支閘補好——已經派給 inkstone/ISEP#30,證據在 comment 3970。'
|
||||
|
||||
t 0 "宣告之後真的動手了(文字後面還有工具呼叫)" \
|
||||
'T:先讀原始碼,接下來我會去補測試。' 'U:Read'
|
||||
|
||||
echo
|
||||
echo "── B 群:該擋(真的宣告了未來動作,且這回合沒按下任何 trigger)────"
|
||||
|
||||
t 2 "『下一步我去把 X 補上』,無派工無票號" \
|
||||
'T:我看完了。下一步我去把那支閘的誤攔補上。'
|
||||
|
||||
t 2 "『接下來我會去補測試』" \
|
||||
'T:目前狀況清楚了。接下來我會去補測試。'
|
||||
|
||||
t 2 "把請示寫成條件句(2026-08-17 leo 實撞那族)" \
|
||||
'T:回「規劃」我就派人盤這份計畫。'
|
||||
|
||||
t 2 "『我等你確認』——空手停在等人身上" \
|
||||
'T:我等你確認。'
|
||||
|
||||
t 2 "『我現在就去跑一遍』" \
|
||||
'T:我現在就去跑一遍那批測試。'
|
||||
|
||||
t 2 "『確認後我才動』" \
|
||||
'T:核准後我才去改那支閘。'
|
||||
|
||||
t 0 "票號寫在行內 code 裡也算數(等長替換才量得到)" \
|
||||
'T:下一步我去把那支閘補好——已派給 `inkstone/ISEP#30`。'
|
||||
|
||||
t 2 "票號離宣告句太遠(文章別處提到而已)⇒ 不算它在別人手上" \
|
||||
'T:早上那批已經在 inkstone/ISEP#30 收掉了。
|
||||
|
||||
(中略:這裡是一大段跟那張票無關的驗收敘述,用來把距離拉開。'"$(printf 'x%.0s' $(seq 1 320))"')
|
||||
|
||||
下一步我去把那支閘的誤攔補上。'
|
||||
|
||||
echo
|
||||
echo "── C 群:訊息承諾的每一條出路,都要真的走得通 ─────────────────"
|
||||
|
||||
t 0 "出路①:這個回合直接呼叫 Agent 派出去" \
|
||||
'T:下一步我去把那支閘補好。' 'U:Agent'
|
||||
|
||||
t 0 "出路②:把票號寫進那句話" \
|
||||
'T:下一步我去把那支閘補好(已派給 inkstone/ISEP#30)。'
|
||||
|
||||
t 0 "出路③:改寫那句話,用 ⏸ 等: 說明它在等什麼" \
|
||||
'T:⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘,不是我能自己按的)。
|
||||
|
||||
在那之前我等你確認,這件事我不自己動。'
|
||||
|
||||
t 0 "出路③:宣告 + 標記同時存在也放行(標記說明了它在等什麼)" \
|
||||
'T:那批 workflow 要重打 bundle,下一步我去重打。⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除。'
|
||||
|
||||
t 0 "出路④:這個回合收尾在動作上(最後一個工具呼叫之後沒有文字)" \
|
||||
'T:下一步我去把那支閘補好。' 'U:Edit'
|
||||
|
||||
echo
|
||||
echo "── 出路③ 的反面:沒寫標記就照樣擋(不准靠刪掉那句話矇混)───────"
|
||||
|
||||
t 2 "同一句話拿掉 ⏸ 等: 標記 ⇒ 擋" \
|
||||
'T:那批 workflow 要重打 bundle。我等你確認,這件事我不自己動。'
|
||||
|
||||
echo
|
||||
echo "── 壞輸入不准亂擋(fail-open)────────────────────────────────"
|
||||
|
||||
n_bad=0
|
||||
for payload in '{}' '{"transcript_path":"/nonexistent/xx.jsonl"}' 'not-json'; do
|
||||
N=$((N+1))
|
||||
printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" >/dev/null 2>&1; rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
printf ' ✅ 壞輸入 %-34s ⇒ exit=0\n' "$payload"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ 壞輸入 %-34s ⇒ exit=%s(期望 0)\n' "$payload" "$rc"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
n_bad=$((n_bad+1))
|
||||
done
|
||||
|
||||
echo
|
||||
echo "────── 通過 $PASS / 失敗 $FAIL (共 $N 向)"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# 跨 repo 戳記實測(inkstone/ISEP#30 comment 3949,脈絡 inkstone/InkStoneCo#57)
|
||||
#
|
||||
# 補的是什麼:hooks/tests/main-and-prod-push-guard.test.sh 那八向都只在單一 repo
|
||||
# (測試腳本自己所在的 repo)裡驗證,從沒測過「站在 A、要推 B 的 main」這個形狀
|
||||
# ——而這正是 2026-08-21 真的撞到、讓戳記永遠對不上的那個情境。這支專門補這塊。
|
||||
#
|
||||
# 用法:main-and-prod-push-guard-cross-repo.test.sh <要測的 hook 絕對路徑>
|
||||
# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑
|
||||
# 到那時就對不上了(自己撞過一次:exit=127 command not found)。
|
||||
set -u
|
||||
G="$1"
|
||||
STAMP=/tmp/.main-push-ok
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"; rm -f "$STAMP"' EXIT
|
||||
|
||||
for d in A B; do
|
||||
git init -q -b main "$WORK/$d"
|
||||
git -C "$WORK/$d" config user.email t@t.com
|
||||
git -C "$WORK/$d" config user.name t
|
||||
echo x > "$WORK/$d/f.txt"
|
||||
git -C "$WORK/$d" add f.txt
|
||||
git -C "$WORK/$d" commit -q -m init
|
||||
done
|
||||
A="$WORK/A"; B="$WORK/B"
|
||||
|
||||
pass=0; fail=0
|
||||
t() { # t <說明> <cwd> <指令> <期望 exit>
|
||||
local desc="$1" cwd="$2" cmd="$3" want="$4"
|
||||
local rc
|
||||
rc=$(cd "$cwd" && CLAUDE_CODE_CHILD_SESSION=1 python3 -c '
|
||||
import json, subprocess, sys
|
||||
p = subprocess.run(["bash", sys.argv[2]],
|
||||
input=json.dumps({"tool_name": "Bash",
|
||||
"tool_input": {"command": sys.argv[1]}}),
|
||||
capture_output=True, text=True)
|
||||
print(p.returncode)
|
||||
' "$cmd" "$G")
|
||||
if [ "$rc" = "$want" ]; then printf ' ✅ %-58s exit=%s\n' "$desc" "$rc"; pass=$((pass+1))
|
||||
else printf ' ❌ %-58s exit=%s(期望 %s)\n' "$desc" "$rc" "$want"; fail=$((fail+1)); fi
|
||||
}
|
||||
|
||||
echo "── 2026-08-21 實撞的原形狀:站在 A,要推 B 的 main ──"
|
||||
rm -f "$STAMP"
|
||||
t "沒戳記 → 擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
t "替 B 開的戳記 → 推 B 的 main 該放行(舊版在此情境永遠擋,這是本票要修的洞)" \
|
||||
"$A" "cd $B && git push origin HEAD:main" 0
|
||||
|
||||
echo "── 反向不准鬆:替 A 開的戳記,不能拿去放行推 B(08-11 那次穿透的形狀)──"
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "替 A 開的戳記 → 拿去推 B 的 main 必須仍被擋" \
|
||||
"$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── git -C 語法要吃到同一套判斷 ──"
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
t "替 B 開戳記,用 git -C B push" "$A" "git -C $B push origin main" 0
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── 08-11 原始穿透的形狀:子殼裡的 cd 不能外洩到殼外 ──"
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "子殼裡 cd 去 B 但沒在殼內推;殼外站著 A 真的推 → 符合 A 的戳記,放行" \
|
||||
"$A" "(cd $B && true); git push origin HEAD:main" 0
|
||||
rm -f "$STAMP"
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "子殼裡 cd 去 B 且在殼內真的推 → 目標是 B,戳記是 A,必須擋" \
|
||||
"$A" "(cd $B && git push origin HEAD:main)" 2
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── 順手抓到、一併修的洞:純括號包住整條指令,不准繞過目的地判斷 ──"
|
||||
t "(git push origin HEAD:main) 沒有任何戳記 → 必須擋(舊版在此整段放行)" \
|
||||
"$A" "(git push origin HEAD:main)" 2
|
||||
|
||||
echo "── 同 repo(session 站著的那個)舊行為原封不動 ──"
|
||||
rm -f "$STAMP"
|
||||
t "站在 A 推 A 自己的 main,沒戳記 → 擋" "$A" "git push origin HEAD:main" 2
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "站在 A 推 A 自己的 main,替 A 開戳記 → 放行" "$A" "git push origin HEAD:main" 0
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── 舊有行為一條都不能壞 ──"
|
||||
t "推 feature branch 放行" "$A" "git push origin feat/xyz" 0
|
||||
t "推 tag 放行" "$A" "git push origin refs/tags/v1.0.0" 0
|
||||
t "只是提到 main 的 gh pr create,放行" "$A" "gh pr create --base main --title t" 0
|
||||
|
||||
echo "── subagent 沒戳記,即使 cd 去別的 repo 也照擋 ──"
|
||||
rm -f "$STAMP"
|
||||
t "subagent 站在 A、cd 去 B 推 main,沒戳記仍擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
|
||||
echo "── 單次用完即丟、900 秒逾時:換到跨 repo 場景一樣要成立 ──"
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
t "第一次:替 B 開戳記推 B → 放行" "$A" "cd $B && git push origin HEAD:main" 0
|
||||
t "第二次:同一枚戳記(已用掉)再推一次 → 應該擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"; touch "$STAMP"
|
||||
t "touch 出的空戳記 → 推 B 的 main 仍應擋(08-12 補的洞不能被本次改動重開)" \
|
||||
"$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%m%d%H%M.%S)" "$STAMP" 2>/dev/null
|
||||
t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "────── 通過 $pass / 失敗 $fail"
|
||||
[ "$fail" = 0 ]
|
||||
Regular → Executable
+24
-37
@@ -1,42 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# main-and-prod-push-guard.sh 的迴歸測試——重點在「geek6688 豁免的範圍夠不夠窄」。
|
||||
# 🔴 寫成檔案跑:測試指令必然含 `wrangler deploy`,直接在 Bash 打會被 prod-write-guard 擋。
|
||||
#
|
||||
# 用一個**沒有 .github-armed** 的臨時 CLAUDE_PROJECT_DIR,
|
||||
# 否則本機真的有那個檔 ⇒「該擋的」會假性通過,測了等於沒測。
|
||||
HOOK="$1"
|
||||
TMPPROJ=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPPROJ"' EXIT
|
||||
# 八向實測 main-and-prod-push-guard.sh
|
||||
# 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔)。
|
||||
G="$1" # 要測的 hook 路徑
|
||||
pass=0; fail=0
|
||||
|
||||
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
|
||||
PASS=0; FAIL=0
|
||||
t(){ mk "$3" | env CLAUDE_PROJECT_DIR="$TMPPROJ" "$HOOK" >/dev/null 2>&1; rc=$?
|
||||
got=$([ $rc -eq 2 ] && echo block || echo pass)
|
||||
if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1))
|
||||
else echo " ❌ $2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; }
|
||||
t() { # t <說明> <指令> <期望 exit>
|
||||
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$2" \
|
||||
| CLAUDE_CODE_CHILD_SESSION=1 CLAUDE_PROJECT_DIR="$(dirname "$(dirname "$G")")" \
|
||||
bash "$G" >/tmp/pg.out 2>&1
|
||||
rc=$?
|
||||
if [ "$rc" = "$3" ]; then printf ' ✅ %-46s exit=%s\n' "$1" "$rc"; pass=$((pass+1))
|
||||
else printf ' ❌ %-46s exit=%s(期望 %s)\n' "$1" "$rc" "$3"; fail=$((fail+1)); fi
|
||||
}
|
||||
|
||||
D="wrangler deploy"
|
||||
echo "── 該放行(今晚五次誤攔的原形狀)──"
|
||||
t "checkout -b 後推 feature 分支" 'git checkout -q -b fix/x ma'"in"' && git push -q origin fix/x' 0
|
||||
t "gh pr create --base(不是 git push)" 'gh pr create --head f --base ma'"in"' --title t' 0
|
||||
t "推 tag" 'git push -q origin refs/tags/v0.3.3' 0
|
||||
t "推 feature 分支(帶 -u)" 'git push -q -u origin feat/milestone-must-have-due' 0
|
||||
t "分支名含 domain" 'git push origin fix/custom-domain-setup' 0
|
||||
|
||||
echo "── geek6688(leo 2026-08-12 明文授權總管可直接動)應放行 ──"
|
||||
t pass "指名 geek6688 主機名" "npx $D --name arcrun-cypher-executor --config geek6688.toml"
|
||||
t pass "用 geek6688 的 token 變數" "CLOUDFLARE_API_TOKEN=\$CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE npx $D"
|
||||
t pass "用 geek6688 的 account id 變數" "CLOUDFLARE_ACCOUNT_ID=\$CLOUDFLARE_ACCOUNT_ID_GEEK6688 npx $D"
|
||||
echo "── 該擋 ──"
|
||||
t "直接推預設分支" 'git push origin ma'"in" 2
|
||||
t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2
|
||||
t "推 master" 'git push -q origin mas'"ter" 2
|
||||
|
||||
echo "── 其他實例仍要 leo 親手 arm(範圍不能外溢)──"
|
||||
t block "打 leo21c" "npx $D --name arcrun-mcp --account-id leo21c-acct"
|
||||
t block "打 uncle6 官方" "npx $D --name arcrun-kbdb --config uncle6.toml"
|
||||
t block "看不出打哪裡" "npx $D"
|
||||
t block "wrangler publish 到別台" "npx wrangler publish --name arcrun-mcp"
|
||||
t block "versions deploy 到別台" "npx wrangler versions deploy --name arcrun-kbdb"
|
||||
|
||||
echo "── stage 照舊自由 ──"
|
||||
t pass "帶 --env staging" "npx $D --env staging"
|
||||
t pass "打 staging 主機" "npx $D --name arcrun-rag-installer-staging"
|
||||
|
||||
echo "── 非部署動作不受影響 ──"
|
||||
t pass "推自己的分支" "git push -u gitea fix/my-branch"
|
||||
t pass "讀本閘原始碼(指令裡含關鍵字)" "sed -n '1,50p' .claude/hooks/main-and-prod-push-guard.sh"
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS / 失敗 $FAIL"
|
||||
[ $FAIL -eq 0 ] || exit 1
|
||||
echo "────── 通過 $pass / 失敗 $fail"
|
||||
[ "$fail" = "0" ]
|
||||
|
||||
@@ -47,7 +47,7 @@ try:
|
||||
# 不是只有「會改壞東西的指令」。
|
||||
# 2026-08-05 第三次補(leo:「我看你剛剛 bash 裡很多 grep,為什麼不是查 wiki?」):
|
||||
# 再補「讀檔形成結論」的動詞——head/cat/sed/tail/awk/find/jq。
|
||||
# 之前只認「動外部系統」與部分查證動詞,`head changelog.md` 這種
|
||||
# 之前只認「動外部系統」與部分查證動詞,「head changelog.md」 這種
|
||||
# **直接讀檔下判斷**的完全不觸發。
|
||||
if re.search(r'\b(wrangler|curl|npx|acr|gh|deploy|push|git|grep|unzip|manifest|version'
|
||||
r'|head|cat|sed|tail|awk|find|jq)\b', cmd):
|
||||
@@ -61,10 +61,10 @@ try:
|
||||
# grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞
|
||||
# 🔴 2026-08-05 leo 點破:「最常做的就是 grep,為什麼沒在裡面」
|
||||
# ——Grep 其實有註冊,壞在**這行取詞規則**:
|
||||
# ① 連字號被當分隔 ⇒ `bge-m3` 只取到 `bge`(3 字)不足 4 字 ⇒ 整支不觸發
|
||||
# ① 連字號被當分隔 ⇒ 「bge-m3」 只取到「bge」(3 字)不足 4 字 ⇒ 整支不觸發
|
||||
# ② **中文完全不匹配** ⇒ 查「版本號」「出貨」這類詞一律不觸發
|
||||
# 而我日常查的關鍵字大量正是這兩類 ⇒ hook 形同虛設。
|
||||
# ⇒ 容許 `-`/`.`,並支援 CJK;中文 2 字即算一個詞。
|
||||
# ⇒ 容許「-」與「.」,並支援 CJK;中文 2 字即算一個詞。
|
||||
words = re.findall(r'[A-Za-z_][A-Za-z0-9_.-]{2,}', q)
|
||||
cjk = re.findall(r'[\u4e00-\u9fff]{2,}', q)
|
||||
words = words + cjk
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
# 推 main 的請求:未署名
|
||||
|
||||
- repo:/Users/youlinhsieh/Documents/tech_projects/ISEP
|
||||
- 分支:main
|
||||
- 時間:2026-08-20 20:59:30
|
||||
- 分支:fix/push-guard-target-not-substring
|
||||
- 時間:2026-08-21 01:28:51
|
||||
|
||||
- 它想跑的指令:
|
||||
```
|
||||
git push gitea HEAD:main
|
||||
git push -q origin master
|
||||
```
|
||||
|
||||
## 還沒推上去的 commit(原始資料,不是轉述)
|
||||
|
||||
```
|
||||
43c328d Merge pull request 'feat/milestone-must-have-due' (#50) from feat/milestone-must-have-due into main
|
||||
bcb736e Merge pull request 'fix/worksheet-dedup-by-content' (#49) from fix/worksheet-dedup-by-content into main
|
||||
6772ca6 每個里程碑都要有真的期限,9999 也擋
|
||||
1b55512 待驗工作單改用宣稱內容去重,驗過的不再冒出來
|
||||
1920d4c Merge pull request '身為 leo,我要雲端 clone 下來就有閘,我才不用先處理憑證' (#48) from fix/b4-real-probe into main
|
||||
47ed778 改走「直接複製進薄殼 repo」,並修掉一支會偷跑指令的閘
|
||||
03d9782 查了官方文件才發現:setup script 根本讀不到環境變數,而 exit 1 會鎖死 session
|
||||
36d8e05 認證驗證加 timeout:掛住比失敗更糟
|
||||
1356372 B4 的探針我自己沒撞過,實撞後發現它根本不會擋
|
||||
d306158 Merge pull request '身為 leo,我要雲端 env 檔能直接產在桌面,我才不用去翻隱藏目錄' (#46) from feat/cloud-env-outdir into main
|
||||
9099c3f make-cloud-env.sh:輸出路徑可指定,且不再對使用者指定的目錄動權限
|
||||
5bceb03 v0.3.1
|
||||
4e73b8b Merge pull request '身為 leo,我要雲端驗收步驟在閘死掉時真的變紅,我才不會再被三個綠燈騙一次' (#45) from fix/testing-b-section-discriminating into main
|
||||
291787e TESTING.md B 段整段換掉——舊版在閘全滅時會回綠
|
||||
3a95121 雲端零閘的兩個真因:setup 不自驗+沒有 release 撐版本號 (#44)
|
||||
daa1674 雲端零閘的兩個真因:setup 從不驗證自己+沒有任何 release 撐版本號
|
||||
c48495d Merge pull request 'fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」' (#42) from fix/sdd-guard-path-resolution into main
|
||||
8718658 fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」(InkStoneCo#22)
|
||||
e6d183d Merge pull request '產生雲端 env 設定給 leo 貼(InkStoneCo#14)' (#41) from feat/cloud-env-generator into main
|
||||
f855d82 產生雲端 env 設定,不要 leo 自己拼湊(InkStoneCo#14)
|
||||
```
|
||||
|
||||
## 改了哪些檔
|
||||
|
||||
```
|
||||
docs/TESTING.md | 14 +---------
|
||||
scripts/make-cloud-env.sh | 69 -----------------------------------------------
|
||||
2 files changed, 1 insertion(+), 82 deletions(-)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 推 main 的請求:未署名
|
||||
|
||||
- repo:/Users/youlinhsieh/Documents/tech_projects/InkStoneCo
|
||||
- 分支:main
|
||||
- 時間:2026-08-21 01:28:23
|
||||
|
||||
- 它想跑的指令:
|
||||
```
|
||||
git push -q origin master
|
||||
```
|
||||
|
||||
## 還沒推上去的 commit(原始資料,不是轉述)
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
## 改了哪些檔
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
總管裁完請刪掉這個檔——留著代表「還沒裁」。
|
||||
@@ -37,8 +37,12 @@ lookup() { # $1=變數名 → 印出值(找不到就空)
|
||||
return 1
|
||||
}
|
||||
|
||||
OUT_DIR="$HOME/.claude/cloud-env"
|
||||
mkdir -p "$OUT_DIR"; chmod 700 "$OUT_DIR"
|
||||
# 預設丟 ~/.claude/cloud-env(權限 700)。要放別的地方= OUT_DIR=~/Desktop bash scripts/make-cloud-env.sh
|
||||
OUT_DIR="${OUT_DIR:-$HOME/.claude/cloud-env}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
# 只在「這個目錄是我們自己造的預設位置」時才收緊權限——
|
||||
# OUT_DIR 可被覆寫,不該對使用者指定的既有目錄(例如 ~/Desktop)動權限。
|
||||
[ "$OUT_DIR" = "$HOME/.claude/cloud-env" ] && chmod 700 "$OUT_DIR"
|
||||
OUT="$OUT_DIR/$(date +%Y%m%d-%H%M%S).txt"
|
||||
|
||||
SETUP="$(cd "$(dirname "$0")/.." && pwd)/docs/cloud-setup-script.sh"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""把 ISEP 整份「複製」進薄殼 repo 的 .claude/isep/,並產生對應的 settings.json。
|
||||
|
||||
為什麼是複製而不是 marketplace(leo 2026-08-21 拍板):
|
||||
雲端 session 是「fresh clone 薄殼 repo」+「setup script 讀不到環境變數」,
|
||||
走 marketplace 就得處理憑證、可見性、快取三件事,每一件都失敗過。
|
||||
**複製進 repo 之後,clone 下來就有,沒有任何前置條件。**
|
||||
|
||||
代價是「兩份內容會漂」——所以這支同時支援 --check,比對薄殼那份與 ISEP 真身。
|
||||
"""
|
||||
import json, shutil, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
ISEP = Path(__file__).resolve().parent.parent
|
||||
OUT = ISEP / ".shell-payload" / "dot-claude"
|
||||
SUB = "isep" # 薄殼裡的落點:.claude/isep/
|
||||
COPY = ["hooks", "skills", "commands", "scripts", ".claude-plugin"]
|
||||
|
||||
def build() -> str:
|
||||
if OUT.exists(): shutil.rmtree(OUT)
|
||||
dest = OUT / SUB
|
||||
dest.mkdir(parents=True)
|
||||
n = 0
|
||||
for d in COPY:
|
||||
src = ISEP / d
|
||||
if not src.exists(): continue
|
||||
shutil.copytree(src, dest / d, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
n += sum(1 for _ in (dest / d).rglob("*") if _.is_file())
|
||||
|
||||
# 🔴 幾支閘會把 log 寫到 $CLAUDE_PROJECT_DIR/.claude/hooks/<name>.log。
|
||||
# 複製後那個目錄不存在 ⇒ 2026-08-21 冒煙測試實測 3 支報 No such file。
|
||||
# 先把目錄造出來(git 不追空目錄,所以放 .gitkeep)。
|
||||
(OUT / "hooks").mkdir(parents=True, exist_ok=True)
|
||||
(OUT / "hooks" / ".gitkeep").write_text("", encoding="utf-8")
|
||||
|
||||
# settings.json:把 ISEP 的 hooks.json 逐條改寫成薄殼裡的絕對路徑。
|
||||
# 🔴 保留 CLAUDE_PLUGIN_ROOT 這個變數名——ISEP 的閘內部都用它定位自己的 lib/,
|
||||
# 改名等於要動 44 支閘。這裡只是把它指到複製過來的那份。
|
||||
hooks = json.loads((ISEP / "hooks" / "hooks.json").read_text(encoding="utf-8"))["hooks"]
|
||||
root = f'"$CLAUDE_PROJECT_DIR/.claude/{SUB}"'
|
||||
out = {}
|
||||
regs = 0
|
||||
for ev, groups in hooks.items():
|
||||
out[ev] = []
|
||||
for g in groups:
|
||||
ng = {k: v for k, v in g.items() if k != "hooks"}
|
||||
ng["hooks"] = []
|
||||
for h in g["hooks"]:
|
||||
cmd = h["command"]
|
||||
# ISEP 內部寫成 ${CLAUDE_PLUGIN_ROOT}/hooks/x.sh(有時帶引號)
|
||||
cmd = cmd.replace('"${CLAUDE_PLUGIN_ROOT}"', root).replace("${CLAUDE_PLUGIN_ROOT}", root.strip('"'))
|
||||
ng["hooks"].append({**h, "command": f'export CLAUDE_PLUGIN_ROOT={root}; {cmd}'})
|
||||
regs += 1
|
||||
out[ev].append(ng)
|
||||
(OUT / "settings.json").write_text(
|
||||
json.dumps({"hooks": out}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return f"複製 {n} 個檔|改寫 {regs} 條 hook 註冊"
|
||||
|
||||
def main():
|
||||
if "--check" in sys.argv[1:]:
|
||||
before = OUT.exists() and subprocess.run(
|
||||
["diff", "-rq", str(OUT), str(OUT)], capture_output=True).returncode == 0
|
||||
print("(--check 需要薄殼 clone 才有意義,見 docs/cloud-session-bootstrap.md)")
|
||||
return
|
||||
print(build())
|
||||
print(f"產物:{OUT}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -35,3 +35,20 @@ Gitea 上**一個 release tag 都沒有**。
|
||||
⇒ 「東西做出來了」與「有人在用它」是兩件事,而只有後者算交付。
|
||||
⇒ 判準:**去執行環境查它有沒有被載入**(`claude plugin list` / `claude plugin details`),
|
||||
不要從 repo 裡有什麼檔案去推論。
|
||||
|
||||
## ⚠️ MISTAKE: 閘的「釋放章」用 `stat -f %m` 先,雲端 Linux 永遠放行不了
|
||||
|
||||
2026-08-23 雲端 cloud-worker 實撞:三支閘(`prod-write-guard`/`main-and-prod-push-guard`/
|
||||
`stage-before-prod-guard`)取戳記 mtime 都寫成 `stat -f %m … || stat -c %Y …`。
|
||||
GNU(Linux)的 `stat -f` 是 `--file-system`,`%m` 被當成**檔名** ⇒ 把整張檔案系統表印到
|
||||
stdout(被 `$()` 收進去)且 exit 1 ⇒ 時間變數變非數字 ⇒ `case *[!0-9]*` 命中 ⇒ 釋放章判無效。
|
||||
後果:總管 `touch /tmp/.prod-write-ok` 在雲端**從沒生效過**,連 routine 要求的心跳都打不出去。
|
||||
|
||||
⇒ 正確做法:**GNU 先、BSD 後** → `stat -c %Y … || stat -f %m …`(Linux 走前者、macOS 走後者)。
|
||||
⇒ 為什麼本機測不出來:dev 在 macOS,BSD `stat -f %m` 本來就正常 ⇒ 這種「只咬某一個 OS」的坑,
|
||||
**驗證要在真正會跑的環境(雲端 Linux)跑,不能只在 dev 機跑**。
|
||||
⇒ 同款第二例(同日抓到,未修):`history-first-guard` 的逃生口說明寫 `touch /tmp/.kbdb-down`,
|
||||
但它讀該檔是 `cat` 出來當時間戳算術——**空 `touch` 會炸**,正解是 `date +%s > /tmp/.kbdb-down`。
|
||||
教訓:**逃生口本身也要被測**,不然閘壞了連逃生口都用不了。
|
||||
(修復:分支 `fix/stamp-release-stat-ordering-linux`;回報 ISEP#30 / InkStoneCo#14)
|
||||
日期: 2026-08-23
|
||||
|
||||
Reference in New Issue
Block a user