diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 300c5df..d1a1ddf 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:51 支機械閘(64 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(84 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、49 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "author": { "name": "Leo", "url": "https://uncle6.me" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9fb8449..ce1c03d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(84 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、49 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.21.0", + "version": "0.22.0", "keywords": [ "inkstone", "guardrails", diff --git a/README.md b/README.md index 3c5e55d..68c9ecd 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ |---|---|---| | `hooks/` | 61 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 84 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | | `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | -| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | -| `skills/` | 2 支 | | +| `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … | +| `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` | | `scripts/` | 49 支 | `ticket`/`roster`/`isep-nag`/`wiki-compress`/`github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) | > 🔴 這五個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推 @@ -70,6 +70,13 @@ hook 一律用官方的 `${CLAUDE_PLUGIN_ROOT}`,**不准寫死絕對路徑、 🔴 **只改這裡,然後兩邊 `/plugin update`。** 不要再改 `InkStoneCo/.claude/hooks/`——那個目錄退場中。 +🔴 **但有些檔案的內容不是在這裡寫的**(`inkstone/ISEP#122`): +`skills/`/`commands/`/`agents/` 底下有幾個檔案在 InkStoneCo 也有一份, +**誰是真相源、往哪個方向修,一律查 `docs/file-ownership.tsv`**,不要憑「ISEP 一定比較新」動手 +——2026-09-02 實查,兩個方向都真的發生過(`ship-check` 是那邊新,`sdd-check` 是這邊新)。 +改完那類檔案要把 commit/sha256 兩欄一起更新;沒更新的話, +`isep-presence-beacon.sh` 會在下一個 session 開場當著大家的面說它對不上。 + ## 這些閘各自在管什麼 **不用點開任何 `.sh`**——`docs/hooks-inventory.md` 一支一行白話,按「你會在什麼時候撞到它」分組。 diff --git a/docs/TESTING.md b/docs/TESTING.md index 7b3f2de..2ab33b2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -330,11 +330,19 @@ bash hooks/tests/unpushed-police.test.sh - B 群(⑦—⑩,該報)任一紅 ⇒ 為了不吵而改成放行了,那是把閘關掉不是修好 - 特別看 ④:問不到遠端(離線)**不准當成「你沒推」** -### A18 — 信標會講出雲端會壞掉的那三件事:14 條 +### A18 — 信標會講出雲端會壞掉的那幾件事:26 條 ``` bash hooks/tests/isep-presence-beacon.test.sh ``` -**該看到**:`通過 14 條,失敗 0 條`。全離線。 +**該看到**:`通過 26 條,失敗 0 條`。全離線。 + +> 📌 `inkstone/ISEP#122` 從 14 條加到 26 條:信標原本只掃 `scripts/`, +> 而**真正會自動載入的東西**(skill/command/agent)同樣兩邊各有一份。 +> 實查:那 9 個檔案在 `0.1.0`(c263866)複製過來之後**一次都沒再同步**, +> 到 2026-09-02 已經分家兩個、而且**方向相反**—— +> `skills/ship-check/SKILL.md` 是 InkStoneCo 那邊新,`commands/sdd-check.md` 是 ISEP 這邊新。 +> ⇒ 所以新增的不只是「有沒有掃到」,還有「**說不說得出真相源是哪一份**」 +> (`docs/file-ownership.tsv`),以及**雲端沒有專案那一份可比時**改用 sha256 單邊驗。 **它在守什麼**(inkstone/ISEP#90 ②③):信標原本只證明「有一份 plugin 載入了」, 不證明「載入的是哪一份」——08-27 雲端載 0.3.9、main 0.9.0,差 7 個 release, @@ -346,6 +354,71 @@ bash hooks/tests/isep-presence-beacon.test.sh - ⑤⑦ 紅 ⇒ 舊複本遮蔽正門抓不到(`InkStoneCo/scripts/ticket` 那件) - ⑥ 紅 ⇒ **誤攔**:同步過的複本被念,人就學會忽略它 - ⑪ 紅 ⇒ 內層迴圈變數撞名的回歸(同一個 `.claude` 下第二個殘骸會被靜靜跳過) +- ⑮⑱⑲⑳ 任一紅 ⇒ 自動載入的分身抓不到(`ship-check` 那件會再發生一次) +- ⑰ 紅 ⇒ **誤攔**:同步過的複本被念,人就學會忽略它(跟 ⑥ 同一條線) +- ㉔ 紅 ⇒ 雲端唯一還作數的那個檢查失效(薄殼沒有 InkStoneCo 可以比) +- ㉕ 紅 ⇒ **誤攔**:還沒登記 sha256 的檔案被當成「被改過」 + +### A29 — 閘印出來的逃生門,照著打真的過得去:10 條 +> 📌 編號跳過 A27/A28:那兩格被同一張票的 PR `inkstone/ISEP#124` 佔著(還沒併)。 +``` +bash hooks/tests/history-first-guard.test.sh +``` +**該看到**:`通過 10 條,失敗 0 條`。全離線,全程用 `KBDB_STAMP_DIR` 指到 TMP, +**不碰這台機器真正的 `/tmp/.kbdb-*` 戳記**(清掉別人的戳記=把閘弄成隨機的)。 + +**它在守什麼**(`inkstone/ISEP#122`,2026-09-02 實撞):`history-first-guard.sh` 印的 +`🚪 KBDB 真的連不上:touch /tmp/.kbdb-down 後重送` 是**假的**—— +`touch` 造出來的是空檔,而那支閘讀的是**檔案內容**當時戳,空字串被當成 0 +⇒ 「距今 17 億秒」⇒ 永遠不新鮮 ⇒ **照著訊息打完,被一模一樣地擋第二次**。 + +🔴 這跟本票 comment 6071、`inkstone/ISEP#125` 是同一句話: +**閘印出來的下一步,沒有人實際照著打過一次。** + +**失敗**: +- ④ 紅 ⇒ 逃生門又是假的(這是本支唯一分辨得出新舊版的那一格,見檔頭) +- ⑦ 紅 ⇒ 逃生門變成永久後門(一次 `touch` 就免疫,過期不算數這件事沒了) +- ⑧⑨⑩ 任一紅 ⇒ **誤攔**:文件/新檔/測試檔本來就不該被這支碰 + +### A30 — 測試沙盒傳錯參數時要在**複製之前**收手:10 條 +``` +bash hooks/tests/hook-sandbox.test.sh +``` +**該看到**:`通過 10 條,失敗 0 條`。全離線。 + +**它在守什麼**(`inkstone/ISEP#122`,2026-09-02 實撞,**兩次**): +`hooks/tests/lib/hook-sandbox.sh` 的 `hook_sandbox ` 舊版**不驗參數**, +直接 `cp -R "$(dirname "$1")"`。順手把 repo 根目錄傳進去時: + +``` +$ bash hooks/tests/main-and-prod-push-guard.test.sh "$PWD" + dirname → ~/Documents/tech_projects ← 上一層,不是 hooks/ + cp -R → 整個 tech_projects(所有 repo、所有 worktree)搬進 mktemp + 實測 一次 13 GB+一次 10 GB,磁碟可用從 25 GB 掉到 462 MB + 印出來的 ❌ 沙盒建不起來 ← 只有這一句 +``` + +🔴 **它沒說參數傳錯,也沒說它已經把磁碟寫滿了。** +判準用「要求某個東西在場」(`$1` 要是真的檔案;上一層要叫 `hooks`), +不是關鍵字比對——兩條任一不成立就在 `mktemp` 之前 `return 1`。 + +📌 **正確用法**(兩支需要傳參數的測試,`docs/TESTING.md` 之前沒寫過): +``` +bash hooks/tests/main-and-prod-push-guard.test.sh hooks/main-and-prod-push-guard.sh +bash hooks/tests/main-and-prod-push-guard-cross-repo.test.sh hooks/main-and-prod-push-guard.sh +bash hooks/tests/prod-write-guard.test.sh hooks/prod-write-guard.sh +bash hooks/tests/stage-before-prod-guard.test.sh hooks/stage-before-prod-guard.sh +bash hooks/tests/gitea-arm-check.test.sh . +``` +**不傳的後果不是報錯,是假綠**:`prod-write-guard.test.sh` 的 `HOOK="$1"` 空掉時 +每一條都執行空指令回 0 ⇒ 「該擋」全部變成「實得 pass」,**19 條假紅** +(同一支傳對參數是 `通過 37 / 失敗 0`)。不必自己傳參數的包裝在 +`scripts/test-main-and-prod-push-guard.sh`。 + +**失敗**: +- ①③⑤⑥ 任一紅 ⇒ 收手收得太晚,暫存區已經開始長東西(磁碟風險回來了) +- ② 紅 ⇒ 訊息沒給出走得通的那一行(本票整張票在講的就是這件事) +- ⑧⑨⑩ 任一紅 ⇒ **誤攔/複製錯**:正常用法被弄壞,或複本裡混進了 `hooks/` 以外的東西 ### A19 — 下游做完時頂層跟著關:49 條 ``` bash scripts/test-ticket-handoff-writeback.sh diff --git a/docs/file-ownership.tsv b/docs/file-ownership.tsv new file mode 100644 index 0000000..139c51b --- /dev/null +++ b/docs/file-ownership.tsv @@ -0,0 +1,30 @@ +# file-ownership.tsv — 同一個檔案兩邊都有時,**哪一份是真相源**(inkstone/ISEP#122) +# +# 為什麼要有這張表(2026-09-02 實查,兩個方向都真的發生過): +# skills/ship-check/SKILL.md InkStoneCo 那份前進到 651 行,ISEP 停在 595 行 +# commands/sdd-check.md ISEP 那份前進到 81 行,InkStoneCo 停在 65 行 +# (InkStoneCo 那份還在教已經退役的「唯一 active SDD」) +# ⇒ 「ISEP 一定比較新」是錯的,「InkStoneCo 一定比較新」也是錯的。 +# 兩邊都會被就地編輯,所以歸屬**不能靠人記得**,要寫下來、而且要有東西去比對。 +# +# 誰在用這張表:`hooks/lib/beacon_report.py`(SessionStart 信標,只報告、不擋人) +# ① 兩邊內容不同時 → 照這張表講出「真相源是哪一份、該往哪個方向修」 +# ② 表上有 sha256 時 → 順便驗 ISEP 這份有沒有被就地改過(雲端沒有 InkStoneCo +# 可以比,這一格是那時唯一還能作數的檢查) +# +# 規約三條: +# 1. **只有內容會分家的檔案才需要一列。** 兩邊一模一樣時不必登記,信標也不會出聲。 +# 2. 真相源是 `inkstone/InkStoneCo` ⇒ **內容要改就改在那裡**,改完把檔案原樣搬過來、 +# 更新 ④⑤ 兩欄、升版。**不要在 ISEP 這邊改內容**(改了兩邊就各自演化,就是本表要解的病)。 +# 3. 真相源是 `inkstone/ISEP` ⇒ 專案那份是退場中的舊複本,該被同步或刪掉; +# ③④⑤ 三欄填 `-`。 +# +# 欄位(TAB 分隔,5 欄): +# ① plugin 內路徑 +# ② 真相源 repo +# ③ 真相源在那個 repo 裡的路徑(真相源就是 ISEP 時填 `-`) +# ④ 這一份是從哪顆 commit 搬過來的(同上,填 `-`) +# ⑤ 搬過來的當下 sha256(同上,填 `-`) +# +skills/ship-check/SKILL.md inkstone/InkStoneCo .claude/skills/ship-check/SKILL.md bd52fa3a508cbfb29755ddc24923c6fea8c3fbf7 847967830007e804a16dec9adfc5d4120c233445649d4d5cdd34b04550fb3270 +commands/sdd-check.md inkstone/ISEP - - - diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 52d2246..778d006 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -203,7 +203,7 @@ | 閘名 | 對你意味著什麼 | 動作 | |---|---|---| -| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。 | 📝 記錄(context 注入) | +| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。它另外會報四件「這一份是不是還有效」:①這一份落後 ISEP main 幾版 ②專案裡有沒有內容不同的同名**腳本**②b 專案裡有沒有內容不同的同名**skill/command/agent**——這些是**自動載入**的,載到舊的那份不會有任何症狀,只會安靜地教錯的東西(`docs/file-ownership.tsv` 說得出每一組的真相源是哪一份、往哪個方向修)②c 本身的內容跟那張歸屬表對不對得上(雲端沒有專案那一份可比時,這是唯一還作數的檢查)③工作區有沒有已退役機制留下的產物。**全部只講不擋。** | 📝 記錄(context 注入) | | `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) | | `scripts/mainline refresh` | (不是閘,是腳本)對話一開始把**現在的主線**那條 milestone 的進度與期限更新一次,好讓每回合眼前那一行講的是今天的數字。拿不到 Gitea 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。 | 📝 記錄 | | `overdue-nag-guard.sh` | 對話一開始就去 Gitea 撈**沒有人會叫的事**:逾期的 milestone、掛著等你的票(標「等了幾天」)、標著「有人在做」卻好幾天沒動的票;撈完用白話講出來,**有事就發 Telegram 給你**。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) | @@ -252,7 +252,7 @@ | 閘名 | 對你意味著什麼 | 動作 | |---|---|---| -| `history-first-guard.sh` | AI 要改一個舊檔案之前,先把這個檔案過去被改過幾次、被誰在什麼情況下改過的紀錄攤在它眼前,逼它回答「這是不是已經修過的老問題」再動手。 | 🛑 擋 | +| `history-first-guard.sh` | AI 要改一個舊檔案之前,先把這個檔案過去被改過幾次、被誰在什麼情況下改過的紀錄攤在它眼前,逼它回答「這是不是已經修過的老問題」再動手。前面還有第 0 道:這一小時內有沒有問過 KBDB。**KBDB 連不上時 `touch /tmp/.kbdb-down` 就放行**——這條逃生門 2026-09-02 之前是壞的(`touch` 造的是空檔,而它讀的是檔案內容)**照著訊息打完還是被擋**,`inkstone/ISEP#122` 修好並補了迴歸測試。 | 🛑 擋 | --- diff --git a/hooks/history-first-guard.sh b/hooks/history-first-guard.sh index b86fb9f..6deaf89 100755 --- a/hooks/history-first-guard.sh +++ b/hooks/history-first-guard.sh @@ -56,15 +56,32 @@ STAMP="/tmp/.history-guard-$(printf '%s' "$FILE_PATH" | shasum | cut -c1-12)" # # 時戳由 kbdb-asked-stamp.sh(PostToolUse,matcher 對 kbdb_* 工具)寫下。 # 逃生口:KBDB 真的連不上時 `touch /tmp/.kbdb-down`——放行但留痕,且回覆裡要說明。 -KBDB_STAMP=/tmp/.kbdb-asked -KBDB_DOWN=/tmp/.kbdb-down +# +# 🔴 **逃生門要真的走得通**(inkstone/ISEP#122,2026-09-02 實撞,本檔第二次修): +# 上面那句訊息叫人 `touch /tmp/.kbdb-down`,而 `touch` 造出來的是**空檔** +# ⇒ 舊版 `t=$(cat "$f")` 讀到空字串 ⇒ 算式當成 0 ⇒ 「距今 17 億秒」 +# ⇒ 永遠不新鮮 ⇒ **照著訊息打完,還是被擋**。 +# 實測(2026-09-02,KBDB 兩支工具都回 Connection closed 時): +# $ touch /tmp/.kbdb-down → 再送同一個編輯 → 一模一樣的擋,訊息一字不差 +# ⇒ 那行逃生門是印出來好看的,**沒有人照著打過一次** +# (同族:ISEP#125 worktree 閘印的逃生門原文照打 rc=2;本票 comment 6071)。 +# +# 改法:**內容不是數字就用檔案的 mtime**——`touch` 做的正是更新 mtime, +# 所以訊息裡那一行從此真的走得通,而 `kbdb-asked-stamp.sh` 寫數字的舊格式照樣相容。 +# 路徑用 `KBDB_STAMP_DIR` 可覆寫,**只為了讓迴歸測試不去動這台機器真正的戳記** +# (測試把別的 session 的戳記清掉=把閘弄成隨機的)。 +KBDB_STAMP="${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked" +KBDB_DOWN="${KBDB_STAMP_DIR:-/tmp}/.kbdb-down" kbdb_fresh=0 now=$(date +%s) for f in "$KBDB_STAMP" "$KBDB_DOWN"; do - if [ -f "$f" ]; then - t=$(cat "$f" 2>/dev/null || echo 0) - [ $((now - t)) -lt 3600 ] && kbdb_fresh=1 - fi + [ -f "$f" ] || continue + t=$(cat "$f" 2>/dev/null || true) + case "$t" in + ''|*[!0-9]*) t=$(date -r "$f" +%s 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0) ;; + esac + [ "${t:-0}" -gt 0 ] || continue + [ $((now - t)) -lt 3600 ] && kbdb_fresh=1 done if [ "$kbdb_fresh" -eq 0 ]; then cat >&2 <<'KEOF' diff --git a/hooks/kbdb-asked-stamp.sh b/hooks/kbdb-asked-stamp.sh index 9c664b8..c14716a 100755 --- a/hooks/kbdb-asked-stamp.sh +++ b/hooks/kbdb-asked-stamp.sh @@ -16,5 +16,6 @@ # KBDB(一次看所有庫,最便宜)→ 該 repo 的 wiki/tasks → git log -S → 讀源碼(最貴, # 只看得到「現在長怎樣」,看不到「為什麼變成這樣」) set -eu -date +%s > /tmp/.kbdb-asked +# 路徑跟 history-first-guard.sh 用同一個覆寫變數,否則測試會兩邊指到不同地方 +date +%s > "${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked" exit 0 diff --git a/hooks/lib/beacon_report.py b/hooks/lib/beacon_report.py index 57afb2b..46bde0c 100644 --- a/hooks/lib/beacon_report.py +++ b/hooks/lib/beacon_report.py @@ -1,6 +1,7 @@ -# ── 以下三格是 inkstone/ISEP#90 加的「這一份是不是還有效」自檢 ────────── +# ── 以下五格是「這一份是不是還有效」自檢 ───────────────────────────── +# ①②③ inkstone/ISEP#90 立;②b②c inkstone/ISEP#122 補(會自動載入的東西也有分身) # 都**只是報告,不擋任何事**(SessionStart 本來就不該擋),而且每一格拿不到答案就閉嘴。 -import json, os, re, subprocess, sys, time, urllib.request +import hashlib, json, os, re, subprocess, sys, time, urllib.request ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "") PROJ = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() @@ -103,6 +104,144 @@ if sh: "在雲端一律死在「拿不到 gitea token」。要嘛刪掉複本改叫 " "`\"$CLAUDE_PLUGIN_ROOT\"/scripts/<名字>`,要嘛把複本同步回 ISEP。" % "、".join(sh)) +# ══ ②b 會**自動載入**的東西(skill/command/agent)兩邊各有一份而內容不同 ══ +# +# 🔴 為什麼上面那一格抓不到(inkstone/ISEP#122,2026-09-02 實查): +# ② 只掃 `scripts/`。而真正會**自動載入**的東西住在別的目錄,同樣兩邊各有一份: +# plugin `skills/<名>/SKILL.md` ↔ 專案 `.claude/skills/<名>/SKILL.md` +# plugin `commands/<名>.md` ↔ 專案 `.claude/commands/<名>.md` +# plugin `agents/<名>.md` ↔ 專案 `.claude/agents/<名>.md` +# +# 實況:這 9 個檔案在 ISEP `0.1.0`(c263866)從 InkStoneCo 複製過來**一次**, +# 之後再也沒有同步過。到 2026-09-02 已經分家兩個,而且**方向相反**: +# `skills/ship-check/SKILL.md` InkStoneCo 651 行 / ISEP 595 行 +# `commands/sdd-check.md` ISEP 81 行 / InkStoneCo 65 行 +# (InkStoneCo 那份還在教 ISEP#91 已退役的「唯一 active SDD」) +# ⇒ 「ISEP 一定比較新」與「InkStoneCo 一定比較新」**兩句都是錯的**。 +# 兩份都會被就地編輯 ⇒ 歸屬只能寫下來(`docs/file-ownership.tsv`)並且要有東西去比。 +# +# 🔴 這一格比 ② 嚴重:腳本要有人叫它才會跑,**skill/command 是自動載入的**—— +# 載到舊的那份不會報錯、不會變慢、不會有任何症狀,只會安靜地教錯的東西。 +# (ship-check 就是這樣:舊描述在「我要發一篇部落格文章」時根本不會被觸發。) +# +# 判準與 ② 同一條:**檔名一樣而內容不同**才出聲,同步過的不吵。 +LOADABLE_PAIRS = [("skills", ".claude/skills", True), + ("commands", ".claude/commands", False), + ("agents", ".claude/agents", False)] + + +def ownership(): + """讀 docs/file-ownership.tsv → {plugin 內路徑: (真相源, 真相源路徑, commit, sha256)}""" + out = {} + try: + with open(os.path.join(ROOT, "docs", "file-ownership.tsv"), encoding="utf-8") as f: + for line in f: + if not line.strip() or line.lstrip().startswith("#"): + continue + c = line.rstrip("\n").split("\t") + if len(c) >= 2 and c[0].strip(): + out[c[0].strip()] = tuple((c[1:5] + ["-", "-", "-", "-"])[:4]) + except Exception: + pass + return out + + +OWN = ownership() + + +def loadable_pairs(): + """產生 (plugin 內相對路徑, 專案內相對路徑)——只列 plugin 真的有的那些""" + for pdir, jdir, nested in LOADABLE_PAIRS: + src = os.path.join(ROOT, pdir) + if not os.path.isdir(src): + continue + try: + names = sorted(os.listdir(src)) + except Exception: + continue + for name in names: + if nested: + if os.path.isfile(os.path.join(src, name, "SKILL.md")): + yield "%s/%s/SKILL.md" % (pdir, name), "%s/%s/SKILL.md" % (jdir, name) + elif name.endswith(".md") and os.path.isfile(os.path.join(src, name)): + yield "%s/%s" % (pdir, name), "%s/%s" % (jdir, name) + + +def whose(rel): + """真相源是誰 → 一句照著做就會走到的話。表上沒有就誠實說未定,不要猜。""" + row = OWN.get(rel) + if not row: + return "歸屬未定 ⇒ 兩份都看一眼,決定之後補一列進 docs/file-ownership.tsv" + src, spath, _commit, _sha = row + if src == "inkstone/ISEP": + return "真相源=ISEP 這一份 ⇒ 專案那份是舊複本,同步過去或刪掉它" + return ("真相源=%s:%s ⇒ 內容改在那裡,改完原樣搬進 ISEP、" + "更新 docs/file-ownership.tsv 的 commit/sha256、升版" % (src, spath or "?")) + + +def shadow_loadables(): + out = [] + roots = [PROJ, os.path.join(PROJ, "InkStoneCo")] + for prel, jrel in loadable_pairs(): + a = os.path.join(ROOT, prel) + try: + ab = open(a, "rb").read() + except Exception: + continue + for base in roots: + b = os.path.join(base, jrel) + try: + if os.path.realpath(b) == os.path.realpath(a) or not os.path.isfile(b): + continue + if open(b, "rb").read() != ab: + out.append("%s ↔ %s(%s)" + % (prel, os.path.relpath(b, PROJ), whose(prel))) + except Exception: + pass + return out + + +ld = shadow_loadables() +if ld: + notes.append( + "🟡 **會自動載入的東西兩邊各有一份,而且內容不同**:\n - %s\n" + " 自動載入的東西載到舊的那份**不會有任何症狀**——不報錯、不變慢," + "只會安靜地教錯的東西(`ship-check` 的舊描述在「我要發一篇部落格文章」時" + "根本不會被觸發)。歸屬表:docs/file-ownership.tsv(inkstone/ISEP#122)。" + % "\n - ".join(ld)) + +# ══ ②c ISEP 自己這一份,跟歸屬表記的那顆對不對得上 ══════════════════════ +# +# 🔴 為什麼要有這一格:②b 要「專案那一份」在磁碟上才比得出來, +# 而**雲端的 project dir 是薄殼,根本沒有那一份**(ISEP#90 記過同一件事)。 +# 這一格只比「檔案 vs 表上寫的 sha256」——離線、單邊、不依賴任何別的 repo, +# 是雲端唯一還作數的那個檢查。 +def off_manifest(): + out = [] + for rel, (src, spath, commit, sha) in OWN.items(): + if not sha or sha == "-": + continue + p = os.path.join(ROOT, rel) + if not os.path.isfile(p): + continue + try: + h = hashlib.sha256(open(p, "rb").read()).hexdigest() + except Exception: + continue + if h != sha: + out.append("%s(表記 %s 的 %s…,實際 %s…;真相源 %s:%s)" + % (rel, (commit or "?")[:7], sha[:12], h[:12], src, spath or "?")) + return out + + +om = off_manifest() +if om: + notes.append( + "🟡 **ISEP 這一份跟歸屬表對不上**:%s。兩種可能,兩種都要動手:" + "① 它被就地改過 ⇒ 內容要改在真相源那邊,這裡只放原樣搬過來的複本;" + "② 它是同步過的新內容、只是沒更新 docs/file-ownership.tsv 的 commit/sha256 ⇒ 補上那兩欄。" + % "、".join(om)) + # ══ ③ 工作區有沒有「已退役機制」留下的產物 ══════════════════════════════ # # 判準是機械的、而且會自己長大:**plugin 自己的原始碼裡有沒有任何一個字提到這個目錄**。 diff --git a/hooks/tests/history-first-guard.test.sh b/hooks/tests/history-first-guard.test.sh new file mode 100755 index 0000000..5885779 --- /dev/null +++ b/hooks/tests/history-first-guard.test.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# history-first-guard.sh 的迴歸測試 —— 重點在**逃生門真的走得通**(inkstone/ISEP#122) +# +# ── 這支在守什麼 ──────────────────────────────────────────────────── +# 2026-09-02 實撞:KBDB 兩支工具都回 `Connection closed`,照這支閘印出來的那行 +# `touch /tmp/.kbdb-down` +# 打完再送同一個編輯,**被一模一樣地擋第二次**——因為 `touch` 造的是空檔, +# 而舊版是 `cat` 檔案內容當時戳,空字串被當成 0 ⇒ 「距今 17 億秒」⇒ 永遠不新鮮。 +# +# 🔴 一道閘印出來的出路走不通,比沒有出路更糟:人照做了、還是被擋, +# 下一步就是去繞過這道閘(本 repo 的 ticket-api-bypass-guard.sh 檔頭記過同一課)。 +# 所以這支測試的第一等公民是**逃生門那一條**,不是「擋不擋得住」。 +# +# 🔴 **這支測試自己怎麼被證明有鑑別力**(不要只看它全綠): +# 把修好之前那一版拿來跑,第 ④ 條要紅。因為舊版沒有 KBDB_STAMP_DIR, +# 直接餵它會去讀這台機器真正的 /tmp 戳記而「剛好過關」,所以要先把路徑隔離: +# git show <修好前的 commit>:hooks/history-first-guard.sh > /tmp/old.sh +# sed 's#/tmp/\.kbdb-asked#${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked#; +# s#/tmp/\.kbdb-down#${KBDB_STAMP_DIR:-/tmp}/.kbdb-down#' /tmp/old.sh > /tmp/old-iso.sh +# bash hooks/tests/history-first-guard.test.sh /tmp/old-iso.sh +# 2026-09-02 實跑:舊版 9/10(④ 紅),修好後 10/10。 +# ⚠️ 第 ③ 條**兩版都是綠的**——它只驗離開碼 2,而過了第 0 道之後歷史警察照樣會擋。 +# 真正分辨得出來的是 ④「擋人的是誰」。這一格是本支最容易寫成假綠的地方。 +# +# 🔴 全程用 KBDB_STAMP_DIR 指到 TMP,**不碰這台機器真正的 /tmp/.kbdb-* 戳記** +# (把別的 session 的戳記清掉=把閘弄成隨機的)。不打網路。 +set -u +HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/history-first-guard.sh}" +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +export KBDB_STAMP_DIR="$TMP/stamps"; mkdir -p "$KBDB_STAMP_DIR" + +PASS=0; FAIL=0; N=0 + +# 造一個「有前科」的 code 檔:放在一個有 git 歷史的假 repo 裡 +REPO="$TMP/repo"; mkdir -p "$REPO" +( cd "$REPO" && git init -q && git config user.email t@t && git config user.name t \ + && printf 'echo old\n' > victim.sh && git add . && git commit -qm "第一版" ) >/dev/null 2>&1 +VICTIM="$REPO/victim.sh" + +# run <檔案> → 印出離開碼(送一份 Edit 的 payload 進去) +run() { + printf '{"tool_input":{"file_path":"%s"}}' "$1" \ + | CLAUDE_PROJECT_DIR="$REPO" bash "$HOOK" >/dev/null 2>"$TMP/err" + printf '%s' "$?" +} + +check() { # check <說明> <實際> <期望> + N=$((N+1)) + if [ "$2" = "$3" ]; then printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)) + else printf ' ❌ %s —— 期望離開碼 %s,實際 %s\n' "$1" "$3" "$2"; sed 's/^/ /' "$TMP/err" | head -6; FAIL=$((FAIL+1)); fi +} + +clean_stamps() { rm -f "$KBDB_STAMP_DIR/.kbdb-asked" "$KBDB_STAMP_DIR/.kbdb-down"; rm -f /tmp/.history-guard-*; } + +echo "── 第 0 道:問過 KBDB 了沒 ──────────────────────────────────" +clean_stamps +check "① 沒有任何戳記 → 擋" "$(run "$VICTIM")" 2 +grep -q "KBDB" "$TMP/err" && printf ' ✅ ② 擋下時訊息講得出是哪一道\n' && PASS=$((PASS+1)) || { printf ' ❌ ② 訊息沒提到 KBDB\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +echo "── 🔴 逃生門:照訊息打那一行,就要真的過得去 ──────────────────" +clean_stamps +touch "$KBDB_STAMP_DIR/.kbdb-down" # ← 訊息裡原文那一行做的事 +check "③ touch 出來的空檔(本票的實撞)→ 要放行過第 0 道" "$(run "$VICTIM")" 2 +# ↑ 期望仍是 2:過了第 0 道之後會被「歷史警察」擋(這個檔真的有前科), +# 所以要看訊息換人了沒——這是這支測試最容易寫錯的一格。 +grep -q "歷史警察" "$TMP/err" && printf ' ✅ ④ 擋人的換成歷史警察 ⇒ 第 0 道確實被逃生門放過了\n' && PASS=$((PASS+1)) || { printf ' ❌ ④ 還是卡在 KBDB 那一道 ⇒ 逃生門是假的\n'; sed 's/^/ /' "$TMP/err" | head -4; FAIL=$((FAIL+1)); }; N=$((N+1)) + +clean_stamps +date +%s > "$KBDB_STAMP_DIR/.kbdb-down" # 舊格式(檔案內容是時戳) +run "$VICTIM" >/dev/null +grep -q "歷史警察" "$TMP/err" && printf ' ✅ ⑤ 舊格式(內容寫時戳)照樣認得 ⇒ 沒有把既有的弄壞\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑤ 舊格式壞了\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +clean_stamps +date +%s > "$KBDB_STAMP_DIR/.kbdb-asked" +run "$VICTIM" >/dev/null +grep -q "歷史警察" "$TMP/err" && printf ' ✅ ⑥ 真的問過 KBDB(kbdb-asked-stamp.sh 寫的戳記)→ 過第 0 道\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑥ 問過了還被擋\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +echo "── 過期的戳記不算數(逃生門不能變成永久後門)──────────────────" +clean_stamps +touch -t "$(date -v-2H '+%Y%m%d%H%M' 2>/dev/null || date -d '2 hours ago' '+%Y%m%d%H%M')" "$KBDB_STAMP_DIR/.kbdb-down" +run "$VICTIM" >/dev/null +grep -q "KBDB" "$TMP/err" && printf ' ✅ ⑦ 兩小時前的空戳記 → 過期,回到擋\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑦ 過期的戳記還在放行 ⇒ 一次 touch 就永久免疫\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +echo "── 不該擋的(誤攔比漏擋嚴重)────────────────────────────────" +clean_stamps +printf '# doc\n' > "$REPO/readme.md" +check "⑧ 文件檔(.md)→ 一路放行,連第 0 道都不該碰" "$(run "$REPO/readme.md")" 0 +clean_stamps +check "⑨ 不存在的新檔 → 放行" "$(run "$REPO/brand-new.sh")" 0 +clean_stamps +mkdir -p "$REPO/tests"; printf 'echo t\n' > "$REPO/tests/a.sh" +check "⑩ 測試檔 → 放行" "$(run "$REPO/tests/a.sh")" 0 + +echo +printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N" +[ "$FAIL" = 0 ] || exit 1 diff --git a/hooks/tests/hook-sandbox.test.sh b/hooks/tests/hook-sandbox.test.sh new file mode 100755 index 0000000..76ebeb0 --- /dev/null +++ b/hooks/tests/hook-sandbox.test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# hooks/tests/lib/hook-sandbox.sh 的迴歸測試(inkstone/ISEP#122) +# +# 🔴 為什麼有這支(2026-09-02 實撞,證據在票上): +# `hook_sandbox` 的第一個參數是「那支 hook 的檔案路徑」,傳錯時舊版不檢查就 +# `cp -R "$(dirname "$1")"` ⇒ 複製的是上一層。傳 repo 根目錄進去, +# 複製的就是**整個 tech_projects**:兩次吃掉 23 GB,磁碟剩 462 MB, +# 而它只印一句「沙盒建不起來」——**沒說參數錯,也沒說它已經把磁碟寫滿**。 +# +# 所以這支測的不是「錯誤訊息好不好看」,是**收手的時機**: +# ④⑤ 兩條驗「複製之前就 return 1」——暫存區裡不准留下任何東西。 +# +# 用法:bash hooks/tests/hook-sandbox.test.sh +set -u +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$HERE/.." && pwd) # …/hooks +REPO=$(CDPATH= cd -- "$ROOT/.." && pwd) +PASS=0; FAIL=0 +ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); } +no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); } + +# 每一條都在自己的子殼裡跑:hook_sandbox 會設全域變數,不隔離會互相污染。 +run(){ # run <參數> → 印 "rc|沙盒路徑|訊息" + ( . "$HERE/lib/hook-sandbox.sh" + msg=$(hook_sandbox "$1" 2>&1); rc=$? + printf '%s|%s|%s' "$rc" "${HOOK_SANDBOX:-}" "$(printf '%s' "$msg" | tr '\n' ' ')" + ) +} + +echo "── 該收手(傳錯參數)──────────────────────────────────────" + +r=$(run "$REPO"); rc=${r%%|*}; rest=${r#*|}; sb=${rest%%|*}; msg=${rest#*|} +[ "$rc" = "1" ] && ok "① 傳 repo 根目錄(本票的實撞)→ return 1" \ + || no "① 傳 repo 根目錄(本票的實撞)→ return 1" "實得 rc=$rc" +case "$msg" in *"hooks/main-and-prod-push-guard.sh"*) + ok "② 訊息給得出一行**真的跑得起來**的用法" ;; + *) no "② 訊息給得出一行**真的跑得起來**的用法" "訊息:$msg" ;; +esac +if [ -z "$sb" ] || [ ! -d "$sb" ]; then ok "③ 收手在 mktemp 之前,暫存區沒有殘骸" +else no "③ 收手在 mktemp 之前,暫存區沒有殘骸" "留下了 $sb"; fi + +r=$(run ""); [ "${r%%|*}" = "1" ] && ok "④ 空參數 → return 1(不是拿空字串去 dirname)" \ + || no "④ 空參數 → return 1" "實得 rc=${r%%|*}" + +r=$(run "$REPO/scripts/ticket"); rc=${r%%|*}; rest=${r#*|}; sb=${rest%%|*} +if [ "$rc" = "1" ]; then ok "⑤ 檔案存在但上一層不是 hooks/ → 一樣收手" +else no "⑤ 檔案存在但上一層不是 hooks/ → 一樣收手" "實得 rc=$rc"; fi +if [ -z "$sb" ] || [ ! -d "$sb" ]; then ok "⑥ ⑤ 這條也沒把 scripts/ 複製出去" +else no "⑥ ⑤ 這條也沒把 scripts/ 複製出去" "留下了 $sb"; fi + +r=$(run "$ROOT/沒有這支.sh"); [ "${r%%|*}" = "1" ] && ok "⑦ 路徑不存在 → return 1" \ + || no "⑦ 路徑不存在 → return 1" "實得 rc=${r%%|*}" + +echo "── 不准把本來會過的弄壞(誤攔比漏擋嚴重)────────────────────" +( . "$HERE/lib/hook-sandbox.sh" + hook_sandbox "$ROOT/main-and-prod-push-guard.sh"; rc=$? + if [ "$rc" != "0" ]; then echo "RC=$rc"; exit 0; fi + [ -f "$HOOK_SANDBOX_HOOK" ] && echo "HOOKOK" + [ -d "$HOOK_SANDBOX/hooks/lib" ] && echo "LIBOK" + # 只複製 hooks/,不該把 repo 的其他目錄帶進來 + [ -d "$HOOK_SANDBOX/hooks/scripts" ] && echo "LEAK" + hook_sandbox_cleanup +) > /tmp/.hs-ok.$$ 2>&1 +grep -q HOOKOK /tmp/.hs-ok.$$ && ok "⑧ 正常用法照樣建得起來,複本裡有那支 hook" \ + || no "⑧ 正常用法照樣建得起來" "$(cat /tmp/.hs-ok.$$)" +grep -q LIBOK /tmp/.hs-ok.$$ && ok "⑨ 整個 hooks/(含 lib/)都在複本裡" \ + || no "⑨ 整個 hooks/(含 lib/)都在複本裡" "$(cat /tmp/.hs-ok.$$)" +grep -q LEAK /tmp/.hs-ok.$$ && no "⑩ 只複製 hooks/,沒有把 repo 其他目錄帶進去" "複本裡出現 scripts/" \ + || ok "⑩ 只複製 hooks/,沒有把 repo 其他目錄帶進去" +rm -f /tmp/.hs-ok.$$ + +echo +echo "通過 $PASS 條,失敗 $FAIL 條(共 $((PASS+FAIL)) 條)" +[ "$FAIL" -eq 0 ] diff --git a/hooks/tests/isep-presence-beacon.test.sh b/hooks/tests/isep-presence-beacon.test.sh index 271d240..a4aeb62 100755 --- a/hooks/tests/isep-presence-beacon.test.sh +++ b/hooks/tests/isep-presence-beacon.test.sh @@ -25,9 +25,21 @@ mkplugin() { cp "$HOOK" "$d/hooks/$(basename "$HOOK")" cp "$REAL_HOOKS/lib/beacon_report.py" "$d/hooks/lib/beacon_report.py" printf '#!/bin/sh\necho real\n' > "$d/scripts/ticket" + # inkstone/ISEP#122:會自動載入的東西(skill/command/agent)也要造得出來 + mkdir -p "$d/skills/demo" "$d/commands" "$d/agents" "$d/docs" + printf 'PLUGIN 版的 skill\n' > "$d/skills/demo/SKILL.md" + printf 'PLUGIN 版的 command\n' > "$d/commands/demo.md" + printf 'PLUGIN 版的 agent\n' > "$d/agents/demo.md" printf '%s\n' "$d" } +# mkown <列...> → 寫一份 docs/file-ownership.tsv(欄位用真正的 TAB) +mkown() { + d="$1"; shift; mkdir -p "$d/docs" + { printf '# 測試用\n'; for row in "$@"; do printf '%s\n' "$row"; done; } \ + | sed 's/|/\t/g' > "$d/docs/file-ownership.tsv" +} + # run → 印出 systemMessage(JSON 壞掉就印 __BADJSON__) run() { CLAUDE_PLUGIN_ROOT="$1" CLAUDE_PROJECT_DIR="$2" bash "$1/hooks/$(basename "$HOOK")" 2>/dev/null \ @@ -112,6 +124,72 @@ printf '#!/bin/sh\necho "OLD"\n' > "$J6/scripts/ticket" out=$(run "$P" "$J6") check "⑭ 報告內容含引號 → JSON 仍然合法" "$out" "🟢 ISEP" "!__BADJSON__" +P2=$(mkplugin 1.0.0) # ⑫⑬ 會刪掉 $P 的 report,這一組要自己一份 +echo "── ②b 會自動載入的東西也有分身(inkstone/ISEP#122)──────────────" +# 🔴 這一組守的是 ship-check 那件:plugin 帶著舊描述,而舊描述在「我要發部落格文章」 +# 的情境根本不會被觸發 ⇒ 那個 session 永遠載不到出貨流程,而且不會有任何症狀。 +JS="$TMP/proj-skill"; mkdir -p "$JS/.claude/skills/demo" +printf '專案版的 skill(比較舊)\n' > "$JS/.claude/skills/demo/SKILL.md" +out=$(run "$P2" "$JS") +check "⑮ 專案有同名 skill 而內容不同 → 要點名" "$out" \ + "會自動載入的東西兩邊各有一份" "skills/demo/SKILL.md" ".claude/skills/demo/SKILL.md" +check "⑯ 歸屬表沒登記 → 誠實說未定,不要替人猜方向" "$out" "歸屬未定" + +JS2="$TMP/proj-skill-same"; mkdir -p "$JS2/.claude/skills/demo" +cp "$P2/skills/demo/SKILL.md" "$JS2/.claude/skills/demo/SKILL.md" +out=$(run "$P2" "$JS2") +check "⑰ 內容一模一樣 → 不吵(誤攔比漏擋嚴重)" "$out" "!會自動載入的東西兩邊各有一份" + +JC="$TMP/proj-cmd"; mkdir -p "$JC/.claude/commands" +printf '專案版的 command(比較舊)\n' > "$JC/.claude/commands/demo.md" +out=$(run "$P2" "$JC") +check "⑱ command 也要掃(sdd-check 那件)" "$out" "commands/demo.md" ".claude/commands/demo.md" + +JA="$TMP/proj-agent"; mkdir -p "$JA/.claude/agents" +printf '專案版的 agent(比較舊)\n' > "$JA/.claude/agents/demo.md" +out=$(run "$P2" "$JA") +check "⑲ agent 也要掃" "$out" "agents/demo.md" ".claude/agents/demo.md" + +JCL="$TMP/proj-cloud"; mkdir -p "$JCL/InkStoneCo/.claude/skills/demo" +printf '專案版的 skill(比較舊)\n' > "$JCL/InkStoneCo/.claude/skills/demo/SKILL.md" +out=$(run "$P2" "$JCL") +check "⑳ 雲端排法:真身在 \$TOP/InkStoneCo/ 底下也要抓到" "$out" \ + "InkStoneCo/.claude/skills/demo/SKILL.md" + +echo "── 歸屬表要講得出「往哪個方向修」──────────────────────────────" +PO=$(mkplugin 1.0.1) +mkown "$PO" "skills/demo/SKILL.md|inkstone/InkStoneCo|.claude/skills/demo/SKILL.md|abc1234|-" +out=$(run "$PO" "$JS") +check "㉑ 真相源=InkStoneCo → 說「內容改在那裡」並給得出路徑" "$out" \ + "真相源=inkstone/InkStoneCo:.claude/skills/demo/SKILL.md" "內容改在那裡" "!歸屬未定" + +mkown "$PO" "skills/demo/SKILL.md|inkstone/ISEP|-|-|-" +out=$(run "$PO" "$JS") +check "㉒ 真相源=ISEP → 說「專案那份是舊複本」" "$out" \ + "真相源=ISEP 這一份" "舊複本" "!歸屬未定" + +echo "── ②c 沒有專案那一份也要驗得出來(雲端唯一作數的檢查)────────────" +# 雲端的 project dir 是薄殼,沒有 InkStoneCo 可以比 ⇒ 只剩「檔案 vs 表上的 sha256」 +SHA=$(shasum -a 256 "$PO/skills/demo/SKILL.md" | cut -d" " -f1) +mkown "$PO" "skills/demo/SKILL.md|inkstone/InkStoneCo|.claude/skills/demo/SKILL.md|abc1234|$SHA" +out=$(run "$PO" "$J") +check "㉓ sha256 對得上(而且專案那份不存在)→ 一個字都不說" "$out" \ + "🟢 ISEP v1.0.1 已載入" "!跟歸屬表對不上" "!會自動載入的東西兩邊各有一份" + +printf '被就地改過\n' >> "$PO/skills/demo/SKILL.md" +out=$(run "$PO" "$J") +check "㉔ 檔案被就地改過 → 就算沒有專案那份,也要抓得到" "$out" \ + "跟歸屬表對不上" "skills/demo/SKILL.md" "abc1234" + +mkown "$PO" "skills/demo/SKILL.md|inkstone/InkStoneCo|.claude/skills/demo/SKILL.md|abc1234|-" +out=$(run "$PO" "$J") +check "㉕ 表上 sha 欄是 \`-\` → 這一格閉嘴(還沒登記不是錯)" "$out" "!跟歸屬表對不上" + +echo "── 加了這兩格也不准讓信標消失 ────────────────────────────────" +out=$(run "$P2" "$JS") +check "㉖ ②b 有報告時,那行綠色信標仍在且 JSON 合法" "$out" \ + "🟢 ISEP v1.0.0 已載入" "!__BADJSON__" + echo printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N" [ "$FAIL" = 0 ] || exit 1 diff --git a/hooks/tests/lib/hook-sandbox.sh b/hooks/tests/lib/hook-sandbox.sh index 57f62f1..9d62968 100644 --- a/hooks/tests/lib/hook-sandbox.sh +++ b/hooks/tests/lib/hook-sandbox.sh @@ -39,9 +39,38 @@ # ⇒ hostsum 兩次都回 `NODIR`(相等)、沙盒的請求數當然是 0 # ⇒ **第一條斷言變成「拿空的比空的」的假綠**。 # 所以改成「設變數、不印」,而且 hook_sandbox_assert 開頭會擋空值(見下)。 +# +# 🔴 **先驗參數再複製**(inkstone/ISEP#122,2026-09-02 實撞,本檔第二次修): +# `$1` 是「那支 hook 的路徑」。傳錯(例如順手傳了 repo 根目錄)時,舊版不檢查就 +# `cp -R "$(dirname "$1")" …` ⇒ **複製的是那個目錄的上一層**。 +# 當天實測:`bash hooks/tests/main-and-prod-push-guard.test.sh "$PWD"` +# ⇒ dirname 變成 ~/Documents/tech_projects +# ⇒ 把 **整個 tech_projects**(所有 repo、所有 worktree)複製進 mktemp +# ⇒ 兩次就吃掉 23 GB,磁碟從 25 GB 剩到 462 MB,`cp` 一路吐 +# `No space left on device`,最後才印一句「❌ 沙盒建不起來」。 +# ⇒ 訊息只說「建不起來」,**沒說是參數傳錯,也沒說它已經把磁碟寫滿了**。 +# 判準用「要求某個東西在場」,不是關鍵字比對: +# ① `$1` 要指到一個**真的檔案**(傳目錄、傳空字串都不算) +# ② 它的上一層目錄名要叫 `hooks`(沙盒的前提就是「複製一整個 hooks/」) +# 兩條任一不成立就**在複製之前**收手,並印出走得通的那一行。 hook_sandbox() { _hs_real=$1 + if [ -z "${_hs_real:-}" ] || [ ! -f "$_hs_real" ]; then + printf '❌ hook_sandbox:第一個參數要是「那支 hook 的檔案路徑」,實得 %s\n' \ + "${_hs_real:-(空的)}" >&2 + printf ' 例:bash hooks/tests/main-and-prod-push-guard.test.sh hooks/main-and-prod-push-guard.sh\n' >&2 + printf ' (不必自己傳的版本:bash scripts/test-main-and-prod-push-guard.sh)\n' >&2 + return 1 + fi _hs_hooks=$(CDPATH= cd -- "$(dirname -- "$_hs_real")" && pwd) || return 1 + if [ "$(basename "$_hs_hooks")" != "hooks" ]; then + printf '❌ hook_sandbox:%s 的上一層不是 hooks/,而沙盒要複製的就是那個目錄。\n' \ + "$_hs_real" >&2 + printf ' 算出來的來源是 %s——照複製下去會把它整個搬進暫存區(2026-09-02 這樣寫滿過磁碟)。\n' \ + "$_hs_hooks" >&2 + printf ' 例:bash hooks/tests/main-and-prod-push-guard.test.sh hooks/main-and-prod-push-guard.sh\n' >&2 + return 1 + fi HOOK_SANDBOX=$(mktemp -d) || return 1 cp -R "$_hs_hooks" "$HOOK_SANDBOX/hooks" || return 1 HOOK_SANDBOX_HOST="${_hs_hooks%/hooks}/pending-main-push" diff --git a/skills/ship-check/SKILL.md b/skills/ship-check/SKILL.md index 5280ee7..13c03a7 100644 --- a/skills/ship-check/SKILL.md +++ b/skills/ship-check/SKILL.md @@ -1,18 +1,21 @@ --- name: ship-check description: | - 改完任何會影響用戶的東西之後、說「做完了」之前必讀(改雲端 worker/portal/daemon/ - workflow/installer 都算)。也在下列時機自動載入:要打包 App、要出貨、要推 bundle、 - 要送 MS Store、leo 問「可以測了嗎」「版本為什麼沒變」「更新了嗎」「封測者拿得到嗎」。 - 核心判準:**版本號是 leo 唯一的驗收介面**——portal 版本卡看雲端、daemon 檢查更新看桌面; - 版本沒動=他無從判斷你做了什麼=等於沒交付,而「我在某台實例 wrangler deploy 過了」不算。 - 收齊:兩條版本線的差別/重打 bundle(最常漏,要 grep 複驗改動真的進去)/ - 改 workflow 要重編預編圖/三支機械閘+把 DMG/zip 真的打開檢查/寫 changelog(用戶語言)/ - D20 開閘出貨/purge jsDelivr/從 leo 會看的那兩處抓實際畫面複驗。 - 附「常見漏掉的」實撞表與收工前五問。 + **任何東西要從「內」(Gitea)送到「外」之前必讀**——不只是 arcrun。 + 對外包含:發一篇部落格文章到 uncle6.me、推 tag 到 GitHub 鏡像 repo、投稿 n8n 官方模板庫、 + 出雲端零件包、出桌面 daemon。也在下列時機自動載入:改完會影響用戶的東西之後、 + 要打包 App/出貨/推 bundle/送 MS Store、要發文、要 pages deploy、要推 GitHub、 + leo 問「可以測了嗎」「版本為什麼沒變」「更新了嗎」「封測者拿得到嗎」「發出去了嗎」。 + 🔴 第一個動作是**分辨這是哪個出口**,因為 stage/版本/arm 每個出口都不同—— + 流程本體住在 system-dev/docs/3-specs/critical-paths/ship.md(唯一一份),本 skill 是它的入口。 + 核心判準:**收的人拿到的東西真的變了,而且他看得出變了什麼**; + 對 arcrun 兩條線而言那個介面就是版本號(portal 看雲端、daemon 看桌面)。 + 收齊:五個出口的判準/兩條版本線的差別/重打 bundle(最常漏)/改 workflow 要重編預編圖/ + 三支機械閘+把 DMG/zip 真的打開檢查/寫 changelog/D20 開閘出貨/purge jsDelivr/ + 從收的人會看的那個地方抓實際畫面複驗。附「常見漏掉的」實撞表與收工前五問。 --- -# /ship-check — 改完東西後,讓 leo 看得到版本變了 +# /ship-check — 東西要出去之前,先確定收的人真的會拿到 > **這支解什麼病**(leo 2026-08-05 原話): > 「對人來說,**我雲端看 portal 有沒有更新,本地看 daemon 有沒有更新**, @@ -25,6 +28,44 @@ description: | --- +## 🚦 第一步:這是哪個出口?(2026-09-02 補,`inkstone/InkStoneCo#112`) + +> **為什麼補這段**(leo 2026-09-02):「Gitea 為『內』,只要對外都是『外』, +> **任何東西出去之前都要經過出貨流程,不是只有 Arcrun, Arcrun RAG 這個計劃**」 +> +> 🔴 **這支 skill 2026-09-01 失效過**:它自己與它指向的地方**只認得 daemon 那個形狀**, +> 於是那晚要發部落格與 GitHub 模板時,它沒把人導向任何可用的流程——**只好即興補**。 + +- 🔴 **流程本體只有一份**:`system-dev/docs/3-specs/critical-paths/ship.md` + - 它定義了**內/外的判準**、**六個步驟**、**五個出口各自的 stage/版本/arm/驗法** + - **動手前先讀那一卷的對應出口段**,不要憑本 skill 的印象做 +- **判準一句:送出去之後,有沒有一個不是我們的人看得到/拿得到?** 有 ⇒ 走那六步 +- **五個出口,各自去 `ship.md` 的哪一段**: + - ① 桌面 daemon(`v0.18.x`)——**本 skill 底下的內容主要是它與 ②** + - ② 雲端零件包(`1.4.x`)——同上 + - ③ 部落格 uncle6.me — 🔴 **沒有 arm、沒有版本號**,stage 是 `<8碼>.kbcontent.pages.dev` 快照; + `pages deploy` **必須在 repo 外的目錄跑**,否則 `.env` 的 token 會蓋掉 OAuth 並報成權限錯誤 + |**驗法是瀏覽器,不是 `curl`**(收件人是讀者 ⇒ 見 §5.8 與 `ship.md` 出口③「驗法」) + - ④ GitHub 鏡像 repo — 🔴 **要 arm,而且連 `git remote add` 都擋**(D20); + Gitea 就是它的 stage;**推 tag 之外還要發 release note** + (怎麼發、要不要再 arm ⇒ `ship.md` 出口④ 的「release note 怎麼發」段) + - 🔴 **步驟 3 的「Use this template」也要 arm,而且要單獨請一次**——它建的是 + **第二個 repo**,撞 ROE「單一 repo」那條,夾不進推 tag 的窗口。 + 且 guard 只掛在 `Bash` ⇒ **走瀏覽器按那顆按鈕不會被擋,這條靠人守** + (⇒ `ship.md` 出口④ 的「Use this template 要不要 arm」段) + - ⑤ n8n 官方投稿 — 沒有 stage,投稿端點尚未查到(`inkstone/llm-wiki-template#9`) +- 🔴 **不要拿 ① 的答案去套 ③④⑤**——那正是 2026-09-01 出錯的機制 +- 🔴 **「這個出口沒有 arm」不等於「這個出口不必經過 leo」**——③⑤ 的人閘是**步驟 5** + (leo 親手在 stage 上走過那一遍)。判準全文=`ship.md` 的 + 「誰批准、誰按鍵、在哪台跑」段:**leo 批准,機器執行** +- **跨多個 repo 的一批貨,「100%」看母票的相依清單,不要看 milestone 的百分比** + (milestone 只數 hub 那個 repo,別的 repo 那幾張不在分母裡) + ⇒ `ship.md` 的「那個數字看哪裡」段 +- 🔴 **發現 `ship.md` 沒涵蓋你手上這件事 ⇒ 那一卷缺了一格,回去補它**, + 不要在別處另開一份流程(leo 2026-09-02 明確選了「改寫 ship.md」) + +--- + ## ⚠️ 這支 skill 自己的失效模式(先讀這段) leo 2026-08-05:「**你寫完一個 skill 然後每個我要提醒你,表示這個 skill 無效**」 @@ -44,8 +85,19 @@ leo 2026-08-05:「**你寫完一個 skill 然後每個我要提醒你,表示 ## 什麼時候跑 -**改完任何會影響用戶的東西之後**(雲端 worker/portal/daemon/workflow), -在說「做完了」之前。不是收工才跑。 +**任何東西要送到「外面」之前**——改完會影響用戶的東西、要發一篇文章、要推 tag 到 GitHub、 +要投稿、要出零件包或 daemon。在說「做完了」之前跑,不是收工才跑。 + +⚠️ **底下的步驟是出口①②(arcrun 兩條版本線)的細節。** +出口③④⑤ 請照上面那段回 `ship.md` 讀該出口的判準——**它們沒有版本號卡可以看**。 + +🔴 **但底下 §5.8「驗前端=用瀏覽器真的載一次」是全出口通則,不歸①② 所有** +(2026-09-02 補:本 skill 這句劃界曾把 §5.8 圈進①②, +而 `ship.md` 對出口③ 給的驗法是 `curl` ⇒ **兩個檔對同一個問題給了兩種答案**)。 +- **一句話判準:用收的人的那個介面驗。** + - 收的人的介面是**網頁** ⇒ 瀏覽器(出口③ 讀者、出口① 的 portal 版本卡) + - 收的人的介面是 **API/CLI** ⇒ `curl` 才是對的(出口② 的 `/health`,程式在讀它) +- ⇒ 這不是「①② 用 curl、③ 用瀏覽器」,是**每個出口各自問自己的收件人在看什麼** --- @@ -503,6 +555,10 @@ curl -s "https://rag.arcrun.dev/docs/start/install-windows/?cb=$RANDOM" \ > leo 原話:「**你的環境有 web,你應該用 web 驗,你已經開啓了卻沒有完成,你要把這個列入規定。**」 +🔴 **適用範圍:全出口,不是只有①②**(2026-09-02 補,見上面「什麼時候跑」那段的劃界)。 +**收的人的介面是網頁就套這條**——出口③ 部落格的收件人是讀者,他的介面就是瀏覽器。 +`ship.md` 出口③「驗法」那格寫的是同一件事,兩邊只有一種說法。 + **為什麼 `curl | grep` 是假驗證**(08-08 實撞,leo 抓到而不是我發現): `curl` 拿到的是 **HTML 原始碼**——它**不執行 JS、不載入 `config.js`、不發 API 請求**。 所以我 grep 到文案就宣稱「前端驗過」,而使用者實際打開看到的是整條紅色錯誤: