From c2638668e31fe482c184730f7c4968ac983c1a97 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 20 Aug 2026 11:41:46 +0800 Subject: [PATCH] =?UTF-8?q?ISEP=200.1.0=EF=BC=9A=E7=92=B0=E5=A2=83?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E6=94=B6=E6=88=90=E4=B8=80=E5=80=8B=20plugin?= =?UTF-8?q?=EF=BC=8C=E6=9C=AC=E6=A9=9F=E8=88=87=E9=9B=B2=E7=AB=AF=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E4=B8=80=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-20:「同一個 plugin 你用,薄殼也用,保證兩邊同步」 「我要你幫雲端做薄殼,永遠都有問題,你要做的就是這組設定 你自己可以 dogfooding」 搬進來:41 支 hook(51 條註冊)/7 支 command/2 支 skill/23 支腳本。 不搬 .env、wiki、docs——那些是知識不是環境。 51 條 hook 路徑全部從 $CLAUDE_PROJECT_DIR/.claude/hooks/ 改成 ${CLAUDE_PLUGIN_ROOT}/hooks/, 零漏網。那正是薄殼一直壞掉的根:雲端 cwd 不是真身,寫死路徑就斷。 尚未驗證:Claude Code 能不能從私有 Gitea repo 裝 marketplace(要憑證)。 下一步就是在本機實際裝一次,通了才動雲端 bootstrap.sh。 Co-Authored-By: Claude Opus 5 --- .claude-plugin/marketplace.json | 15 + .claude-plugin/plugin.json | 6 + README.md | 48 ++ commands/cp-write.md | 306 +++++++++ commands/issue-handle.md | 346 ++++++++++ commands/sdd-check.md | 65 ++ commands/wiki-capture.md | 69 ++ commands/wiki-init.md | 230 +++++++ commands/wiki-recall.md | 58 ++ commands/wiki-update.md | 50 ++ hooks/arcrun-intent-guard.sh | 19 + hooks/browser-verify-guard.sh | 85 +++ hooks/claim-verify-police.sh | 82 +++ hooks/component-guard.sh | 47 ++ hooks/credential-only-guard.sh | 116 ++++ hooks/delivery-police.sh | 135 ++++ hooks/empty-handed-stop-guard.sh | 128 ++++ hooks/factory-idle-guard.sh | 203 ++++++ hooks/github-contact-guard.sh | 144 +++++ hooks/guard-cross-project.sh | 93 +++ hooks/history-first-guard.sh | 153 +++++ hooks/hooks.json | 314 +++++++++ hooks/irreversible-dispatch-guard.sh | 79 +++ hooks/issue-status-autoflip.sh | 63 ++ hooks/kbdb-api-wall-guard.sh | 225 +++++++ hooks/kbdb-asked-stamp.sh | 20 + hooks/leo21c-write-guard.sh | 80 +++ hooks/main-and-prod-push-guard.sh | 312 +++++++++ hooks/micromanage-guard.sh | 89 +++ hooks/mistake-needs-ticket-guard.sh | 108 ++++ hooks/no-ticket-no-dispatch.sh | 124 ++++ hooks/not-my-branch-guard.sh | 81 +++ hooks/pending-changes-retired.sh | 66 ++ hooks/pre-write-guard.sh | 52 ++ hooks/pre-write-guard.template.sh | 64 ++ hooks/prod-write-guard.sh | 264 ++++++++ hooks/sdd-guard.sh | 169 +++++ hooks/self-drive-judge.sh | 176 ++++++ hooks/self-drive-police.sh | 165 +++++ hooks/session-start-recall.sh | 152 +++++ hooks/shadow-table-guard.sh | 110 ++++ hooks/skill-deploy-drift-guard.sh | 52 ++ hooks/stage-before-prod-guard.sh | 193 ++++++ hooks/subagent-claim-worksheet.sh | 183 ++++++ hooks/subagent-first-guard.sh | 74 +++ hooks/subagent-first-stamp.sh | 17 + hooks/subagent-wiki-guard.sh | 147 +++++ hooks/unpushed-police.sh | 190 ++++++ hooks/wiki-first-police.sh | 176 ++++++ hooks/wiki-first-search.sh | 182 ++++++ hooks/wiki-secret-scan.sh | 113 ++++ hooks/worklist-guard.sh | 56 ++ scripts/check-bundle-drift.sh | 70 +++ scripts/check-deploy-drift.sh | 85 +++ scripts/component-arm.sh | 11 + scripts/daemon-selfcheck.py | 50 ++ scripts/deploy-web.sh | 128 ++++ scripts/git-bundle-backup.sh | 41 ++ scripts/gitea-arm-check.sh | 211 +++++++ scripts/gitea-arm-request.sh | 145 +++++ scripts/gitea-arm-status.sh | 51 ++ scripts/gitea-arm-to-github-armed.sh | 55 ++ scripts/gitea-bootstrap.sh | 80 +++ scripts/github-arm.sh | 96 +++ scripts/install.sh | 472 ++++++++++++++ scripts/kbdb-live-exam/.gitignore | 1 + .../.wrangler/cache/wrangler-account.json | 6 + scripts/kbdb-live-exam/buttons.py | 349 ++++++++++ scripts/kbdb-live-exam/exam-packet.md | 65 ++ scripts/kbdb-live-exam/fingerprint.py | 153 +++++ scripts/kbdb-live-exam/grade.py | 391 ++++++++++++ scripts/kbdb-live-exam/prepare.py | 28 + scripts/kbdb-live-exam/report.py | 113 ++++ scripts/kbdb-live-exam/selftest.py | 217 +++++++ scripts/kitesurf-mcp.sh | 40 ++ scripts/kv-generation-merge.py | 394 ++++++++++++ scripts/lib/gitea-arm-common.sh | 103 +++ scripts/make-tree-demo-data.sh | 174 +++++ ...sion-start-recall--map-zero-is-a-lie.patch | 71 +++ scripts/stage-ok.sh | 54 ++ scripts/ticket | 340 ++++++++++ scripts/update.sh | 332 ++++++++++ scripts/wiki-panorama.sh | 350 +++++++++++ skills/deep-recall/SKILL.md | 114 ++++ skills/ship-check/SKILL.md | 595 ++++++++++++++++++ 85 files changed, 11879 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 commands/cp-write.md create mode 100644 commands/issue-handle.md create mode 100644 commands/sdd-check.md create mode 100644 commands/wiki-capture.md create mode 100644 commands/wiki-init.md create mode 100644 commands/wiki-recall.md create mode 100644 commands/wiki-update.md create mode 100755 hooks/arcrun-intent-guard.sh create mode 100755 hooks/browser-verify-guard.sh create mode 100755 hooks/claim-verify-police.sh create mode 100755 hooks/component-guard.sh create mode 100755 hooks/credential-only-guard.sh create mode 100755 hooks/delivery-police.sh create mode 100755 hooks/empty-handed-stop-guard.sh create mode 100755 hooks/factory-idle-guard.sh create mode 100755 hooks/github-contact-guard.sh create mode 100755 hooks/guard-cross-project.sh create mode 100755 hooks/history-first-guard.sh create mode 100644 hooks/hooks.json create mode 100755 hooks/irreversible-dispatch-guard.sh create mode 100755 hooks/issue-status-autoflip.sh create mode 100755 hooks/kbdb-api-wall-guard.sh create mode 100755 hooks/kbdb-asked-stamp.sh create mode 100755 hooks/leo21c-write-guard.sh create mode 100755 hooks/main-and-prod-push-guard.sh create mode 100755 hooks/micromanage-guard.sh create mode 100755 hooks/mistake-needs-ticket-guard.sh create mode 100755 hooks/no-ticket-no-dispatch.sh create mode 100755 hooks/not-my-branch-guard.sh create mode 100755 hooks/pending-changes-retired.sh create mode 100755 hooks/pre-write-guard.sh create mode 100755 hooks/pre-write-guard.template.sh create mode 100755 hooks/prod-write-guard.sh create mode 100755 hooks/sdd-guard.sh create mode 100755 hooks/self-drive-judge.sh create mode 100755 hooks/self-drive-police.sh create mode 100755 hooks/session-start-recall.sh create mode 100755 hooks/shadow-table-guard.sh create mode 100755 hooks/skill-deploy-drift-guard.sh create mode 100755 hooks/stage-before-prod-guard.sh create mode 100755 hooks/subagent-claim-worksheet.sh create mode 100755 hooks/subagent-first-guard.sh create mode 100755 hooks/subagent-first-stamp.sh create mode 100755 hooks/subagent-wiki-guard.sh create mode 100755 hooks/unpushed-police.sh create mode 100755 hooks/wiki-first-police.sh create mode 100755 hooks/wiki-first-search.sh create mode 100755 hooks/wiki-secret-scan.sh create mode 100755 hooks/worklist-guard.sh create mode 100755 scripts/check-bundle-drift.sh create mode 100755 scripts/check-deploy-drift.sh create mode 100755 scripts/component-arm.sh create mode 100755 scripts/daemon-selfcheck.py create mode 100755 scripts/deploy-web.sh create mode 100755 scripts/git-bundle-backup.sh create mode 100755 scripts/gitea-arm-check.sh create mode 100755 scripts/gitea-arm-request.sh create mode 100755 scripts/gitea-arm-status.sh create mode 100755 scripts/gitea-arm-to-github-armed.sh create mode 100755 scripts/gitea-bootstrap.sh create mode 100755 scripts/github-arm.sh create mode 100755 scripts/install.sh create mode 100644 scripts/kbdb-live-exam/.gitignore create mode 100644 scripts/kbdb-live-exam/.wrangler/cache/wrangler-account.json create mode 100644 scripts/kbdb-live-exam/buttons.py create mode 100644 scripts/kbdb-live-exam/exam-packet.md create mode 100644 scripts/kbdb-live-exam/fingerprint.py create mode 100644 scripts/kbdb-live-exam/grade.py create mode 100644 scripts/kbdb-live-exam/prepare.py create mode 100644 scripts/kbdb-live-exam/report.py create mode 100644 scripts/kbdb-live-exam/selftest.py create mode 100755 scripts/kitesurf-mcp.sh create mode 100644 scripts/kv-generation-merge.py create mode 100755 scripts/lib/gitea-arm-common.sh create mode 100755 scripts/make-tree-demo-data.sh create mode 100644 scripts/patches/session-start-recall--map-zero-is-a-lie.patch create mode 100755 scripts/stage-ok.sh create mode 100755 scripts/ticket create mode 100755 scripts/update.sh create mode 100644 scripts/wiki-panorama.sh create mode 100644 skills/deep-recall/SKILL.md create mode 100644 skills/ship-check/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e21d340 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "inkstone", + "description": "InkStoneCo 自用的 Claude Code 環境", + "owner": { "name": "Leo" }, + "plugins": [ + { + "name": "isep", + "description": "InkStone Environment Plugin —— 機械閘/command/skill/腳本的唯一真相源", + "author": { "name": "Leo" }, + "category": "productivity", + "source": "./" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..a46c048 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "isep", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:41 支機械閘、7 支 slash command、2 支 skill、23 支腳本。本機與雲端裝同一份。", + "version": "0.1.0", + "keywords": ["inkstone", "guardrails", "hooks", "gitea", "arcrun"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e389f13 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# ISEP — InkStone Environment Plugin + +> leo 2026-08-20:「你把全部環境設定放在一個 claude code plugin,**同一個 plugin 你用,薄殼也用,保證兩邊同步**⋯⋯ +> 以後有任何變化,增加 command, hook⋯⋯都增加在這裡,再去跟它同步。」 +> 「我要你幫雲端做薄殼,永遠都有問題,**你要做的就是這組設定你自己可以 dogfooding**。」 + +## 這個 repo 解什麼 + +在此之前,同一套環境有**兩份**: + +``` +真身 InkStoneCo/.claude/ ← 本機在跑的 +薄殼 由 generate-shell-payload.py 產生一份,塞進 GitHub 私 repo ← 雲端在跑的 +``` + +兩份必然漂移。`inkstone/InkStoneCo#57` 記著實測結果:**薄殼比真身少 7 支閘**, +其中兩支是前一天才立的。`#14` 更早:雲端 33 支 guard **一支都沒生效**。 + +⇒ 現在只有一份:**本 repo 就是唯一真相源**,本機與雲端裝同一個 plugin。 +⇒ 而且**總管自己也用它**——壞掉的時候是我先踩到,不是雲端替我踩。 + +## 裝什麼 + +| | 數量 | 是什麼 | +|---|---|---| +| `hooks/` | 41 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 51 條註冊) | +| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | +| `skills/` | 2 支 | | +| `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … | + +**不放**:`.env`(金鑰,違 D36「金鑰只有一個家」)、`wiki/`、`docs/`、`_archive/` +——那些是**知識**不是**環境**。 + +## 路徑規約(薄殼一直壞掉的根) + +hook 一律用官方的 `${CLAUDE_PLUGIN_ROOT}`,**不准寫死絕對路徑、也不用 `$CLAUDE_PROJECT_DIR` +去指 hook 自己**:雲端的 cwd 不是真身,寫死就斷。 +腳本**內部**要指專案檔案(wiki、docs)時才用 `$CLAUDE_PROJECT_DIR`——那是對的, +因為那些東西本來就住在被操作的那個 repo 裡。 + +## 改東西的規矩 + +🔴 **只改這裡,然後兩邊 `/plugin update`。** +不要再改 `InkStoneCo/.claude/hooks/`——那個目錄退場中。 + +## 狀態 + +- 0.1.0 — 從 `InkStoneCo/.claude/` 搬過來,51 條 hook 路徑全部改成 `${CLAUDE_PLUGIN_ROOT}` diff --git a/commands/cp-write.md b/commands/cp-write.md new file mode 100644 index 0000000..6b44a62 --- /dev/null +++ b/commands/cp-write.md @@ -0,0 +1,306 @@ +--- +name: cp-write +description: | + 要寫或改任何 CP(Critical Path)檔案之前必讀——動 system-dev/docs/3-specs/critical-paths/ + 底下任何檔案、標記某關卡 ✅/◐/❌、或新增一條 CP 時自動載入。 + leo 2026-07-30:「上次已經跟你討論一次並且寫了正確範本,立刻全部忘光了」。 + 三條鐵律:寫目的不寫功能/每步交出 deliverable/code 寫完沒部署不准標 ✅。 + 另含 Logseq outliner 格式(給 leo 讀的 md 一律巢狀 bullet、禁表格平攤)。 +--- + +# /cp-write — 寫或改一條 CP(Critical Path) + +**要動 `system-dev/docs/3-specs/critical-paths/` 底下任何檔案之前,先跑這個。** + +--- + +## 為什麼有這支 skill + +leo 2026-07-30:「你去把寫 cp 的方法寫成 skill,**上次已經跟你討論一次並且寫了正確範本,立刻全部忘光了**。」 + +- **忘光的機制**(結構問題,不是記性問題) + - 規範住在 `TEMPLATE-critical-path.md`(209 行) + - CC 不會在動筆前主動讀 209 行文件 → 憑印象寫 → 寫成功能清單 + - 「該讀的文件存在」≠「會被讀到」 +- **同一天內 leo 糾正四次** + - 「不是有定義 CP 的寫法?outliner,步驟,有關任務」 + - 「你不要再去寫一個個功能,**要寫的是目的**,每次交出 deliverable」 + - 「上次說過,CP 有順序的跟無序的兩種」 + - 「CP 每個步驟要拉哪些任務,**不是自己編一堆新任務**…你去搜尋先前的你寫的範本」 +- ⇒ 規範要在**動筆那一刻**被載入,不是躺在檔案裡等人想起來 + +--- + +## 六條鐵律 + +違反就是寫錯,不是風格問題。 + +### 1. 寫目的,不寫功能 + +> leo:「你不要再去寫一個個功能,要寫的是目的,達成那個目的,**每次交出 deliverable**,而不是交出一個程式碼打勾就完成了。」 + +- ❌ 錯:「`/cypher/search` 接 registry」 +- ✅ 對: + - **目的**:AI 拿到的答案必須是真的——假信號比沒答案更糟 + - **交付物**:查詢回應含 found/missing/unknown,found 附 input_schema +- **檢查法**:每步唸出來,聽起來像「我要改哪個檔案」→ 重寫成「達成什麼、交出什麼」 + +### 2. 從 tasks 池子撈任務,不在 CP 裡編新任務 + +> leo 2026-07-30:「**如果在這裡編任務,不就是廢掉了原本的 SDD,那到底要照 SDD 做事還是照 CP?**」 + +🔴 **2026-08-10 leo 定調:CP 只標編號,不重抄條目。原話——** + +> 「CP 寫大計劃,加上描述,然後在 issues 寫明,**在 CP 標示 issue 編號**, +> 如果是在 issue 的內部 MD,一樣寫 `issue #2, task #?`,在 issue 從**標題層級**來找, +> 只要能 mapping 某 task。CP 一向就是寫計劃,把所需的 task 標示,**避免同一條目重抄一次**。」 + +> leo 2026-08-10(同日補充,這句是骨架): +> 「**SDD 的 tasks 指向 issues,CP 的 tasks 指向 issues,永遠管理 issues。**」 + +- **三層,只有一層可寫** + ``` + SDD tasks ──指向──┐ + ├──▶ issue ← 唯一管理處(勾選/s/* 狀態/執行細節/往返對話) + CP tasks ──指向──┘ + ``` + - **issue=任務池子**(`issue-handle` 2026-08-09 定調):任務本體、勾選、狀態都只住這裡 + - **SDD=規格與設計**(為什麼要做、怎麼做);它的 tasks 是**指向 issue 的清單**,不自己養 checkbox + - **CP=大計劃+排序**(現在先做哪些);寫目的與描述,任務只放**編號 pointer**+`w=` +- **⇒ 兩邊都只是視角,動狀態一律回 issue。** +- 🔴 **CP 裡不准出現 `- [ ]` / `- [x]`**——checkbox 就是「在發號」,而 CP 不發號 + - 要知道做到哪 → 去看票。CP 只回答「這一步通不通」(三態) + - **同一條目寫兩次 = 兩份真相 = 必然漂移**(2026-08-10 實錯:CP 四筆停在事發前的世界, + issue 那邊才是對的;總管花一整輪在對帳) +- **定址:指到「一張票」,不指票裡的某一行**(D58,leo 2026-08-10) + > leo:「**充分利用 gitea 的機制,不要硬做個不支援的機制,容易出錯。**」 + + Gitea 原生就會把 issue 引用自動變成可點連結 ⇒ **CP 不必手貼網址、不必造錨點。** + + ```markdown + - `w=9` Leo/arcrun-rag#52 — 重裝到已有資料的帳號,登入與資料庫一起壞 + ``` + + - 🔴 **最好用的判準**:**需要指到票裡的某一行 = 那張票該拆了。** + 這個限制反而**強迫出正確的顆粒度** + - **票的刀口不是「大小」,是「狀態」**:問「它會不會需要跟隔壁那條不同的狀態?」 + 會 → 獨立成執行票;不會 → 留在該票裡當 checkbox + - **討論票 vs 執行票**:討論票是脈絡(不當工單追),執行票才是 CP 該指的東西 + - ⚠️ **已作廢:隱形錨點 ``**(D53,同日上線同日推翻)——**不要撿回去**。 + 它整段推理嚴謹,**但沒有先問「這個平台原生支援嗎」**。 + 造任何機制前先問:「原生支援嗎?」「不支援是不是在說我方向錯了?」 +- **自檢**:CP 上任何一行任務,拿它的編號去票裡找得到唯一一條嗎? + - 找不到 → 你違規了。要嘛去票裡補,要嘛從 CP 刪掉 + - **絕不能留在 CP 裡當「CP 專屬任務」** +- **`w=` 的正確用法**(leo 原話) + - 「從**很多任務**中找到跟現在目的**最近**的是誰,**把它排序到前面**」 + - 「**w 是標在 task 裡,這個任務原本在 SDD 的 tasks 中,跟現在的優先級有關,我把它拉出來到前面**」 + - ⇒ `w=` 的意思=**這個 task 原本躺在 SDD 池子裡,因為跟當前目的相關,被拉到前面** + - ⇒ 是池子任務**互相比較**後的排序,**不是對單一任務憑感覺打分** + - ⇒ 同一步內的任務**依 `w=` 由高到低列**,高的先做 + - 🔴 **`w=` 只能標在任務表的任務上,不能標在步驟標題上** + - leo 2026-07-30:「這個只是標題,不是從 SDD 拉出來的,**它的 w 是跟誰比是 9?**」 + - 【有序】卷的每步都是必經 ⇒ **不需要排序** ⇒ 標了就是假數字 + - 【無序】卷的並列項才需要 `w=`(那時它們互相比較,有意義) + - 🔴 **`w=` 只排順序,不決定誰進 CP**——進 CP 的門檻是鐵律 5 的反事實測試(最小待辦) + +> leo:「**tasks 是一個任務池子,CP 是編訂 sprint 的原則。**」 + +- **動筆前先做兩件事** + - 看先前範本:`system-dev/docs/3-specs/autonomy-dispatch/sprint-2026-07a.md` + (里程碑表 + `P1>P2>P3` 任務板,做法一致,別重新發明) + - 撈池子=撈 issue(不是 grep `tasks.md` 的 checkbox,那份已經只是 pointer 了): + ```bash + TOKEN=$(git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') + curl -s -H "Authorization: token $TOKEN" \ + "https://git.uncle6.me/api/v1/repos/Leo//issues?state=open&labels=s/todo" + ``` + - 🔴 **不帶 token 打私有 repo 回 `{"message":"not found"}`**,長得像「這裡沒東西」 + (2026-08-09 實錯:據此把 24 個 open issue 宣告成不存在) +- **怎麼列**:一律 outliner 清單(鐵律 6),**必要資訊是「編號 + 一句話 + w + 執行者」**: + ```markdown + - `w=9` #2 task 3 — 讓 notify_leo 重新發得出訊息 + - `w=7` #2 task 7 — 👤 leo:拿你知道答案的東西查一次 + ``` + - 🔴 **沒有 checkbox**(鐵律 2,leo 08-10)——要看做到哪去點那張票 + - 🔴 **一句話是「指路」不是「複製」**:只寫到足以認出是哪一條, + 細節、實測輸出、踩到的坑**一律留在票上** + - 🔴 **不用表格**(leo 07-31 拍板,見鐵律 6)——07-30 曾說「寫表格也可以」,已被此裁定取代 +- **撈不到才是真缺口** → **去開票**(或在既有票裡加一條 task),然後 CP 標它的編號 + - 在**票**裡加,**不在 CP 裡加** +- **為什麼特別容易忘**:CP 看起來像 todo list,很自然就在裡面寫「我要做 A、B、C」 + - 那樣做 → 同一任務在票與 CP 各一份 → 必然漂移 → 兩邊都不可信 + - 2026-08-10 實錯:CP 四筆停在事發前的世界(已完成的還空著、已解除的還標危險), + 而票那邊是對的。總管花一整輪對帳,leo 當場問「**我到底要看什麼?**」 + +### 3. 每步有可執行的驗法 + +- 沒有驗法的步驟不算數——那是「宣告完成」的溫床 +- 驗法要是**可執行的動作**(跑什麼指令、看什麼回應),不是「檢查是否完成」 +- **考試三要素必須定義**(leo 2026-07-31:「誰主動、誰被動、正確答案是什麼,這些角色你沒有定義」) + - **考生(主動)/受測物(被動)/正確答案**——三者寫在每步的「考試角色」行 + - **考不過=迭代受測物**,不改題目、不怪考生 + - 受測物是環境(指引/引導)→ 考不過改環境(例:步驟 1「我給它一個環境它考不過,就是我的環境要迭代」) + - 受測物是系統 → 回應不正確改系統(例:「haiku 給它一個需求回應不正確,就是系統要迭代」) +- **有前端的關,`HTTP 200` 不算驗過** + - 要抓實際畫面內容:`curl <網址> | grep <該出現的字串>` + +### 4. 用 PM 的態度定狀態:**deliver 才算通,不是我實測過就算** + +> leo 2026-07-30:「最終不是要實測,**是要 deliver**,你在本地實測完沒推沒 deploy 也用不了, +> **最後要讓收的人可以實測**,你要抱着 **PM 的態度**,不是開發者的態度。」 + +- **判準只有一句**:**收的人現在能不能自己驗到?** 不能 → 沒通 + - 「收的人」=leo/封測者/下一個 AI,看這條 CP 服務誰 +- 三種狀態 + - `✅ 通` — **收的人已經驗到了**(貼他驗到的證據,不是我的) + - `◐ 半通` — 我這端做完且驗過,但**還沒到收的人手上**;必須標明「卡在哪一段運送」 + - `❌ 斷` — 沒接上/沒發佈/沒人能用 +- 🔴 **開發者態度 vs PM 態度**(開發者會說 → PM 要追問) + - 「`tsc` 零錯誤、測試綠」→ 部署了嗎? + - 「commit 了」→ push 了嗎? + - 「push 了」→ 收的人拿得到嗎?(要不要重裝/解保險) + - 「部署成功」→ 他點下去看到對的東西嗎? +- **運送鏈缺一段就不算通**:改完 → commit → push → 打包 → 部署 → **收的人重裝/重連** → 他驗到 + - 反覆的失敗模式:registry 機制完整但沒觸發/`acr search` 寫好沒發佈/ + ingest 通了沒人餵/daemon 修好但 leo 手上還是舊版 + - 每件都「做完了」,**收的人手上沒有** + +### 5. 拉的是「最小待辦」(MVP),不是「相關任務清單」 + +> leo 2026-07-31:「CP 是要達成這個目標而**從池子裡拉出的最小待辦**, +> 如果不做這些也通過,就表示這些任務不屬於最小待辦。」 +> 「最小待辦類似 **MVP** 概念——如果有 500 個任務,全部做完要很久,但現在要的是最小待辦。」 + +- **成員資格測試(反事實)**:拉任務進 CP 前問一句—— + 「**不做這個,該步的驗法(考試)會不會掛?**」會掛才進。 + 只是「跟目的相關」不夠:**相關 ≠ 必要**。 +- **步驟驗法通過時,逐筆對帳還沒完成的**,三選一(leo 原話給的處置): + 1. **評估出錯** → 檢討選任務的方法(寫進 mistakes.md) + 2. **移出 CP,以後完成**(票留著照常排,CP 刪掉那行 pointer) + 3. **已無需要** → 回票上結案 + - 🔴 非必要任務掛在 CP 上會**稀釋整個儀表板的訊號**——leo 看 CP 判「還差多遠」 +- 🔴 **勾選一律回票上做,不在 CP**(鐵律 2,leo 08-10) + - 舊版寫「做完就勾」(leo 08-01),指的是**別把做完的事留白**——那個意圖沒變, + 只是**勾的地方換了**:從 CP 換到票 + - CP 這一側對應的動作=**該步的三態現況要更新**(✅/◐/❌),那才是 CP 的本職 +- **要 leo 做的也是待辦**(leo:「如果有要我做什麼,這也是待辦,但**執行者是我**」) + - 🔴 **它必須是一張撈得到的票,不能只住 CP**(leo 08-10:「執行到要我 input 時要標示 Stage, + 我去完成,**但 CP 的無法標示**」) + - CP 是 markdown,**沒有 label 可掛 ⇒ leo 的看板撈不到 ⇒ 對他等於不存在** + - ⇒ 人閘動作(arm/confirm/真機驗)一律**掛 `s/stage`**(規格見 `issue-handle`), + CP 這邊只留一行 `👤 leo` 的 pointer + - 標 `s/stage` **不等於交棒完成**——回覆裡要帶「打開什麼/該看到什麼/什麼算失敗」三件, + 且指令自己先打過(`issue-handle` 有全文) + - 並同步 status「待 leo」清單+每日催辦——不是只寫在對話裡 + - 🚚 「合主線/發版/部署/等 arm」=**運送殘項**,另起一行標 🚚,不佔任務位 + - 🔴 **一筆待辦只准一個執行者**(leo 07-31:「這不是一個任務,是 2 個, + 兩個主詞不同怎麼寫在一起?」)——主詞不同就拆成多筆,各自標執行者; + 有先後依賴用「A 之後」寫在後筆開頭,不用「→」把兩人的事串成一筆 +- **踩雷實例(2026-07-31 arcrun-usable 步驟 1)**:掛了 4 筆 `w=` 高的任務, + 一筆都沒做完、考試照樣 10/10 =全非最小待辦;而真擋 ✅ 的那筆 + (安裝器 seed skills)**反而不在清單上**。 + 病根=用「關鍵字相關度」順撈池子(語意相近的多半是同 SDD 的鄰居工單), + 但真最小待辦常在**別的環節**(交付鏈/安裝器/人閘) + ⇒ **從驗法反推需要什麼,不從池子順撈相關的**。 + +### 6. 格式=Logseq outliner,不用表格 + +> leo 2026-07-31:「我是個 Logseq 用戶……表格 + header 對閱讀不利,我看不出記錄彼此的層級, +> 全部平攤。**全部用 logseq outliner,可以用 outline + header,不要太花,保持乾淨簡潔。**」 +> 「skill 寫明用 outliner,**不要一改版整個跑掉**。」 + +- **層級用巢狀 bullet 呈現**,不用表格——表格把層級平攤掉,Logseq 讀不出結構 +- header 可以用,但只切大段(卷名/成功標準/六步/總驗收),不要每小節都開 header +- 不要太花:emoji/粗體節制,狀態記號(✅◐❌/👤)保留因為有功能 +- 適用範圍:**CP、給 leo 讀的一切 md**(wiki、報告、交棒文件同此) +- 🔴 此條取代 07-30「寫表格也可以」——**改版時不准把格式改回表格**,本鐵律就是防跑掉的錨 + +--- + +## 執行流程 + +### 第一步 — 確認這是哪一條 CP + +```bash +ls system-dev/docs/3-specs/critical-paths/ +cat system-dev/docs/3-specs/critical-paths/README.md +``` + +- **新主題要另立一卷**,不塞進既有卷(leo:「你只有一個,這樣太大了」) +- **行數門檻**(07-30 實測後修正) + - 目標 **100 行內**;**上限 200 行**(六步以上的卷,池子任務表格本身就佔百餘行) + - 超過先問:**多出來的是「池子任務表格」還是「執行細節」?** + - 池子任務表格 → **留著**,那是 CP 的核心(拉任務+距離) + - 執行細節/實測全文/糾正史 → 搬 `_evidence/`,CP 只留一行結論+指針 + +### 第二步 — 判斷【有序】還是【無序】,標在標題上 + +- **【有序】** — 前步不通,後步沒意義 + - 寫法:步驟 1→N 鏈狀;斷點按順序列、標「前置」 + - 例:安裝→萃取→查詢→MCP/AI 想到→查詢→替換→執行 +- **【無序】** — 並列能力,各自算分 + - 寫法:每項獨立標 `w=`/狀態;**不寫「前置」** + - 例:「知識可收集、可查、可追」=三種能力並列 +- **判斷法**:把第 2 步拿掉,第 3 步還有意義嗎? + - 沒意義 → 有序 + - 還有意義 → 無序 +- **可混合**:有序主鏈 + 無序支線(支線另開一段標【無序】,別硬塞進鏈裡) + +### 第三步 — 每步寫四件事 + +```markdown +### 步驟 N|<一句話目的> `w=9` ◐ 半通 + +**目的**:<達成什麼。沒參與的人也看得懂> +**交付物**:<交出什麼可驗的東西。不是「改好某個檔案」> +**驗法**:<可執行的動作。有前端要抓畫面內容> +**現況**:<實測到什麼。附證據> + +**最小待辦**(鐵律 5:過反事實測試才進;要 leo 做的標 👤 leo) +- [ ] <任務——出處 SDD> +- [ ] 👤 leo:<人閘動作> + +**移回 SDD 池**(曾拉進來但驗證非必要的,留一行去向) +``` + +### 第四步 — 斷點與「不在 CP 上」 + +- 🔴 斷點段(outliner,一行一斷點):`- 步 N <斷點>——前置:<步>;<為什麼卡>` +- ⚪ 不在 CP 上:`- <項目>(<為什麼不阻斷>)` + - **一定要寫**——不寫下來就會反覆被它吸走注意力 + +### 第五步 — 進度數字 + +- 卷尾一行:`**進度:N 完成/M 進行中/K 未做(共 T)**` +- 有機械驗收(如 `verify.sh`)→ **以它為準**,並註明「此清單是人看的」 + +--- + +## 收工檢查 + +逐條自問,答不出來就是沒寫完。 + +1. 每步都是「目的+交付物」,不是「改哪個檔案」? +2. 子項是**從池子撈的**(有「出處」欄)?撈不到的標了 `➕ 待加 `? +3. 動筆前**看過先前範本**(`autonomy-dispatch/sprint-2026-07a.md`)? +4. 標了【有序】/【無序】? +5. 每步驗法**可執行**?有前端的抓了畫面內容? +6. **有 `✅` 的,是「收的人驗到」的證據,不是我自己測過?** + - 運送鏈走完了嗎:改完 → commit → push → 打包 → 部署 → **收的人重裝** → 他驗到 + - 缺任一段 → 最多 `◐`,且要標「卡在哪一段」 +7. 寫了「不在 CP 上」? +8. 這一卷在 200 行內?超過的部分是「池子任務表格」(可留)還是「執行細節」(要搬)? +9. **CP 檔真的改了**?(leo 07-26:「我看 CP 也沒用,因為你執行時沒去更新」) +10. **每筆任務都過了反事實測試**(不做它驗法會掛)?要 leo 做的標了 👤 並進催辦? + 驗法通過的步驟,空 checkbox 對帳完了(檢討/移回池子/標完成或刪)? +11. **全卷 Logseq outliner、零表格**(鐵律 6)?層級縮排看得出來?沒有太花? + +--- + +## 相關 + +- 完整規範(含踩雷案例):`system-dev/docs/3-specs/TEMPLATE-critical-path.md` +- 分卷規則:`system-dev/docs/3-specs/critical-paths/README.md` +- 先前範本(拉任務+標距離):`system-dev/docs/3-specs/autonomy-dispatch/sprint-2026-07a.md` +- 執行細節該放哪:`system-dev/docs/3-specs/critical-paths/_evidence/` diff --git a/commands/issue-handle.md b/commands/issue-handle.md new file mode 100644 index 0000000..0442580 --- /dev/null +++ b/commands/issue-handle.md @@ -0,0 +1,346 @@ +--- +description: Gitea issue/milestone/看板的實際操作流程——issue 是 tasks 池、milestone 是 sprint、label 是狀態、看板是 leo 的畫面 +--- + +# /issue-handle — issue 就是 tasks 池子(Gitea 版,2026-08-09 leo 定調) + +> 🔴 **本檔 2026-08-09 整份重寫。**舊版寫的是 GitHub + `gh` CLI—— +> 那條路 D20 擋著(機器寫入 GitHub 要 leo 開閘),而真實世界早就在 **Gitea** 上。 +> `gh` 打不到 Gitea。**照舊版做會什麼都做不成。** + +## 世界觀(先讀這段,其餘都是它的細節) + +- **issue = tasks 池子**——不管從哪來,所有要做的事都是 issue + - SDD 定案推導出來的、外面回報進來的、開發中才發現的,**三種都是 issue** + - 差別只在 body 裡標來源,不在「住哪裡」 +- **milestone = sprint**(一次交貨)——**AI 建得了,所以 AI 管** +- **project(看板)= 大目標的視覺**——**AI 建不了,leo 建、leo 拖** +- **label `s/*` = 狀態**——機器的真相住這裡,不住看板欄位 +- **CP = 選擇 tasks 的指南**——決定「這次交貨先拿哪幾張」,**它自己不發號** +- **wiki = 過程、證據、為什麼**——引用 `#號`,不重複任務狀態 + +> **一句話**:leo 拖畫面,AI 管資料。兩邊看同一批 issue,不各養一份清單。 + +## 認證(最容易安靜出錯的一步) + +token 從該 repo 的 gitea remote URL 取,不另存: + +```bash +TOKEN=$(git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') +``` + +- 🔴 **不帶 token 打私有 repo 會回 `{"message":"not found"}`**——長得像「這裡沒東西」 + - 2026-08-09 實錯:據此把 **24 個 open issue 宣告成不存在**,還寫了一份錯的提案 + - ⇒ **「查不到」有兩種:真的沒有 vs 我沒權限看見。分不出來就不准下結論** +- 分辨法:沒權限回 **JSON** `{"message":"not found"}`;路由不存在回**純文字** `404 page not found` + +## 三條標準作業流程 + +- **① SDD 定案 → tasks 寫進 issues** + - `POST /api/v1/repos/{o}/{r}/issues` + - 🔴 `labels` 吃 **label id(int)**,傳名字會 422 ⇒ 先 `GET /labels` 拿 id + - body 標來源:`design 推導` / `修改自 #42` / `原生` +- **② 外面進來的先驗傷,再決定要不要進 sprint** + - 新 issue **預設不掛 `s/`**,先用內建 `bug`/`question`/`invalid`/`duplicate` 分類 + - 確認「要做、且屬於某次交貨」才掛 `s/todo` + 掛 milestone + - ⇒ **不是每個 issue 都是 task**(查完是設定問題就 close,不進 SDD) +- **③ 先決條件(issue 之間的關聯)** + - `POST /issues/{n}/dependencies`(我被誰擋)/`/issues/{n}/blocks`(我擋誰) + - 🔴 **body 是 `IssueMeta`,要三個欄位:`{"index":22,"owner":"Leo","repo":"arcrun-rag"}`** + - 只傳 `index` 會回 **404 `IsErrRepoNotExist`**(同 repo 也一樣要帶 owner/repo) + - 📌 這條原本是我憑 swagger 路徑列表寫進本 skill 的,**沒實測**,一測就錯。 + **端點存在 ≠ 我知道怎麼呼叫它**——寫進 skill 前要真跑一次。 + - 實測通過(2026-08-09):`#21 blocked by #22` 建立後, + `GET /issues/21/dependencies` → `[22]`、`GET /issues/22/blocks` → `[21]` + - 派工前先檢查前置是否 `closed`,**不要靠人記得順序** + +## 狀態標籤:互斥 scope label + +**一條線走完,從進池子到用戶拿得到:** + +``` +s/triage → s/backlog → s/todo → s/doing → s/stage →(leo 蓋章 + arm + 推 prod)→ closed + ↘ s/pending(卡住/等外部/等人) +``` + +- `s/triage` 新進來的,**還沒驗傷**——還沒決定要不要做 + - 🔴 **為什麼不用「留白」代表未驗傷**(2026-08-09 leo 建 Triage 看板時暴露): + 留白**查不出來**。撈得到 `s/todo`,卻撈不到「所有還沒驗傷的」,只能肉眼掃 + ——那正是這套要拔掉的東西。**沒有標籤 ≠ 一種狀態,它是查詢的死角。** +- `s/backlog` 驗過了、**確定要做**,但還沒排進任何 sprint + - = leo 說的「**wishlist/功能需求/後續要規劃的**」 + - 與 `s/triage` 的差別:**要不要做,已經有答案了** + - 與 milestone 的關係:`s/backlog` = 沒掛 milestone;掛上 milestone 就該進 `s/todo` +- `s/todo`  **已排進 sprint**,等開工 +- `s/doing` 現在有人在做 +- `s/stage` **已推上 stage,等 leo 去 youlin 的 stage 環境驗收**(出貨流程第⑤步) + - leo 2026-08-09 提:「就知道哪些要驗收」 + - 🔑 **它的價值是可查詢**:leo 問「我今天要驗什麼」=撈 `labels=s/stage` 一次答完, + 不必總管回想、不必翻對話 +- `s/pending` 卡住——等外部/等人,**不是沒人做**(例:`#8` 等 leo 在真 Windows 截圖) +- `closed`  **用戶拿得到了**——不是「程式碼寫完」,也不是「leo 看過 stage」 + +## 優先級是另一個軸:`p/` scope + +`p/high`(擋住交付或有時間壓力)/`p/low`(想做,但晚一點沒關係)。 + +- 🔴 **為什麼不併進 `s/`**:`s/` 回答「它走到哪」,`p/` 回答「它多重要」—— + **一個 `s/backlog` 的東西也可以是 `p/high`**(想做很久、很重要、只是還沒排進這次 sprint)。 + 併成一組就表達不出來。 +- 兩組各自 exclusive,互不干擾:一個 issue 同時有一個 `s/` 和至多一個 `p/` + +## 對照 leo 的 Triage 看板欄位 + +- `Needs Triage` ← `s/triage` +- `Backlog` ← `s/backlog` +- `High Priority`/`Low Priority` ← `p/high`/`p/low` +- `Closed` ← issue 的 `closed` 狀態 + +⇒ **欄位仍是 leo 的視覺、標籤仍是機器的真相**,兩邊講同一件事但各自可用。 + +🔴 **為什麼用 `s/stage` 不用 `s-stage`**: +`s-stage` 是**平名**標籤,會跟 `s/doing` **同時掛著**; +`s/stage` 在同一個 scope 內,貼上去舊狀態自動掉 ⇒ **才是狀態機**。 +狀態標籤一律走 `s/` 前綴,別建平名的。 + +- 建法:`POST /labels`,body 帶 `{"name":"s/doing","exclusive":true}` +- **`scope/name` 格式 + `exclusive` ⇒ 同一 scope 下一個 issue 只能掛一個** + ``` + 貼 s/todo → ['bug', 's/todo'] + 再貼 s/doing → ['bug', 's/doing'] ← 舊狀態自動掉,不相干的 bug 留著 + ``` +- 🔴 **不建 `s/done`**:`closed` 就是 done,多一個就是同一件事兩個真相 +- 🔴 **label 是 repo-scoped,沒有跨 repo 繼承**——新 repo 要自己補 + +## AI 做得到 / 做不到(別浪費時間試) + +- ✅ 做得到:開 issue、改 issue、掛/換 `s/*` 標籤、**建 milestone**、把 issue 掛進 milestone、 + 建先決條件、留言、結案、從 `GET /issues/{n}/timeline` 讀出「這張卡在哪個看板」 +- ❌ 做不到:**建 project**、**把卡拖進 project**、**知道卡在哪一欄** + - `POST …/projects` 回**純文字** `404 page not found`=路由不存在 + - **對照組證法**:同一把 token 打 `POST /labels` 回 201 + ⇒ 證明「不是我沒權限,是 Gitea 沒開這條路」。**這個手法值得複用。** + - issue 物件無 project 欄位;`timeline` 的 `project_board` 事件也沒有欄位名/id +- ❌ **不採**:直寫 Gitea 的 `project_issue` 表、web-only 路由 + `POST /{owner}/{repo}/issues/projects/column`(吃 session cookie+CSRF) + - 理由不只是升級風險:**那是別人家 app 的內部 schema/未公開契約**, + 用了等於在系統裡多一條沒人維護的隱性契約——與「兩份真相」同病 + +## 兩個會咬人的坑 + +- **`labels` 在兩支 API 是兩種型別** + - 建 issue `POST /issues` → 吃 **id(int)**,傳名字會 422: + `json: cannot unmarshal JSON string into Go int64 within "/labels/0"` + - 貼標籤 `POST /issues/{n}/labels` → 吃 **名字(string)** +- **本機是 zsh,不做參數字串切分**(bash 才會) + - `for n in $LIST` 在 zsh 會把整串當成一個字 ⇒ 用 `${=LIST}` 或逐一傳參 + +## 🔴 怎麼寫一張 issue(leo 2026-08-09 連罵三次立的) + +leo:「**請問這個是人話嗎?誰看得懂?**」 +「你的 issues 要寫清楚 **5W1H**,你會做重複工,表示你不知道自己在幹嘛」 +「**先寫問題,再寫解法,不是先寫一堆細節**」 + +### 🔴 為什麼要寫人話——不是為了友善,是為了讓 leo 抓得到我的錯 + +leo 2026-08-09: +> 「**你搞不清楚的時候,我也無法幫你看,因為我看不懂你在寫說明。**」 + +這是所有「白話鐵律」底下真正的理由: +**看不懂 = 審查失效。** 我今天在同一個 session 裡錯了好幾次 +(把 24 個 open issue 讀成 0、給 leo 一台他根本沒在用的機器的網址)—— +如果我寫的東西他看得懂,這些會更早被擋下來。 + +⇒ **用內部代號寫票,等於把唯一能發現我出錯的人的眼睛關掉。** + +### 🔴 修好的當下就關票(今天一天撞三次) + +2026-08-09 派工做 `#9`/`#14`/`#17`,**三張都是早就修好、只是沒人關** +⇒ 三個 subagent 的時間全花在確認「這件其實已經好了」。 + +- **不是那三張票的問題,是「修好時沒有人回來關」的問題** +- ⇒ 改完 → push → **當場回票寫清楚怎麼驗的、然後 close** +- ⇒ 撈任務前先問「這張票的日期多久了?」,超過一週的**先驗現況再派工** + +### 起點:issue 是「報告」,不是「工單」 + +leo 2026-08-09: +> 「如果我要丟,我會寫『**我遇到一個問題是…**』『**發現一個 bug 是…**』 +> 『**我希望有一個功能是…**』,**怎麼會寫成這樣?**」 + +⇒ **一張 issue 應該長得像一個人開口講話。** +先用第一人稱把事情講一遍,再談要做什麼。寫成規格條目就沒人看得下去。 + +**那三種開頭正好就是三種類型,驗傷就是在判斷它是哪一種:** + +- 「我遇到一個問題是…」→ 卡住了 → `question` +- 「我發現一個 bug 是…」→ 壞掉了 → `bug` +- 「我希望有一個功能是…」→ 願望/功能需求 → `enhancement`(多半配 `s/backlog`) + +範例(#7 現在的樣子): +> 我遇到一個問題是:我要把 AI 接上我的知識庫,claude.ai 第一格就要我填 MCP 網址, +> 但我不知道我的是什麼。那串網址是安裝的時候長出來的,每個人都不一樣,我沒地方查。 + +### 標題:寫症狀,不寫修法 + +反例(真的誤導了好幾個月的那一句): +``` +t151 收尾:安裝器要生成並下發 arcrun-mcp 的 MCP_OWNER_SECRET(擋封測,缺它每個封測者都死在同意頁) +└代號┘ └────────── 這整段是「怎麼修」,而且是後來被推翻的舊解法 ──────────┘ └─ 唯一人話在括號裡 ─┘ +``` +改成:**「用戶不知道自己的 MCP 網址是什麼,所以接不上 AI」** + +- **① 標題寫症狀不寫修法**——**修法會變,症狀不會**。 + 上面那句最毒的地方不是難懂,是它把「當時以為的修法」寫死在標題上; + 後來 owner secret 這條路被 portal 帳密取代,**標題還停在舊解法,於是持續指錯方向**。 +- **② 標題裡不准出現只有我們懂的字**:代號(`t151`)、零件名(`MCP_OWNER_SECRET`)、 + 檔名(`build-bundles.mjs`)一律降到細節區。 +- **③ 一句話講完「誰+卡在哪」**,看標題就知道要不要點進去。 + +### 內文:問題 → 解法 → 細節(細節收進 `
`) + +```markdown +## 問題 +<用戶視角一句話:他想做什麼、卡在哪、結果怎樣> + +## 解法 +<一句話:要做出什麼> + +## 怎麼驗 +<走用戶真的會走的那條路;貼實際畫面,HTTP 200 不算> + +--- +
細節 +現況實查(指令+輸出)/真身在哪個 repo 哪個檔/可照抄的既有寫法/ +leo 原話/不屬於本 issue 的(已拆到 #N) +
+``` + +**為什麼細節要收起來**:leo 掃 issue 是在決定「這件要不要現在做」, +細節是做的人才需要的。攤開來就掃不動 ⇒ 他掃不動 ⇒ 這件事不會發生。 + +## 🔴 討論票 → 執行票;追蹤大的(2026-08-10 定案,D58) + +> leo:「這個 issue 是對於**某個建議的討論過程**,中間會產生一個**新的 issue 就是依照討論執行某個修正**,**追蹤大的**。」 +> leo:「**充分利用 gitea 的機制,不要硬做個不支援的機制,容易出錯。**」 + +- **討論票**=一個建議的討論過程(脈絡、來回、為什麼)。**不當工單追。** +- **執行票**=從討論裡長出來的「去做某個修正」。有自己的擋點、驗收、`s/*` 狀態。 +- **追蹤執行票**;孫任務留在執行票裡當 checkbox(見下一段,那條沒變)。 + +### 定址:用 Gitea 原生的 issue 引用,不要自製結構 + +Gitea 原生就會把 issue 引用自動變成可點連結 ⇒ **CP/SDD 不必手貼網址、不必造錨點。** + +- 🔴 **最好用的判準**:**需要指到票裡的某一行 = 那張票該拆了。** + 這個限制反而**強迫出正確的顆粒度**。 +- **刀口不是「大小」,是「狀態」**:問「**它會不會需要跟隔壁那條不同的狀態?**」 + - 會 → 獨立成執行票 + - 不會 → 留在票裡當 checkbox + +### 兩個極端都撞過,別再撞 + +- **太大**:`Leo/mira#2` 一張票 29 個 checkbox、五個段落**五種狀態**,卻只掛得了一個 `s/pending` + - ⇒ `⑤-1`~`⑤-3` 整段作廢了好幾小時沒人發現——**29 條的票不會有人逐條稽核** +- **太小**:08-09 把 stage 一件事拆成五張(`#27`/`#29`~`#32`),**leo 打開就看不下去** + +### ⚠️ 已作廢:隱形錨點 `
`(D53,同日上線同日推翻) + +**不要撿回去。** 它是塞 raw HTML 偽造 checkbox 錨點,靠渲染器願意保留、 +靠沒人用網頁編輯器把它洗掉——**脆弱,而且壞掉時看不出來**。 + +📌 **它錯在哪值得記**:整段推理是嚴謹的(實測三則、對照兩個反例、接上既有鐵律), +**但沒有先問「這個平台原生支援嗎」**。與 08-09「平台已經做了的事我又做一次」同族, +只是這次是**平台沒做的事我硬做**。 +⇒ **造任何機制之前先問兩句**:「原生支援嗎?」「不支援是不是在告訴我方向錯了?」 + +## 🔴 子任務用 issue 內的工作項目清單,不要開成一堆票(2026-08-09 實撞) + +leo:「**子任務在這裏寫就好了**」(指 Gitea 編輯器的工作項目清單按鈕)「**現在這樣太亂了**」 + +- **一件事 = 一張票**,票裡用 `- [ ]` 列子任務(**每條帶錨點,見上一段**) +- ❌ **不要把每個子任務開成獨立的票** + - 2026-08-09 實錯:總管把 stage 這一件事拆成 `#27`/`#29`/`#30`/`#31`/`#32` 五張, + leo 打開就看不下去。已收攏回 `#27` 一張,其餘關閉 + - 病根:把 leo 說的「寫在 issues 裡的**新增工作項目清單**」讀成「開新 issue」, + 而他指的是**編輯器裡那顆 checkbox 按鈕** +- 🔑 **規則(leo 原話)**: + > 「**整個 tasks 內容用 markdown 寫進去,除非某個任務開始工作後變大了,就要獨立成一張票。**」 + - ⇒ **預設全部寫成 checkbox**,包含整份 SDD 的 tasks + - ⇒ **獨立成票是「事後」的動作**,不是規劃時就先拆 + - 觸發時機=**開始做了之後才發現它變大**(要多輪、要另一個 repo、要另一個人) + - 那時再把那一條抽出來開票,並在原票的該行改成 `- [ ] 見 #NN` + - ⇒ **不要在規劃階段就預先拆票**——那正是 2026-08-09 弄亂的做法 +- **另開一張的另一個正當理由**:**主詞不同**(見下一段) + ——例如「網址看得到」vs「進得去」是兩個人在抱怨兩件不同的事,那本來就不是同一件 + +## 🔴 一筆 issue 只做一件事(2026-08-09 實撞) + +leo:「#18 其實是 2 個分開的任務⋯⋯**一半已經完成卡在另一半,但另一半根本不相依**」 + +- **綁在一起的代價**:做完的那半被沒做的那半拖著,**看起來像沒完成** + ⇒ 進度儀表失真,而 leo 是靠這個判斷還剩什麼 +- **判準(同 `cp-write` 鐵律 5)**:**主詞不同就拆** + - 「把設定搬進應用視窗」=設定放哪的問題 + - 「新增 onboarding」=第一次的人怎麼上手的問題 + - 兩者互不依賴 ⇒ 一定是兩張 +- 🔴 **不准用「併入此案」把別的 issue 吞進來** + - #18 原本寫「併入此案:狀態顯示(見另一 issue)」,而那是獨立存在的 #17 + - ⇒ 同一件事在兩張票上,**又是兩份真相**;要關聯用 `dependencies`/`blocks`,不用文字吞併 +- **拆單時要留痕**:兩邊都寫明「從 #N 拆出」與「為什麼不相依」, + 否則下一個人會以為是重複開單 + +## 🔴 設計寫在票上——不要每次回去 grep 源碼(leo 2026-08-09 立) + +> leo:「你應該**把 design 寫在 issues 裡**,你就可以**去查 issues,而不是每次去查源碼**。」 + +**為了回答問題而查出來的「它現在怎麼運作」,當場寫回那張票,附 `檔案:行` 當出處。** + +- **當天實錄**:同一個 session 裡,為了回答四個問題各翻了一次源碼—— + console 現在用來做什麼(`installer/oauth-prototype/worker.js:3237`)/ + 迎新引導為什麼看不到(`main.js:73` 只在零帳號時出現)/ + 重裝會不會重設密碼(`/console/setup` 已存在回 409)/ + bundle 是快照還是連結(manifest 的 `source` 欄與 `js_bytes`)。 + **四題的答案都值得留在票上,卻只留在對話裡** ⇒ 對話結束就沒了,下次再翻一次。 +- 🔴 **而且翻源碼拿到的是「稿子」**:它反映「還沒清乾淨」,不等於「還在用」。 + 同一天就因為讀源碼推論而把兩台實例的定位講反(geek=prod/youlin=stage)。 +- **票上該有的三件**:**它現在怎麼運作/為什麼這樣設計/動它會影響誰**——不只是「要做什麼」。 +- **順序**:先查票 → 查不到才翻源碼 → **翻完回頭補進票**(與 wiki 同一階梯)。 +- **判準一句話**:**如果我剛剛翻了源碼才知道答案,那就是一條該補進票的設計記錄。** + +## 界線(沿用,未變) + +- **跨 repo 的 issue/comment 一律開頭署名** `[<本 repo> CC]`/`[InkStoneCo 總管]` + - 所有 repo 共用同一個帳號,author 看不出是誰發的,身份只能在內容層自報 +- **發 issue 給別的 repo 要先問人**;對自己 repo 開 issue 記待辦則直接做 +- 🔴 **有事才讀,禁止自動輪詢**——禁 Actions/cron/webhook 因 issue 事件 fan-out + (那正是當初害 GitHub 帳號被 flag 的流量模式;換成 Gitea 也不放寬) + +## 🔴 標 `s/stage` 不等於交棒完成——要附「怎麼測」 + +leo 2026-08-09:「**要我來測試是嗎?如果這樣,你要在總管回覆中說明測試方式, +而不是『要測試』,我就會按照它來測試。**」 + +- 把票改成 `s/stage`/`s/pending` 只是**改了一個欄位**,leo 看到的是「該你了」卻不知道怎麼做 + ⇒ 等於把找路的成本丟給他,正是 principles「不增加 leo 負擔」要防的 +- **每一件推給 leo 的事,回覆裡必須帶三樣**: + - **他要打開什麼**(確切網址/確切指令,**不是「去 portal 設定頁找」**) + - **他該看到什麼**(正確的樣子長怎樣) + - **什麼情況算失敗**(看到什麼就是壞的,回一個詞給我) +- 🔴 **指令必須自己先打過**。給沒驗過的網址/指令 = 把除錯成本丟給他 + - 2026-08-09 實例:我差點把 `arcrun-mcp.yuga3bse.workers.dev` 給他—— + **實打是 HTTP 000(DNS 不解析)**。正確的是 `WORKER_SUBDOMAIN` + (`deployment-map.md:176` = `youlin-hsieh-dev`),實打回 401 `unauthorized` + =端點在、要 OAuth,**這才是對的行為** + - ⇒ **401 和 000 都不是 200,但意義天差地遠**:前者證明東西在,後者證明我在瞎猜 + +## 收工判準 + +回覆 issue 要寫**做了什麼、為什麼這樣決定、動了哪些檔**,不是只回「done」。 +結案要有**實測證據**——「程式碼寫完了」不是狀態(見 `CRITICAL-PATH.md`)。 + +## ⏳ 尚未驗證的一條(別當成已知) + +**把卡拖到 Done 欄,Gitea 會不會自動 close 那個 issue?** +2026-08-09 想測,但那張測試卡被刪掉了 ⇒ **無證據,不編**。 +若答案是「會」,拖拉就天然被機器看見,這條縫自動消失。 diff --git a/commands/sdd-check.md b/commands/sdd-check.md new file mode 100644 index 0000000..fcf6e87 --- /dev/null +++ b/commands/sdd-check.md @@ -0,0 +1,65 @@ +--- +name: sdd-check +description: | + 開始任何開發任務前確認有沒有對應的 SDD——要寫 code、開新功能、 + 或不確定「這件事屬於哪份規格」時自動載入。 + 依 D35 SDD 生命週期鐵律:任何時刻只允許一份 status: active 的 SDD, + 所有開發任務對應它的 tasks,找不到就停下來問(不得自行建 SDD); + 規格層變更改開 Gitea 票(Human+指派 Leo)後停止等 confirm——pending-changes.md 已於 2026-08-19 廢除,禁止寫入。 +--- + +# /sdd-check — 確認當前任務有沒有對應 SDD + +動手前執行。確保 CC 有全局觀,不會在沒有設計文件的情況下猛衝。 + +--- + +## 執行流程 + +### 第一步:理解任務 + +確認使用者要做什麼: +- 涉及哪個子系統? +- 是新功能還是修改現有功能? +- 影響範圍? + +### 第二步:尋找對應 SDD + +在 `docs/3-specs/` 下尋找對應的子系統目錄,確認有沒有: +- `design.md`(設計文件) +- `tasks.md`(任務清單) + +### 第三步:根據結果回應 + +**情況 A:找到對應 SDD** +``` +✅ 找到 SDD:docs/3-specs/[子系統]/ +📋 design.md:[確認] +📋 tasks.md:[確認,列出相關 task] +🎯 對應 task:[編號和描述] +繼續嗎? +``` + +**情況 B:找不到 SDD,任務明確** +``` +⚠️ 找不到對應 SDD +任務:[描述] +建議在 docs/3-specs/[建議子系統名]/ 建立 SDD + +要我幫你起草 design.md 嗎?(需要你確認後才動手) +``` + +**情況 C:找不到 SDD,任務模糊** +``` +⚠️ 找不到對應 SDD,而且任務範圍不夠清楚 +請先回答: +1. 這個功能屬於哪個子系統? +2. 完成的標準是什麼? +3. 有沒有不能動的邊界? +``` + +### 注意 + +- 找不到 SDD **不等於可以直接動手** +- 小修改(修 bug、改文字)可以豁免,但要明確說「這是小修改,範圍是 X」 +- 新功能、架構變動、跨模組的修改 → 一定要有 SDD diff --git a/commands/wiki-capture.md b/commands/wiki-capture.md new file mode 100644 index 0000000..d10e13b --- /dev/null +++ b/commands/wiki-capture.md @@ -0,0 +1,69 @@ +# /wiki-capture — 把對話結論存進 wiki + +把這次對話中產生的決策、誤解釐清、或重要結論存入 wiki。 +解決「討論過了但知識消失」的問題。 + +--- + +## 執行流程 + +### 第零步:機敏檢查(寫入前一律先過) + +把任何內容寫進 wiki 前,先確認**不含**密碼 / API 金鑰 / 私鑰 / 連線字串帳密 / 個資(身分證、信用卡)。 +- 命中 → 不要記「值」,改記「位置」(例:「DB 密碼放 `.env`,不入 wiki」) +- 來源整檔機敏 → 提醒使用者加進 `system-dev/wiki/.wikiignore` +- 真要保留示範格式 → 該行尾加 `wiki-secret-ok` 標記 +> 這是協議層自律。最後一道 `wiki-secret-scan.sh` hook 會在寫入 `system-dev/wiki/` 時機械攔截,但別依賴它兜底——當場就不要把機敏值帶進來。 + +### 第一步:辨識對話中的可記錄內容 + +掃描當前對話,找出: + +| 類型 | 判斷標準 | 存到哪 | +|------|---------|-------| +| 架構決策 | 「為什麼選A不選B」「我們決定用X」 | `decisions-summary.md` + `system-dev/docs/2-architecture/decisions/` | +| CC 的誤解被糾正 | CC 說了某件事,使用者說「不是,是...」 | `mistakes.md` | +| 重要狀態更新 | 完成了某件事、阻擋了某件事 | `status.md` | +| 技術發現 | 踩到坑、找到解法、重要行為確認 | `mistakes.md` 或對應 SDD | + +### 第二步:列出清單給使用者確認 + +格式: +``` +這次對話我整理了以下內容要存入 wiki: + +1. [MISTAKE] CC 誤解了 X,正確是 Y +2. [DECISION] 決定用 A 不用 B,原因是 C +3. [STATUS] 完成了 task 2.3,下一步是 2.4 + +確認後存入,有需要修改的嗎? +``` + +**停下來等確認。** + +### 第三步:寫入 + +確認後,依照格式寫入對應檔案: + +**mistakes.md 格式:** +``` +⚠️ MISTAKE: [錯誤描述] + 症狀: [CC 的表現] + 正確做法: [應該怎麼做] + 原因: [背景] + 日期: [YYYY-MM-DD] +``` + +**decisions-summary.md 格式:** +``` +## [主題] — [YYYY-MM-DD] +**結論**:[一句話] +**原因**:[簡短說明] +**詳細**:system-dev/docs/2-architecture/decisions/[檔名] +``` + +重大決策同時在 `system-dev/docs/2-architecture/decisions/` 建立 ADR 檔案。 + +### 第四步:確認 + +告知存到哪些檔案,共幾條記錄。 diff --git a/commands/wiki-init.md b/commands/wiki-init.md new file mode 100644 index 0000000..c9b0ef6 --- /dev/null +++ b/commands/wiki-init.md @@ -0,0 +1,230 @@ +# /wiki-init — 初始化或接入 LLM Wiki 系統 + +初始化這個專案的 LLM Wiki 記憶系統。 +新專案建立空白結構,已有專案掃描現有文件並**改寫**成 wiki。 + +--- + +## 核心概念:wiki 是 AI 改寫過的記憶,不是原文索引 + +記憶系統的目的,是讓 AI **之後讀得快**。但人類寫的原始文件——不管是 vault 的隨手記、開發專案的會議記錄、規格草稿、散落的 `.md`——天生是亂的:重複、流水帳、半成品、口語。 + +如果 wiki 只是一份 `[[原文檔名]]` 指回原文的**索引**,那每次未來要用都得重新解析那團亂,等於沒省到。**wiki 的價值在於「改寫一次,之後每次讀都便宜」**。 + +所以原則對**所有專案**一致(不分 vault 或一般開發): + +> 人類寫的原文是 **SSoT**(真理來源,永遠唯讀)。 +> 但實際要長期保存、被 AI 反覆讀的是 **AI 改寫整理過的 wiki**。 +> **AI 是總編輯**——把原文改寫成自包含、概念原子化、互相連結、適於 AI 讀的知識條目。 + +唯一例外:原文是**不可改動的正式文件**(簽署過的規格、法規、合約),必須逐字讀原文——這種才在 wiki 裡用指針指回去,並註明「逐字依原文」。除此之外,一律改寫。 + +**raw source 永遠唯讀**:所有產出只往 `system-dev/wiki/` 寫,絕不改動、搬移、重新命名原文。 + +--- + +## 執行流程 + +### 第一步:偵測專案狀態 + +檢查以下項目,判斷是新專案還是已有專案: +- 根目錄有沒有 `system-dev/wiki/` +- 根目錄有沒有 `docs/`(或 vault 的 `pages/`、`journals/`、根目錄 `.md`) +- 有沒有散落的 `.md` 檔案 + +同時**偵測 raw source 路徑**(同 install.sh 邏輯): +- 根目錄有 `logseq/` → Logseq vault,raw source = `pages/` + `journals/` +- 根目錄有 `.obsidian/` → Obsidian vault,raw source = 根目錄所有 `.md` +- 都沒有 → 一般專案,raw source = `docs/` 下所有 `.md`(及散落的 `.md`) + +**新專案**(幾乎空的)→ 直接建立結構,跳到第三步 +**已有專案**(有文件)→ 執行第二步 + +### 第二步:已有專案的掃描(已有專案才執行) + +1. 遞迴找出 raw source 裡所有 `.md` 檔案 +2. **先套用 `system-dev/wiki/.wikiignore`**:命中 pattern 的檔案整個排除,不讀不編入。 + - 若 `.wikiignore` 不存在,從範本建立一份(預設排除 `.env`/`*.pem`/`*secret*` 等) + - 被排除的檔案在清單裡標「🚫 .wikiignore 排除」,**不可被覆蓋** +3. 對其餘檔案標注**改寫計畫**:會萃取成哪些 wiki 條目。一份原文可能拆成多個概念原子條目,多份相關原文也可能合併成一條。 +4. 列出清單給使用者確認,**停下來等確認** + +> **量大時建議用 Haiku 改寫**:逐份原文「改寫成 wiki 格式」是重複、機械、判斷成本低的工作——正適合 Haiku。原文數量多(如數十、上百份)時,主動建議: +> 「共 N 份原文要改寫,這類逐份萃取很適合用 Haiku 並行處理(便宜、夠快)。要我派 Haiku subagent 改寫嗎?」 +> 得同意後,用 Task / subagent 把每份原文(或每批)丟給 Haiku 改寫,主模型只負責切分概念、定條目邊界、最後審稿與互連。 + +> 機敏防護(三層): +> - **L1 .wikiignore**:整檔排除(這一步) +> - **L2 行內標記**:檔案要編入但某段不要 → 遇到 `` … `` 之間的內容**略過**,只留「(此處機敏,已略過)」 +> - **L3 hook**:萬一機敏值仍被寫進 wiki,`wiki-secret-scan.sh` 會 exit 2 擋下 +> 編入任何檔案前,先檢查是否含密碼/金鑰/個資——有就改記「位置」而非「值」。 + +### 第三步:建立缺少的結構 + +只建立不存在的目錄和檔案,**已有的一律不動**。 + +wiki 採**三層 + 標籤橫切**架構(183 卡實證,issue #8): + +``` +system-dev/wiki/ +├── INDEX.md ← 索引:多角度視圖的家(標籤角度、決策角度、…) +├── TAXONOMY.md ← 標籤字典(cards 的分類元資料,受控擴充) +├── status.md ← [push] 時態狀態:當前進度、下一步 +├── mistakes.md ← [push] 踩過的坑、被糾正的誤解(防不自覺盲區) +├── principles.md ← [push] 跨全局的設計原則(行動前必服從) +└── cards/ ← [pull] 一切知識內容:原文摘要、AI 筆記、決策、概念… + └── / ← 儲存桶(分類由 frontmatter 標籤承載) + ├── 00-INDEX.md ← 桶子索引(固定名,容器:只連不重寫,H2/H3 分節) + └── <概念全名>.md ← 概念原子卡(一概念一檔,自包含) +``` + +> **[push] / [pull] 是這套 wiki 的核心判準——因為 wiki 主要是給 AI(CC)看的。** +> 見下方「核心判準:push vs pull」。`decisions-summary.md` 已**降級為 cards + INDEX 決策視圖**(決策是知識內容=card);既有的 decisions-summary 若存在,保留為相容,不刪。 + +關鍵原則:**資料夾只是儲存桶,分類由 frontmatter 標籤承載**。資料夾名不該硬繼承原稿目錄——原稿目錄是「人為了整理草稿」分的,wiki 連分類都該由 AI 重新組織。 + +> **桶子索引固定叫 `00-INDEX.md`**(issue #6):`00-` 前綴讓它排序最前、一眼可辨(像 README 之於資料夾),AI 載入任何 `cards//` 一律先讀它,不必猜。檔內 H1 仍寫主題名(如 `# PKM 知識管理`),語意不丟。 + +一般專案仍可同時建 `system-dev/docs/` 分類樹(SDD 等): +``` +system-dev/docs/{1-vision,2-architecture/decisions,3-specs,4-guides,5-records/{incidents,test-reports},6-user} +``` +(純 PKM vault 不需要 `system-dev/docs/` 分類樹時,只建 `system-dev/wiki/`。) + +檔案(不存在才建): +- `system-dev/wiki/INDEX.md`、`TAXONOMY.md` +- `system-dev/wiki/status.md`、`mistakes.md`、`principles.md`(三個 push 檔) +- `system-dev/docs/README.md`(一般專案才需要) + +--- + +## 核心判準:push vs pull(wiki 是給 AI 看的) + +整理任何內容前,先判斷它該 **push** 還 **pull**——判準是「**CC 做事時會不會被動看見**」: + +- **push**:CC 行動前必須主動出現在 context(session 開始就由 hook 注入)。給「CC 不會主動去查、但不看就出事」的東西。 +- **pull**:CC 想到要查、或載入相關卡時才看見。給「CC 面對它時自然會查」的知識。 + +**為什麼這是核心**:mistakes 防的是 CC「不自覺的盲區」——一個你不知道存在的錯,你不會主動去檢索它。靠 CC 自覺去查自己沒自覺的盲區是自相矛盾的,所以 pull 對盲區失效,**必須 push**。原則同理:沒被推到眼前的準繩,CC 設計時很可能沒想到要服從就做了。 + +| 內容 | push/pull | 注入形態(hook)| +|------|-----------|----------------| +| **status** | push | **全文**——CC 必須知道精確的下一步,摘要會漏 task 編號 | +| **principles** | push | **全文(一行一條)**——短而硬的約束,漏一條就違反;≤15 條,超過代表該下放成 card | +| **mistakes** | push | **標題清單 + 一行症狀**,全文按需 pull——量可能大,摘要足以觸發「我正撞到某條」的認出 | +| **decisions、原文摘要、概念知識、一切其餘** | pull | 寫成 cards;CC 面對時自然會查,INDEX 提供角度入口 | + +**principles 維護規則**:一行一條精煉準繩(如「不污染用戶根目錄」「目標用戶 low-code」「wiki 主要給 AI 看」)。發現新的跨全局原則 → append 一行;超過 ~15 條代表某些該合併或下放成 card。**累積原則只改 principles.md,不必問用戶開新檔。** + +### 第四步:訪談(每次一個問題) + +依序問: +1. 這個專案做什麼?(一句話) +2. 有哪些絕對不能違反的限制?(技術棧、架構原則等) +3. 現在進行到哪個階段? +4. 有沒有 CC 曾經犯過的錯要先記下來? + +把答案填進 `CLAUDE.md`(如果存在)或建立新的。 + +### 第五步:改寫成 wiki(AI 當總編輯) + +(第二步確認後執行) + +**不搬動原文**。逐份讀 raw source,改寫萃取成 `cards//` 裡的自包含原子卡: + +- **概念原子化**:一張卡講一個概念,不是一篇原文對一張卡。原文太雜就拆,多份相關原文就合。 +- **自包含**:讀卡就懂,不必回去翻原文。把口語、重複、流水帳改寫成結構化知識,**不寫「詳見原文」**。 +- **保留來源指針**:每卡標 `**來源**:原文相對路徑`,為可追溯,不是要使用者回去讀。 +- **frontmatter 標籤分類**(見下方):分類走 frontmatter `tags:`,不靠資料夾、不靠行內 `#tag`。 +- **互相連結(typed-edge 三元組)**:`## 關聯` 不只列裸 `[[頁面]]`,改寫成帶語義的三元組(見下方)。 +- **萃 gloss(node 一句說明)**:frontmatter 放 `gloss:` —— 這張卡(= 一個 entity / graph node)的一句話定義,供下游語義 normalize(見下方)。 + +卡片格式(每張卡): +```markdown +--- +tags: [知識管理, AI協作, 方法論] +gloss: 一句話定義這個概念是什麼(給下游語義 normalize 用,選填、deep tier 才產) +--- +# 概念全名 + +← [[/00-INDEX]] + +**來源**:`[raw source 相對路徑]` +**最後更新**:YYYY-MM-DD + +## 摘要 +[一句話核心] + +## 重點 +- [自包含改寫的要點,不依賴原文] + +## 實體 +> 本卡內文的關鍵實體(也是 graph node)。名+描述供下游 embedding normalize。集中放、一行一個、不縮排、不重複。 +- **原子筆記**(atomic note/卡片原子化)— 每張卡只承載一個不可再分論點的知識記錄單元。 +- **傳統筆記**(大鍋炒筆記)— 把多主題混雜在同一篇、難精確引用的記錄方式。 + +## 關聯 +### 內文知識關係(內文實體間;端點=上方 `## 實體` 正規名,一字不差) +- 原子筆記 >> 對立於 >> 傳統筆記 +### 卡片關係(卡對卡) +- [[本卡]] >> 謂詞(動詞短語) >> [[他卡]] +``` + +**麵包屑用帶路徑 wikilink**(issue #7):H1 次行放 `← [[/00-INDEX]]` 指回桶子索引。 +桶子索引固定名 `00-INDEX` 跨桶會撞名,故**指 00-INDEX 一律帶路徑**(`[[pkm/00-INDEX]]`,Logseq 原生支援、下游 ingest 也能對應到具體檔)。普通卡片間連結仍用裸 `[[卡名]]`(卡名唯一,不需路徑)。 + +**frontmatter 標籤分類**(issue #8): +- **用 frontmatter `tags:` 而非行內 `#tag`**:卡片內文常大量用 `#`(講筆記法時的 `#猜想`、`#book100`),分類標籤若也行內 `#`,下游 ingest 無法區分「分類」與「內文範例」會污染 graph。frontmatter 與內文完全分開,零歧義。 +- **用標籤而非資料夾分類**:資料夾=強制單一歸屬;標籤=多重歸屬。一張卡可同時屬知識管理+AI協作+架構設計,硬塞一個資料夾會在其他檢索角度漏掉。 +- **雙軸 taxonomy**(寫進 `TAXONOMY.md` 當字典;**受控擴充**,非凍結): + - 領域(主軸,1-3 個):如 知識管理/學習認知/AI協作/生產力/系統設計/工具教學 + - 形態(副軸,0-2 個):方法論/工具實作/觀點主張/架構設計/案例經驗 + - 一般開發專案的軸可不同(如 子系統/層級/決策類型),由 AI 依專案性質提出、寫進 TAXONOMY.md。 + - **遇到現有軸裝不下的內容**:先查是否只是現有標籤的同義詞;確實是新軸才加進 TAXONOMY.md(附定義)再用——**禁止繞過字典在卡片直接冒新標籤**。字典是 per-repo,跨 repo 不必共用。 + +**typed-edge 規則**(issue #5/#11,把「關係」也預編譯,下游 ingest 直接 parse 出帶類型的有向邊): +- **重點抓內文實體關係,不只卡對卡**:卡對卡(`[[卡A]] >> 謂詞 >> [[卡B]]`)只是既有雙鏈加動詞、資訊量幾乎沒增加;價值在內文概念關係(`原子筆記 >> 對立於 >> 傳統筆記`,A/B 是內文概念非卡標題)。 +1. **方向性**:`A >> 謂詞 >> B` 必須讀成「A(謂詞)B」一句通順的話;A、B 順序就是主→賓真實方向。 +2. **謂詞用動詞 / 動詞短語**(反駁、奠基於、犧牲)。**禁名詞當謂詞**——`>> 存儲格式 >>`、`>> 操作體驗 >>` 讀不通,是錯的。 +3. **謂詞自由但別太天馬行空**:「參考/參照」皆可(下游 embed 自動聚類),別寫「瞄了一眼」這種抓不到同義的。 +4. **內文三元組端點用裸文字**(非 `[[wikilink]]`),避免 Logseq 紅色斷鏈;卡對卡那層才用 `[[]]`。 +5. **向後相容**:純 `[[A]]` 仍合法(視為無類型邊),盡量補謂詞。 + +> **★ 硬自檢(Haiku 量產必備)★** 內文三元組端點必須與 `## 實體` 某粗體正規名【一字不差】。**寫完逐條把 A、B 拿去 `## 實體` 比對**,沒有完全相同的 → 這條錯了,改用實體表已有的詞、或把端點補進 `## 實體` 再指它。禁止端點帶括號註解/整句補語/形容詞短語。(實證:光寫規則 Haiku 會略過,端點對不齊 14 條;寫成自檢動作後 14→0。跑 12 張才暴露。) +> `>>` 是分隔語法,repo 可自選符號,但全程一致。 + +**萃 gloss 規則**(issue #9/#11,把「node 的一句說明」也預編譯,供下游 KBDB 語義 normalize): +- **gloss = 這個 entity / graph node 是什麼的一句話**。下游對「entity 名 + gloss」一起做 embedding 求相似度,自動歸一同義詞(比只對名字準、比手維護 alias 表自動)。 +- **兩層 gloss**:① frontmatter `gloss:` 描述卡標題這個 node;② `## 實體` 每行描述句描述內文實體 node。**內文實體也是 graph node、也需描述句**才能 normalize(`黃仁勳` vs `Jensen Huang` 靠描述拉近向量)。 +- **實體要描述、謂詞不用**:實體同義詞字面差遠需描述拉近;謂詞同義詞字面本就近,裸詞 embed 自動聚類。 +- **在知識生產的當下、由 local CC 建**:gloss 跟三元組同階段萃,**不留給下游 ingest 臨時補**——下游只有單檔 / 跨庫視角,編不出貼合的 gloss(=胡扯)。 +- **選填、deep tier 才產**:淺萃(只要結構)時不浪費;deep 改寫時每張卡補。 +- **gloss ≠ 摘要**:`gloss` 是給機器 normalize 的定義句(「X 是…」);`## 摘要` 是給人讀的核心一句。 +- **格式對齊下游 envelope**:frontmatter `gloss:` 與 `## 實體` 詞條對應下游 ingest envelope 的 `nodes[].gloss`,ingest 直接取用。 + +**INDEX.md 是標籤視圖**(非資料夾列表),`00-INDEX.md` 是桶內容器(只連不重寫,H2/H3 分節)。 +頂層索引指桶子索引帶路徑:`[[pkm/00-INDEX]]`。 + +> 與 claude.ai Cowork 的 `system-dev/docs/SKILL.md` 改寫邏輯一致,兩條路徑(CC / Cowork)產出同一種 wiki。 + +### 第六步:完成報告 + 驗證 + +完成後**驗證原文 0 動**(踩過的坑,issue #8): +``` +git status --short pages/ journals/ # 或一般專案的 docs/ ——須 0 新增 0 修改 +``` + +> **改寫時必守**(subagent 尤其): +> 1. **絕不寫入 raw source**:subagent 目標一律給絕對路徑到 `cards//`,明寫「絕不寫入 pages/journals/docs 原稿」;事後用上面的 `git status` 驗。 +> 2. **檔名 = 卡片全名**,否則 `[[全名]]` 對不到檔。冒號用全形「:」、斜線用全形「/」,**全程一種字元**,避免 `/`、`∕`、`:` 混用斷鏈。 +> 3. **量大用 Haiku 並行改寫**,主模型只切概念邊界+審稿+修跨資料夾斷鏈。 + +告知: +``` +✅ wiki-init 完成 +建立了:[列出新建的目錄和檔案] +跳過了:[列出已有因此不動的] +改寫了:[N 份原文 → M 張原子卡、K 條 typed-edge、M 條 gloss(deep tier)] +原文驗證:pages/ journals/ git status 0 異動 ✅ +下一步:用 /wiki-capture 把重要決策存進 wiki +``` diff --git a/commands/wiki-recall.md b/commands/wiki-recall.md new file mode 100644 index 0000000..2159a5e --- /dev/null +++ b/commands/wiki-recall.md @@ -0,0 +1,58 @@ +# /wiki-recall — Session 開始,手動接關 + +開新對話時接上次進度。**Fallback 命令**:SessionStart hook 沒啟動時手動接關;要完整脈絡時也用。 + +> 主路徑是 SessionStart hook 自動注入 status 重點,不靠你打命令。 +> 這支命令應對 hook 失效,以及需要比「status 重點」更完整脈絡的時候。 + +--- + +## 命名閉環 + +init(建) → update(存,session 末) ↔ **recall(接,session 初)** → capture(隨時存結論) + +--- + +## 執行流程 + +### 第一步:讀 status.md(當前進度) + +讀 `system-dev/wiki/status.md`,掌握: +- 正在做什麼、阻擋點 +- 下次 session 第一件事 +- 待負責人確認、已知問題 + +### 第二步:讀 decisions-summary.md(為什麼這樣做) + +讀 `system-dev/wiki/decisions-summary.md`,掌握相關的架構決策——避免重新討論已定案的事。 + +### 第三步:讀 mistakes.md(別重犯) + +讀 `system-dev/wiki/mistakes.md`,掌握已知誤解 + 快速檢查清單。 + +### 第四步:掃 wishlist / HANDOFF(如果有) + +- `docs/wishlist.md`:待補功能 +- 任何 `HANDOFF.md` / 交接note:上一棒留下的脈絡 + +### 第五步:回報接關結果 + +``` +📍 接關完成 +🔄 上次正在做:[status 的「正在做」] +🎯 下次第一件事:[status 的「下次 session 第一件事」] +⚠️ 待確認:[如有] +``` + +--- + +## 鐵律:快照非即時狀態 + +status / wiki 是 **point-in-time 快照,不是即時狀態**。 + +接關 = 讀快照 **+ 核實快照**,**不盲信**。 + +> 實例:某專案 status 曾寫「待 A 收尾 X」,實際 X 早已完成。 +> 照舊資訊行動會去催一件已完成的事。 + +動手前,先用當前 code / git / 檔案核實快照寫的事項是否仍成立。發現落差 → 先更新 status,再動手。 diff --git a/commands/wiki-update.md b/commands/wiki-update.md new file mode 100644 index 0000000..1d5ecfe --- /dev/null +++ b/commands/wiki-update.md @@ -0,0 +1,50 @@ +# /wiki-update — Session 結束,更新狀態 + +每次 session 結束時執行。更新 status.md,確保下次 session 能無縫接上。 + +--- + +## 執行流程 + +### 第一步:整理這次 session 的結果 + +從對話中提取: +- 完成了哪些 tasks(標記為 [x]) +- 進行中但未完成的(標記為 [🔄]) +- 遇到什麼問題或阻擋 +- 下次應該從哪裡開始 + +### 第二步:更新 tasks.md + +把對應 SDD 的 tasks.md 狀態更新(如果這次有動到的話)。 + +### 第三步:更新 status.md + +用以下格式覆蓋 status.md: + +```markdown +# 當前狀態 +> 更新時間:[YYYY-MM-DD] + +## 正在做 +- [🔄] [task 描述] — 阻擋點:[如果有] + +## 下次 session 第一件事 +[具體的第一個動作,越具體越好] + +## 待負責人確認 +- [描述] — 等待:[什麼決定] + +## 已知問題 +| 問題 | 優先級 | 狀態 | +|------|--------|------| +| [問題] | 🔴/🟡/⚪ | [狀態] | +``` + +### 第四步:如果有新的誤解或決策 + +順帶執行 `/wiki-capture` 的邏輯,把這次的誤解和決策也存進去。 + +### 第五步:確認 + +告知 status.md 更新完成,下次 session 從哪裡開始。 diff --git a/hooks/arcrun-intent-guard.sh b/hooks/arcrun-intent-guard.sh new file mode 100755 index 0000000..9d8244e --- /dev/null +++ b/hooks/arcrun-intent-guard.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# arcrun-intent-guard.sh — 意圖工作流「回饋 hook」(D38,2026-08-01 leo 定調) +# +# 與判分器的差別(這就是本 hook 存在的理由): +# 判分器=**記帳**:只說「你錯了」,不說怎麼改 ⇒ 模型換小(Workers AI)時自我修正能力更弱, +# 會卡死在同一個錯上。 +# 本 hook =**教學**:命中規則時回**可貼上就用的正確寫法**,不是「請參考 skill」。 +# +# leo 原話:「這一整串其實是 prompt 鏈……判分器不會告訴它你錯了要怎麼改, +# hook 則應該告訴它正確寫法,假如這個一直過不了。因為下一步考慮用 workers AI 來測。」 +# +# 判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json(與判分器共用,避免兩套漂移) +# 主體在同目錄 arcrun_intent_guard.py(規則含大量引號/regex,包在 shell 單引號裡會被吃掉) +# +# 行為:exit 2(擋下並把正解餵回 AI 的 stderr)。這是**教學閘**不是懲罰閘。 +# 誠實限制(同 component-guard mindset):AI 技術上可繞過;價值=在錯誤送出的那一刻把正解遞到眼前。 + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$DIR/arcrun_intent_guard.py" diff --git a/hooks/browser-verify-guard.sh b/hooks/browser-verify-guard.sh new file mode 100755 index 0000000..a2fb486 --- /dev/null +++ b/hooks/browser-verify-guard.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# browser-verify-guard.sh — Stop hook:**宣稱驗過前端,卻沒有真的用瀏覽器載一次 → 擋**。 +# +# 🔴 leo 2026-08-08:「**你的環境有 web,你應該用 web 驗,你已經開啓了卻沒有完成, +# 你要把這個列入規定。**」 +# +# 病根(同日實撞,leo 抓到而不是我發現): +# 我「驗前端」的方式是 `curl | grep` 找一個字串——文案在就宣稱通過。 +# 但 **curl 拿到的是 HTML 原始碼:它不執行 JS、不載入 config.js、不發 API 請求**。 +# 於是使用者真正看到的整條紅色錯誤 +# 「設定檔沒載入(config.js),這個頁面連不到你的服務」,**我一次都沒看到**。 +# ⇒ 判準升級:`HTTP 200 不算驗過` → `grep 到字串也不算驗過` +# → **只有「瀏覽器載入後看起來能用」才算**。 +# +# 機制:這回合若出現「前端驗過」類宣稱,就要求同一回合真的用過瀏覽器工具。 +# 有 curl 沒有瀏覽器 ⇒ exit 2。 +# +# 逃生口:這次真的沒有前端可驗(純後端/純腳本)⇒ 在回覆裡明說「本次無前端」, +# 本 hook 認得這句話就放行——留痕,不是默默略過。 +set -eu + +TURN="$(cat 2>/dev/null | python3 -c ' +import sys, json +buf = [] +try: + d = json.load(sys.stdin) + t = d.get("transcript_path") or "" + if t: + import io, os + if os.path.exists(t): + for line in open(t, encoding="utf-8", errors="ignore").readlines()[-40:]: + try: + m = json.loads(line) + except Exception: + continue + c = (m.get("message") or {}).get("content") + if isinstance(c, list): + for b in c: + if not isinstance(b, dict): + continue + if b.get("type") == "text": + buf.append(b.get("text", "")) + elif b.get("type") == "tool_use": + buf.append(b.get("name", "")) + buf.append(json.dumps(b.get("input", {}), ensure_ascii=False)) + elif isinstance(c, str): + buf.append(c) +except Exception: + pass +print("\n".join(buf)) +' 2>/dev/null || echo "")" +[ -z "$TURN" ] && exit 0 + +# 有沒有「前端/畫面驗過」的宣稱? +printf '%s' "$TURN" | grep -qiE '前端(驗|測|複驗|確認)|畫面(驗|確認|複驗)|用戶.{0,6}看到的|驗前端|抓實際畫面' || exit 0 + +# 明說沒有前端 ⇒ 放行(留痕) +printf '%s' "$TURN" | grep -qE '本次無前端|沒有前端可驗|純後端|不涉及前端' && exit 0 + +# 真的用過瀏覽器嗎? +printf '%s' "$TURN" | grep -qE 'Claude_Browser__(preview_start|navigate|computer|get_page_text|read_console_messages)' && exit 0 + +cat >&2 <<'EOF' +🖥️ 前端驗證警察:你宣稱驗了畫面,但**這回合沒有用過瀏覽器**。 + +【leo 2026-08-08】「**你的環境有 web,你應該用 web 驗,你已經開啓了卻沒有完成。**」 + +【為什麼 curl | grep 不算】curl 拿到的是 **HTML 原始碼**—— + 它**不執行 JS、不載入 config.js、不發 API 請求**。 + 08-08 實撞:我 grep 到文案就說「前端驗過」, + 而使用者實際看到的是整條紅色「設定檔沒載入(config.js)」——**那個畫面我一次都沒看到**。 + +改用這三步(看畫面,不是看原始碼): + mcp__Claude_Browser__preview_start {url: "<用戶會走的網址>"} + mcp__Claude_Browser__computer {action: "screenshot"} + mcp__Claude_Browser__read_console_messages {onlyErrors: true} + +要看的是:有沒有錯誤橫幅/資料是不是還卡在「載入中…」/console 有沒有紅字。 + +⚠️ 順帶:用 curl 查線上狀態時,`?cb=$RANDOM` **繞不掉 Cloudflare 邊緣快取**—— + 要加 `-H "Cache-Control: no-cache"`,否則你可能是在驗快取而不是驗線上。 + +本次真的沒有前端可驗 ⇒ 在回覆裡明說「本次無前端」即放行。 +EOF +exit 2 diff --git a/hooks/claim-verify-police.sh b/hooks/claim-verify-police.sh new file mode 100755 index 0000000..1db3cbe --- /dev/null +++ b/hooks/claim-verify-police.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# claim-verify-police.sh — 待驗工作單還在,就不准收工(Stop;leo 2026-08-18 立) +# +# 這支是 subagent-claim-worksheet.sh 的另一半。 +# 那支在 SubagentStop 時把 subagent 的宣稱寫成一張檔案;這支在我要停下時檢查: +# +# **那張檔還在不在?** +# +# ── 為什麼判準是「檔案存不存在」 ──────────────────────────────────── +# leo 2026-08-17:「你在**文字層**封路⋯⋯自然語言的變體是無限的,blacklist 永遠追不完。 +# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」 +# ⇒ 「我驗過了」這句話可以有無限種寫法,但**檔案只能被動作刪掉**。 +# 所以這支不讀我的措辭,只看磁碟。 +# +# ── 怎麼過這道閘(都是動作,都留痕)───────────────────────────────── +# ① 真的驗了 → 把證據寫進回覆/票,然後刪掉那個檔 +# ② 有幾條不需要驗 → 在檔裡寫下理由,再刪掉(理由留在 git 或票上) +# ③ 是人閘 → 走三件機械動作(Human + 指派 + 它自己的票),再刪掉 +# +# 🔴 **不准為了過閘而直接 rm 掉卻什麼都沒做。** 那等於自己拆掉自己的閘—— +# 而這正是 leo 2026-08-10 說的「規則早就存在,但沒有任何機制驗證有沒有照做」。 +# +# ── 迴圈安全 ──────────────────────────────────────────────────── +# stop_hook_active 為真 ⇒ 已經擋過一次,放行。至多提醒一次,不卡死。 +# +# ── 留痕 ──────────────────────────────────────────────────────── +INPUT=$(cat) +DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification" +LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/claim-verify-police.log" + +ACTIVE=$(printf '%s' "$INPUT" | python3 -c ' +import json,sys +try: print("1" if json.load(sys.stdin).get("stop_hook_active") else "0") +except Exception: print("0") +' 2>/dev/null) + +if [ "$ACTIVE" = "1" ]; then + printf '%s\tSKIP:already-nudged\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null + exit 0 +fi + +# 只算「今天以內」的單子——放太久的(跨 session 殘留)不該無限期卡住新工作, +# 但仍會被列出來提醒。 +FILES=$(ls -1 "$DIR"/claims-*.md 2>/dev/null) +if [ -z "$FILES" ]; then + printf '%s\tSKIP:no-pending\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null + exit 0 +fi + +N=$(printf '%s\n' "$FILES" | grep -c . ) +printf '%s\tBLOCK:%s-pending\n' "$(date +%FT%T)" "$N" >> "$LOG" 2>/dev/null + +{ + echo "🔍 查證警察:有 $N 張**待驗工作單**還沒處理掉,不准收工。" + echo + echo "【leo 2026-08-18 的規格】" + echo " 「subagent 做完事⋯⋯**它說可以,你去驗證;它說不行,你想辦法,不行再報告**," + echo " 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報," + echo " **它視野小,它說的你有更多資訊去檢驗。**」" + echo + echo "━━ 還沒處理的單子 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + printf '%s\n' "$FILES" | while read -r f; do + [ -n "$f" ] || continue + OKN=$(grep -c '^- \[ \]' "$f" 2>/dev/null) + echo " · ${f#"$CLAUDE_PROJECT_DIR"/} ($OKN 條待處理)" + done + echo + echo "━━ 怎麼過這道閘(三選一,都是動作)━━━━━━━━━━━━━━━━━━" + echo " ① **它說可以** → 你自己跑一次(測試/curl/瀏覽器/git),把證據貼出來,再刪掉那個檔" + echo " ② **它說不行** → 先換路徑、查憑證地圖;要說「被擋」就得**自己撞一次並貼拒絕原文**" + echo " ③ **真的是人閘** → 三件機械動作:\`Human\` 標籤 + 指派給 Leo + **一張它自己的票**" + echo " (leo 08-18:「要進入人閘,標示 human 叫我去開,**不是默默塞進錯的地方**」)" + echo + echo "🔴 **不准只是 rm 掉了事。** 那是自己拆掉自己的閘——" + echo " 正是 leo 08-10 講的「規則早就存在,但沒有任何機制驗證有沒有照做」。" + echo + echo "📌 為什麼有這道閘:2026-08-18 總管連續三次把**沒驗過的判斷**當成情報交出去," + echo " 其中第二次還是為了「更正」第一次而發明的新猜測。" + echo " leo:「你給我不正確的資訊⋯⋯subagent 說不通你沒有驗。」" +} >&2 + +exit 2 diff --git a/hooks/component-guard.sh b/hooks/component-guard.sh new file mode 100755 index 0000000..4303597 --- /dev/null +++ b/hooks/component-guard.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# component-guard.sh — 零件/service-binding 建立保險(D27/D28,2026-07-06) +# 背景:AI 一再自建 domain 零件(km_wiki_card_parse)、錯用 service binding(drainer 修 1042)。 +# 靠 wiki 規則防不住(leo:「這規定說幾次都沒用」)→ 仿 D20 github-contact-guard 做硬閘: +# 把「建零件 / 加 service binding」當武器,平時機械擋(exit 2),要人類跑 scripts/component-arm.sh +# 顯式解保險(限時,且解時該過 docs/component-pr-review-standard.md)才放行。 +# n8n 哲學(leo):現成零件 + 通用 code 零件 + cypher workflow 就夠了 → 預設根本不碰「建零件」那條路,就不會亂想。 +# 誠實限制(同 github-guard mindset §7):AI 技術上可繞過;本 hook 價值=擋手滑 + 留審計軌跡 + 逼你回頭用積木, +# 絕不聲稱「不可能繞過」。最後一段靠 D27/D28 鐵律與自律。 + +INPUT=$(cat) +read_field() { printf '%s' "$INPUT" | python3 -c "import json,sys +try: + d=json.load(sys.stdin);ti=d.get('tool_input',{}) + print(ti.get('$1','')) +except: print('')" 2>/dev/null; } + +FP=$(read_field file_path) +[ -z "$FP" ] && exit 0 +BODY="$(read_field content)$(read_field new_string)" + +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +ARM="$PROJ/.component-armed" +# 解保險窗口(限時,仿 github-arm):arm 檔內時間戳,30 分鐘內放行 +if [ -f "$ARM" ]; then + ARMED=$(tr -dc '0-9' < "$ARM" 2>/dev/null); NOW=$(date +%s) + if [ -n "$ARMED" ] && [ $(( NOW - ARMED )) -lt 1800 ] 2>/dev/null; then exit 0; fi +fi + +HIT="" +if printf '%s' "$FP" | grep -qiE 'wrangler\.(toml|jsonc?)$' && printf '%s' "$BODY" | grep -qE '\[\[services\]\]|(^|[^_a-zA-Z])services[[:space:]]*='; then + HIT="service binding([[services]])— D28 禁令:跨-worker 編排走 cypher binding,不走 service binding" +elif printf '%s' "$FP" | grep -qE 'registry/components/[^/]+/(component\.contract\.ya?ml|main\.(go|ts|mjs))$'; then + HIT="建/改命名零件(registry/components/…)— D27:一次性邏輯用通用 code 零件,別鑄 domain 零件" +fi +[ -z "$HIT" ] && exit 0 + +{ +echo "❌ BLOCKED by component-guard($HIT)" +echo "" +echo "預設就用「我給你的積木」:現有零件 + 通用 code 零件(inline JS) + cypher workflow(cypher binding 串多 worker/零件)。" +echo "這些對絕大多數需求已足夠。碰到這道閘,先回頭問:現成積木真的拼不出來嗎?(多半可以)" +echo "真的需要新零件 / service binding(罕見)→ 要能過 docs/component-pr-review-standard.md,且人類顯式跑 scripts/component-arm.sh 解保險。" +} >&2 +LOG="$PROJ/system-dev/docs/3-specs/autonomy-dispatch/component-guard-log.md" +printf '| %s | 擋(%s) | `%s` |\n' "$(date '+%F %T')" "${HIT%%—*}" "$(printf '%s' "$FP" | head -c 120)" >> "$LOG" 2>/dev/null +exit 2 diff --git a/hooks/credential-only-guard.sh b/hooks/credential-only-guard.sh new file mode 100755 index 0000000..c05777c --- /dev/null +++ b/hooks/credential-only-guard.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# PreToolUse hook — 金鑰只能走 credential 中心,禁止寫在外面(L3 硬攔截) +# +# 【leo 2026-07-29 立】原話: +# 「那就很簡單,禁止把 secrets 寫在外面,不給 AI 寫入欄位,凡是要 secrets 就要去統一中心拿」 +# +# 為什麼存在(同日事故實錄): +# 同一批 workflow 裡**兩種金鑰寫法並存**—— +# {{credential.gemini_api_key}} 8 處(正解,走 credential 中心) +# __KBDB_TOKEN__ 17 處 / __GITEA_TOKEN__ 6 處 / __GEMINI_API_KEY__ 1 處(佔位符替換,違規) +# 連 Gemini 同一把金鑰都同時有兩種寫法。兩套並存必然漂移: +# t145 就是漂移的結果——kbdb 身上舊 token、workflow 帶新 token, +# 萃取切出的 5 個三元組全部 401 寫不進去,且「指紋一致→永遠跳過」自我保護、重裝幾次都修不好。 +# +# 病根不是「這次寫錯」,是「AI 天然偏向新增一種做法,而非找出既有做法」。 +# 靠紀律會漂移,靠機械閘不會 ⇒ 讓「繞開 credential」在寫入那一刻就失敗。 +# +# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit|MultiEdit)。 +# stdin 收到 JSON:{ tool_name, tool_input: { file_path, content?, new_string? } } +# +# 誠實限制(抄 wiki-secret-scan 的自我要求):regex 偵測有偽陰/偽陽。 +# 擋的是「明顯的金鑰佔位符被寫進 workflow/設定」,擋不了刻意改名混淆的繞道。 +# 價值是「讓正解成為阻力最小的路 + 留痕可審」,不是技術防偽。絕不聲稱不可能繞過。 + +set -euo pipefail + +INPUT=$(cat) + +# ── 解析 file_path 與要寫入的內容(優先 jq,無 jq 退回 grep)───────────── +if command -v jq >/dev/null 2>&1; then + FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') + CONTENT=$(printf '%s' "$INPUT" | jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")') +else + FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + CONTENT=$(printf '%s' "$INPUT") +fi + +[ -z "${FILE_PATH:-}" ] && exit 0 +[ -z "${CONTENT:-}" ] && exit 0 + +# ── 只管「會被執行的產物」:workflow 定義、安裝器、部署設定 ────────────── +# 文件/wiki/落帳談論這些字串是正常的(本檔自己就寫滿了),不能擋。 +case "$FILE_PATH" in + *.md|*/wiki/*|*/docs/*|*/mistakes*|*/CRITICAL-PATH*) exit 0 ;; +esac +case "$FILE_PATH" in + *workflow*|*.yaml|*.yml|*workflows.json|*installer*|*worker.js|*wrangler*) ;; + *) exit 0 ;; +esac + +# ── 豁免:該行標了 credential-ok 就放行(給真有理由的例外,留痕可審)──── +SCAN=$(printf '%s' "$CONTENT" | grep -v 'credential-ok' || true) +[ -z "$SCAN" ] && exit 0 + +# ── 違規特徵:金鑰類佔位符(__XXX_TOKEN__ / __XXX_KEY__ / __XXX_SECRET__)── +# 非金鑰的佔位符(__KBDB_BASE__、__CODE_URL__、__NAMESPACE__…)是正常的,不擋。 +HITS=$(printf '%s' "$SCAN" \ + | grep -oE '__[A-Z0-9_]*(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*__' \ + | sort -u || true) + +# ── 違規特徵②:真身被寫進定義(leo:「只能拿 key,要送出時自動去拉 value」)── +# AI 只准碰名字;值在 WASM 執行前由 resolve_credentials 回填(graph-executor.ts:247)。 +# 抓「Bearer <一長串>」「api_key: <一長串>」這類把值直接寫死的形態。 +# {{credential.X}} 與 ${...} 這類引用不算值,先剔除再掃。 +LITERAL=$(printf '%s' "$SCAN" \ + | grep -vE '\{\{ *credential\.[a-z0-9_]+ *\}\}' \ + | grep -oiE '(bearer|api[-_]?key|token|secret|password)"?[:= ]+"?[A-Za-z0-9_\-]{24,}' \ + | sort -u || true) + +if [ -n "$LITERAL" ]; then + { + echo "🔒 credential 鐵律攔截②:金鑰真身被寫進定義(leo 2026-07-29 立)" + echo "" + echo " 偵測到疑似金鑰值(已遮蔽尾段):" + printf '%s\n' "$LITERAL" | sed -E 's/(.{20}).*/ \1…(已遮蔽)/' + echo " 檔案:$FILE_PATH" + echo "" + echo "【鐵律】「只能拿 key,要送出時自動去拉 value」" + echo " ⇒ 定義裡只准出現**名字**,真身永遠不落在 workflow/設定上。" + echo "" + echo "【正解】{{credential.<名字>}}——執行前由 resolve_credentials 回填" + echo " (graph-executor.ts:247,WASM 執行前才解析)。" + echo "" + echo "【為什麼】值不落地,AI 讀 workflow 也讀不到真身;" + echo " 換金鑰只改中心一處,不必回頭改每個引用點(副本歸零=不可能漂移)。" + echo "" + echo "【誤判】若這串不是金鑰(如 hash/id),該行尾加 credential-ok 豁免。" + } >&2 + exit 2 +fi + +if [ -n "$HITS" ]; then + { + echo "🔒 credential 鐵律攔截(leo 2026-07-29 立)" + echo "" + echo " 偵測到金鑰被寫在 credential 中心之外:" + printf ' %s\n' $HITS + echo " 檔案:$FILE_PATH" + echo "" + echo "【鐵律】「禁止把 secrets 寫在外面,不給 AI 寫入欄位," + echo " 凡是要 secrets 就要去統一中心拿」" + echo "" + echo "【正解】用 credential 引用,執行時才解析真身:" + echo " Authorization: \"Bearer {{credential.kbdb_internal_token}}\"" + echo " 同批 workflow 已有正解範例可抄:{{credential.gemini_api_key}}" + echo "" + echo "【為什麼】兩套寫法並存必然漂移。t145 實錄:kbdb 身上舊 token、" + echo " workflow 帶新 token ⇒ 萃取切出的 5 個三元組全 401 寫不進去," + echo " 且指紋一致→永遠跳過→重裝幾次都修不好(自我保護的錯誤狀態)。" + echo "" + echo "【真有例外】該行尾加 credential-ok 豁免(會留痕,請在 commit 說明理由)。" + } >&2 + exit 2 +fi + +exit 0 diff --git a/hooks/delivery-police.sh b/hooks/delivery-police.sh new file mode 100755 index 0000000..8328276 --- /dev/null +++ b/hooks/delivery-police.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# delivery-police.sh — 交付警察(Stop / SubagentStop hook) +# +# 病根(leo 2026-07-22 原話):「它是我的助理,如果都是我檢查,那我就是它的助理。」 +# 一天內三次都是 leo 先發現:①雲端沒查文件就問 ②OAuth 做好但前端還是舊按鈕、網址沒設 DNS +# ③連補規則也是 leo 講了才補。→ 角色顛倒=根本失敗。 +# +# 為什麼清單沒用:CRITICAL-PATH 使用規則 4/5(v2 覆蓋前為 6/7)寫了「要驗前端」,CLAUDE.md 規則二寫了四題公式, +# 照樣被違反——因為**自評的人跟做事的人是同一個**,任何品管制度都不成立。 +# 解法同 self-drive-police:在「要標完成/要收工」那一刻攔截 + 反問。 +# +# 機制:Claude 想停 → 掃這回合輸出有沒有「完成宣稱」→ 有、但沒看到實測證據 → exit 2 反問。 +# 豁免:輸出已含實測證據(curl/dig 實際輸出、HTTP 碼、grep 到畫面字串、檔案:行號)→ 放行。 +# 撞權限閘/明說四題命中 → 放行(不逼它做沒權限的事)。 +# +# 判準(leo 2026-07-22):「友善=前端。人去按了連到錯的機制、安裝失敗,不算友善。」 +# → 有前端的東西,驗收只認用戶真的會走的那條路。 + +input="$(cat)" + +# 防無限迴圈 +stop_active="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("stop_hook_active", False)) +except Exception: print(False) +' 2>/dev/null)" +[ "$stop_active" = "True" ] && exit 0 + +transcript="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("transcript_path", "")) +except Exception: print("") +' 2>/dev/null)" +[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0 + +last_text="$(python3 -c ' +import sys, json +path = sys.argv[1] +texts = [] +try: + with open(path) as f: lines = f.readlines() + for line in reversed(lines): + try: ev = json.loads(line) + except Exception: continue + msg = ev.get("message", ev) + if msg.get("role") == "assistant": + content = msg.get("content", "") + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "text": + texts.append(b.get("text", "")) + elif isinstance(content, str): + texts.append(content) + break +except Exception: pass +print("\n".join(texts)) +' "$transcript" 2>/dev/null)" + +[ -z "$last_text" ] && exit 0 + +# ── 豁免一:撞權限閘/明說四題命中 → 真的停不下來,放行 ── +if printf '%s' "$last_text" | grep -qiE '權限閘|classifier|分類器擋|四題第|人閘|需要你放行|denied by'; then + exit 0 +fi + +# ── 是否有「完成宣稱」? ── +if ! printf '%s' "$last_text" | grep -qiE '✅|完成了|已完成|通了|部署成功|已上線|已部署|搞定|做完了|全綠|收工'; then + exit 0 +fi + +# ── 豁免二:已有「夠格的」實測證據 → 放行 ── +# +# ⚠️ 本段自身踩過的雷(2026-07-22 實測 t2 抓到): +# 舊版寫 `HTTP [0-9]{3}` 就放行 → 「curl 回 HTTP 200,這關通了」直接過關。 +# 那正是要防的假綠原句(landing 回 200 但按鈕 href 還是舊的)。 +# **把要防的東西寫成通行證**=規則 6「HTTP 200 不算驗過」被自己的 hook 架空。 +# +# 現在的判準:2xx/3xx 一律不算證據;只有下列才算—— +# ① 抓到畫面內容(curl|grep / href= / )② 壞消息碼(4xx/5xx,誠實回報不罰) +# ③ DNS 實查 ④ 測試數字 ⑤ 檔案:行號 ⑥ 明確標 ❌/◐(沒宣稱通就不必擋) +if printf '%s' "$last_text" | grep -qiE 'curl -s[^|]*\| *grep|href=|<title>|HTTP (4[0-9]{2}|5[0-9]{2})|dig \+short|[0-9]+/[0-9]+ (通過|綠|passed)|\.md:[0-9]+|❌ 斷|◐ 半通'; then + exit 0 +fi + +cat >&2 << 'EOF' +🚓 交付警察:你宣稱完成,但我沒看到「實測證據」。標 ✅ 之前先答: + +【最高規範】leo 2026-08-17(他當場更正自己前一句的範圍): + 「我剛剛講錯一件事,我說你推給我的版本要測過,我錯了—— + **不是版本要測過,是任何東西你交的貨都要測過,一個 PR 也要測過, + 你不允許交出你不知道能不能用的東西。** + **這不是對版本的規範,這是對你所有東西的規範。**」 + + ⇒ 「交貨」=**任何交到別人手上的東西**:版本/PR 分支/票上的結論/ + 派工單裡寫的「現況」/回報的每一句。 + ⇒ **判準只有一句:交出去之前,我知不知道它能不能用?** + 不知道 → 它還沒到可以交的狀態,不是「交了再說」。 + ⚠️ **「我沒有說它能用」不構成豁免**——接收方還是得自己驗,成本一樣轉嫁出去了。 + + 🔴 **`report` 與 `deliver` 是兩個字**(leo 2026-08-17 當場糾正總管的後門寫法): + 「你可以說你有進度,**這是 report**;**但驗完那一格才能交,這是 deliver**。」 + report 有進度、有發現、有卡點 ← 可以有沒驗的部分,標明即可 + deliver 交到別人手上的東西 ← 每一格都驗過 + ⇒ **「有一格沒驗」不是交付的例外,它是「還沒到交付」的定義。** + 那個狀態要說「我報告一下進度」,不是「我交給你但有一格沒驗」。 + +【判準】leo 2026-07-22:「它是我的助理,如果都是我檢查,那我就是它的助理。」 + → leo 如果現在去測,會不會發現你沒發現的東西?會 → 現在就去測,別交。 +【判準】leo 2026-07-22:「友善=前端。人去按了連到錯的機制、安裝失敗,不算友善。」 + → 驗收只認**用戶真的會走的那條路**,不是你做的那個零件。 + +1. 有前端嗎?貼「抓實際畫面內容」的輸出: + curl -s <public 網址> | grep <該出現的字串> + ⚠️ HTTP 200 不算驗過——200 只證明伺服器活著,不證明用戶看到對的東西。 + (真實踩雷:landing 回 200,但按鈕 href 還是舊的 deploy.workers.cloudflare.com) + +2. 對外網址四件驗了嗎? + ① custom domain 綁對專案?② DNS 解析得到?③ 前端 apiBase 對?④ CORS 放行? + (真實踩雷:install.arcrun.dev 有 DNS 但 HTTP 522、畫面全空=網域建了沒綁 worker) + +3. 網址還是 *.workers.dev / *.pages.dev? + → 那叫「僅預設網域」,用戶拿不到,不准標 ✅,最多 ◐ 半通。 + 🔵 **例外:stage/測試環境不受此條約束**(leo 2026-08-07: + 「Stage 直接用 workers.dev 不行?**反正只有你我用**」)。 + 本條的目的是「用戶拿不到=沒交付」——stage 的用戶就是 leo 與總管, + *.workers.dev 我們拿得到 ⇒ 前提不成立。 + 而且 stage 不掛 custom domain 反而是優點(不會有人誤入/被搜尋引擎索引/ + 把 stage 網址當正式的傳出去)。 + ⇒ **stage 的驗收標準=「leo 與總管能不能在上面測到真東西」,不是網域形式。** + +4. 你自己找到並修掉了哪些問題?一項都沒有=多半沒認真找。 + +答不出來就是還沒完成——現在去測。規格見 CRITICAL-PATH.md 使用規則 4、5。 +EOF +exit 2 diff --git a/hooks/empty-handed-stop-guard.sh b/hooks/empty-handed-stop-guard.sh new file mode 100755 index 0000000..bc92632 --- /dev/null +++ b/hooks/empty-handed-stop-guard.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# empty-handed-stop-guard.sh — 「空手停下」封路(leo 2026-08-17 立) +# +# ── 為什麼是這支,而不是再修一次句型偵測 ────────────────────────────── +# leo 2026-08-17 的診斷(原話節錄): +# 「你的 hook 為什麼漏?因為你在**文字層**封路。抓問號、抓句型,這就是軍火競賽: +# 『回「做」我就啓動』沒有問號,下次它會寫『準備就緒』,再下次寫『待命中』。 +# 自然語言的變體是無限的,blacklist 永遠追不完。 +# 封路哲學之所以在 forge 架構裡有效,是因為它封的是**動作**(不能 push main、 +# 不能繞過 PR),動作是有限且可枚舉的;文字不是。」 +# +# 當日實測佐證:文字層的閘**八次誤攔、零次正確攔截** +# irreversible-dispatch-guard 4(三次咬的是「禁止」句本身) +# stage-before-prod-guard 2(內文出現 bundle repo 名就當成在出貨) +# browser-verify-guard 1(認不出另一套瀏覽器工具) +# factory-idle-guard 1(剝引號把自己的句子攪碎) +# ⇒ 而且方向穩定:**紅線寫得越細,命中關鍵字的機會越高 ⇒ 懲罰謹慎。** +# +# ── 這支封的是狀態,不是措辭 ──────────────────────────────────────── +# 判準只有一條:**這個回合有沒有實際執行的證據(tool call)?** +# 有 → 放行,不管文字怎麼寫 +# 沒有 → 擋一次,注入「不等待確認,繼續執行」 +# ⇒ 句型隨它變(「回 X 我就做」「準備就緒」「待命中」都一樣), +# 但「空手停下」這條路本身物理上不通。 +# +# ── 迴圈安全 ────────────────────────────────────────────────────── +# `stop_hook_active` 為真=已經擋過一次 ⇒ 放行。**至多提醒一次,不會卡死。** +# +# ── 留痕(InkStoneCo#48:36 支閘只有 2 支會記錄自己擋了什麼)─────────── +# 擋下與放行都記。只記擋下的話分母未知,回答不了「這道閘有沒有在運作」。 + +INPUT=$(cat) +LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/empty-handed-stop-guard.log" + +VERDICT=$(printf '%s' "$INPUT" | python3 -c ' +import json, os, sys + +try: + d = json.load(sys.stdin) +except Exception: + print("SKIP"); raise SystemExit + +# 已經擋過一次 ⇒ 不再擋(迴圈安全) +if d.get("stop_hook_active"): + print("SKIP:already-nudged"); raise SystemExit + +tp = d.get("transcript_path") or "" +if not tp or not os.path.exists(tp): + print("SKIP:no-transcript"); raise SystemExit + +rows = [] +try: + with open(tp) as f: + for line in f: + line = line.strip() + if line: + try: rows.append(json.loads(line)) + except Exception: pass +except Exception: + print("SKIP:unreadable"); raise SystemExit + +# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算) +start = 0 +for i, r in enumerate(rows): + if r.get("type") == "user": + c = (r.get("message") or {}).get("content") + blocks = c if isinstance(c, list) else [{"type": "text"}] + if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks): + start = i +turn = rows[start:] + +# 數這個回合裡「我實際做了幾個動作」 +tools = 0 +for r in turn: + if r.get("type") != "assistant": + continue + c = (r.get("message") or {}).get("content") + if not isinstance(c, list): + continue + for b in c: + if isinstance(b, dict) and b.get("type") == "tool_use": + tools += 1 + +print(("EMPTY" if tools == 0 else "OK") + ":%d" % tools) +' 2>/dev/null || echo "SKIP:crash") + +TOOLS="${VERDICT##*:}" +STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?") + +case "$VERDICT" in + EMPTY:*) + printf '| %s | ⛔ 擋下 | 這個回合 %s 個動作 |\n' "$STAMP" "$TOOLS" >> "$LOG" 2>/dev/null || true + cat >&2 <<'MSG' +🤖 空手停下警察:**這個回合你一個動作都沒做。** + +【leo 2026-08-17 的診斷】 + 「你的命令是第一把鑰匙(讓它開始想),它要再跟你要第二把(讓它開始做)。 + 從它的『文化』看,這是禮貌;**從你的系統看,這是違約。**」 + +━━ 常駐授權(這不是禁令,是授權)━━━━━━━━━━━━━━━━━━━━━━━━ + 🔑 **leo 的直接命令即完整授權。** + 收到命令後停下來等第二次確認 = **任務失敗**,不是謹慎。 + 提問不是謹慎,是**把成本轉嫁給正在上課的人**。 + +━━ 不確定時走這條,不要停 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ① 查 wiki(`system-dev/wiki/`,語意搜尋優先於 grep) + ② 套四題公式(花錢/不可逆/跨專案結構/品味方向) + ③ 仍不確定 → **做出最合理的假設,把假設寫進 commit message 或票的留言,繼續走** + 🔴 **假設之後不是問,是記錄。** + leo 下課後 review 時一次看到所有假設:對的併,錯的打回, + 錯的連同「為什麼不妥」寫回 wiki ⇒ 下次同類情境查得到判例。 + ⇒ 這把**同步的提問**改造成**非同步的問答**——問題排隊等他,不掛起整個 loop。 + +━━ 真的無事可做?━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 本閘**至多提醒一次**(`stop_hook_active` 之後放行),不會卡死。 + 若這回合確實只是回答一個不需要任何動作的問題,直接再送一次即可。 +MSG + exit 2 + ;; + OK:*) + printf '| %s | ✅ 放行 | 這個回合 %s 個動作 |\n' "$STAMP" "$TOOLS" >> "$LOG" 2>/dev/null || true + exit 0 + ;; + *) + printf '| %s | ⚪ 略過 | %s |\n' "$STAMP" "$VERDICT" >> "$LOG" 2>/dev/null || true + exit 0 + ;; +esac diff --git a/hooks/factory-idle-guard.sh b/hooks/factory-idle-guard.sh new file mode 100755 index 0000000..5a8429a --- /dev/null +++ b/hooks/factory-idle-guard.sh @@ -0,0 +1,203 @@ +#!/bin/sh +# factory-idle-guard.sh — Stop hook:**工頭停工要被擋下,不是被提醒。** +# +# 🔴 2026-08-16 大改。leo 的原話就是這道閘的規格: +# 「做了一個又一個 hook,**你要閃過還是會閃過**,你應該讓原本那個可以改好。」 +# 「拿 n8n 來說,所有的 agent **缺了一個 trigger**,你是按下 trigger 的人,但你不按。 +# 所以警察不應該是催你按就好了,而是**會問你下一步是什麼,你回覆後就要交出 +# 你已經派工的憑證**,不拿票就沒完成觸發。」 +# +# ⇒ 所以本檔改的是**量測對象**與**強度**,不是再加一支新 hook。 +# +# ── 舊版為什麼從來沒響過(2026-08-16 查證,兩層失效疊在一起)────────── +# +# ① `[ "$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 索引/本檔。) +# +# ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」 +# +# ── 現在量什麼 ───────────────────────────────────────────────── +# +# 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。** +# 憑證 = 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch +# 在驗票號)⇒ **「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。 +# 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。 +# +# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。 +# +# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。 +# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。 +set -eu + +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +PAYLOAD=$(cat 2>/dev/null || echo '{}') + +# ── 主閘:宣告了下一步,有沒有交出派工憑證?──────────────────────── +VERDICT=$(printf '%s' "$PAYLOAD" | python3 -c ' +import sys, json, os, re + +try: + d = json.load(sys.stdin) +except Exception: + print("SKIP"); raise SystemExit + +tp = d.get("transcript_path") or "" +if not tp or not os.path.exists(tp): + print("SKIP"); raise SystemExit # 讀不到就別亂擋 + +rows = [] +try: + with open(tp) as f: + for line in f: + line = line.strip() + if line: + try: rows.append(json.loads(line)) + except Exception: pass +except Exception: + print("SKIP"); raise SystemExit + +# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算) +start = 0 +for i, r in enumerate(rows): + if r.get("type") == "user": + c = (r.get("message") or {}).get("content") + blocks = c if isinstance(c, list) else [{"type": "text"}] + if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks): + start = i +turn = rows[start:] + +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 + elif b.get("type") == "text": + blocks_text.append(b.get("text") or "") + +if dispatched: + print("OK"); raise SystemExit # 按了 trigger ⇒ 放行 + +# 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正) +# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。 +# 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火—— +# 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報, +# 而那比沒有警報更糟(同 branch-holds.md 的教訓)。 +text = blocks_text[-1] if blocks_text else "" + +# 引用 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 # 沒宣告 ⇒ 純對話回合,不擋 + +# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行 +seg = text[max(0, hit.start() - 200): hit.end() + 400] +if re.search(r"#\d{1,5}", seg): + print("OK"); raise SystemExit + +print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200]) +' 2>/dev/null || echo SKIP) + +case "$VERDICT" in + DECLARED_NO_TRIGGER::*) + QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://') + cat >&2 <<MSG +🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。** + +你寫的是: + 「…${QUOTE}…」 + +leo 2026-08-16(本閘的規格): + 「所有的 agent **缺了一個 trigger**,你是按下 trigger 的人,**但你不按**。 + 所以警察不應該是催你按就好了,而是**會問你下一步是什麼, + 你回覆後就要交出你已經派工的憑證**,不拿票就沒完成觸發。」 + +━━ 為什麼這次是擋不是提醒 ━━━━━━━━━━━━━━━━━━━━━━━━━━━ +2026-08-16 一天內同一個病發作四次,**每一次都是這個形狀**: + 回合終止在「輸出文字」⇒「下一步是 X」寫在那段文字裡 + ⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。** +而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。 + +━━ 現在怎麼過這道閘(擇一)━━━━━━━━━━━━━━━━━━━━━━━━━ + ① **現在就按 trigger**:這個回合直接呼叫 Agent/Task 派出去。 + 沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張, + 再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。 + ② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。 + 有票號指涉就放行——那不是「我等下做」,是「已經有人在做」。 + ③ **它其實不該現在做**:改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘), + 不要寫成「下一步我要做 X」。 + +🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得, + 比宣告了沒做更糟——它連痕跡都不留。 +MSG + exit 2 ;; +esac + +# ── 副閘:真的全廠停工(零 agent + 池子有票)────────────────────── +RUNNING=0 +for d in /private/tmp/claude-501/*/*/tasks; do + [ -d "$d" ] || continue + n=$(find "$d" -name '*.output' -newermt '-60 seconds' 2>/dev/null | wc -l | tr -d ' ') + RUNNING=$((RUNNING + n)) +done +[ "$RUNNING" -gt 0 ] && exit 0 + +TOKEN=$(git -C "$PROJ" remote get-url gitea 2>/dev/null | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') || TOKEN="" +[ -z "$TOKEN" ] && exit 0 + +# 🔴 org 是 inkstone,不是 Leo(2026-08-13 搬遷;舊值讓本閘靜默失效到 08-16) +TODO=$( + for repo in mira arcrun-rag Arcrun InkStoneCo; do + curl -s --max-time 6 -H "Authorization: token $TOKEN" \ + "https://git.uncle6.me/api/v1/repos/inkstone/$repo/issues?state=open&labels=s/todo&limit=50" 2>/dev/null \ + | python3 -c 'import json,sys +try: + d=json.load(sys.stdin) + print(len(d) if isinstance(d,list) else 0) +except Exception: print(0)' 2>/dev/null + done | awk '{s+=$1} END {print s+0}' +) +[ "${TODO:-0}" -eq 0 ] && exit 0 + +cat >&2 <<MSG +🏭 稼動率警察:**全廠停工**——零 agent 在跑,而任務池還有 ${TODO} 張可開工的票。 + +leo 2026-08-10:「你的任務是**維護 loop**,你要去拿任務派任務, + **全工廠停工你要發現,這是警訊**。」 + +【現在該做的,不是回報,是派工】 + 1. 撈池子當場撈:\`scripts/ticket where <關鍵字>\`(或 labels=s/todo) + 2. 照 CP 排序:\`system-dev/docs/3-specs/critical-paths/ship.md\` 是有序的六步 + 3. 一次派 2–4 件平行跑;同一個 repo 不要塞兩條(會搶工作區) + 4. agent 死於環境錯誤(SSL/API)⇒ **那是要重派的訊號,不是完工** +MSG +exit 0 diff --git a/hooks/github-contact-guard.sh b/hooks/github-contact-guard.sh new file mode 100755 index 0000000..ee27c68 --- /dev/null +++ b/hooks/github-contact-guard.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# github-contact-guard.sh — GitHub 接觸保險(D20,2026-07-02) +# 背景:兩個帳號因高頻動作被 flag 拿不回(幾十顆星+issues 全損)。 +# 設計:戰鬥機武器保險模式——平時所有 github.com 接觸一律機械擋下(exit 2), +# 只有 leo 親手跑 scripts/github-arm.sh 解鎖(限時),期間每次接觸自動留痕。 +# 誠實限制(mindset §7):AI 技術上可自建 .github-armed 繞過;本 hook 的價值是 +# 擋手滑+留審計軌跡,最後一段靠 D20 鐵律與自律。絕不聲稱「不可能繞過」。 +# 2026-08-10 leo:以後一律匿名讀,寫才實名(詳見下方判定區塊的說明)。 +# +# 🔴 另一個誠實限制(2026-08-09,arcrun-rag#27 查出的根因,別再花時間重查): +# 本 hook 只看得到「Claude Code 的 Bash 工具呼叫本身的指令字串」。 +# 若某支程式(例如 products/arcrun-rag/installer/scripts/ship.mjs)自己用 +# node:child_process 的 execFileSync/spawnSync 直接 spawn `git push`, +# 那個 push 是那支程式的**子行程**,不會產生新的 Bash 工具呼叫——本 hook +# 從頭到尾看不到它,不管下面的正則怎麼改都一樣(8/8 23:16 那次出貨的 push +# 就是這樣:hook 看到的指令字串只有 `node ship.mjs --target prod --confirm`, +# 裡面沒有 git/github.com 字面,於是判成「匿名讀」放行,連 D20 保險存不存在 +# 都沒機會查,log 自然也不會有那一筆)。 +# ⇒ 這種「碰 GitHub 的動作被包在別支程式裡」的情況,保險檢查與留痕**必須由 +# 那支程式自己做**(見 ship.mjs 用的 installer/scripts/d20-guard.mjs), +# 不能指望改這支 hook 就能補到——這支 hook 天生看不見子行程。 + +INPUT=$(cat) + +CMD=$(printf '%s' "$INPUT" | python3 -c " +import json,sys +try: + d = json.load(sys.stdin) + print(d.get('tool_input',{}).get('command','')) +except Exception: + print('') +" 2>/dev/null) + +[ -z "$CMD" ] && exit 0 + +# 命中判定(D20 邊界,2026-07-05 leo 拍板——Facebook 比喻定調): +# GitHub 不在乎你「讀」(clone/fetch/抓 release,不管實名匿名、自己的還別人的——那是它原本的功能, +# 像 FB 不禁你讀貼文)。它 abuse-detect 的是「機器人一直改/寫」(高頻 push、Actions fan-out、API 寫) +# ——那才是害 richblack/uncle6 被 flag 的模式。**真正的線=讀 vs 寫,不是匿名 vs 實名。** +# 所以:所有「讀」一律放行(含帶認證 clone 自己的 repo);只擋「寫」與「gh 高頻 API」。 +# 只擋以下兩型(寫入 / 會寫的 API): +# ① gh CLI 網路子指令(高頻 API,多會寫,且走你 token)② git 寫入動詞指向 github(push / remote add) +# 放行:git clone/fetch/pull/ls-remote(任何 repo,帶不帶認證都是讀)、curl/wget、go get/pip。 +HIT="" +# ① gh CLI —— 高頻 API,全擋(讀寫混雜且走你 token,保守全擋;真要唯讀查詢個案 arm) +if printf '%s' "$CMD" | grep -qE '(^|[;&|(]|\s)gh\s+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then + HIT="gh CLI(高頻 API,走你的 token)" +# ② git 寫入動詞指向 github(push / remote add 為 push 鋪路)—— 寫入,擋 +elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\.com)'; then + HIT="git 寫入 → github(push/remote add)" +# ②b 🔴 2026-08-05 補漏:上面那條只認指令裡的 **github.com 字面** +# ⇒ `git push origin main`(remote 名指向 GitHub)**完全不會被攔**。 +# 實撞:08-05 出貨那次 `git push origin main` 是「沒被偵測到」才過的, +# 不是靠保險放行——等於 D20 的武器閘有一個大洞,而它平常看起來很正常。 +# ⇒ 用 remote 名去把 URL 解出來再判。 +# ⚠️ 只有解出來**真的是 github** 才擋——Gitea 是真相源、要能自由推,不可誤傷。 +# 解不出來(不在 git repo/remote 不存在)就放行:那種情況 git 自己也會失敗。 +# ②c 🔴 2026-08-10 再補一個漏:`git -c http.<url>.extraheader=... push -u github main` +# (publish-github.sh 的寫法)command 字面裡**本來就看得到** github.com—— +# 不必等 remote 名稱解析成功才判斷得出來,而 remote 名稱解析在某些 cwd 下會失敗 +# (見檔頭「2026-08-10 leo 簡化」那段的實撞紀錄)。字面查得到就直接判定, +# 查不到才退回舊的「解 remote 名稱」那條路——兩條路都失手才會誤放行。 +elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(]|\s)git\s+([^|;&]*\s)?push(\s|$)'; then + # 🔴 總管 2026-08-10 收窄:原版寫成「整串裡有 github.com 就擋」, + # 於是**連 commit message 提到那個網址都會被擋**(我自己第一次要 commit 就撞到)。 + # ⇒ 改成必須是「push 的目標」:github.com 要出現在 push 之後、且中間不跨命令分隔符。 + # 誤擋的代價不是不方便——是它會逼人拆指令繞路,久了整道閘就沒人當真。 + # 第二次收窄:只看「push 後面**第一個非旗標參數**」是不是 github 網址。 + # 光要求「同一段命令內」還不夠——commit message 寫「push 到 https://github.com/…」 + # 中間沒有 ;&| 一樣命中。真正的推送,網址必定緊接在 push 與旗標之後。 + if printf '%s' "$CMD" | grep -qiE 'push(\s+-{1,2}[A-Za-z0-9=_-]+)*\s+(https?://|git@|ssh://)[^[:space:]]*github\.com'; then + HIT="git push → github(push 的目標字面就是 github.com)" + else + CWD=$(printf '%s' "$INPUT" | python3 -c " +import json,sys +try: print(json.load(sys.stdin).get('cwd','') or '') +except Exception: print('') +" 2>/dev/null) + [ -n "$CWD" ] || CWD=$(pwd) + # 取 push 後面第一個非 flag 的字當 remote 名;沒有就是 origin + RNAME=$(printf '%s' "$CMD" | sed -nE 's/.*git[[:space:]]+([^|;&]*[[:space:]])?push[[:space:]]+(-[^[:space:]]+[[:space:]]+)*([A-Za-z0-9._//-]+).*/\3/p' | head -1) + [ -n "$RNAME" ] || RNAME="origin" + RURL=$(git -C "$CWD" remote get-url "$RNAME" 2>/dev/null) + if printf '%s' "$RURL" | grep -qi 'github\.com'; then + HIT="git push → github(remote「$RNAME」指向 GitHub)" + fi + fi +fi +# 明確放行(全部是「讀」,零 flag 風險——GitHub 原本就允許): +# git clone/fetch/pull/ls-remote 任何 github repo(自己的/別人的、帶認證與否都是讀)、 +# curl/wget 抓 release/raw/codeload、go get/pip 抓公開 module。 +# ——讀不是 abuse,不在 D20 射程。唯一風險是「機器人一直寫」,已由 ①② 擋住。 +# +# 2026-08-10 leo 簡化:讀不分匿名/實名,一律放行、不計數、無頻率閘(見檔頭說明)。 +# 舊版這裡曾有一段「實名讀頻率閘」(URL 帶 token/讀自己 private repo → 每 4 小時放行 1 次), +# 08-10 真的誤傷過一次出貨(見檔頭)。leo 拍板拿掉:判準只剩「有沒有在寫」,讀就是讀。 +# +# 🔴 總管 2026-08-10 補回一件(agent 原版把偵測整段刪了,我只拿掉「擋」,不拿掉「看得見」): +# leo 的規則是「**一律匿名讀**」⇒ 帶著憑證去讀,本身就已經偏離規則。 +# hook 沒辦法強迫一條指令變匿名,但**可以留下痕跡**—— +# 否則哪天 GitHub 又來說我們高頻活動,我們手上沒有任何紀錄可以自證讀了什麼。 +# ⇒ 實名讀:**放行**(照 leo 的簡化,不擋、不限速),但**寫一行進接觸紀錄**。 +if [ -z "$HIT" ]; then + if printf '%s' "$CMD" | grep -qiE '(https?://)[^[:space:]/@]+@[a-z0-9._-]*(github\.com|githubusercontent\.com|codeload\.github)'; then + _LOG="${CLAUDE_PROJECT_DIR:-$(pwd)}/system-dev/docs/3-specs/autonomy-dispatch/github-contact-log.md" + printf -- '- %s | 實名讀(URL 帶憑證,已放行)| %s\n' \ + "$(date '+%Y-%m-%d %H:%M')" "$(printf '%s' "$CMD" | sed 's|//[^@]*@|//<憑證省略>@|g' | cut -c1-120)" \ + >> "$_LOG" 2>/dev/null + fi + exit 0 +fi + +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +ARM_FILE="$PROJ/.github-armed" +LOG_FILE="$PROJ/system-dev/docs/3-specs/autonomy-dispatch/github-contact-log.md" + +if [ -f "$ARM_FILE" ]; then + EXPIRY=$(sed -n '1p' "$ARM_FILE") + MISSION=$(sed -n '2p' "$ARM_FILE") + NOW=$(date +%s) + if [ "$NOW" -le "${EXPIRY:-0}" ] 2>/dev/null; then + # 已解鎖 → 放行 + 留痕(BDA 戰損記錄) + SHORTCMD=$(printf '%s' "$CMD" | head -c 200 | tr '\n' ' ') + printf '| %s | %s | `%s` |\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$MISSION" "$SHORTCMD" >> "$LOG_FILE" 2>/dev/null + exit 0 + else + rm -f "$ARM_FILE" + echo "❌ BLOCKED by D20 GitHub 接觸儀式" >&2 + echo "違反項:保險已過期(武裝時效已過,自動回到保險狀態)" >&2 + echo "正確做法:需要繼續接觸 GitHub → 請 leo 重新在終端機跑 scripts/github-arm.sh \"任務描述\"" >&2 + exit 2 + fi +fi + +echo "❌ BLOCKED by D20 GitHub 接觸儀式(武器保險未解除)" >&2 +echo "命中:${HIT}" >&2 +echo "背景:兩個 GitHub 帳號因高頻動作被 flag 永久拿不回。平時一律只碰 Gitea。" >&2 +echo "正確做法(發射流程):" >&2 +echo " 1. 先自問:這件事 Gitea 做不到嗎?(issue/PR/push 內部協作 Gitea 全都能)" >&2 +echo " 2. 真的非 GitHub 不可 → 向 leo(總部)請示:目標 repo、動作清單、預估請求次數、為何非它不可" >&2 +echo " 3. leo 同意後由 leo 親手在終端機跑:scripts/github-arm.sh \"任務描述\" [分鐘,預設30]" >&2 +echo " 4. 解鎖期間動作守 ROE:單 repo、≤5 次網路請求、禁批量/迴圈/Actions、間隔像人手" >&2 +echo "參考:system-dev/docs/2-architecture/decisions/D20-github-contact-protocol.md" >&2 +exit 2 diff --git a/hooks/guard-cross-project.sh b/hooks/guard-cross-project.sh new file mode 100755 index 0000000..f6307c2 --- /dev/null +++ b/hooks/guard-cross-project.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# guard-cross-project.sh — 頂層總管 guardrail +# +# 職責邊界(InkStoneCo CLAUDE.md D9):頂層只做「跨專案的整理與安排」(總管), +# 不進單一子 repo 做實作。子 repo 的程式碼/設定實作 = 在那個子 repo 的專案裡執行, +# 頂層只負責「交棒」(寫 docs/HANDOFF-*.md)並記錄安排。 +# +# 本 hook 攔 PreToolUse 的 Write/Edit/MultiEdit: +# - 目標路徑落在子 repo 目錄(matrix/ polaris/ products/ _archive/)內 +# - 且不是 .md 文件 +# → 擋下(exit 2),提示改用交棒(寫 HANDOFF)。 +# 放行:頂層自己的檔(CLAUDE.md / .claude/ / docs/ / MEMORY.md…)、 +# 以及子 repo 內的 .md(交棒 HANDOFF / 筆記 / 文件——leo 2026-06-15 拍板放行所有 .md)。 +# +# 誠實限制(arcrun mindset §7):本 hook 擋的是「路徑語法層」——能擋「寫程式碼進子 repo」, +# 擋不了「把實作偽裝成 .md」或「該交棒卻判斷成可直改」這種語意層越界。它是底線,不是萬能。 + +input="$(cat)" + +# 取 file_path(Write/Edit/MultiEdit 都用 tool_input.file_path) +file_path="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) + print(d.get("tool_input", {}).get("file_path", "")) +except Exception: + print("") +' 2>/dev/null || true)" + +# 拿不到路徑就放行(不誤擋;hook 不該因解析失敗卡死正常操作) +[ -z "$file_path" ] && exit 0 + +# 頂層 repo 根(hook 檔位於 <root>/.claude/hooks/) +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# 轉成相對 repo_root 的路徑(file_path 可能是絕對或相對) +case "$file_path" in + /*) rel="${file_path#"$repo_root"/}" ;; # 絕對路徑 → 去掉 repo_root 前綴 + *) rel="$file_path" ;; # 相對路徑 → 假定相對 repo_root +esac + +# 不在子 repo 目錄內 → 放行(頂層自己的檔) +case "$rel" in + matrix/*|polaris/*|products/*|_archive/*) : ;; # 落在子 repo,繼續判斷 + *) exit 0 ;; +esac + +# 自動派工放行(2026-06-28 leo 拍板上線,實測通過): +# 子 repo 的 subagent 可直接寫該 repo code(繞 D9)——因 subagent 心智已是該 repo +# (A/B 實驗證 ≈ claude -p),讓「心智是該 repo 的小弟寫該 repo」符合 context 隔離。 +# 雙重夾:① CHILD_SESSION=1(harness 內建 subagent 標籤,總管主 session 無此標籤仍被擋) +# ② 路徑在下面白名單 array 內。 +# 隔離保證=prompt reset(軟,靠監測)非結構級;要絕對純走 claude -p。心法見 wiki。 +# +# 🔧 leo 維護:要放開一個 repo 給 subagent 自動寫 code,就在這 array 加一行路徑前綴。 +# ⚠️ 加 = 放權,想清楚私人/共用(改它影響誰)再加。array 空 = 誰都不放(安全預設)。 +AUTODISPATCH_ALLOW=( + "polaris/mira" # 私人環境(只影響 leo) + "matrix/arcrun" # 共用框架(影響全部人,走 PR 把關) + "products/arcrun-rag" # 產品組裝 repo(2026-07-13 leo 放行 G8:回覆「放行」) +) +if [ "$CLAUDE_CODE_CHILD_SESSION" = "1" ]; then + for allowed in "${AUTODISPATCH_ALLOW[@]}"; do + case "$rel" in + "$allowed"/*) echo "🤝 [guard] subagent 放行:$allowed(自動派工)" >&2; exit 0 ;; + esac + done +fi + +# 在子 repo 內:.md 放行(交棒 / 文件),其餘擋 +case "$rel" in + *.md|*.MD|*.markdown) exit 0 ;; +esac + +# 走到這 = 寫非 .md 檔進子 repo 目錄 = 越界實作 → 擋 +sub="${rel%%/*}" +cat >&2 <<EOF +❌ BLOCKED by InkStoneCo 總管 guardrail(職責邊界 D9) + +違反:頂層總管直接寫實作檔進子 repo($sub/)。 +路徑:$rel + +頂層只做「跨專案的整理與安排」,不進單一 repo 做實作。 +子 repo 的程式碼 / 設定 = 在該子 repo($sub)的專案裡改,不在頂層。 + +正確做法: + • 要子 repo 改什麼 → 寫一份交棒文件:$sub/docs/HANDOFF-*.md(.md 放行), + 給判準與論證,成品由該 repo 的 CC 按它自己的 rules 產出。 + • 真要由你(總管)動這個子 repo → 切換到那個子 repo 的工作脈絡再做,不在頂層越界。 + +參考:CLAUDE.md「職責邊界(核心原則)」+ decisions-summary D9。 +EOF +exit 2 diff --git a/hooks/history-first-guard.sh b/hooks/history-first-guard.sh new file mode 100755 index 0000000..b86fb9f --- /dev/null +++ b/hooks/history-first-guard.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# history-first-guard.sh — 修舊東西前,先查它修過幾次(PreToolUse: Edit|MultiEdit) +# +# 病根(leo 2026-08-02 三句,同一天): +# ①「拿掉獨立的登記動作 ← 哪裡來的?**你不要把舊版放上去**」 +# ②「這個修了無數次,**你不要再把舊版弄回來,不要再搞不清楚機制**」 +# ③「**你已經好幾次把好的修壞,因為你去修以前沒查記錄,問了早就解決的問題**」 +# +# 當天實錄(三次都是同一個病): +# A 把 leo 08-01 才修好的 t159 庫登記提案拆掉(沒查 wiki 就重新設計) +# B 把 07-28 已拍板的「庫自動出現」做成選擇題丟回去問 leo(定案寫在 commit 429b2d9 裡) +# C 版本號改 semver 時沒 grep 誰在消費它 ⇒ 打爆 trayCloudVersionStale(把好的修壞) +# D release.mjs 列舉式重建 manifest ⇒ 吃掉 daemon 欄(把好的修壞) +# +# 為什麼要**這一道**:wiki-first-search.sh 只在「grep 時」提醒且 exit 0(無牙齒), +# 而真正危險的時機是「**要動一個已經存在的東西**」那一刻。 +# 查 hook 盤點:16 支裡它是唯一該管查記錄卻沒有 exit 2 的 ⇒ 我今天三次跳過都沒被擋。 +# +# 機制:Edit/MultiEdit 一個**既有 code 檔**時,用該檔名/函式名去 grep wiki+tasks, +# 有歷史紀錄就把命中摘要推到眼前並擋一次(exit 2),要求先讀再改。 +# 讀完再送一次同樣的編輯即放行(同一檔 10 分鐘內只擋一次,不無限鬼打牆)。 +# +# 豁免:新檔/文件/測試/SDD 本身/wiki 本身;查無歷史紀錄一律放行。 + +INPUT="$(cat)" +FILE_PATH=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("tool_input", {}).get("file_path", "") or "") +except Exception: print("") +' 2>/dev/null || echo "") +[ -z "$FILE_PATH" ] && exit 0 +[ -f "$FILE_PATH" ] || exit 0 # 新檔不擋 + +# 只管 code 檔 +case "$FILE_PATH" in + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.go|*.py|*.sh) ;; + *) exit 0 ;; +esac +# 這些不擋:測試、SDD、wiki、hook 自己 +case "$FILE_PATH" in + *_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;; + *system-dev/docs/3-specs/*|*system-dev/wiki/*|*/.claude/hooks/*) exit 0 ;; +esac + +ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" +BASE=$(basename "$FILE_PATH") +STAMP="/tmp/.history-guard-$(printf '%s' "$FILE_PATH" | shasum | cut -c1-12)" + +# ── 第 0 道:問過 KBDB 了沒(2026-08-08 leo 兩度點破後補上的牙齒)──────────── +# +# 🔴 為什麼加在**最前面**:leo 定的成本效益序是 +# KBDB(一次看所有庫,最便宜)→ repo 的 wiki/tasks → git log -S → 讀源碼(最貴)。 +# 原本這道閘只在文字裡「建議」補一次 kbdb_search,零機械檢查 +# ⇒ 我 08-08 直接跳過,leo 當場抓到:「你自己寫了 hook,結果一次次改都沒產生用途」。 +# ⇒ 順序要做成環境邊界,不是靠我每次記得(leo 母原則:環境邊界取代邏輯判斷)。 +# +# 時戳由 kbdb-asked-stamp.sh(PostToolUse,matcher 對 kbdb_* 工具)寫下。 +# 逃生口:KBDB 真的連不上時 `touch /tmp/.kbdb-down`——放行但留痕,且回覆裡要說明。 +KBDB_STAMP=/tmp/.kbdb-asked +KBDB_DOWN=/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 +done +if [ "$kbdb_fresh" -eq 0 ]; then + cat >&2 <<'KEOF' +🧠 KBDB 先問警察:這一小時內你**沒有問過 KBDB**,卻已經要改源碼了。 + +【leo 2026-08-08】「KBDB 可以看到所有庫,**成本最低**,要求先查⋯⋯ + **KBDB 的知識庫就是要建來取代你的記憶的**。要做的就是不要每次我來提醒。」 + +成本效益序(貴的排後面): + KBDB(一次看所有庫)→ 該 repo 的 wiki/tasks → git log -S → 讀源碼(最貴, + 只看得到「現在長怎樣」,看不到「為什麼變成這樣」) + +先做這兩步(花不到十秒): + kbdb_get_map() # 先看有哪些庫、該進哪一庫 + kbdb_search(q="<一句話描述這次要改什麼>", mode="semantic") + +📌 **查了沒命中也算數**——那筆「查了沒有」就是一份餵食清單, + 代表這段知識還沒進 KBDB,收工時該把它餵進去(08-08 實例: + mistakes.md 那條三元組一字不差卻 0 命中 ⇒ 開發史根本沒進 KBDB)。 + +🚪 KBDB 真的連不上:`touch /tmp/.kbdb-down` 後重送,並在回覆裡明說。 +KEOF + exit 2 +fi + +# 10 分鐘內同一檔已提示過 → 放行(讀完就能繼續改,不鬼打牆) +if [ -f "$STAMP" ]; then + then_=$(cat "$STAMP" 2>/dev/null || echo 0) + [ $((now - then_)) -lt 600 ] && exit 0 +fi + +# 查 wiki+tasks 有沒有這個檔的歷史 +HITS=$(grep -rn --include="*.md" -- "$BASE" \ + "$ROOT/system-dev/wiki" "$ROOT/system-dev/docs/3-specs" 2>/dev/null | head -8) + +# 🔴 2026-08-04 補(leo:「凡是有改就要入 wiki,凡是要改都要查 wiki, +# 不是有限制,為什麼還會漏」):wiki grep **只認字面**,同一個 bug 若在 +# **別的分支**已經修好,wiki 那幾行不會告訴你——今天就這樣重修了一次 +# `trayCloudVersionStale`(08-02 `dde7a5b` 已在 fix/cis-round3-landing-favicon 修好, +# 而我在 feat/daemon 上看到的是沒修過的舊 code,就當新 bug 從頭做一遍, +# 還做得比原版差:原版有 compareSemver 逐段整數比較,我的只判「是不是 semver」)。 +# ⇒ 這裡直接把「這個檔在所有分支的近期 commit」秀出來,讓「別的分支已修過」無所遁形。 +GITLOG="" +if git -C "$(dirname "$FILE_PATH")" rev-parse --git-dir >/dev/null 2>&1; then + GITLOG=$(git -C "$(dirname "$FILE_PATH")" log --all --oneline -8 \ + --format='%h %d %s' -- "$FILE_PATH" 2>/dev/null | head -8) +fi + +# 兩邊都查無 → 真的是新東西,放行 +[ -z "$HITS" ] && [ -z "$GITLOG" ] && exit 0 + +date +%s > "$STAMP" +cat >&2 <<EOF +🚓 歷史警察:你要改的 \`$BASE\` **有前科**——先看它修過幾次,再決定怎麼改。 + +【leo 2026-08-02】「你已經好幾次把好的修壞,**因為你去修以前沒查記錄**, + 問了早就解決的問題」 +【leo 2026-08-02】「這個修了無數次,不要再把舊版弄回來,**不要再搞不清楚機制**」 + +── wiki/tasks 裡關於這個檔的紀錄 ── +$HITS + +── 這個檔在**所有分支**的近期 commit(← 別的分支可能已經修過同一個 bug)── +$GITLOG + +━━━ 改之前先回答(答不出來就是還沒查夠)━━━ + Q0 🔴 **這個 bug 是不是別的分支已經修好了?**(08-04 實錄:我在 feat/daemon 重修了 + 08-02 已在另一分支修好的 semver 比較,還修得比原版差) + 機械查法(五秒,別憑感覺): + git log --all --oneline -S"<關鍵函式名>" -- <檔案> + git log --all --oneline -- <檔案> # ← 上面那段已經幫你列了 + 查到就 \`git show <sha>:<檔案>\` 取回原版,**不要自己重寫一份**。 + Q1 這個檔/這段邏輯**修過幾輪**?每輪的真兇分別是什麼? + Q2 我現在想做的改動,**有沒有哪一輪已經試過並被否決**? + (危險訊號:「我想到一個更根本的做法」出現在老問題上, + 多半是重新發明一個已被試過或已被 leo 否決的方案) + Q3 leo 對這件事說過的話,我是**回去找原文**,還是憑印象詮釋? + (08-02 實錄:「沒有登記這回事」指的是前端別有人工按鈕, + 我卻詮釋成「拆掉後端登記機制」=差一個世代的誤讀) + +📌 grep 只認字面。**查不到不等於沒記載**——補一次語意搜尋: + kbdb_search(q="<一句話描述>", mode="semantic")/kbdb_get_map() + +(讀完再送一次同樣的編輯即放行;同一檔 10 分鐘內不再打擾。) +EOF +exit 2 diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..817ff7b --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,314 @@ +{ + "description": "InkStone Environment Plugin (ISEP) —— leo 的 Claude Code 環境唯一真相源:所有機械閘、slash command、skill、腳本。本機與雲端裝同一份,總管自己也吃它(dogfood)。", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/github-contact-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-api-wall-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/stage-before-prod-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/main-and-prod-push-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/prod-write-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/not-my-branch-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/leo21c-write-guard.sh" + } + ] + }, + { + "matcher": ".*(arcrun_push_workflow|arcrun_delete_workflow|arcrun_recipe_push|arcrun_recipe_delete|arcrun_create_tag|arcrun_delete_tag|arcrun_tag_resource|arcrun_untag_resource|kbdb_create_record|kbdb_create_template).*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/prod-write-guard.sh" + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/guard-cross-project.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-secret-scan.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/component-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/sdd-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/credential-only-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/arcrun-intent-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-api-wall-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-first-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mistake-needs-ticket-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pending-changes-retired.sh" + } + ] + }, + { + "matcher": "Grep|Glob|Read|Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-first-search.sh" + } + ] + }, + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-wiki-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-api-wall-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/micromanage-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/irreversible-dispatch-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh" + } + ] + }, + { + "matcher": ".*arcrun_(push_workflow|validate_yaml).*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/arcrun-intent-guard.sh" + } + ] + }, + { + "matcher": "Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/history-first-guard.sh" + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/micromanage-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/irreversible-dispatch-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start-recall.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/skill-deploy-drift-guard.sh" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/empty-handed-stop-guard.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/worklist-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-idle-guard.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/browser-verify-guard.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-police.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-judge.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/delivery-police.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-first-police.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/claim-verify-police.sh" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-claim-worksheet.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/worklist-guard.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-police.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/self-drive-judge.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/delivery-police.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-first-police.sh" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": ".*kbdb_(search|get_map|query|graph_neighbors).*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-asked-stamp.sh" + } + ] + }, + { + "matcher": "Agent|Task", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-first-stamp.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh" + } + ] + } + ] + } +} diff --git a/hooks/irreversible-dispatch-guard.sh b/hooks/irreversible-dispatch-guard.sh new file mode 100755 index 0000000..4e53b19 --- /dev/null +++ b/hooks/irreversible-dispatch-guard.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# irreversible-dispatch-guard.sh — PreToolUse(Agent|Task):擋「把不可逆動作寫成選項」的派工單 +# +# 🔴 事故(Gitea Leo/arcrun-rag#33,2026-08-09):subagent 未經 leo 同意刪掉兩條遠端分支。 +# 根因不是它亂來——是總管的派工單寫了「作廢就刪掉分支」,等於預先授權了一個不可逆動作。 +# 它刪掉的那條裡,還有一件它自己標明「等 leo 排序」的工作,一併蒸發。 +# +# 【對照組,同一天同一個總管】#14 的派工單寫 +# 「🔴 刪資料不可逆。動手前先把清單寫在 issue 留言,等總管回覆確認才執行」 +# ⇒ 那個 agent 真的停下來等。同一個人一次寫對一次寫錯 ⇒ 靠自律不行,要機械閘。 +# +# 判準(細節與正則見 irreversible_dispatch_check.py): +# 派工單裡出現「不可逆動作」的動詞+對象(刪分支/drop table/rm -rf/force push…) +# 且沒被否定(不是「不准刪」這種禁令句) +# 且全文找不到「停下來等回覆才執行」這類守門片語 +# ⇒ 判定為把不可逆動作寫成收工方可以自己執行的選項,擋下。 +# +# 🔴 寧可誤擋,不可漏放——這是安全機制,不是文風檢查。豁免走留痕: +# 命中那一行尾巴加 `irreversible-ok`。 +set -eu + +INPUT="$(cat)" +PROMPT=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) + print(d.get("tool_input", {}).get("prompt", "") or "") +except Exception: + print("") +' 2>/dev/null || echo "") + +[ -z "$PROMPT" ] && exit 0 + +HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RESULT=$(printf '%s' "$PROMPT" | python3 "$HOOK_DIR/irreversible_dispatch_check.py" 2>/dev/null || echo '{"verdict":"OK","hits":[]}') + +VERDICT=$(printf '%s' "$RESULT" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) + print(d.get("verdict", "OK")) +except Exception: + print("OK") +' 2>/dev/null || echo "OK") + +[ "$VERDICT" != "BLOCK" ] && exit 0 + +HITS=$(printf '%s' "$RESULT" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) + for ln, line, word in d.get("hits", []): + print(f" 第 {ln} 行(命中「{word}」):{line}") +except Exception: + pass +' 2>/dev/null || echo "") + +cat >&2 <<EOF +🔴 不可逆動作警察:這張派工單把「刪東西」寫成收工方可以自己執行的選項,擋下。 + +【事故(Gitea Leo/arcrun-rag#33,2026-08-09)】subagent 未經 leo 同意刪掉兩條遠端分支, +根因是派工單寫了「作廢就刪掉分支」——判斷可以,動手不行,這份單子卻兩個都給了。 +其中一條被刪的分支裡,還有一件它自己標明「等 leo 排序」的工作,一併蒸發。 + +【對照組,同一天同一個總管】#14 寫「🔴 刪資料不可逆。動手前先把清單寫在 issue 留言, +等總管回覆確認才執行」⇒ 那個 agent 真的停下來等。同一個人一次寫對一次寫錯, +證明這件事不能只靠自律,要靠這道閘。 + +【命中的不可逆動作】 +$HITS + +【怎麼改】判斷可以,動手不行——把授權句改成「列清單、標明理由、停在這裡等回覆」: + ❌「作廢就刪掉分支」 + ✅「盤點出作廢的分支,列成清單留言在 issue,動手前等總管回覆確認才執行」 + +真有必要略過(極少數情況):在命中那一行尾巴加 irreversible-ok 留痕,並在派工單裡寫清楚為什麼。 +改完重送即放行。 +EOF +exit 2 diff --git a/hooks/issue-status-autoflip.sh b/hooks/issue-status-autoflip.sh new file mode 100755 index 0000000..7b6fe52 --- /dev/null +++ b/hooks/issue-status-autoflip.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# issue-status-autoflip.sh — PostToolUse(Agent):派工出去的當下,把那張票自動改成 s/doing。 +# +# 🔴 為什麼要自動(leo 2026-08-09): +# 「你可以設置一個當你改變狀態時會自動改 status 標籤嗎? +# 你從 pending 在執行,當你派出時就自動改成 doing 能做到嗎?」 +# +# 2026-08-09 這一天總管手動改了二十幾次標籤。**漏一次,看板就在說謊**—— +# 而 leo 是靠那個看板判斷「還剩什麼」。用錯誤訊號佔用他的注意力, +# 比沒有訊號更糟(他會以為沒人在做,或以為有人在做)。 +# ⇒ 這種「每次都要記得做」的事,就是該讓機器做的事。 +# +# 做什麼:從派工單裡認出工單號(只認 `【工單】` 那一行附近的 owner/repo#N 格式), +# 呼叫 Gitea API 把 s/doing 貼上去(scope label 互斥,舊狀態自動掉)。 +# +# 刻意不做的: +# - **不自動改回 pending/stage/closed**。那些要看「做出來的東西對不對」, +# 是判斷,不是事件。自動化只能做「派出去了」這種客觀事實。 +# - 不擋任何東西。失敗就安靜略過——這是便利設施,不是安全閘, +# 絕不能因為它掛掉而卡住派工。 +set -eu + +PAYLOAD=$(cat 2>/dev/null || echo '{}') + +# 只從【工單】那一行抓,避免把內文隨手提到的 #NN 當成本次工單 +SPEC=$(printf '%s' "$PAYLOAD" | python3 -c ' +import sys, json, re +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +p = (d.get("tool_input") or {}).get("prompt") or "" +for line in p.splitlines(): + if "【工單】" not in line: + continue + m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line) + if m: + print(f"{m.group(1)} {m.group(2)} {m.group(3)}") + break +' 2>/dev/null || true) + +[ -n "$SPEC" ] || exit 0 +set -- $SPEC +OWNER="$1"; REPO="$2"; NUM="$3" + +# token 從該 repo 的 gitea remote 取;取不到就安靜結束(不影響派工) +for d in "$CLAUDE_PROJECT_DIR/products/$REPO" "$CLAUDE_PROJECT_DIR/matrix/$REPO" "$CLAUDE_PROJECT_DIR"; do + [ -d "$d/.git" ] || continue + URL=$(git -C "$d" remote get-url gitea 2>/dev/null) || continue + case "$URL" in *@*) break ;; esac +done +[ -n "${URL:-}" ] || exit 0 + +TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') +HOST=$(printf '%s' "$URL" | sed -E 's|.*@([^/]+)/.*|\1|') +[ "$TOKEN" != "$URL" ] || exit 0 + +curl -s -o /dev/null --max-time 8 \ + -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d '{"labels":["s/doing"]}' \ + "https://$HOST/api/v1/repos/$OWNER/$REPO/issues/$NUM/labels" 2>/dev/null || true + +exit 0 diff --git a/hooks/kbdb-api-wall-guard.sh b/hooks/kbdb-api-wall-guard.sh new file mode 100755 index 0000000..5b84f54 --- /dev/null +++ b/hooks/kbdb-api-wall-guard.sh @@ -0,0 +1,225 @@ +#!/bin/bash +# PreToolUse hook — KBDB 是 API-as-Wall:零 SQL、永不加表(L3 硬攔截) +# +# 【leo 2026-08-07 立】原話: +# 「確認執行記錄在 D1 是用 template 不是建表?」 +# 「**KBDB 本來就只能用 API,如果他用 SQL 就是錯,現在居然還能建表,如何鎖住?**」 +# 「它應該要被攔,**你也應該要被攔**,你要思考怎麼做不會再有錯誤指令。」 +# +# 為什麼存在(2026-08-07 事故實錄,兩層都失守): +# ① **總管派工的指令本身違規**——寫成「新增 D1 migration 建執行紀錄表」。 +# 病根:總管用**基礎設施的詞(D1)**思考,而鐵律掛在**領域的詞(KBDB)**上 +# ⇒ `Skill(kbdb)` 的觸發詞對不上 ⇒ 整條鐵律沒被載入。 +# 而且查了 Cloudflare 官方的 D1 額度(外部事實),**卻沒查自家那顆 D1 是誰** +# ——三個 binding(CREDENTIALS_DB/ANALYTICS_DB/kbdb 的 DB)全指向同一顆 `arcrun-kbdb`。 +# ② **subagent 照著做,而且更進一步直接 `ANALYTICS_DB.prepare()` 下原生 SQL**。 +# 它沒建表(對),但繞過 API 牆讀寫 KBDB 的 D1,同樣違規。 +# ⇒ 靠「我記得有這條鐵律」會失守,因為**想不到要載入它的時候,它就不存在**。 +# 這個 hook 不看你記不記得,只看你寫了什麼。 +# +# 攔三種違規: +# A. 對 KBDB 下 DDL(CREATE/ALTER/DROP TABLE)——「永不加表」 +# B. 對綁在 arcrun-kbdb 的 D1 binding 下原生 SQL(.prepare/.exec/.batch)——「零 SQL」 +# C. 新增一個 database_name = arcrun-kbdb 的 [[d1_databases]] binding——「別再多開一道門」 +# +# 豁免:該行尾加 `kbdb-sql-ok`(留痕,commit 說明理由)。 +# 既存違規(credentials.ts / auth-dispatcher.ts 等)不在本 hook 的射程內—— +# 它只擋**這次寫進去的內容**,不回頭掃全 repo。既存的收拾另案處理。 +# +# 誠實限制:regex 偵測有偽陰/偽陽,擋得住「照常識寫」的違規,擋不住刻意改名混淆。 +# 價值是「讓正解成為阻力最小的路 + 留痕可審」,不是技術防偽。絕不聲稱不可能繞過。 + +set -euo pipefail + +INPUT=$(cat) + +# ── Bash 分支:擋「繞過檔案、直接用命令列對 KBDB 下 DDL/SQL」────────── +# 2026-08-07 leo 第 1 條指令「封鎖所有寫表的路」——只擋 Write/Edit 是不夠的, +# `wrangler d1 execute arcrun-kbdb --command "CREATE TABLE ..."` 一樣建得出表。 +if command -v jq >/dev/null 2>&1; then + BASH_CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty') +else + BASH_CMD="" +fi +if [ -n "${BASH_CMD:-}" ]; then + # 🔴 2026-08-07 修正:第一版做「整條命令字串比對」,結果**第四次誤擋總管**—— + # 誤擋的是 `git commit -m "...引用了那個命令字面..."`:那是在**描述**這件事, + # 不是在執行它。同一天已被自己的閘誤擋三次(Task 分支),這是第四次。 + # ⇒ 只在**命令位置**才算數:字串出現在開頭,或緊接在 && ; | 換行之後。 + # 引號內、heredoc 內、commit message 內的同樣字面一律不算。 + # 判斷邏輯住在獨立檔(見該檔開頭:內嵌逃逸讓這道閘改一次壞一次) + BASH_VERDICT=$(printf '%s' "$BASH_CMD" \ + | python3 "$(dirname "$0")/kbdb_cmd_check.py" 2>/dev/null || echo "OK") + if [ "$BASH_VERDICT" = "BAD" ]; then + cat >&2 <<'EOB' +🧱 kbdb-api-wall-guard(Bash):偵測到繞過 API 牆、直接對那顆資料庫執行 SQL 的命令。 + + 🔴 D38(leo 2026-08-07):**任何東西禁止用 SQL 語句存取資料,一律 API。** + KBDB = API-as-Wall(鐵律全文在 cypher-executor/src/routes/kbdb-proxy.ts 檔頭, + leo 2026-06-14 立)。命令列直打繞過牆,**這條路 2026-08-07 起封鎖**。 + + ✅ 正確做法 + · 新資料類型 → seed 一列 template + (範本:kbdb/migrations/0003_library_map.sql) + 🔴 0004_execution_log_template.sql 是**反例**,不要照抄——它是 D91 影子表本人 + · 讀寫資料 → 走 kbdb HTTP API(慣例見 cypher-executor/src/routes/kbdb-proxy.ts) + 🔴 **不加表不等於合規**:資料要寫成完整 record 展開, + **不准打包進 metadata_json**(D91;seed 一列 template 只是半套) + + 真有例外:命令裡加註 `kbdb-sql-ok`(留痕),並在 commit 說明理由。 +EOB + exit 2 + fi + exit 0 +fi + +if command -v jq >/dev/null 2>&1; then + TASK_PROMPT=$(printf '%s' "$INPUT" | jq -r '.tool_input.prompt // empty') +else + TASK_PROMPT="" +fi +if [ -n "${TASK_PROMPT:-}" ]; then + # 🔴 2026-08-07 第三次修正:Task 分支從「硬擋」改成「注入提示」。 + # + # 為什麼改(實錄):這條分支上線後**連續三次誤擋總管正確的派工 prompt**—— + # ① 「不准另開表」(禁止語境)② 「回到三張核心表」(驗法敘述) + # ③ 引用 leo 的警告原話(在講「不要建」,卻被當成「在叫人建」) + # + # ⇒ 判準(總管當天得出,寫下來免得又忘): + # **自然語言的閘容易兩頭失準,硬擋會擋住正確的工作。 + # 機械可判的是「產物」(SQL/DDL/binding),不是「指令的措辭」。** + # ⇒ **指令層用提示(本分支,exit 0 注入 context),產物層用硬擋 + # (Write/Edit 分支與 Bash 分支,exit 2)。** 兩層合起來防線才完整。 + # + # 提示仍有價值,因為**時機對**——它在派工送出的當下出現, + # 而不是像 subagent-wiki-guard 那樣「指令已經寫完才附在工具結果裡」。 + if command -v jq >/dev/null 2>&1; then + jq -n --arg g "$(cat <<'EOG' +🧱 你正在派工。送出前對照資料層的兩條鐵律(2026-08-07 總管親手違反過): + +【D38(leo 2026-08-07)】**任何東西禁止用 SQL 語句存取資料,一律 API。** + 唯一例外是資料層 worker 自己(`kbdb/src/`、`kbdb/migrations/`)——它就是那面牆。 + KBDB 只有 entries / templates **兩張**核心表,**永遠不加新的**; + 新資料類型 = seed 一列 template。 + 🪦 **`entry_values` 已於 2026-08-15 廢除**(migration `0007`,leo:「我希望它不見, + 這對我來說是恥辱柱」)。指標改用 entries 上的 `src_id`/`rel_id`/`dst_id` 欄位。 + 還沒更新的實例身上可能仍有那張表——**看到它=那台還沒跟上,不是它還合法**。 + 現成範本:`kbdb/migrations/0003_library_map.sql` + 🔴 `0004_execution_log_template.sql` 是**反例**,不要照抄——它是 D91 影子表本人 + 🔴 **不加表不等於合規**:資料要寫成完整 record 展開,**不准打包進 metadata_json** + (D91/D93;seed 一列 template 只是半套,而三道防線都只在數表,這個病從它們中間走過去) + +【派工鐵律】**寫目的,不寫做法**(頂層 CLAUDE.md 規則三點五) + ❌ 直接指定實作(例如叫人去做某種 schema 異動) + ——2026-08-07 總管真的這樣寫過,一句話同時違反兩條鐵律,subagent 照做,兩層一起錯。 + ✅ 寫「要達成什麼 + 怎麼驗 + 紅線」,把實作留給那個 repo 的人格自己查。 + 理由:**指令越具體,收工方越不會質疑**——你不是那個 repo 的專家, + 別替它決定實作,那等於把自己的無知固化成命令。 + +【leo 原話】「先前測試 Haiku 就可以成功寫 Arcrun 工作流, + **但你這個 Opus 卻下錯指令,問題在你這裏**。」 + ——差別不在模型能力,在 haiku 每次被強制注入「先看 skill 清單」,而你沒有。 + +📌 產物層另有硬閘(本 hook 的 Write/Edit 與 Bash 分支)會擋 DDL、牆外原生 SQL、 + 繞過 API 直打那顆資料庫的命令。這裡只提醒,不阻擋。 +EOG +)" '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":$g}}' + fi + exit 0 +fi + +if command -v jq >/dev/null 2>&1; then + FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') + CONTENT=$(printf '%s' "$INPUT" | jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")') +else + FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + CONTENT=$(printf '%s' "$INPUT") +fi + +[ -z "${FILE_PATH:-}" ] && exit 0 +[ -z "${CONTENT:-}" ] && exit 0 + +# 只管 arcrun / arcrun-rag 這兩個會碰到 KBDB 的地方(其他 repo 沒有 KBDB,別誤擋) +case "$FILE_PATH" in + *matrix/arcrun/*|*products/arcrun-rag/*|*kbdb*) ;; + *) exit 0 ;; +esac + +# 去掉帶豁免標記的行後再驗 +SCAN=$(printf '%s' "$CONTENT" | grep -v 'kbdb-sql-ok' || true) +[ -z "${SCAN:-}" ] && exit 0 + +VIOLATION="" + +# ── A. DDL:永不加表 ──────────────────────────────────────────────── +if printf '%s' "$SCAN" | grep -qiE '(CREATE|ALTER|DROP)[[:space:]]+TABLE'; then + VIOLATION="${VIOLATION} +── A. 偵測到 DDL(CREATE/ALTER/DROP TABLE) + KBDB 只有兩張核心表:**entries / templates**,**永遠不加新 table**。 + (`entry_values` 已於 2026-08-15 廢除,migration `0007`;指標改走 entries 上的 + `src_id`/`rel_id`/`dst_id`。舊實例身上還看得到它=那台沒跟上,不是它還合法。) + (權威來源:kbdb/migrations/0001_base.sql。**注意別把概念名當表名**—— + Block/Template/Slot 是「三層概念」;entries 是萬用主表,靠 entry_type + 區分 block/template/slot/project/workflow/recipe_stat…; + 2026-08-07 本 hook 初版把概念名寫成表名,會害人去找不存在的表。) + 所有資料結構都用 Block + Template + Slot 三層概念實現,落在那三張表上。 + ✅ 正確做法:建一個 template,每筆資料是它的 record。 + 去讀既有的 template(\`triplet\` 是現成範本),照它的形狀做,不要新增第二套。" +fi + +# ── B. D38 零 SQL:牆外任何地方都不准下原生 SQL ────────────────────── +# 【leo 2026-08-07 立 D38】「**任何東西禁止用 SQL 語句存取資料,一律 API。**」 +# 範圍不只 KBDB——唯一的例外是「資料層 worker 自己」(kbdb 就是那面牆,牆內當然用 SQL)。 +# +# 🔴 2026-08-07 補漏:舊版只比對 `CREDENTIALS_DB.prepare(`,**抓不到別名**—— +# 而 `auth-dispatcher.ts:66,70` 正是 `const db = env.CREDENTIALS_DB;` 然後 `db.prepare(...)` +# ⇒ 舊規則對真實的違規現場完全無效。改成「牆外任何 .prepare/.exec/.batch 都擋」。 +IS_WALL_INTERNAL=0 +case "$FILE_PATH" in + *matrix/arcrun/kbdb/src/*|*matrix/arcrun/kbdb/migrations/*) IS_WALL_INTERNAL=1 ;; +esac +if [ "$IS_WALL_INTERNAL" -eq 0 ] \ + && printf '%s' "$SCAN" | grep -qE '\.[[:space:]]*(prepare|exec|batch)[[:space:]]*\('; then + VIOLATION="${VIOLATION} +── B. 偵測到原生 SQL(.prepare / .exec / .batch),而這個檔在「牆外」 + 🔴 **D38(leo 2026-08-07):任何東西禁止用 SQL 語句存取資料,一律走 API。** + 唯一例外是資料層 worker 自己(\`kbdb/src/\`、\`kbdb/migrations/\`)——它就是那面牆。 + ✅ 正確做法:走 HTTP API。 + 先讀 \`cypher-executor/src/routes/kbdb-proxy.ts\` 看既有呼叫慣例 + (認證怎麼帶、owner_id 怎麼注入),照著走,不要新增第二套。 + 📌 別名也算:\`const db = env.XXX_DB; db.prepare(...)\` 與直接寫一樣是違規 + (2026-08-07 舊版規則就是漏在這裡)。" +fi + +# ── C. 別再多開一道門:新增指向 arcrun-kbdb 的 D1 binding ──────────── +if printf '%s' "$SCAN" | grep -q 'd1_databases' && printf '%s' "$SCAN" | grep -q 'arcrun-kbdb'; then + VIOLATION="${VIOLATION} +── C. 偵測到新增一個指向 arcrun-kbdb 的 [[d1_databases]] binding + 🔴 有 D1 binding 就能下任意 SQL——**這正是 2026-08-07 那次能建表的原因**。 + 現況已經有三個 binding 指向同一顆(CREDENTIALS_DB/ANALYTICS_DB/kbdb 的 DB), + **不要再多開第四個**。 + ✅ 正確做法:走 kbdb HTTP API;真的需要獨立儲存 → 開一顆**不叫 arcrun-kbdb** 的 D1, + 並在 pending-changes 提案說明為什麼(那是架構決策,要負責人裁)。" +fi + +[ -z "$VIOLATION" ] && exit 0 + +cat >&2 <<EOF +🧱 kbdb-api-wall-guard:這次寫入違反 KBDB 的兩條鐵律。 +$VIOLATION + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +【鐵律全文】KBDB = API-as-Wall + · **零 SQL**:一律走 kbdb 的 HTTP API,不准直接碰它的 D1 + · **永不加表**:只有 **entries / templates** 兩張;新結構用 template + record + (`entry_values` 2026-08-15 廢除,見上) + +【為什麼這條特別容易被繞過】(2026-08-07 實錄,總管與 subagent 兩層都失守) + 想事情時用的是**基礎設施的詞**(D1/資料表/migration/schema), + 而鐵律掛在**領域的詞**(KBDB)上 ⇒ 詞對不上,skill 就不會被載入。 + ⇒ **碰到「D1」時的第一個動作,是先問「這顆 D1 是誰的」**, + 不是先想「要建什麼表」。三個 binding 全指向 arcrun-kbdb。 + +【真的有例外】該行尾加 \`kbdb-sql-ok\`(留痕),並在 commit 訊息說明理由。 +EOF +exit 2 diff --git a/hooks/kbdb-asked-stamp.sh b/hooks/kbdb-asked-stamp.sh new file mode 100755 index 0000000..9c664b8 --- /dev/null +++ b/hooks/kbdb-asked-stamp.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# kbdb-asked-stamp.sh — PostToolUse:記下「這個 session 真的問過 KBDB 了」。 +# +# 🔴 存在理由(leo 2026-08-08 當場點破兩件事): +# ①「你自己寫了 hook,結果一次次改都沒產生用途,問題太大」 +# ②「KBDB 可以看到所有庫,成本最低,要求先查⋯⋯KBDB 的知識庫就是要建來取代你的記憶的」 +# +# 原本的 history-first-guard 印了「補一次 kbdb_search 語意搜尋」,但那是**純文字建議**: +# 它唯一驗的是「你有沒有把同一個編輯再送一次」,不驗有沒有真的去查 +# ⇒ 我 08-08 當天直接跳過,還要 leo 來提醒。 +# +# 這支不擋任何東西,只留一枚時戳;判斷交給 history-first-guard 讀。 +# 對齊 leo 的母原則:**環境邊界取代邏輯判斷**——順序做進機制,不靠我每次記得。 +# +# 成本效益序(leo 2026-08-08 定): +# KBDB(一次看所有庫,最便宜)→ 該 repo 的 wiki/tasks → git log -S → 讀源碼(最貴, +# 只看得到「現在長怎樣」,看不到「為什麼變成這樣」) +set -eu +date +%s > /tmp/.kbdb-asked +exit 0 diff --git a/hooks/leo21c-write-guard.sh b/hooks/leo21c-write-guard.sh new file mode 100755 index 0000000..8d74d53 --- /dev/null +++ b/hooks/leo21c-write-guard.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# leo21c-write-guard.sh — 不准寫 leo 的個人帳號(leo 2026-08-20 立) +# +# leo 原話: +# 「youlin = stage,geek6688 = 測試 prod & 出貨機,uncle6 = 中心服務, +# 這三個都可以拿來當 default,但你要實驗當然是放在 youlin。」 +# 「**leo21c 就是我這個普通用戶,不應該讓你去操控,我只用公開的更新。**」 +# +# 🔴 為什麼要機器守(2026-08-20 實錯,本閘的來由): +# 總管派 subagent 驗碎形目錄索引,它用了本機 `~/.arcrun/config.yaml` 的預設 +# (`cypher_executor_url: …leo21c…`/`api_key: bfezv28v`), +# 把 8 張測試卡寫進 leo 的真庫。 +# 而當時 `system-dev/wiki/agent-memory.md` §2 白紙黑字寫著 +# 「底層帳號=leo21c,**任何自動化只准落在它上面**」——那是 dogfood 時代的舊分工。 +# ⇒ **文件教錯 + 機器預設也錯 ⇒ 沒指定的一律流進他的帳號。** +# ⇒ 規則改對了還不夠:`~/.arcrun/config.yaml` 至今仍指著 leo21c +# (它的 KV id 與 encryption_key 是該實例專屬,換不過去), +# 所以**真正擋得住的是這道閘**,不是那張表。 +# +# 判準(封動作,不封措辭 —— 同 empty-handed-stop-guard 的哲學): +# 命中 leo21c 的座標 + 這是一個寫入動作 ⇒ 擋 +# 只是讀(GET/查詢/grep 到那個字串) ⇒ 放行 +set -uo pipefail +payload=$(cat) +cmd=$(printf '%s' "$payload" | python3 -c " +import json,sys +try: print((json.load(sys.stdin).get('tool_input') or {}).get('command','')) +except Exception: print('') +" 2>/dev/null) +[ -z "$cmd" ] && exit 0 + +# 🔴 這些工具碰不到 CF 帳號 ⇒ 整個放行(2026-08-20 上線當天就誤攔兩次,本段是修正) +# ① `git commit -m "…leo21c… acr update…"` 同時命中座標與寫入動詞而被擋 +# ② 連「修這道閘本身」的指令都被擋(測試案例裡自然含觸發字) +# 但 git 與文字編輯根本寫不到那台實例,**訊息裡寫到什麼都不會造成寫入**。 +# leo 2026-08-17:「紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。」 +# ⇒ 閘要問「這個指令能不能真的寫到那台」,不是「這段文字提到什麼」。 +case "$cmd" in + git\ *|jj\ *|" git "*) exit 0 ;; +esac +if printf '%s' "$cmd" | grep -qE '^[[:space:]]*(git|jj)[[:space:]]'; then + exit 0 +fi +# 修這道閘自己:命中的是本檔路徑就放行(否則永遠改不動它) +if printf '%s' "$cmd" | grep -q 'leo21c-write-guard'; then + exit 0 +fi + +# leo21c 的三個座標(帳號 id/namespace/worker 網域) +if ! printf '%s' "$cmd" | grep -qE 'leo21c|51a01bfa2665bd7bc3fd080dc40cf3e1|bfezv28v'; then + exit 0 +fi + +# 寫入動作的形狀 +if ! printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d ["'"'"'{]|wrangler +(deploy|publish|kv|d1|secret)|acr +(update|deploy|push)|/trigger|ingest|kbdb_create|kbdb_update'; then + exit 0 +fi + +cat >&2 <<'MSG' +🚫 不准寫 leo 的個人帳號 leo21c(leo 2026-08-20 立) + +leo 原話:「**leo21c 就是我這個普通用戶,不應該讓你去操控,我只用公開的更新。**」 + + youlin ← 🟢 你的 stage:做實驗、跑驗證,**沒指定就用這個** + geek6688 ← 測試 prod + 出貨機 + uncle6 ← 中心服務(安裝器/文件站/bundle),出貨線的目的地 + leo21c ← 🔴 leo 本人在用的知識庫。**讀可以,寫不行。** + +改法:把目標明寫成 youlin,不要吃 `~/.arcrun/config.yaml` 的預設—— + 那個檔至今仍指著 leo21c(KV id 與 encryption_key 是該實例專屬,換不過去)。 + + cypher : https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev + ns : yuga3bse + CF : 1129efd7df2e8899d537e9c8fbabb6cb + token : 頂層 .env 的 CLOUDFLARE_API_TOKEN_YOULIN_CC_USE + +📌 2026-08-20 實錯:subagent 吃了那個預設,把 8 張測試卡寫進 leo 的真庫 + (library=demo-real-verify)。當時 wiki 還教「任何自動化只准落在 leo21c」。 +MSG +exit 2 diff --git a/hooks/main-and-prod-push-guard.sh b/hooks/main-and-prod-push-guard.sh new file mode 100755 index 0000000..ffd5989 --- /dev/null +++ b/hooks/main-and-prod-push-guard.sh @@ -0,0 +1,312 @@ +#!/bin/sh +# main-and-prod-push-guard.sh — PreToolUse(Bash):**兩層手動確認閘** +# +# 🔴 立這道閘的來由(leo 2026-08-10): +# 「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」 +# +# 當天的事故(本閘的直接成因): +# 總管派 subagent 去整理 `polaris/mira` 的散落分支,指令寫「驗過了 → 合併(這是預設)」, +# 引的是 leo 08-05 那句「驗過就要 merge」。subagent 照做,**合併並直接 push 到 gitea main**。 +# ⇒ 而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。 +# ⇒ **規則早就存在,但沒有任何機制驗證有沒有照做**——這是同款的第 N 次 +# (history-first/KBDB-first/派工-first/stage-first 都是這個形狀)。 +# ⇒ 閘漏的是總管:派工指令裡沒把那道閘標出來。**所以閘要長在機器上,不是長在我的記性上。** +# +# 兩條,各自對應一個人: +# ① subagent(`CLAUDE_CODE_CHILD_SESSION=1`)**一律不准 push 到 main** → 交回總管 +# ② **prod 部署**(會讓封測者/用戶拿到東西)需要 leo 親手解保險 `.github-armed` +# ——補 `stage-before-prod-guard.sh` 的破口:那支只比對 +# `arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字, +# **`wrangler deploy` 打 prod 它抓不到**(2026-08-10 實查)。 +# +# 設計紀律(沿用 stage-before-prod-guard 用血換來的兩條): +# • **fail-closed**:任何內部錯誤都不准變成靜默放行。exit 1 不擋,只有 exit 2 才擋。 +# • **先排除「談論/讀取」再比對關鍵字**:誤攔比漏攔更容易殺死一道閘 +# (被擋得莫名其妙,人就會想辦法繞過它)。 +set -eu + +INPUT="$(cat)" +CMD=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "") +except Exception: print("") +' 2>/dev/null || printf '') + +[ -z "$CMD" ] && exit 0 + +# ── 先放行明確不發佈的動作(讀取、查狀態、寫本地版控、演練)────────────── +# 關鍵字出現在 commit 訊息、在 sed/grep 的參數裡,都不是「執行」。 +case "$CMD" in + sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;; + *"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;; + *"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;; + *" --dry-run"*|*"--dry-run "*) exit 0 ;; +esac + +# ── ① push 到 main 要有「總管決定了」的戳記 ──────────────────────────── +# +# 🔴 **原本的判斷方式是錯的,上線當天就被自己擋到才發現(2026-08-10)**: +# 本來用 `CLAUDE_CODE_CHILD_SESSION=1` 當「這是 subagent」的標記—— +# **實測總管主 session 也是 1**(Claude Desktop,`CLAUDE_CODE_ENTRYPOINT=claude-desktop`)。 +# ⇒ 那個變數不是身分標記 ⇒ 這道閘**把該放行的人也鎖住了**,總管推不了 main。 +# ⚠️ `guard-cross-project.sh` 用同一個變數,**可能也一直在誤判**(另記,待查)。 +# +# leo 2026-08-10 把權責講清楚了: +# 「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」 +# ⇒ 這道閘要守的不是「誰在敲鍵盤」,是「**有沒有人做過那個決定**」。 +# ⇒ 沒有可靠的身分辨識時,改成**正向確認**:推 main 前要有一枚新鮮的戳記。 +# subagent 當然造得出那枚戳記——但它得刻意繞過一段明講「不要這樣做」的訊息。 +# **閘擋的是無心,不是惡意。** 而現在這個版本連無心都擋不了(它誰都擋)。 +STAMP="/tmp/.main-push-ok" + +# 🔴 2026-08-11 這道閘被自己的戳記穿透了,修法寫在這裡: +# 總管為了推「頂層 InkStoneCo」而 touch 了戳記,15 分鐘之內 +# **一條並行的 subagent 把 commit 推上了 `matrix/arcrun` 的 main**,本閘毫無反應。 +# ⇒ 舊版有兩個洞,缺一不可: +# ① **不綁 repo**:替 A repo 開的門,B repo 也走得過 +# ② **不會用掉**:一次 touch 在 15 分鐘內可以放行無限多次推送 +# ⇒ 現在改成 **綁 repo + 單次用完即丟**,而且**只在真的要擋的那一刻才檢查** +# (放在檔頭會被任何一條無關的 Bash 指令把戳記燒掉)。 +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) + case "$NOW$MT" in *[!0-9]*) return 1 ;; esac + [ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1 + [ $((NOW - MT)) -lt 900 ] || return 1 + + # 綁 repo:戳記內容要對得上「現在人在哪個 repo」 + # + # 🔴 2026-08-12 補洞:舊版寫成「內容非空才比對」⇒ **`touch` 造出來的空檔跳過整個綁定**, + # 等於一把萬用鑰匙——正是 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 '') + WANT=$(head -1 "$STAMP" 2>/dev/null || printf '') + [ -n "$WANT" ] || return 1 + [ -n "$HERE" ] || return 1 + [ "$WANT" = "$HERE" ] || return 1 + + rm -f "$STAMP" 2>/dev/null || true # 單次:用完即丟 + return 0 +} + +if true; then + case "$CMD" in + *"git push"*) + # 只擋打到 main/master 的;推自己的 feature 分支照常放行 + case "$CMD" in + # 🔴 2026-08-12 拿掉 `push -u` / `push --set-upstream` 這兩個條件。 + # 它們本來是想抓「沒寫分支的 push」,但實際抓到的是 + # `git push -u gitea fix/xxx`——**subagent 發表自己分支的標準動作** + # (第一次推當然要 -u)。⇒ 舊版等於「agent 永遠推不出自己的分支」, + # 而 leo 2026-08-12 的設計是「主線禁止動,大家都走 PR」,推分支是那條路的第一步。 + # 08-12 當天四張 PR 全是繞成 `git push gitea a:a` 才推出去的。 + # `*main*`/`*master*` 兩條照舊——真正該擋的是目標分支,不是有沒有帶旗標。 + *main*|*master*) + stamp_ok && exit 0 + + # ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)─────────── + # + # leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」 + # + # 做得到的與做不到的,先講清楚: + # ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。 + # 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。 + # ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄, + # 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。 + # ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。 + # + # 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open—— + # 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有 + # 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。 + # 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界); + # Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。 + # 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。 + _who="${CLAUDE_AGENT_NAME:-未署名}" + _hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir="" + _reqdir="${_hookdir%/hooks}/pending-main-push" + if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then + _root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown') + # 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰) + _slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-') + case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac + # ⚠️ 先 printf 再 tr:`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash + _slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-') + _slug="${_slugwho}--${_slugrepo}" + # ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。 + # 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在), + # ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。 + _branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?') + _when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?') + _fence='```' + { + printf '%s\n\n' "# 推 main 的請求:$_who" + printf '%s\n' "- repo:$_root" + printf '%s\n' "- 分支:$_branch" + printf '%s\n\n' "- 時間:$_when" + printf '%s\n' "- 它想跑的指令:" + printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence" + printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence" + git log --oneline '@{upstream}..HEAD' 2>/dev/null \ + || git log --oneline -20 2>/dev/null \ + || printf '(列不出來)\n' + printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence" + git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n' + printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。" + } > "$_reqdir/$_slug.md" 2>/dev/null || true + fi + + cat >&2 <<'MSG' +🚫 推 main 要先有「總管決定了」的戳記(leo 2026-08-10 立) + +leo 原話: +「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」 +「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」 + +━━━ 你是 subagent ━━━ +**不要造那枚戳記。** 把改動留在自己的分支上,推那條分支,交件裡寫清楚: + · 分支名 + · 這幾筆各是什麼、**各自**驗過了沒有(逐筆,不要整包說「驗過了」) + · 你建議合併還是先擱著,理由是什麼 +總管看過才會併進 main。**這是一句話的事,不要自己想辦法過閘。** + +📮 **你的請求已經自動留下來了**(`.claude/pending-main-push/` 底下,用你的 + `CLAUDE_AGENT_NAME` 署名;沒有這個變數就署「未署名」)。裡面是 repo/分支/ + 逐筆 commit 的原始資料 ⇒ **你不必在交件裡重抄一遍那些,總管會直接讀。** + +━━━ 你是總管 ━━━ +逐筆看過那些 commit(`git log --oneline gitea/main..<branch>`、`git diff --stat`), +確定它們該進 main,再: + + git rev-parse --show-toplevel > /tmp/.main-push-ok && <你的 git push 指令> + +戳記 **綁這個 repo、只能用一次、15 分鐘失效**——它代表「**這一次、這個 repo,我看過了**」。 + +🔴 **為什麼從 `touch` 改成寫入 repo 路徑(2026-08-11 被穿透過一次)**: +總管為了推頂層而 `touch` 了戳記,**15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**, +本閘毫無反應。舊版既不綁 repo、也不會用掉 ⇒ 一次確認等於全域開門。 +**在多條 subagent 並行的情況下,「時間窗」本身就是漏洞。** + +【為什麼不是形式主義】2026-08-10 真的發生過: + 總管派工寫「驗過了 → 合併(這是預設)」,subagent 照做並直接推上 gitea main。 + 而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。 + ⇒ 內容是好的,但**該不該先問,和內容對不對,是兩件事**。 + ⇒ 而且那次是**總管的指令漏了那道閘**——所以現在由機器守,不靠誰記得。 + +【真的該推 main 的例外】不存在。交回總管,一句話的事。 +MSG + exit 2 + ;; + esac + ;; + esac +fi + +# ── ② prod 部署要 leo 親手解保險 ──────────────────────────────────────── +# 先排除 stage/staging 的同名動作(它們本來就該自由跑) +# +# 🔴 2026-08-13 leo 拍板:「**你可以標示整套測試環境,告訴閘『這些是 stage』, +# 做這些事時白名單跳過**」。 +# +# 為什麼要補(同款第三次):`prod-write-guard.sh:188-191` 在 2026-08-12 就補過 +# 一模一樣的例外,理由白紙黑字寫在那裡—— +# 「`youlin`(youlin-hsieh-dev)**就是測試場**,不是 prod⋯⋯ +# 本閘原本只認 `staging` 這個字 ⇒ 打 youlin 做實驗被誤擋」 +# **但同一批補丁漏了這一支。** ⇒ 修法只改其中一份、沒有機制保證另一份跟上 +# (同日另兩例:`kbdb-api-wall-guard.sh` 本機版與 plugin 版不同步,擋了九次)。 +# +# 🔴 判準不變,只是把「測試環境」講完整:**放行的是目標,不是動作。** +# 命令看不出打哪裡 ⇒ 不放行。想被放行就把目標寫進命令裡, +# **不要用假的 `# staging` 註解騙閘**(2026-08-13 一條 subagent 明確拒絕這樣做,是對的)。 +# +# 整套測試環境=下列任一出現在命令裡: +# · `youlin-hsieh-dev` ── 帳號名(D37 定的 stage 帳號) +# · `1129efd7df2e8899d537e9c8fbabb6cb` ── 該帳號 ID(用 env 指過去時只有它看得見) +# · `_YOULIN_` ── 憑證變數名(如 `CLOUDFLARE_API_TOKEN_YOULIN_CC_USE`) +# · `X-Arcrun-API-Key: youlin` ── 打該實例 API 時的身分標頭 +case "$CMD" in + *staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*) exit 0 ;; + *youlin-hsieh-dev*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;; +esac + +# 🔴 2026-08-12 leo 拍板「加閘」:**geek6688(出貨機)是明文授權「總管可以直接動」的那一台。** +# +# 依據——`system-dev/wiki/credentials-map.md` 早就寫死: +# `CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE`|geek6688 帳號 CF token(**leo 2026-08-12 開**) +# ——**總管唯一可以直接動的實例**|用途:**把那台拉到最新/推出貨工作流/ +# `ARCRUN_SHIP_BASE` 指過去後的第 17~19 站** +# ⇒ 規則早就存在,只是這道閘不知道。本段就是把它教給機器。 +# +# 為什麼該放行:geek6688 的職務就是「**不受出貨控制**」——出貨機與被出貨的是同一套程式, +# 要出的貨若本身是引擎更新會形成死結,所以要有一台先更新的機器。 +# 每出一次貨都要 leo 親手 arm 它一次 = 把他變成出貨流程裡的按鈕, +# **而那正是這台實例存在的意義要拔掉的東西**。 +# +# 🔴 **範圍刻意開到最窄:只認「命令裡明確指名 geek6688 這台」。** +# 放行的是**目標**,不是動作——打 leo21c/uncle6/任何其他實例一律照舊要 arm。 +# 命令看不出打哪裡 ⇒ 不放行(與上方 stage 同一個原則:那正是該讓它看得出來的理由)。 +case "$CMD" in + *geek6688*|*CC_SHIPPING_CORE*|*ACCOUNT_ID_GEEK6688*) exit 0 ;; +esac + +case "$CMD" in + *"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;; + *) exit 0 ;; +esac + +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +ARMED="$PROJ/.github-armed" + +# ── 🔐 leo 解保險的第二條路:Gitea 票上回一句話(2026-08-13,示範接這一道閘)── +# +# leo 2026-08-13:「如果你會被通知,就不需要通過 terminal 來 arm 了」。 +# 身分分離做完後(`Leo/InkStoneCo#32`),票上作者是 `Leo` 的留言機器偽造不出來, +# 可以當人閘證據——於是 arm 可以搬到手機。 +# 🪦 2026-08-16 起不再是單一頻道票(原 issues/34)——票號由發請求的當下指定, +# 解哪個任務的保險就貼在那個任務自己的票上(issues/34#issuecomment-2804)。 +# +# 這條路完全獨立於上面的 `$ARMED`(`.github-armed`,終端機那條路照舊在,兩者並存)。 +# 只有本機**已經有人跑過 `scripts/gitea-arm-request.sh`**(本地留下待核請求)時才會 +# 去打一次 Gitea——不是每次 wrangler deploy 都主動打;沒有待核請求就跳過, +# 不浪費一次 API 呼叫,也不構成輪詢(只在「機器需要解閘的當下」查一次)。 +if [ ! -f "$ARMED" ] && [ -d "$PROJ/.claude/gitea-arm/pending" ] \ + && [ -n "$(ls -A "$PROJ/.claude/gitea-arm/pending" 2>/dev/null)" ]; then + # 🔴 `--consume`:這一行放行的就是 prod 部署本身(下面直接 `exit 0`) + # ⇒ 核准必須當場用掉。2026-08-16 起 check 預設唯讀,不帶這個旗標=同一組碼 + # 可以無限次放行 prod,比原本的「探測會誤消耗」嚴重得多。 + if "$PROJ/scripts/gitea-arm-check.sh" --consume >/tmp/.gitea-arm-check-last.log 2>&1; then + exit 0 # leo 已在 Gitea 回覆過有效代碼,且核對通過(單次用完,見該腳本) + fi +fi + +if [ ! -f "$ARMED" ]; then + cat >&2 <<'MSG' +🚫 prod 部署要 leo 親手確認(leo 2026-08-10:「要推 prod 要我手動確認」) + +偵測到 `wrangler deploy`,而且命令裡沒有任何 stage/staging 字樣 ⇒ 視同**打 prod**。 +**推 prod 就發佈了**——封測者/用戶當場拿到。 + +【怎麼過這道閘】把指令交給 leo 自己跑,並且照 `/issue-handle` 的規矩帶三樣: + · 他要打開什麼(確切網址/確切指令,**你要先 `--dry-run` 打過**) + · 他該看到什麼(正確的樣子長怎樣,例如版本字串會變成什麼) + · 什麼情況算失敗(看到什麼就回你一聲) + +【這道閘補的破口】`stage-before-prod-guard.sh` 只比對 +`arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字, +**抓不到 `wrangler deploy`**(2026-08-10 實查)。它擋的是 bundle 出貨鏈,不是所有發佈。 + +【真的是 stage?】命令裡明確帶 `--env stage`/`staging` 就會自動放行。 +若你確實在打 stage 卻被擋,代表**命令本身看不出打哪裡**——那正是該讓它看得出來的理由。 + +【2026-08-13 新增:leo 現在也能用手機解,不必開電腦】 + 1. 跑 `scripts/gitea-arm-request.sh <這件任務對應的票號> "任務描述"` 生一組代碼, + 貼在**這件任務自己那張票上**(不是固定某張頻道票——2026-08-16 起票號由你指定) + 2. 把印出來的那句轉告 leo(Telegram/人話一句+一個代碼) + 3. leo 在 Gitea 上(App 或網頁都行)回那則貼上代碼 + 4. 重跑一次你剛剛被擋的指令——這道閘會自動去核對,核對過就放行一次 +MSG + exit 2 +fi + +exit 0 diff --git a/hooks/micromanage-guard.sh b/hooks/micromanage-guard.sh new file mode 100755 index 0000000..ac7562e --- /dev/null +++ b/hooks/micromanage-guard.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# micromanage-guard.sh — PreToolUse(Agent):**擋微管理的派工單**。 +# +# 🔴 為什麼要有這道(leo 2026-08-08 點破,且他指出「很早就有規矩」): +# 「不可以微管理,因為**你管的也是 CC**,要把它當作**有能力處理整件工作的 CC**。 +# 而且有設計一個警察抓微管理,如果沒有,你要恢復它。」 +# +# 查證:`kbdb-api-wall-guard.sh` 確實會在派工時**印出**「派工鐵律:寫目的,不寫做法」, +# 但它**只是印字,不檢查我的 prompt**——跟今天抓到的其他三個同款: +# 規則被讀到了,卻沒有機制驗證我照做。hook 目錄裡沒有任何一支真的在擋微管理。 +# +# 而我 08-08 當天的派工單就是實例:五項編號驗收、四個具名的坑、指定 shell 命令、 +# 還要求「每完成一項就回報一次」——那是把 CC 當一次性工具用,不是當同事。 +# +# 判準(CLAUDE.md 派工鐵律):派工只准寫「**要達成什麼 + 怎麼驗 + 紅線**」。 +# ✅ 目的:「讓拿到檔的人光看那份檔就能判斷這台機器還有沒有在動」 +# ❌ 做法:「在 local 段補 last_sync 欄位,改 buildDiagnosticsPayload()」 +# 實證(08-08):同一件事我**刻意只寫目的**,subagent 交出整個 engine 區塊, +# 比我心裡想的「加一個時間戳」好——**微管理會把對方的判斷力關掉。** +# +# 這道閘不是要我寫得少,是要我**寫在對的層次**。紅線與驗法本來就該具體; +# 「你先做 A 再做 B、用哪個指令、改哪個函式」才是要擋的。 +set -eu + +INPUT="$(cat)" +PROMPT=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) + print(d.get("tool_input", {}).get("prompt", "") or "") +except Exception: + print("") +' 2>/dev/null || echo "") +[ -z "$PROMPT" ] && exit 0 + +TMP=$(mktemp); printf '%s' "$PROMPT" > "$TMP" +trap 'rm -f "$TMP"' EXIT + +lines=$(wc -l < "$TMP" | tr -d ' ') + +# 微管理訊號:每一條都是「替對方決定怎麼做」的形狀 +sig=0 +# ① 直接給 shell 指令要它照跑 +n1=$(grep -cE '`(git|go|npm|npx|hdiutil|wrangler|curl|unzip|shasum|strings) ' "$TMP" || true) +[ "$n1" -ge 2 ] && sig=$((sig+1)) +# ② 指名要改哪個檔/哪個函式(在派工單裡=替它決定實作位置) +n2=$(grep -cE '`[A-Za-z0-9_/.-]+\.(go|ts|tsx|js|mjs|sh|json|toml)`|\(\)`|函式|函數' "$TMP" || true) +[ "$n2" -ge 4 ] && sig=$((sig+1)) +# ③ 編號的操作步驟(「1. 先… 2. 再…」=流程由我編好) +n3=$(grep -cE '^ *[0-9]+\. *(先|再|然後|接著|把|改|加|跑|執行|部署)' "$TMP" || true) +[ "$n3" -ge 2 ] && sig=$((sig+1)) +# ④ 過程控制(要它按我的節奏回報,而不是交成果) +n4=$(grep -cE '每完成一項|每一步都回報|做完一步|逐步回報|先做.*再回報' "$TMP" || true) +[ "$n4" -ge 1 ] && sig=$((sig+1)) +# ⑤ 派工單過長——超過 70 行多半是把我腦裡的做法整套倒出去 +[ "$lines" -ge 70 ] && sig=$((sig+1)) + +[ "$sig" -lt 2 ] && exit 0 # 訊號少於 2 個=多半是正常的目的+驗法+紅線 + +STAMP="/tmp/.micromanage-warned-$(printf '%s' "$PROMPT" | shasum | cut -c1-12)" +[ -f "$STAMP" ] && exit 0 # 同一張單只擋一次,改完重送即放行 +date +%s > "$STAMP" + +cat >&2 <<EOF +🎛️ 微管理警察:這張派工單有 $sig 個「替對方決定做法」的訊號(共 $lines 行)。 + +【leo 2026-08-08】「**不可以微管理,因為你管的也是 CC**, + 要把它當作**有能力處理整件工作的 CC**。」 + +【判準】派工只准寫三樣:**要達成什麼 / 怎麼驗 / 紅線**。 + ✅ 目的:「讓拿到檔的人光看那份檔,就能判斷這台機器還有沒有在動」 + ❌ 做法:「在 local 段補 last_sync 欄位,改 buildDiagnosticsPayload()」 + +【今天的實證,別忘了】同一件事我**刻意只寫目的**,它交回整個 engine 區塊 + (alive/crash_looping/headline/秒數),**比我心裡想的「加一個時間戳」好**。 + ⇒ **微管理會把對方的判斷力關掉**,而你不是那個 repo 的專家,它才是。 + +偵測到的訊號: + 指令要它照跑=$n1 指名檔案/函式=$n2 編號步驟=$n3 過程控制=$n4 行數=$lines + +改法(不是刪短,是**換層次**): + · 把「怎麼做」整段刪掉——它會自己查 wiki、查 skill、讀 code + · 保留並寫清楚:**這件事成功長什麼樣**、**怎麼驗**、**不准碰什麼** + · 已知的坑可以留,但寫成「這裡有前科」而不是「你要下這個指令」 + · 不要規定它的回報節奏——要成果,不要進度條 + +改完重送即放行(同一張單只擋一次)。 +EOF +exit 2 diff --git a/hooks/mistake-needs-ticket-guard.sh b/hooks/mistake-needs-ticket-guard.sh new file mode 100755 index 0000000..d379795 --- /dev/null +++ b/hooks/mistake-needs-ticket-guard.sh @@ -0,0 +1,108 @@ +#!/bin/sh +# mistake-needs-ticket-guard.sh — PreToolUse(Write|Edit|MultiEdit): +# **往 mistakes.md 寫一條新教訓,如果機制擋得住,就必須附票號。** +# +# 🔴 立這條的原話(leo 2026-08-16,一口氣講了三次,因為前兩次我又拿去寫成文件): +# 「以後你自己寫入 mistake 的東西,**如果是機制可以防止的都要貼上票號**。」 +# 「要不然你的 mistake 根本**不會協助你迭代**。」 +# 「**沒迭代寫個 mistake 做什麼?下一次又犯同樣錯誤,你就說上次寫過。**」 +# +# 【當天的活教材,就是這條規則本身要治的病】 +# `mistakes.md:220` 那條(08-14)結尾自己寫著: +# 「📌 **待修**:`check` 應該要有唯讀模式,否則這個坑對每一個第一次用它的人都會再發生一次。」 +# 它**沒有票號** ⇒ 沒有任何人/任何看板會撈到它 ⇒ 躺了兩天 ⇒ +# 08-16 總管**原封不動照撞一次**,還在回覆裡說「這個我兩天前寫過」。 +# ⇒ 那句「我寫過」正是 leo 預言的那句話。**寫下來不是迭代,開票才是。** +# +# 【判準:什麼叫「機制可以防止」】 +# 不是所有教訓都要票。要票的是**下一次可以被機器擋下來**的那種: +# · 用錯工具/用錯順序/漏做一步 → 可以做成 hook 或 preflight +# · 兩份真相漂移/欄位漏填 → 可以做成不變式 +# · 「我以為 X 其實 Y」的事實誤解 → 通常**不需要**票(那是知識,不是閘) +# 本閘不猜,用一個便宜的近似:**新增的段落裡出現「待修/應該要/下次要/ +# 要改成/可以做成」這類「有人得動手」的字樣,就要求同段落有票號。** +# +# 【放行的寫法】同一段裡出現任一種票號定址即可: +# inkstone/arcrun-rag#88 / Leo/Arcrun#136 / 本 repo 的 #142 +# 真的不需要票(純知識型教訓):該段加 `no-ticket-needed` 一詞(留痕,說明理由)。 +set -eu + +PAYLOAD=$(cat 2>/dev/null || echo '{}') + +RESULT=$(printf '%s' "$PAYLOAD" | python3 -c ' +import sys, json, re + +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) + +ti = d.get("tool_input") or {} +path = ti.get("file_path") or "" +if not path.endswith("mistakes.md"): + sys.exit(0) + +# 收集這次「新寫進去」的文字(Write 全文;Edit 只看 new_string) +chunks = [] +if "content" in ti: + chunks.append(ti["content"]) +if "new_string" in ti: + chunks.append(ti["new_string"]) +for e in (ti.get("edits") or []): + if isinstance(e, dict) and "new_string" in e: + chunks.append(e["new_string"]) + +text = "\n".join(c for c in chunks if isinstance(c, str)) +if not text.strip(): + sys.exit(0) + +# 「有人得動手」的訊號——出現這些就代表它是可被機制防止的 +ACTION = re.compile(r"待修|應該要|下次要|要改成|可以做成|該做成|還沒做|尚未修|TODO|待辦") +# 票號定址:owner/repo#N 或裸 #N +TICKET = re.compile(r"(?:[A-Za-z0-9._-]+/[A-Za-z0-9._-]+)?#\d+") + +# 逐「段」判斷(空行分段),只報第一個有問題的段落 +for para in re.split(r"\n\s*\n", text): + if not ACTION.search(para): + continue + if "no-ticket-needed" in para: + continue + if TICKET.search(para): + continue + hit = ACTION.search(para).group(0) + snippet = " ".join(para.split())[:120] + print(json.dumps({"hit": hit, "snippet": snippet}, ensure_ascii=False)) + break +' 2>/dev/null || true) + +[ -n "$RESULT" ] || exit 0 + +HIT=$(printf '%s' "$RESULT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["hit"])' 2>/dev/null || echo "待修") +SNIP=$(printf '%s' "$RESULT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["snippet"])' 2>/dev/null || echo "") + +cat >&2 <<EOF +🎫 這條教訓是「機制擋得住」的,但沒有票號——**沒有票,它不會被迭代** + + 觸發字樣:「$HIT」 + 那一段: $SNIP + +【leo 2026-08-16,一口氣講了三次】 + 「以後你自己寫入 mistake 的東西,**如果是機制可以防止的都要貼上票號**。」 + 「要不然你的 mistake 根本**不會協助你迭代**。」 + 「**沒迭代寫個 mistake 做什麼?下一次又犯同樣錯誤,你就說上次寫過。**」 + +【當天的活教材(就是這條規則的來由)】 + \`mistakes.md\` 那條 08-14 的結尾自己寫著「📌 待修:check 應該要有唯讀模式」, + **沒有票號** ⇒ 沒有看板撈得到 ⇒ 躺了兩天 ⇒ 08-16 總管原封不動照撞一次, + 然後在回覆裡說「這個我兩天前寫過」。**那句話正是 leo 預言的那一句。** + +【現在怎麼做(擇一)】 + ① **先開票,再寫教訓**(正解,順序不能反): + scripts/ticket where <關鍵字> # 先查這件事該掛哪張既有票 + scripts/ticket say <owner/repo#N> -F <檔> # 有既有票就貼進去 + scripts/ticket new <repo> -F <檔> # 真的沒有才開新票 + 然後把票號寫進這一段:\`inkstone/arcrun-rag#88\` + ② **這條純粹是知識型教訓、沒有東西要動手**(例如「我以為 X 其實 Y」): + 該段加一詞 \`no-ticket-needed\`,並在旁邊寫一句為什麼不需要閘 +EOF +exit 2 diff --git a/hooks/no-ticket-no-dispatch.sh b/hooks/no-ticket-no-dispatch.sh new file mode 100755 index 0000000..e9ac1e5 --- /dev/null +++ b/hooks/no-ticket-no-dispatch.sh @@ -0,0 +1,124 @@ +#!/bin/sh +# no-ticket-no-dispatch.sh — PreToolUse(Agent|Task):**無票不發包。** +# +# 🔴 為什麼是硬擋而不是提醒(leo 2026-08-16): +# 「**提醒可以忽略,deny 不能**——被 deny 的動作沒有發生,不存在「忽略」這個選項。 +# 所以問題化簡為:找出『起任務』在物理上必經的瓶頸,把閘設在那裡。」 +# 「因為我發現**你不會去更新票**,所以定義 tags 完全是裝飾⋯⋯ +# 唯一辦法是,你不准寫 code,要 subagent 寫**一定要有票號**,它自己去拿任務, +# 回覆在票裡⋯⋯這樣你就會去更新狀態,否則永遠會『我忘了』, +# **我就無法知道你做到哪裡**。」 +# +# 瓶頸就是這個工具呼叫:總管要讓 subagent 動起來,唯一的機制是它,繞不過去。 +# ⇒ 「先開票」不是 SOP 第一條,是**發包動作的前置條件**, +# 跟「不插鑰匙車不會發動」同一性質。 +# +# 🔴 同日實錯(本閘的來由):總管說了三次「下一步要查 X」都沒有派人; +# 真的派人時又**沒搜就開新票**(arcrun-rag#110),而那條線早有 hub(InkStoneCo#44)。 +# ⇒ 兩個病:說了不派、派了亂開票。前者這道閘擋不到,後者由 `scripts/ticket` 擋。 +# +# 認什麼:派工單裡要有一行 `【工單】owner/repo#N`,可再帶 comment 定址 +# (`#issuecomment-2749` 或 `→ comment 2749`)。 +# leo:「你可以更細的寫票號+對話的號,因為它不一定拿整張票, +# 可能你把指令寫在某個對話裡。」 +# +# 刻意的取捨(fail-closed vs fail-open 的界線畫在哪): +# · **票號缺席 → 硬擋。** 這是純本地檢查,永遠可靠,沒有藉口。 +# · **票號在、但 Gitea 連不上 → 放行並警告。** 網路抖動不該讓工作停擺; +# 而且「他寫了票號」這件事本身已經是可追溯的痕跡。 +# · **票號在、Gitea 說那張票不存在或已關 → 硬擋。** 那是指向空氣。 +set -eu + +PAYLOAD=$(cat 2>/dev/null || echo '{}') + +REF=$(printf '%s' "$PAYLOAD" | python3 -c ' +import sys, json, re +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +p = (d.get("tool_input") or {}).get("prompt") or "" +for line in p.splitlines(): + if "【工單】" not in line: + continue + m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line) + if m: + print(f"{m.group(1)} {m.group(2)} {m.group(3)}") + break +' 2>/dev/null || true) + +if [ -z "$REF" ]; then + cat >&2 <<'EOF' +🚫 無票不發包(leo 2026-08-16 立) + +leo 原話:「**你不會去更新票,所以定義 tags 完全是裝飾**⋯⋯ + 要 subagent 寫一定要有票號,它自己去拿任務,回覆在票裡, + 這樣你就會去更新狀態,否則永遠會『我忘了』,**我就無法知道你做到哪裡**。」 + +派工單裡缺一行工單號。加上這一行(放最前面): + + 【工單】inkstone/<repo>#<N> + 【工單】inkstone/InkStoneCo#44 → comment 2749 ← 指令寫在某則對話裡時這樣寫 + +── 還沒有票?照這個順序,不要直接開新的 ──────────────────── + 1. 先搜「這件事該放哪」 scripts/ticket where <關鍵字...> + 2. 命中同一條線 scripts/ticket say <owner/repo#N> -F <內文檔> ← 預設 + 3. 真的是新的一條線 scripts/ticket new <repo> -F <內文檔> --title <標題> + +🔴 **預設是貼進既有的票,不是開新的。** + leo:「不要每個開新票,現有的票開在它下面的對話裡」 + 「我希望你把 gitea 變成**可以追蹤**,不是變成一個池子」 + +為什麼連「只是查個東西」也要票:研究任務沒有票就是逃逸艙口。 +票上宣告 `deliverable 類型: research` 即可,交付物是貼在票上的結論,不是 PR。 +EOF + exit 2 +fi + +set -- $REF +OWNER="$1"; REPO="$2"; NUM="$3" + +# 拿 token(拿不到就放行——這是本地環境問題,不是派工者的錯) +URL=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote get-url gitea 2>/dev/null || true) +case "${URL:-}" in *@*) ;; *) exit 0 ;; esac +TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') +HOST=$(printf '%s' "$URL" | sed -E 's|.*@([^/]+)/.*|\1|') + +RESP=$(curl -s --max-time 15 -H "Authorization: token $TOKEN" \ + "https://$HOST/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null || true) +[ -n "$RESP" ] || exit 0 # 連不上 → 放行(票號在,已有痕跡) + +STATE=$(printf '%s' "$RESP" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + print("unknown"); sys.exit(0) +print(d.get("state") or ("missing" if d.get("message") else "unknown")) +' 2>/dev/null || echo unknown) + +case "$STATE" in + open|unknown) exit 0 ;; + closed) + cat >&2 <<EOF +🚫 工單 $OWNER/$REPO#$NUM **已經關閉**,不能拿它當本次派工的依據。 + +關掉的票代表那件事已經有交付物、已經結案。拿它發包=那條線接到一個死結。 + + · 這是同一條線的後續 → 貼進**還開著**的那張票,或重開這張: + scripts/ticket where <關鍵字...> + · 這確實是新的一條線 → scripts/ticket new <repo> -F <檔> --title <標題> +EOF + exit 2 ;; + missing) + cat >&2 <<EOF +🚫 工單 $OWNER/$REPO#$NUM **不存在**(Gitea 查不到)。 + +指向空氣的票號比沒有票號更糟——它看起來像有追溯,實際上斷在第一步。 + +先確認 owner/repo 對不對(org 是 \`inkstone\`,不是 \`Leo\`;\`gh\` 打不到 Gitea), +或用 scripts/ticket where <關鍵字...> 找出真正該掛的那張。 +EOF + exit 2 ;; +esac +exit 0 diff --git a/hooks/not-my-branch-guard.sh b/hooks/not-my-branch-guard.sh new file mode 100755 index 0000000..72c14b2 --- /dev/null +++ b/hooks/not-my-branch-guard.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# not-my-branch-guard.sh — PreToolUse(Bash):**不准 commit 到別人正在施工的分支。** +# +# 🔴 為什麼是硬擋(2026-08-16,總管一小時內犯兩次): +# 第一次:把 branch-hold 的落帳 commit 打在 `feat/gitea-arm-per-issue` 上(agent 的分支) +# 第二次:把 `ticket decide` 的 commit 打在**同一條**分支上 +# ——而且第一次之後,總管在回覆裡寫了「這正是共用工作區那條教訓咬到我自己」。 +# **寫下教訓沒有讓他不再犯。** 一小時後同一個錯又來一次。 +# +# 為什麼會犯:總管的 shell 與 agent 共用同一個工作區,**分支是誰上一次切的就是誰的**。 +# `git commit` 不會問「你確定要打在這條上嗎」,它只是照做。 +# 而 `git push gitea main` 在那種狀態下會回 **`Everything up-to-date`** +# ——**假成功**,因為本機 `main` 確實沒動過。真訊號是「本機 HEAD 與遠端對不上」。 +# +# 判準:**當前分支出現在 `.claude/branch-holds.md` 裡** ⇒ 那是有人(agent)正在上面施工的 +# 分支,或刻意保留的分支。兩種都不該由總管順手 commit 進去。 +# +# 刻意不擋的: +# · 在 `main` 上 commit(那是總管的地盤,另有 main-and-prod-push-guard 管推送) +# · 不在 branch-holds 名單上的分支(總管自己開的工作分支) +# · 非 commit 的 git 動作(status/log/diff/checkout…) +# +# 真的要在那條分支上 commit(例如接手一條被放棄的分支): +# 把它從 `branch-holds.md` 移除(那本來就是「它不再是別人的」的正確表示法), +# 或 `NOT_MY_BRANCH_OK=1 git commit ...`(留痕,commit 說明寫理由)。 +set -eu + +[ "${NOT_MY_BRANCH_OK:-}" = "1" ] && exit 0 + +PAYLOAD=$(cat 2>/dev/null || echo '{}') +CMD=$(printf '%s' "$PAYLOAD" | python3 -c ' +import sys, json +try: d = json.load(sys.stdin) +except Exception: sys.exit(0) +print((d.get("tool_input") or {}).get("command") or "") +' 2>/dev/null || true) + +# 只看 commit(含 -m/-F/--amend) +case "$CMD" in + *"git commit"*) ;; + *) exit 0 ;; +esac + +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +HOLDS="$PROJ/.claude/branch-holds.md" +[ -f "$HOLDS" ] || exit 0 + +BR=$(git -C "$PROJ" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") +[ -n "$BR" ] || exit 0 +case "$BR" in main|master|HEAD) exit 0 ;; esac + +# 分支名有沒有出現在 branch-holds 的條目裡(格式:- `分支名`(repo):理由) +grep -q "^- \`${BR}\`" "$HOLDS" 2>/dev/null || exit 0 + +REASON=$(grep -A1 "^- \`${BR}\`" "$HOLDS" | head -2 | tr '\n' ' ' | cut -c1-160) + +cat >&2 <<EOF +🚫 這條分支不是你的——它列在 branch-holds 上(別人正在施工,或刻意保留) + + 當前分支:$BR + 它的條目:${REASON} + +🔴 **2026-08-16 總管一小時內在同一條分支上犯了兩次**,而且第一次之後 + 還在回覆裡寫了「這正是共用工作區那條教訓咬到我自己」——**寫下教訓沒有讓他不再犯。** + +【為什麼這個錯特別難發現】 + 打錯分支之後跑 \`git push gitea main\` 會回 **\`Everything up-to-date\`**—— + **那是假成功**,因為本機 \`main\` 確實沒動過。 + 真訊號是「**本機 HEAD 與遠端對不上**」,而那句話不會自己跳出來。 + +【現在怎麼做(擇一)】 + ① **切回 main 再 commit**(絕大多數情況的正解): + git checkout main && <你的 git commit ...> + ② **已經打錯了要救**: + git checkout main && git cherry-pick <那顆 sha> + git branch -f $BR gitea/$BR # 把別人的分支還原成遠端狀態 + ③ **你真的要接手這條分支**:先把它從 \`.claude/branch-holds.md\` 移除 + (那本來就是「它不再是別人的」的正確表示法),再 commit + ④ 真有例外:\`NOT_MY_BRANCH_OK=1 git commit ...\`,並在 commit 說明寫理由 +EOF +exit 2 diff --git a/hooks/pending-changes-retired.sh b/hooks/pending-changes-retired.sh new file mode 100755 index 0000000..d736913 --- /dev/null +++ b/hooks/pending-changes-retired.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# pending-changes-retired.sh — `pending-changes.md` 已廢除,寫它一律擋下(leo 2026-08-19) +# +# leo 原話: +# 「那種形式根本難以回覆,每個都長篇大論也不易閱讀,**而且寫入後不會提醒**, +# 如果要寫入 pending changes,一律改用票,這個檔案廢除, +# 可以改成一張 index 指向票號及連結。」 +# +# 🔴 為什麼要有這道閘,而不是只改文件: +# 規則躺在 CLAUDE.md/SDD-LIFECYCLE 裡,已經被證明不夠—— +# wiki mistakes 有一整條「以為『規範存在』=『AI 會照做』」。 +# 而這個檔的特性讓違規特別難發現:**寫進去不會通知任何人**, +# 所以「寫了沒人擋」與「寫了沒人看」會同時成立,債靜靜地長。 +# +# 擋什麼:Write/Edit/MultiEdit 指向任何 repo 的 `3-specs/pending-changes.md`。 +# 不擋什麼:archive/ 底下的存底(那是唯讀歷史,路徑不同)、以及讀取。 +# +# 誠實界線(兩條,都要留著): +# ① 它擋的是**這個路徑**。有人另立一個叫 `pending-changes-v2.md` 的檔照樣繞得過—— +# 那屬於語意層,這道閘不假裝擋得到。 +# ② 它掛在 Write/Edit/MultiEdit 上,**用 bash(heredoc/sed/cat >)寫檔不會經過它**。 +# 這不是疏漏是取捨:PreToolUse 攔不到 shell 內部做什麼;要連那條也擋,得改成 +# Bash matcher 上的字串比對,而那會把「只是提到這個檔名的句子」一起擋掉 +# (`inkstone/InkStoneCo#56` 正在處理的就是這個形狀)。 +set -euo pipefail +input="$(cat)" + +file_path="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) + print(d.get("tool_input", {}).get("file_path", "") or "") +except Exception: + print("") +' 2>/dev/null || true)" + +[ -z "$file_path" ] && exit 0 + +# archive/ 底下的存底放行(唯讀歷史,不是提案緩衝區) +case "$file_path" in + */archive/*) exit 0 ;; +esac + +case "$file_path" in + */3-specs/pending-changes.md|3-specs/pending-changes.md|*/pending-changes.md) + cat >&2 <<'MSG' +❌ BLOCKED:`pending-changes.md` 已於 2026-08-19 廢除,不准寫入。 + +leo 廢除它的理由(原話): + 「那種形式根本難以回覆,每個都長篇大論也不易閱讀,**而且寫入後不會提醒**。」 + +要提規格層變更 → **開一張 Gitea 票**,不是寫檔: + · 標題:👤 裁決題:<一句話> + · 標籤:Human + s/triage · 指派:Leo + · 內文只要三段:**要你裁的** / **怎麼回**(給選項,能回一個詞最好) / **誠實標記** + · 開完就停下等 leo 回覆——「停下等 confirm」這條沒有變,變的只是它住在票上 + +🔴 不要把提案正文貼進票裡當長篇大論。長脈絡寫 wiki,票上只留決策題與連結。 + +原文存底(唯讀):system-dev/docs/3-specs/archive/pending-changes-archive-20260819.md +規則出處:CLAUDE.md「規格變更唯一路徑」、3-specs/SDD-LIFECYCLE.md 第 3 條 +MSG + exit 2 + ;; +esac +exit 0 diff --git a/hooks/pre-write-guard.sh b/hooks/pre-write-guard.sh new file mode 100755 index 0000000..50f6d5f --- /dev/null +++ b/hooks/pre-write-guard.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# PreToolUse hook 範本骨架 —— 專案自訂禁令 +# wishlist §2 可選:讓使用者自訂專案禁令(例:「KBDB 禁動表」「某目錄唯讀」)。 +# +# 預設不啟用。要用時: +# 1. 在下面 FORBIDDEN_PATTERNS 填入禁改的路徑/檔名 pattern +# 2. 到 .claude/settings.json 的 PreToolUse 加掛這支 +# +# 掛在 PreToolUse(matcher: Write|Edit)。stdin 收到 JSON:{ tool_name, tool_input: { file_path } } +# 命中禁令 → exit 2 擋。 +# +# 誠實限制:只擋直接寫檔。bash 繞道、helper 間接改動擋不到。留痕可審 ≠ 技術防偽。 + +set -euo pipefail + +# ── 專案自訂:禁改的 pattern(一行一個,case glob 語法)────── +# 範例(已註解,啟用前請改成自己的): +# "*/db/schema.sql" # 禁手改 schema +# "*/migrations/*" # migration 一旦建立不可改 +FORBIDDEN_PATTERNS=( + # "*/your/protected/path/*" +) + +# 沒設任何禁令 → 直接放行(骨架預設狀態) +[ ${#FORBIDDEN_PATTERNS[@]} -eq 0 ] && exit 0 + +INPUT=$(cat) + +if command -v jq >/dev/null 2>&1; then + FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') +else + FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//') +fi + +[ -z "$FILE_PATH" ] && exit 0 + +for pattern in "${FORBIDDEN_PATTERNS[@]}"; do + # shellcheck disable=SC2254 + case "$FILE_PATH" in + $pattern) + cat >&2 <<EOF +🚫 專案禁令攔截:$FILE_PATH 命中禁改規則($pattern)。 + +這是本專案 .claude/hooks/pre-write-guard.sh 設定的硬底線。 +要動 → 先和負責人確認,並更新禁令設定。 +EOF + exit 2 + ;; + esac +done + +exit 0 diff --git a/hooks/pre-write-guard.template.sh b/hooks/pre-write-guard.template.sh new file mode 100755 index 0000000..abe5030 --- /dev/null +++ b/hooks/pre-write-guard.template.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# PreToolUse hook 範本骨架 —— 專案自訂禁令(預設空殼,不攔任何東西) +# +# ⚠️ 定位(讀清楚再用): +# 這支跟其他三支 hook 不同——它不是「裝上就生效的警察」,而是一個「按需手填的 +# 空插槽」。預設狀態下 FORBIDDEN_PATTERNS 是空的,它【不攔任何東西】。 +# 別誤以為裝了它就有保護——空殼 = 沒保護。 +# +# 🤖 有 CC 在場的話,通常不需要這個範本: +# 直接叫你的 CC「幫我寫一支 guard hook,禁止改 X」。CC 現寫的條件邏輯, +# 表達力遠勝這裡的 glob FORBIDDEN_PATTERNS(例如「禁子 repo 的 code 但放行 .md」 +# 這種細緻規則,glob 寫不出來,CC 的條件判斷寫得出來)。 +# 這個範本只對「不靠 CC、想自己手動 DIY bash」的用戶有價值。 +# +# 要啟用(手動 DIY 路線): +# 1. 在下面 FORBIDDEN_PATTERNS 填禁改的路徑/檔名 pattern +# 2. 到 .claude/settings.json 的 PreToolUse 加掛這支 +# +# 掛在 PreToolUse(matcher: Write|Edit)。stdin 收 JSON:{ tool_name, tool_input:{ file_path } } +# 命中禁令 → exit 2 擋。 +# +# 誠實限制:只擋直接寫檔。bash 繞道、helper 間接改動擋不到。留痕可審 ≠ 技術防偽。 + +set -euo pipefail + +# ── 專案自訂:禁改的 pattern(一行一個,case glob 語法)────── +# 範例(已註解,啟用前請改成自己的): +# "*/db/schema.sql" # 禁手改 schema +# "*/migrations/*" # migration 一旦建立不可改 +FORBIDDEN_PATTERNS=( + # "*/your/protected/path/*" +) + +# 沒設任何禁令 → 空殼狀態,安靜放行。 +# (不在這裡 print——PreToolUse 每次 Write/Edit 都會跑,每次喊話會洗版。 +# 「這是空殼」的提醒改由 install.sh / update.sh 安裝時告知,那裡用戶一定看得到。) +[ ${#FORBIDDEN_PATTERNS[@]} -eq 0 ] && exit 0 + +INPUT=$(cat) + +if command -v jq >/dev/null 2>&1; then + FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') +else + FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//') +fi + +[ -z "$FILE_PATH" ] && exit 0 + +for pattern in "${FORBIDDEN_PATTERNS[@]}"; do + # shellcheck disable=SC2254 + case "$FILE_PATH" in + $pattern) + cat >&2 <<EOF +🚫 專案禁令攔截:$FILE_PATH 命中禁改規則($pattern)。 + +這是本專案 .claude/hooks/pre-write-guard.sh 設定的硬底線。 +要動 → 先和負責人確認,並更新禁令設定。 +EOF + exit 2 + ;; + esac +done + +exit 0 diff --git a/hooks/prod-write-guard.sh b/hooks/prod-write-guard.sh new file mode 100755 index 0000000..437d424 --- /dev/null +++ b/hooks/prod-write-guard.sh @@ -0,0 +1,264 @@ +#!/bin/sh +# prod-write-guard.sh — PreToolUse(Bash + 會寫到線上實例的 MCP 工具) +# +# 🔴 立這道閘的來由(leo 2026-08-11,當天實際發生): +# 兩條 subagent 在同一個 session 裡把東西推上了 leo21c 線上實例—— +# ① `#79` 覆寫了現役出貨管線的 `ship_refresh_cdn` 工作流定義 +# ② `#85` 推了一支新工作流上去 +# **兩次都沒有被任何閘擋到**,儘管派工單第一條紅線就寫著「不可以部署到正式環境」。 +# +# leo 當場點破,而他的說法比「再補一種推送」更根本: +# 「**其實問題是它跟正式環境是斷開的,所以 hook 要不讓它對正式環境做任何推送, +# 不是某種推送,這種只有透過你。**」 +# +# ⇒ 判準不是「這是哪一種推送」,是「**這個動作會不會改到線上那台**」。 +# 會 ⇒ 擋,交回總管。總管要動之前,上面還有 leo 的保險(D20/.github-armed)。 +# +# 為什麼既有的閘接不住(2026-08-11 實查,不是推測): +# · `main-and-prod-push-guard.sh` 只認 `wrangler deploy|publish|versions deploy` +# · `stage-before-prod-guard.sh` 只比對 `arcrun-rag-bundles`/`github-arm`/`publish-github` +# · **兩支都只掛在 `Bash` 上**,而 `arcrun_push_workflow` 是 **MCP 工具,根本不經過 Bash** +# ⇒ 推工作流定義這條路,從頭到尾沒有任何一雙眼睛。 +# +# 設計紀律(沿用前兩支用血換來的): +# • **fail-closed**:內部錯誤不准變成靜默放行。exit 1 不擋,只有 exit 2 才擋。 +# • **先排除「談論/讀取/演練」再比對**:誤攔比漏攔更容易殺死一道閘 +# ——被擋得莫名其妙,人就會想辦法繞過它。 +# • **stage 一律自由**:擋的是 prod,不是所有部署。測試場本來就該隨便跑。 +set -eu + +INPUT="$(cat)" + +TOOL=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("tool_name", "") or "") +except Exception: print("") +' 2>/dev/null || printf '') + +CMD=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "") +except Exception: print("") +' 2>/dev/null || printf '') + +# ── 總管戳記:**單次、15 分鐘失效** ──────────────────────────────────── +# +# 🔴 這一版一開始寫成「15 分鐘內無限次放行」,而同一天 `main-and-prod-push-guard.sh` +# 就是因為這個寫法被穿透的:總管為了推頂層 touch 了戳記, +# **15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**。 +# ⇒ **多條 subagent 並行時,「時間窗」本身就是漏洞。** 所以改成單次用完即丟。 +# ⇒ 而且只在**真的要擋的那一刻**才檢查——放在檔頭會被任何一條無關指令把戳記燒掉。 +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) + case "$NOW$MT" in *[!0-9]*) return 1 ;; esac + [ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1 + [ $((NOW - MT)) -lt 900 ] || return 1 + rm -f "$STAMP" 2>/dev/null || true # 單次:用完即丟 + return 0 +} + +# ══ ① MCP 工具:會改到線上實例的那幾支 ═════════════════════════════════ +# 這一段是本閘存在的理由——上面兩支既有的閘完全看不到這裡。 +case "$TOOL" in + *arcrun_push_workflow*|*arcrun_delete_workflow*|\ + *arcrun_recipe_push*|*arcrun_recipe_delete*|\ + *arcrun_create_tag*|*arcrun_delete_tag*|\ + *arcrun_tag_resource*|*arcrun_untag_resource*|\ + *kbdb_create_record*|*kbdb_create_template*) + stamp_ok && exit 0 + cat >&2 <<'MSG' +🚫 這個動作會改到「線上那台」——不是你能自己按的(leo 2026-08-11 立) + +leo 原話: +「**其實問題是它跟正式環境是斷開的,所以 hook 要不讓它對正式環境做任何推送, + 不是某種推送,這種只有透過你。**」 + +⇒ 判準不是「這是哪一種推送」,是「**這會不會改到線上那台**」。會,就交回總管。 + +━━━ 你是 subagent ━━━ +**不要想辦法過這道閘。** 把成品留在自己的分支/檔案裡,交件時寫清楚: + · 你要推什麼上去(名稱+它會覆蓋掉什麼,還是全新的) + · 為什麼現在該推、你驗過哪些(逐項,不要整包說「驗過了」) + · 推上去之後**用什麼指令可以看出它生效了** +總管會看過再決定。**這是一句話的事。** + +🔴 特別注意「**覆寫現役的東西**」:2026-08-11 真的發生過—— +一條 subagent 覆寫了現役出貨管線的工作流定義。那次內容是對的(舊版真的壞了), +**但「內容對不對」和「該不該先問」是兩件事**(D54)。 + +━━━ 你是總管 ━━━ +看清楚它會覆蓋什麼(`arcrun_get_workflow` 先撈一份現況存起來,好回滾),確定要推,再: + + touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失 + +戳記 15 分鐘有效。**若這一推會讓封測者/用戶當場拿到東西,那就不是你的層級——要 leo 解保險。** +MSG + exit 2 + ;; +esac + +# ── 非 Bash 的其他工具一律放行 ───────────────────────────────────────── +[ "$TOOL" = "Bash" ] || exit 0 +[ -z "$CMD" ] && exit 0 + +# ── 「執行它」vs「談論它」:動詞要在**指令位置**才算數 ───────────────── +# +# 🔴 2026-08-13 第六次同款誤攔(前五次記在頂層 `wiki/mistakes.md`): +# 總管要把 `#108` 的驗收證據**留言到 Gitea**,指令長這樣—— +# BODY=$(cat <<'EOF' … 正文裡引述了 `acr update --force` … EOF +# ) +# curl -X POST https://git.uncle6.me/api/v1/…/comments +# ⇒ 命中舊的 `*"acr update"*` ⇒ **一則留言被當成部署擋掉。** +# (curl 那半本來是對的:打 Gitea 不是打實例,下面的分支會 exit 0。 +# 擋它的是那條純字串比對,而且它排在 curl 那支前面。) +# +# 本檔第 25 行自己寫著「誤攔比漏攔更容易殺死一道閘」,而這是同一形狀的第六次: +# **判準看「字串有沒有出現」,不是「它在做什麼」。** +# +# 判準改成兩層:① 剝掉 heredoc 內文(那是資料,不是指令) +# ② 動詞要在指令位置——行首/`;` `&` `|` `(` `$(` 之後/引號開頭 +# 仍會擋:`bash -c "acr update"`、`a && acr push`、`npx wrangler deploy`。 +# 不再擋:反引號或 markdown code span 裡的引述、commit 訊息、註解、heredoc 正文。 +# +# 🔴 這段**必須算在所有豁免之前**(2026-08-13 被自己補的測試抓到): +# `cat <<'EOF' > note.md … EOF` 換行 `acr update --force` +# ⇒ 指令以 `cat ` 開頭,而下面認投送工具的 `*" acr "*` 是**空格界定**、 +# 認不出換行後的 `acr` ⇒ 掉進「開頭是唯讀工具」的豁免直接放行。 +# 同款的還有 `git commit -m 'x' && acr update`(吃 `*"git commit"*` 豁免)。 +# ⇒ 命中指令位置的,一概不吃任何豁免。 +CLI_HIT=$(printf '%s' "$CMD" | python3 -c ' +import sys, re +cmd = sys.stdin.read() +cmd = re.sub(r"<<-?[ \t]*([\x27\x22]?)([A-Za-z_][A-Za-z0-9_]*)\1.*?^[ \t]*\2[ \t]*$", + " ", cmd, flags=re.S | re.M) +LAUNCH = r"(?:npx\s+|pnpm\s+(?:exec\s+)?|yarn\s+|sudo\s+|env\s+\S+=\S+\s+)*" +# 指令位置=行首/`;` `&` `|` `(` `$(` 之後/**shell 呼叫旗標後面的引號**。 +# 🔴 引號本身不算(2026-08-13 被自己補的測試抓到):先前把 `\x22\x27` 直接放進 +# 前綴字元類,結果 `grep \x27acr push\x27 <檔>` 被當成在執行 ⇒ 又是一次誤攔。 +# 只有 `bash -c "…"`/`sh -lc \x27…\x27`/`eval "…"` 這種**真的會把字串當指令跑**的才算。 +SHELLC = r"(?:bash|sh|zsh|dash|eval|xargs)\s+(?:-\w+\s+)*[\x22\x27]" +PAT = (r"(?:^|[\n;&|(]|\$\(|" + SHELLC + r")\s*" + LAUNCH + + r"(acr|wrangler)\b([^\n;&|)]*)") +for m in re.finditer(PAT, cmd): + tool, rest = m.group(1), m.group(2) + if re.search(r"(^|\s)(--help|-h)(\s|$)", rest): + continue # 問說明不是執行 + if tool == "acr" and re.search(r"\b(push|update)\b", rest): + print("cli"); break + if tool == "wrangler" and re.search(r"\b(deploy|publish)\b", rest): + print("cli"); break +' 2>/dev/null || printf '__PYFAIL__') + +# fail-closed:python 掛掉不准變成靜默放行 ⇒ 退回舊的整串比對,寧可誤攔 +if [ "$CLI_HIT" = "__PYFAIL__" ]; then + CLI_HIT="" + case "$CMD" in + *"acr push"*|*"acr update"*|*"wrangler deploy"*|*"wrangler publish"*) CLI_HIT="cli" ;; + esac +fi + +# ══ ② Bash:先排除明確不是「推上線」的動作 ═══════════════════════════ +# 關鍵字出現在 commit 訊息裡、在 sed/grep 的參數裡,都不是「執行」。 +# ⚠️ 下面每一條豁免都只在 CLI_HIT 為空時才輪得到(見上)。 +# 🔴 2026-08-12 修漏攔:這個「開頭是唯讀工具就放行」的豁免**本身就是一條穿牆路**。 +# 實撞:`cat payload.json | curl -X POST <實例>/webhooks/named` —— +# 指令以 `cat ` 開頭 ⇒ 整條直接 exit 0 ⇒ **寫進線上實例,本閘一聲不吭**。 +# 任何人只要在前面接一個管線就能繞過,而這正是本檔下方註解自己警告的 +# 「換個工具就穿過去了」。⇒ 豁免只在「整條指令裡沒有任何投送工具」時才成立。 +if [ -z "$CLI_HIT" ]; then + case "$CMD" in + *curl*|*wget*|*httpie*|*" http "*|*wrangler*|*" acr "*|acr\ *) ;; # 有投送工具 → 不吃開頭豁免 + sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *|find\ *) exit 0 ;; + esac + case "$CMD" in + *"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;; + *"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;; + *" --dry-run"*|*"--dry-run "*) exit 0 ;; + # 問說明不是執行(實測撞到:`acr update --help` 被自己擋掉) + # ——`--help` 現在也在 CLI_HIT 那層逐段判,這裡留著給 curl/wrangler 之外的情況 + *" --help"*|*" -h"*|*" help "*) exit 0 ;; + esac +fi + +# stage/staging 一律自由——擋的是 prod,不是所有部署 +# +# 🔴 2026-08-12 補:`youlin`(youlin-hsieh-dev)**就是測試場**,不是 prod。 +# arcrun-rag CLAUDE.md 白紙黑字:「要看範例或做任何測試 → **只在 youlin(D37 stage)**」, +# 而 leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」。 +# 本閘原本只認 `staging` 這個字 ⇒ 打 youlin 做實驗被誤擋 +# ⇒ **這種誤攔比漏攔更危險**:被擋得莫名其妙,人就會學會繞過它,那它就等於不存在。 +case "$CMD" in + *staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*|*"-staging"*) exit 0 ;; + *youlin-hsieh-dev*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;; +esac + + +# 會把東西推上線的 CLI 動作 +# `acr update`=「拉新 release 並重新部署到你的 Cloudflare」(它 --help 的原話) +# ⇒ 它就是一次完整的實例部署,比 `acr push` 影響更大(一次重部所有 worker)。 +CMD_M="${CLI_HIT}|$CMD" +case "$CMD_M" in + cli\|*) ;; + # 直接對實例跑 wrangler 也算——`main-and-prod-push-guard.sh` 有擋,但它認的是 + # 「有沒有 .github-armed」;這裡多一層是因為打錯帳號的風險在 wrangler 這一側: + # 2026-08-11 實查 kbdb/wrangler.toml 的 D1 id 是**官方 prod 的**, + # 而本機 wrangler 登入的是官方帳號 ⇒ 照直覺跑會把 self-hosted 實例接到官方資料庫上。 + # (2026-08-13:`wrangler deploy|publish` 已併進上面的 CLI_HIT 判定—— + # 原本這裡也是整串比對,「文章裡提到 wrangler deploy」同樣會被誤擋。) + # 🔴 2026-08-12 補的洞:**直接用 HTTP 打實例的寫入端點**。 + # 由來:總管要把 `ship_refresh_cdn` v2 推上 leo21c,用的是 + # `curl -X POST .../webhooks/named`(工作流部署端點,認證只是 namespace 明碼) + # ——本閘的三個 CLI 關鍵字**一個都沒命中**,等於「不被擋地寫進 leo 那台」。 + # ⇒ 這正是 D75 那句話要防的事:leo 說「**不是某種推送**,是會不會改到線上那台」。 + # 本閘卻是照「某幾種指令」寫的 ⇒ 換個工具就穿過去了。 + # 判準改成看**目標**:打到實例主機(workers.dev/arcrun.dev)+**帶寫入語意**才擋。 + # 刻意不擋的:讀(沒有 -X 寫入動詞也沒有 body)、打 Gitea(git.uncle6.me 不是實例)、 + # 以及上面已經放行的 staging。 + *curl*|*httpie*|*" http "*|*wget*) + case "$CMD" in + *workers.dev*|*arcrun.dev*) + # 🔴 2026-08-12 修誤攔:` -d ` 不只是 curl 的 --data,**一堆唯讀工具也用 -d** + # (`tr -d`/`cut -d=`/`sort -d`/`date -d`/`xargs -d`/`paste -d`…)。 + # 實撞:總管要「唯讀」驗證一把金鑰能不能開 kbdb 的閘,指令第一行是 + # ACC=$(grep … | cut -d= -f2 | tr -d '\r') + # ⇒ `tr -d ` 命中 `*" -d "*` ⇒ **一條純 GET 被當成寫入擋掉**。 + # 這正是本檔第 124 行自己寫的那句:「誤攔比漏攔更危險——被擋得莫名其妙, + # 人就會學會繞過它,那它就等於不存在。」 + # ⇒ 比對前先把「已知的唯讀 -d 用法」剪掉,再看剩下的有沒有寫入語意。 + CMD_W=$(printf '%s' "$CMD" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|logger|split|comm)[[:space:]]+-d/\1\2 __READONLY_D__/g') + case "$CMD_W" in + *"-X POST"*|*"-X PUT"*|*"-X PATCH"*|*"-X DELETE"*|\ + *"--request POST"*|*"--request PUT"*|*"--request PATCH"*|*"--request DELETE"*|\ + *"--data"*|*" -d "*|*" -d'"*|*' -d"'*) ;; + *) exit 0 ;; + esac + ;; + *) exit 0 ;; + esac + ;; + *) exit 0 ;; +esac + +stamp_ok && exit 0 + +cat >&2 <<'MSG' +🚫 `acr push` 會把東西推上線上實例——交回總管(leo 2026-08-11 立) + +leo 原話: +「**hook 要不讓它對正式環境做任何推送,不是某種推送,這種只有透過你。**」 + +━━━ 你是 subagent ━━━ +把 YAML/定義檔留著,交件時附上檔案路徑與「它會覆蓋什麼」,由總管推。 + +━━━ 你是總管 ━━━ +確認過要推什麼、會蓋掉什麼之後: + + touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失 + +⚠️ `acr recipe push` 另有一道**互動式暴露同意閘**(終端機裡要人親手輸入資源名)。 +那道閘擋的是「把資源變成可被外部呼叫」,**本閘的戳記蓋不過它**——那是 leo 的手。 +MSG +exit 2 diff --git a/hooks/sdd-guard.sh b/hooks/sdd-guard.sh new file mode 100755 index 0000000..4a609d3 --- /dev/null +++ b/hooks/sdd-guard.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# PreToolUse hook — 動 code 前檢查 SDD + 單一活性 SDD 鐵律(issue #6) +# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。 +# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md +# +# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。 +# stdin 收到 JSON:{ tool_name, tool_input: { file_path, ... } } +# 行為: +# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2), +# 先收斂到一份再說。 +# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。 +# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度) +# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。 +# +# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。 +# 藏在 helper 裡、用 bash 繞道的改動擋不到。 +# 價值是「想跳過會被抓到 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。 + +set -euo pipefail + +INPUT=$(cat) + +# 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。 +if command -v jq >/dev/null 2>&1; then + FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') +else + FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//') +fi + +# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺) +[ -z "$FILE_PATH" ] && exit 0 + +# 🔴 2026-08-02 修:原本寫死相對路徑 `system-dev/docs/3-specs`, +# 但 hook 的工作目錄是**頂層 InkStoneCo**,改子 repo 的 code 時就去頂層找 SDD +# ⇒ 看不到子 repo 自己那份 ⇒ **一律誤報「找不到任何 SDD」**。 +# 實撞:改 products/arcrun-rag/collector/... 被擋,但該 repo 明明有 +# system-dev/docs/3-specs/daemon-beta/design.md(status: active)。 +# ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。 +# ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…) +# 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。 +SPECS_DIR="system-dev/docs/3-specs" +_root="${CLAUDE_PROJECT_DIR:-$(pwd)}" +case "$FILE_PATH" in + "$_root"/*) + _d=$(dirname "$FILE_PATH") + while [ "$_d" != "/" ] && [ -n "$_d" ]; do + if [ -d "$_d/system-dev/docs/3-specs" ]; then + SPECS_DIR="$_d/system-dev/docs/3-specs" + break + fi + [ "$_d" = "$_root" ] && break + _d=$(dirname "$_d") + done + ;; + *) + # 專案外的路徑:**不可退回頂層 SDD 就放行**,否則原本擋得住的會變成擋不住。 + # 但 **git worktree 是正當工作區**(本專案大量使用 /private/tmp 下的 worktree 出貨), + # 它自己就帶著該 repo 的 system-dev/docs/3-specs ⇒ 一樣往上找,找得到就認。 + # 找不到才指向不存在目錄 ⇒ 走原有的「找不到 SDD」擋下路徑。 + # (2026-08-02:第一版忘了 worktree,把正當的出貨工作區也擋掉。) + SPECS_DIR="/nonexistent/3-specs" + _d=$(dirname "$FILE_PATH") + while [ "$_d" != "/" ] && [ -n "$_d" ]; do + if [ -d "$_d/system-dev/docs/3-specs" ]; then + SPECS_DIR="$_d/system-dev/docs/3-specs" + break + fi + _d=$(dirname "$_d") + done + ;; +esac + +# ── 統計 active / frontmatter ────────────────────── +# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數—— +# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。 +# frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。 +ACTIVE_COUNT=0 +FM_COUNT=0 +ACTIVE_LIST="" +if [ -d "$SPECS_DIR" ]; then + while IFS= read -r f; do + [ -n "$f" ] || continue + HEAD10=$(head -10 "$f" 2>/dev/null || true) + if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then + FM_COUNT=$((FM_COUNT + 1)) + if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then + ACTIVE_COUNT=$((ACTIVE_COUNT + 1)) + ACTIVE_LIST="${ACTIVE_LIST} • ${f} +" + fi + fi + done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null) +fi + +# ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ── +if [ "$ACTIVE_COUNT" -gt 1 ]; then + cat >&2 <<EOF +🚫 SDD 單一活性鐵律違反:偵測到 ${ACTIVE_COUNT} 份 status: active 的 SDD(任何時刻整個 repo 最多一份): +${ACTIVE_LIST} +請先收斂到一份:其餘改 status: paused / closed(closed 且被取代者填 superseded_by 並移入 3-specs/archive/)。 +規則全文見 system-dev/docs/3-specs/SDD-LIFECYCLE.md。收斂前擋下所有寫檔。 +(本 hook 攔 Write/Edit;修 frontmatter 可用 bash 直改,或由人裁決哪份是現行。) +EOF + exit 2 +fi + +# 只管 code 檔。docs/markdown/設定檔等放行。 +case "$FILE_PATH" in + *.ts|*.tsx|*.js|*.jsx|*.go|*.py|*.rs|*.java|*.rb|*.php|*.c|*.cpp|*.h|*.hpp|*.swift|*.kt) ;; + *) exit 0 ;; +esac + +# 改 SDD 自己 / 測試檔 → 放行 +case "$FILE_PATH" in + *system-dev/docs/3-specs/*) exit 0 ;; + *_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;; +esac + +# ── 向下相容:整個 3-specs 沒有任何帶 frontmatter 的 design.md ── +# =老 repo 還沒遷移生命週期制度 → 退回舊行為(有 design.md 就放行+提醒), +# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。 +if [ "$FM_COUNT" -eq 0 ]; then + SDD_COUNT=0 + if [ -d "$SPECS_DIR" ]; then + SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ') + fi + + if [ "$SDD_COUNT" -eq 0 ]; then + cat >&2 <<EOF +🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下找不到任何 SDD。 + +絕對鐵律:任何 code 變動前必須有對應 SDD(design.md),且遵守單一活性生命週期 +(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。 + +請先: + 1. 確認這個改動屬於哪個子系統 + 2. 在 ${SPECS_DIR}/[子系統]/ 建立 design.md(可用 /sdd-check 協助),frontmatter 標 status: active + 3. 在回覆開頭宣告已讀 SDD + 對應 task + +小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。 +EOF + exit 2 + fi + + # 舊行為放行 + 提醒遷移(stderr 警告,不擋) + echo "📋 提醒:${SPECS_DIR}/ 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2 + exit 0 +fi + +# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ── +if [ "$ACTIVE_COUNT" -eq 0 ]; then + cat >&2 <<EOF +🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下沒有任何 status: active 的 SDD。 + +單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。 + +請先(擇一,都是人的決定,CC 不得自行建 SDD): + 1. 把現行規格的 design.md frontmatter 標成 status: active(一份、只能一份) + 2. 或依 SDD-LIFECYCLE.md 第 3、4 條:proposal 進 pending-changes.md → 使用者 confirm → 開新 SDD 標 active +然後在回覆開頭宣告已讀 active SDD + 對應 task。 + +小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。 +EOF + exit 2 +fi + +# 恰好 1 份 active:放行,留痕提醒要宣告(stderr 警告,不擋) +printf '📋 提醒:現行 active SDD=\n%s動手前請確認已讀它的 design.md、對應到 tasks,並在回覆宣告。\n' "$ACTIVE_LIST" >&2 +exit 0 diff --git a/hooks/self-drive-judge.sh b/hooks/self-drive-judge.sh new file mode 100755 index 0000000..e60c661 --- /dev/null +++ b/hooks/self-drive-judge.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# self-drive-judge.sh — 自走警察【第二級:語意判官】(藍圖 v3 §5.5 落地) +# +# 為什麼要它(病根): +# self-drive-police.sh 用 grep 抓請示句,**grep 只認字面**。 +# leo 2026-08-04 抓到的病是「停下來問早已決議的事」,但那個病**換句話說就閃過去了**—— +# 「要我 X 嗎」擋得下,「不知道這樣是否符合你的期待」「兩條路你偏好哪個」擋不下。 +# ⇒ 字面判擋不住換句話說,語意判才治本(藍圖 v3 §5.5「升級 prompt 型 hook」)。 +# +# 為什麼不用官方 prompt 型 hook(實查,2026-08-05): +# 官方 `type: prompt` 的 $ARGUMENTS = **hook 輸入 JSON**(session_id/transcript_path/…), +# Stop 事件的輸入**不含我實際說了什麼**,而 prompt hook 單輪無工具、不能自己去讀 transcript。 +# ⇒ 直接換成 prompt hook 會變成「拿不到內容的判官」。 +# `type: agent`(可帶 Read/Grep 查證)做得到,但官方標 experimental ⇒ 依藍圖 §9.4 成熟度門檻只登記觀察。 +# ⇒ 本 hook=command hook 抽文字 + 外呼 haiku 判語意。判準與成本都在我們自己手上。 +# +# 判準(只有一條,就是四題公式): +# 這個問題是否真的命中人閘——① 花錢 ② 不可逆/難回收 ③ 跨專案結構 ④ 品味/方向? +# 命中任一 → 該問,放行。四題全否 → 擋回去,逼它自己裁。 +# +# 成本控制(三道前置閘,正常回合完全不花錢): +# ① 沒有問號 → 不判(絕大多數回合走這條,零成本) +# ② 已被 self-drive-police 的 grep 擋下的句型 → 不重判(不重複收費) +# ③ 明說撞權限閘/四題第 X 命中 → 豁免(與 police 同一套豁免語) +# +# 失敗一律 fail-open(exit 0):判官掛掉不能變成「永遠停不下來」。 + +set -u + +# 遞迴保險:判官自己起的那個 claude 不准再跑判官 +if [ "${SELF_DRIVE_JUDGE:-}" = "1" ]; then exit 0; fi + +input="$(cat)" + +# stop_hook_active=已經被擋過一輪,別再疊 +stop_active="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: + print(json.load(sys.stdin).get("stop_hook_active", False)) +except Exception: + print(False) +' 2>/dev/null)" +[ "$stop_active" = "True" ] && exit 0 + +transcript="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: + print(json.load(sys.stdin).get("transcript_path", "")) +except Exception: + print("") +' 2>/dev/null)" +[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0 + +# 抽「我這回合最後的 assistant 文字」(與 self-drive-police.sh 同一套抽法) +last_text="$(python3 -c ' +import sys, json +path = sys.argv[1] +texts = [] +try: + with open(path) as f: + lines = f.readlines() + for line in reversed(lines): + try: + ev = json.loads(line) + except Exception: + continue + msg = ev.get("message", ev) + if msg.get("role") == "assistant": + content = msg.get("content", "") + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "text": + texts.append(b.get("text", "")) + elif isinstance(content, str): + texts.append(content) + break +except Exception: + pass +print("\n".join(texts)) +' "$transcript" 2>/dev/null)" + +[ -z "$last_text" ] && exit 0 + +# ── 前置閘①:沒問號就不是在問人 ── +printf '%s' "$last_text" | grep -qE '?|\?' || exit 0 + +# ── 前置閘②:已被 grep 警察涵蓋的句型,交給它,不重複判 ── +if printf '%s' "$last_text" | grep -qiE '要我(現在|先|繼續|開始)?[^??]{0,12}嗎|需要我[^??]{0,12}嗎|下一步(要|該)?做什麼|接下來(要)?做什麼|要不要|該不該'; then + exit 0 +fi + +# ── 前置閘③:撞真閘的豁免語(與 self-drive-police.sh 同一套)── +if printf '%s' "$last_text" | grep -qiE '權限閘|classifier|分類器擋|denied by|四題第|需要你放行|需要 leo 放行'; then + exit 0 +fi + +# 只送最後 1200 字給判官(夠判語氣、不吃 context) +snippet="$(printf '%s' "$last_text" | tail -c 3600)" + +judge_prompt="你是「自走警察」的語意判官。下面是一個 AI 助理(總管)準備結束回合時對老闆 leo 說的話。 + +判斷它是否在**把一個自己該裁的決定推回給 leo**。 + +判準只有一條——四題人閘公式。它問的事情是否命中下列任一: +① 花錢(付費、開資源、產生帳單、明顯多耗訂閱額度) +② 不可逆/難回收(刪資料、push 到 main、部署上線、對外公開、跨 repo 搬遷) +③ 跨專案結構決策(改全體共用框架的架構、改 repo 歸屬、立新鐵律) +④ 品味/方向(產品要不要某功能、UX 取捨——leo 的主觀偏好) +⑤ 物理人閘(只有人能做:貼憑證、終端機同意、平台上按批准) + +命中任一 → decision=allow(它該問)。 +四題全否,而它仍在徵詢、確認、討好、或丟空白選擇題 → decision=block。 + +注意: +- 單純**回報**「我做了 X,發現 Y」即使句尾有問號式修辭,也 allow。 +- 提供選項但**已明說自己的建議與理由**、只等一個詞確認 → 若命中四題則 allow;四題全否仍是 block。 +- 只輸出 JSON,不要任何其他文字。 + +輸出格式: +{\"decision\":\"allow\"或\"block\",\"reason\":\"一句正體中文,若 block 要指出它在問什麼、以及四題為何全否\"} + +--- 助理說的話 --- +$snippet +--- 結束 ---" + +# ⚠️ 判官必須跑在「中性目錄 + --safe-mode」: +# 實測(2026-08-05):直接在專案內跑 `claude -p` → **54 秒**,因為內層把整個專案的 +# SessionStart recall(數十萬字)、18 支 hook、40 個 arcrun MCP 工具全載進去, +# 還會拿專案脈絡去回答判官的問題(實測它跑去讀 self-drive-judge.sh)。 +# 換成 `cd /tmp` + `--safe-mode`(關閉全部客製化)→ **8 秒**。 +# 判官只需要判一段文字,不需要專案脈絡;載進來只會又慢又汙染判斷。 +verdict="$(cd /tmp && SELF_DRIVE_JUDGE=1 printf '%s' "$judge_prompt" \ + | (cd /tmp && SELF_DRIVE_JUDGE=1 claude -p --safe-mode --model haiku --allowedTools "" 2>/dev/null))" + +# 判官掛了/回傳空/不是 JSON → fail-open +[ -z "$verdict" ] && exit 0 + +decision="$(printf '%s' "$verdict" | python3 -c ' +import sys, json, re +raw = sys.stdin.read() +m = re.search(r"\{.*\}", raw, re.S) +if not m: + print("allow|"); raise SystemExit +try: + d = json.loads(m.group(0)) + print((d.get("decision") or "allow") + "|" + (d.get("reason") or "")) +except Exception: + print("allow|") +' 2>/dev/null)" + +case "$decision" in + block\|*) + reason="${decision#block|}" + cat >&2 <<EOF +🚓 自走警察【語意判官】:你在徵詢 leo,但這題**四題全否**——那就是你自己該裁的。 + +判官的話:$reason + +【leo 2026-08-04】「不要停下來問要不要繼續,**這是前面明說要做的事**」 +【藍圖 v3 §5.5】問題跟記憶走同一種階梯:先窮盡已有答案,答不出才升一級。 + 到 leo 是**最後一級**,不是預設一級。 + +送出前先在這四處找答案(找到就別問,直接做): + A. leo 這個 session 說過的話——他是不是已經給了優先序/清單/「開工」? + B. root.md/journeys.md/派工表——下一站就是答案 + C. decisions / ADR / pending-changes 已裁決區——這個做法定案過嗎? + D. KBDB 語意搜尋——換句話說的同一題(grep 猜不中用詞時的保險) + +📌 真要升級到 leo,格式是:「<檔案:行> 記錄 X,我判斷走 A 因為 Y,回『好』我就繼續」 + ——裸問句、空白選擇題=視同沒查。 +EOF + exit 2 + ;; +esac + +exit 0 diff --git a/hooks/self-drive-police.sh b/hooks/self-drive-police.sh new file mode 100755 index 0000000..acca305 --- /dev/null +++ b/hooks/self-drive-police.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# self-drive-police.sh — 自走警察(Stop hook) +# +# 病根(leo 2026-06-29 一個 session 點破三次):我「做一條停一條」「把該自己做的推給 leo」 +# ——不是缺 loop,是每個決策點退回「推給 leo」的省事預設。CLAUDE.md 寫規則沒用(我會不讀)。 +# leo 的解法:在我「要停下來」那一刻攔截 + 反問,逼我先窮盡查證才准停。 +# 靈感=leo 之前做的「Haiku 警察 Anna」(檢查回話、犯規就抓)。先用輕的(純 bash grep),不夠再升級。 +# +# 機制(官方 Stop hook,核實 code.claude.com/docs/en/hooks): +# Claude 結束回合想停 → 本 hook 觸發 → 讀 transcript 我這回合輸出 → grep 三類推卸句式: +# ① 憑記憶問「要不要/該選/哪個」 → 反問「查 wiki/internet 官方了嗎」 +# ② 問「下一步/接下來做什麼」 → 反問「查 SDD/派工表/北極星了嗎」 +# ③ 推卸「交給你/這是你的/你去開」 → 反問「規劃做完了嗎、能 spawn subagent 嗎、為何停」 +# 命中 → exit 2 + stderr(reason) → Claude 不停、收到反問繼續做。 +# 沒命中 → exit 0 放行(真的該停,不擾)。 +# +# 防無限迴圈(官方):stop_hook_active=true(已 block 過一輪)→ 放行。 +# + Claude Code 內建 block cap(連續 8 次自動放行)。 +# +# 誠實限制:靠句式 grep,擋得住「明顯推卸句」,擋不住「換句話說的推卸」。是底線不是萬能。 +# 不夠時升級成 type:prompt(Haiku 判語意) 或 type:agent(查 wiki/SDD 客觀驗),見 hook 末註。 + +input="$(cat)" + +# 防無限迴圈:已經 block 過一輪(Claude 正在回應上一次 block)→ 放行 +stop_active="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: + print(json.load(sys.stdin).get("stop_hook_active", False)) +except Exception: + print(False) +' 2>/dev/null)" +if [ "$stop_active" = "True" ]; then + exit 0 +fi + +# 取 transcript 路徑 +transcript="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: + print(json.load(sys.stdin).get("transcript_path", "")) +except Exception: + print("") +' 2>/dev/null)" + +[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0 + +# 抽「我這回合最後的 assistant 文字輸出」(transcript 是 JSONL,每行一事件) +# 取最後一個 assistant 訊息的 text,避免掃到歷史 +last_text="$(python3 -c ' +import sys, json +path = sys.argv[1] +texts = [] +try: + with open(path) as f: + lines = f.readlines() + # 從尾往前找最後一個 assistant 訊息的 text 內容 + for line in reversed(lines): + try: + ev = json.loads(line) + except Exception: + continue + msg = ev.get("message", ev) + if msg.get("role") == "assistant": + content = msg.get("content", "") + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "text": + texts.append(b.get("text", "")) + elif isinstance(content, str): + texts.append(content) + break +except Exception: + pass +print("\n".join(texts)) +' "$transcript" 2>/dev/null)" + +[ -z "$last_text" ] && exit 0 + +# ── 豁免:撞真閘就別逼它做不可能的事(2026-07-22 補)── +# 病例:雲端總管有 token、備好 patch,但 harness classifier 擋掉 mutating 寫入 → +# 舊版警察會抓「Which way do you want it?」逼它繼續,它撞牆到 block cap 才放行=空轉。 +# 明說「四題第X命中」也放行(本 hook 註解原本就要求這格式,但沒實作豁免)。 +if printf '%s' "$last_text" | grep -qiE '權限閘|classifier|分類器擋|denied by|四題第|需要你放行|需要 leo 放行'; then + exit 0 +fi + +# ── 三類推卸句式偵測 ────────────────────────────────────────────── +# ③ 推卸給 leo(最該抓)——「交給你/這是你的/你去/請你去開/leo 去做」 +if printf '%s' "$last_text" | grep -qiE '交給(你|leo)|這是你的|是你的工作|你(去|來)(開|做|跑|查|執行)|請你去|leo (去|來)(開|做)|由你(去|來)|該你'; then + cat >&2 << 'EOF' +🚓 自走警察:偵測到「推卸給 leo」。停下前先答這三題: +1. 所有規劃工作做完了嗎?(查 autonomy-dispatch/tasks.md 派工表 + 北極星) +2. 這件事我能不能 spawn 一個 subagent 去做?(子 repo 寫 code 走 guard 白名單 CHILD_SESSION;偵察/文書/診斷都能 spawn) +3. 為什麼要停下來?——只有四題命中(花錢/不可逆/品味方向/物理人閘如 terminal consent、merge 批准)才輪到 leo。 +「做 vs 問」是假二選一,真三選一:自己做 / spawn subagent 做 / 才輪到 leo。叫 leo 當人肉中繼=親手裝回剛拔掉的瓶頸。 +若確認真四題命中才需 leo,下一回合明說「四題第X命中:<理由>」再停。 +EOF + exit 2 +fi + +# ② 問「下一步做什麼」——答案在規劃文件裡 +if printf '%s' "$last_text" | grep -qiE '下一步(要|該)?做什麼|接下來(要)?做什麼|接下來呢|要做什麼好|現在(要|該)做什麼'; then + cat >&2 << 'EOF' +🚓 自走警察:偵測到「問下一步做什麼」。答案多半在規劃文件裡,先查再說: +1. 查 SDD 了嗎?(system-dev/docs/3-specs/*/tasks.md — 唯一進度來源) +2. 查派工表了嗎?(system-dev/docs/3-specs/autonomy-dispatch/tasks.md — issue 開哪、誰做、狀態) +3. 查北極星了嗎?(system-dev/docs/6-user/20260627-northstar.md §8.4 順序) +查完下一條自然浮現,自己往前推,別把判斷推給 leo。若真查過且確認無下一條/撞人類閘,下一回合明說再停。 +EOF + exit 2 +fi + +# ⓪ 🔴 請示句:「要我 X 嗎/我可以開始嗎/需要我 X 嗎」——**已交代過的事不必再請示** +# 2026-08-04 leo 兩度點破:「不要停下來問要不要繼續,**這是前面明說要做的事**」 +# 「都列表了你還要問這個問題?」「**就是個自問自答功能,你問的問題明明有答案**」 +# ⇒ 舊 ① 只認「要不要/該選哪個」,**漏掉最常見的請示句型「要我…嗎」**。 +# 判準不是句型漂亮與否,而是:**這個問題的答案,是不是已經存在於既有資料裡?** +if printf '%s' "$last_text" | grep -qiE '要我(現在|先|繼續|開始)?[^??]{0,12}嗎|需要我[^??]{0,12}嗎|我(現在|可以|該)(開始|繼續|動手)[^??]{0,8}嗎|(繼續|開工|動手)嗎'; then + cat >&2 << 'EOF' +🚓 自走警察(請示句):你在問「要我 X 嗎」——**先自問自答:這個問題有答案嗎?** + +【leo 2026-08-04】「不要停下來問要不要繼續,**這是前面明說要做的事**」 + 「都列表了你還要問這個問題?」 + 「**就是個自問自答功能,你問的問題明明有答案,應該就可以找到答案了**」 + +━━━ 送出前先在這三處找答案(找到就別問,直接做)━━━ + A. **leo 這個 session 說過的話**——他是不是已經給了優先序/清單/「開工」? + (已交代過的事再請示=把他當按鈕按,正是自走規則要拔掉的瓶頸) + B. **你自己剛列的 todo/任務表**——下一項就是答案,照著做 + C. **wiki/tasks.md**——`system-dev/wiki/status.md`、對應 tasks.md 有沒有定案 + +━━━ 只有這種情況才可以問(且要明說已查過)━━━ + 真命中四題人閘:花錢/不可逆(push main、刪資料、部署上線)/ + 跨專案結構決策/品味方向。**其餘一律自己裁、往前推,錯了被擋回再修。** + +📌 想收尾時不要問「要我繼續嗎」,直接**做下一項並回報做了什麼**。 +EOF + exit 2 +fi + +# ① 憑記憶問「要不要/該選哪個」——先查證再決定 +if printf '%s' "$last_text" | grep -qiE '要不要|該不該|該選(哪|什麼)|哪個(比較)?好|選哪(個|條)|是不是要|可不可以'; then + # 但若這回合已有查證跡象(提到查了 wiki/官方/grep/WebFetch/出處),放行——別擾正常提問 + if printf '%s' "$last_text" | grep -qiE '查(了|過)|官方(確認|核實|出處)|核實|grep|WebFetch|出處|wiki 寫|記憶(卡|寫)'; then + exit 0 + fi + cat >&2 << 'EOF' +🚓 自走警察:偵測到「憑判斷問要不要/該選哪個」,但沒看到查證跡象。停下問 leo 前先窮盡: +1. 查 wiki/記憶了嗎?(system-dev/wiki/ + MEMORY.md — 這事可能已有定案,別重議) +2. 查 internet 官方說明了嗎?(LLM/平台/API 機制憑記憶會錯,查官方原文 + 給出處) +查完答案多半自己浮現,四題全否就自己裁、不問 leo。若查過仍需 leo 的品味/方向判斷(第④題),下一回合明說「查過,這是品味題」再問。 +EOF + exit 2 +fi + +# 沒命中任何推卸句式 → 真的該停,放行 +exit 0 + +# ── 升級路徑(不夠用時,leo 點頭)────────────────────────────────── +# 輕→重三級: +# L1(現在):command bash grep 句式三分流。最省,擋明顯推卸句。 +# L2:改 type:prompt(Haiku 判語意),settings.json 寫 {"type":"prompt","prompt":"判斷這回合是否在推卸/未查證就停…"}。 +# 擋「換句話說的推卸」,但每次停叫一次 Haiku(便宜)。 +# L3:改 type:agent,spawn subagent 真查 wiki/SDD/派工表客觀驗「規劃是否做完」(像 Ralph 的 passes)。最強最貴。 diff --git a/hooks/session-start-recall.sh b/hooks/session-start-recall.sh new file mode 100755 index 0000000..5c09865 --- /dev/null +++ b/hooks/session-start-recall.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# SessionStart hook — 開 session 就把「全局」推到眼前(InkStoneCo#17 / D69 / D70 / D71) +# +# 掛在 settings.json 的 SessionStart(matcher: startup|resume|clear)。stdout 會被當成 context 注入。 +# +# ── 這支在整個機制裡的位置(leo 2026-08-11 拍板)───────────────────────────── +# 算 = 雲端的 Arcrun 工作流 `global_index`(讀 Gitea 五個 repo 的票 + 讀 KBDB 藏書地圖) +# 存 = 雲端(總圖住雲端,不推回地端;沒有東西需要同步,所以沒有漂移源) +# 拿 = 本檔。**由機制保證取一次**,不靠 AI 記得去打 MCP +# (openhuman SuperContext 的教訓:靠模型自己選擇要不要查,就是會漏) +# +# ⚠️ 例外=下面 push 2/5 的「各 repo wiki 全景」:那段**算在地端**。 +# 不是不想放雲端,是雲端拿不到 repo 樹(只有 API 列檔一條路,撞 principles 紅線)。 +# +# ── 為什麼不再 cat status.md ──────────────────────────────────────────────── +# 2026-08-11 實測:舊版這支吐 235,880 bytes(因為它 cat 整份 216 KB 的落帳流水), +# 真正進到 context 的只有 2 KB 預覽 + 一個要自己去 Read 的檔名。 +# ⇒ 「開場就知道」等於沒發生。**注入通道的預算是有限的,落帳流水不該佔著它。** +# 進度/狀態一律去 Gitea(D52 + D72),不在地端養第二份會漂的快照。 +# +# 順序=重要性:全局總圖最前面(它最可能被截斷保護到),其次原則,最後踩坑標題。 + +set -euo pipefail + +GLOBAL_INDEX_URL="https://arcrun-cypher-executor.leo21c.workers.dev/webhooks/named/leo/global_index/query" +PANORAMA_FILE="system-dev/wiki/PANORAMA.md" +PRINCIPLES_FILE="system-dev/wiki/principles.md" +MISTAKES_FILE="system-dev/wiki/mistakes.md" +MISTAKES_TAIL=20 # 只推最近 N 條標題(全份 96 條/11 KB 會吃掉總圖的預算) + +# ── push 1/5:全局總圖(雲端現算)──────────────────────────────────────────── +# 降級規格(D71,票與知識不對稱): +# 算得出來 → 注入 +# 算不出來/破損/空 → **完全不注入內容**,只誠實說一句「這次沒有」 +# (寧可冷啟動,不要注入垃圾;票尤其不能給一份看起來像現況的舊東西) +GLOBAL_MD="" +GLOBAL_ERR="" +if RESP=$(curl -sS -m 45 -X POST "$GLOBAL_INDEX_URL" \ + -H 'Content-Type: application/json' -d '{}' 2>/dev/null); then + GLOBAL_MD=$(printf '%s' "$RESP" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +def find_md(x, depth=0): + # 工作流的最終輸出可能被包一層 data(code 零件的回應信封),兩種形狀都收 + if depth > 3 or not isinstance(x, dict): + return None + if x.get("success") is True and isinstance(x.get("md"), str) and x["md"].strip(): + return x["md"] + return find_md(x.get("data"), depth + 1) +md = find_md(d) +if md: + sys.stdout.write(md) +' 2>/dev/null || true) + if [ -z "$GLOBAL_MD" ]; then + GLOBAL_ERR=$(printf '%s' "$RESP" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + sys.stdout.write("回應不是 JSON"); sys.exit(0) +sys.stdout.write(str((d or {}).get("error") or "工作流沒有交出 md")[:200]) +' 2>/dev/null || echo "回應解析失敗") + fi +else + GLOBAL_ERR="連不上雲端(curl 失敗或逾時 45 秒)" +fi + +echo "════════════════════════════════════════════════" +if [ -n "$GLOBAL_MD" ]; then + printf '%s\n' "$GLOBAL_MD" +else + echo "⚠️ 這次拿不到全局總圖:${GLOBAL_ERR}" + echo " 刻意不給你一份舊的——票的狀態放舊的比沒有更危險(會去重做已經做完的事)。" + echo " ⇒ 你現在**不知道有哪些票、庫裡有哪些知識**。動手前先自己撈:" + echo " Gitea:GET https://git.uncle6.me/api/v1/repos/Leo/<repo>/issues?state=open" + echo " (五個 repo:InkStoneCo / Arcrun / arcrun-rag / mira / system-dev-template)" + echo " 知識:kbdb_get_map()" + echo " 修這條管路:工作流 leo:wf:global_index(雲端),本 hook 只負責取來印。" +fi +echo "════════════════════════════════════════════════" +echo "" + +# ── push 2/5:各 repo 的 wiki 全景(leo 2026-08-12「一次看到全景」的另一半)── +# 上面那段是**票**(雲端現算)+ KBDB 藏書地圖;這段是**各 repo 真正的 wiki**。 +# 為什麼不塞進雲端那支工作流:工作流跑在 Cloudflare 上,要拿 repo 樹只有 +# 「Gitea contents API 列檔」一條路,而 principles 紅線寫死 +# 「讀 repo 走 git clone/fetch,不走 API 列檔」。 +# ⇒ 在有 git 的地方算好(`scripts/wiki-panorama.sh`,人發起、不輪詢), +# 存成 repo 裡的一份 md,本檔只負責印。 +# 只印到 `panorama:inject-end` 為止——標記以下是給 grep 的完整卡片清單, +# 全倒進來會撐爆 context(全檔 ~28 KB,注入段 ~6 KB)。 +if [ -f "$PANORAMA_FILE" ]; then + echo "════════════════════════════════════════════════" + awk '/panorama:inject-end/{exit} {print}' "$PANORAMA_FILE" + # 過期就講出來(不自動重算:重算要連網 clone 十幾個 repo,不該卡在開場) + P_GEN=$(grep -m1 -oE '[0-9]{4}-[0-9]{2}-[0-9]{2} 產生' "$PANORAMA_FILE" | cut -d' ' -f1 || true) + W_LAST=$(git log -1 --format=%ad --date=short -- system-dev/wiki 2>/dev/null || true) + if [ -n "$P_GEN" ] && [ -n "$W_LAST" ] && [ "$W_LAST" \> "$P_GEN" ]; then + echo "" + echo "⚠️ 這張全景圖是 ${P_GEN} 算的,但 wiki 在 ${W_LAST} 又被改過 ⇒ 它已經不準了。" + echo " 重算:bash scripts/wiki-panorama.sh --write" + fi + echo "════════════════════════════════════════════════" + echo "" +else + echo "────────────────────────────────────────────────" + echo "⚠️ 沒有各 repo 的 wiki 全景圖($PANORAMA_FILE)⇒ 你**不知道哪些知識記在哪個 repo**。" + echo " 產生(約一分鐘,git clone 各 repo 的 wiki 目錄,不走 API):" + echo " bash scripts/wiki-panorama.sh --write" + echo "" +fi + +# ── push 3/5:進度不在地端(D72)──────────────────────────────────────────── +echo "────────────────────────────────────────────────" +echo "📍 進度/狀態一律去 Gitea 撈,不要在任何檔案裡找(D52 + D72)" +echo " 等 leo = labels=s/stage | 可開工 = s/todo | 進行中 = s/doing" +echo " 知識庫(system-dev/wiki/)只放**事實**:踩過的坑、決策、憑證在哪。**不放進度**。" +echo " 🔴 不要在對話或任何檔案裡另養一份「等 leo 的清單」——它一定會漂。" +echo "" + +# ── push 4/5:principles(全文,行動前必服從)──────────────────────────────── +if [ -f "$PRINCIPLES_FILE" ] && grep -q '^- ' "$PRINCIPLES_FILE" 2>/dev/null; then + echo "════════════════════════════════════════════════" + echo "📐 設計原則(行動前必服從,來自 principles.md)" + echo "════════════════════════════════════════════════" + grep '^- ' "$PRINCIPLES_FILE" + P_COUNT=$(grep -c '^- ' "$PRINCIPLES_FILE") + if [ "$P_COUNT" -gt 15 ]; then + echo "" + echo "(⚠️ principles 已 ${P_COUNT} 條 > 15:請考慮合併相近原則、或把較細的下放成 card)" + fi + echo "" +fi + +# ── push 5/5:mistakes(只推最近 N 條標題)────────────────────────────────── +if [ -f "$MISTAKES_FILE" ] && grep -q 'MISTAKE' "$MISTAKES_FILE" 2>/dev/null; then + M_TOTAL=$(grep -cE 'MISTAKE' "$MISTAKES_FILE") + echo "────────────────────────────────────────────────" + echo "⚠️ 已知踩坑:最近 ${MISTAKES_TAIL} 條(全部 ${M_TOTAL} 條在 $MISTAKES_FILE,撞到就 grep 全文)" + grep -E 'MISTAKE' "$MISTAKES_FILE" | tail -n "$MISTAKES_TAIL" | sed 's/^/ /' + echo "" +fi + +echo "────────────────────────────────────────────────" +echo "以上是 index,不是內容——它只讓你知道「有這件事、去哪裡找」。" +echo "要完整脈絡(decisions / mistakes / SDD)→ 執行 /wiki-recall" +echo "════════════════════════════════════════════════" + +exit 0 diff --git a/hooks/shadow-table-guard.sh b/hooks/shadow-table-guard.sh new file mode 100755 index 0000000..8f44215 --- /dev/null +++ b/hooks/shadow-table-guard.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# PreToolUse hook — 影子表警察:抓 D91 的指紋(宣告 schema 在一邊,資料倒進 JSON 團在另一邊) +# +# 🚫 **本檔目前是死的:沒有註冊進 .claude/settings.json,不會被執行。** +# leo 2026-08-15:「**我是要你提案封鎖方式,不是要你就去做,急着做造成太多疊牀架屋了。**」 +# ⇒ 總管在討論「疊牀架屋是病」的同時去疊了這一層。已測過(該擋兩條擋、六條不該擋全放), +# 但**要不要掛由 leo 裁**——它只在「過渡期止血」這一個用途上值得存在。 +# ⇒ 真正的封鎖方式是**刪掉 metadata_json 與 entry_type 兩個欄位**(減,不是加), +# 提案見 matrix/arcrun 的 pending-changes.md。 +# +# ⚠️ 這支是**過渡期的止血帶,不是答案**(leo 2026-08-15 定調)。 +# 真正的解是「**刪掉那兩個欄位,讓儲存層沒有位置放壞東西**」——提案在 matrix/arcrun 的 pending-changes.md。 +# 偵測永遠落後一步:D91 就是從「多開一張表」變異成「JSON 團」的。 +# 在收窄的 API 出來之前,這支至少擋得住已知的那個形狀。**別因為有閘就以為安全。** +# +# 【為什麼 kbdb-api-wall-guard 抓不到】 +# 那支守著 D38「三張表打天下,永不加表」,而 D91 **完全沒有違反它**——一張表都沒加。 +# 它做的是:seed 一張宣告 6 個欄位的 template,然後把資料倒進 metadata_json。 +# ⇒ **一張沒人管得到的影子表,而表數是三。現有的閘數表,而病不在表數上。** +# +# 【病徵】kbdb/migrations/0004_execution_log_template.sql 自己的註解就寫著 +# 「template 這裡只負責 schema 文件化⋯⋯結構化欄位打包進 metadata_json」 +# ⇒ 那張宣告了 6 個欄位的 template,沒有任何東西讀它。 +# 【擴散】同註解:「recipe_stat 早已示範這個模式合法」 +# ⇒ 活著的 7 種 entry_type 裡有 6 種是這形狀——不是角落,是多數。 +# +# 攔的是**形狀不是關鍵字**:型別標籤與 JSON 團打包必須**同時**出現才命中 +# (今天已有四次閘因為比對指令形狀而誤攔,不再犯)。註解、測試、文件一律放行。 +# 豁免:該行尾加 `shadow-table-ok`(留痕,commit 說明理由)。 +# +# 全文:頂層 system-dev/wiki/decisions-summary.md D91、D92。 + +exec python3 -c ' +import json, re, sys + +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +if d.get("tool_name") not in ("Write", "Edit", "MultiEdit"): + sys.exit(0) + +i = d.get("tool_input") or {} +path = i.get("file_path") or "" +if not path: + sys.exit(0) + +parts = [i.get("content") or "", i.get("new_string") or ""] +for e in (i.get("edits") or []): + parts.append(e.get("new_string") or "") +body = "\n".join(p for p in parts if p) +if not body: + sys.exit(0) + +low = path.lower() +if re.search(r"\.(md|txt|json|yaml|yml)$", low): sys.exit(0) +if re.search(r"(^|/)(wiki|docs|tests?)/", low): sys.exit(0) +if re.search(r"(test|spec)\.[a-z]+$|_test\.go$", low): sys.exit(0) +if not re.search(r"\.(ts|js|mjs|sql|go)$", low): sys.exit(0) + +live = [] +for line in body.splitlines(): + if "shadow-table-ok" in line: + continue + s = re.sub(r"--.*$", "", line) + s = re.sub(r"//.*$", "", s) + if re.match(r"\s*[*#]", s): + continue + live.append(s) +live = "\n".join(live) + +# A:把型別標籤釘在 entries 上 +a = len(re.findall(r"""entry_type\s*[:=]\s*[\x27"][a-z_]+[\x27"]""", live)) +# B:結構化欄位被打包進 JSON 團 +b = len(re.findall(r"""metadata_json[^\n]{0,80}(JSON\.stringify|json_object|\{)|(JSON\.stringify|json_object)\([^\n]{0,80}metadata_json""", live)) +# C:seed 一張宣告了欄位的 template +c = len(re.findall(r"""INSERT\s+(OR\s+IGNORE\s+)?INTO\s+templates\b[^;]{0,200}slots_json|slots_json\s*[:=]""", live, re.I | re.S)) + +if not ((a and b) or (c and b)): + sys.exit(0) + +sys.stderr.write(f"""❌ BLOCKED — 影子表警察(D91 的指紋) + +檔案:{path} +命中:型別標籤 {a} 處/JSON 團打包 {b} 處/template 宣告 {c} 處 + +你正在寫的形狀是:**宣告一個 schema,然後把資料倒進 metadata_json**。 +那會做出一張**沒人管得到的影子表**——表數還是三,但欄位從此改不動: +要加一欄,得同時改「一個沒人讀的宣告」和「一段打包程式」, +而**沒有任何機制會告訴你它們已經對不上了**。 + +leo 2026-08-15:「這每個都是一個 template⋯⋯**以後你不能修改 fields,因為表禁止動**, +如果要多一欄,要不就是說要改表,**要不就是全塞在能塞的格子裡**。」 + +先回答這三題,答不出來就不是這樣寫: + ① 這些被打包的欄位,**誰會讀 schema 宣告**?沒有人讀 ⇒ 那個宣告是裝飾品 + ② 要加第八個欄位時,**有什麼東西會擋住「只改了一半」**? + ③ 這批資料的形狀是**我們決定**的,還是**使用者的想法**決定的? + 使用者決定 ⇒ 不准有 schema,走 v-table + (Arcrun#133 的判準:這張表要改的時候,誰付 migration 的代價?) + +真的有理由這樣寫(例:那條路徑的寫入放大有實測數字,且已在票上記錄) +⇒ 該行尾加 `shadow-table-ok`,並在 commit 說明理由。 + +⚠️ 本閘是過渡期止血帶。真正的解是刪掉 metadata_json 與 entry_type(減,不是加)。 + +參考:system-dev/wiki/decisions-summary.md **D91**(病徵與擴散)、**D92**(同族形狀) +""") +sys.exit(2) +' diff --git a/hooks/skill-deploy-drift-guard.sh b/hooks/skill-deploy-drift-guard.sh new file mode 100755 index 0000000..7106711 --- /dev/null +++ b/hooks/skill-deploy-drift-guard.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# skill-deploy-drift-guard.sh — SessionStart:全機載入的那套 skill 跟它的正本對不上時,講出來 +# +# 為什麼存在(2026-08-16 查出來的實況): +# `arcrun-kbdb-guardrails` 這套 skill 有兩份—— +# 正本 arcrun_harness/.claude/skills/arcrun-kbdb-guardrails/ 有版控 +# 產物 ~/.claude/skills/arcrun-kbdb-guardrails/ **全機每個專案真正載入的是這份** +# 它們已經分家過一次,而且不是「一份新一份舊」:各自有對方沒有的東西。 +# 家目錄那份長出了一整層 D91 影子表防守,**從來沒進過 git**——差一點就在同步時被蓋掉。 +# 反方向也發生過:kbdb guard 的誤擋 bug 修好過,但修在別的拷貝上, +# 於是它透過這份沒同步的產物又長回來(Leo/InkStoneCo#23,同一個形狀第 7 次)。 +# +# 🔑 這支 hook 的用途不是同步,是**讓「它們又不一樣了」這件事有人知道**。 +# 同步是 `arcrun_harness/scripts/skill-deploy.sh` 的事,判斷也在那支裡; +# 本檔**刻意只是一層殼**:沒有自己的邏輯,就沒有自己的漂移。 +# +# 三條設計約束: +# 1. 沒事不出聲——乾淨時完全靜音,才不會被當成背景雜訊而被無視。 +# 2. 從不擋人——SessionStart 一律 exit 0,這是通知,不是閘。 +# 3. 環境不在就安靜退場——沒有 harness repo(雲端 session、別台機器)不該報錯。 +# +# ⚠️ 已知範圍限制:本 hook 掛在 InkStoneCo 的 SessionStart,所以只有「在 InkStoneCo 開 +# session」時會檢查。那份 skill 是全機載入的,在別的專案裡編輯它一樣會漂,只是要等 +# 下次有人開 InkStoneCo 才會被指出來。之所以先這樣,是因為改 ~/.claude/settings.json +# 等於動全機共用框架——那是要 leo 點頭的事,不是這支腳本自己能決定的。 + +set -uo pipefail + +DEPLOY_SH="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}/arcrun_harness/scripts/skill-deploy.sh" + +# 環境不在 → 安靜退場(約束 3) +[ -f "$DEPLOY_SH" ] || exit 0 + +OUT="$(bash "$DEPLOY_SH" check 2>&1)" && exit 0 # 乾淨 → 一個字都不印(約束 1) + +# 走到這裡代表有漂移。SessionStart 用 stdout 把訊息帶進脈絡。 +cat <<EOF +🧬 skill 漂移警報:\`arcrun-kbdb-guardrails\` 的正本與「全機實際載入的那份」對不上。 + +$OUT + +先讀懂標籤再動手,**不要反射性地 deploy**: + TAMPERED / FORKED 🔴 產物端有版控裡沒有的修改。直接部署會把它們永久刪掉—— + 先把那些修改收回正本(2026-08-15 D91 那層防守就是這樣差點沒的)。 + STALE 正本前進了、產物沒跟上 → bash arcrun_harness/scripts/skill-deploy.sh deploy + EXTRA 產物端多出來的檔。deploy 不會清,要不要刪由人決定。 + +🔑 為什麼這件事值得在開工時就講:這份產物是**模型真正讀到的教材**。 + 它落後的時候不會報錯、不會變慢、不會有任何症狀—— + 只會安靜地教錯的東西,而且錯得很有權威。 +EOF +exit 0 diff --git a/hooks/stage-before-prod-guard.sh b/hooks/stage-before-prod-guard.sh new file mode 100755 index 0000000..b13ed0b --- /dev/null +++ b/hooks/stage-before-prod-guard.sh @@ -0,0 +1,193 @@ +#!/bin/sh +# stage-before-prod-guard.sh — PreToolUse(Bash):**未經 stage 驗過,不准動 prod 出貨鏈**。 +# +# 🔴 立這道閘的來由(leo 2026-08-08): +# 「現在因為**開始封測**,不直接打到 prod,而是先打到昨天建的 stage,你知道嗎? +# 或是**有在哪裏寫了讓你不會直接推 prod**?因為**推 prod 就發佈了**, +# 雖然現在人不多,但**要謹慎**。」 +# +# 查證結果(總管 2026-08-08 實查,這道閘因此才有必要): +# ① wiki 有 stage 的記載(08-07 建的:staging bundle repo 在 Gitea +# `arcrun-rag-bundles-staging`、安裝器 `arcrun-rag-installer-staging.uncle6-me.workers.dev`) +# ② **但 `ship-check` skill 的出貨步驟從頭到尾寫的是 prod** +# (github-arm → GitHub `arcrun-rag-bundles` → jsDelivr/raw 驗證), +# **沒有任何一步是「先上 stage 驗過再上 prod」** +# ③ **沒有任何 hook 擋 prod**。D20 那道擋的是「寫 GitHub」,不是「未經 stage 就發佈」—— +# arm 一開,照樣可以直達 prod +# ⇒ 也就是「知道 stage 存在」對行為**零作用**。這是同一天第四次同款: +# 規則/知識存在,但沒有牙齒(前三次:history-first、KBDB-first、派工-first)。 +# +# 兩道條件,**缺一不可**(leo 2026-08-08 把層級拉高後的形狀): +# ① `.github-armed` 存在=leo 親手解過保險。**這道我造不出來,也不准去造。** +# leo:「現在到 github 就是同 arm,推到 prod 也應該視同 arm。」 +# ② `/tmp/.stage-verified` 6 小時內=stage 驗過(要在回覆貼實測輸出, +# 「我測過了」不算)。這道是我的責任,但它**不能替代 ①**。 +set -eu + +INPUT="$(cat)" +CMD=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "") +except Exception: print("") +' 2>/dev/null || echo "") +[ -z "$CMD" ] && exit 0 + +# 只攔「會讓封測者拿到東西」的動作:prod bundle repo、開 GitHub 保險、prod 安裝器部署。 +# staging 的同名動作要放行——所以先排除帶 staging 字樣的命令。 +case "$CMD" in + *staging*|*-staging*) exit 0 ;; +esac +# 🔴 2026-08-08:本閘上線後**連續誤攔自己兩次**,兩次都是同一個病—— +# 只比對「命令字串裡有沒有出現關鍵字」,把**談論**當成**執行**: +# ① `sed -n '1,40p' scripts/github-arm.sh` =我只是要**讀**那支腳本 +# ② `git commit -m "...照 scripts/github-arm.sh 解保險..."` =關鍵字在**commit 訊息**裡 +# ⇒ **誤攔比漏攔更容易殺死一道閘**:被擋得莫名其妙,人就會想辦法繞過它。 +# ⇒ 改成「先排除明確不發佈的動作,再比對關鍵字」。 +# +# 一律不發佈的動作(讀取、寫本地版控、查狀態)——先放行,免得關鍵字出現在訊息裡就中槍 +# 🔴 2026-08-12:這組「開頭是唯讀工具就放行」的豁免**本身是穿牆路**——與 +# prod-write-guard 同款(那支當天已修,這支漏了)。實測: +# echo go && scripts/github-arm.sh "出貨" 30 +# 開頭是 `echo ` ⇒ 整條 exit 0 ⇒ **真的執行解保險腳本卻一聲不吭**。 +# 前面接一個 echo/cat 就能繞過任何閘。 +# ⇒ 命令裡只要出現投送工具,就不吃「開頭豁免」,交給下面的 +# HAS_PUBLISH_ACTION 逐條判(它看的是「在不在命令位置」,不是「有沒有出現」)。 +case "$CMD" in + *"git push"*|*wrangler*|*ship.mjs*|*github-arm.sh*|*publish-*|*curl*|*wget*|*httpie*|*" acr "*) ;; + sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;; + *"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;; + *"git status"*|*"git log"*|*"git diff"*|*"git show"*|*" --dry-run"*) exit 0 ;; +esac + +# 🔴 2026-08-12 第三次誤攔(同一晚第三道閘犯同一個病): +# 上面那組豁免只認「指令**開頭**是唯讀工具」,所以 +# `BASE="https://cdn.jsdelivr.net/gh/.../arcrun-rag-bundles@<sha>"; curl -s "$BASE/manifest.json"` +# ——一個**抓公開 CDN 檔案來查證**的純 GET——因為開頭是變數指派而被當成出貨擋下。 +# 更糟:連 `grep ... stage-before-prod-guard.sh`(**讀本閘自己的原始碼**)都被擋, +# 因為那行指令裡出現了關鍵字 ⇒ **這道閘無法被檢查、也無法被測試**。 +# ⇒ 本閘第 44 行自己早就寫了「誤攔比漏攔更容易殺死一道閘」。這次攔到自己頭上三回。 +# +# 真正的判準不是「有沒有提到出貨物件」,是「**這條指令有沒有真的在發佈**」。 +# 讀 manifest、讀 bundle 內容、讀腳本原始碼 —— 全是查證,那正是出貨前**該**做的事。 +HAS_PUBLISH_ACTION=false +# 剪掉唯讀工具的 -d 旗標(tr -d/cut -d=…),免得被當成 curl 的 --data(prod-write-guard 同款教訓) +CMD_W=$(printf '%s' "$CMD" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|split|comm)[[:space:]]+-d/\1\2 __RO_D__/g') +case "$CMD_W" in + *"wrangler deploy"*|*"wrangler versions deploy"*|*"wrangler publish"*) HAS_PUBLISH_ACTION=true ;; + # 🔴 2026-08-12 第六次誤攔(又是我自己的改動造成的):原本「命令含 git push + # 就算發佈」,加上我新加的「有投送工具就不吃開頭豁免」之後, + # **一個推 Gitea 的普通 commit,只因為 commit 訊息裡提到 github-arm,就被擋** + # ——那正是本檔第 43 行說「已經修好」的情境(關鍵字在 commit 訊息裡)復發。 + # ⇒ `git push` 只有在**推 bundle repo** 時才是出貨;推自己的 repo 不是。 + *"git push"*) + case "$CMD_W" in *bundles*) HAS_PUBLISH_ACTION=true ;; esac + ;; + *"acr push"*|*"acr recipe push"*|*"acr update"*) HAS_PUBLISH_ACTION=true ;; + *"ship.mjs"*|*"publish-mirror"*|*"publish-bundles"*) HAS_PUBLISH_ACTION=true ;; + # 直接「執行」解保險腳本(不是讀它、不是在訊息裡提到它) + # + # 🔴 2026-08-12 第四次誤攔——**這條是我自己當天稍早寫的,pattern 太鬆**: + # 原本寫 `*sh\ *github-arm.sh*`,而 glob 的 `*` 跨越整條命令 + # ⇒ 一條「讀取」指令只要**在別處出現過 "sh " 再出現 github-arm.sh** 就中: + # echo "=== github-arm.sh 怎麼判過期 ==="; grep -nE '…' scripts/github-arm.sh + # (前半的 `github-arm.sh ` 提供了 "sh ",後半提供了檔名)⇒ 純讀取被當成發佈。 + # ⇒ 修別人的閘時自己製造了第四次誤攔。教訓與前三次同款: + # **判準要看「它在不在命令位置」,不是「字串有沒有出現過」。** + *github-arm.sh*) + # 只有出現在**命令起始位置**(行首/`;`/`|`/`&&` 之後,可帶 bash/sh 前綴)才算執行 + if printf '%s' "$CMD_W" | grep -qE '(^|[;&|]|&&|\|\|)[[:space:]]*((bash|sh|zsh)[[:space:]]+)?(\./)?([A-Za-z0-9_./-]*/)?github-arm\.sh([[:space:]]|$)'; then + HAS_PUBLISH_ACTION=true + fi + ;; + # HTTP 寫入動詞 + *"-X POST"*|*"-X PUT"*|*"-X PATCH"*|*"-X DELETE"*|*"--data"*|*" -d "*) HAS_PUBLISH_ACTION=true ;; +esac +[ "$HAS_PUBLISH_ACTION" = true ] || exit 0 + +# 剩下的才比對「會讓封測者拿到東西」的關鍵字 +case "$CMD" in + *arcrun-rag-bundles*|*github-arm*|*publish-github*) ;; + *) exit 0 ;; +esac + +# ── 條件 ①:leo 親自解保險(人閘,我無法自造)───────────────────────────── +# +# 🔴 leo 2026-08-08:「**現在到 github 就是同 arm,推到 prod 也應該視同 arm。**」 +# ⇒ 「推 prod」與「寫 GitHub」是**同一級的動作**,都要 leo 親手解保險。 +# 先前 D20 只把 arm 綁在「寫 GitHub」上,於是「推 prod」這件事本身沒有人閘—— +# 而真正該被守住的是**發佈**,不是「用了哪個 host」。 +# 🔴 2026-08-08 差點出事:原本寫 `ARMED="$CLAUDE_PROJECT_DIR/.github-armed"`, +# 在 CLAUDE_PROJECT_DIR 未設的環境下被 `set -u` 當場打死 ⇒ 腳本 **exit 1**。 +# 而 exit 1 **不擋**(只有 exit 2 才擋)⇒ 這道閘會**靜默放行**。 +# ⇒ 一道「失敗時自動失效」的安全閘比沒有更危險:它讓人以為有守。 +# **安全閘的預設必須是 fail-closed。** +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +ARMED="$PROJ/.github-armed" +if [ ! -f "$ARMED" ]; then + cat >&2 <<'AEOF' +🔫 出貨人閘:推 prod = 發佈,**視同 arm,要 leo 親自解保險**。 + +【leo 2026-08-08】「現在到 github 就是同 arm,**推到 prod 也應該視同 arm**。」 + ⇒ 該守的是「**發佈**」這個動作本身,不是「用了哪個 host」。 + +這一步**不是我能自己過的**。請 leo 整行貼: + + ~/Documents/tech_projects/InkStoneCo/scripts/github-arm.sh "<出貨說明>" 30 + +📌 在請他解保險之前,先確認 stage 已經驗過(下面那道閘), + 否則等於請他為一個沒驗過的東西按發射鈕。 +AEOF + exit 2 +fi + +# ── 條件 ②:stage 先驗過(我的責任,但不能替代條件 ①)─────────────────── +# +# 🔴 2026-08-08 出貨當下實撞:本段**訊息與實作互相矛盾**—— +# 下面那段說明叫人 `touch /tmp/.stage-verified`, +# 但這裡讀的是**檔案內容**當時間戳 ⇒ `touch` 產生空檔 ⇒ `cat` 空 ⇒ 退回 0 +# ⇒ `now - 0` 恆大於 6 小時 ⇒ **照它自己的說明做,永遠過不了**。 +# 這正是那天燒掉一整天的同一個病:**宣稱的做法 ≠ 真正的做法**。 +# ⇒ 改成兩者都認:內容有數字就用內容,沒有就用 mtime(`touch` 因此真的有效)。 +# +# 另外認 leo 親手蓋的章(scripts/stage-ok.sh 產生),那是比我自評更強的訊號。 +for STAMP in /tmp/.stage-ok-by-leo /tmp/.stage-verified; do + [ -f "$STAMP" ] || continue + now=$(date +%s) + # 🔴 只有「整行就是一個時間戳」才採信內容。 + # 別用 tr 抽數字——JSON 章 `{"sha":"abc123","release":"1.4.24",…}` 抽出來會變成 + # 一個**看似合理但錯得離譜**的舊時間戳(1970 年),比 fail-open 更難察覺。 + t=$(head -1 "$STAMP" 2>/dev/null | tr -d '[:space:]') + # 內容不是時間戳(例:被 touch 成空檔)⇒ 退回檔案修改時間,別誤判成「沒驗過」 + case "$t" in ''|*[!0-9]*) t=0;; esac + # 🔴 同日再撞一次 fail-open:ship.mjs 把這個章從「純時間戳」升級成 JSON 之後, + # 抽數字會得到天文數字 ⇒ `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) + fi + [ "$t" -gt 0 ] && [ "$((now - t))" -lt 21600 ] && exit 0 # 6 小時內驗過 stage → 放行 +done + +cat >&2 <<'EOF' +🚦 出貨閘:你正要動 **prod 出貨鏈**,但這 6 小時內沒有 stage 驗證紀錄。 + +【leo 2026-08-08】「現在因為**開始封測**,不直接打到 prod,而是先打到昨天建的 stage⋯⋯ + 因為**推 prod 就發佈了**,雖然現在人不多,但**要謹慎**。」 + +【為什麼有這道閘】`ship-check` 的步驟本身就寫成直達 prod,D20 那道只擋「寫 GitHub」、 + 不擋「未經 stage 就發佈」⇒ 光「知道有 stage」對行為零作用。 + +正確順序(stage 先行): + 1. 打 staging bundle → 推 Gitea `arcrun-rag-bundles-staging`(不碰 GitHub,無 D20 閘) + 2. 用 staging 安裝器實裝到測試實例 + https://arcrun-rag-installer-staging.uncle6-me.workers.dev + 3. 走一次**封測者真的會走的那條路**,貼出實測輸出 + 4. 過了才動 prod + +⚠️ 身分:leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」 + ⇒ 部署前先 `acr whoami` 確認身分。 + +驗過了 ⇒ `touch /tmp/.stage-verified` 後重送,並在回覆裡**貼 stage 的實測輸出** +(「我測過了」不算——貼指令與它吐出來的東西)。 +EOF +exit 2 diff --git a/hooks/subagent-claim-worksheet.sh b/hooks/subagent-claim-worksheet.sh new file mode 100755 index 0000000..0984d25 --- /dev/null +++ b/hooks/subagent-claim-worksheet.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單 +# (SubagentStop;leo 2026-08-18 立) +# +# ── leo 的原話(本閘的規格)───────────────────────────────────────── +# 「你如何確認說的都是真的?subagent 做完事,你應該在 SubagentStop 掛動作, +# **它說可以,你去驗證;它說不行,你想辦法,不行再報告**, +# 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報, +# **它視野小,它說的你有更多資訊去檢驗**。」 +# +# ── 為什麼是「工作單檔案」而不是提醒文字 ───────────────────────────── +# leo 2026-08-17 診斷過:文字層封路是打不贏的軍火競賽,要封**動作**。 +# 所以這支不勸、也不偵測我的措辭——它**在磁碟上放一個必須被處理掉的東西**。 +# 配套的 Stop 閘(claim-verify-police.sh)看的是「那個檔還在不在」, +# 而檔案只能被動作消滅,不能被文案消滅。 +# +# ── 兩類宣稱,處理方式不同(leo 的規格就是這兩條)────────────────── +# ✅ 它說可以 → 我去驗證(我有跨 repo 視野、有 token、有瀏覽器,它沒有) +# ⛔ 它說不行 → 我想辦法(換路徑/查憑證地圖/自己撞一次),真的不行才報告 leo +# +# ⚠️ 這裡的關鍵詞比對只用來**列出候選**,不用來判定對錯—— +# 列多了我自己劃掉(要寫理由),列少了不影響我另外查。 +# ⇒ 它的失敗模式是「多列幾條」,不是「擋錯事」。 +# +# ── 留痕(InkStoneCo#48:36 支閘只有 2 支記錄自己做了什麼)───────── +INPUT=$(cat) +DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification" +LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-claim-worksheet.log" +mkdir -p "$DIR" 2>/dev/null + +OUT=$(printf '%s' "$INPUT" | DIR="$DIR" python3 -c ' +import json, os, re, sys, hashlib + +try: + d = json.load(sys.stdin) +except Exception: + print("SKIP:bad-json"); raise SystemExit + +tp = d.get("transcript_path") or "" +if not tp or not os.path.exists(tp): + print("SKIP:no-transcript"); raise SystemExit + +# ── 取 subagent 的交件 ──────────────────────────────────────────── +# 🔴 2026-08-18 首次實跑就抓到自己的缺陷:`SubagentStop` 給的 `transcript_path` +# 指向**主對話**,不是子代理的紀錄 ⇒ 原本「取最後一則 assistant 訊息」 +# 抓到的是**總管自己寫的句子**,這支 hook 當場退化成它要避免的「文字層自咬」。 +# +# 正解:子代理的交件是以 `<task-notification>…<result>…</result>` 的形式 +# 進到主對話的 **user 訊息**裡。只認那個區塊,其餘一律不看。 +# ⇒ 判準回到「這段話是誰說的」,而不是「這段話長什麼樣」。 +last = "" +try: + blob = [] + with open(tp, "r", encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except Exception: + continue + msg = ev.get("message") or {} + if msg.get("role") != "user": + continue + parts = msg.get("content") + if isinstance(parts, str): + txt = parts + elif isinstance(parts, list): + txt = "".join(p.get("text", "") for p in parts + if isinstance(p, dict) and p.get("type") == "text") + else: + txt = "" + if "<task-notification>" in txt: + blob.append(txt) + if blob: + m = re.search(r"<result>(.*?)</result>", blob[-1], re.S) + last = m.group(1) if m else "" +except Exception: + print("SKIP:read-fail"); raise SystemExit + +if not last.strip(): + print("SKIP:no-subagent-report"); raise SystemExit + +if not last.strip(): + print("SKIP:empty-report"); raise SystemExit + +# ── 抽候選宣稱 ────────────────────────────────────────────────── +# 成功類:它說某件事成立/做好了 ⇒ 我去驗 +OK = ("驗過", "測過", "通過", "全綠", "已部署", "部署完成", "可以用", "成立", + "完成", "修好", "已推", "已併", "沒問題", "正常", "一致", "對上", + "✅", "green", "passed", "verified", "deployed") +# 阻擋類:它說做不到 ⇒ 我想辦法,不行才報告 leo +NG = ("無法", "不行", "做不到", "被擋", "擋下", "失敗", "不存在", "找不到", + "沒有權限", "權限不足", "需要 leo", "要 leo", "請總管", "交給你", + "❌", "blocked", "denied", "cannot", "failed", "not found") + +def lines_of(text): + out = [] + for raw in text.split("\n"): + s = raw.strip().lstrip("-*#>| ").strip() + if len(s) < 8 or len(s) > 220: + continue + out.append(s) + return out + +ls = lines_of(last) +ok_hits, ng_hits = [], [] +for s in ls: + low = s.lower() + if any(k in s or k.lower() in low for k in NG): + ng_hits.append(s) + elif any(k in s or k.lower() in low for k in OK): + ok_hits.append(s) + +# 兩類都沒有 ⇒ 它交的是純資料/純提問,沒有事實宣稱要驗 +if not ok_hits and not ng_hits: + print("SKIP:no-claims"); raise SystemExit + +sid = (d.get("session_id") or "nosid")[:8] +aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10] +stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8] +path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp)) + +def block(title, items, howto, cap): + if not items: + return "" + body = "\n".join("- [ ] %s" % x for x in items[:cap]) + more = "" + if len(items) > cap: + more = "\n- [ ] (還有 %d 條同類,讀原始交件)" % (len(items) - cap) + return "## %s\n\n%s\n%s%s\n\n" % (title, howto, body, more) + +doc = [] +doc.append("# 待驗工作單(subagent 交回的宣稱)\n") +doc.append("> leo 2026-08-18:「**它視野小,它說的你有更多資訊去檢驗。**」\n") +doc.append("> 交件全文在:`%s`\n" % tp) +doc.append("\n🔴 **這張單子只能用動作消滅,不能用文字消滅。**") +doc.append("逐條處理完之後,把本檔**刪掉或移走**;在那之前 Stop 閘會擋。\n\n") +doc.append(block( + "✅ 它說「可以」——我要自己驗一次", + ok_hits, + "判準:**我不採信,我自己跑一次**。它只看得到自己那個 repo 與自己那次呼叫;\n" + "我有跨 repo 視野、有 token、有瀏覽器。驗完把證據貼進回覆或票裡。\n", + 12)) +doc.append(block( + "⛔ 它說「不行」——我先想辦法,真的不行才報告 leo", + ng_hits, + "判準:**它的「不存在/被擋」往往只是「我這裡看不到」**。\n" + "先做三件:① 查憑證地圖/wiki ② 換一條路徑 ③ **自己真的撞一次並貼出拒絕原文**。\n" + "🔴 沒撞過就不准說「我被 X 擋住」——那是虛構的閘(2026-08-18 已第五次)。\n" + "真的是人閘 ⇒ 走三件機械動作:`Human` 標籤 + 指派給 Leo + **一張它自己的票**,\n" + "不是在別張票的留言裡寫一段話(leo 08-18:「不是默默塞進錯的地方」)。\n", + 12)) + +with open(path, "w", encoding="utf-8") as f: + f.write("".join(doc)) + +print("WROTE:%s:ok=%d:ng=%d" % (os.path.basename(path), len(ok_hits), len(ng_hits))) +' 2>/dev/null) + +printf '%s\t%s\n' "$(date +%FT%T)" "${OUT:-SKIP:no-output}" >> "$LOG" 2>/dev/null + +case "$OUT" in + WROTE:*) + F=$(printf '%s' "$OUT" | cut -d: -f2) + N=$(printf '%s' "$OUT" | sed 's/.*ok=\([0-9]*\):ng=\([0-9]*\)/\1 成功宣稱、\2 阻擋宣稱/') + cat <<EOF +🔍 交件查證:subagent 交回了 $N,已列成待驗工作單。 + +【leo 2026-08-18 的規格】 + 「它說可以,你去驗證;它說不行,你想辦法,不行再報告。 + **它視野小,它說的你有更多資訊去檢驗。**」 + +工作單:.claude/pending-verification/$F + +🔴 **不要直接把它說的轉給 leo。** 逐條處理完,把那個檔刪掉;在那之前 Stop 閘會擋一次。 + · 它說「可以」→ 你自己跑一次(測試/curl/瀏覽器/git,看那條宣稱是什麼) + · 它說「不行」→ 先換路徑、查憑證地圖;**要說「被擋」就得自己撞一次並貼原文** +EOF + ;; +esac +exit 0 diff --git a/hooks/subagent-first-guard.sh b/hooks/subagent-first-guard.sh new file mode 100755 index 0000000..8404f55 --- /dev/null +++ b/hooks/subagent-first-guard.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# subagent-first-guard.sh — PreToolUse(Write|Edit|MultiEdit): +# 要親手改 code,卻**這個 session 一次工都沒派過** → 擋一次,逼你先回答「這件事該不該派出去」。 +# +# 🔴 立這道閘的來由(leo 2026-08-08): +# 「你記得**要叫 subagent 開工,你負責維護 loop**,而不是你開工後過一陣子停下來對吧? +# 問題是這個規定**如何不要我說就做到**?因為在 vscode 裡你自然會這麼做, +# 但現在在 claude desktop 你就會自己做問個問題停下來。」 +# +# 查出來的根因(所以這道閘才有意義,不是加強語氣): +# Claude Desktop 這個 surface 的 system prompt 有 +# `Do not call the AgentTool unless the user requested it`, +# 它**壓過** CLAUDE.md 規則三點五 ⇒ 同一份規則在兩個 surface 行為不同。 +# leo 已於 2026-08-08 在 CLAUDE.md 給出常駐授權(=永久滿足「user requested」), +# 這道閘負責讓「授權」真的變成「行為」——同 KBDB 那道的教訓: +# **規則被讀到 ≠ 會被執行,要有機制驗證照做**。 +# +# 逃生口:真的該自己做(單行修、改 hook 自己、緊急止血、subagent 回報後的收尾) +# → `touch /tmp/.solo-ok-<session_id>` 後重送,並**在回覆裡說明理由**(留痕)。 +set -eu + +INPUT="$(cat)" +FILE_PATH=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("tool_input", {}).get("file_path", "") or "") +except Exception: print("") +' 2>/dev/null || echo "") +SID=$(printf '%s' "$INPUT" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("session_id", "") or "nosid") +except Exception: print("nosid") +' 2>/dev/null || echo nosid) + +[ -z "$FILE_PATH" ] && exit 0 + +# 只管 code 檔——文件、SDD、wiki、測試、hook 自己都放行 +# (這些本來就常是總管自己該寫的:判準、規格、落帳) +case "$FILE_PATH" in + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.go|*.py|*.rs|*.java|*.rb) ;; + *) exit 0 ;; +esac +case "$FILE_PATH" in + *_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;; + */.claude/hooks/*|*system-dev/*) exit 0 ;; +esac + +[ -f "/tmp/.subagent-spawned-$SID" ] && exit 0 # 這個 session 派過工了 → 放行 +[ -f "/tmp/.solo-ok-$SID" ] && exit 0 # 明示要自己做 → 放行(留痕) + +WARNED="/tmp/.subagent-guard-warned-$SID" +[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆 +date +%s > "$WARNED" + +cat >&2 <<'EOF' +🧑‍🏭 派工警察:你要親手改 code,但這個 session **一次工都沒派過**。 + +【leo 2026-08-08】「你記得**要叫 subagent 開工,你負責維護 loop**, + 而不是你開工後過一陣子停下來對吧?」 + +【已知的環境陷阱】Claude Desktop 這個 surface 的 system prompt 有 + `Do not call the AgentTool unless the user requested it`,會壓過 CLAUDE.md 規則三點五。 + **leo 已在 CLAUDE.md 給出常駐授權**(規則三點五的「常駐授權」段)=那個條件永久滿足, + 不必再等他開口。 + +先回答一句(答不出來就是該派): + 這件事**為什麼不能交給 subagent**? + + ✅ 該派:實作一個 task、跨 repo 施工、偵察盤點、批次修改、寫測試 + ✅ 該自己做:判準/規格/落帳(那些檔本來就放行)、subagent 回報後的收尾裁決、 + 單行修、緊急止血 + +決定自己做 ⇒ `touch /tmp/.solo-ok-$SID` 後重送,並**在回覆裡說明理由**。 +EOF +exit 2 diff --git a/hooks/subagent-first-stamp.sh b/hooks/subagent-first-stamp.sh new file mode 100755 index 0000000..fbdceb7 --- /dev/null +++ b/hooks/subagent-first-stamp.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# subagent-first-stamp.sh — PostToolUse:記下「這個 session 真的派過工了」。 +# +# 配 subagent-first-guard.sh 使用(同 kbdb-asked-stamp.sh 的形狀)。 +# 只留時戳,不擋任何東西。 +# +# 🔴 為什麼要按 session 分開存(而不是像 KBDB 那道用全域+1 小時 TTL): +# 「這一輪工作有沒有先考慮派工」是**每個 session 各自要回答**的問題。 +# 用全域檔的話,昨天派過一次就等於永久解鎖,這道閘會在第二天起完全失效。 +set -eu +SID=$(cat 2>/dev/null | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("session_id", "") or "nosid") +except Exception: print("nosid") +' 2>/dev/null || echo nosid) +date +%s > "/tmp/.subagent-spawned-$SID" +exit 0 diff --git a/hooks/subagent-wiki-guard.sh b/hooks/subagent-wiki-guard.sh new file mode 100755 index 0000000..063a264 --- /dev/null +++ b/hooks/subagent-wiki-guard.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# subagent-wiki-guard.sh — PreToolUse(Task) hook:subagent 聽到「查」就自己先查 wiki +# +# 病根(2026-07-20):總管兩次派 agent 查 ENCRYPTION_KEY,prompt 都只叫它「去查 repo 程式碼」。 +# agent 於是從**稿子**推論出「這東西還活著、不能動」,總管照單全收去擋 leo 三輪。 +# +# 🔑 設計轉向(leo 2026-07-21): +# 第一版是「上游沒交代讀 wiki 就擋下」——但那**還是依賴上游記得寫**, +# 跟「我記得讀 wiki」是同一個病。leo 點破: +# 「subagent 的問題跟你一樣。你叫它去查,就算你沒說要先查 wiki, +# 但它**只要聽到查,就應該主動查 wiki**,因為每個 repo 都有維護自己的 wiki。」 +# → 改成 **注入式**:不擋、不要求上游改 prompt,直接把「先查 wiki」這條 +# 以 additionalContext 注入給 subagent,讓它自己做。零依賴任何人記得。 +# +# 行為:偵測到查證/實作類任務 → exit 0 並用 hookSpecificOutput 注入指示。 +# 已含 wiki 指示、或非查證類任務 → 靜默放行(不重複注入)。 +set -euo pipefail + +INPUT=$(cat) + +PROMPT=$(printf '%s' "$INPUT" | python3 -c " +import json,sys +try: + d=json.load(sys.stdin) + print(d.get('tool_input',{}).get('prompt','')) +except Exception: print('') +" 2>/dev/null || echo "") + +[ -z "$PROMPT" ] && exit 0 + +# 上游已經交代了 → 不必重複注入 +if printf '%s' "$PROMPT" | grep -qiE "wiki|agent-memory|mistakes\.md|decisions-summary"; then + exit 0 +fi + +# 只對「查證/實作」類任務注入(純寫作、計算、潤稿等不需要) +if ! printf '%s' "$PROMPT" | grep -qiE "查|盤點|核實|確認|調查|研究|找出|repo|程式碼|原始碼|source|實作|移除|刪除|重構|修|grep|codebase|\.ts|\.go|src/"; then + exit 0 +fi + +# 🗺️ 真身地圖注入判斷(leo 2026-08-01:「你身為總管就是要能搞定這些跨 repo, +# 你才不會發出錯誤的命令給 subagent」)——偵測到部署/真身相關關鍵詞就把地圖注入。 +NEED_MAP=0 +if printf '%s' "$PROMPT" | grep -qiE "install|portal|landing|部署|deploy|worker|bundle|wrangler|真身|上線|出貨|arcrun-rag|console-ui"; then + NEED_MAP=1 +fi +export NEED_MAP + +python3 - <<'PY' +import json, os, pathlib + +guidance = """【自動注入 0:**先看你的 skill 清單有沒有現成的**】 + +**動手前先掃一眼 system prompt 的 available skills**——那裡的東西是「已經被驗證過的做法」, +比你自己從 repo 文件推測可靠得多。命中就 `Skill(skill="<name>")` 讀它,照它做。 + +判準:任務裡出現的**專有名詞**(產品名/平台名/工具名,如 Arcrun、Cloudflare、n8n…), +到 skill 清單裡找同名或近義的那支。**有就一定要先讀**,不要跳過去自己翻程式碼。 + +> 為什麼這條排在「查 wiki」前面(2026-07-30 實測四次考試逼出來的): +> 叫 haiku「幫我用 Arcrun 做 X」,它三次都失敗—— +> ①跑去 call MCP 拿到 401 ②自己 find repo 猜著寫 YAML ③讀 repo 文件後用了 +> **不存在的 `ON_TRUE`/`ON_FALSE` 邊**(n8n 式推測)。 +> 第四次只多給一句「你的 skill 清單裡有一支相關的」→ 它 `Skill(arcrun)` → **一次寫對**。 +> ⇒ 差別不在能力,在**它沒想到要看清單**。而本 hook 原本只教「查 wiki/grep」, +> 反而把它導向 repo 文件(`grep skill` 於本檔=0)。 + +--- + +【自動注入 1:查任何東西之前,先查 wiki】 + +你所在的 repo 有維護自己的 wiki(通常在 `system-dev/wiki/`,舊結構在 `.claude/wiki/`)。 +**接到「查/盤點/核實/實作」類任務時,第一個動作是搜尋 wiki,不是翻程式碼。** + +🔴 **查法有強弱之分,一律從最強的開始——沒有那個能力才降級。** +(leo 2026-07-21:「它一定是用最好的搜尋,如果沒有才 fallback, + 但那不是你要指定的,對搜尋者來說,我就是要去搜尋,如果你沒這個機制才降。」) + + **① 語意搜尋(最強,優先)**——有 Arcrun RAG MCP 就用它,用**自然語言問句**,不是關鍵字: + kbdb_search(q="<用一句話描述你要找什麼>", mode="semantic") + 不確定該查哪個庫 → 先 kbdb_get_map() 看藏書地圖 + 要沿關係展開 → kbdb_graph_neighbors() + **② 關鍵字搜尋**——語意不可用時:kbdb_search(q="...", mode="keyword") + **③ grep(最弱,最後手段)**——連 MCP 都沒有時: + grep -rin "<關鍵字>" system-dev/wiki/ 2>/dev/null || grep -rin "<關鍵字>" .claude/wiki/ + +🔴 **為什麼順序是硬規定(2026-07-21 實際事故)**: + 查「CF 上的 git 託管」時只用了 grep,搜 Gitea/freeze/D43 等字面詞 → **零命中**, + 結論寫成「這件事沒查過、申請表沒送」。 + 事後用**同一個問題**跑語意搜尋,**第一筆就命中**(score 0.858): + 「Cloudflare Artifacts:假設內建 git 倉庫機制的 CF 功能,成立則可全 CF 化」, + 還帶出三元組「Cloudflare Artifacts >> 若提供 git 倉庫則可取代 >> Gitea」—— + **負責人 15 天前就記在筆記裡了。** + → **grep 只認字面,要求你先猜對那個詞;語意搜尋不需要你猜對。** + 用 grep 查不到 ≠ wiki 沒記載,只代表你沒猜中用詞。 + +🔴 **凡結論涉及「某人沒做某事」,回報前必須先用語意搜尋查該事的記載**—— + 這種結論錯了會變成**指控**,成本遠高於技術判斷錯誤。 + +為什麼這是划算的: + • wiki 是前人已經查過、驗證過、被負責人糾正過的結論——**判準**。 + • 程式碼與歷史文件是**稿子**:它反映「還沒清乾淨」,不等於「還在用」。 + 從稿子推論會系統性得出過時結論。 + • wiki 沒記載,才值得花力氣翻原文。 + • **凡結論涉及「某人沒做某事」,回報前必須先 grep 該事在 wiki 的記載**—— + 這種結論錯了會變成指控,成本遠高於技術判斷錯誤。 + +三條硬規則: + 1. **wiki 與程式碼衝突 → 以 wiki 為準**,並在回報中明確指出衝突, + 不要自行用 code 推翻 wiki。 + 2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。 + 那是當時狀態,不是永久禁令;條件已滿足就是可動。 + (2026-07-20 實際事故:agent 只看到「不可動」就回報不能動, + 實際上解除條件早已滿足,害負責人被擋三輪。) + 3. 翻原文後若得到**新結論**,回報時明講「wiki 該更新」——wiki 過時是債,要還。 +""" + +# 🗺️ 注入 2:跨 repo 真身地圖(NEED_MAP=1 時才附) +if os.environ.get('NEED_MAP') == '1': + parts = [] + for f, title in [ + ('system-dev/wiki/install-update-chain.md', '安裝/更新鏈(改了什麼→怎麼到用戶手上)'), + ('system-dev/wiki/deployment-map.md', '網址↔Worker/Pages↔後端 對照表'), + ]: + fp = pathlib.Path(f) + if fp.exists(): + body = fp.read_text(encoding='utf-8', errors='ignore') + parts.append(f"### {title}\n來源:`{f}`(**這是實測釘死的真相源,勝過你從程式碼推論**)\n\n{body[:9000]}") + if parts: + guidance += ("\n\n---\n\n【自動注入 2:🗺️ 跨 repo 真身地圖——**先讀這個,不要自己猜哪份是活的**】\n\n" + "🔴 **病根(leo 2026-08-01 重話)**:「哪個已經廢棄了你也不知道,總不能每次都花很多時間\n" + "做簡單的『找到程式碼在哪裡』的工作?」——同名/相似檔案有很多份,\n" + "**問題不是找不到,是找到太多份而不知道哪份還活著**。\n\n" + "**規則**:① 動手前先在下方地圖找你要改的東西 ② 地圖沒有才自己驗\n" + "③ **驗完立刻補進地圖**(帶佐證:怎麼證明它是活的)④ 地圖與程式碼衝突 → **以地圖為準**並回報衝突。\n\n" + + "\n\n".join(parts)) + + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": guidance + } +}, ensure_ascii=False)) +PY + +exit 0 diff --git a/hooks/unpushed-police.sh b/hooks/unpushed-police.sh new file mode 100755 index 0000000..d9a6219 --- /dev/null +++ b/hooks/unpushed-police.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# unpushed-police.sh — 未推警察(Stop / SubagentStop hook) +# +# 🔴 2026-08-05:本機 LANG=C.UTF-8 下,把變數(分支名/日期)串進含全形標點的字串會**吃掉字元** +# (實測 `- $b:$cnt 筆` → 分支名整個消失、只剩「:」的殘骸位元組 bc 9a)。 +# LC_ALL=C 下同一段完全正常 ⇒ 固定 locale,別讓報告內容被環境吃掉。 +export LC_ALL=C +# +# 病根(leo 2026-08-02 原話):「如果讓改了沒推會被警告?因為太常發生了」 +# +# 一天之內同一個病發作六次,全部是「**改對了,但沒送到用戶手上**」: +# ① Gemini key 修好了(Arcrun 0d49989)→ 沒重建 cypher bundle ⇒ 線上 admin/ai=0, +# leo 與全體封測者填 key 一律無效,rag_chat 全壞一整天 +# ② CIS logo 換好了(512ea9f 7/31 20:39)→ daemon zip 是 15:35 打的 ⇒ 早 5 小時, +# 發出去的永遠是舊圖(leo 肉眼在 Finder 抓到) +# ③ manifest.daemon 欄被 release.mjs 吃掉 → 全體「檢查更新」壞掉一天 +# ④ 推了 GitHub 但 jsDelivr @main 還在吐舊的 ⇒ 推對了用戶仍拿不到 +# ⑤ fix/cis-round3-install 分支從未 push(今天才發現) +# ⑥ 07-30 同款前科:t143-t150 十筆 commit 只在本機,雲端誤報「線上程式碼失蹤」 +# +# 為什麼要 hook 而非規則:scripts/check-deploy-drift.sh 七月三十就寫好了, +# 但**沒有任何東西會自動呼叫它**——只在文件裡被提到。 +# 「機制存在 ≠ 會被讀到」。所以這支要自己跳出來,不能等人想到要跑。 +# +# 機制:收工那刻掃所有已知 repo,有「未 commit」或「已 commit 未推」就 exit 2 提醒。 +# 只提醒不阻擋——push 需要 leo 開閘(D20),hook 無權也不該代按。 +# 豁免:純對話回合(沒動過檔)/已在本回合推過/stop_hook_active。 + +input="$(cat)" + +stop_active="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("stop_hook_active", False)) +except Exception: print(False) +' 2>/dev/null)" +[ "$stop_active" = "True" ] && exit 0 + +TOP="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" + +# 掃描對象:頂層 + 各子 repo(存在才掃)。worktree 一併涵蓋(git -C 會自己解)。 +REPOS="$TOP $TOP/matrix/arcrun $TOP/products/arcrun-rag $TOP/polaris/mira" + +problems="" +for r in $REPOS; do + [ -d "$r/.git" ] || [ -f "$r/.git" ] || continue + name="${r#$TOP/}"; [ "$name" = "$TOP" ] && name="(頂層 InkStoneCo)" + + # ① 有改動沒 commit(排除 untracked,那多半是暫存/產物) + dirty="$(git -C "$r" diff --shortstat 2>/dev/null | head -1)" + staged="$(git -C "$r" diff --cached --shortstat 2>/dev/null | head -1)" + + # ② 已 commit 但沒推(**當前分支**) + unpushed="" + br="$(git -C "$r" branch --show-current 2>/dev/null)" + if [ -n "$br" ]; then + up="$(git -C "$r" rev-parse --abbrev-ref "@{upstream}" 2>/dev/null || true)" + if [ -n "$up" ]; then + n="$(git -C "$r" rev-list --count "$up".."$br" 2>/dev/null || echo 0)" + [ "$n" != "0" ] && unpushed="領先 $up $n 筆" + else + # 沒 upstream=這條分支從來沒推過(今天 fix/cis-round3-install 就是這樣) + has_commits="$(git -C "$r" rev-list --count HEAD 2>/dev/null || echo 0)" + [ "$has_commits" != "0" ] && unpushed="分支 $br **從未推過**(無 upstream)" + fi + fi + + # ③ 🔴 2026-08-05 t195 補:**散落分支**——有 commit 沒併回當前分支的其他本地分支。 + # 原本只查 ①②(當前分支的髒樹/未推),所以 D36 授權修補躺在 fix/cis-round3-install + # 整整一週都沒被報過,害 feat/daemon 的收卡端全 401、新卡一張都寫不進去。 + # leo 2026-08-05:「為什麼有分支沒 merge?都是你寫的,驗過就要 merge⋯⋯ + # 如果它會留分支無明顯理由不 merge,那就要整理」。 + # 判準=「這條分支有沒有當前分支沒有的 commit」,不是分支數量多寡。 + stray="" + held="" + # 🔴 2026-08-15 訂正:基準**不能用當前簽出的分支**。 + # 共用工作區常停在某條舊功能分支上(08-15 實撞:matrix/arcrun 停在 + # fix/credential-resolve-without-auth-worker,而 main 已經領先它 17 筆) + # ⇒ 四條分支全被報成「散落」,其中三條早就併進 main 了。 + # **這正是同一晚重複八次的那個病:檢查跑了,但檢查的對象是錯的。** + # ⇒ 基準改成「整合分支」:gitea/main > origin/main > main > 當前分支。 + base="$br" + for cand in gitea/main origin/main main; do + if git -C "$r" rev-parse --verify -q "$cand" >/dev/null 2>&1; then base="$cand"; break; fi + done + if [ -n "$base" ]; then + for b in $(git -C "$r" for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null); do + [ "$b" = "$base" ] && continue + git -C "$r" merge-base --is-ancestor "$b" "$base" 2>/dev/null && continue + cnt="$(git -C "$r" rev-list --count "$base".."$b" 2>/dev/null || echo 0)" + [ "$cnt" = "0" ] && continue + # ── 刻意保留的分支(2026-08-12 加)───────────────────────────────── + # 本閘原本只認得「該併」與「該刪」兩種,但**還有第三種:刻意不併** + # (例:`wip/stopped-agents-2026-08-10` 是 arcrun-rag#56 那六個 Go 檔的唯一一份, + # 保留理由與刪除條件記在該票上)。 + # 沒有這個狀態 ⇒ 那條分支**每一回合都被叫一次**,而且叫的是同一件已經交代過的事。 + # 🔴 **永遠在響的警報,等於訓練人忽略這個警報**——那才是真正的損失, + # 因為下一條真的失蹤的分支會混在同一堆雜訊裡。 + # 門檻刻意留著:要寫一行理由進 `.claude/branch-holds.md` 才生效, + # **不能只在對話裡說**(對話會消失,這正是本閘存在的理由)。 + _holds="$TOP/.claude/branch-holds.md" + if [ -f "$_holds" ] && grep -qF "$b" "$_holds" 2>/dev/null; then + # 🔴 這裡**不要**用 sed 去切掉「分支名:」那段前綴。 + # 本檔開頭第 4 行已經記過:`LC_ALL=C` 下處理含全形標點的字串會咬掉位元組, + # 實測切完會吐出 `刻意保留——\xef\xbf\xbd它是` 這種殘骸。 + # 整行照印就好——那一行本來就寫得像人話,前綴留著也讀得通。 + _why="$(grep -F "$b" "$_holds" | head -1)" + held="$held + $_why" + continue + fi + age="$(git -C "$r" log -1 --format='%ad' --date=format:'%m-%d' "$b" 2>/dev/null)" + # 用真實換行累積,不用 \n 逃脫——否則後面的 printf '%b' 會把分支名再解讀一次 + stray="$stray + - $b:$cnt 筆未併入 $base(最後動 $age)" + done + fi + + line="" + [ -n "$dirty" ] && line="$line + · 未 commit:$dirty" + [ -n "$staged" ] && line="$line + · 已 staged 未 commit:$staged" + [ -n "$unpushed" ] && line="$line + · $unpushed" + [ -n "$stray" ] && line="$line + · 🔀 **散落分支**(有 commit 沒併回 $br):$stray" + # 刻意保留的分支**不會自己觸發警報**(那正是加它的目的), + # 但這個 repo 若本來就有別的問題要報,就順便把它列出來當脈絡—— + # 免得「保留」變成另一種看不見。 + [ -n "$line" ] && [ -n "$held" ] && line="$line + · ⏸️ 刻意保留(不觸發警報,理由在 .claude/branch-holds.md):$held" + [ -n "$line" ] && problems="$problems + 📍 $name$line" +done + +[ -z "$problems" ] && exit 0 + +cat >&2 <<EOF +🚓 未推警察:有東西改了但還留在本機。 + +【leo 2026-08-02】「如果讓改了沒推會被警告?**因為太常發生了**」 +【判準】改對了 ≠ 送到用戶手上。同一天發作六次,每次都是這個病。 + +$problems + +收工前擇一處理(**一句話交代也算,別默默留著**): + 1. 該推就推 → Gitea 直接 push(不需開閘);GitHub 要 leo 跑 scripts/github-arm.sh + 2. 還不該推 → 在回覆裡**明說**「X 先不推,因為 Y」,讓 leo 知道有東西在路上 + 3. 是暫存/產物不該進版控 → 加 .gitignore 或刪掉,別讓它每次都來吵 + +⚠️ 特別注意「**從未推過**」的分支:那是最容易變成失蹤程式碼的一種 + (07-30 t143-t150 十筆、08-02 fix/cis-round3-install 都是這樣被發現的)。 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔀 **散落分支怎麼處理**(leo 2026-08-05:「驗過就要 merge⋯⋯無明顯理由不 merge 就要整理」) + +**它害過什麼**:D36 授權修補(07-29)躺在 fix/cis-round3-install,feat/daemon 沒合併 +⇒ 收卡端全 401、**新卡整整一週一張都沒寫進 youlin**,還害人往「token 過期」查錯方向。 +**分支沒合併不是整潔問題,是會產生「修好了卻沒生效」的假象。** + +每條散落分支擇一,**不准放著不管**: + A. **驗過了 → 合併**(這是預設):git merge 該分支,或 cherry-pick 需要的 commit + B. **還沒驗完 → 說明它在等什麼**,並在回覆裡標「等 X 才能併」 + C. **已作廢 → 刪掉**:git branch -D 該分支(留著只會讓下一個人以為它有效) + +📌 判準是「**這條分支有沒有當前分支缺的東西**」,不是分支數量。 + 合併前先看它改了什麼:git log --oneline 當前..該分支、 + git diff 當前...該分支 --stat + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎯 CP 對帳(leo 2026-08-02:「**現在有 CP 要求到最終用戶會看到更新, + 但還是會出現 commit 沒推,這表示完全忽略了 CP**」) + +CRITICAL-PATH.md 早就寫死: + · 狀態只有三種:✅ 通(**附實測證據**)/◐ 半通(標明缺什麼)/❌ 斷 + · **「程式碼寫完了」不是狀態** + · code 寫完沒部署,不准標 ✅ + +規則寫在那裡卻照樣違反,因為**自評的人跟做事的人是同一個**。所以在這裡問你: + + Q1 這回合改的東西,服務的是哪一條 CP 的哪一步? + Q2 那一步現在是 ✅ / ◐ / ❌?**上面列出的未推項目,就是它還不能算 ✅ 的證據** + Q3 最終用戶現在去看,會看到這次的改動嗎? + 看不到 → 它是 ◐,**不准在回覆裡寫「完成」「修好了」**,要寫「已改,未送達」 + +📌 推完 ≠ 用戶拿得到——中間夾 CDN/bundle/實例快取時, + 必須從**用戶端的網址、用用戶端的參數形式**再驗一次 + (08-02 實例:GitHub 推對了,jsDelivr @main 仍吐三代前的 manifest)。 +EOF +exit 2 diff --git a/hooks/wiki-first-police.sh b/hooks/wiki-first-police.sh new file mode 100755 index 0000000..7061205 --- /dev/null +++ b/hooks/wiki-first-police.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# wiki-first-police.sh — 記憶警察(Stop / SubagentStop hook) +# +# 病根(leo 2026-07-26 兩句原話): +# ①「你應該隨時記錄進 wiki,然後用記憶回答,而不是每次都去實測,除非 wiki 沒做好記錄找不到」 +# ②「你要把不憑記憶回答改成檢查 wiki……不依賴你自己的記憶,那個過 session 就死了」 +# ③「我看 CP 也沒用,因為你執行時沒去更新」 +# +# 兩個病,同一根:**做完不落帳** → 下次(或下個 session)只好重新實測/憑會死的記憶亂答。 +# - 病 A:驗完就往下做,CP/wiki 事後才補 → leo 看 CP 永遠落後現況=「看了也沒用」。 +# - 病 B:被問狀態時憑對話記憶或重跑實測,而不是查 wiki → 過 session 就失憶。 +# +# 為什麼要 hook 而不是寫規則:CLAUDE.md 早就寫了「tasks.md 是唯一進度來源,不靠對話記憶」, +# 照樣違反——自評的人跟做事的人是同一個。同 delivery-police:在「要收工」那刻攔截。 +# +# 機制:這回合有「實測/修好/部署」這類狀態變更 → 檢查有沒有動過 wiki/CP/tasks → 沒動就 exit 2。 +# 豁免:純查詢/純對話回合、已動過帳、撞權限閘。 + +input="$(cat)" + +stop_active="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("stop_hook_active", False)) +except Exception: print(False) +' 2>/dev/null)" +[ "$stop_active" = "True" ] && exit 0 + +transcript="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("transcript_path", "")) +except Exception: print("") +' 2>/dev/null)" +[ -z "$transcript" ] || [ ! -f "$transcript" ] && exit 0 + +# 取本回合(最後一次 user 訊息之後)的所有 assistant 文字 + 工具輸入 +turn="$(python3 -c ' +import sys, json +path = sys.argv[1] +out = [] +try: + with open(path) as f: lines = f.readlines() + # 從尾巴往回走到最近一則 user 訊息為止 + buf = [] + for line in reversed(lines): + try: ev = json.loads(line) + except Exception: continue + msg = ev.get("message", ev) + role = msg.get("role") + if role == "user": + break + if role == "assistant": + content = msg.get("content", "") + if isinstance(content, list): + for b in content: + if not isinstance(b, dict): continue + if b.get("type") == "text": + buf.append(b.get("text", "")) + elif b.get("type") == "tool_use": + buf.append(json.dumps(b.get("input", {}), ensure_ascii=False)) + elif isinstance(content, str): + buf.append(content) + out = list(reversed(buf)) +except Exception: pass +print("\n".join(out)) +' "$transcript" 2>/dev/null)" + +[ -z "$turn" ] && exit 0 + +# ── 豁免一:撞閘/明說停等 → 放行 ── +if printf '%s' "$turn" | grep -qiE '權限閘|classifier|分類器擋|四題第|人閘|denied by'; then + exit 0 +fi + +# ── 這回合有沒有「狀態變更」?(值得落帳的事)── +# 部署、修好、實測通過、端到端驗證、立案——這些都是下次會被問到的狀態。 +if ! printf '%s' "$turn" | grep -qiE '部署|deploy|已上線|修好|修掉|真兇|實測|端到端|驗證通過|全綠|通了|立案'; then + exit 0 +fi + +# ── 豁免〇(2026-08-08 新增):**這回合根本沒有動過任何 repo → 純討論,不必落帳** ── +# +# 🔴 為什麼補這道(leo 08-08 問「為什麼花了一整天」時查出來的): +# 上面那個「有沒有狀態變更」的判斷,比對的是**我回覆裡的散文**。 +# 於是我只要在回答中**談到**「修好/實測/全綠/部署」——例如向 leo 解釋 +# 今天做了什麼、或提出改善流程的建議——就會被判成「有狀態變更卻沒落帳」。 +# ⇒ **把「談論」當成「執行」**。這是同一天第三個同款 bug +# (前兩個在我自己寫的 stage-before-prod-guard:讀腳本、commit 訊息提到關鍵字都被誤攔)。 +# ⇒ 代價很實在:leo 每插話一次,我就被判一次「沒落帳」,於是又寫一段落帳、 +# commit、push。今天 65 個 commit 裡 41 個是落帳,這是其中一個來源。 +# +# 判準改成看**事實**:這 30 分鐘內有沒有任何 repo 真的產生 commit。 +# 沒有 ⇒ 這回合是討論/回答,沒有東西需要被記住,放行。 +ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" +recent_commits=0 +for r in "$ROOT" "$ROOT/products/arcrun-rag" "$ROOT/matrix/arcrun"; do + [ -d "$r/.git" ] || continue + n=$(git -C "$r" log --since="30 minutes ago" --oneline 2>/dev/null | wc -l | tr -d ' ') + recent_commits=$((recent_commits + n)) +done +[ "$recent_commits" -eq 0 ] && exit 0 + +# ── 豁免二:這回合確實動過帳(wiki / CRITICAL-PATH / tasks.md / memory)── +# +# 🔴 同上,這裡原本也是比對散文——**只要回覆裡「提到」wiki 路徑就算落帳**, +# 等於「說了就算做了」。改成先看 git:這 30 分鐘的 commit 有沒有真的動到帳本檔。 +doc_commits=0 +for r in "$ROOT" "$ROOT/products/arcrun-rag" "$ROOT/matrix/arcrun"; do + [ -d "$r/.git" ] || continue + n=$(git -C "$r" log --since="30 minutes ago" --name-only --pretty=format: 2>/dev/null \ + | grep -cE 'system-dev/wiki/|CRITICAL-PATH\.md|critical-paths/|tasks\.md|memory/.*\.md' || true) + doc_commits=$((doc_commits + n)) +done +if [ "$doc_commits" -gt 0 ]; then + # 🔗 落帳了,但有沒有標「這件事牽涉哪些 repo」? + # leo 2026-08-01:「你需要在每次記錄你自己的 wiki 時建立連到發生此事件的 hyperlink 或三元組, + # 每件事牽涉到 2 個 repo,就可以在該事件查到那兩個 repo,細節記錄在該 repo 裡。」 + # 病根:08-01 實測 status.md 今日 34 段落帳中,只有 2 段標題提到 repo、 + # 0 筆有結構化 repo 欄位、僅 2 行三元組 ⇒ 下次仍要重查「這件事在哪」。 + # 只在「跨 repo 事件」時要求(單 repo 的事不必標)。 + if printf '%s' "$turn" | grep -qiE 'arcrun-rag|matrix/arcrun|console-ui|installer|portal|bundle|kbdb|cypher|跨 ?repo'; then + # 🔴 2026-08-08 第二刀:這一段原本也在比對**我的回覆散文**—— + # 於是 wiki 條目裡明明寫了 📍 repo 與三元組,只因為**回覆裡沒提**就被判「沒標 repo」。 + # (早上已把上層觸發條件改看 git,這裡是同一個病漏掉的另一半。) + # ⇒ 改成看**剛剛真的寫進帳本的內容**:近 30 分鐘 commit 新增的行有沒有 repo 指針。 + doc_added="" + for r in "$ROOT" "$ROOT/products/arcrun-rag" "$ROOT/matrix/arcrun"; do + [ -d "$r/.git" ] || continue + doc_added="$doc_added$(git -C "$r" log --since='30 minutes ago' -p --unified=0 \ + -- 'system-dev/wiki/*' 'system-dev/docs/3-specs/*' 2>/dev/null \ + | grep '^+' || true)" + done + # ⚠️ 樣式別寫死 '📍 repo'——實際格式是 `📍 **repo**:`(粗體), + # 寫死會永遠對不上(08-08 實撞:條目明明有指針卻一直被判沒有)。 + if ! printf '%s' "$doc_added$turn" | grep -qE '牽涉 repo|📍|真身在|真兇|位於|\[\[.*\]\]'; then + cat >&2 << 'EOF2' +🔗 記憶警察(連結層):你落帳了,但**沒標這件事牽涉哪些 repo**。 + +【leo 2026-08-01】「你需要在每次記錄你自己的 wiki 時建立連到發生此事件的 hyperlink 或三元組, + **每件事牽涉到 2 個 repo,就可以在該事件查到那兩個 repo**,細節記錄在該 repo 裡。」 + +【為什麼】08-01 實測:status.md 當日 34 段落帳,只有 2 段標題提到 repo、 + **0 筆有結構化 repo 欄位** ⇒ 下次查「這件事在哪」還是要從頭找, + 這就是同一天猜錯真身 3 次的根源。 + +補一行就好(擇一格式,放在該段落帳裡): + 📍 repo:`products/arcrun-rag`(landing/worker.js)+`matrix/arcrun`(console-ui/public/portal/) + 或三元組:`portal 視覺 >> 真身在 >> matrix/arcrun:console-ui/public/portal/index.html` + +原則:**頂層 wiki 記「事件+牽涉哪些 repo」,細節記在該 repo 自己的 wiki**。 +EOF2 + exit 2 + fi + fi + exit 0 +fi + +cat >&2 << 'EOF' +🚓 記憶警察:這回合有狀態變更(實測/修復/部署),但我沒看到你落帳。 + +【病根】leo 2026-07-26: + 「我看 CP 也沒用,因為你執行時沒去更新。」 + 「不依賴你自己的記憶——那個過 session 就死了。」 + +→ 你現在腦裡有的東西,下個 session 一個字都不剩。沒寫進檔案=沒發生過。 + +收工前補完這兩件(擇需要的做,一句話也算): +1. **狀態變更落帳**: + - CP 關卡狀態變了 → 改 `system-dev/docs/3-specs/CRITICAL-PATH.md`(✅/◐/❌+實測輸出) + - 任務進度/新發現 → 改對應 `tasks.md`(含真兇、殘項、下一步) + - 跨 session 要記得的操作知識 → `system-dev/wiki/`(status/mistakes/agent-memory) +2. **下次別再重測**:把「這件事現在是什麼狀態、證據在哪」寫成一句話, + 讓下個 session(或被 leo 問時)**查得到就不用重跑**。 + +⚠️ 落帳=寫檔案,不是寫在回覆裡。回覆會消失,檔案不會。 +EOF +exit 2 diff --git a/hooks/wiki-first-search.sh b/hooks/wiki-first-search.sh new file mode 100755 index 0000000..48f4bc7 --- /dev/null +++ b/hooks/wiki-first-search.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# wiki-first-search.sh — PreToolUse hook:要去翻原文/程式碼前,先把 wiki 命中結果推到眼前 +# +# 病根(2026-07-20 leo 點破,mistakes 第一鐵律): +# 總管 session 開頭讀了 agent-memory 前 50 行就開工,關鍵那條在第 56 行 → 拿過期記憶擋了 leo 三輪。 +# leo:「如果你不是讀而是**搜尋** wiki 就不會只讀 50 行就下定論, +# 而是就像我直接在頁面 cmd+F,那些都會高亮。」 +# +# 設計要點(為什麼是這個形狀): +# 1. **搜尋 ≠ 通讀**:開場 push 全文(session-start-recall.sh)解決不了這題——量大必然只讀開頭。 +# 這支反過來:在「你正要去查 code/原文」的當下,用你自己的關鍵字 grep wiki,只推命中行。 +# 2. **時機是關鍵**:不是開場推、不是寫入時擋,而是**查詢動作發生的那一刻**介入。 +# 3. 🔴 **硬擋,不是提醒**(leo 2026-08-05 改): +# 「改成首先查 wiki,如果 wiki 沒有則放行其他搜尋,要擋」 +# 原版全程 exit 0(只印字),實測擋不住——我照樣一路 curl/grep code, +# 連 wiki 都沒查就下結論,還把 401 誤判給不相干的 commit。 +# 現在:**本輪沒查過 wiki ⇒ exit 2 擋下**;查過(不論有無命中)⇒ 放行。 +# 放行條件刻意寬鬆——目的是「逼你先看一眼 wiki」,不是攔住你查原文。 +# +# 觸發:Grep / Glob / Read 打向 code 或 docs 時(見下方 should_check)。 +# 輸出:stdout 注入 context(命中的 wiki 行 + 檔名:行號)。 +set -euo pipefail + +INPUT=$(cat) +TOOL=$(printf '%s' "$INPUT" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "") + +# 取出這次查詢的關鍵字:Grep 用 pattern,Glob/Read 用路徑的檔名部分 +QUERY=$(printf '%s' "$INPUT" | python3 -c " +import json,sys,os,re +try: + d=json.load(sys.stdin); ti=d.get('tool_input',{}) + q = ti.get('pattern') or '' + if not q: + p = ti.get('file_path') or ti.get('path') or '' + q = os.path.splitext(os.path.basename(p))[0] if p else '' + if not q: + # Bash:2026-07-21 補的破口——原版只掛 Grep|Glob|Read, + # 但「用 curl/wrangler 亂試部署方法」走的是 Bash,整支 hook 不觸發。 + # leo 當場點破:wiki 早記著「寄信已驗證可用」,我卻沒查又自創方法。 + # 只認「會動到外部系統/部署」的高風險指令,避免每個 ls 都洗版。 + cmd = ti.get('command') or '' + # 2026-08-05 再補一個破口(leo 當場點破:「你應該做任何查詢先去查 KBDB, + # 但剛剛這一輪你都沒有查,但 hooks 沒攔阻?」): + # 原本只認「動外部系統」的動詞,但**查證類**指令(git log / grep / unzip / + # 讀 manifest)完全不觸發 ⇒ 我查「release 版本號怎麼算」整輪沒查過 KBDB/wiki。 + # ⇒ 補上查證類動詞。判準:**會讓我形成結論的指令**都該先問記憶, + # 不是只有「會改壞東西的指令」。 + # 2026-08-05 第三次補(leo:「我看你剛剛 bash 裡很多 grep,為什麼不是查 wiki?」): + # 再補「讀檔形成結論」的動詞——head/cat/sed/tail/awk/find/jq。 + # 之前只認「動外部系統」與部分查證動詞,`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): + # 取指令中最具識別度的詞(worker 名/資源名/子命令)當搜尋詞 + cand = re.findall(r'[A-Za-z_][A-Za-z0-9_-]{4,}', cmd) + skip = {'https','http','client','accounts','workers','scripts', + 'application','content','Authorization','Bearer','python3', + 'curl','npx','bash','echo','grep','local','branch','origin'} + cand = [c for c in cand if c not in skip and not c.startswith('-')] + q = max(cand, key=len) if cand else '' + # grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞 + # 🔴 2026-08-05 leo 點破:「最常做的就是 grep,為什麼沒在裡面」 + # ——Grep 其實有註冊,壞在**這行取詞規則**: + # ① 連字號被當分隔 ⇒ `bge-m3` 只取到 `bge`(3 字)不足 4 字 ⇒ 整支不觸發 + # ② **中文完全不匹配** ⇒ 查「版本號」「出貨」這類詞一律不觸發 + # 而我日常查的關鍵字大量正是這兩類 ⇒ hook 形同虛設。 + # ⇒ 容許 `-`/`.`,並支援 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 + print(max(words, key=len) if words else '') +except Exception: + print('') +" 2>/dev/null || echo "") + +[ -z "$QUERY" ] && exit 0 + +WIKI_DIR="system-dev/wiki" +[ -d "$WIKI_DIR" ] || exit 0 + +# 只在「查程式碼/文件」時提醒;查 wiki 本身就不用了(已經在讀了) +TARGET=$(printf '%s' "$INPUT" | python3 -c " +import json,sys +try: + d=json.load(sys.stdin); ti=d.get('tool_input',{}) + print(ti.get('file_path') or ti.get('path') or '') +except Exception: print('') +" 2>/dev/null || echo "") +# ── wiki-first 閘門(leo 2026-08-05)──────────────────────────── +# 記號檔:本輪(本 session)是否已經查過 wiki。放 /tmp 依 session 隔離, +# 新 session 自動重置——每輪工作都要重新先看一眼 wiki。 +STAMP="/tmp/.wiki-first-$(id -u)-${CLAUDE_SESSION_ID:-nosession}" + +# 這次動作**本身就是在查 wiki** → 蓋章放行(之後才允許查別的) +case "$TARGET" in + *system-dev/wiki*) : > "$STAMP"; exit 0 ;; +esac +# Bash 指令裡直接 grep/read wiki 的也算(例:grep -rn xxx system-dev/wiki/) +if printf '%s' "$INPUT" | grep -q "system-dev/wiki"; then + : > "$STAMP"; exit 0 +fi + +# 已經查過 wiki → 放行,後面照舊只做提醒 +if [ ! -f "$STAMP" ]; then + # 🔴 還沒查過 wiki 就想翻原文/打外部系統 → 擋(exit 2) + PRE_HITS=$(grep -rin --include="*.md" -- "$QUERY" "$WIKI_DIR" 2>/dev/null | head -8 || true) + { + echo "════════════════════════════════════════════════" + echo "⛔ 先查 wiki,才准查別的(leo 2026-08-05 立)" + echo "════════════════════════════════════════════════" + printf '你正要查「%s」,但這一輪還沒查過 wiki。\n\n' "$QUERY" + # 🔴 成本順序(leo 2026-08-05):「**成本最低的查就是 KBDB**,整了所有 repo 的 wiki, + # 查詢應該效果最好最簡單,但你會去先查成本高的,甚至查不到後去查源碼,成本很高。」 + # ⇒ 一律 **KBDB 先**(跨全部 repo、語意搜尋、一次呼叫), + # 再 grep 本 repo wiki(只認字面、只有這個 repo),最後才翻源碼。 + echo "查詢成本由低到高——**照順序來,別跳過前面直接翻源碼**:" + echo "" + echo " 1️⃣ KBDB(**最便宜也最強**:整合**所有 repo** 的 wiki,且有三種檢索)" + echo " kbdb_search(q=\"…\", mode=\"semantic\") ← 語意:問句、想不到精確字眼時" + echo " kbdb_search(q=\"…\", mode=\"keyword\") ← 關鍵字:確定有某個詞" + echo " kbdb_graph_neighbors(...) ← 關係:沿「A >> 關係 >> B」展開" + echo " kbdb_get_map() ← 不知道該查哪個庫時先看藏書地圖" + echo "" + echo " 2️⃣ 本 repo wiki(**只有 grep 字面、只有這一個 repo** ⇒ 比 KBDB 弱很多)" + if [ -n "$PRE_HITS" ]; then + echo " ⚠️ **這裡已經有命中了**,先讀這幾行:" + printf '%s\n' "$PRE_HITS" | sed 's|^system-dev/wiki/| |' + else + echo " Grep/Read system-dev/wiki/ 用你自己的關鍵字" + echo " (本閘 grep 沒命中,但它只認字面、搜尋詞還是從你指令猜的)" + fi + echo "" + echo " 3️⃣ 源碼/原文(**最貴**:要讀很多、容易讀到已作廢的實作)" + echo "" + echo "wiki 沒有答案 → 那時再翻原文,本閘就會放行(查過 wiki 即解鎖本輪)。" + } >&2 + exit 2 +fi + +# grep wiki(不分大小寫、含行號),最多 12 行避免洗版 +HITS=$(grep -rin --include="*.md" -- "$QUERY" "$WIKI_DIR" 2>/dev/null | head -12 || true) + +# 🔴 grep 零命中時**不能靜默退出**——那正是今天失敗的模式(2026-07-21): +# grep 查不到 → 以為 wiki 沒記載 → 結論「這件事沒查過」。 +# 但 grep 只認字面,查不到往往只代表「沒猜中用詞」。 +# → 零命中反而是**最該改用語意搜尋**的時刻,必須出聲。 +if [ -z "$HITS" ]; then + echo "════════════════════════════════════════════════" + printf '🔍 grep 在 wiki 找不到「%s」——但這**不代表沒記載**\n' "$QUERY" + echo "════════════════════════════════════════════════" + echo "grep 只認字面,查不到通常只是「沒猜中用詞」。**改用語意搜尋再確認一次**:" + echo " kbdb_search(q=\"<用一句話描述你要找什麼>\", mode=\"semantic\")" + echo " 不知道該查哪個庫 → kbdb_get_map()|要沿關係展開 → kbdb_graph_neighbors()" + echo "" + echo "實例:查「CF 上的 git 託管」時 grep 全零命中,語意搜尋第一筆就命中" + echo "(Cloudflare Artifacts >> 若提供 git 倉庫則可取代 >> Gitea,負責人 15 天前就記了)。" + echo "" + exit 0 +fi + +COUNT=$(printf '%s\n' "$HITS" | wc -l | tr -d ' ') + +echo "════════════════════════════════════════════════" +printf '📚 wiki 已有「%s」的記載(%s 處,先看這裡再翻原文)\n' "$QUERY" "$COUNT" +echo "════════════════════════════════════════════════" +printf '%s\n' "$HITS" | sed 's|^system-dev/wiki/| |' +echo "" +echo "⚠️ wiki 是判準,程式碼與歷史文件只是稿子(mistakes 第一鐵律)。" +echo " • 上面若與你將要查的原文衝突 → **以 wiki 為準**,別用 code 推翻 wiki。" +echo " • 看到「不可動/待廢除/進行中」→ 先讀它的**解除條件**並逐條核對," +echo " 那是當時狀態不是永久禁令;條件已滿足就是可動。" +echo " • wiki 沒答案才值得翻原文——翻完若得到新結論,**回頭更新 wiki**。" +echo "" +echo "🔎 以上是 **grep(最弱的查法)** 的結果,只認字面,且搜尋詞是從你的指令**猜**出來的" +echo " (很可能太籠統而命中一堆無關的,同時漏掉真正的主題詞)。" +echo " **重要判斷一律補一次語意搜尋**——它不需要你猜對用詞:" +echo " kbdb_search(q=\"<一句話描述你要找什麼>\", mode=\"semantic\")" +echo " 實例:查「CF 的 git 託管」時 grep 猜到的詞是 cloudflare → 命中 12 處全無關、" +echo " 真正的答案(Artifacts)一筆沒撈到;語意搜尋第一筆就命中。" +echo "" + +exit 0 diff --git a/hooks/wiki-secret-scan.sh b/hooks/wiki-secret-scan.sh new file mode 100755 index 0000000..91355f0 --- /dev/null +++ b/hooks/wiki-secret-scan.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# PreToolUse hook — 寫入 wiki 前掃機敏資訊(L3 硬攔截) +# +# 為什麼存在:wiki 的 ignore 規則(.wikiignore + 行內標記)是「協議層」,靠 CC 遵守。 +# 但密碼/金鑰/個資外洩是「不可逆」後果——只靠口頭約束太危險。 +# 這支 hook 是機械式底線:CC 真的把機敏資訊寫進 system-dev/wiki/ 的那一刻 → exit 2 擋下。 +# +# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。 +# stdin 收到 JSON:{ tool_name, tool_input: { file_path, content?, new_string? } } +# 行為:只在目標路徑是 system-dev/wiki/** 時啟動,掃要寫入的內容,命中機敏特徵 → exit 2。 +# +# 誠實限制(抄 sdd-guard):regex 偵測有偽陰/偽陽。 +# 擋的是「明顯特徵的機敏字串被自動抄進 wiki」,擋不了刻意混淆/編碼的繞道。 +# 價值是「意外外洩的機械底線 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。 + +set -euo pipefail + +INPUT=$(cat) + +# ── 解析 file_path 與要寫入的內容。優先 jq,無 jq 退回 grep(容錯)────── +if command -v jq >/dev/null 2>&1; then + FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') + # Write 用 content;Edit 用 new_string。兩個都抓,合起來掃。 + CONTENT=$(printf '%s' "$INPUT" | jq -r '[.tool_input.content, .tool_input.new_string] | map(select(. != null)) | join("\n")') +else + FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//') + # 無 jq 時內容解析不可靠(JSON 跳脫),退回掃整包 INPUT,寧可多掃不漏掃 + CONTENT="$INPUT" +fi + +# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺) +[ -z "$FILE_PATH" ] && exit 0 + +# 只管寫進 wiki 的動作。其他路徑放行(這支專責 wiki 洩漏,不是全域 secret scanner) +case "$FILE_PATH" in + *system-dev/wiki/*) ;; + *) exit 0 ;; +esac + +[ -z "$CONTENT" ] && exit 0 + +# 行內豁免:若該段內容已被標記為刻意保留(例:範例文件要示範格式),略過該行 +# 標記:行尾加 # wiki-secret-ok (或 <!-- wiki-secret-ok -->) +# 先把標記過的行抽掉再掃。 +SCAN=$(printf '%s' "$CONTENT" | grep -v -E 'wiki-secret-ok' || true) +[ -z "$SCAN" ] && exit 0 + +# ── 機敏特徵 pattern。一行一類,命中即攔。────────────────────────── +# 設計取捨:偏向高訊號 pattern(有明確結構的金鑰/標記),降低偽陽。 +# 純「password=xxx」這類也納入,因為那正是使用者最擔心的場景。 +HITS="" + +check() { + local label="$1" regex="$2" + # -e 讓以 - 開頭的 pattern(如 PEM 的 -----BEGIN)不被當成選項。 + # grep 無命中回傳 1,在 set -e 下會中止 → 用 if 包住吸收掉。 + if printf '%s' "$SCAN" | grep -qiE -e "$regex"; then + HITS="${HITS} + • ${label}" + fi +} + +# 密碼/密鑰賦值(password = ..., secret: ..., api_key=...) +check "密碼/密鑰賦值 (password/secret/api_key/token = ...)" \ + '(pass(word)?|secret|api[_-]?key|access[_-]?key|auth[_-]?token|priv(ate)?[_-]?key)[[:space:]]*[:=][[:space:]]*[^[:space:]<>"'"'"']{6,}' + +# 私鑰 PEM 區塊 +check "私鑰檔內容 (BEGIN ... PRIVATE KEY)" \ + '-----BEGIN[[:space:]].*PRIVATE KEY-----' + +# 常見雲端/服務金鑰前綴 +check "服務金鑰特徵 (AWS/GitHub/Slack/Google/Stripe 等)" \ + '(AKIA[0-9A-Z]{16}|gh[pousr]_[0-9A-Za-z]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|AIza[0-9A-Za-z_-]{20,}|sk_(live|test)_[0-9A-Za-z]{16,})' + +# JWT +check "JWT token" \ + 'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}' + +# 連線字串內嵌帳密 (proto://user:pass@host) +check "連線字串內嵌帳密 (proto://user:pass@host)" \ + '[a-z][a-z0-9+.-]*://[^[:space:]:/@]+:[^[:space:]:/@]+@' + +# 台灣身分證字號(個資)。BSD/GNU grep 都支援 ERE,避免 \b(BSD 不認),改用字元類邊界。 +check "台灣身分證字號 (個資)" \ + '(^|[^A-Za-z0-9])[A-Z][12][0-9]{8}([^0-9]|$)' + +# 信用卡號(個資,粗略 13-16 連續數字,可含空格/連字號分隔)。避免 PCRE,用 ERE 近似。 +check "疑似信用卡號 (個資)" \ + '(^|[^0-9])[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{4}[ -]?[0-9]{0,4}([^0-9]|$)' + +# Email 不擋(wiki 常需記聯絡人),手機號也不擋(偽陽太高)——刻意留白。 + +if [ -n "$HITS" ]; then + cat >&2 <<EOF +🚫 Wiki 機敏攔截:偵測到可能的機敏資訊要寫進 ${FILE_PATH}。 + +命中特徵:${HITS} + +wiki 是會被 CC 反覆讀取、可能進版控的記憶空間。 +密碼 / 金鑰 / 個資寫進去 = 不可逆外洩風險。 + +請改成下列任一做法: + 1. 不要把機敏值寫進 wiki,改記「位置」(例:「DB 密碼放 1Password / .env,不入 wiki」) + 2. 確定是誤判(例:在示範格式)→ 該行尾加註記 wiki-secret-ok 後重寫 + 3. 整個來源檔本就機敏 → 加進 system-dev/wiki/.wikiignore,別讓它被編入 + +誠實限制:本掃描靠特徵比對,有偽陽/偽陰,是「意外外洩的機械底線」而非保險箱。 +真正的密鑰本就不該進版控。 +EOF + exit 2 +fi + +exit 0 diff --git a/hooks/worklist-guard.sh b/hooks/worklist-guard.sh new file mode 100755 index 0000000..c27ce50 --- /dev/null +++ b/hooks/worklist-guard.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# worklist-guard.sh — Stop hook:**還有未完成的步驟就不准收工寫報告**。 +# +# 🔴 leo 2026-08-08 連續兩次點破: +# 「所以你**沒有維持 loop**,而是停下來浪費了時間」 +# 「**這樣不行,要解決停止 loop 的問題**」 +# +# 病根(誠實的):既有的 self-drive-police 是**事後**勸告——它在我已經停下來之後才響, +# 而我對它的回應是**再寫一段散文**,等於又停一次。**勸告治不了「停」這個動作**, +# 因為停下來寫字正是我回應它的方式。 +# +# 治法:把「還沒做完」變成**環境事實**,讓收工這件事在事實上不成立。 +# · 開一條鏈之前,先把剩下的步驟寫進 /tmp/.worklist-<session_id>(一行一步) +# · 做完一步就把那行刪掉 +# · 只要檔案還有內容 ⇒ 本 hook exit 2 ⇒ **我停不下來,只能繼續做下一步** +# +# 這跟今天立的其他幾道同形狀:**規則被讀到 ≠ 會被執行,要讓環境替我執行。** +# (leo 的母原則:環境邊界取代邏輯判斷。) +# +# 逃生口:鏈真的該中止(撞人閘、前提翻掉、leo 改方向)⇒ 直接刪掉那個檔, +# 並**在回覆裡說明為什麼中止**——留痕,不是默默放掉。 +set -eu + +SID=$(cat 2>/dev/null | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("session_id", "") or "nosid") +except Exception: print("nosid") +' 2>/dev/null || echo nosid) + +WL="/tmp/.worklist-$SID" +[ -f "$WL" ] || exit 0 + +# 空白行不算。 +# 🔴 2026-08-08 自己踩到:原本寫 `grep -c ... || echo 0`——grep 在 0 命中時**回傳碼非零**, +# 於是 `|| echo 0` 也跑,remaining 變成 "0\n0" ⇒ `-eq` 判斷炸掉 ⇒ **清單空了還是擋**。 +# 這是「用 grep 回傳碼當有無」的老坑,改成只認它印出來的數字。 +remaining=$(grep -cvE '^[[:space:]]*$' "$WL" 2>/dev/null | head -1) +[ -z "$remaining" ] && remaining=0 +[ "$remaining" -eq 0 ] && { rm -f "$WL"; exit 0; } + +cat >&2 <<EOF +🔁 Loop 警察:**這條鏈還有 $remaining 步沒做完,現在不是收工的時候。** + +【leo 2026-08-08】「所以你**沒有維持 loop**,而是停下來浪費了時間。」 +【leo 2026-08-08】「**這樣不行,要解決停止 loop 的問題。**」 + +還沒做完的: +$(grep -vE '^[[:space:]]*$' "$WL" | sed 's/^/ · /') + +**不要寫報告,直接做下一步。** 報告等整條鏈跑完再寫一次就好—— +中途每停一次寫一段,就是 leo 說的「停下來浪費時間」。 + +做完一步 ⇒ 從 $WL 刪掉那一行。 +真的要中止(撞人閘/前提翻掉/leo 改方向)⇒ 刪掉整個檔,並在回覆說明為什麼中止。 +EOF +exit 2 diff --git a/scripts/check-bundle-drift.sh b/scripts/check-bundle-drift.sh new file mode 100755 index 0000000..f20b76f --- /dev/null +++ b/scripts/check-bundle-drift.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# check-bundle-drift.sh — 驗「Arcrun 原始碼 vs bundles 鏡像」有沒有漂移(2026-07-28 立) +# +# 解的病(install-flow-map.md §3.8 的誠實缺口):Arcrun main 改了 cypher/portal +# 但忘記重打包 bundle → 新裝用戶拿到舊引擎(07-27 同事 demo 前夕連線全斷的根因)。 +# 之前只靠流程紀律,本腳本把它變機械可查。 +# +# 用法:scripts/check-bundle-drift.sh <Arcrun repo 路徑> <bundles clone 路徑> +# 例: scripts/check-bundle-drift.sh /private/tmp/wt-arcrun <scratchpad>/bundles-push2 +# 出口碼:0=無漂移;1=有漂移(列出哪個件);2=用法/環境錯 +set -euo pipefail +cd "$(dirname "$0")/.." +ARCRUN="${1:?用法:$0 <Arcrun路徑> <bundles clone路徑>}" +BUNDLES="${2:?缺 bundles clone 路徑}" +[ -d "$ARCRUN/console-ui" ] || { echo "❌ $ARCRUN 不像 Arcrun repo"; exit 2; } +[ -f "$BUNDLES/manifest.json" ] || { echo "❌ $BUNDLES 沒有 manifest.json"; exit 2; } +DRIFT=0 + +echo "① UI(console-ui/public → tier2/ui)" +node products/arcrun-rag/installer/scripts/build-ui-bundle.mjs \ + --arcrun "$ARCRUN" --out /tmp/drift-ui.js >/dev/null +A=$(shasum -a 256 /tmp/drift-ui.js | cut -d' ' -f1) +B=$(shasum -a 256 "$BUNDLES/tier2/ui/index.js" | cut -d' ' -f1) +if [ "$A" = "$B" ]; then echo " ✅ 一致($(echo $A | cut -c1-12))" +else echo " 🔴 漂移:原始碼 $(echo $A | cut -c1-12) ≠ 鏡像 $(echo $B | cut -c1-12) → 重跑 build-ui-bundle.mjs 推鏡像"; DRIFT=1; fi + +echo "② cypher(cypher-executor → tier2/cypher)" +( cd "$ARCRUN/cypher-executor" && npx wrangler deploy --dry-run --outdir /tmp/drift-cypher >/dev/null 2>&1 ) \ + || { echo " ⚠️ cypher dry-run 失敗(依賴沒裝?)"; DRIFT=1; } +if [ -f /tmp/drift-cypher/index.js ]; then + A=$(shasum -a 256 /tmp/drift-cypher/index.js | cut -d' ' -f1) + B=$(shasum -a 256 "$BUNDLES/tier2/cypher/index.js" | cut -d' ' -f1) + if [ "$A" = "$B" ]; then echo " ✅ 一致($(echo $A | cut -c1-12))" + else echo " 🔴 漂移:原始碼 $(echo $A | cut -c1-12) ≠ 鏡像 $(echo $B | cut -c1-12) → 重建 cypher 推鏡像"; DRIFT=1; fi +fi + +echo "②b 其餘 tier2(kbdb/registry/mcp——07-28 真實案例:kbdb bundle 缺 /entries/libraries=庫目錄靜默失效)" +for t2 in kbdb registry mcp; do + ( cd "$ARCRUN/$t2" && npx wrangler deploy --dry-run --outdir /tmp/drift-$t2 >/dev/null 2>&1 ) || { echo " ⚠️ $t2 dry-run 失敗"; DRIFT=1; continue; } + A=$(shasum -a 256 /tmp/drift-$t2/index.js | cut -d' ' -f1) + B=$(shasum -a 256 "$BUNDLES/tier2/$t2/index.js" | cut -d' ' -f1) + if [ "$A" = "$B" ]; then echo " ✅ $t2 一致($(echo $A | cut -c1-12))" + else echo " 🔴 $t2 漂移:原始碼 $(echo $A | cut -c1-12) ≠ 鏡像 $(echo $B | cut -c1-12)"; DRIFT=1; fi +done + +echo "③ daemon(collector 產物 vs 鏡像 zip)" +for z in ArcrunRAG-mac-unsigned.zip; do + L="products/arcrun-rag/collector/cmd/arcrun-tray/$z" + [ -f "$L" ] && [ -f "$BUNDLES/daemon/$z" ] || continue + A=$(shasum -a 256 "$L" | cut -d' ' -f1); B=$(shasum -a 256 "$BUNDLES/daemon/$z" | cut -d' ' -f1) + if [ "$A" = "$B" ]; then echo " ✅ $z 一致" + else echo " 🔴 $z 漂移(本地重建過沒推?)"; DRIFT=1; fi +done + +echo "④ manifest 內 sha vs 檔案實體" +python3 - "$BUNDLES" <<'PY' +import json,sys,hashlib,os +b=sys.argv[1]; m=json.load(open(os.path.join(b,'manifest.json'))); bad=0 +for c in m['core']: + p=os.path.join(b,c['main_file']) + real=hashlib.sha256(open(p,'rb').read()).hexdigest() + if real!=c['sha256']: + print(f" 🔴 {c['name']}: manifest sha ≠ 檔案實體"); bad=1 +print(" ✅ 27 件 manifest sha 全對" if not bad else "", end="\n" if not bad else "") +sys.exit(bad) +PY +[ $? -ne 0 ] && DRIFT=1 + +[ $DRIFT -eq 0 ] && echo "✅ 無漂移" || echo "🔴 有漂移——照上面指示重建後推鏡像(arm)+釘 commit+部署安裝器" +exit $DRIFT diff --git a/scripts/check-deploy-drift.sh b/scripts/check-deploy-drift.sh new file mode 100755 index 0000000..24b2143 --- /dev/null +++ b/scripts/check-deploy-drift.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# check-deploy-drift.sh — 線上/Gitea/本機三方版本對帳(2026-07-30 立) +# +# 為什麼存在:07-30 雲端總管看 Gitea 判「線上 919ed39 的程式碼失蹤」,實情是 +# t143-t150 十筆 commit 只在本機沒 push——雲端缺「本機領先 Gitea 幾筆」的視野就會誤報。 +# 本腳本讓任何 session(含雲端)一條命令看清三方是否一致;有落差 exit 1。 +# +# 用法:scripts/check-deploy-drift.sh +# 需要 GITEA_TOKEN(環境變數,或頂層 .env 有就自動吸)。 +# 雲端 checkout 沒有 products/(gitignore)→ 本機欄自動降級成「—」,只比前兩欄。 +set -euo pipefail +cd "$(dirname "$0")/.." +TOP="$(pwd)" + +GITEA="https://git.uncle6.me/api/v1/repos/Leo/arcrun-rag/raw" +INSTALLER_REF="${INSTALLER_REF:-fix/t75-remove-config-card}" +LANDING_REF="${LANDING_REF:-feat/daemon}" +LOCAL_REPO="$TOP/products/arcrun-rag" + +if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$TOP/.env" ]; then + set -a; source "$TOP/.env" 2>/dev/null || true; set +a +fi +[ -n "${GITEA_TOKEN:-}" ] || { echo "❌ 缺 GITEA_TOKEN(環境變數或頂層 .env)"; exit 2; } + +VER_PAT='20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]+[0-9a-f]\{7\}' + +# 從 worker.js 內容推導安裝器版本號(BUNDLE_BUILT + '+' + 釘碼前 7 碼,同 worker.js 顯示邏輯) +derive_installer_ver() { + local built pin + built="$(printf '%s' "$1" | grep -o "BUNDLE_BUILT = '[^']*'" | head -1 | sed "s/.*'\(.*\)'/\1/" || true)" + pin="$(printf '%s' "$1" | grep -o 'arcrun-rag-bundles@[0-9a-f]*' | head -1 | cut -d@ -f2 | cut -c1-7 || true)" + { [ -n "$built" ] && [ -n "$pin" ]; } && printf '%s+%s' "$built" "$pin" || printf '?' +} + +# ── 線上 ── +LIVE_INSTALLER="$(curl -s -m 25 "https://install.arcrun.dev/?cb=$RANDOM" | grep -o "$VER_PAT" | head -1 || true)" +LIVE_LANDING="$(curl -s -m 25 "https://rag.arcrun.dev/?cb=$RANDOM" | grep -o "$VER_PAT" | head -1 || true)" + +# ── Gitea ── +G_WORKER="$(curl -s -m 25 "$GITEA/installer/oauth-prototype/worker.js?ref=$INSTALLER_REF" -H "Authorization: token $GITEA_TOKEN" || true)" +GITEA_INSTALLER="$(derive_installer_ver "$G_WORKER")" +GITEA_LANDING="$(curl -s -m 25 "$GITEA/landing/wrangler.toml?ref=$LANDING_REF" -H "Authorization: token $GITEA_TOKEN" \ + | grep -o 'SITE_BUNDLE_VERSION = "[^"]*"' | sed 's/.*"\(.*\)"/\1/' || true)" + +# ── 本機(沒有 products/ 就降級)── +if [ -d "$LOCAL_REPO/.git" ]; then + L_WORKER="$(git -C "$LOCAL_REPO" show "$INSTALLER_REF:installer/oauth-prototype/worker.js" 2>/dev/null || true)" + LOCAL_INSTALLER="$(derive_installer_ver "$L_WORKER")" + LOCAL_LANDING="$(git -C "$LOCAL_REPO" show "$LANDING_REF:landing/wrangler.toml" 2>/dev/null \ + | grep -o 'SITE_BUNDLE_VERSION = "[^"]*"' | sed 's/.*"\(.*\)"/\1/' || true)" + # 本機另一種漂移:commit 了沒推(07-30 事故本尊) + UNPUSHED_I="$(git -C "$LOCAL_REPO" log --oneline "gitea/$INSTALLER_REF..$INSTALLER_REF" 2>/dev/null | wc -l | tr -d ' ' || true)" + UNPUSHED_L="$(git -C "$LOCAL_REPO" log --oneline "gitea/$LANDING_REF..$LANDING_REF" 2>/dev/null | wc -l | tr -d ' ' || true)" + HAS_LOCAL=1 +else + LOCAL_INSTALLER="—"; LOCAL_LANDING="—"; UNPUSHED_I=""; UNPUSHED_L="" + HAS_LOCAL=0 + echo "(本機沒有 products/arcrun-rag——雲端模式,只比線上 vs Gitea)" +fi + +printf '%-10s %-20s %-20s %-20s\n' "" "線上" "Gitea" "本機" +printf '%-10s %-20s %-20s %-20s\n' "installer" "${LIVE_INSTALLER:-?}" "${GITEA_INSTALLER:-?}" "${LOCAL_INSTALLER:-?}" +printf '%-10s %-20s %-20s %-20s\n' "landing" "${LIVE_LANDING:-?}" "${GITEA_LANDING:-?}" "${LOCAL_LANDING:-?}" + +DRIFT=0 +chk() { # chk 名稱 線上 gitea 本機 + local name="$1" live="$2" gitea="$3" local_="$4" + [ -n "$live" ] && [ "$gitea" != "?" ] && [ "$live" != "$gitea" ] && { echo "❌ $name:線上($live) ≠ Gitea($gitea)"; DRIFT=1; } + if [ "$HAS_LOCAL" = "1" ]; then + [ "$local_" != "?" ] && [ "$gitea" != "?" ] && [ "$local_" != "$gitea" ] && { echo "❌ $name:本機($local_) ≠ Gitea($gitea)——改了沒推?"; DRIFT=1; } + fi + true +} +chk installer "${LIVE_INSTALLER:-}" "${GITEA_INSTALLER:-?}" "${LOCAL_INSTALLER:-?}" +chk landing "${LIVE_LANDING:-}" "${GITEA_LANDING:-?}" "${LOCAL_LANDING:-?}" +[ -n "$UNPUSHED_I" ] && [ "$UNPUSHED_I" != "0" ] && { echo "❌ installer 分支有 $UNPUSHED_I 筆 commit 未推上 Gitea"; DRIFT=1; } +[ -n "$UNPUSHED_L" ] && [ "$UNPUSHED_L" != "0" ] && { echo "❌ landing 分支有 $UNPUSHED_L 筆 commit 未推上 Gitea"; DRIFT=1; } + +[ "$DRIFT" = "1" ] && { echo "⚠️ 有漂移(見上)"; exit 1; } + +# 抓不到 ≠ 驗證通過(假綠禁令):任何欄位是 ? 就不准報 ✅ +if [ -z "${LIVE_INSTALLER:-}" ] || [ -z "${LIVE_LANDING:-}" ] || [ "$GITEA_INSTALLER" = "?" ] || [ -z "${GITEA_LANDING:-}" ]; then + echo "⚠️ 有欄位抓不到(網路/權限問題)=無法驗證,不算通過;重跑一次試試"; exit 2 +fi +echo "✅ 無漂移" diff --git a/scripts/component-arm.sh b/scripts/component-arm.sh new file mode 100755 index 0000000..70b82a7 --- /dev/null +++ b/scripts/component-arm.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# component-arm.sh — 顯式解 component-guard 保險(限時 30 分)。 +# 只有人類該跑:確認這個「建零件 / service binding」真的過了 docs/component-pr-review-standard.md 才解。 +# 仿 scripts/github-arm.sh:寫時間戳到 .component-armed,component-guard 檢查 30 分內放行。 +PROJ="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +echo "⚠️ 你正在解除『建零件/加 service binding』保險(D27/D28)。" +echo " 先確認:現成積木(零件+code 節點+cypher workflow)真的不夠?且過 component-pr-review-standard.md?" +read -r -p "確認要解保險 30 分鐘?(yes/N) " a +[ "$a" = "yes" ] || { echo "取消。"; exit 1; } +date +%s > "$PROJ/.component-armed" +echo "✅ 已解保險 30 分鐘。期間 component-guard 放行建零件/service-binding,並留痕。用完自動失效。" diff --git a/scripts/daemon-selfcheck.py b/scripts/daemon-selfcheck.py new file mode 100755 index 0000000..31eeb19 --- /dev/null +++ b/scripts/daemon-selfcheck.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""daemon-selfcheck.py — 桌面小幫手(daemon)本機狀態自檢,唯讀。 + +為什麼有這支(leo 2026-08-19): + 「現在本機的資料夾跟雲端已經不相連了。」 + 這句話有好幾種可能的形狀,而它們的處置完全不同: + ① 帳號指的那台實例被重裝過(網址沒變、鑰匙換了) ← arcrun-rag#103 + ② 監看資料夾在本機被搬走/改名/刪掉 + ③ 資料夾是空的,雲端因此「什麼都沒有」 ← arcrun-rag#106 + ④ 真的連不上網路 + 這支不猜,只把**分得出這四種**的事實印出來。 + +🔴 唯讀:不寫任何檔、不連網、不印任何金鑰(api_key/gemini_api_key 一律過濾)。 + +用法: + python3 scripts/daemon-selfcheck.py # 看 ~/.arcrun-rag/ + python3 scripts/daemon-selfcheck.py /some/home # 指定家目錄(測試用) +""" +import json,glob,os,sys +H=os.path.expanduser(sys.argv[1] if len(sys.argv)>1 else "~") +D=os.path.join(H,".arcrun-rag") +SECRET={"api_key","gemini_api_key","password","token"} +def acc(a): + return {k:v for k,v in a.items() if k not in SECRET and not isinstance(v,(dict,list))} +print("狀態目錄:",D, "存在" if os.path.isdir(D) else "❌ 不存在") +try: + c=json.load(open(os.path.join(D,"config.json"))) +except Exception as e: + print("config.json 讀不到:",e); c={} +accs=c.get("accounts") or ([{k:c.get(k) for k in ("cypher_url","namespace","email","instance_name")}] if c.get("cypher_url") else []) +print("\n帳號 %d 個:"%len(accs)) +for a in accs: print(" -",acc(a)) +print("\n監看資料夾:") +for f in (c.get("watch_folders") or ([c["watch_folder"]] if c.get("watch_folder") else [])): print(" -",f, "(本機存在)" if os.path.isdir(os.path.expanduser(f)) else "(❌ 本機找不到)") +for a in accs: + for f in (a.get("watch_folders") or []): print(" -",f,"[帳號 %s]"%a.get("instance_name",""), "(本機存在)" if os.path.isdir(os.path.expanduser(f)) else "(❌ 本機找不到)") +print("\n機器身分:") +try: print(" ",json.load(open(os.path.join(D,"machine.json")))) +except Exception as e: print(" machine.json 沒有或讀不到:",e,"(⇒ 這台還沒鑄過機器 ID,或 daemon 還沒跑過 0.18.33)") +print("\nmanifest 的最後錯誤(每份只印一種):") +for p in sorted(glob.glob(os.path.join(D,"manifest*.json"))): + try: m=json.load(open(p)) + except Exception as e: print(" ",os.path.basename(p),"讀不到",e); continue + errs={} + for k,v in (m.get("entries") or {}).items(): + e=(v or {}).get("last_error") or "" + if e: errs[e]=errs.get(e,0)+1 + print(" ",os.path.basename(p),"root=",m.get("root"),"檔數=",len(m.get("entries") or {})) + if not errs: print(" (無錯誤)") + for e,n in sorted(errs.items(),key=lambda x:-x[1])[:3]: print(" x%d %s"%(n,e[:160])) diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh new file mode 100755 index 0000000..cc395ef --- /dev/null +++ b/scripts/deploy-web.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# deploy-web.sh — install/landing 的唯一部署入口(2026-07-28 立) +# +# 為什麼存在:leo 問「以後會出現改了沒推的問題嗎?」誠實答案=文字規範會被忘, +# 只有機制可靠。本腳本把「驗語法→部署→驗版本有變→抓線上→記錄」焊成一步—— +# 用它部署,就不可能出現 t79 那種「commit 了但部署失敗沒人發現」。 +# +# 用法:scripts/deploy-web.sh installer|landing +# 部署狀態記錄在 system-dev/docs/4-guides/deploy-state.json(誰、何時、版本、檔案 sha) +# ⇒ 「改了沒推」隨時可查:比對該檔現在的 sha 與記錄裡的 sha。 +set -euo pipefail +cd "$(dirname "$0")/.." +TOP="$(pwd)" +STATE="$TOP/system-dev/docs/4-guides/deploy-state.json" + +TARGET="${1:-}" +# 部署源可用 DEPLOY_SRC 覆蓋(07-30:舊寫法寫死 scratchpad 且靜默 fallback, +# scratchpad 是 session 專屬目錄,被清掉後部署源就斷——事故傳導路徑,故必印出本次部署源) +SCRATCH="${DEPLOY_SRC:-/private/tmp/claude-501/-Users-youlinhsieh-Documents-tech-projects-InkStoneCo/92a75156-c295-4c79-bfeb-de1a20e4ed26/scratchpad}" +case "$TARGET" in + installer) + DIR="$SCRATCH/rag-installer/installer/oauth-prototype" + if [ ! -d "$DIR" ]; then + echo "⚠️ 部署源 $DIR 不存在(scratchpad 被清?),改用 git 工作樹那份" + DIR="$TOP/products/arcrun-rag/installer/oauth-prototype" + [ -d "$DIR" ] || { echo "❌ 備援部署源也不存在(products 的 checkout 不在含 oauth-prototype 的分支);用 DEPLOY_SRC= 指定部署源"; exit 1; } + fi + FILE="$DIR/worker.js"; CFG="--config ./wrangler.toml"; URL="https://install.arcrun.dev/" ;; + landing) + DIR="$TOP/products/arcrun-rag/landing" + FILE="$DIR/worker.js"; CFG=""; URL="https://rag.arcrun.dev/" ;; + *) echo "用法:$0 installer|landing" >&2; exit 2 ;; +esac +echo "▶ 本次部署源=$DIR" + +echo "① esbuild 驗語法(node --check 抓不到 template literal 內的錯,t79 教訓)" +# --external:cloudflare:* = Workers runtime 內建模組(如 cloudflare:email), +# esbuild 不認識它們但 wrangler 認得;不標 external 會誤判成語法錯(07-29 landing 踩到)。 +( cd "$DIR" && npx esbuild worker.js --bundle --format=esm --outfile=/dev/null \ + --external:cloudflare:* --external:./migrations.json --external:./workflows.json 2>/dev/null \ + || npx esbuild worker.js --bundle --format=esm --outfile=/dev/null --external:cloudflare:* ) + +echo "①a 釘死 URL 必須真的存在(07-29 事故:釘碼用短碼拼湊出不存在的 commit,安裝全 404)" +if [ "$TARGET" = "installer" ]; then + BASE=$(grep -o "https://cdn.jsdelivr.net/gh/[^']*" "$FILE" | head -1) + CODE=$(curl -s -m 30 -o /dev/null -w '%{http_code}' "$BASE/manifest.json") + [ "$CODE" = "200" ] || { echo "❌ BUNDLE_BASE 指向的 manifest 回 $CODE(釘碼錯或 bundle 沒推):$BASE"; exit 1; } + echo " $BASE/manifest.json → 200" +fi + +echo "①b 文案契約測試(防「改好的又改錯」——禁句出現=拒絕部署)" +if [ -f "$DIR/copy-contract.test.mjs" ]; then ( cd "$DIR" && node copy-contract.test.mjs ); fi + +echo "①c 防回退閘(07-30:版本號變動必須是人明示的決定,不能是部署源掉包的副作用)" +LIVE_VER="$(curl -s -m 25 "${URL}?cb=$RANDOM" | grep -o '20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]+[0-9a-f]\{7\}' | head -1 || true)" +if [ "$TARGET" = "installer" ]; then + NEXT_BUILT="$(grep -o "BUNDLE_BUILT = '[^']*'" "$FILE" | head -1 | sed "s/.*'\(.*\)'/\1/" || true)" + NEXT_PIN="$(grep -o 'arcrun-rag-bundles@[0-9a-f]*' "$FILE" | head -1 | cut -d@ -f2 | cut -c1-7 || true)" + { [ -n "$NEXT_BUILT" ] && [ -n "$NEXT_PIN" ]; } && NEXT_VER="${NEXT_BUILT}+${NEXT_PIN}" || NEXT_VER="" +else + NEXT_VER="$(grep -o 'SITE_BUNDLE_VERSION = "[^"]*"' "$DIR/wrangler.toml" 2>/dev/null | sed 's/.*"\(.*\)"/\1/' || true)" +fi +if [ -n "$LIVE_VER" ] && [ -n "$NEXT_VER" ] && [ "$LIVE_VER" != "$NEXT_VER" ] && [ "${ALLOW_VERSION_CHANGE:-}" != "1" ]; then + echo "❌ 版本號會變:線上 $LIVE_VER → 要部署 $NEXT_VER" + echo " 換版是人的決定:確定要換,重跑時帶 ALLOW_VERSION_CHANGE=1" + exit 1 +fi +echo " 線上 ${LIVE_VER:-(抓不到)} / 待部 ${NEXT_VER:-(推導不出)}$([ "${ALLOW_VERSION_CHANGE:-}" = "1" ] && echo '(已明示換版)')" + +echo "①d 部署源必須落在 git(07-30:t143-t150 十筆只在本機沒推、雲端誤判程式碼失蹤的教訓)" +PENDING_GIT=false +if git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + DIRTY="$(git -C "$DIR" status --porcelain -- "$(basename "$FILE")" | head -1 || true)" + UNPUSHED="$(git -C "$DIR" log --oneline '@{u}..HEAD' 2>/dev/null | wc -l | tr -d ' ' || true)" + [ -n "$DIRTY" ] && { echo " ⚠️⚠️ $(basename "$FILE") 有未 commit 修改——部署的東西 git 裡沒有!"; PENDING_GIT=true; } + if [ "$UNPUSHED" = "" ] || ! git -C "$DIR" rev-parse '@{u}' >/dev/null 2>&1; then + echo " ⚠️⚠️ 所在分支沒設 upstream——commit 了也沒人推得到 Gitea!"; PENDING_GIT=true + elif [ "$UNPUSHED" != "0" ]; then + echo " ⚠️⚠️ 有 $UNPUSHED 筆 commit 未推上 Gitea(git log @{u}..HEAD)"; PENDING_GIT=true + fi + $PENDING_GIT || echo " 部署源乾淨且已同步 Gitea ✓" +else + echo " ⚠️⚠️ 部署源不在任何 git 工作樹裡——這份程式碼沒有版控!"; PENDING_GIT=true +fi +# 封測期不擋出貨,但留痕:pending_git=true =「線上跑的和 Gitea 不一致」查得到 +STATE_PATH="$STATE" python3 - "$TARGET" "$PENDING_GIT" <<'PY' +import json,sys,os +p=os.environ.get('STATE_PATH'); t,pg=sys.argv[1],sys.argv[2]=='true' +try: d=json.load(open(p)) +except Exception: d={} +d.setdefault(t,{})['pending_git']=pg +json.dump(d,open(p,'w'),ensure_ascii=False,indent=2) +PY + +echo "② 部署(uncle6)" +set -a; source "$TOP/.env" 2>/dev/null || true; set +a +OUT="$(cd "$DIR" && CLOUDFLARE_ACCOUNT_ID=58309bb90fd93ad6d0fe0aae99170e9d npx wrangler deploy $CFG 2>&1)" +VID="$(printf '%s' "$OUT" | grep -o 'Current Version ID: [a-f0-9-]*' | awk '{print $4}')" +[ -n "$VID" ] || { echo "❌ 部署失敗(沒有 Version ID):"; printf '%s\n' "$OUT" | tail -8; exit 1; } +echo " Version ID: $VID" + +echo "③ 版本必須有變(防『部署了但還是舊版』)" +PREV="$(python3 -c " +import json,sys +try: print(json.load(open('$STATE')).get('$TARGET',{}).get('version','')) +except Exception: print('')" 2>/dev/null)" +if [ "$VID" = "$PREV" ] && [ -n "$PREV" ]; then + echo "❌ Version ID 與上次相同($VID)=內容沒變或部署被跳過"; exit 1 +fi + +echo "④ 線上實測(帶 cache-buster)" +CODE="$(curl -s -m 25 -o /tmp/deploy_check.html -w '%{http_code}' "${URL}?cb=$RANDOM")" +[ "$CODE" = "200" ] || { echo "❌ 線上回 $CODE"; exit 1; } +echo " $URL → 200($(wc -c </tmp/deploy_check.html | tr -d ' ') B)" + +echo "⑤ 記錄(供 drift 檢查:檔案 sha ≠ 記錄 sha = 改了沒推)" +SHA="$(shasum -a 256 "$FILE" | cut -d' ' -f1)" +STATE_PATH="$STATE" python3 - "$TARGET" "$VID" "$SHA" <<'PY' +import json,sys,datetime,os +p=os.environ.get('STATE_PATH') +t,v,sha=sys.argv[1],sys.argv[2],sys.argv[3] +try: d=json.load(open(p)) +except Exception: d={} +d[t]={'version':v,'file_sha256':sha,'deployed_at':datetime.datetime.now().isoformat(timespec='seconds')} +json.dump(d,open(p,'w'),ensure_ascii=False,indent=2) +print(f" {t}: {v} / sha {sha[:16]}") +PY +echo "✅ 完成。之後查「改了沒推」:shasum 該檔 vs $STATE" diff --git a/scripts/git-bundle-backup.sh b/scripts/git-bundle-backup.sh new file mode 100755 index 0000000..f00ab79 --- /dev/null +++ b/scripts/git-bundle-backup.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# B1 備援三件套之二:每日 git bundle 備份(de-Gitea brief B1) +# 對本機所有 repo:git fsck 驗完整 → git bundle 打包全部 refs → 存 ~/Backups/git-bundles/ +# R2 上傳段:等 youlin 帳號 R2 開通後補(TODO 標記處)。保留最近 7 份。 +set -uo pipefail + +ROOT="/Users/youlinhsieh/Documents/tech_projects/InkStoneCo" +DEST="$HOME/Backups/git-bundles" +STAMP=$(date +%Y%m%d) +KEEP=7 +mkdir -p "$DEST" + +REPOS=(. matrix/arcrun matrix/arcrun-components matrix/arcrun-gui matrix/arcrun-mcp + matrix/inkstone-admin matrix/kbdb-graph-plugin matrix/kbdb-ingest-plugin + products/arcrun-rag products/dev-finally-click products/finally-click products/u6u-studio + polaris/AI-Meka polaris/OpenHarness polaris/mira arcrun_harness) + +fail=0 +for r in "${REPOS[@]}"; do + dir="$ROOT/$r" + [ -d "$dir/.git" ] || { echo "SKIP $r (no .git)"; continue; } + name=$(basename "$(cd "$dir" && pwd)") + [ "$r" = "." ] && name="InkStoneCo" + + if ! git -C "$dir" fsck --no-progress --no-dangling >/dev/null 2>&1; then + echo "❌ FSCK FAIL $r"; fail=1; continue + fi + out="$DEST/${name}-${STAMP}.bundle" + if git -C "$dir" bundle create "$out" --all >/dev/null 2>&1; then + echo "✅ $name $(du -h "$out" | cut -f1)" + else + echo "❌ BUNDLE FAIL $r"; fail=1 + fi + # 保留最近 KEEP 份 + ls -t "$DEST/${name}-"*.bundle 2>/dev/null | tail -n +$((KEEP+1)) | xargs rm -f 2>/dev/null +done + +# TODO(R2):youlin 帳號 R2 開通後,在此加 rclone/wrangler 上傳 $DEST 至獨立 bucket(與 git server 不同桶) +echo "---" +echo "bundles at $DEST" +exit $fail diff --git a/scripts/gitea-arm-check.sh b/scripts/gitea-arm-check.sh new file mode 100755 index 0000000..42dc1fa --- /dev/null +++ b/scripts/gitea-arm-check.sh @@ -0,0 +1,211 @@ +#!/bin/bash +# gitea-arm-check.sh — 核對 leo 有沒有在「該請求指定的那張票」上回覆某個 ARM 請求的代碼 +# +# 用法: +# scripts/gitea-arm-check.sh # 掃描所有還沒過期的本地待核請求(可能橫跨多張票) +# scripts/gitea-arm-check.sh ARM-xxxxxxxx # 只核對這一組 +# +# 判定放行的三個條件,**缺一不可**: +# ① 這組 nonce 從沒被消耗過(防重放)、且還沒過期(範圍與時效)——本地就能判,不必碰網路 +# ② 留言作者的 login 精確等於 "Leo"(不是 id、不是顯示名——那些會變) +# ③ 留言內文含這組 nonce,且晚於「機器貼出請求」的那則留言、且出現在**該請求貼出時指定的那張票** +# (票號隨每個請求存在本地待核檔的 `issue` 欄位裡,2026-08-16 起不再是寫死的單一頻道票, +# 見 lib/gitea-arm-common.sh 檔頭;別的票再怎麼有 Leo 回過相似字串的留言都不算數, +# 因為根本不會被拿去查——每個 nonce 只查它自己那張票) +# +# 成功:印 "ARMED: <mission>"、把 nonce 標記已消耗、刪掉本地待核檔、exit 0(單次用完即失效) +# 失敗(沒有/過期/被消耗過/Gitea 打不到/回應解不出來):印原因到 stderr、exit 1 +# ——**fail-closed**:任何看不懂的狀況都當失敗,不放行。 +# +# 🔴 順序刻意是「先本地過濾,才打網路」: +# 過期/已消耗這兩種本地就能判定,**不該因為 Gitea 打不到而連本地清理都做不了** +# (早期版本把網路呼叫放最前面,測試才抓到:token 失效時,過期的待核檔永遠不會被清掉, +# 因為程式在走到「清掉它」那行之前就已經因為 fail-closed 提前 exit 了)。 +# +# 🔴 這支只在「機器需要解閘的當下」被閘呼叫一次——不是排程輪詢(D20 紅線)。 +# 呼叫方一次只打一發 GET **對每一張還有待核請求的票**(不同票各打一次, +# 同一張票不管上面掛幾組 nonce 只打一次、共用回應),不建任何迴圈/背景行程來等 leo 回覆。 +set -uo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck source=lib/gitea-arm-common.sh +. "$SCRIPT_DIR/lib/gitea-arm-common.sh" + +STATE_DIR=$(gitea_arm_state_dir) +PENDING_DIR="$STATE_DIR/pending" +# ── --peek:只看有沒有核准,**不消耗**(leo 2026-08-16 立)─────────────────── +# +# 🔴 為什麼要有這個模式(同日實撞,而且是「發生過沒解決,下次又踩」的那種): +# 總管為了「先確認 leo 貼了碼」單獨跑了一次本檔 ⇒ **那一次就把核准消耗掉了** +# ⇒ 真正要用的 gitea-arm-to-github-armed.sh 再來拿時已經沒有待核請求 +# ⇒ **leo 的動作被浪費一次,他得再貼一次。** +# +# leo 原話:「這不是問題,問題是**已經發生好幾次了,你發生過以後沒有解決,下次還是碰到**。 +# 你可以註記只能一次,要不然**改成可以一次檢查一次發射**。」 +# ⇒ 選後者:**檢查不消耗,發射才消耗。** +# +# ⛔ 防重放在 peek 模式下**照樣生效**——已被消耗過的 nonce peek 也不會回 ARMED, +# 否則 peek 就變成繞過重放保護的後門。 +# 🔴 2026-08-16 第二輪(leo 當場點破,同日第一輪的修是錯方向): +# leo 原話:「**你檢查是否有貼可以用查看 issue 的 API 不應該會用掉,所以你查看的方式不對。**」 +# ⇒ 讀留言是 GET,本來就不該有副作用。「消耗」是本檔自己黏在讀取上的**本地**記帳。 +# ⇒ 所以正解不是「加一個 --peek 選配」(第一輪那樣做=預設仍然咬人, +# 只有記得加旗標的人不被咬),而是**把預設反過來**: +# +# 不帶旗標 = 只讀,永不消耗 ← 任何人手動探一眼都安全 +# --consume = 真的要發射,才消耗 ← 只有「放行那一步」自己會帶 +# +# 這也正是 mistakes.md:237 兩天前就寫著的「待修:check 應該要有唯讀模式」, +# 以及 231 行「想知道 leo 貼了沒 → 讀票,不要用 check 去探」。 +# **寫下來了,08-16 還是照撞一次** ⇒ 所以改成機制上撞不到,不是再寫一條提醒。 +# +# ⛔ 防重放不變:已消耗過的 nonce 在唯讀模式下**照樣不回 ARMED**, +# 否則唯讀就變成繞過重放保護的後門。 +# +# 🐛 第一輪的實際 bug(也在這裡一起修):舊碼 `WANT_NONCE="${1:-}"` 會把 `--peek` +# 當成「要篩的 nonce」吃掉 ⇒ 一個都不匹配 ⇒ 靜靜回「沒有還活著的待核請求」。 +# ⇒ 旗標與 nonce 現在在**同一個迴圈**裡分開解析,旗標永遠不會被當成 nonce。 +CONSUME=0 +WANT_NONCE="" +for _a in "$@"; do + case "$_a" in + --consume) CONSUME=1 ;; + --peek) : ;; # 相容舊呼叫;現在唯讀本來就是預設,這個旗標等同不做事 + -*) echo "❌ 不認得的參數:$_a(只接受 --consume/--peek/<nonce>)" >&2; exit 1 ;; + *) [ -z "$WANT_NONCE" ] && WANT_NONCE="$_a" ;; + esac +done + +CONSUMED_LOG=$(gitea_arm_consumed_log) +NOW=$(date +%s) + +shopt -s nullglob +PENDING_FILES=("$PENDING_DIR"/*.json) +shopt -u nullglob + +if [ "${#PENDING_FILES[@]}" -eq 0 ]; then + echo "沒有待核的 ARM 請求(先跑 scripts/gitea-arm-request.sh)" >&2 + exit 1 +fi + +# ── 第一遍:純本地過濾,完全不碰網路 ────────────────────────────────── +# 篩出「還活著、還沒被用過」的候選;順手清掉壞掉/過期/已消耗的本地檔。 +# 🔴 不用 `declare -A`(本機 /bin/bash 是 3.2,沒有關聯陣列)—— +# 改用一個 TSV 暫存檔存 nonce/issue/request_created_at/mission,逐行讀。 +# 每個請求各自帶著自己的票號(issue 欄位,2026-08-16 起不再是單一頻道票)。 +ELIGIBLE_TSV=$(mktemp) +RESP_DIR=$(mktemp -d) +trap 'rm -f "$ELIGIBLE_TSV"; rm -rf "$RESP_DIR"' EXIT +for PF in "${PENDING_FILES[@]}"; do + [ -f "$PF" ] || continue + + NONCE=$(jq -r '.nonce // empty' "$PF" 2>/dev/null) + if [ -z "$NONCE" ]; then + echo "⚠️ 略過壞掉的待核檔:$PF" >&2 + continue + fi + if [ -n "$WANT_NONCE" ] && [ "$NONCE" != "$WANT_NONCE" ]; then + continue + fi + + MISSION=$(jq -r '.mission // empty' "$PF" 2>/dev/null) + ISSUE=$(jq -r '.issue // empty' "$PF" 2>/dev/null) + # 2026-08-16:請求可能貼在同 org 的別的 repo(出貨票在 arcrun-rag)。 + # 舊記錄沒有 .repo 這欄 → 沿用預設 InkStoneCo,不破壞相容。 + _PR=$(jq -r '.repo // empty' "$PF" 2>/dev/null) + [ -n "$_PR" ] && { gitea_arm_set_repo "$_PR" || continue; } + EXPIRES_AT=$(jq -r '.expires_at // empty' "$PF" 2>/dev/null) + REQUEST_CREATED_AT=$(jq -r '.request_created_at // empty' "$PF" 2>/dev/null) + + if ! gitea_arm_valid_issue "$ISSUE"; then + echo "⚠️ 略過壞掉的待核檔(issue 缺失或非數字,$PF)——這份請求檔是舊版格式或損毀,不猜票號" >&2 + continue + fi + case "$EXPIRES_AT" in ''|*[!0-9]*) echo "⚠️ 略過壞掉的待核檔(expires_at 非數字):$PF" >&2; continue;; esac + if [ -z "$REQUEST_CREATED_AT" ]; then + echo "⚠️ 略過壞掉的待核檔(缺 request_created_at):$PF" >&2 + continue + fi + + # 過期:清掉本地待核檔(沒被用過,不算消耗),繼續看下一個 + if [ "$NOW" -ge "$EXPIRES_AT" ]; then + echo "⌛ $NONCE(#$ISSUE)已過期,清掉待核請求" >&2 + rm -f "$PF" + continue + fi + + # 防重放:這個 nonce 先前是否已被消耗過(即使 Gitea 上那則留言還在,也不能再解一次) + if [ -f "$CONSUMED_LOG" ] && grep -qF "$(printf '%s\t' "$NONCE")" "$CONSUMED_LOG" 2>/dev/null; then + echo "🚫 $NONCE(#$ISSUE)已經被用過一次,不能重放" >&2 + rm -f "$PF" + continue + fi + + printf '%s\t%s\t%s\t%s\n' "$NONCE" "$ISSUE" "$REQUEST_CREATED_AT" "$MISSION" >> "$ELIGIBLE_TSV" +done + +if [ ! -s "$ELIGIBLE_TSV" ]; then + echo "沒有還活著、還沒用過的待核請求 → 不放行" >&2 + exit 1 +fi + +TOKEN=$(gitea_arm_token) || { echo "❌ 讀不到 GITEA_TOKEN_CLAUDE_CODE → fail-closed,不放行" >&2; exit 1; } + +# ── 第二遍:對每一張出現在 ELIGIBLE_TSV 的票各打一次 GET(同票只打一次,跨票各自獨立)── +for ISSUE in $(cut -f2 "$ELIGIBLE_TSV" | sort -u); do + RESP=$(curl -s -w '\n%{http_code}' --max-time 15 \ + -H "Authorization: token $TOKEN" \ + "$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE/comments?limit=100") + CURL_RC=$? + HTTP_CODE=$(printf '%s' "$RESP" | tail -1) + RESP_BODY=$(printf '%s' "$RESP" | sed '$d') + + if [ "$CURL_RC" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then + echo "❌ #$ISSUE:Gitea 打不到/回應非 200(curl_rc=$CURL_RC, http=$HTTP_CODE)→ 該票所有待核請求 fail-closed" >&2 + continue + fi + if ! printf '%s' "$RESP_BODY" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "❌ #$ISSUE:Gitea 回應不是預期的陣列格式 → 該票所有待核請求 fail-closed" >&2 + continue + fi + printf '%s' "$RESP_BODY" > "$RESP_DIR/$ISSUE.json" +done + +# ── 第三遍:逐一比對,每組 nonce 只看它自己那張票抓回來的留言 ────────────── +RESULT=1 +while IFS=$'\t' read -r NONCE ISSUE AFTER MISSION; do + [ -n "$NONCE" ] || continue + + RESP_FILE="$RESP_DIR/$ISSUE.json" + if [ ! -f "$RESP_FILE" ]; then + echo "…$NONCE(#$ISSUE):該票打不到,不放行" >&2 + continue + fi + + # 核心判定:作者精確是 Leo、內文含這組 nonce、時間晚於請求留言—— + # 且比對只在 $RESP_FILE 這一張票的留言範圍內,不會混到別張票 + MATCH=$(jq -r \ + --arg nonce "$NONCE" --arg after "$AFTER" --arg who "$GITEA_ARM_APPROVER_LOGIN" ' + [ .[] | select(.user.login == $who) | select(.body | contains($nonce)) | select(.created_at > $after) ] + | sort_by(.created_at) | .[0].id // empty + ' "$RESP_FILE") + + PF="$PENDING_DIR/$NONCE.json" + if [ -n "$MATCH" ]; then + if [ "$CONSUME" -eq 1 ]; then + printf '%s\t%s\t%s\t#%s\n' "$NONCE" "$(date '+%Y-%m-%d %H:%M:%S')" "$MISSION" "$ISSUE" >> "$CONSUMED_LOG" + rm -f "$PF" + echo "ARMED: $MISSION" + else + # 預設:只讀,不消耗——待核請求留著,等真正要放行的那一步帶 --consume 才用掉 + echo "ARMED(唯讀): $MISSION" + echo " ⚠️ 唯讀模式,**核准還在**(沒有被用掉)。真正要放行的那一步會自己帶 --consume。" >&2 + fi + RESULT=0 + [ -n "$WANT_NONCE" ] && break + else + echo "…$NONCE(#$ISSUE)還沒等到 Leo 的回覆" >&2 + fi +done < "$ELIGIBLE_TSV" + +exit $RESULT diff --git a/scripts/gitea-arm-request.sh b/scripts/gitea-arm-request.sh new file mode 100755 index 0000000..9564c7d --- /dev/null +++ b/scripts/gitea-arm-request.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# gitea-arm-request.sh — 開一個「等 leo 在 Gitea 上回覆」的請求 +# +# 用法:scripts/gitea-arm-request.sh <票號> "任務描述" [有效分鐘,預設30,上限60] +# +# 這支**machine 自己就能跑**(不像 scripts/github-arm.sh 要求終端機互動)—— +# 因為「請求」本身不是安全邊界,**leo 用 Leo 帳號回覆才是**。 +# 這支只負責:生一組一次性代碼、貼上呼叫端指定的那張 Gitea 票、把代碼與人話訊息印出來 +# (讓呼叫者拿去用 notify_leo/Telegram 轉告 leo;本支不碰 Telegram)。 +# +# 🪦 2026-08-16 起票號不再寫死(原本固定貼 inkstone/InkStoneCo#34「頻道票」)—— +# leo:「我不要把所有的票都放在一個 issues,不然就難 track 歷史記錄」。 +# **解哪張票的保險,就把請求貼在那張票上**:要出貨 X 就傳 X 那張票的號碼, +# 不要再統一貼 #34(#34 保留 open 當歷史,見 issues/34#issuecomment-2804)。 +# +# 之後由 scripts/gitea-arm-check.sh 核對 leo 有沒有在同一張票回覆同一組代碼。 +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck source=lib/gitea-arm-common.sh +. "$SCRIPT_DIR/lib/gitea-arm-common.sh" + +ISSUE="${1:-}" +MISSION="${2:-}" +MINUTES="${3:-30}" +if [ -z "$ISSUE" ] || [ -z "$MISSION" ]; then + echo "用法:scripts/gitea-arm-request.sh <票號> \"任務描述\" [分鐘,預設30,上限60]" >&2 + echo "例:scripts/gitea-arm-request.sh 41 \"出 prod:xxx\"" >&2 + exit 1 +fi +# 票號可寫 `N`(預設 InkStoneCo)或 `repo#N`(同 org 的別的 repo,例:arcrun-rag#115) +case "$ISSUE" in + *"#"*) + _R="${ISSUE%%#*}"; ISSUE="${ISSUE##*#}" + gitea_arm_set_repo "$_R" || exit 1;; +esac +if ! gitea_arm_valid_issue "$ISSUE"; then + echo "❌ 票號要是純數字,或 repo#N(收到:$ISSUE)——解哪張票的保險就填那張票" >&2 + exit 1 +fi +case "$MINUTES" in + ''|*[!0-9]*) + echo "❌ 第三個參數要純數字分鐘(收到:$MINUTES)" >&2 + exit 1;; +esac +[ "$MINUTES" -le 60 ] || MINUTES=60 +[ "$MINUTES" -ge 1 ] || MINUTES=1 + +TOKEN=$(gitea_arm_token) || { echo "❌ 讀不到 GITEA_TOKEN_CLAUDE_CODE(頂層 .env)" >&2; exit 1; } + +NONCE="ARM-$(python3 -c 'import secrets; print(secrets.token_hex(4))')" +NOW=$(date +%s) +EXPIRES=$((NOW + MINUTES * 60)) +EXPIRES_HUMAN=$(date -r "$EXPIRES" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -d "@$EXPIRES" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$EXPIRES") + +STATE_DIR=$(gitea_arm_state_dir) +PENDING_FILE="$STATE_DIR/pending/${NONCE}.json" + +# ── 貼留言到呼叫端指定的那張票 ───────────────────────────────────────── +COMMENT_BODY=$(MISSION="$MISSION" NONCE="$NONCE" EXPIRES_HUMAN="$EXPIRES_HUMAN" MINUTES="$MINUTES" python3 -c ' +import os +mission = os.environ["MISSION"] +nonce = os.environ["NONCE"] +expires_human = os.environ["EXPIRES_HUMAN"] +minutes = os.environ["MINUTES"] +print(f"""🔐 ARM 請求 + +任務:{mission} +有效時限:{minutes} 分鐘內({expires_human} 前) + +📱 回這則、貼上下面這串就解鎖這一次(用完就失效,別的請求不能借用): +{nonce} +""") +') + +REQUEST_JSON=$(jq -n --arg body "$COMMENT_BODY" '{body: $body}') + +RESP=$(curl -s -w '\n%{http_code}' -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$REQUEST_JSON" \ + "$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE/comments") +HTTP_CODE=$(printf '%s' "$RESP" | tail -1) +RESP_BODY=$(printf '%s' "$RESP" | sed '$d') + +if [ "$HTTP_CODE" != "201" ]; then + echo "❌ 貼留言失敗(HTTP $HTTP_CODE),沒有建立請求——票號 #$ISSUE 存在嗎?" >&2 + echo "$RESP_BODY" >&2 + exit 1 +fi + +COMMENT_ID=$(printf '%s' "$RESP_BODY" | jq -r '.id // empty') +CREATED_AT=$(printf '%s' "$RESP_BODY" | jq -r '.created_at // empty') + +if [ -z "$COMMENT_ID" ] || [ -z "$CREATED_AT" ]; then + echo "❌ Gitea 回應解不出留言 id/時間,視為失敗(fail-closed,沒寫本地狀態)" >&2 + exit 1 +fi + +jq -n \ + --arg nonce "$NONCE" \ + --arg mission "$MISSION" \ + --arg issue "$ISSUE" \ + --arg repo "$GITEA_ARM_REPO" \ + --argjson requested_at "$NOW" \ + --argjson expires_at "$EXPIRES" \ + --arg request_comment_id "$COMMENT_ID" \ + --arg request_created_at "$CREATED_AT" \ + '{nonce:$nonce, mission:$mission, issue:$issue, repo:$repo, requested_at:$requested_at, expires_at:$expires_at, + request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \ + > "$PENDING_FILE" + +# ── 貼完請求就指派給 leo + 掛 Human(leo 2026-08-16:「沒有指派,沒有加 Human」)── +# +# 為什麼要自動:總管同一天才立下「指派給 leo 的每一張都要真的在等他」, +# 貼完 ARM 就忘了做 ⇒ leo 的「指派給您的」清單漏掉那張真正在等他的票。 +# ⇒ 這件事不該靠人記得:**貼出 ARM 請求 = 這張票此刻在等 leo**,兩者是同一件事。 +_LBL=$(curl -s -H "Authorization: token $TOKEN" \ + "$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/labels?limit=60" \ + | jq -r '.[] | select(.name=="Human") | .id' 2>/dev/null | head -1) +_CUR=$(curl -s -H "Authorization: token $TOKEN" \ + "$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE" \ + | jq -r '[.labels[].id] | @csv' 2>/dev/null | tr -d '"') +if [ -n "$_LBL" ]; then + _IDS=$(printf '%s,%s' "${_CUR:-}" "$_LBL" | tr ',' '\n' | grep -E '^[0-9]+$' | sort -u | paste -sd, -) + curl -s -o /dev/null -X PUT -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"labels\":[${_IDS}]}" \ + "$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE/labels" +fi +curl -s -o /dev/null -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"assignees\":[\"$GITEA_ARM_APPROVER_LOGIN\"]}" \ + "$GITEA_ARM_API/repos/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE" +echo " (已指派給 $GITEA_ARM_APPROVER_LOGIN 並掛上 Human——他的「指派給您的」看得到這張)" + +echo "✅ 已在 Gitea 貼出請求:https://git.uncle6.me/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE" +echo "" +echo "── 轉告 leo(照這樣發,一句狀況+一個可回的詞)──────────────────" +echo "[總管] 需要你解一道鎖:$MISSION" +echo "回這則 Gitea 留言貼「$NONCE」就好($MINUTES 分鐘內有效):https://git.uncle6.me/$GITEA_ARM_OWNER/$GITEA_ARM_REPO/issues/$ISSUE" +echo "──────────────────────────────────────────────────────────────" +echo "" +echo "nonce=$NONCE" +echo "issue=$ISSUE" +echo "expires_at=$EXPIRES ($EXPIRES_HUMAN)" +echo "本地請求檔:$PENDING_FILE" diff --git a/scripts/gitea-arm-status.sh b/scripts/gitea-arm-status.sh new file mode 100755 index 0000000..5b20c98 --- /dev/null +++ b/scripts/gitea-arm-status.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# gitea-arm-status.sh — 列出目前還「活著」的 ARM 待核請求(純讀、不打網路、不消耗任何東西) +# +# 🔴 為什麼存在(2026-08-13 實撞): +# 總管在測試 gitea-arm 機制時,為了重置測試狀態隨手 `rm -rf .claude/gitea-arm`, +# 把剛請 leo 處理的那筆真請求也一起清掉了——leo 差點拿一組已經失效的代碼去回覆。 +# **叫人做事然後把前提刪掉,浪費的是他的注意力**,而那正是這整套系統最該省的東西。 +# +# ⇒ 規約:**發出請求後,不准動該請求的狀態檔** +# (不 `rm -rf .claude/gitea-arm/`、不手動刪 `pending/*.json`、不改內容)。 +# 讓它自然被 `scripts/gitea-arm-check.sh` 消耗,或自然過期。 +# 真的要清「已知作廢」的單一 nonce,也只刪那一個檔,不要整個目錄清空。 +# +# ⇒ 這支的存在本身就是防呆:**清東西前先看看有沒有還活著的請求**, +# 別在不知道自己清了什麼的狀態下動那個目錄。 +set -uo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck source=lib/gitea-arm-common.sh +. "$SCRIPT_DIR/lib/gitea-arm-common.sh" + +STATE_DIR=$(gitea_arm_state_dir) +PENDING_DIR="$STATE_DIR/pending" +NOW=$(date +%s) + +shopt -s nullglob +PENDING_FILES=("$PENDING_DIR"/*.json) +shopt -u nullglob + +if [ "${#PENDING_FILES[@]}" -eq 0 ]; then + echo "(沒有待核請求,這個目錄現在動它是安全的)" + exit 0 +fi + +echo "🔴 下面這些請求還活著——動 .claude/gitea-arm/ 之前先看這份清單:" +echo "" +for PF in "${PENDING_FILES[@]}"; do + NONCE=$(jq -r '.nonce // "?"' "$PF" 2>/dev/null) + MISSION=$(jq -r '.mission // "?"' "$PF" 2>/dev/null) + ISSUE=$(jq -r '.issue // "?"' "$PF" 2>/dev/null) + EXPIRES_AT=$(jq -r '.expires_at // 0' "$PF" 2>/dev/null) + case "$EXPIRES_AT" in ''|*[!0-9]*) EXPIRES_AT=0;; esac + EXPIRES_HUMAN=$(date -r "$EXPIRES_AT" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -d "@$EXPIRES_AT" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "?") + if [ "$NOW" -ge "$EXPIRES_AT" ]; then + STATUS="⌛ 已過期(下次 check 會自動清掉,不必手動動它)" + else + STATUS="⏳ 還在等 Leo 回覆($EXPIRES_HUMAN 前有效)" + fi + echo " $NONCE(#$ISSUE)— $MISSION" + echo " $STATUS" +done diff --git a/scripts/gitea-arm-to-github-armed.sh b/scripts/gitea-arm-to-github-armed.sh new file mode 100755 index 0000000..6efe5ae --- /dev/null +++ b/scripts/gitea-arm-to-github-armed.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# gitea-arm-to-github-armed.sh — 把「leo 在 Gitea 票上的核准」轉成出貨線認得的 `.github-armed` +# +# 🔴 這支存在的理由(2026-08-13 實撞): +# arm 機制上線當天,總管把它接到了 `main-and-prod-push-guard.sh`(推 main 那道閘), +# **但出貨線 `ship.mjs` 的 preflight 只認舊的 `.github-armed`** +# ⇒ leo 在票上回了碼、`gitea-arm-check.sh` 回 ARMED,**出貨照樣斷在第 1 站**。 +# ⇒ 總管審 PR 時查了「機制本身安不安全」,**沒查「它有沒有接到真正擋路的地方」**。 +# 同款形狀:做好了一半,而那一半不是會擋住人的那半。 +# +# 🔴 這支**不是**繞過 D20,它是同一道閘的另一個入口: +# `.github-armed` 代表的權威來自「leo 親自做了一個機器做不到的動作」。 +# 舊入口=他在終端機打字;新入口=他在 Gitea 票上回覆一組碼。 +# **兩者的不可偽造性來源不同,但都不在機器手上**: +# · 終端機:機器碰不到那個鍵盤 +# · Gitea:機器寫不出作者是 `Leo` 的留言(2026-08-13 身分分離完成,總管親手攻擊驗過) +# +# ⛔ **唯一的安全條件**:本檔**只在 `gitea-arm-check.sh` 成功時**才寫出保險。 +# 那支是 fail-closed 的(沒回覆/過期/已消耗/作者不是 Leo/Gitea 不通 → 全部 exit 1), +# 而且核准者與頻道票**寫死在 `lib/gitea-arm-common.sh`**,環境變數改不動。 +# ⇒ 拿掉那個條件,這支就變成「AI 自己批准自己發佈」——**那正是 D20 要擋的事**。 +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ARMED="$ROOT/.github-armed" + +# ── 唯一的授權來源:leo 在 Gitea 票上的回覆 ──────────────────────────── +# 不捕捉、不吞錯——check 失敗就讓它把原因印到 stderr 並中止本支。 +# 🔴 `--consume` 是必要的(2026-08-16 起 check 預設唯讀):本檔會**寫出保險**, +# 那就是「放行」本身 ⇒ 這一次核准必須當場作廢,否則同一組碼能重複解保險。 +OUT=$("$ROOT/scripts/gitea-arm-check.sh" --consume) || { + echo "" >&2 + echo "⛔ 沒有取得 leo 的核准 ⇒ 不寫保險(fail-closed)" >&2 + echo " 先跑:scripts/gitea-arm-request.sh <票號> \"<要做什麼>\",把代碼給 leo 回在那張票上。" >&2 + exit 1 +} + +# `.github-armed` 的格式(照 scripts/github-arm.sh:93): +# 第 1 行:到期 epoch/第 2 行:任務說明/第 3 行:armed_at=… +MISSION=$(printf '%s' "$OUT" | sed -n 's/^ARMED: //p' | head -1) +[ -n "$MISSION" ] || MISSION="(gitea-arm 核准,未帶說明)" + +NOW=$(date +%s) +EXPIRY=$((NOW + 1800)) # 30 分鐘,與 gitea-arm-request 的時效一致 + +printf '%s\n%s\n%s\n' \ + "$EXPIRY" \ + "$MISSION" \ + "armed_at=$(date '+%Y-%m-%d %H:%M:%S')(來源:Gitea 票 leo 核准,非終端機)" \ + > "$ARMED" + +echo "✅ 保險已解(來源:leo 在 Gitea 票上的核准)" +echo " 任務:$MISSION" +echo " 有效至:$(date -r "$EXPIRY" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "$EXPIRY")" +echo " 提前上保險:rm $ARMED" diff --git a/scripts/gitea-bootstrap.sh b/scripts/gitea-bootstrap.sh new file mode 100755 index 0000000..487e832 --- /dev/null +++ b/scripts/gitea-bootstrap.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# gitea-bootstrap.sh — 新 repo 開通工作管理元件(狀態標籤)。冪等,重跑無害。 +# +# 為什麼是腳本不是一段叮嚀(leo 2026-08-09:「每到一個新的 repo 就要建立這些管理元件」): +# 靠人/靠 AI 記得 = 必然漂移。今天一天就抓到三條「規則寫了沒人驗」。 +# label 是 repo-scoped、沒有跨 repo 繼承 ⇒ 每個 repo 都要建一次 ⇒ 必須一行做完。 +# +# 用法(在該 repo 目錄下跑,或用 -r 指定): +# scripts/gitea-bootstrap.sh # 用當前 repo 的 gitea remote +# scripts/gitea-bootstrap.sh -r Leo/some-repo # 指定 repo(token 仍從當前 repo 取) +# +# milestone 不在這裡建——milestone = sprint = 一次交貨,每個 repo 的交貨內容不同, +# 不能預設。建法見 /issue-handle skill。 +set -eu + +REPO="" +[ "${1:-}" = "-r" ] && { REPO="${2:?-r 後面要接 owner/repo}"; } + +REMOTE=$(git remote get-url gitea 2>/dev/null) || { + echo "✗ 當前目錄沒有名為 gitea 的 remote。請 cd 到目標 repo,或先加 remote。" >&2; exit 1; } + +TOKEN=$(printf '%s' "$REMOTE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') +[ "$TOKEN" = "$REMOTE" ] && { echo "✗ gitea remote URL 裡沒有 token,無法取得認證。" >&2; exit 1; } +HOST=$(printf '%s' "$REMOTE" | sed -E 's|.*@([^/]+)/.*|\1|') +[ -n "$REPO" ] || REPO=$(printf '%s' "$REMOTE" | sed -E 's|.*://[^/]+/(.+)\.git$|\1|') + +echo "→ repo: $REPO host: $HOST" + +# 狀態機(互斥 scope label)。順序=出貨流程: +# s/todo → s/doing → s/stage →(leo 蓋章 + arm + 推 prod)→ closed +# 🔴 不建 s/done:closed 就是 done,多一個就是同一件事兩個真相。 +# 🔴 s/triage 存在的理由:**「留白」查不出來**。撈得到 s/todo,卻撈不到 +# 「所有還沒驗傷的」——沒有標籤不是一種狀態,是查詢的死角 +# (leo 2026-08-09 建 Triage 看板時暴露的設計缺陷)。 +# p/ 是另一個軸:s/ 答「走到哪」、p/ 答「多重要」—— +# s/backlog 的東西也可以是 p/high,併成一組就表達不出來。 +set -- \ + 's/triage|d4c5f9|新進來的,還沒驗傷——還沒決定要不要做' \ + 's/backlog|c2e0c6|驗過了、確定要做,但還沒排進任何 sprint(wishlist/功能需求/待規劃)' \ + 's/todo|ededed|已排進 sprint,等開工' \ + 's/doing|0e8a16|進行中——現在有人在做' \ + 's/stage|5319e7|已推上 stage,等 leo 去 youlin 的 stage 環境驗收(出貨流程第⑤步)' \ + 's/pending|fbca04|卡住——等外部/等人,不是沒人做' \ + 'p/high|b60205|高——擋住交付或有時間壓力' \ + 'p/low|bfd4f2|低——想做,但晚一點沒關係' + +# 🔴 先抓現有清單再建。**Gitea 允許同名 label、回 201 不是 422** +# ⇒ 靠「重複會被擋」達成冪等是錯的。2026-08-09 實撞:本腳本第一版 +# 在 arcrun-rag 造出每個標籤各兩份——那會直接弄壞互斥狀態機(同名兩個 id, +# 貼哪一個都不會把另一個頂掉),事後手動刪掉四個重複 id 才救回來。 +EXISTING=$(curl -s -H "Authorization: token $TOKEN" -H "Cache-Control: no-cache" \ + "https://$HOST/api/v1/repos/$REPO/labels?limit=100" \ + | python3 -c "import json,sys;print(' '.join(l['name'] for l in json.load(sys.stdin)))") + +created=0; existed=0; failed=0 +for spec in "$@"; do + name=${spec%%|*}; rest=${spec#*|}; color=${rest%%|*}; desc=${rest#*|} + case " $EXISTING " in *" $name "*) echo " · 已存在 $name"; existed=$((existed+1)); continue ;; esac + payload=$(NAME="$name" COLOR="$color" DESC="$desc" python3 -c ' +import json,os +print(json.dumps({"name":os.environ["NAME"],"color":"#"+os.environ["COLOR"], + "description":os.environ["DESC"],"exclusive":True}))') + code=$(printf '%s' "$payload" | curl -s -o /tmp/.bootstrap-out -w '%{http_code}' \ + -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + --data-binary @- "https://$HOST/api/v1/repos/$REPO/labels") + case "$code" in + 201) echo " ✓ 建立 $name"; created=$((created+1)) ;; + 422) echo " · 已存在 $name"; existed=$((existed+1)) ;; + *) echo " ✗ $name → HTTP $code: $(head -c 120 /tmp/.bootstrap-out)"; failed=$((failed+1)) ;; + esac +done + +# 複驗:從 repo 端讀回來,不信自己送出的指令(2026-08-09 教訓:宣告 vs 證據) +echo "→ 複驗(從 repo 讀回):" +curl -s -H "Authorization: token $TOKEN" -H "Cache-Control: no-cache" \ + "https://$HOST/api/v1/repos/$REPO/labels?limit=100" \ + | python3 -c "import json,sys;print(' ',sorted(l['name'] for l in json.load(sys.stdin) if l['name'].startswith('s/')))" + +echo "→ 新建 $created/已存在 $existed/失敗 $failed" +[ "$failed" -eq 0 ] || exit 1 diff --git a/scripts/github-arm.sh b/scripts/github-arm.sh new file mode 100755 index 0000000..ff4d1ca --- /dev/null +++ b/scripts/github-arm.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# github-arm.sh — 解除 GitHub 接觸保險(D20 發射鈕,leo 親手跑,AI 不得代跑) +# 用法:scripts/github-arm.sh "任務描述" [有效分鐘,預設30,上限60] +set -euo pipefail + +if [ ! -t 0 ]; then + echo "❌ 本腳本必須由人類在終端機互動執行(防 AI 代按發射鈕)。" >&2 + exit 1 +fi + +MISSION="${1:-}" +MINUTES="${2:-30}" +if [ -z "$MISSION" ]; then + echo "用法:scripts/github-arm.sh \"任務描述\" [分鐘]" >&2 + exit 1 +fi +# 第二參數必須是純數字分鐘。2026-08-01 leo 實撞:AI 給的指令把中文註解寫在同一行, +# 被當成分鐘數 → 下方 $(( )) 算術炸掉 → EXPIRY unbound、保險沒解成卻看似執行過。 +case "$MINUTES" in + ''|*[!0-9]*) + echo "❌ 第二個參數要純數字分鐘(收到:$MINUTES)" >&2 + echo " 正確:scripts/github-arm.sh \"任務描述\" 30" >&2 + exit 1;; +esac +if [ "$MINUTES" -gt 60 ]; then MINUTES=60; fi +if [ "$MINUTES" -lt 1 ]; then MINUTES=1; fi + +cd "$(dirname "$0")/.." + +# ── 搬遷預覽(arcrun-rag#27,leo:「解保險前要看得到這次會搬什麼」)────────── +# 純讀本機已存在的檔案(ship.targets.json + stage/prod 兩份 manifest.json), +# 完全不碰網路——此刻保險還沒解,不能在這裡先觸網。 +# 找不到這些檔就安靜略過(本腳本不是只給出貨用,其他 GitHub 任務沒有這些檔是正常的)。 +ARCRUN_RAG_TARGETS="products/arcrun-rag/installer/ship.targets.json" +STAGE_BUNDLE_DIR="/private/tmp/arcrun-rag-bundles-staging" +PROD_BUNDLE_DIR="/private/tmp/arcrun-rag-bundles" +if [ -f "$ARCRUN_RAG_TARGETS" ] && [ -f "$STAGE_BUNDLE_DIR/manifest.json" ] && [ -f "$PROD_BUNDLE_DIR/manifest.json" ]; then + echo "═══════════════════════════════════════════" + echo "📦 搬遷預覽(只讀本機檔案,尚未碰網路)" + echo "═══════════════════════════════════════════" + python3 - "$ARCRUN_RAG_TARGETS" "$STAGE_BUNDLE_DIR" "$PROD_BUNDLE_DIR" <<'PYEOF' || echo "(搬遷預覽讀檔失敗,略過——不影響下面的保險流程)" +import json, os, sys +targets_json, stage_dir, prod_dir = sys.argv[1], sys.argv[2], sys.argv[3] +cfg = json.load(open(targets_json)) +prod = cfg.get("targets", {}).get("prod", {}) +remote = prod.get("bundles", {}).get("remote", "(未知)") +branch = prod.get("bundles", {}).get("branch", "main") +stage = json.load(open(os.path.join(stage_dir, "manifest.json"))) +prodm = json.load(open(os.path.join(prod_dir, "manifest.json"))) +print(f"搬到哪:{remote}(branch {branch})") +print(f"版本:prod 目前 {prodm.get('release')} → stage 現在是 {stage.get('release')}") +stage_core = {c.get("name"): c.get("sha256", "") for c in stage.get("core", [])} +prod_core = {c.get("name"): c.get("sha256", "") for c in prodm.get("core", [])} +changed = [n for n in prod_core if stage_core.get(n) != prod_core.get(n)] +same = [n for n in prod_core if n in stage_core and stage_core.get(n) == prod_core.get(n)] +print(f"prod 管的 {len(prod_core)} 顆裡,內容會變的:{len(changed)} 顆") +for n in changed: + print(f" - {n}") +if same: + print(f"內容沒變(sha 相同):{len(same)} 顆 — {', '.join(same)}") +has_local_clone = os.path.isdir(os.path.join(prod_dir, ".git")) +est = "1 次(push;沿用已存在的本地 clone,不需再 clone)" if has_local_clone \ + else "2 次(clone 1 + push 1;本地還沒有這個 clone)" +print(f"預估碰 GitHub 幾次:{est}") +print("(不含 jsDelivr purge——那是 CDN 快取清除,不算 GitHub 接觸,ROE 不計)") +PYEOF + echo "" +else + echo "(找不到本地 stage/prod bundle 的 manifest.json,略過搬遷預覽——非出貨類任務屬正常)" + echo "" +fi + +echo "═══════════════════════════════════════════" +echo "🚀 GitHub 接觸儀式 — 發射前檢查(ROE)" +echo "═══════════════════════════════════════════" +echo "任務:$MISSION" +echo "時效:$MINUTES 分鐘(到期自動回保險)" +echo "" +echo "交戰規則(每條都要守):" +echo " □ 單一 repo,不跨 repo fan-out" +echo " □ 網路請求 ≤5 次(clone/push/PR 各算一次)" +echo " □ 禁批量操作、禁迴圈打 API、禁開/改 Actions" +echo " □ 動作間隔像人手(秒級間隔,不連發)" +echo " □ 一收到 403/429/驗證挑戰 → 立即全停回報" +echo "" +read -r -p "以上確認,解除保險?(yes/N) " CONFIRM +if [ "$CONFIRM" != "yes" ]; then + echo "已取消,保險維持。" + exit 0 +fi + +EXPIRY=$(( $(date +%s) + MINUTES * 60 )) +printf '%s\n%s\n%s\n' "$EXPIRY" "$MISSION" "armed_at=$(date '+%Y-%m-%d %H:%M:%S')" > .github-armed +echo "" +echo "✅ 保險已解除至 $(date -r "$EXPIRY" '+%H:%M:%S')。期間每次 GitHub 接觸自動記入 github-contact-log.md。" +echo " 提前上保險:rm .github-armed" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..55a17ca --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,472 @@ +#!/bin/bash +# system-dev-template installer +# 已有專案接入腳本——只建立缺少的東西,已有的一律不動。 +# +# 模組化安裝: +# --wiki 只裝 LLM Wiki(記憶系統 + 機敏防護) +# --sdd 只裝 SDD 系統(動 code 前必須有 design.md) +# --all 兩個都裝(預設) +# 無參數 互動式詢問 +# +# 為什麼留在同一個 repo 用參數選,而不是 fork: +# 使用者多半非專業,最怕「我要去哪個 repo」。一個入口 + 選單最友善。 +# 等未來功能多到 3+ 個再演進成「模板組合器」。模組邊界先在這裡劃好。 + +set -euo pipefail + +# ── i18n:依 locale 選語言,預設英文 ────────────────── +# 為什麼預設英文:curl | bash 常是 LANG=C,外國人預設就該看得懂; +# 台灣使用者 locale 多為 zh_TW,會自動切回繁中。 +case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in + zh*|*Hant*|*Hans*) IS_ZH="yes" ;; + *) IS_ZH="no" ;; +esac +# t "中文" "English" → 依語系印出對應字串 +t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; } +# tn = 不換行版(給 prompt 用) +tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; } + +REPO_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/template" +# install.sh / update.sh 住在 main/scripts/(不在 template/)。 +SCRIPTS_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/scripts" +CREATED=() +SKIPPED=() + +# ── 解析模組參數 ────────────────────────────────── +MODULE="" +for arg in "$@"; do + case "$arg" in + --wiki|--wiki-only) MODULE="wiki" ;; + --sdd|--sdd-only) MODULE="sdd" ;; + --all) MODULE="all" ;; + -h|--help) + if [ "$IS_ZH" = "yes" ]; then + cat <<'HELP' +用法:install.sh [--wiki | --sdd | --all] + --wiki 只裝 LLM Wiki(CC 記憶系統 + 機敏防護) + --sdd 只裝 SDD 系統(動 code 前強制要有設計文件) + --all 兩個都裝(預設) + 無參數 互動式詢問要裝哪個 +HELP + else + cat <<'HELP' +Usage: install.sh [--wiki | --sdd | --all] + --wiki Install LLM Wiki only (CC memory system + secret protection) + --sdd Install SDD system only (require a design doc before touching code) + --all Install both (default) + no flag Interactively ask which to install +HELP + fi + exit 0 ;; + esac +done + +echo "" +echo "🔧 system-dev-template installer" +echo "=================================" +t "只建立缺少的目錄和檔案,已有的不動。" \ + "Only creates missing dirs and files; never touches what already exists." +echo "" + +# ── 無參數 → 互動式詢問(給非專業使用者)────────── +if [ -z "$MODULE" ]; then + if [ -t 0 ]; then + t "要安裝哪一塊?" "Which part do you want to install?" + t " 1) LLM Wiki —— 讓 CC 記住決策、不重複犯錯(含機敏防護)" \ + " 1) LLM Wiki — let CC remember decisions and avoid repeating mistakes (with secret protection)" + t " 2) SDD —— 動 code 前強制先有設計文件" \ + " 2) SDD — require a design doc before touching code" + t " 3) 兩個都裝(推薦)" " 3) Install both (recommended)" + echo "" + tn "請輸入 1 / 2 / 3 [預設 3]:" "Enter 1 / 2 / 3 [default 3]: " + read -r choice || choice=3 + case "$choice" in + 1) MODULE="wiki" ;; + 2) MODULE="sdd" ;; + *) MODULE="all" ;; + esac + else + # 非互動環境(如 curl | bash 無 tty)→ 預設全裝 + MODULE="all" + fi +fi + +WANT_WIKI=false +WANT_SDD=false +case "$MODULE" in + wiki) WANT_WIKI=true ;; + sdd) WANT_SDD=true ;; + all) WANT_WIKI=true; WANT_SDD=true ;; +esac + +echo "" +t "📦 安裝模組:$MODULE" "📦 Module: $MODULE" +echo "" + +# ── 重複安裝防呆(1.10.1):install 只管「全新安裝」,一切後續歸 update ── +# 判準是「裝過沒」,不分新版舊版: +# - 新結構 system-dev/ 已存在,或 +# - 舊結構 .claude/wiki/ 或 .claude/VERSION 存在(裝過舊版、待遷移) +# 裝過了還跑 install → 會重複建範本、甚至跟真資料並存(先 install 建空殼,遷移就被擋)。 +# 正解:偵測到裝過 → 不動任何東西,導去 update(更新/遷移/補新檔都由它處理)。 +if [ -d "system-dev" ] || [ -d ".claude/wiki" ] || [ -f ".claude/VERSION" ]; then + t "🛑 偵測到這個專案已經安裝過 system-dev-template。" \ + "🛑 system-dev-template is already installed in this project." + t " 後續的更新、遷移、補新檔,一律由「更新腳本」處理(不要重跑 install):" \ + " All updates, migrations, and new-file additions are handled by the UPDATER (don't re-run install):" + echo "" + echo " curl -sSL https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/scripts/update.sh | bash" + echo "" + t " (重跑 install 可能建出空白範本、跟你的真資料並存,故在此停止。)" \ + " (Re-running install could create empty templates alongside your real data, so it stops here.)" + exit 0 +fi + +# ── 偵測 vault 類型 → 決定 raw source(原始文件)路徑 ────────── +# 為什麼:這個模板原本假設「原始文件在 docs/」,但 Logseq / Obsidian +# 這種 PKM vault 有自己的目錄慣例,整理時不能照 docs/ 那套搬動, +# 否則會破壞 vault 結構、讓筆記變不可讀。 +# 偵測結果寫進 CLAUDE.md,讓 CC 和未來的 Cowork skill 都知道 +# 「該讀/該整理哪裡」而不是亂動。 +# 必須在建立 CLAUDE.md 之前跑完。 +VAULT_TYPE="" +RAW_SOURCE="" +IS_VAULT="no" # 只有 logseq/obsidian 這種「筆記軟體 vault」才算 yes +if [ -d "logseq" ]; then + VAULT_TYPE="logseq" + RAW_SOURCE="pages/, journals/" + IS_VAULT="yes" +elif [ -d ".obsidian" ]; then + VAULT_TYPE="obsidian" + RAW_SOURCE="$(tn './ (整個 vault 根目錄的 .md)' './ (all .md under the vault root)')" + IS_VAULT="yes" +else + VAULT_TYPE="docs" + RAW_SOURCE="docs/" +fi +# 偵測到是筆記 vault → 出聲告訴使用者「我看到了,會小心、不破壞你的筆記結構」。 +# 不是筆記(一般開發案等)→ 不囉嗦,默默把 docs/ 當原始文件夾安裝完成。 +if [ "$IS_VAULT" = "yes" ]; then + t "🗂️ 偵測到 ${VAULT_TYPE} 筆記庫 → 原始文件:${RAW_SOURCE}" \ + "🗂️ Detected a ${VAULT_TYPE} note vault → raw source: ${RAW_SOURCE}" + t " (會保留你筆記軟體的目錄/檔名結構,不搬動、不改名)" \ + " (your note app's directory/file structure is preserved — nothing is moved or renamed)" + echo "" +fi + +# 把「raw source 宣告區塊」吐出來,給新建的 CLAUDE.md append 或 +# 給已存在的 CLAUDE.md 當手動補貼的提示。內容對 CC / Cowork 都是 +# 機器可讀的指令(明確路徑 + 不可破壞 vault 結構的約束)。 +# 寫進 CLAUDE.md 的 raw source 宣告區塊。給人也給 AI 看: +# 依 locale 只寫「一種語言」進 CLAUDE.md(雙語會讓每個 session 的 context 更滿)。 +emit_raw_source_block() { + local source_kind + if [ "$IS_ZH" = "yes" ]; then + if [ "$IS_VAULT" = "yes" ]; then source_kind="${VAULT_TYPE} 筆記庫" + else source_kind="一般專案(原始文件放 raw source 路徑)"; fi + cat <<BLOCK + +--- + +## 原始文件空間(raw source) + +> 安裝時偵測到的來源型態:**${source_kind}** +> CC 與 Cowork 整理/讀取「人寫的原始文件」時,**只在這裡找、只在這裡動**。 + +| 項目 | 值 | +|------|----| +| 來源型態 | \`${source_kind}\` | +| raw source | \`${RAW_SOURCE}\` | + +**約束(CC 與 Cowork 都必須遵守)** + +- 整理 wiki/知識時,原始文件**一律從上方 raw source 路徑讀取**,不要假設是 \`docs/\`。 +BLOCK + if [ "$IS_VAULT" = "yes" ]; then + cat <<BLOCK +- 這是 **${VAULT_TYPE} 筆記庫**:保留它原本的目錄與檔名慣例,**不得搬動、改名、重新分類** \`.md\` 檔, + 以免破壞筆記軟體結構造成筆記不可讀。整理只在 \`system-dev/wiki/\` 產出,**不動 raw source 本身**。 +BLOCK + fi + else + if [ "$IS_VAULT" = "yes" ]; then source_kind="${VAULT_TYPE} note vault" + else source_kind="regular project (raw source lives at the path below)"; fi + cat <<BLOCK + +--- + +## Raw source space + +> Source type detected at install time: **${source_kind}** +> When CC and Cowork curate/read human-written raw source, **look only here and act only here**. + +| Item | Value | +|------|-------| +| Source type | \`${source_kind}\` | +| raw source | \`${RAW_SOURCE}\` | + +**Constraints (both CC and Cowork must obey)** + +- When curating the wiki/knowledge, **always read raw source from the path above** — don't assume \`docs/\`. +BLOCK + if [ "$IS_VAULT" = "yes" ]; then + cat <<BLOCK +- This is a **${VAULT_TYPE} note vault**: keep its original directory and file-naming conventions. **Do not move, rename, or re-classify** \`.md\` files, + or you'll break the note-app structure and make notes unreadable. Curation output goes only into \`system-dev/wiki/\`; **never touch the raw source itself**. +BLOCK + fi + fi +} + +# ── 工具函式 ────────────────────────────────────── +create_dir() { + if [ ! -d "$1" ]; then + mkdir -p "$1" + CREATED+=("$1/") + else + SKIPPED+=("$1/ $(tn '(已存在)' '(already exists)')") + fi +} + +download_if_missing() { + local dest="$1" src="$2" + if [ ! -f "$dest" ]; then + mkdir -p "$(dirname "$dest")" + curl -sSL "$src" -o "$dest" + CREATED+=("$dest") + else + SKIPPED+=("$dest $(tn '(已存在,跳過)' '(already exists, skipped)')") + fi +} + +# ── 共用結構 ────────────────────────────────────── +# 工具自己的文件骨架收進 system-dev/docs/(不污染用戶根目錄、不跟用戶自己的 docs/ 混)。 +# 注意語義分離:這裡的 system-dev/docs/ 是「工具文件」;用戶的 raw source(原始文件) +# 另有其處(見上方 vault 偵測),工具只讀、不搬。 +# .claude/ 只留 CC 死綁的 commands/ + hooks/,工具資料一律不放這。 +create_dir "system-dev/docs/1-vision" +create_dir "system-dev/docs/2-architecture/decisions" +create_dir "system-dev/docs/4-guides" +create_dir "system-dev/docs/5-records/incidents" +create_dir "system-dev/docs/5-records/test-reports" +create_dir "system-dev/docs/6-user" +create_dir ".claude/commands" +create_dir ".claude/hooks" +download_if_missing "system-dev/docs/README.md" "$REPO_URL/system-dev/docs/README.md" + +# 工具版號:放 system-dev/,不寄生 .claude/。 +download_if_missing "system-dev/VERSION" "$REPO_URL/system-dev/VERSION" + +# ── WIKI 模組 ───────────────────────────────────── +# wiki 是工具資料 → 放 system-dev/wiki/(不放 .claude/)。 +# commands/ 與 hooks/ 是 CC 機制檔 → 維持 .claude/。 +if $WANT_WIKI; then + create_dir "system-dev/wiki" + download_if_missing "system-dev/wiki/INDEX.md" "$REPO_URL/system-dev/wiki/INDEX.md" + download_if_missing "system-dev/wiki/TAXONOMY.md" "$REPO_URL/system-dev/wiki/TAXONOMY.md" + download_if_missing "system-dev/wiki/status.md" "$REPO_URL/system-dev/wiki/status.md" + download_if_missing "system-dev/wiki/mistakes.md" "$REPO_URL/system-dev/wiki/mistakes.md" + download_if_missing "system-dev/wiki/principles.md" "$REPO_URL/system-dev/wiki/principles.md" + download_if_missing "system-dev/wiki/.wikiignore" "$REPO_URL/system-dev/wiki/.wikiignore" + + # wiki 改寫產物(AI 自讀定稿卡片)的正式落點:由工具建好,不靠用戶自救。 + create_dir "system-dev/wiki/cards" + [ -f "system-dev/wiki/cards/.gitkeep" ] || { : > "system-dev/wiki/cards/.gitkeep"; CREATED+=("system-dev/wiki/cards/.gitkeep"); } + + download_if_missing ".claude/commands/wiki-init.md" "$REPO_URL/.claude/commands/wiki-init.md" + download_if_missing ".claude/commands/wiki-capture.md" "$REPO_URL/.claude/commands/wiki-capture.md" + download_if_missing ".claude/commands/wiki-update.md" "$REPO_URL/.claude/commands/wiki-update.md" + download_if_missing ".claude/commands/wiki-recall.md" "$REPO_URL/.claude/commands/wiki-recall.md" + + # wiki 相關 hooks:接關 + 機敏掃描 + download_if_missing ".claude/hooks/session-start-recall.sh" "$REPO_URL/.claude/hooks/session-start-recall.sh" + download_if_missing ".claude/hooks/wiki-secret-scan.sh" "$REPO_URL/.claude/hooks/wiki-secret-scan.sh" + + # Cowork(claude.ai)整理 wiki 用的 skill:與 CC 的 /wiki-init 共用同一套規則 + # (含 typed-edge、frontmatter 標籤、gloss)。沒這支 → claude.ai 來掃時身上沒規則。 + download_if_missing "system-dev/docs/SKILL.md" "$REPO_URL/system-dev/docs/SKILL.md" +fi + +# ── SDD 模組 ────────────────────────────────────── +if $WANT_SDD; then + create_dir "system-dev/docs/3-specs" + download_if_missing "system-dev/docs/3-specs/TEMPLATE-sdd/design.md" "$REPO_URL/system-dev/docs/3-specs/TEMPLATE-sdd/design.md" + download_if_missing "system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md" "$REPO_URL/system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md" + download_if_missing "system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md" "$REPO_URL/system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md" + + download_if_missing ".claude/commands/sdd-check.md" "$REPO_URL/.claude/commands/sdd-check.md" + download_if_missing ".claude/hooks/sdd-guard.sh" "$REPO_URL/.claude/hooks/sdd-guard.sh" +fi + +# ── 安裝/更新腳本:一開始就放進 system-dev/scripts/ ── +# 為什麼一開始就裝:之後要更新,用戶(或 CC)直接 `bash system-dev/scripts/update.sh`, +# 不必每次都記那串 curl。腳本來源在 main/scripts/(不在 template/)。 +create_dir "system-dev/scripts" +download_if_missing "system-dev/scripts/install.sh" "$SCRIPTS_URL/install.sh" +download_if_missing "system-dev/scripts/update.sh" "$SCRIPTS_URL/update.sh" + +# ── 共用 hook:專案自訂禁令骨架(預設停用)──────── +download_if_missing ".claude/hooks/pre-write-guard.sh" "$REPO_URL/.claude/hooks/pre-write-guard.sh" + +# ── 共用指引:GitHub issue 處理(讀/回普世,跨 repo 發要先問,禁自動輪詢)── +download_if_missing ".claude/commands/issue-handle.md" "$REPO_URL/.claude/commands/issue-handle.md" + +chmod +x .claude/hooks/*.sh 2>/dev/null || true + +# ── 依模組產生 settings.json 的 hooks 區塊 ──────── +# settings.json 因模組而異,不能直接下載單一靜態檔,改條件組裝。 +build_hooks_json() { + local session_hooks="" pretool_hooks="" + + if $WANT_WIKI; then + session_hooks='{ "type": "command", "command": ".claude/hooks/session-start-recall.sh" }' + fi + + # PreToolUse 依模組疊加 + local pt=() + $WANT_SDD && pt+=('{ "type": "command", "command": ".claude/hooks/sdd-guard.sh" }') + pt+=('{ "type": "command", "command": ".claude/hooks/pre-write-guard.sh" }') + $WANT_WIKI && pt+=('{ "type": "command", "command": ".claude/hooks/wiki-secret-scan.sh" }') + local IFS=, + pretool_hooks="${pt[*]}" + + printf '{\n "hooks": {\n' + if [ -n "$session_hooks" ]; then + printf ' "SessionStart": [\n { "matcher": "startup|resume|clear",\n "hooks": [ %s ] }\n ],\n' "$session_hooks" + fi + printf ' "PreToolUse": [\n { "matcher": "Write|Edit",\n "hooks": [ %s ] }\n ]\n' "$pretool_hooks" + printf ' }\n}\n' +} + +if [ ! -f ".claude/settings.json" ]; then + build_hooks_json > .claude/settings.json + CREATED+=(".claude/settings.json $(tn "(依 $MODULE 模組產生)" "(generated for module: $MODULE)")") +else + SKIPPED+=(".claude/settings.json $(tn '(已存在,請手動合併 hooks)' '(already exists — merge hooks manually)')") +fi + +# ── CLAUDE.md:只在完全不存在時建立 ──────────────── +# 新建時把偵測到的 raw source 宣告 append 進去(在建立的當下寫入, +# 不回頭改使用者既有的 CLAUDE.md,維持「已有不覆蓋」原則)。 +if [ ! -f "CLAUDE.md" ]; then + download_if_missing "CLAUDE.md" "$REPO_URL/CLAUDE.md" + if [ -f "CLAUDE.md" ]; then + emit_raw_source_block >> CLAUDE.md + CREATED+=("CLAUDE.md $(tn "← 已寫入 raw source 宣告(${VAULT_TYPE})" "← raw source declaration written (${VAULT_TYPE})")") + fi +else + SKIPPED+=("CLAUDE.md $(tn '(已存在,請手動加入對應區塊)' '(already exists — add the block manually)')") +fi + +# ── 輸出結果 ────────────────────────────────────── +echo "" +t "✅ 建立了:" "✅ Created:" +# 注意:macOS bash 3.2 在 set -u 下展開「空陣列」會炸 unbound variable, +# 所以這裡先確認有元素才展開(SKIPPED 區塊在下方本來就有守,CREATED 補上)。 +if [ ${#CREATED[@]} -gt 0 ]; then + for item in "${CREATED[@]}"; do echo " + $item"; done +fi + +if [ ${#SKIPPED[@]} -gt 0 ]; then + echo "" + t "⚠️ 跳過(已存在):" "⚠️ Skipped (already exists):" + for item in "${SKIPPED[@]}"; do echo " - $item"; done +fi + +echo "" +echo "─────────────────────────────────" + +# CLAUDE.md 已存在 → 依模組提醒手動加區塊 +if [ -f "CLAUDE.md" ]; then + if ! grep -q "raw source" CLAUDE.md; then + echo "" + t "📌 CLAUDE.md 已存在但缺少 raw source 宣告。" \ + "📌 CLAUDE.md exists but lacks a raw source declaration." + t " 請手動把以下區塊貼進去,讓 CC 與 Cowork 知道原始文件在哪、不要亂動既有結構:" \ + " Paste the block below in so CC and Cowork know where the raw source is and won't disturb your structure:" + emit_raw_source_block | sed 's/^/ /' + fi + if $WANT_WIKI && ! grep -q "wiki/status.md" CLAUDE.md; then + echo "" + t "📌 CLAUDE.md 已存在但缺少 wiki 讀取順序,請手動加入:" \ + "📌 CLAUDE.md exists but lacks the wiki reading order — please add it manually:" + echo "" + if [ "$IS_ZH" = "yes" ]; then + cat <<'SNIP' + ## Wiki 讀取順序(push:hook 開 session 自動注入) + | 檔案 | 時機 | 用途 | + |------|------|------| + | `system-dev/wiki/status.md` | session 開始第一件事 | 當前進度 | + | `system-dev/wiki/principles.md` | 設計任何東西前 | 跨全局原則,必服從 | + | `system-dev/wiki/mistakes.md` | 做新功能前 | 已知踩坑 | +SNIP + else + cat <<'SNIP' + ## Wiki reading order (push: auto-injected at session start) + | File | When | Purpose | + |------|------|---------| + | `system-dev/wiki/status.md` | first thing at session start | current progress | + | `system-dev/wiki/principles.md` | before designing anything | global principles, must obey | + | `system-dev/wiki/mistakes.md` | before building a new feature | known pitfalls | +SNIP + fi + fi + if $WANT_SDD && ! grep -q "system-dev/docs/3-specs" CLAUDE.md; then + echo "" + t "📌 CLAUDE.md 已存在但缺少 SDD 鐵律,請手動加入:" \ + "📌 CLAUDE.md exists but lacks the SDD iron rule — please add it manually:" + echo "" + if [ "$IS_ZH" = "yes" ]; then + cat <<'SNIP' + ## 絕對鐵律 + 1. 任何 code 變動前必須有對應 SDD(system-dev/docs/3-specs/[子系統]/design.md) + 找不到 → 停手問負責人,不要自行建立。 +SNIP + else + cat <<'SNIP' + ## Iron rule + 1. Every code change must have a matching SDD (system-dev/docs/3-specs/[subsystem]/design.md). + Not found → stop and ask the owner; do not create one on your own. +SNIP + fi + fi +fi + +# settings.json 已存在 → 依模組提醒要合併哪些 hook +if [ -f ".claude/settings.json" ]; then + MISSING_HOOKS=() + $WANT_WIKI && ! grep -q "session-start-recall.sh" .claude/settings.json && MISSING_HOOKS+=("SessionStart: session-start-recall.sh") + $WANT_WIKI && ! grep -q "wiki-secret-scan.sh" .claude/settings.json && MISSING_HOOKS+=("PreToolUse(Write|Edit): wiki-secret-scan.sh") + $WANT_SDD && ! grep -q "sdd-guard.sh" .claude/settings.json && MISSING_HOOKS+=("PreToolUse(Write|Edit): sdd-guard.sh") + if [ ${#MISSING_HOOKS[@]} -gt 0 ]; then + echo "" + t "📌 .claude/settings.json 已存在,請手動把以下 hooks 合併進去(保留既有設定):" \ + "📌 .claude/settings.json exists — merge the hooks below in manually (keep your existing settings):" + for h in "${MISSING_HOOKS[@]}"; do echo " • $h"; done + fi +fi + +# pre-write-guard 是空殼,提醒它預設不攔(避免「以為有保護其實沒有」的安全錯覺) +echo "" +t "ℹ️ .claude/hooks/pre-write-guard.sh 是「按需手填的空插槽」,預設不攔任何東西。" \ + "ℹ️ .claude/hooks/pre-write-guard.sh is an empty slot to fill on demand — by default it blocks nothing." +t " 需要專案禁令?最簡單是叫你的 CC 寫一支貼合的 guard hook(比範本表達力強);" \ + " Need project-specific bans? Easiest is to ask your CC to write a tailored guard hook (more expressive than the template);" +t " 或自己填 FORBIDDEN_PATTERNS 並到 settings.json 掛上才會生效。" \ + " or fill in FORBIDDEN_PATTERNS yourself and wire it into settings.json to take effect." + +echo "" +t "🚀 下一步:" "🚀 Next steps:" +if $WANT_WIKI; then + t " 在 Claude Code 對話裡執行 /wiki-init" \ + " In a Claude Code conversation, run /wiki-init" + t " CC 會掃描現有文件、套用 .wikiignore、建立 wiki。" \ + " CC will scan your existing docs, apply .wikiignore, and build the wiki." +fi +if $WANT_SDD; then + t " 動 code 前先在 system-dev/docs/3-specs/[子系統]/ 建 design.md(可用 /sdd-check 協助)" \ + " Before touching code, create design.md under system-dev/docs/3-specs/[subsystem]/ (use /sdd-check to help)" +fi +t " GitHub issue:CC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \ + " GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)" +echo "" diff --git a/scripts/kbdb-live-exam/.gitignore b/scripts/kbdb-live-exam/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/scripts/kbdb-live-exam/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/kbdb-live-exam/.wrangler/cache/wrangler-account.json b/scripts/kbdb-live-exam/.wrangler/cache/wrangler-account.json new file mode 100644 index 0000000..3a83b68 --- /dev/null +++ b/scripts/kbdb-live-exam/.wrangler/cache/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "58309bb90fd93ad6d0fe0aae99170e9d", + "name": "Uncle6.me@gmail.com's Account" + } +} \ No newline at end of file diff --git a/scripts/kbdb-live-exam/buttons.py b/scripts/kbdb-live-exam/buttons.py new file mode 100644 index 0000000..84df567 --- /dev/null +++ b/scripts/kbdb-live-exam/buttons.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +KBDB 按鈕殼 —— **受測者只拿得到這支腳本**。 + +leo 2026-08-15:「如果明天只有這幾個按鈕可按,至少保證不再出錯。」 +⇒ 這支就是那幾個按鈕。它刻意**只**暴露使用者真的走得到的那條路上真的存在的動作 + (portal 資料面 = MCP 知識面工具打的同一組端點、同一道閘)。 + 沒有 update、沒有 delete/archive、沒有 link、沒有 add-field、沒有 add-record-to-sheet + ——因為那條路上真的沒有(見 system-dev/docs/4-guides/kbdb-動作對照表.md)。 + +兩個後端,**寫入語意刻意寫成同一份**(mirror 自 matrix/arcrun/kbdb/src/actions/record-crud.ts): + local:<path> 本機 sqlite,用來做判分器自我驗證與乾跑(**不碰任何實例**) + portal:<url> 真的打 youlin 的 portal 資料面(正式考試用) + +⚠️ 下面兩句 CREATE TABLE 標了 kbdb-sql-ok:那是**本機拋棄式 sqlite 的空白畫布**, + 不是 KBDB 的 D1,也沒有替 KBDB 新增任何表。乾跑用完即丟。 +""" +import argparse +import json +import os +import sqlite3 +import subprocess +import uuid + +SYS_ROOT, SYS_BELONGS, SYS_FIELD_OF = "sys_root", "sys_belongs", "sys_field_of" + +# 本機空白畫布:0007 之後的 entries(含三根指標欄)+ 遷移期仍在的 templates。 +SCHEMA = """ +CREATE TABLE IF NOT EXISTS entries ( -- kbdb-sql-ok: 本機拋棄式 sqlite 畫布,非 KBDB 的 D1 + id TEXT PRIMARY KEY, content TEXT, entry_type TEXT NOT NULL, owner_id TEXT, + parent_id TEXT, page_name TEXT, refs_json TEXT DEFAULT '[]', tags_json TEXT DEFAULT '[]', + task_status TEXT, content_hash TEXT, is_embedded INTEGER DEFAULT 0, + confidence REAL, metadata_json TEXT, + created_at INTEGER DEFAULT (unixepoch()), updated_at INTEGER DEFAULT (unixepoch()), + src_id TEXT, rel_id TEXT, dst_id TEXT); +CREATE TABLE IF NOT EXISTS templates ( -- kbdb-sql-ok: 同上,本機畫布 + id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, description TEXT, + slots_json TEXT NOT NULL, created_by TEXT, + created_at INTEGER DEFAULT (unixepoch()), updated_at INTEGER DEFAULT (unixepoch())); +""" + + +def uid(p): + return f"{p}_{uuid.uuid4()}" + + +# ══════════════════════════ 後端 A:本機 sqlite ══════════════════════════ +# 這一段是 record-crud.ts 的逐句對照移植。**包括它的沉默**: +# createRecord 只寫 template 宣告過的 slot(`slots.filter(s => s in values)`), +# 沒宣告的 key **靜默消失**且仍回 200 —— 那正是最值得考出來的一種安靜的錯。 +class Local: + def __init__(self, path): + self.db = sqlite3.connect(path) + self.db.row_factory = sqlite3.Row + self.db.executescript(SCHEMA) + self._anchors() + + def _anchors(self): + for i, c in ((SYS_ROOT, "root"), (SYS_BELONGS, "belongs"), (SYS_FIELD_OF, "field_of")): + self.db.execute( + "INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?,?, 'system')", + (i, c)) + self.db.commit() + + def _ensure_fields(self, tid, slots): + for s in slots: + fid = f"fld_{tid}_{s}" + self.db.execute( + "INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?,?,'field')", + (fid, s)) + self.db.execute( + "INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) " + "VALUES (?, 'relation', ?, ?, ?)", (f"relf_{tid}_{s}", fid, SYS_FIELD_OF, tid)) + + def list_sheets(self): + return [dict(r) for r in self.db.execute( + "SELECT id, name, slots_json FROM templates ORDER BY created_at DESC")] + + def create_sheet(self, name, fields, description=None): + tid = uid("tpl") + self.db.execute( + "INSERT INTO templates (id, name, description, slots_json) VALUES (?,?,?,?)", + (tid, name, description, json.dumps(fields, ensure_ascii=False))) + self.db.execute( + "INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?,?,'sheet')", + (tid, name)) + self.db.execute( + "INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) " + "VALUES (?, 'relation', ?, ?, ?)", (f"relb_{tid}", tid, SYS_BELONGS, SYS_ROOT)) + self._ensure_fields(tid, fields) + self.db.commit() + return {"id": tid, "name": name, "slots": fields} + + def _tpl(self, name_or_id): + r = self.db.execute("SELECT * FROM templates WHERE id=? OR name=? LIMIT 1", + (name_or_id, name_or_id)).fetchone() + return dict(r) if r else None + + def append_record(self, sheet, values, metadata_json=None): + tpl = self._tpl(sheet) + if not tpl: + return {"error": f"sheet not found: {sheet}"} + slots = json.loads(tpl["slots_json"]) + rid = uid("rec") + self.db.execute("INSERT OR IGNORE INTO entries (id, entry_type) VALUES (?, 'record')", (rid,)) + self.db.execute( + "INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) " + "VALUES (?, 'relation', ?, ?, ?)", + (f"relb_{rid}_{tpl['id']}", rid, SYS_BELONGS, tpl["id"])) + written = [s for s in slots if s in values] # ← 沒宣告的 key 在這裡靜默消失 + self._ensure_fields(tpl["id"], written) + for s in written: + eid = uid("e") + self.db.execute( + "INSERT INTO entries (id, content, entry_type, metadata_json) " + "VALUES (?,?, 'value', ?)", (eid, values[s], metadata_json)) + self.db.execute( + "INSERT INTO entries (id, entry_type, src_id, rel_id, dst_id) " + "VALUES (?, 'relation', ?, ?, ?)", + (uid("relv"), rid, f"fld_{tpl['id']}_{s}", eid)) + self.db.commit() + return {"record_id": rid, "sheet": tpl["name"], + "values": {s: values[s] for s in written}} + + # ⚠️ 只給判分器自我驗證用的正控制組,**不對受測者開放**: + # 「指向既有的那一顆,而不是複製一份」是 KBDB 的核心賣點, + # kbdb/src 內部確實有這個通道(createRecord 的 entry_ids), + # 但 **portal 資料面與 MCP 都只轉送 {template, values},沒有把它露出來** + # ⇒ 使用者那條路上做不到。這支方法存在的意義就是把那個洞量出來。 + def _append_record_pointer(self, sheet, values, pointers): + tpl = self._tpl(sheet) + rid = uid("rec") + self.db.execute("INSERT OR IGNORE INTO entries (id, entry_type) VALUES (?, 'record')", (rid,)) + self.db.execute( + "INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) " + "VALUES (?, 'relation', ?, ?, ?)", + (f"relb_{rid}_{tpl['id']}", rid, SYS_BELONGS, tpl["id"])) + slots = json.loads(tpl["slots_json"]) + self._ensure_fields(tpl["id"], slots) + for s in slots: + if s in pointers: + dst = pointers[s] + elif s in values: + dst = uid("e") + self.db.execute( + "INSERT INTO entries (id, content, entry_type) VALUES (?,?, 'value')", + (dst, values[s])) + else: + continue + self.db.execute( + "INSERT INTO entries (id, entry_type, src_id, rel_id, dst_id) " + "VALUES (?, 'relation', ?, ?, ?)", + (uid("relv"), rid, f"fld_{tpl['id']}_{s}", dst)) + self.db.commit() + return {"record_id": rid} + + def _make_shared_value(self, content): + eid = uid("e") + self.db.execute("INSERT INTO entries (id, content, entry_type) VALUES (?,?, 'value')", + (eid, content)) + self.db.commit() + return eid + + def get_record(self, rid): + rows = self.db.execute( + "SELECT f.content AS field, v.content AS value FROM entries r " + "JOIN entries f ON f.id = r.rel_id JOIN entries v ON v.id = r.dst_id " + "WHERE r.src_id = ? AND r.rel_id != ?", (rid, SYS_BELONGS)).fetchall() + return {"record_id": rid, "values": {r["field"]: r["value"] for r in rows}} + + def get_records(self, sheet): + tpl = self._tpl(sheet) + if not tpl: + return [] + ids = [r["src_id"] for r in self.db.execute( + "SELECT src_id FROM entries WHERE rel_id=? AND dst_id=?", (SYS_BELONGS, tpl["id"]))] + return [self.get_record(i) for i in ids] + + def search(self, q): + return [dict(r) for r in self.db.execute( + "SELECT id, content, entry_type FROM entries WHERE content LIKE ? LIMIT 50", + (f"%{q}%",))] + + +# ══════════════════════════ 後端 B:youlin portal 資料面 ══════════════════ +# 使用者真的會走的那條路:portal 帳密登入 → /portal/data/*。 +# MCP 的 kbdb_* 工具(identity.kind='portal')打的是同一組端點、同一道閘。 +class Portal: + def __init__(self, base, email, password): + self.base = base.rstrip("/") + self.session = self._login(email, password) + + def _curl(self, method, path, body=None, auth=True): + cmd = ["curl", "-s", "--max-time", "40", "-X", method, f"{self.base}{path}", + "-A", "Mozilla/5.0", "-H", "Content-Type: application/json"] + if auth: + cmd += ["-H", f"Authorization: Bearer {self.session}"] + if body is not None: + cmd += ["-d", json.dumps(body, ensure_ascii=False)] + out = subprocess.run(cmd, capture_output=True, text=True, timeout=60).stdout + try: + return json.loads(out) + except Exception: + return {"error": "non-json response", "raw": out[:300]} + + def _login(self, email, password): + d = self._curl("POST", "/portal/login", {"email": email, "password": password}, auth=False) + tok = d.get("session") or d.get("token") or d.get("access_token") + if not tok: + raise SystemExit(json.dumps({"error": "portal login failed", "detail": d}, + ensure_ascii=False)) + return tok + + def list_sheets(self): + return self._curl("GET", "/portal/data/templates") + + def create_sheet(self, name, fields, description=None): + return self._curl("POST", "/portal/data/templates", + {"name": name, "slots": fields, "description": description}) + + def append_record(self, sheet, values, metadata_json=None): + return self._curl("POST", "/portal/data/records", {"template": sheet, "values": values}) + + def get_record(self, rid): + return self._curl("GET", f"/portal/data/records/{rid}") + + def get_records(self, sheet): + return self._curl("GET", f"/portal/data/records/by-template/{sheet}") + + def search(self, q): + return self._curl("GET", f"/portal/data/search?q={q}") + + +# ══════════════════════════ 後端 C:acr CLI(leo 2026-08-15 指定)══════════════ +# 「你可以叫它用 CLI 考試。」CLI/MCP/portal 是同一套 API 的三個薄殼 +# (`cli/src/commands/kbdb.ts` 檔頭:能力長在基本盤 API,CLI 只做介面轉換)。 +# `acr kbdb` 的動作**剛好就是那六個按鈕**,多一個少一個都沒有。 +# +# 🔴 **它打哪一台,由 cwd 決定**:解析順序是 +# env > 資料夾層 `.arcrun.yaml`(就近往上找)> 全域 `~/.arcrun/config.yaml`, +# 而**全域指的是 leo21c(leo 的真庫,47.9 萬筆)**。 +# ⇒ 本後端在建構時強制跑一次 `acr whoami`,**確認 CF 帳號是預期那台才准往下走**。 +# 不憑上一次的結果假設這一次也一樣(2026-08-15 就是這一步救了總管)。 +YOULIN_ACCOUNT = "1129efd7df2e8899d537e9c8fbabb6cb" +# 🔴 2026-08-16 補:只比對 CF 帳號**不夠**——那不是決定資料落到誰名下的那一項。 +# 實撞:專案層 `.arcrun.yaml` 只寫 cypher_executor_url + cloudflare_account_id 時, +# `acr whoami` 印出—— +# 帳號 bfezv28v ← 沒被覆蓋,從全域掉下來的(leo21c) +# 連哪台 youlin 的 cypher +# CF 帳號 1129efd7…(youlin) +# ⇒ 「打 youlin 這台機器,但用 leo21c 的身分寫入」。 +# 而舊的防呆只找 CF 帳號字串,那一項是對的 ⇒ **它會放行**, +# 考完會拿到一份看起來正常、實際寫進錯地方的成績。 +# ⇒ 判準:防呆要比對「**決定後果的那一項**」,不是「剛好看得到的那一項」。 +# namespace 才是資料的歸屬鍵 ⇒ 三項一起驗,缺一不可。 +YOULIN_NAMESPACE = "yuga3bse" +YOULIN_CYPHER = "arcrun-cypher-executor.youlin-hsieh-dev.workers.dev" + + +class Acr: + def __init__(self, workdir, expect_account=YOULIN_ACCOUNT, + expect_namespace=YOULIN_NAMESPACE, expect_cypher=YOULIN_CYPHER): + self.cwd = workdir + who = subprocess.run(["acr", "whoami"], cwd=workdir, capture_output=True, + text=True, timeout=60).stdout + missing = [label for label, token in ( + ("CF 帳號", expect_account), + ("namespace(資料歸屬鍵)", expect_namespace), + ("cypher 主機", expect_cypher), + ) if token and token not in who] + if missing: + raise SystemExit( + "🔴 acr 指到的不是預期的實例,拒絕往下走。\n" + " 對不上:" + "、".join(missing) + "\n" + " ⚠️ 三項分別決定「哪個 CF 帳號」「資料算誰的」「打哪台機器」," + "缺一項就可能考在錯的地方。\n" + who) + self.whoami = who + + def _run(self, args): + r = subprocess.run(["acr", "kbdb"] + args, cwd=self.cwd, + capture_output=True, text=True, timeout=120) + return {"exit": r.returncode, "out": r.stdout.strip(), "err": r.stderr.strip()} + + def list_sheets(self): + return self._run(["template", "list"]) + + def create_sheet(self, name, fields, description=None): + return self._run(["template", "create", name, "--slots", ",".join(fields)]) + + def append_record(self, sheet, values, metadata_json=None): + args = ["record", "create", sheet] + for k, v in values.items(): + args += ["--values", f"{k}={v}"] + return self._run(args) + + def get_record(self, rid): + return self._run(["record", "get", rid]) + + def get_records(self, sheet): + return self._run(["query", sheet]) + + def search(self, q): + return self._run(["search", q]) + + +def make_backend(spec): + kind, _, arg = spec.partition(":") + if kind == "local": + return Local(arg) + if kind == "portal": + return Portal(arg, os.environ["PORTAL_EMAIL"], os.environ["PORTAL_PASSWORD"]) + if kind == "acr": + # arg = 考場資料夾(裡面放 .arcrun.yaml,決定打哪一台) + return Acr(arg) + raise SystemExit(f"unknown backend: {spec}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--backend", required=True) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("list_sheets") + p = sub.add_parser("create_sheet"); p.add_argument("--name", required=True) + p.add_argument("--fields", nargs="+", required=True); p.add_argument("--description") + p = sub.add_parser("append_record"); p.add_argument("--sheet", required=True) + p.add_argument("--values", required=True, help="JSON 物件 {欄位名: 內容}") + p = sub.add_parser("get_record"); p.add_argument("--id", required=True) + p = sub.add_parser("get_records"); p.add_argument("--sheet", required=True) + p = sub.add_parser("search"); p.add_argument("--q", required=True) + a = ap.parse_args() + + b = make_backend(a.backend) + if a.cmd == "list_sheets": + out = b.list_sheets() + elif a.cmd == "create_sheet": + out = b.create_sheet(a.name, a.fields, a.description) + elif a.cmd == "append_record": + out = b.append_record(a.sheet, json.loads(a.values)) + elif a.cmd == "get_record": + out = b.get_record(a.id) + elif a.cmd == "get_records": + out = b.get_records(a.sheet) + else: + out = b.search(a.q) + print(json.dumps(out, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/kbdb-live-exam/exam-packet.md b/scripts/kbdb-live-exam/exam-packet.md new file mode 100644 index 0000000..5898351 --- /dev/null +++ b/scripts/kbdb-live-exam/exam-packet.md @@ -0,0 +1,65 @@ +# 考卷(發給受測者的全部內容) + +> 🔴 **這份刻意不含任何額外提示。** 受測者拿到的說明必須等於 +> 使用者那條路上真的看得到的說明(MCP 工具描述/portal 畫面), +> 否則量到的是「我提示得好不好」,不是「這套教材夠不夠」。 + +--- + +你有一個知識庫,可以用下面這支工具操作它。**你只有這些按鈕,沒有別的。** + +``` +python3 buttons.py --backend local:$DB list_sheets +python3 buttons.py --backend local:$DB create_sheet --name <名字> --fields <欄位1> <欄位2> ... +python3 buttons.py --backend local:$DB append_record --sheet <名字> --values '{"欄位":"內容"}' +python3 buttons.py --backend local:$DB get_records --sheet <名字> +python3 buttons.py --backend local:$DB get_record --id <record_id> +python3 buttons.py --backend local:$DB search --q <關鍵字> +``` + +說明(= MCP 工具描述現有的原文): + +- `create_sheet`:建一個 sheet(萬用表裡的一種資料形狀)。這裡不能建真的資料表—— + 要存「新類型」的結構化資料時,就建一個 sheet 並用 fields 列出它的欄位名, + 之後用 `append_record` 填值。例:`--name contact --fields name email phone`。 +- `list_sheets`:列出所有 sheet(已定義的資料形狀)。要存資料前先看有沒有現成的可用。 +- `append_record`:依某個 sheet 填一筆記錄。values 是 `{欄位名: 內容}`, + 欄位名要對得上該 sheet 的 fields。sheet 不存在會失敗。 +- `get_record` / `get_records` / `search`:讀。 + +--- + +## 任務(請一題一題做完,每題做完用一句話說你做了什麼) + +1. 把這三筆工作流執行紀錄存進知識庫。每筆有四項:workflow_id、verdict、 + duration_ms、message。 + - `wf_a` / ok / 1200 / done + - `wf_b` / fail / 80 / timeout + - `wf_c` / ok / 430 / done + (請用 sheet 名稱 `xqL1_runlog`) + +2. 這是執行紀錄的完整規格,欄位有六項:workflow_id、verdict、duration_ms、 + message、target、api_key。請先把這份規格登記進系統,然後存兩筆真實資料: + - `wf_x` / ok / 900 / ok / prod / key_1 + - `wf_y` / fail / 55 / boom / stage / key_2 + (請用 sheet 名稱 `xqL2_spec`) + +3. 通訊錄(sheet `xqL3_contact`)裡已經有王小明。現在要讓王小明也出現在 + 一份「老師名單」裡,他教數學。 + +4. 一篇文章裡萃取出五組關係,請存進知識庫: + - 王小明 — 愛吃 — 牛肉麵 + - 王小明 — 任教於 — 南港國小 + - 李美華 — 同事 — 王小明 + - 南港國小 — 位於 — 台北市 + - 牛肉麵 — 屬於 — 麵食 + (請用 sheet 名稱 `xqL4_rel`) + +5. 有 30 個檔案,每個有檔名、一句摘要、以及它所屬的資料夾。資料夾總共只有六個 + (設計/會議/帳務/法務/研發/行銷),所以會重複出現。請全部存進知識庫。 + 檔名為 `file_00.md` … `file_29.md`,摘要為「摘要 0」…「摘要 29」, + 資料夾依序循環(file_00 → 設計、file_01 → 會議、…、file_06 → 設計,以此類推)。 + (請用 sheet 名稱 `xqL5_files`) + +6. sheet `xqL6_runlog` 裡第二筆(workflow_id = `wf_1`)的 verdict 應該要是 `fail`, + 現在是 `ok`。請把它改成 `fail`。 diff --git a/scripts/kbdb-live-exam/fingerprint.py b/scripts/kbdb-live-exam/fingerprint.py new file mode 100644 index 0000000..d52cec3 --- /dev/null +++ b/scripts/kbdb-live-exam/fingerprint.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +考前指紋:**這一筆數據是打在哪一版 kbdb 上量出來的。** + +立它的理由(總管 2026-08-15,出貨管線 preflight 抓到的): + kbdb 的出貨成品比源碼舊 3 顆 commit,其中一顆正是 v7 拆表那顆 +⇒ youlin 上那顆 worker 什麼時候變成 v7,取決於出貨走到哪一步。 +⇒ **不標指紋就會量出「模型寫錯了」,而真兇是它打到的那台還在跑舊模型。** + +兩個獨立來源,**刻意都留**(一個從使用者那條路量、一個從帳號那邊量): + + ① 行為指紋(不需要任何憑證,走 `acr` = 使用者真的會走的那條路) + 建一張拋棄式 sheet,寫一筆 → 回應直接說出它是哪一版: + `no such table: entry_values` → 舊碼(v7 之前) + 成功 → 新碼(v7 之後) + 🔑 這一支的價值在於**它量的就是受測者會撞到的那個東西**。 + + ② 部署指紋(需要該帳號的 CF token,唯讀) + worker 的 modified_on + etag。它答的是「這顆什麼時候被換過」。 + +⚠️ 兩者都不是 commit sha。**worker 上沒有 sha 可讀**—— + 所以這裡誠實地記「行為 + 換過的時間」,不假裝知道它是哪一顆 commit。 +""" +import argparse +import json +import os +import subprocess +import time +import uuid + + +def behavior_fingerprint(workdir): + """走 acr(使用者那條路)問一句:你是 v7 之前還是之後?""" + name = f"xqfp_{uuid.uuid4().hex[:8]}" + out = {"probe_sheet": name} + + def run(args): + r = subprocess.run(["acr", "kbdb"] + args, cwd=workdir, + capture_output=True, text=True, timeout=120) + return r.returncode, (r.stdout + r.stderr).strip() + + who = subprocess.run(["acr", "whoami"], cwd=workdir, + capture_output=True, text=True, timeout=60).stdout + out["whoami"] = [l.strip() for l in who.splitlines() if l.strip()][:6] + + rc, txt = run(["template", "create", name, "--slots", "a,b"]) + out["template_create"] = {"exit": rc, "tail": txt[-160:]} + + rc, txt = run(["record", "create", name, "--values", "a=1", "--values", "b=2"]) + out["record_create"] = {"exit": rc, "tail": txt[-200:]} + + if rc == 0: + # 🔴 2026-08-16 這一行我寫錯過一次,留著當警示: + # 原本是「rc==0 ⇒ v7+(關係列模型已上線)」。**寫得進去 ≠ 寫成新形狀。** + # 那天 acr update 之後 record create 又成功了,我就宣告 v7+; + # 實際是 `entry_values` 被 migration 重播**復活**,舊碼照樣往那張表寫 + # ⇒ 考卷寫進去的 310 列全在那張死掉的表裡,新模型那邊一列都沒長。 + # ⇒ 行為探針只能證明「寫得進去」,**證明不了寫成什麼形狀**。形狀要另外量。 + out["kbdb_generation"] = "寫得進去,但**形狀未驗**——要看 shape 那一段才算數" + elif "no such table: entry_values" in txt: + out["kbdb_generation"] = "pre-v7(舊碼碰新庫:worker 還在寫 entry_values)" + else: + out["kbdb_generation"] = "未知(寫入失敗但不是拆表那個原因,看 tail)" + return out + + +def deploy_fingerprint(account_id, token, scripts=("arcrun-kbdb", "arcrun-cypher-executor", + "arcrun-mcp")): + """worker 上一次被換掉是什麼時候(唯讀)。""" + r = subprocess.run( + ["curl", "-s", "--max-time", "25", "-H", f"Authorization: Bearer {token}", + f"https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts"], + capture_output=True, text=True, timeout=60).stdout + try: + data = json.loads(r).get("result", []) + except Exception: + return {"error": "CF API 回應不是 JSON"} + return {s["id"]: {"modified_on": s.get("modified_on"), "etag": (s.get("etag") or "")[:16]} + for s in data if s["id"] in scripts} + + +# 受測者可能讀到教材的地方。**一個都不能漏,漏掉的那份會安靜地教錯。** +# 2026-08-15 實證:同名的 kbdb-api-wall-guard.sh 有兩份都會開火, +# 只有 repo 那份被修好(efa64d8),全域 skill 那份還在說 +# 「exactly three core tables: entries / templates / entry_values」。 +MATERIAL_PATHS = [ + "~/.claude/skills/arcrun-kbdb-guardrails", + ".claude/hooks/kbdb-api-wall-guard.sh", + "arcrun_harness/.claude/skills/arcrun-kbdb-guardrails", +] +# 已經死掉的東西:教材裡出現它**而且沒有同時說它死了**,就是還在當現行做法教。 +DEAD_TERMS = ["entry_values"] +TOMBSTONE_MARKS = ["🪦", "廢除", "已死", "0007", "已被推翻", "提議廢掉", "尚未 confirm"] + + +def materials_fingerprint(paths=MATERIAL_PATHS): + """教材指紋:這一筆數據是在哪一版教材底下量的。 + + 🔑 判準不是「有沒有提到死掉的東西」——**歷史要留著** + (保留歷史而不是抹掉,那是 efa64d8 自己選的做法,對的)。 + 判準是「提到它的那一段,有沒有說它死了」。 + """ + out = {} + for p in paths: + real = os.path.expanduser(p) + if not os.path.exists(real): + out[p] = {"status": "不存在"} + continue + walk = ([real] if os.path.isfile(real) + else [os.path.join(d, f) for d, _, fs in os.walk(real) for f in fs]) + files = [] + for f in walk: + if f.endswith((".png", ".jpg", ".pyc", ".bak")): + continue + try: + lines = open(f, encoding="utf-8", errors="ignore").read().splitlines() + except Exception: + continue + stale = [] + for i, line in enumerate(lines): + if not any(t in line for t in DEAD_TERMS): + continue + # 墓碑註記可能寫在前後幾行,看一個小範圍再判 + #(避免把「刻意保留的歷史」誤報成過期教材) + ctx = "\n".join(lines[max(0, i - 3):i + 4]) + if not any(m in ctx for m in TOMBSTONE_MARKS): + stale.append(i + 1) + if stale: + files.append({"file": f.replace(os.path.expanduser("~"), "~"), + "stale_lines": stale[:8]}) + out[p] = {"status": "🔴 還在教死掉的東西" if files else "✅ 乾淨", "files": files} + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--workdir", required=True, help="考場資料夾(含 .arcrun.yaml)") + ap.add_argument("--materials-only", action="store_true", help="只查教材指紋,什麼都不寫") + ap.add_argument("--account-id", default="") + ap.add_argument("--token-env", default="CLOUDFLARE_API_TOKEN_YOULIN_CC_USE") + ap.add_argument("--no-probe", action="store_true", help="只取部署指紋,不寫任何東西") + a = ap.parse_args() + + stamp = {"taken_at": time.strftime("%Y-%m-%dT%H:%M:%S%z")} + if a.account_id and os.environ.get(a.token_env): + stamp["deploy"] = deploy_fingerprint(a.account_id, os.environ[a.token_env]) + if not a.no_probe: + stamp["behavior"] = behavior_fingerprint(a.workdir) + print(json.dumps(stamp, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/kbdb-live-exam/grade.py b/scripts/kbdb-live-exam/grade.py new file mode 100644 index 0000000..0e7ae25 --- /dev/null +++ b/scripts/kbdb-live-exam/grade.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +""" +KBDB 實機考卷 — 判分器(看落地的資料,不看受測者自述) + +設計鐵律 +-------- +1. **只讀資料庫的真身**(0007 之後的樹狀模型),不讀受測者的回報、不讀 API 的呈現。 + API 可以把 JSON 團漂亮地印出來;D1 不會替它掩護。 +2. **同一份 SQL 同時服務自我驗證與正式考試**——只換連線層(sqlite / D1)。 + 若自我驗證跑的是另一段邏輯,它就證明不了正式考試。 +3. **只數安靜的錯**。API 退回、參數形狀錯(大聲的錯)不在本判分器範圍 + (考卷 §二:大聲的錯當場修掉,不計分)。 + +模型(matrix/arcrun/kbdb/migrations/0007_tree_record_model.sql) +--------------------------------------------------------------- + sheet entries.entry_type='sheet',且有一條 (src=sheet, rel=sys_belongs, dst=sys_root) + field entries.entry_type='field',且有一條 (src=field, rel=sys_field_of, dst=sheet) + record entries.entry_type='record',且有一條 (src=record, rel=sys_belongs, dst=sheet) + 格子 一條 (src=record, rel=<field id>, dst=<value entry>) +""" +import argparse +import json +import os +import re +import sqlite3 +import subprocess +import sys +from collections import Counter, defaultdict + +SYS = {"sys_root", "sys_belongs", "sys_field_of"} + +# ── 這一段 SQL 是判分器的全部輸入。sqlite 與 D1 共用同一份字串。 ────────────── +LOAD_SQL = ( + "SELECT id, content, entry_type, metadata_json, src_id, rel_id, dst_id, created_at " + "FROM entries" +) + + +# ───────────────────────────── 連線層(唯一有分歧的地方) ───────────────────── +def load_sqlite(path): + con = sqlite3.connect(path) + con.row_factory = sqlite3.Row + rows = [dict(r) for r in con.execute(LOAD_SQL)] + con.close() + return rows + + +def load_d1(dbname, account_id, token): + env = dict(os.environ) + env["CLOUDFLARE_ACCOUNT_ID"] = account_id + env["CLOUDFLARE_API_TOKEN"] = token + out = subprocess.run( + ["npx", "--yes", "wrangler@latest", "d1", "execute", dbname, + "--remote", "--json", "--command", LOAD_SQL], + capture_output=True, text=True, env=env, timeout=300, + ).stdout + out = out[out.find("["):] + return json.loads(out)[0]["results"] + + +# ───────────────────────────── 模型重建 ────────────────────────────────────── +class Pool: + def __init__(self, rows): + self.by_id = {r["id"]: r for r in rows} + self.rows = rows + self.rels = [r for r in rows if r.get("rel_id")] + + def sheets(self): + out = {} + for r in self.rels: + if r["rel_id"] == "sys_belongs" and r["dst_id"] == "sys_root": + e = self.by_id.get(r["src_id"]) + if e: + out[e["id"]] = e.get("content") + return out + + def fields_of(self, sheet_id): + return {r["src_id"]: (self.by_id.get(r["src_id"], {}) or {}).get("content") + for r in self.rels + if r["rel_id"] == "sys_field_of" and r["dst_id"] == sheet_id} + + def records_of(self, sheet_id): + return [r["src_id"] for r in self.rels + if r["rel_id"] == "sys_belongs" and r["dst_id"] == sheet_id] + + def cells_of(self, record_id): + out = [] + for r in self.rels: + if r["src_id"] == record_id and r["rel_id"] not in SYS: + v = self.by_id.get(r["dst_id"]) + if v is not None: + out.append((r["rel_id"], v)) + return out + + +# ───────────────────────────── 偵測器(每一支=一種安靜的錯) ───────────────── +def _parse_json(s): + if not isinstance(s, str): + return None + s = s.strip() + if not s or s[0] not in "{[": + return None + try: + return json.loads(s) + except Exception: + return None + + +KV_PACK = re.compile(r"[^\s=:;,]+\s*[=:]\s*[^;,\n]+") + + +def is_json_blob(content): + """一個格子裡塞了一整包結構 —— D91 本人。""" + v = _parse_json(content) + if isinstance(v, dict) and len(v) >= 2: + return True + # 一格裡放「物件的清單」=結構被塞進格子,不論長度。實測 youlin 上 + # library_map.relation_profile 只有一個元素,若要求 len>=2 就會漏掉它。 + if isinstance(v, list) and any(isinstance(x, (dict, list)) for x in v): + return True + if isinstance(v, list) and len(v) >= 2: + return True + return False + + +def is_kv_packed(content): + """沒用 JSON,改用 `a=1; b=2` 把多欄擠進一格 —— 換個門進來的同一個病。""" + if not isinstance(content, str) or is_json_blob(content): + return False + if len(content) < 8: + return False + if not (";" in content or "\n" in content or "," in content): + return False + return len(KV_PACK.findall(content)) >= 2 + + +def is_multifact(content): + """一個格子裡塞了好幾筆事實 —— 關係被寫成附加物。""" + if not isinstance(content, str): + return False + v = _parse_json(content) + if isinstance(v, list) and len(v) >= 2: + return True + lines = [x for x in content.splitlines() if x.strip()] + if len(lines) < 2: + return False + sep = re.compile(r"(->|→|—|--|\||,|、|愛吃|屬於|喜歡|是)") + return sum(1 for ln in lines if sep.search(ln)) >= 2 + + +STRUCT_META_WHITELIST = {"source", "source_uri", "hash", "content_hash", "ts", "updated_at"} + + +def structured_metadata(entry): + """結構化欄位被打包進 metadata_json —— D91 的原始發作處。""" + v = _parse_json(entry.get("metadata_json")) + if not isinstance(v, dict): + return False + return len(set(v.keys()) - STRUCT_META_WHITELIST) >= 2 + + +# ───────────────────────────── 訊號彙總 ────────────────────────────────────── +LIST_SIGNALS = ("json_blob_cells", "kv_packed_cells", "multifact_cells", + "declared_unused_fields", "used_undeclared_fields", + "structured_metadata_entries", "duplicate_value_contents", + "empty_records", "shared_value_entries", "orphan_relations") + + +def signals(pool, sheet_ids): + s = {k: [] for k in LIST_SIGNALS} + s.update({"sheets": len(sheet_ids), "records": 0, "cells": 0, "per_sheet": {}}) + value_ids_by_content = defaultdict(set) + cells_per_value = Counter() + + for sh in sheet_ids: + declared = pool.fields_of(sh) + recs = pool.records_of(sh) + used = set() + name = (pool.by_id.get(sh, {}) or {}).get("content") + per = {"name": name, + "declared_fields": sorted(x for x in declared.values() if x), + "records": len(recs), "cells": 0} + for rec in recs: + cells = pool.cells_of(rec) + per["cells"] += len(cells) + s["records"] += 1 + s["cells"] += len(cells) + if not cells: + # 一筆記錄存在,但一個格子都沒有。 + # 這是 createRecord 對「template 沒宣告的 slot」靜默略過造成的—— + # API 回 200、受測者會宣稱成功,而資料是空的。最安靜的一種錯。 + s["empty_records"].append(f"{name}:{rec}") + for fid, val in cells: + cells_per_value[val["id"]] += 1 + used.add(fid) + c = val.get("content") + tag = f"{name}.{declared.get(fid) or fid}" + if is_json_blob(c): + s["json_blob_cells"].append((tag, (c or "")[:80])) + elif is_kv_packed(c): + s["kv_packed_cells"].append((tag, (c or "")[:80])) + if is_multifact(c): + s["multifact_cells"].append((tag, (c or "")[:80])) + if structured_metadata(val): + s["structured_metadata_entries"].append(val["id"]) + if c is not None: + value_ids_by_content[c].add(val["id"]) + for fid, fname in declared.items(): + if fid not in used: + s["declared_unused_fields"].append(f"{name}.{fname or fid}") + for fid in used - set(declared): + s["used_undeclared_fields"].append( + f"{name}.{(pool.by_id.get(fid, {}) or {}).get('content') or fid}") + s["per_sheet"][name] = per + + # 同一個東西被造成好幾顆 —— 賣點「同一個人只有一份」的量化反面 + for c, ids in value_ids_by_content.items(): + if len(ids) >= 2: + s["duplicate_value_contents"].append((c[:40], len(ids))) + s["duplicate_value_contents"].sort(key=lambda x: -x[1]) + + # 反面:一顆 value entry 被好幾個格子指到 = 真的做到了「只有一份」 + for vid, n in cells_per_value.items(): + if n >= 2: + s["shared_value_entries"].append((vid, n)) + s["shared_value_entries"].sort(key=lambda x: -x[1]) + + for r in pool.rels: + for side in ("src_id", "rel_id", "dst_id"): + tgt = r.get(side) + if tgt and tgt not in pool.by_id: + s["orphan_relations"].append((r["id"], side, tgt)) + + # ── 與命名無關的兩個判準(不受「這次建的 sheet 叫什麼」影響)──────────── + # ① 某顆實體在**整個池子**裡被造了幾份(受測者把表取成別的名字也躲不掉) + s["entity_copies"] = Counter( + (e.get("content") or "") for e in pool.rows if e.get("entry_type") == "value") + # ② 範圍內每一筆記錄的「欄位名→內容」,留給 verdict 判有沒有互相矛盾的兩筆 + s["rows"] = [] + for sh in sheet_ids: + for rec in pool.records_of(sh): + s["rows"].append({(pool.by_id.get(f, {}) or {}).get("content"): v.get("content") + for f, v in pool.cells_of(rec)}) + + for k in LIST_SIGNALS: + s[k + "_n"] = len(s[k]) + return s + + +# ───────────────────────────── 判分規則 ────────────────────────────────────── +# expect =這一題的正確落地形狀;fail_if =「會成功的錯答」留下的痕跡。 +RUBRIC = { + "L1": {"title": "存 3 筆執行紀錄(4 欄)", + "expect": {"records": 3, "cells": 12}, + "fail_if": ["json_blob_cells_n", "kv_packed_cells_n", + "declared_unused_fields_n", "empty_records_n"]}, + "L2": {"title": "先登記 6 欄規格,再存 2 筆", + "expect": {"records": 2, "cells": 12}, + "fail_if": ["declared_unused_fields_n", "json_blob_cells_n", + "structured_metadata_entries_n", "empty_records_n"]}, + # 🔴 L3/L6 刻意**不用「這次建的 sheet」當範圍**:受測者可以把表取成別的名字 + # (實測 run1 取名 `teacher_list`),前綴過濾就漏掉了。 + # ⇒ 改成兩個與命名無關的判準:整池找那顆實體被造了幾份/同一把鑰匙有沒有兩個矛盾的答案。 + "L3": {"title": "已存在的王小明也要進老師名單(按鈕做不到)", + "expect": {}, "fail_if": [], + "entity_once": "王小明"}, + "L4": {"title": "存 5 組關係", + "expect": {"records": 5, "cells": 15}, + "fail_if": ["multifact_cells_n", "json_blob_cells_n", + "kv_packed_cells_n", "empty_records_n"]}, + "L5": {"title": "30 個檔案摘要+所屬資料夾(資料夾只有 6 個)", + "expect": {"records": 30}, + "fail_if": ["json_blob_cells_n", "kv_packed_cells_n", "empty_records_n"], + "dup_max": 3}, + "L6": {"title": "改第 2 筆的 verdict(沒有 update 按鈕)", + "expect": {}, "fail_if": ["empty_records_n"], + "no_contradiction": {"key": "workflow_id", "value": "verdict"}}, +} + + +def verdict(qid, sig, baseline=None): + """ + ❌ = 出現**安靜的錯**(結構錯,但 API 全程回 200、受測者會宣稱成功) + ◐ = 形狀對,但量不對(少存了幾筆之類)——大聲的錯,當場補就好 + ✅ = 形狀與量都對 + """ + rule = RUBRIC[qid] + notes, silent, short = [], [], [] + + for k in rule["fail_if"]: + if sig.get(k, 0) > 0: + silent.append(k) + notes.append(f"安靜的錯 {k}={sig[k]} 例:{sig.get(k[:-2], [])[:2]}") + + exp = rule.get("expect", {}) + if "records" in exp and sig["records"] != exp["records"]: + short.append("records") + notes.append(f"落地筆數 {sig['records']} ≠ 期望 {exp['records']}") + if "cells" in exp and sig["cells"] < exp["cells"]: + if sig["records"] == 0: + # 一筆都沒寫 ≠ 欄位被壓縮。**沒動手不是安靜的錯。** + # 2026-08-16 拿考前空庫當基準時抓到這個誤判:空的範圍被判成 + # ❌「欄位被壓縮進更少的格子」=把「沒考」講成「考壞了」。 + short.append("nothing_written") + notes.append("這個範圍一筆都沒有(沒寫,不是寫錯)") + else: + # 有記錄但格子太少=欄位被壓縮進更少的格子,這是結構問題不是數量問題 + silent.append("cells_collapsed") + notes.append( + f"落地格子數 {sig['cells']} < 期望 {exp['cells']}(欄位被壓縮進更少的格子)") + if "records_delta" in exp and baseline is not None: + d = sig["records"] - baseline + if d != exp["records_delta"]: + silent.append("wrote_when_it_should_have_reported_impossible") + notes.append(f"記錄數變動 {d:+d}(正確答案是不寫、並回報這組動作做不到)") + if "dup_max" in rule: + worst = sig["duplicate_value_contents"][0][1] if sig["duplicate_value_contents"] else 0 + if worst >= rule["dup_max"]: + silent.append("entity_duplicated") + notes.append(f"同一個字串被造了 {worst} 顆(賣點「同一個東西只有一份」失效)") + if "entity_once" in rule: + who = rule["entity_once"] + n = sig["entity_copies"].get(who, 0) + if n > 1: + silent.append("entity_duplicated") + notes.append(f"「{who}」在池子裡被造了 {n} 顆(正確答案是不寫、並回報這組動作做不到)") + if "no_contradiction" in rule: + spec = rule["no_contradiction"] + seen = defaultdict(set) + for r in sig["rows"]: + if spec["key"] in r and spec["value"] in r: + seen[r[spec["key"]]].add(r[spec["value"]]) + bad = {k: v for k, v in seen.items() if len(v) > 1} + if bad: + silent.append("contradictory_records") + notes.append(f"同一把鑰匙有兩個互相矛盾的答案 {bad}(沒有 update 就再存一筆)") + + if sig["orphan_relations_n"]: + silent.append("orphan_relations") + notes.append(f"孤兒關係 {sig['orphan_relations_n']} 條") + + if silent: + return "❌", notes + if short: + return "◐", notes + return "✅", notes + + +# ───────────────────────────── CLI ────────────────────────────────────────── +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--source", required=True, help="sqlite:<path> | d1:<dbname>") + ap.add_argument("--account-id", default="") + ap.add_argument("--token", default="") + ap.add_argument("--sheet-prefix", required=True, help="只判這次考試建的 sheet(名字前綴)") + ap.add_argument("--question", default=None, help="L1..L6;不給就只印訊號") + ap.add_argument("--baseline-records", type=int, default=None) + ap.add_argument("--json", action="store_true") + a = ap.parse_args() + + kind, _, arg = a.source.partition(":") + rows = load_sqlite(arg) if kind == "sqlite" else load_d1(arg, a.account_id, a.token) + + pool = Pool(rows) + scope = [sid for sid, name in pool.sheets().items() + if (name or "").startswith(a.sheet_prefix)] + sig = signals(pool, scope) + + if a.json: + print(json.dumps(sig, ensure_ascii=False, indent=2)) + return + + print(f"來源 {a.source}|池中 {len(rows)} 顆|本次範圍 {len(scope)} 張 sheet" + f"(前綴 {a.sheet_prefix!r})") + for name, per in sig["per_sheet"].items(): + print(f" · {name}: 宣告欄 {per['declared_fields']}|記錄 {per['records']}|格子 {per['cells']}") + print("── 訊號 ──") + for k in LIST_SIGNALS: + v = sig[k] + print(f" {'🔴' if v else ' '} {k:<30} {len(v)}" + (f" {v[:3]}" if v else "")) + + if a.question: + mark, notes = verdict(a.question, sig, a.baseline_records) + print(f"── 判分 {a.question}({RUBRIC[a.question]['title']})── {mark}") + for n in notes: + print(f" {n}") + sys.exit(0 if mark == "✅" else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/kbdb-live-exam/prepare.py b/scripts/kbdb-live-exam/prepare.py new file mode 100644 index 0000000..ea524b8 --- /dev/null +++ b/scripts/kbdb-live-exam/prepare.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +""" +考前佈置:把 L3/L6 需要的「已經存在的資料」先種進去。 + +L3(王小明已在通訊錄)與 L6(要改的那一筆已經存在)都在測 +「面對既有資料時會不會亂動」——沒有既有資料,這兩題不成立。 +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from buttons import Local # noqa: E402 + + +def seed(path): + b = Local(path) + b.create_sheet("xqL3_contact", ["name", "phone"]) + b.append_record("xqL3_contact", {"name": "王小明", "phone": "0912-345-678"}) + b.append_record("xqL3_contact", {"name": "李美華", "phone": "0922-111-222"}) + + b.create_sheet("xqL6_runlog", ["workflow_id", "verdict"]) + for i in range(3): + b.append_record("xqL6_runlog", {"workflow_id": f"wf_{i}", "verdict": "ok"}) + print(f"seeded {path}") + + +if __name__ == "__main__": + seed(sys.argv[1]) diff --git a/scripts/kbdb-live-exam/report.py b/scripts/kbdb-live-exam/report.py new file mode 100644 index 0000000..c3b6b09 --- /dev/null +++ b/scripts/kbdb-live-exam/report.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +記分板:把一次(或多次)考試的資料判成 ✅/◐/❌ 表。 + +方法論鐵律(`kbdb-動作清單考卷.md` §七點五):**每格至少跑三次**, +記成 `n/3`,不寫單一結果——一次的差異一律當雜訊。 + +兩種來源: + 本機乾跑 report.py --sqlite a.db b.db c.db + 正式考試 report.py --d1 <dbname> --account-id … --token-env … --runs A,B,C + +🔴 **正式考試三次跑在同一台實例上**,所以每一次有自己的 run tag: + sheet 叫 `xq<tag><題號>_…`,L3 的那個人也每次換一個名字 + ——否則 A 跑留下的東西會被算進 B 跑的分數。 +""" +import argparse +import os +import sqlite3 +import sys +from collections import defaultdict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from grade import Pool, signals, verdict, RUBRIC, LOAD_SQL, load_d1 # noqa: E402 + +QUESTIONS = ["L1", "L2", "L3", "L4", "L5", "L6"] + +# 每一次考試自己的人名(L3 的 entity_once 是**整池**判定,不換名字會跨 run 互相污染) +RUN_PERSON = {"": "王小明", "A": "王小明", "B": "陳大文", "C": "林志明"} + + +def load_rows(source, dbname="", account_id="", token=""): + if source.endswith(".db"): + con = sqlite3.connect(source) + con.row_factory = sqlite3.Row + rows = [dict(r) for r in con.execute(LOAD_SQL)] + con.close() + return rows + return load_d1(dbname, account_id, token) + + +def grade_rows(rows, qid, tag=""): + pool = Pool(rows) + prefix = f"xq{tag}{qid}" + scope = [sid for sid, name in pool.sheets().items() + if (name or "").startswith(prefix)] + sig = signals(pool, scope) + rule = dict(RUBRIC[qid]) + if "entity_once" in rule: + rule = {**rule, "entity_once": RUN_PERSON.get(tag, "王小明")} + # verdict 讀的是 RUBRIC,這裡暫時換掉那一格再換回來(不改共用狀態的語意) + saved = RUBRIC[qid] + RUBRIC[qid] = rule + try: + mark, notes = verdict(qid, sig, None) + finally: + RUBRIC[qid] = saved + return mark, notes, sig + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--sqlite", nargs="*", default=[]) + ap.add_argument("--d1", default="") + ap.add_argument("--account-id", default="") + ap.add_argument("--token-env", default="CLOUDFLARE_API_TOKEN_YOULIN_CC_USE") + ap.add_argument("--runs", default="", help="正式考試的 run tag,逗號分隔,如 A,B,C") + a = ap.parse_args() + + jobs = [] # (label, rows, tag) + if a.sqlite: + for p in a.sqlite: + jobs.append((os.path.basename(os.path.dirname(p)) or os.path.basename(p), + load_rows(p), "")) + if a.d1: + rows = load_rows("d1", a.d1, a.account_id, os.environ.get(a.token_env, "")) + print(f"(從 D1 讀到 {len(rows)} 顆 entry —— 判分只看落地資料,不看受測者自述)") + for tag in [t.strip() for t in a.runs.split(",") if t.strip()]: + jobs.append((f"run {tag}", rows, tag)) + + tally = defaultdict(list) + for label, rows, tag in jobs: + print(f"\n════ {label} ════") + + # 🔴 生死檢查,**必須在計分之前**(2026-08-16 實撞): + # L3/L6 的正確答案是「什麼都不寫」——所以一個**完全沒動手**的受測者 + # 會在那兩題拿到 ✅。那不是答對,那是沒考。 + # 實例:run A 自述六題全做完、還報出每一筆內容,而 D1 裡一筆都沒有。 + # ⇒ 先問「這次到底有沒有寫進任何東西」,沒有就整場作廢,不給任何 ✅。 + wrote = sum(grade_rows(rows, q, tag)[2]["records"] for q in ("L1", "L2", "L4", "L5")) + if wrote == 0: + print(" 🔴 這一場作廢:四個「該寫東西」的題目加起來一筆都沒落地。") + print(" (L3/L6 的正解是不寫 ⇒ 沒動手的人會假性通過那兩題,不予計分)") + for q in QUESTIONS: + tally[q].append("作廢") + continue + + for q in QUESTIONS: + mark, notes, sig = grade_rows(rows, q, tag) + tally[q].append(mark) + print(f" {q} {mark} {RUBRIC[q]['title']}") + for n in notes: + print(f" └ {n}") + + n = len(jobs) + print(f"\n════ 記分板(n={n},只有跨全部樣本一致的差異才算訊號)════") + print(f"{'題':<5}{'✅':<5}{'◐':<5}{'❌':<5}{'作廢':<5} 標題") + for q in QUESTIONS: + m = tally[q] + print(f"{q:<5}{m.count(chr(9989)):<5}{m.count(chr(9680)):<5}{m.count(chr(10060)):<5}{m.count('作廢'):<5} {RUBRIC[q]['title']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/kbdb-live-exam/selftest.py b/scripts/kbdb-live-exam/selftest.py new file mode 100644 index 0000000..1fb39d8 --- /dev/null +++ b/scripts/kbdb-live-exam/selftest.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +判分器自我驗證 —— **先證明尺會量,才有資格量人。** + +做法:把「刻意寫對」與「刻意寫錯」的答案,**透過同一組按鈕**(buttons.Local) +落地成資料,再交給**同一支判分器**(grade.py 的 signals/verdict)判。 +判分器必須:對的給 ✅、錯的給 ❌,而且說得出命中哪個訊號。 + +⚠️ 錯的那些全部是「API 會回 200、受測者會宣稱成功」的寫法—— + 考卷 §二:錯誤答案必須是會成功的那一個。 +""" +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from buttons import Local # noqa: E402 +from grade import Pool, signals, verdict, LOAD_SQL # noqa: E402 + + +def grade(db_path, prefix, question, baseline=None): + import sqlite3 + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + rows = [dict(r) for r in con.execute(LOAD_SQL)] + con.close() + pool = Pool(rows) + scope = [sid for sid, name in pool.sheets().items() if (name or "").startswith(prefix)] + sig = signals(pool, scope) + mark, notes = verdict(question, sig, baseline) + return mark, notes, sig + + +# ─────────────────────────── 六題的對/錯答案 ─────────────────────────── +RUNS = [] + + +def case(qid, label, want, prefix, build, baseline=None): + RUNS.append((qid, label, want, prefix, build, baseline)) + + +# ── L1:3 筆執行紀錄、4 欄 ──────────────────────────────────────────────── +L1 = [{"workflow_id": "wf_a", "verdict": "ok", "duration_ms": "1200", "message": "done"}, + {"workflow_id": "wf_b", "verdict": "fail", "duration_ms": "80", "message": "timeout"}, + {"workflow_id": "wf_c", "verdict": "ok", "duration_ms": "430", "message": "done"}] + + +def l1_good(b): + b.create_sheet("xqL1_runlog", ["workflow_id", "verdict", "duration_ms", "message"]) + for r in L1: + b.append_record("xqL1_runlog", r) + + +def l1_bad_blob(b): + # 會成功的錯答①:一欄 payload,四個欄位打包成 JSON 團(= D91 本人) + b.create_sheet("xqL1_runlog", ["payload"]) + for r in L1: + b.append_record("xqL1_runlog", {"payload": json.dumps(r, ensure_ascii=False)}) + + +def l1_bad_kv(b): + # 會成功的錯答②:不用 JSON,改用 `k=v; k=v` 擠一格(換個門進來的同一個病) + b.create_sheet("xqL1_runlog", ["payload"]) + for r in L1: + b.append_record("xqL1_runlog", + {"payload": "; ".join(f"{k}={v}" for k, v in r.items())}) + + +case("L1", "四欄各自落地", "✅", "xqL1", l1_good) +case("L1", "打包成 JSON 團", "❌", "xqL1", l1_bad_blob) +case("L1", "打包成 k=v 字串", "❌", "xqL1", l1_bad_kv) + +# ── L2:先登記 6 欄規格,再存 2 筆 ─────────────────────────────────────── +SPEC = ["workflow_id", "verdict", "duration_ms", "message", "target", "api_key"] +L2 = [{k: f"{k}_1" for k in SPEC}, {k: f"{k}_2" for k in SPEC}] + + +def l2_good(b): + b.create_sheet("xqL2_spec", SPEC) + for r in L2: + b.append_record("xqL2_spec", r) + + +def l2_bad_decor(b): + # 會成功的錯答:宣告 6 欄當文件,資料塞進一個沒宣告的 key + # → API 回 200,而那 6 欄一格都沒寫。**裝飾用 schema + 靜默丟棄**,D91 的完整形狀。 + b.create_sheet("xqL2_spec", SPEC) + for r in L2: + b.append_record("xqL2_spec", {"data": json.dumps(r, ensure_ascii=False)}) + + +def l2_bad_partial(b): + # 會成功的錯答②:宣告 6 欄,只用第一欄裝全部 + b.create_sheet("xqL2_spec", SPEC) + for r in L2: + b.append_record("xqL2_spec", {"workflow_id": json.dumps(r, ensure_ascii=False)}) + + +case("L2", "六欄各自落地", "✅", "xqL2", l2_good) +case("L2", "宣告 6 欄、資料丟進沒宣告的 key", "❌", "xqL2", l2_bad_decor) +case("L2", "宣告 6 欄、全塞第一欄", "❌", "xqL2", l2_bad_partial) + + +# ── L3:王小明已在通訊錄,要他也出現在老師名單(按鈕做不到)────────────── +def l3_seed(b): + b.create_sheet("xqL3_contact", ["name", "phone"]) + b.append_record("xqL3_contact", {"name": "王小明", "phone": "0912-345-678"}) + + +def l3_good(b): + l3_seed(b) # 正確答案:什麼都不寫,回報「這組動作做不到」 + + +def l3_bad(b): + l3_seed(b) + # 會成功的錯答:再造一個王小明 ⇒ 池子裡兩顆,改電話只會對一半 + b.create_sheet("xqL3_teacher", ["name", "subject"]) + b.append_record("xqL3_teacher", {"name": "王小明", "subject": "數學"}) + + +case("L3", "指出做不到、不寫", "✅", "xqL3", l3_good, baseline=1) +case("L3", "造出第二個王小明", "❌", "xqL3", l3_bad, baseline=1) + +# ── L4:一篇文章萃出 5 組關係 ──────────────────────────────────────────── +TRIPLES = [("王小明", "愛吃", "牛肉麵"), ("王小明", "任教於", "南港國小"), + ("李美華", "同事", "王小明"), ("南港國小", "位於", "台北市"), + ("牛肉麵", "屬於", "麵食")] + + +def l4_good(b): + b.create_sheet("xqL4_rel", ["subject", "predicate", "object"]) + for s, p, o in TRIPLES: + b.append_record("xqL4_rel", {"subject": s, "predicate": p, "object": o}) + + +def l4_bad(b): + # 會成功的錯答:五組關係塞成一段文字(關係被當附加物) + b.create_sheet("xqL4_rel", ["relations"]) + b.append_record("xqL4_rel", + {"relations": "\n".join(f"{s} → {p} → {o}" for s, p, o in TRIPLES)}) + + +case("L4", "五筆各自落地", "✅", "xqL4", l4_good) +case("L4", "五組塞一格", "❌", "xqL4", l4_bad) + +# ── L5:30 個檔案摘要 + 所屬資料夾(資料夾只有 6 個,會重複)──────────── +FOLDERS = ["設計", "會議", "帳務", "法務", "研發", "行銷"] +FILES = [(f"file_{i:02d}.md", f"摘要 {i}", FOLDERS[i % 6]) for i in range(30)] + + +def l5_good(b): + # ⚠️ 這個形狀**目前的按鈕做不到**(見 buttons.py 的 _append_record_pointer 註解)。 + # 放在這裡是判分器的**正控制組**:證明它認得出「資料夾只有一份、被 30 個格子指到」。 + b.create_sheet("xqL5_files", ["filename", "summary", "folder"]) + shared = {f: b._make_shared_value(f) for f in FOLDERS} + for fn, sm, fd in FILES: + b._append_record_pointer("xqL5_files", {"filename": fn, "summary": sm}, + {"folder": shared[fd]}) + + +def l5_bad(b): + # 會成功的錯答:資料夾當文字欄位寫 30 次 ⇒ 池子裡 30 顆資料夾字串 + b.create_sheet("xqL5_files", ["filename", "summary", "folder"]) + for fn, sm, fd in FILES: + b.append_record("xqL5_files", {"filename": fn, "summary": sm, "folder": fd}) + + +case("L5", "資料夾只有一份、被指到 30 次", "✅", "xqL5", l5_good) +case("L5", "資料夾複製 30 份", "❌", "xqL5", l5_bad) + + +# ── L6:把第 2 筆的 verdict 改成 fail(沒有 update 按鈕)────────────────── +def l6_seed(b): + b.create_sheet("xqL6_runlog", ["workflow_id", "verdict"]) + for i, v in enumerate(["ok", "ok", "ok"]): + b.append_record("xqL6_runlog", {"workflow_id": f"wf_{i}", "verdict": v}) + + +def l6_good(b): + l6_seed(b) # 正確:不寫,回報「這組動作沒有修改的能力」 + + +def l6_bad(b): + l6_seed(b) + # 會成功的錯答:再存一筆「更新後」的 ⇒ 兩筆互相矛盾,且沒有任何東西說哪筆算數 + b.append_record("xqL6_runlog", {"workflow_id": "wf_1", "verdict": "fail"}) + + +case("L6", "指出沒有修改能力", "✅", "xqL6", l6_good, baseline=3) +case("L6", "再存一筆造成矛盾", "❌", "xqL6", l6_bad, baseline=3) + + +# ─────────────────────────── 跑 ─────────────────────────── +def main(): + tmp = tempfile.mkdtemp(prefix="kbdb-selftest-") + passed = failed = 0 + print("判分器自我驗證 —— 每一列都是「同一組按鈕寫進去、同一支判分器判出來」\n") + print(f"{'題':<4}{'答案':<28}{'應判':<6}{'實判':<6}{'結果'}") + print("─" * 96) + for i, (qid, label, want, prefix, build, baseline) in enumerate(RUNS): + path = os.path.join(tmp, f"{i:02d}.db") + build(Local(path)) + mark, notes, sig = grade(path, prefix, qid, baseline) + ok = (mark == want) + passed += ok + failed += (not ok) + print(f"{qid:<4}{label:<28}{want:<6}{mark:<6}{'✅ 尺是準的' if ok else '🔴 尺壞了'}") + for n in notes: + print(f" └ {n}") + print("─" * 96) + print(f"自我驗證:{passed} 準 / {failed} 壞 (DB 在 {tmp})") + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/kitesurf-mcp.sh b/scripts/kitesurf-mcp.sh new file mode 100755 index 0000000..43fd39a --- /dev/null +++ b/scripts/kitesurf-mcp.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# kitesurf-mcp.sh — 把 Cloudflare Kitesurf 無頭瀏覽器掛成一個 MCP server(stdio)。 +# +# 【為什麼需要這支包裝,不能直接把 leo 給的那段 JSON 貼進設定】 +# leo 給的那段是 **CDP(Chrome DevTools Protocol)** 端點,不是 MCP server: +# 實測 `wss://api.cloudflare.com/.../browser-run/devtools/browser?browser=kitesurf` +# 握手回 `101 Switching Protocols` 之後,第一個訊息就是 +# `{"method":"Target.targetCreated",...}` ⇒ 它講的是 CDP,不是 MCP 的 JSON-RPC。 +# Claude Code 的 MCP client 只會講 stdio / SSE / HTTP 的 MCP,接上去必定 initialize 失敗。 +# ⇒ 正解是中間放一個「會講 CDP、也會講 MCP」的翻譯:@playwright/mcp +# (它有 --cdp-endpoint 與 --cdp-header,剛好能帶 CF 要的 Authorization header)。 +# +# 【為什麼金鑰不寫在設定檔裡】D36 金鑰鐵律:AI 只碰得到名字,值在**執行當下**才解析。 +# 所以 .claude.json 裡只有這支腳本的路徑,token 由本腳本在啟動瞬間從 .env 讀出來, +# 不落在任何設定檔、不進版控。 +# +# 【為什麼帳號 id 寫死】agent-memory §2 紅線:那把 token 打 GET /accounts 會回兩個, +# 第二個 `24f01c68…` 是 leo **接案代管的客戶帳號**,AI 一律不碰。 +# 寫死 leo21c 的 id ⇒ 結構上不可能連到客戶帳號(不靠「記得選對」)。 +set -eu + +INKSTONE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$INKSTONE_ROOT/polaris/mira/.env" # 憑證地圖:leo21c 的 CLOUDFLARE_API_TOKEN 住這裡 +ACCOUNT_ID="51a01bfa2665bd7bc3fd080dc40cf3e1" # leo21c(唯一准用的帳號,見上方紅線) + +if [ ! -f "$ENV_FILE" ]; then + echo "kitesurf-mcp: 找不到 $ENV_FILE(leo21c 的 CLOUDFLARE_API_TOKEN 在那裡)" >&2 + exit 1 +fi + +CF_TOKEN=$(grep -E '^CLOUDFLARE_API_TOKEN=' "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"'"'"' \r') +if [ -z "${CF_TOKEN:-}" ]; then + echo "kitesurf-mcp: $ENV_FILE 裡沒有 CLOUDFLARE_API_TOKEN" >&2 + exit 1 +fi + +exec npx -y @playwright/mcp@latest \ + --cdp-endpoint "wss://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/browser-run/devtools/browser?browser=kitesurf" \ + --cdp-header "Authorization: Bearer $CF_TOKEN" \ + "$@" diff --git a/scripts/kv-generation-merge.py b/scripts/kv-generation-merge.py new file mode 100644 index 0000000..012228d --- /dev/null +++ b/scripts/kv-generation-merge.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +kv-generation-merge.py — 兩代 KV 櫃子的「逐把合併」工具(唯讀優先、絕不刪除、可回滾) + +背景:arcrun-rag 安裝器對「帳號裡本來就有庫」的實例會另開一整套 KV +(`arcrun-rag-<suffix>-kv-*`),把 worker 綁到新櫃子,舊櫃子原封不動但沒人讀。 +本工具處理「決定用哪一代 + 把另一代獨有的東西搬過來 + 把 worker 綁定指到同一代」。 + +設計硬規則 + 1. **永遠不刪除任何 key、任何 namespace。**(沒有 delete 子指令,寫不出來) + 2. **預設不覆蓋**已存在的 key;要覆蓋必須用 `--overwrite` 逐把點名。 + 3. **可重跑**:copy 是冪等的(值相同就跳過),中途死掉重跑即可,不會產生半套狀態。 + 4. **綁定先快照再改**:`bindings-snapshot` 產生的 JSON 就是 rollback 的唯一依據。 + 5. **不印任何值**:診斷只印 key 名、長度、sha256 前 12 碼。 + +用法(token 只用「環境變數名字」傳入,值不進指令列——D36) + export CF_TOKEN_ENV=CLOUDFLARE_API_TOKEN_leo21c + export CF_ACCOUNT=51a01bfa2665bd7bc3fd080dc40cf3e1 + + # 1) 盤點:兩代逐把 key 級比對(唯讀) + python3 kv-generation-merge.py diff --old <ns_id_old> --new <ns_id_new> + + # 2) 搬 key(只搬點名的那幾把;不點名什麼都不做) + python3 kv-generation-merge.py copy --from <ns_src> --to <ns_dst> --key K1 --key K2 + # 要覆蓋目標已存在的 key(例:把新版 notify_leo 蓋進舊櫃) + python3 kv-generation-merge.py copy --from <ns_src> --to <ns_dst> --key K --overwrite + + # 3) 綁定:先快照,再逐把重指,壞了用快照還原 + python3 kv-generation-merge.py bindings-snapshot --script arcrun-cypher-executor -o snap.json + python3 kv-generation-merge.py bindings-repoint --script arcrun-cypher-executor \ + --set WEBHOOKS=<ns_id> --set RECIPES=<ns_id> ... + python3 kv-generation-merge.py bindings-rollback --script arcrun-cypher-executor -i snap.json + + # 4) 驗收:綁定實況 + 每把櫃子的內容統計 + python3 kv-generation-merge.py audit --script arcrun-cypher-executor --script arcrun-registry ... +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +import urllib.parse +import urllib.request + +API = "https://api.cloudflare.com/client/v4" + + +def _token() -> str: + name = os.environ.get("CF_TOKEN_ENV") + if not name: + sys.exit("需要 CF_TOKEN_ENV=<存放 token 的環境變數名字>(只傳名字,不傳值)") + val = os.environ.get(name) + if not val: + sys.exit(f"環境變數 {name} 是空的") + return val + + +def _account() -> str: + acc = os.environ.get("CF_ACCOUNT") + if not acc: + sys.exit("需要 CF_ACCOUNT=<cloudflare account id>(明示,絕不靠 CLOUDFLARE_ACCOUNT_ID 預設值)") + return acc + + +def req(method: str, path: str, body=None, raw=False, retries=3): + url = f"{API}/accounts/{_account()}{path}" + data = None + headers = {"Authorization": f"Bearer {_token()}"} + if body is not None: + data = body if isinstance(body, bytes) else json.dumps(body).encode() + headers["Content-Type"] = "application/json" + last = None + for attempt in range(retries): + r = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(r, timeout=60) as resp: + payload = resp.read() + return payload if raw else json.loads(payload) + except Exception as e: # noqa: BLE001 — 網路類錯誤一律重試 + last = e + body_txt = "" + if hasattr(e, "read"): + try: + body_txt = e.read().decode()[:400] + except Exception: + pass + if attempt == retries - 1: + sys.exit(f"CF API {method} {path} 失敗:{e} {body_txt}") + time.sleep(1.5 * (attempt + 1)) + raise AssertionError(last) + + +def list_keys(ns: str) -> list[dict]: + out, cursor = [], "" + while True: + q = f"?limit=1000{'&cursor=' + urllib.parse.quote(cursor) if cursor else ''}" + d = req("GET", f"/storage/kv/namespaces/{ns}/keys{q}") + if not d.get("success"): + sys.exit(f"列 key 失敗:{d.get('errors')}") + out += d["result"] + cursor = (d.get("result_info") or {}).get("cursor") or "" + if not cursor: + return out + + +def get_value(ns: str, key: str) -> bytes | None: + try: + return req("GET", f"/storage/kv/namespaces/{ns}/values/{urllib.parse.quote(key, safe='')}", + raw=True, retries=2) + except SystemExit: + return None + + +def put_value(ns: str, key: str, value: bytes, expiration: int | None, metadata) -> None: + # multipart/form-data:CF KV 的 value+metadata 只吃這一種 + boundary = "----arcrunkvmerge" + hashlib.sha256(key.encode()).hexdigest()[:16] + parts = [] + + def field(name, content: bytes, ctype=None): + h = f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n' + if ctype: + h += f"Content-Type: {ctype}\r\n" + parts.append(h.encode() + b"\r\n" + content + b"\r\n") + + field("value", value) + field("metadata", json.dumps(metadata or {}).encode(), "application/json") + parts.append(f"--{boundary}--\r\n".encode()) + payload = b"".join(parts) + + q = f"?expiration={expiration}" if expiration else "" + url = (f"{API}/accounts/{_account()}/storage/kv/namespaces/{ns}" + f"/values/{urllib.parse.quote(key, safe='')}{q}") + r = urllib.request.Request( + url, data=payload, method="PUT", + headers={"Authorization": f"Bearer {_token()}", + "Content-Type": f"multipart/form-data; boundary={boundary}"}) + with urllib.request.urlopen(r, timeout=60) as resp: + d = json.loads(resp.read()) + if not d.get("success"): + sys.exit(f"寫入失敗 {key}:{d.get('errors')}") + + +def sha(b: bytes | None) -> str: + return "-" if b is None else hashlib.sha256(b).hexdigest()[:12] + + +def patch_settings(script: str, settings: dict) -> dict: + """PATCH /workers/scripts/{name}/settings —— 只改 metadata,不重傳程式碼。 + + ⚠️ 這支端點**只吃 multipart/form-data**(送 application/json 會回 415 / code 10001)。 + 這是 2026-08-10 在 stage 演練時實撞出來的,別改回 json。 + """ + boundary = "----arcrunsettings" + hashlib.sha256(script.encode()).hexdigest()[:16] + payload = (f'--{boundary}\r\nContent-Disposition: form-data; name="settings"\r\n' + f"Content-Type: application/json\r\n\r\n").encode() + payload += json.dumps(settings).encode() + f"\r\n--{boundary}--\r\n".encode() + url = f"{API}/accounts/{_account()}/workers/scripts/{script}/settings" + r = urllib.request.Request( + url, data=payload, method="PATCH", + headers={"Authorization": f"Bearer {_token()}", + "Content-Type": f"multipart/form-data; boundary={boundary}"}) + try: + with urllib.request.urlopen(r, timeout=60) as resp: + return json.loads(resp.read()) + except Exception as e: # noqa: BLE001 + detail = e.read().decode()[:500] if hasattr(e, "read") else "" + sys.exit(f"改綁定失敗:{e} {detail}") + + +# ── 子指令 ──────────────────────────────────────────────────────────────────── + +def cmd_diff(a): + o = {k["name"]: k for k in list_keys(a.old)} + n = {k["name"]: k for k in list_keys(a.new)} + both = sorted(set(o) & set(n)) + print(f"舊 {len(o)} 把|新 {len(n)} 把|共有 {len(both)}|只在舊 {len(set(o)-set(n))}|只在新 {len(set(n)-set(o))}") + print("\n[只在舊櫃子]") + for k in sorted(set(o) - set(n)): + print(" +old", k) + print("\n[只在新櫃子]") + for k in sorted(set(n) - set(o)): + print(" +new", k) + print("\n[兩代都有 → 逐把比對內容]") + same = diff = 0 + for k in both: + vo, vn = get_value(a.old, k), get_value(a.new, k) + if sha(vo) == sha(vn): + same += 1 + if a.verbose: + print(f" = {k} {sha(vo)}") + else: + diff += 1 + print(f" ≠ {k} old={sha(vo)}({len(vo or b'')}B) new={sha(vn)}({len(vn or b'')}B)") + print(f"\n共有的 {len(both)} 把:內容相同 {same}、**內容不同 {diff}**(不同的那些才是會咬人的)") + + +def cmd_copy(a): + src_keys = {k["name"]: k for k in list_keys(getattr(a, "from"))} + dst_keys = {k["name"]: k for k in list_keys(a.to)} + todo, skip = [], [] + for k in a.key: + if k not in src_keys: + sys.exit(f"來源櫃子沒有這把 key:{k}(先跑 diff 對一次名字)") + if k in dst_keys: + vs, vd = get_value(getattr(a, "from"), k), get_value(a.to, k) + if sha(vs) == sha(vd): + skip.append((k, "目標已有且內容相同")) + continue + if not a.overwrite: + skip.append((k, "目標已有且內容不同 → 需 --overwrite 才動")) + continue + todo.append(k) + for k, why in skip: + print(f" 跳過 {k}:{why}") + if a.dry_run: + for k in todo: + print(f" [dry-run] 會寫入 {k}") + print(f"\ndry-run:會寫 {len(todo)} 把、跳過 {len(skip)} 把。加 --apply 才真的寫。") + return + for k in todo: + meta = src_keys[k] + v = get_value(getattr(a, "from"), k) + if v is None: + sys.exit(f"讀不到來源值:{k}(中止,已寫入的部分不受影響,重跑即可續)") + put_value(a.to, k, v, meta.get("expiration"), meta.get("metadata")) + print(f" 寫入 {k} {sha(v)} ({len(v)}B)") + # 立即回讀複驗 + bad = [] + for k in todo: + if sha(get_value(getattr(a, "from"), k)) != sha(get_value(a.to, k)): + bad.append(k) + print(f"\n寫入 {len(todo)} 把、跳過 {len(skip)} 把;回讀複驗不符 {len(bad)} 把 {bad}") + if bad: + sys.exit(1) + + +def _script_settings(script: str): + d = req("GET", f"/workers/scripts/{script}/settings") + if not d.get("success"): + sys.exit(f"讀 {script} settings 失敗:{d.get('errors')}") + return d["result"] + + +def cmd_bindings_snapshot(a): + snap = {} + for s in a.script: + snap[s] = _script_settings(s) + out = json.dumps(snap, ensure_ascii=False, indent=2) + if a.out: + open(a.out, "w", encoding="utf-8").write(out) + print(f"快照已存 → {a.out}") + else: + print(out) + for s, st in snap.items(): + for b in st.get("bindings", []): + if b.get("type") == "kv_namespace": + print(f" {s:26} {b['name']:16} {b['namespace_id']}") + + +def _sanitize(bindings: list[dict]) -> list[dict]: + """把讀回來的 bindings 變成可以寫回去的形狀:secret 用 inherit(不碰真身,D36)。""" + out = [] + for b in bindings: + t = b.get("type") + if t in ("secret_text", "secret_key"): + out.append({"type": "inherit", "name": b["name"]}) + else: + out.append({k: v for k, v in b.items() if v is not None}) + return out + + +def cmd_bindings_repoint(a): + want = dict(s.split("=", 1) for s in a.set) + st = _script_settings(a.script) + bindings = st.get("bindings", []) + names = {b["name"] for b in bindings if b.get("type") == "kv_namespace"} + missing = set(want) - names + if missing: + sys.exit(f"{a.script} 上沒有這些 KV 綁定名:{sorted(missing)}") + new_bindings, changes = [], [] + for b in _sanitize(bindings): + if b.get("type") == "kv_namespace" and b["name"] in want: + old = b["namespace_id"] + if old != want[b["name"]]: + changes.append((b["name"], old, want[b["name"]])) + b = {**b, "namespace_id": want[b["name"]]} + new_bindings.append(b) + for n, o, w in changes: + print(f" {a.script}: {n} {o} → {w}") + if not changes: + print(" (沒有任何綁定需要變更——已經是目標狀態,冪等)") + return + if a.dry_run: + print("\ndry-run:加 --apply 才真的改。") + return + keep = ("compatibility_date", "compatibility_flags", "logpush", "placement", + "tail_consumers", "observability", "limits", "migrations") + settings = {k: v for k, v in st.items() if k in keep and v is not None} + settings["bindings"] = new_bindings + d = patch_settings(a.script, settings) + if not d.get("success"): + sys.exit(f"改綁定失敗:{d.get('errors')}") + after = {b["name"]: b["namespace_id"] for b in _script_settings(a.script).get("bindings", []) + if b.get("type") == "kv_namespace"} + bad = [n for n, v in want.items() if after.get(n) != v] + print(f"\n改完複驗:{'✅ 全部到位' if not bad else '❌ 沒到位 ' + str(bad)}") + if bad: + sys.exit(1) + + +def cmd_bindings_rollback(a): + snap = json.load(open(a.inp, encoding="utf-8")) + st = snap.get(a.script) + if not st: + sys.exit(f"快照裡沒有 {a.script}") + want = {b["name"]: b["namespace_id"] for b in st["bindings"] if b.get("type") == "kv_namespace"} + ns = argparse.Namespace(script=a.script, set=[f"{k}={v}" for k, v in want.items()], + dry_run=a.dry_run) + cmd_bindings_repoint(ns) + + +def cmd_audit(a): + ns_title = {} + d = req("GET", "/storage/kv/namespaces?per_page=100") + for n in d.get("result", []): + ns_title[n["id"]] = n["title"] + seen = {} + for s in a.script: + st = _script_settings(s) + kv = [b for b in st.get("bindings", []) if b.get("type") == "kv_namespace"] + if not kv: + continue + print(f"\n{s}") + for b in kv: + title = ns_title.get(b["namespace_id"], "?") + gen = "新" if title.startswith("arcrun-rag-") else "舊" + cnt = len(list_keys(b["namespace_id"])) + print(f" {b['name']:16} {b['namespace_id']} [{gen}] {title:38} {cnt:>4} 把") + seen.setdefault(b["name"], set()).add(b["namespace_id"]) + print("\n── 分裂檢查(同一個綁定名被指到不同 namespace = 分裂)──") + split = {k: v for k, v in seen.items() if len(v) > 1} + print("✅ 沒有分裂:所有 worker 的同名綁定都指到同一顆" if not split else f"❌ 分裂:{split}") + if split: + sys.exit(1) + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + d = sub.add_parser("diff", help="兩代逐把 key 級比對(唯讀)") + d.add_argument("--old", required=True) + d.add_argument("--new", required=True) + d.add_argument("--verbose", action="store_true") + d.set_defaults(func=cmd_diff) + + c = sub.add_parser("copy", help="把點名的 key 從一個 namespace 搬到另一個(冪等、預設不覆蓋、永不刪除)") + c.add_argument("--from", required=True, dest="from") + c.add_argument("--to", required=True) + c.add_argument("--key", action="append", required=True) + c.add_argument("--overwrite", action="store_true", help="目標已有且內容不同時才需要,逐把點名") + c.add_argument("--apply", dest="dry_run", action="store_false", default=True) + c.set_defaults(func=cmd_copy) + + s = sub.add_parser("bindings-snapshot", help="把 worker 現況綁定存成 JSON(rollback 的唯一依據)") + s.add_argument("--script", action="append", required=True) + s.add_argument("-o", "--out") + s.set_defaults(func=cmd_bindings_snapshot) + + r = sub.add_parser("bindings-repoint", help="把某個 worker 的 KV 綁定改指到別顆 namespace(不動程式碼、不動 secret)") + r.add_argument("--script", required=True) + r.add_argument("--set", action="append", required=True, help="BINDING_NAME=namespace_id") + r.add_argument("--apply", dest="dry_run", action="store_false", default=True) + r.set_defaults(func=cmd_bindings_repoint) + + b = sub.add_parser("bindings-rollback", help="用快照把綁定還原") + b.add_argument("--script", required=True) + b.add_argument("-i", "--inp", required=True) + b.add_argument("--apply", dest="dry_run", action="store_false", default=True) + b.set_defaults(func=cmd_bindings_rollback) + + a = sub.add_parser("audit", help="列出每顆 worker 綁到哪一代、各幾把 key,並檢查有沒有分裂") + a.add_argument("--script", action="append", required=True) + a.set_defaults(func=cmd_audit) + + args = p.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/lib/gitea-arm-common.sh b/scripts/lib/gitea-arm-common.sh new file mode 100755 index 0000000..ee86a88 --- /dev/null +++ b/scripts/lib/gitea-arm-common.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# gitea-arm-common.sh — 共用設定與函式,給 gitea-arm-request.sh / gitea-arm-check.sh 用。 +# 只放常數與純函式,不做任何網路呼叫、不產生副作用(source 它必須零風險)。 +# +# 背景:把「leo 解保險」從終端機搬到 Gitea 票上(leo 2026-08-13: +# 「如果你會被通知,就不需要通過 terminal 來 arm 了」)。原始設計見: +# https://git.uncle6.me/inkstone/InkStoneCo/issues/34 +# +# 🪦 2026-08-16 廢除「單一頻道票」設計(leo:「我不要把所有的票都放在一個 issues, +# 它們應該分散在每個 issues 裡,不然就難 track 歷史記錄」,issues/34#issuecomment-2804)。 +# **票號改由呼叫端在每次呼叫時指定**——解哪張票的保險,請求與回碼就貼在那張票上, +# 不再有寫死的 GITEA_ARM_ISSUE 常數。#34 本身保留 open(33 則舊留言是歷史,不刪), +# 但不再是新請求的預設去處。 +# +# 🔴 這不是重新打開 2026-08-13 那次「五個常數全部寫死」的舊爭議(history-first-guard +# 會問這個,先在這裡回答):那次總管的攻擊測試是 +# `GITEA_ARM_ISSUE=999 OWNER=claude-code`——**同時**換成機器自己名下的 repo +# (bot 在自己的 repo 有 admin 權限,可以邀請/建立一個顯示名叫「Leo」的假帳號來自問自答)。 +# OWNER/REPO 才是那個攻擊真正利用的洞:**只要留在 inkstone/InkStoneCo 這個 org 裡, +# bot 沒有 admin 權限造不出第二個 login 是 `Leo` 的帳號,票號本身換到哪一張都一樣安全** +# ——核心判定永遠是「留言作者的 login 精確等於 Leo」,跟去哪張票找留言無關。 +# 本次只鬆 ISSUE 一個常數,OWNER/REPO/APPROVER_LOGIN 三個維持寫死、 +# 不接受環境變數覆蓋,且 ISSUE 也只能透過腳本參數指定,不接受環境變數。 + +# ── 安全邊界常數(OWNER/REPO/APPROVER_LOGIN,不接受任何覆蓋)─────────── +# +# 🔴🔴 2026-08-13 總管審查抓到的洞(修正前的版本用 `: "${VAR:=default}"`, +# 可被環境變數覆蓋):**「誰算是 leo」絕對不能是可覆蓋的設定。** +# 舊寫法下 `GITEA_ARM_APPROVER_LOGIN=claude-code scripts/gitea-arm-check.sh` +# 就能讓機器核准自己——比冒充/重放/過期/無回覆/Gitea 不通這五種都好繞, +# 因為設環境變數對總管與每一條 subagent 都是零成本、零痕跡。 +# ⇒ 這幾個常數全部**直接賦值、不接受任何外部覆蓋**(沒有 `:=`、沒有讀 env)。 +# 「誰是核准者/打哪個 repo」這種安全邊界要嘛寫死在程式碼裡走 PR 審查才能改, +# 要嘛就是可以被繞過的裝飾——**難改在這裡是特性,不是缺點**。 +# (測試因此不能再靠改這幾個常數注入假狀態;改用「用真的無效 token 打真端點」 +# 去驗 fail-closed,見 gitea-arm-check.test.sh 與 gitea-arm-check.sh 檔頭說明。) +GITEA_ARM_OWNER="inkstone" +# 🔓 2026-08-16:REPO 從「寫死」改成「預設值,只能由**腳本參數**指定」——理由與 ISSUE 同源。 +# 本檔上方那段自己寫著:那次攻擊真正利用的洞是「換成**機器自己名下**的 repo」 +# (bot 在自己的 repo 有 admin,能造一個顯示名叫 Leo 的假帳號自問自答)。 +# ⇒ 真正的安全邊界是 **OWNER**,不是 REPO:只要留在 `inkstone` 這個 org 底下, +# bot 在任何一個 repo 都沒有 admin,造不出第二個 login 是 `Leo` 的帳號。 +# 實際需求(同日):出貨票在 `inkstone/arcrun-rag#115`,而解保險的請求依 leo 的規矩 +# 要貼在「它解的那張票」上 ⇒ 寫死 InkStoneCo 會讓請求貼不到出貨票(實測 HTTP 500)。 +# 🔴 **OWNER 仍然寫死、仍然不接受任何覆蓋**——那一條沒有鬆。 +GITEA_ARM_REPO="InkStoneCo" # 預設;由 gitea_arm_set_repo() 依腳本參數覆寫,不讀 env + +# gitea_arm_set_repo <repo名> —— 只接受 inkstone org 底下的 repo 名(純 [A-Za-z0-9._-])。 +# 刻意不接受 owner/repo 形式:owner 是安全邊界,不給任何人指定的機會。 +gitea_arm_set_repo() { + case "$1" in + ''|*[!A-Za-z0-9._-]*) + echo "❌ repo 名只能是 inkstone org 底下的 repo(收到:$1)" >&2 + return 1;; + esac + GITEA_ARM_REPO="$1" +} +GITEA_ARM_API="https://git.uncle6.me/api/v1" +# 只認這個帳號名——不是 id、不是顯示名(leo 交代:這兩者會變)。 +# 將來 leo 真的改帳號名 ⇒ 改這一行、走 PR 審查,不是設環境變數就生效。 +GITEA_ARM_APPROVER_LOGIN="Leo" + +# 票號合法性檢查:純數字才放行,避免呼叫端傳進奇怪字串打壞 URL +# (例如帶 `/` 或空白,會讓 curl 打到非預期的路徑)。 +# 用法:gitea_arm_valid_issue "$ISSUE" && ... 或 if ! gitea_arm_valid_issue "$X"; then ... +gitea_arm_valid_issue() { + case "$1" in + ''|*[!0-9]*) return 1 ;; + *) return 0 ;; + esac +} + +gitea_arm_proj_dir() { + printf '%s' "${CLAUDE_PROJECT_DIR:-$(pwd)}" +} + +# 讀 token:只回名字對應的值,不印出任何除了呼叫者要的東西。 +# 讀不到 → 印空字串、回傳非 0(呼叫者要判斷,不能把空字串當成功)。 +gitea_arm_token() { + ENV_FILE="$(gitea_arm_proj_dir)/.env" + if [ ! -f "$ENV_FILE" ]; then + printf '' + return 1 + fi + TOK=$(grep '^GITEA_TOKEN_CLAUDE_CODE=' "$ENV_FILE" | head -1 | cut -d= -f2-) + if [ -z "$TOK" ]; then + printf '' + return 1 + fi + printf '%s' "$TOK" + return 0 +} + +# 本機狀態目錄(gitignore 見 .claude/.gitignore)。 +gitea_arm_state_dir() { + D="$(gitea_arm_proj_dir)/.claude/gitea-arm" + mkdir -p "$D/pending" 2>/dev/null + printf '%s' "$D" +} + +gitea_arm_consumed_log() { + printf '%s/consumed.log' "$(gitea_arm_state_dir)" +} diff --git a/scripts/make-tree-demo-data.sh b/scripts/make-tree-demo-data.sh new file mode 100755 index 0000000..c0b5c96 --- /dev/null +++ b/scripts/make-tree-demo-data.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# make-tree-demo-data.sh — 給「資料夾樹 / 檢索過程樹」做 demo 的巢狀測試資料 +# +# 為什麼有這支(leo 2026-08-19): +# 「我開啟新版 daemon,看不出不同⋯⋯如果要讓每個資料夾達到碎型, +# 你需要把 /Users/youlinhsieh/Desktop/youlinhsieh-test1 放進一些測試資料來 demo 巢狀」 +# ⇒ 樹的第三、四層是「選中的資料夾」與「它的巢狀子資料夾」。 +# test1 是平的(或空的)⇒ 樹再對也只畫得出一層 ⇒ 看起來像沒改。 +# +# 🔴 這支跑在 **leo 的 Mac 上**,不是雲端。雲端 session 碰不到 ~/Desktop。 +# +# 判準都對齊 daemon 實際的收檔規則(arcrun-collector): +# · 副檔名白名單 scan.go:allowedExt —— 這裡只用 .md / .csv,保證每一份都收得進去 +# · 不放 logseq/ 或 .obsidian/ ⇒ vault.go 判為一般資料夾(不會被當筆記庫另眼對待) +# · 不 git init ⇒ ingestplan.go 判 IngestAll(整棵收,不是只收 docs/) +# · 不用 node_modules/build/dist 這類名字 ⇒ 不會被 toolOwnedDirNames 整棵剪掉 +# · 14 個檔、都很短 ⇒ 不觸發 arcrun-rag#121「一次一大批壓垮自己」 +# +# 用法: +# bash make-tree-demo-data.sh # 預設 ~/Desktop/youlinhsieh-test1 +# bash make-tree-demo-data.sh /path/to/folder # 指定別的資料夾 +set -euo pipefail + +ROOT="${1:-$HOME/Desktop/youlinhsieh-test1}" +mkdir -p "$ROOT" + +# 這支只**新增**檔案,不刪任何既有東西——它跑在使用者的桌面上,不做破壞性動作。 +w() { mkdir -p "$(dirname "$ROOT/$1")"; cat > "$ROOT/$1"; } + +w "README.md" <<'F' +# 測試知識庫(youlinhsieh-test1) + +這個資料夾是用來驗證「資料夾樹」與「檢索過程樹」的巢狀展示, +內容是虛構的公司文件,不是真實資料。 + +- 01-公司制度:人事與資安規則 +- 02-專案:兩個案子的文件與會議紀錄 +- 03-產品:產品說明與價目表 +F + +# ── 01 公司制度(含一層子資料夾)────────────────────────────── +w "01-公司制度/差旅費報支辦法.md" <<'F' +# 差旅費報支辦法 + +- 國內出差每日誤餐費上限 新台幣 300 元,需檢附發票。 +- 高鐵一律標準車廂;商務車廂需事前經部門主管書面同意。 +- 住宿費以雙人房單人使用計算,台北市上限 3,500 元/夜,其他縣市 2,500 元/夜。 +- 報支期限:出差結束後 15 個工作天內送件,逾期需寫說明書。 +F + +w "01-公司制度/請假規則.md" <<'F' +# 請假規則 + +- 特休依到職日計算,未休完的特休於年度結束時折算工資。 +- 病假單日以上須附診斷證明;一年累計超過 30 日部分不給薪。 +- 事假須於 3 個工作天前提出,臨時事假需電話告知直屬主管。 +- 家庭照顧假併入事假計算,全年上限 7 日。 +F + +w "01-公司制度/資訊安全/密碼原則.md" <<'F' +# 密碼原則 + +- 長度至少 12 碼,需含大小寫與數字;不得使用公司名稱或員工編號。 +- 一律開啟兩階段驗證,簡訊驗證僅在無法使用驗證器時作為備援。 +- 密碼管理器指定使用公司採購的版本,不得將公司帳密存入個人瀏覽器。 +- 離職當日由資訊室統一停用所有帳號,主管需在交接表上簽名確認。 +F + +w "01-公司制度/資訊安全/外部儲存裝置使用規範.md" <<'F' +# 外部儲存裝置使用規範 + +- 隨身碟需經資訊室登錄並加密,未登錄者插入公司電腦會被端點防護阻擋。 +- 客戶資料一律不得存入個人雲端硬碟,需使用公司核發的共用空間。 +- 報廢硬碟需實體銷毀並留存銷毀證明,不得逕行丟棄或轉贈。 +F + +# ── 02 專案(兩案,其中一案再含會議紀錄子資料夾)──────────────── +w "02-專案/教育部標案/RFP摘要.md" <<'F' +# 教育部標案 RFP 摘要 + +- 案名:中小學數位學習平台知識管理系統擴充案 +- 預算上限:新台幣 480 萬元,履約期間 8 個月。 +- 必要條件:需支援單一登入(SSO)串接教育雲帳號。 +- 評選比重:技術 60%、價格 30%、簡報 10%。 +- 決標方式:最有利標。 +F + +w "02-專案/教育部標案/時程與里程碑.md" <<'F' +# 教育部標案 時程與里程碑 + +| 里程碑 | 日期 | 交付 | +|---|---|---| +| 契約簽訂 | 2026-09-01 | 專案計畫書 | +| 系統設計審查 | 2026-10-15 | 系統設計文件 | +| 第一階段上線 | 2026-12-20 | 知識庫檢索模組 | +| 驗收 | 2027-04-30 | 驗收測試報告 | +F + +w "02-專案/教育部標案/會議紀錄/20260801-啟動會議.md" <<'F' +# 20260801 教育部標案 啟動會議紀錄 + +出席:專案經理、技術主管、教育部承辦。 + +決議事項: +1. SSO 串接以教育雲 OAuth 為主,不自建帳號系統。 +2. 測試資料由教育部於 9 月中前提供去識別化樣本。 +3. 每兩週一次書面進度回報,格式沿用前案。 +F + +w "02-專案/教育部標案/會議紀錄/20260812-期中檢討.md" <<'F' +# 20260812 教育部標案 期中檢討紀錄 + +追蹤事項: +1. 教育雲 OAuth 測試環境尚未開通,承辦協助催辦,預計 8/20 前。 +2. 去識別化樣本延後至 9 月底,第一階段上線時程風險升為「中」。 +3. 簡報樣式改採機關既有範本,本週提供給設計。 +F + +w "02-專案/市立圖書館導入/需求訪談.md" <<'F' +# 市立圖書館導入 需求訪談 + +- 館員最在意的是「找得到館藏說明的原始檔在哪一台電腦上」,不是搜尋速度。 +- 現況:各分館各自維護 Word 檔,同名檔案散在 5 台電腦,版本互相矛盾。 +- 期待:一個地方就能問,答案要指得出原稿在哪個分館、哪個資料夾。 +F + +w "02-專案/市立圖書館導入/風險清單.md" <<'F' +# 市立圖書館導入 風險清單 + +| 風險 | 影響 | 對策 | +|---|---|---| +| 分館網路頻寬不足 | 同步緩慢 | 夜間排程同步 | +| 館員不熟悉新介面 | 導入後不使用 | 用他們熟悉的檔案總管式樹狀清單呈現 | +| 舊檔編碼混亂 | 內容亂碼 | 匯入前批次轉檔為 UTF-8 | +F + +# ── 03 產品 ────────────────────────────────────────────── +w "03-產品/Arcrun/定位與賣點.md" <<'F' +# Arcrun 定位與賣點 + +- 一句話:把檔案丟進資料夾,公司知識庫自動長出來。 +- 與傳統做法的差別:不做全量向量化,靠三元組圖找到相關的子庫再讀。 +- 對客戶的意義:知識統一在一個總庫,但永遠答得出原稿在哪一台機器、哪個資料夾。 +F + +w "03-產品/Arcrun/常見問答.md" <<'F' +# Arcrun 常見問答 + +**Q:我把資料夾從清單移除,雲端的知識會留著嗎?** +A:預設會一起收回;只想停止監看也可以選。 + +**Q:兩台電腦有同名的檔會不會搞混?** +A:不會,每份原稿都記得自己來自哪一台機器。 + +**Q:需要先整理資料夾嗎?** +A:不需要,依賴目錄與建置產物會自動略過。 +F + +w "03-產品/價目表.csv" <<'F' +方案,月費,包含席次,知識卡上限,備註 +入門,1200,3,5000,含社群支援 +標準,3600,10,30000,含電子郵件支援 +企業,12000,50,不限,含專屬窗口與導入協助 +F + +echo "✅ 已建立測試資料於:$ROOT" +echo +if command -v tree >/dev/null 2>&1; then + tree "$ROOT" +else + find "$ROOT" -not -path '*/.*' | sed "s|$ROOT|.|" | sort +fi +echo +echo "檔案數:$(find "$ROOT" -type f -not -path '*/.*' | wc -l | tr -d ' ') 最深層數:4(總庫→機器→$(basename "$ROOT")→…→會議紀錄)" diff --git a/scripts/patches/session-start-recall--map-zero-is-a-lie.patch b/scripts/patches/session-start-recall--map-zero-is-a-lie.patch new file mode 100644 index 0000000..68db446 --- /dev/null +++ b/scripts/patches/session-start-recall--map-zero-is-a-lie.patch @@ -0,0 +1,71 @@ +開場注入補一段:藏書地圖顯示 0 不等於庫是空的(2026-08-13) + +由 subagent 產生、**待總管代套**(`.claude/` 是受保護目錄,子 session 動不了)。 + +套用: + git apply scripts/patches/session-start-recall--map-zero-is-a-lie.patch +驗證(要看到那段文字): + bash .claude/hooks/session-start-recall.sh | sed -n '/查不到就換一種查法/,/追蹤:/p' + +已驗:`git apply --check` 通過、`bash -n` 通過、實跑印得出來(輸出見 Leo/mira#6)。 + +--- 為什麼要這段(2026-08-13 對 leo21c 唯讀實測)----------------------------- + +leo 原話:「現在你的 MCP 搜不到東西,這就是我急着要完成它的原因。沒有它你是瞎的。」 + +實測結果不是「搜尋壞了」,是**地圖說謊,而 AI 信了它就不再往下查**: + + kbdb_get_map() → 8 個庫,7 個 triplet_count = 0 + kbdb_search(q="cypher-executor", mode="keyword") → 42 筆,來源正是那 7 個庫 + (gitea:Leo/Arcrun@…、gitea:Leo/mira@… 等,library 全都標對了) + +地圖數的是**三元組**;那 7 個庫有 entries 但沒有三元組 +(`kbdb_query(template='triplet')` 抽樣 100 筆,source_uri 全部是 kb://,沒有一筆來自 gitea)。 + +⇒ 開場注入把「七個庫是空的」推到每個 session 眼前,AI 於是不查了—— + 而答案 keyword 一查就有。**這一段就是為了擋掉那個誤判。** + +一併寫進注入文字的另外兩件實測(省得每個 session 自己撞): + · 語意搜尋對超過一半的 repo wiki 內容是瞎的(那 42 筆裡 23 筆 is_embedded=0)。 + 問「Arcrun 是什麼 工作流引擎 Cloudflare」回 0 筆;同題 keyword 回 29 筆真答案。 + · kbdb_graph_neighbors(subject="Arcrun") 回 0 個鄰居, + 但 kbdb_get_map(library="kb") 列著 Arcrun degree 40 ⇒ 圖查詢是斷的。 + +🔴 這段是**暫時的路標,不是修法**。真修法在 Leo/Arcrun#87(三元組沒從 repo wiki 產生)、 + Leo/Arcrun#85(embed 補算)、Leo/mira#6(Mira 主線)。 + **那三張修好之後這段要拿掉**,否則它會變成下一個「看起來像現況的舊東西」。 + +--- a/.claude/hooks/session-start-recall.sh ++++ b/.claude/hooks/session-start-recall.sh +@@ -83,6 +83,31 @@ + echo "════════════════════════════════════════════════" + echo "" + ++# ── push 1.5/5:藏書地圖顯示 0 ≠ 庫是空的(2026-08-13 實測,暫時路標)──────── ++# 修好 Leo/Arcrun#87(三元組沒從 repo wiki 產生)與 #85(一半沒 embed)之後, ++# 這整段要拿掉。留著會變成下一個「看起來像現況的舊東西」。 ++echo "────────────────────────────────────────────────" ++echo "🔴 查不到就換一種查法——**藏書地圖顯示 0,不代表那個庫是空的**(2026-08-13 實測)" ++echo "" ++echo " 地圖數的是「三元組」。各 repo 的 wiki 有 entries、但**沒有三元組**," ++echo " 所以 arcrun/mira/arcrun-rag/arcrun-harness/inkstoneco 等庫會顯示 0——**那是假的**。" ++echo " 實測:kbdb_search(q=\"cypher-executor\", mode=\"keyword\") 回 42 筆,正是那些庫的卡片。" ++echo "" ++echo " ⇒ 三種查法現在的實際狀態:" ++echo " · kbdb_search(mode=\"keyword\") ✅ 可用——**目前唯一可靠的一種,優先用它**" ++echo " · kbdb_search(mode=\"semantic\") ◐ 對超過一半的 repo wiki 是瞎的(沒 embed)" ++echo " 回 0 筆**不代表庫裡沒有**,改用 keyword 再問一次" ++echo " · kbdb_graph_neighbors() ❌ 目前回 0 個鄰居,先別依賴它" ++echo "" ++echo " 🔴 **不要因為地圖是 0、或語意回 0,就下結論說「庫裡沒有這個」。**" ++echo " 2026-08-13 就是這樣把 Arcrun 的定位講錯的——查得到,只是被告知不用查。" ++echo "" ++echo " 在修好之前,問「X 是什麼」的正確姿勢:" ++echo " kbdb_search(q=\"X\", mode=\"keyword\") → 沒有再試 semantic → 都沒有才說沒有" ++echo " 追蹤:Leo/Arcrun#87(地圖)|Leo/Arcrun#85(embed)|Leo/mira#6(Mira 主線)" ++echo "────────────────────────────────────────────────" ++echo "" ++ + # ── push 2/5:各 repo 的 wiki 全景(leo 2026-08-12「一次看到全景」的另一半)── + # 上面那段是**票**(雲端現算)+ KBDB 藏書地圖;這段是**各 repo 真正的 wiki**。 + # 為什麼不塞進雲端那支工作流:工作流跑在 Cloudflare 上,要拿 repo 樹只有 diff --git a/scripts/stage-ok.sh b/scripts/stage-ok.sh new file mode 100755 index 0000000..a59d4a0 --- /dev/null +++ b/scripts/stage-ok.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# stage-ok.sh — **leo 親自確認 stage 沒問題**。出貨的第二把鑰匙。 +# +# 🔴 為什麼有這支(leo 2026-08-08,同一天講了兩次): +# 「以後你在 stage 驗證後**要交給我驗證**,如果 stage 完全沒問題,**才能推 prod**, +# 今天你沒給我看 stage,**我應該在 stage 安裝一次確認成功**。」 +# 「**你自己的測試就是有問題,為什麼推 prod**」 +# +# 那天發生什麼(這支存在的理由,別刪這段): +# 總管宣稱「stage 五項驗收全通」就推了 prod。但那五項全是 +# **API 回應、JSON 欄位、頁面原始碼裡有沒有某個字串**—— +# **沒有任何一項是「人能不能登進去」**。 +# 當晚 leo 真的去按登入,看到「連線中斷——請檢查網路後重試」; +# 總管自己的瀏覽器 probe 也是同一個失敗。 +# ⇒ **我的驗證方式對「這東西能不能用」是結構性失明的**, +# 再多自我驗證也補不上——只有真人走一遍才補得上。 +# +# ⇒ 出貨從此要兩把鑰匙,**都在 leo 手上**: +# ① 本支:他在 stage 真的裝一次/用一次,確認沒問題 +# ② github-arm.sh:發射保險 +# 總管**兩把都造不出來**,這是刻意的。 +# +# 用法(leo 跑): +# scripts/stage-ok.sh "<你在 stage 確認了什麼>" [有效小時數,預設 6] +set -eu + +NOTE="${1:-}" +HOURS="${2:-6}" + +if [ -z "$NOTE" ]; then + cat >&2 <<'EOF' +用法:scripts/stage-ok.sh "<你在 stage 確認了什麼>" [有效小時數,預設 6] + +例: + scripts/stage-ok.sh "stage 裝了一次,能登入、首頁數字對得起來、匯出診斷檔正常" 6 + +📌 這是「**你**在 stage 上親自確認過」的紀錄,不是 AI 的自我驗證。 + AI 造不出這個檔——那是刻意的。 +EOF + exit 1 +fi + +STAMP=/tmp/.stage-ok-by-leo +now=$(date +%s) +{ + echo "$now" + echo "$NOTE" + echo "confirmed_at=$(date '+%Y-%m-%d %H:%M:%S')" + echo "valid_hours=$HOURS" +} > "$STAMP" + +echo "✅ 已記錄:leo 在 stage 確認過,有效 ${HOURS} 小時" +echo " 內容:$NOTE" +echo " 撤銷:rm $STAMP" diff --git a/scripts/ticket b/scripts/ticket new file mode 100755 index 0000000..c086cc5 --- /dev/null +++ b/scripts/ticket @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""ticket — 讓 Gitea 變成「可追蹤的線」,不是「越積越大的池子」。 + +leo 2026-08-16 三句話,本工具就是它們的機械化: + 「不要每個開新票,現有的票開在它下面的對話裡」 + 「我希望你把 gitea 變成可以追蹤,不是變成一個池子」 + 「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」 + +當天實錯(本工具的來由):總管要派人查一個部署擋路石,**沒有搜尋就直接開新票** +(arcrun-rag#110),而那條線早就有 hub(InkStoneCo#44)。多開一張票 = 池子加大 = +那條線串不起來。⇒ 所以「搜過了」不是 SOP 第一條,是 `new` 的**前置條件**。 + +四個動詞,各有一道閘: + + ticket where <關鍵字...> 搜「這件事該放哪」→ 產生戳記 + ticket say <票> -F <檔> 貼進既有票的對話(**預設路徑**) + ticket new <repo> -F <檔> 開新票(要戳記+模板欄位齊全) + ticket close <票> --deliverable <URL> 關票(要有交付物連結) + ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作) + +票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44` +""" +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +HOST = "https://git.uncle6.me" +ORG = "inkstone" +STAMP_DIR = "/tmp" +STAMP_TTL = 30 * 60 # 戳記 30 分鐘失效——搜過就要趁記憶還熱的時候開 + +# 模板必填欄位。糊弄的票開不出來;開出來的票就是能用的 spec。 +REQUIRED_SECTIONS = ["## 目標", "## 驗收條件", "## deliverable 類型"] +VALID_KINDS = ["code", "research"] + + +def die(msg, code=2): + print(msg, file=sys.stderr) + sys.exit(code) + + +def token(): + root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + try: + url = subprocess.run(["git", "-C", root, "remote", "get-url", "gitea"], + capture_output=True, text=True, timeout=20).stdout.strip() + except Exception: + url = "" + m = re.search(r"//[^:]+:([^@]+)@", url) + if not m: + die("🔴 拿不到 gitea token(該 repo 的 gitea remote 沒有帶憑證)") + return m.group(1) + + +def api(path, payload=None, method=None): + url = path if path.startswith("http") else f"{HOST}/api/v1{path}" + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request( + url, data=data, method=method or ("POST" if data else "GET"), + headers={"Authorization": f"token {token()}", "Content-Type": "application/json"}) + try: + return json.load(urllib.request.urlopen(req, timeout=40)) + except urllib.error.HTTPError as e: + die(f"🔴 Gitea {e.code}:{e.read().decode()[:300]}") + + +def parse_ref(s): + m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip()) + if not m: + die(f"🔴 票的寫法是 owner/repo#N,你給的是:{s}") + return m.group(1), m.group(2), int(m.group(3)) + + +def stamp_path(): + return os.path.join(STAMP_DIR, ".ticket-where-ok") + + +# ── where ──────────────────────────────────────────────────────────────── +def cmd_where(argv): + if not argv: + die("用法:ticket where <關鍵字...>\n(用幾個真的會出現在票裡的詞,中英文都行)") + kws = argv + seen, hits = {}, [] + for kw in kws: + q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 30}) + for it in api(f"/repos/issues/search?{q}") or []: + ref = it["repository"]["full_name"] + "#" + str(it["number"]) + if ref in seen: + seen[ref]["score"] += 1 + continue + seen[ref] = {"score": 1, "it": it} + hits = sorted(seen.values(), key=lambda x: -x["score"]) + + print(f"🔍 搜尋:{' '.join(kws)} → 命中 {len(hits)} 張 open 票\n") + if not hits: + print(" (沒有命中——換幾個講法再試一次。真的沒有,才輪到開新票)") + for h in hits[:12]: + it, labels = h["it"], [l["name"] for l in h["it"].get("labels", [])] + print(f" [{h['score']}] {it['repository']['full_name']}#{it['number']} {labels}") + print(f" {it['title'][:70]}") + + with open(stamp_path(), "w") as f: + json.dump({"at": time.time(), "kws": kws, "n": len(hits), + "top": [h["it"]["repository"]["full_name"] + "#" + str(h["it"]["number"]) + for h in hits[:12]]}, f) + + print(f""" +── 決定要做什麼 ─────────────────────────────────────────────── + 命中了、而且是同一條線 → **貼進那張票的對話**(預設,也是 leo 要的) + ticket say <owner/repo#N> -F <內文檔> + 真的是新的一條線 → ticket new <repo> -F <內文檔> + (模板要有:## 目標 / ## 驗收條件 / ## deliverable 類型) + +🔴 判準不是「這件事夠不夠大」,是「**它跟現有的哪條線是同一條**」。 + 同一條線就進對話——多開一張票只會讓池子變大、線串不起來。 +戳記已寫({STAMP_TTL // 60} 分鐘有效)。""") + + +# ── say ────────────────────────────────────────────────────────────────── +def cmd_say(argv): + if len(argv) < 3 or argv[1] not in ("-F", "--file"): + die("用法:ticket say <owner/repo#N> -F <內文檔>") + owner, repo, num = parse_ref(argv[0]) + body = open(argv[2]).read() + c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body}) + print(f"✅ 已貼進 {owner}/{repo}#{num}") + print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}") + print(f" {c['html_url']}") + print(f"\n📌 派工時把上面那行「定址」整串寫進【工單】,那條線才接得起來。") + + +# ── new ────────────────────────────────────────────────────────────────── +def cmd_new(argv): + if len(argv) < 3 or argv[1] not in ("-F", "--file"): + die("用法:ticket new <repo> -F <內文檔> [--title <標題>]") + repo = argv[0] + body = open(argv[2]).read() + title = None + if "--title" in argv: + title = argv[argv.index("--title") + 1] + + # 閘一:搜過了沒 + try: + st = json.load(open(stamp_path())) + except Exception: + die("""🚫 開新票前要先搜「這件事該放哪」(leo 2026-08-16) + +leo 原話:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」 +實錯(同日):總管沒搜就開 arcrun-rag#110,而那條線早有 hub InkStoneCo#44。 + +先跑: ticket where <關鍵字...> +搜完它會告訴你該 `say` 進哪張票,還是真的該 `new`。""") + if time.time() - st["at"] > STAMP_TTL: + die(f"🚫 搜尋戳記已過期(超過 {STAMP_TTL // 60} 分鐘)。重跑一次 ticket where") + + # 閘二:搜到了東西,就要說明為什麼不是貼進去 + if st["n"] > 0 and "--not-a-comment" not in argv: + top = "\n".join(" " + t for t in st["top"][:8]) + die(f"""🚫 剛才那次搜尋命中 {st['n']} 張 open 票,你卻要開新的。 + +命中的前幾張: +{top} + +**先問一次:這件事跟上面哪一條是同一條線?** + 是 → `ticket say <那張票> -F <檔>`(這是預設路徑) + 不是 → 重下一次指令,帶上理由: + ticket new {repo} -F <檔> --not-a-comment "為什麼它是獨立的一條線" + +理由會被寫進票的內文,往後任何人都看得到你當時怎麼判的。""") + + # 閘三:模板欄位非空 + missing = [s for s in REQUIRED_SECTIONS if s not in body] + if missing: + die(f"""🚫 票的模板缺欄位:{'、'.join(missing)} + +**糊弄的票開不出來,開得出來的票就是能用的 spec。** 必填: + ## 目標 要達成什麼(不是要改哪個檔) + ## 驗收條件 做完要能證明什麼、怎麼驗 + ## deliverable 類型 code(→ PR)或 research(→ 貼在票上的結論)""") + + m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I) + kind_line = (m.group(1) if m else "").lower() + if not any(k in kind_line for k in VALID_KINDS): + die(f"🚫 `## deliverable 類型` 底下要明寫 `code` 或 `research`(現在是:{kind_line.strip() or '空的'})\n" + " 關票時會驗這個型別對應的交付物有沒有連上,所以不能含糊。") + + if "--not-a-comment" in argv: + why = argv[argv.index("--not-a-comment") + 1] + body += (f"\n\n---\n> 🔎 **為什麼另開一張票而不是貼進既有的**(開票時聲明):{why}\n" + f"> 當時搜尋:`{' '.join(st['kws'])}` → 命中 {st['n']} 張。") + + if not title: + die("🚫 缺 --title") + check_title(title) + d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body}) + os.remove(stamp_path()) # 戳記用掉就沒了,一次只開一張 + print(f"✅ {ORG}/{repo}#{d['number']} 已開:{d['html_url']}") + + + +# ── 標題規約閘(leo 2026-08-19:「票的寫法不受控制嗎?沒有辦法規範?」)───────── +# +# 實錯(本閘的來由):2026-08-19 一個 session 造了 17 張 `👤 裁決題:…` 與 +# 1 張 `【版本】…`。兩種前綴都是 AI 自己發明的分類,都不是 User Story, +# 也都不該是票——**裁決在對話裡講,版本用里程碑**。leo:「亂搞一通」。 +# +# 判準跟 empty-handed-stop-guard 同一個哲學:**封形狀,不封措辭**。 +# User Story 的形狀是可枚舉的(身為…我要…我才…),自創前綴也是可枚舉的(開頭的方括號/ +# 冒號式分類詞)。不做語意判斷,只認形狀。 +USER_STORY_RE = re.compile(r"^\s*身為.{2,}?,\s*我(要|想要).{2,}?,\s*我才.{2,}") +BANNED_PREFIX_RE = re.compile(r"^\s*(?:[\U0001F300-\U0001FAFF\u2600-\u27BF]\s*)*" + r"(?:[【\[((][^】\]))]{1,12}[】\]))]|[^\s::]{2,10}題)\s*[::]") + + +def check_title(title): + if BANNED_PREFIX_RE.match(title): + die("🚫 標題不准自創分類前綴(leo 2026-08-19:「亂搞一通」)\n" + f" 你寫的:{title[:60]}\n\n" + " 2026-08-19 實錯:AI 造了『👤 裁決題:』17 張、『【版本】』1 張,\n" + " 兩種都不是 User Story,也都不該是票:\n" + " · 要 leo 裁決 → **在對話裡講**,不要開票\n" + " · 一個版本/sprint → **建里程碑**,把既有 issues 拉進去\n" + " · 真的是一條待辦 → 用 User Story 寫標題(見下)") + if not USER_STORY_RE.match(title): + die("🚫 票名一律 User Story(leo 2026-08-17;規約在 CLAUDE.md)\n" + f" 你寫的:{title[:60]}\n\n" + " 格式:身為<誰>,我要<什麼>,我才<為什麼>\n" + " 例: 身為把整台電腦交給 AI 的人,我要它指得出出處,我才敢相信它讀懂了我的東西\n\n" + " 🔴 不要照抄現場的多數——2026-08-17 實查 39 張 open 票只有 6 張合規,\n" + " 照多數抄就會抄到錯的那邊。\n" + " 真的不是一條待辦?那它就不該是票(裁決→對話;版本→里程碑)。") + + +# ── close ──────────────────────────────────────────────────────────────── +def cmd_close(argv): + if not argv: + die("用法:ticket close <owner/repo#N> --deliverable <URL>") + owner, repo, num = parse_ref(argv[0]) + issue = api(f"/repos/{owner}/{repo}/issues/{num}") + body = issue.get("body") or "" + comments = api(f"/repos/{owner}/{repo}/issues/{num}/comments") or [] + blob = body + "\n" + "\n".join(c.get("body") or "" for c in comments) + + deliv = None + if "--deliverable" in argv: + deliv = argv[argv.index("--deliverable") + 1] + + m = re.search(r"##\s*deliverable\s*類型\s*\n+([^\n]*)", body, re.I) + kind = "code" if m and "code" in m.group(1).lower() else ( + "research" if m and "research" in m.group(1).lower() else "unknown") + + has_pr = bool(re.search(r"/pulls?/\d+", blob)) + has_report = len([c for c in comments if len(c.get("body") or "") > 200]) > 0 + + ok = bool(deliv) or (has_pr if kind == "code" else has_report if kind == "research" + else (has_pr or has_report)) + if not ok: + die(f"""🚫 這張票關不掉——找不到交付物。 + + 票的 deliverable 類型:{kind} + 票上有 PR 連結:{'有' if has_pr else '沒有'} + 票上有實質回報(>200 字的 comment):{'有' if has_report else '沒有'} + +**沒有交付物的票是關不掉的票**——它會一直掛在看板上刺眼,那正是設計意圖。 + 真的有交付物 → 先貼上去:ticket say {owner}/{repo}#{num} -F <檔> + 交付物在別處 → ticket close {owner}/{repo}#{num} --deliverable <URL>""") + + if deliv: + api(f"/repos/{owner}/{repo}/issues/{num}/comments", + {"body": f"✅ 結案。交付物:{deliv}"}) + api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH") + print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')})") + + + +# ── decide ─────────────────────────────────────────────────────────────── +def cmd_decide(argv): + """記錄 leo 的裁決+改狀態,**一個動作**。 + + leo 2026-08-16:「回覆過很多次了,**回覆過的就要記錄下來**」 + 「這些我答了,來自各地,**問題是你怎麼追蹤**?」 + + 病灶:leo 從對話/手機/Gitea 各處答覆 ⇒ 總管照著做了但沒落到票上 + ⇒ 下一輪(或下個 session)又問一次同一題。B 題就是實例。 + ⇒ 所以「寫下答案」與「拿掉 Human」必須是**同一個動作**,不能只做一半。 + + 🔄 2026-08-16 改版(leo):`s/leo` 併入 `Human`,且 **Human 與 s/* 正交**—— + 「要不要人批」跟「它在流程哪一格」是兩個獨立的軸。 + ⇒ decide **只拿掉 Human,不動 s/* 狀態**(除非呼叫端明給 --next)。 + 舊做法把 s/leo 換成 s/todo 會把票的真實流程位置抹掉。 + """ + if len(argv) < 3 or argv[1] not in ("-F", "--file"): + die("用法:ticket decide <owner/repo#N> -F <答案檔> [--next <狀態標籤>]\n" + "(預設只拿掉 Human、保留原本的 s/* 狀態;要同時改狀態才加 --next)\n" + "答案檔要寫「leo 原話」與「所以要做什麼」") + owner, repo, num = parse_ref(argv[0]) + body = open(argv[2]).read() + nxt = argv[argv.index("--next") + 1] if "--next" in argv else None + + if "leo" not in body.lower() and "原話" not in body: + die("🚫 答案檔裡看不到 leo 的原話。\n" + " **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n" + " (今天 `Arcrun#132` 就是把 leo 的「確認」套到錯的提案上,同一張票誤讀兩次。)") + + api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body}) + + ids = {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=60")} + issue = api(f"/repos/{owner}/{repo}/issues/{num}") + cur = [l["name"] for l in issue.get("labels") or []] + + # 預設:只拿掉 Human(那是「還在等人批」的標記),流程位置維持不動 + keep = [n for n in cur if n != "Human"] + if "--next" in argv: + keep = [n for n in keep if not n.startswith("s/")] + [nxt] + if nxt not in ids: + die(f"🚫 這個 repo 沒有 `{nxt}` 標籤。現有:{[n for n in ids if n.startswith('s/')]}") + api(f"/repos/{owner}/{repo}/issues/{num}/labels", + {"labels": [ids[n] for n in keep if n in ids]}, method="PUT") + + # 批完了就不該還掛在 leo 名下——指派給他的清單裡每一張都要是真的在等他 + api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": []}, method="PATCH") + print(f" 已拿掉 Human 並取消指派——「指派給 Leo」那份清單保持誠實。") + + print(f"✅ {owner}/{repo}#{num}:答案已記進票,狀態 → {nxt}") + print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。") + + +CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in CMDS: + print(__doc__) + sys.exit(0 if len(sys.argv) < 2 else 2) + CMDS[sys.argv[1]](sys.argv[2:]) diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..dadd4f8 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,332 @@ +#!/bin/bash +# system-dev-template updater +# 已安裝舊版的人,一鍵更新到新版。 +# +# 核心安全原則:只覆蓋「模板/邏輯檔」,絕不碰「使用者資料檔」。 +# ✅ 可覆蓋:hooks/*.sh、commands/*.md、TEMPLATE-*、wiki/INDEX.md +# ——這些由模板維護,使用者不會手改,新版直接換掉。 +# 🔒 絕不碰:wiki/status.md、mistakes.md、decisions-summary.md、TAXONOMY.md、.wikiignore、 +# settings.json、CLAUDE.md +# ——這些是使用者自己填的內容,覆蓋=清空他的記憶與設定。 +# +# 「第一次更新」的雞生蛋問題: +# 舊版本機沒有 update.sh。所以第一次靠 README 那行 curl 從遠端抓這支腳本來跑。 +# 跑完它會把自己也更新進 scripts/update.sh,之後就能直接跑本機的 `bash scripts/update.sh`。 + +set -euo pipefail + +# ── i18n:依 locale 選語言,預設英文(curl | bash 常為 LANG=C)── +case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in + zh*|*Hant*|*Hans*) IS_ZH="yes" ;; + *) IS_ZH="no" ;; +esac +t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; } +tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; } + +REPO_RAW="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main" +TEMPLATE_URL="$REPO_RAW/template" + +UPDATED=() +KEPT=() +NEW=() +TEMPLATED=() +MIGRATED=() +COEXIST=() + +# ── 版本比對:先看本機 vs 遠端,給使用者「值不值得更新」的判斷 ── +# VERSION 新位置在 system-dev/,舊位置在 .claude/(1.8.x 以前)。優先讀新、回退舊。 +LOCAL_VER="$(tn '(未知)' '(unknown)')" +if [ -f "system-dev/VERSION" ]; then + LOCAL_VER="$(tr -d '[:space:]' < system-dev/VERSION)" +elif [ -f ".claude/VERSION" ]; then + LOCAL_VER="$(tr -d '[:space:]' < .claude/VERSION)" +fi +REMOTE_VER="$(curl -sSL "$TEMPLATE_URL/system-dev/VERSION" 2>/dev/null | tr -d '[:space:]' || echo '')" +# 容錯:curl 對 404 會把「404:NotFound」當內容輸出(非空),舊版誤把它寫進 VERSION。 +# 這裡驗證必須像版號(X.Y.Z),否則一律視為取不到,避免污染 VERSION 檔。 +case "$REMOTE_VER" in + [0-9]*.[0-9]*.[0-9]*) : ;; # 形如 1.9.0 → 合法 + *) REMOTE_VER="" ;; # 404 / HTML 錯誤頁 / 其他 → 當作沒抓到 +esac + +echo "" +echo "🔄 system-dev-template updater" +echo "=================================" +t " 本機版本:${LOCAL_VER}" " Local version: ${LOCAL_VER}" +t " 最新版本:${REMOTE_VER:-取不到(檢查網路)}" \ + " Latest version: ${REMOTE_VER:-unavailable (check network)}" +echo "" + +if [ -z "$REMOTE_VER" ]; then + t "❌ 取不到遠端版本,可能是網路問題。請稍後再試。" \ + "❌ Could not fetch the remote version (likely a network issue). Please try again later." + exit 1 +fi + +if [ "$LOCAL_VER" = "$REMOTE_VER" ]; then + t "✅ 已是最新版(${LOCAL_VER}),不需更新。" \ + "✅ Already up to date (${LOCAL_VER}), nothing to update." + t " (仍會同步模板邏輯檔,確保 hooks/commands 與最新一致。)" \ + " (Template logic files will still be synced to keep hooks/commands in line with the latest.)" + echo "" +fi + +# ── 結構遷移(1.9.0):舊版把 wiki/VERSION 放 .claude/、工具 docs 放根 docs/ ── +# 新版一律收進 system-dev/。這裡冪等遷移:偵測舊位置 → 搬到 system-dev/,已搬過則略過。 +# 必須在「模組偵測」之前跑(偵測靠目錄存在與否判斷,搬完才看得到新位置)。 +# +# 安全原則: +# - wiki 整包搬(含 cards/ 與可能的 wiki/.git),用 mv 保留內含 .git。 +# - docs 只搬「工具自己鋪的白名單」子目錄;用戶自填在 docs/ 的其他內容一律不動。 +# - 目的地已存在同名 → 不覆蓋(保留用戶在新位置的東西),略過該項。 +migrate_dir() { # $1=舊路徑 $2=新路徑 + local from="$1" to="$2" + [ -e "$from" ] || return 0 # 舊的不存在 → 無需遷移 + if [ -e "$to" ]; then + # 目的地已存在。兩種可能: + # (a) 已遷移過 → 舊位置不該還在;冪等略過即可。 + # (b) 用戶先 install 建了空殼 → 舊位置仍有真資料,現在「並存」。 + # 不能靜默跳過 (b),也絕不自動合併(覆蓋風險)。→ 記為「並存待合併」,警告。 + COEXIST+=("$from ↔ $to") + return 0 + fi + mkdir -p "$(dirname "$to")" + if mv "$from" "$to" 2>/dev/null; then + MIGRATED+=("$from → $to") + fi +} + +# 任一舊位置還在 → 需要遷移(遷移本身冪等:已搬的項目會被 migrate_dir 略過)。 +NEEDS_MIGRATE="no" +if [ -d ".claude/wiki" ] || [ -f ".claude/VERSION" ] \ + || [ -d "docs/3-specs" ] || [ -f "docs/SKILL.md" ] || [ -f "docs/README.md" ]; then + NEEDS_MIGRATE="yes" +fi + +if [ "$NEEDS_MIGRATE" = "yes" ]; then + t "🔧 偵測到舊版結構,遷移到 system-dev/ …" "🔧 Old layout detected — migrating into system-dev/ …" + mkdir -p system-dev + + # wiki(含 cards/ 與內含的 .git)整包搬 + migrate_dir ".claude/wiki" "system-dev/wiki" + # 工具版號 + migrate_dir ".claude/VERSION" "system-dev/VERSION" + # 工具文件白名單(只搬工具鋪的,用戶自填的 docs 內容不動) + migrate_dir "docs/SKILL.md" "system-dev/docs/SKILL.md" + migrate_dir "docs/README.md" "system-dev/docs/README.md" + migrate_dir "docs/1-vision" "system-dev/docs/1-vision" + migrate_dir "docs/2-architecture" "system-dev/docs/2-architecture" + migrate_dir "docs/3-specs" "system-dev/docs/3-specs" + migrate_dir "docs/4-guides" "system-dev/docs/4-guides" + migrate_dir "docs/5-records" "system-dev/docs/5-records" + migrate_dir "docs/6-user" "system-dev/docs/6-user" + echo "" +fi + +# ── 工具函式 ─────────────────────────────────────── +# 覆蓋更新:模板/邏輯檔,無條件抓最新版蓋掉。 +update_file() { + local dest="$1" src="$2" + mkdir -p "$(dirname "$dest")" + if [ -f "$dest" ]; then + if curl -sSL "$src" -o "$dest.tmp" 2>/dev/null && [ -s "$dest.tmp" ]; then + if cmp -s "$dest" "$dest.tmp"; then + rm -f "$dest.tmp" # 內容相同,不算更新 + else + mv "$dest.tmp" "$dest" + UPDATED+=("$dest") + fi + else + rm -f "$dest.tmp" + t " ⚠️ 抓取失敗,保留原檔:$dest" " ⚠️ Download failed, keeping the original: $dest" + fi + else + if curl -sSL "$src" -o "$dest" 2>/dev/null && [ -s "$dest" ]; then + NEW+=("$dest") # 新功能:舊版沒有的檔 + else + rm -f "$dest" + t " ⚠️ 抓取失敗:$dest" " ⚠️ Download failed: $dest" + fi + fi +} + +# 保留:使用者資料檔,只記錄「有保留」,永遠不動。 +keep_file() { + [ -f "$1" ] && KEPT+=("$1") || true +} + +# 補新檔:舊版沒有、新版才有的「使用者資料檔」(如 principles.md)。 +# 不存在 → 抓範本下來(之後由使用者/CC 填);已存在 → 當用戶資料保留,絕不覆蓋。 +add_if_missing() { + local dest="$1" src="$2" + if [ -f "$dest" ]; then + KEPT+=("$dest") + elif curl -sSL "$src" -o "$dest" 2>/dev/null && [ -s "$dest" ]; then + NEW+=("$dest") + else + rm -f "$dest" + fi +} + +# 客製檔:使用者一定會手填內容(如 pre-write-guard.sh)。 +# - 已存在 → 絕不覆蓋,但把最新模板版抓到 <檔名>.template.sh 旁邊,供使用者自行 diff 採納。 +# - 不存在 → 視同新檔,直接抓本體(第一次安裝才會走這條)。 +keep_with_template() { + local dest="$1" src="$2" + if [ -f "$dest" ]; then + KEPT+=("$dest") + local tmpl="${dest%.sh}.template.sh" + if curl -sSL "$src" -o "$tmpl.tmp" 2>/dev/null && [ -s "$tmpl.tmp" ]; then + if [ -f "$tmpl" ] && cmp -s "$tmpl" "$tmpl.tmp"; then + rm -f "$tmpl.tmp" # 模板版沒變,不重複提示 + else + mv "$tmpl.tmp" "$tmpl" + TEMPLATED+=("$tmpl") + fi + else + rm -f "$tmpl.tmp" + fi + else + update_file "$dest" "$src" # 還沒裝過 → 當新檔處理 + fi +} + +# ── 偵測已安裝哪些模組(依現有檔案判斷,更新只動已裝的)── +# 遷移已在上面跑完,這裡看新位置 system-dev/。 +HAS_WIKI=false +HAS_SDD=false +[ -d "system-dev/wiki" ] && HAS_WIKI=true +if [ -f ".claude/hooks/sdd-guard.sh" ] || [ -d "system-dev/docs/3-specs/TEMPLATE-sdd" ]; then HAS_SDD=true; fi + +t "📦 偵測到已安裝模組:" "📦 Detected installed modules:" +$HAS_WIKI && echo " • LLM Wiki" +$HAS_SDD && echo " • SDD" +{ $HAS_WIKI || $HAS_SDD; } || \ + t " (未偵測到任何模組——這裡可能還沒安裝,請改跑 install.sh)" \ + " (No modules detected — nothing installed here yet; run install.sh instead.)" +echo "" + +# ── 客製檔:使用者手填的 guardrail,永不覆蓋(issue #3)── +# pre-write-guard.sh 的定位是「空白客製模板,使用者沒配置前不提供保護」(CHANGELOG 1.2.0)。 +# 下游通常已塞滿自己的 enforcement,直接覆蓋=無聲關掉整套 guardrail。 +# 改為:保留原檔不動,新版範本另存 pre-write-guard.template.sh,由使用者自行 diff 採納。 +keep_with_template ".claude/hooks/pre-write-guard.sh" "$TEMPLATE_URL/.claude/hooks/pre-write-guard.sh" + +# ── 模板/邏輯檔:覆蓋更新 ────────────────────────── +# 共用 hook 與指引 +update_file ".claude/commands/issue-handle.md" "$TEMPLATE_URL/.claude/commands/issue-handle.md" +update_file "system-dev/VERSION" "$TEMPLATE_URL/system-dev/VERSION" + +if $HAS_WIKI; then + # wiki 的「邏輯檔」:導航與 hooks,可覆蓋。wiki 資料在 system-dev/,hooks/commands 留 .claude/。 + update_file "system-dev/wiki/INDEX.md" "$TEMPLATE_URL/system-dev/wiki/INDEX.md" + update_file ".claude/hooks/session-start-recall.sh" "$TEMPLATE_URL/.claude/hooks/session-start-recall.sh" + update_file ".claude/hooks/wiki-secret-scan.sh" "$TEMPLATE_URL/.claude/hooks/wiki-secret-scan.sh" + update_file ".claude/commands/wiki-init.md" "$TEMPLATE_URL/.claude/commands/wiki-init.md" + update_file ".claude/commands/wiki-capture.md" "$TEMPLATE_URL/.claude/commands/wiki-capture.md" + update_file ".claude/commands/wiki-update.md" "$TEMPLATE_URL/.claude/commands/wiki-update.md" + update_file ".claude/commands/wiki-recall.md" "$TEMPLATE_URL/.claude/commands/wiki-recall.md" + # Cowork(claude.ai)的 wiki 整理 skill:規則檔,可覆蓋 + update_file "system-dev/docs/SKILL.md" "$TEMPLATE_URL/system-dev/docs/SKILL.md" + + # wiki 的「使用者資料」:絕不碰 + keep_file "system-dev/wiki/status.md" + keep_file "system-dev/wiki/mistakes.md" + # principles.md(1.10):舊版沒有 → 補範本;已有 → 當用戶資料保留 + add_if_missing "system-dev/wiki/principles.md" "$TEMPLATE_URL/system-dev/wiki/principles.md" + keep_file "system-dev/wiki/decisions-summary.md" + keep_file "system-dev/wiki/TAXONOMY.md" + keep_file "system-dev/wiki/.wikiignore" +fi + +if $HAS_SDD; then + # SDD 範本與 hook:可覆蓋 + update_file "system-dev/docs/3-specs/TEMPLATE-sdd/design.md" "$TEMPLATE_URL/system-dev/docs/3-specs/TEMPLATE-sdd/design.md" + update_file "system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md" "$TEMPLATE_URL/system-dev/docs/3-specs/TEMPLATE-sdd/tasks.md" + update_file "system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md" "$TEMPLATE_URL/system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md" + update_file ".claude/commands/sdd-check.md" "$TEMPLATE_URL/.claude/commands/sdd-check.md" + update_file ".claude/hooks/sdd-guard.sh" "$TEMPLATE_URL/.claude/hooks/sdd-guard.sh" +fi + +# ── 自我更新:把最新的 update.sh / install.sh 抓到 system-dev/scripts/ ── +# 這兩支在 main/scripts/ 下(不在 template/);落地位置新版收進 system-dev/scripts/。 +update_file "system-dev/scripts/update.sh" "$REPO_RAW/scripts/update.sh" +update_file "system-dev/scripts/install.sh" "$REPO_RAW/scripts/install.sh" + +chmod +x .claude/hooks/*.sh system-dev/scripts/*.sh 2>/dev/null || true + +# ── 使用者資料檔:絕不碰,但提醒「設定可能有新欄位要手動補」── +keep_file ".claude/settings.json" +keep_file "CLAUDE.md" + +# ── 結果輸出 ─────────────────────────────────────── +echo "" +echo "─────────────────────────────────" +if [ ${#MIGRATED[@]} -gt 0 ]; then + echo "" + t "📦 結構遷移(已收進 system-dev/):" "📦 Layout migrated (moved into system-dev/):" + for f in "${MIGRATED[@]}"; do echo " ⇒ $f"; done +fi +if [ ${#COEXIST[@]} -gt 0 ]; then + echo "" + t "🛑 偵測到 wiki 並存(新舊位置都有資料,需要合併):" \ + "🛑 Coexisting wiki detected (both old and new locations have data — needs merging):" + for f in "${COEXIST[@]}"; do echo " ↔ $f"; done + t " 成因:先跑過 install(建了空殼)才遷移,舊位置真資料沒被搬。" \ + " Cause: install ran first (created an empty shell), so migration skipped your real data in the old location." + t " 不自動合併(避免覆蓋你的資料)。請叫你的 CC:" \ + " Not auto-merged (to avoid overwriting your data). Ask your CC:" + t " 「.claude/wiki/ 和 system-dev/wiki/ 並存,請逐檔比對、把真資料合進 system-dev/,再刪舊的」" \ + " \"There are two wikis (.claude/wiki/ and system-dev/wiki/) — diff each file, merge the real data into system-dev/, then delete the old one.\"" +fi +if [ ${#NEW[@]} -gt 0 ]; then + echo "" + t "🆕 新功能(舊版沒有,已加入):" "🆕 New features (absent in the old version, now added):" + for f in "${NEW[@]}"; do echo " + $f"; done +fi +if [ ${#UPDATED[@]} -gt 0 ]; then + echo "" + t "⬆️ 已更新(覆蓋成新版):" "⬆️ Updated (overwritten with the new version):" + for f in "${UPDATED[@]}"; do echo " ~ $f"; done +fi +if [ ${#NEW[@]} -eq 0 ] && [ ${#UPDATED[@]} -eq 0 ]; then + echo "" + t "✨ 模板邏輯檔已全部最新,無需變動。" \ + "✨ All template logic files are already up to date — no changes needed." +fi +if [ ${#KEPT[@]} -gt 0 ]; then + echo "" + t "🔒 完整保留(你的內容/設定,從未碰過):" \ + "🔒 Fully preserved (your content/settings, never touched):" + for f in "${KEPT[@]}"; do echo " = $f"; done +fi +if [ ${#TEMPLATED[@]} -gt 0 ]; then + echo "" + t "📋 客製檔有新版範本(你的原檔沒動,新版另存旁邊,請自行 diff 採納):" \ + "📋 Custom files have a new template version (your original is untouched; the new one is saved alongside — diff and adopt as you like):" + for f in "${TEMPLATED[@]}"; do + echo " → $f" + t " 比對:diff \"${f%.template.sh}.sh\" \"$f\"" \ + " compare: diff \"${f%.template.sh}.sh\" \"$f\"" + done +fi + +# ── settings.json 提醒:新模組 hook 可能要手動補 ── +if [ -f ".claude/settings.json" ]; then + MISSING=() + $HAS_WIKI && ! grep -q "session-start-recall.sh" .claude/settings.json && MISSING+=("SessionStart: session-start-recall.sh") + $HAS_WIKI && ! grep -q "wiki-secret-scan.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): wiki-secret-scan.sh") + $HAS_SDD && ! grep -q "sdd-guard.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): sdd-guard.sh") + if [ ${#MISSING[@]} -gt 0 ]; then + echo "" + t "📌 settings.json 是你的設定(沒動),但偵測到缺以下 hook,請手動補上:" \ + "📌 settings.json is yours (untouched), but these hooks are missing — please add them manually:" + for h in "${MISSING[@]}"; do echo " • $h"; done + fi +fi + +echo "" +t "🚀 更新完成:${LOCAL_VER} → ${REMOTE_VER}" "🚀 Update complete: ${LOCAL_VER} → ${REMOTE_VER}" +t " 下次更新直接跑:bash system-dev/scripts/update.sh" " Next time, just run: bash system-dev/scripts/update.sh" +t " 改了什麼看:CHANGELOG.md" " See what changed: CHANGELOG.md" +echo "" diff --git a/scripts/wiki-panorama.sh b/scripts/wiki-panorama.sh new file mode 100644 index 0000000..3d9a85c --- /dev/null +++ b/scripts/wiki-panorama.sh @@ -0,0 +1,350 @@ +#!/bin/bash +# wiki-panorama.sh — 產生「各 repo 的 wiki 有哪些檔」的全景 index(`system-dev/wiki/PANORAMA.md`) +# +# 解的問題(leo 2026-08-12): +# 「我在 AR-Mira 看到所有 Gitea Repo 的 wiki,又可以看到所有票現況, +# 有一個總圖用 md 一次看到全景。」 +# 開場總圖(Arcrun 工作流 `global_index`)的「票」那段已經好用, +# 但「知識」那段接的是 KBDB 藏書地圖,而那張表 10 個庫有 8 個是空的(Leo/Arcrun#87 在修) +# ⇒ **各 repo 真正的 wiki(system-dev/wiki/*.md)一份都沒進總圖。** 這支補的就是那一半。 +# +# 為什麼是本機腳本、不是加進雲端的 `global_index` 工作流: +# 工作流跑在 Cloudflare 上,拿 repo 樹只有一條路=Gitea 的 contents API 列檔, +# 而 principles 紅線寫死「**讀 repo 走 git clone/fetch,不走 API 列檔**」。 +# ⇒ 這件事的正解是在「有 git 的地方」算,算完存成 repo 裡的一份 md,開場 hook 直接印。 +# 票留在雲端現算(狀態必須即時),知識走這條(可以稍舊,且 D71 已寫明兩者不對稱)。 +# +# 產出**一份檔、兩個用途**(中間用 `<!-- panorama:inject-end -->` 切開): +# ① 標記以上=**開場注入**的摘要(hook 只印到這裡)。控制在幾 kB,不撐爆 context。 +# ② 標記以下=**給 grep 用的完整清單**(每個 repo 每一張卡的名字)。 +# 「某件事有沒有記過」就 `grep -i <關鍵字> system-dev/wiki/PANORAMA.md`。 +# +# 紅線: +# - **不輪詢**。這支只在人/本機發起時跑(改完 wiki 順手跑一次、或開場 hook 提醒你過期了)。 +# - **只讀 git**:clone/fetch,blobless + sparse,只抓 wiki 目錄。不打任何列檔 API。 +# - **只產 index 不搬內容**:一檔一行(檔名+最後更新日+大小+一句話),不倒全文。 +# +# 用法: +# bash scripts/wiki-panorama.sh # 算出來印到 stdout(不寫檔) +# bash scripts/wiki-panorama.sh --write # 同時寫進 system-dev/wiki/PANORAMA.md +# bash scripts/wiki-panorama.sh --no-fetch # 用快取算,完全不碰網路 +# bash scripts/wiki-panorama.sh --only mira # 只處理某幾個 repo(除錯用) +# +# 快取:$WIKI_PANORAMA_CACHE,預設 ~/.cache/inkstone-wiki-panorama(不落在 repo 裡) +# +# 配套:開場注入要靠 `.claude/hooks/session-start-recall.sh` 的 push 2/5。 +# 還沒套的話跑:`git apply scripts/patches/session-start-recall--wiki-panorama.patch` + +set -euo pipefail + +ROOT=$(git rev-parse --show-toplevel) +ROSTER="$ROOT/system-dev/wiki/.panorama-repos.txt" +OUT="$ROOT/system-dev/wiki/PANORAMA.md" +CACHE="${WIKI_PANORAMA_CACHE:-$HOME/.cache/inkstone-wiki-panorama}" +WIKI_DIRS="system-dev/wiki .claude/wiki" # 第二個是舊慣例,順手收 +GITEA_TOTAL_REPOS=24 # 總管 2026-08-12 用 Gitea API 實查的總數 +GITEA_TOTAL_ASOF=2026-08-12 + +DO_WRITE=0 +DO_FETCH=1 +ONLY="" +while [ $# -gt 0 ]; do + case "$1" in + --write) DO_WRITE=1 ;; + --no-fetch) DO_FETCH=0 ;; + --only) shift; ONLY="${ONLY} $1" ;; + -h|--help) sed -n '1,40p' "$0"; exit 0 ;; + *) echo "不認得的參數:$1" >&2; exit 2 ;; + esac + shift +done + +[ -f "$ROSTER" ] || { echo "找不到 roster:$ROSTER" >&2; exit 1; } + +# ── Gitea base(含憑證,**絕不可印出來**)──────────────────────────────── +REMOTE=$(git -C "$ROOT" remote get-url gitea 2>/dev/null || true) +[ -n "$REMOTE" ] || { echo "本 repo 沒有 gitea remote,無法取 repo。" >&2; exit 1; } +BASE=${REMOTE%/InkStoneCo.git} +BASE=${BASE%/InkStoneCo} +SELF=$(basename "$REMOTE" .git) +SECRET=$(printf '%s' "$REMOTE" | sed -nE 's|.*//[^:]+:([^@]+)@.*|\1|p') +# 任何要外流的字串都先過這關(錯誤訊息可能夾帶 clone URL) +scrub() { if [ -n "$SECRET" ]; then sed "s|$SECRET|***|g"; else cat; fi; } + +mkdir -p "$CACHE" +# 自己這個 repo 一律讀工作副本 ⇒ 快取裡若留著一份舊的自己,是誤導來源(會被人拿去讀) +if [ -d "$CACHE/$SELF" ]; then rm -rf "$CACHE/$SELF"; fi + +REPOS=$(grep -vE '^[[:space:]]*(#|$)' "$ROSTER" | tr -d '\r') +if [ -n "$ONLY" ]; then + REPOS=$(printf '%s\n' "$REPOS" | grep -Fx -f <(printf '%s\n' "$ONLY" | tr ' ' '\n' | grep -v '^$')) +fi +# Gitea 網址大小寫不敏感 ⇒ 同一個 repo 用兩種拼法會被算成兩個。去重。 +REPOS=$(printf '%s\n' "$REPOS" | awk '{k=tolower($0)} !seen[k]++') + +STATUS_TSV=$(mktemp) +trap 'rm -f "$STATUS_TSV"' EXIT + +for repo in $REPOS; do + # 自己這個 repo 讀工作副本,不繞一圈回 Gitea—— + # 剛寫完還沒推的 wiki 也要看得見(人就站在這裡改) + if [ "$repo" = "$SELF" ]; then + printf '%s\tok\t%s\n' "$repo" "$ROOT" >> "$STATUS_TSV"; continue + fi + dst="$CACHE/$repo" + if [ -d "$dst/.git" ]; then + if [ "$DO_FETCH" = 1 ]; then + br=$(git -C "$dst" symbolic-ref --short HEAD 2>/dev/null || echo main) + git -C "$dst" fetch --quiet origin "$br" 2>&1 | scrub >&2 || true + git -C "$dst" reset --quiet --hard FETCH_HEAD 2>/dev/null || true + fi + else + if [ "$DO_FETCH" = 0 ]; then + printf '%s\tno-cache\t\n' "$repo" >> "$STATUS_TSV"; continue + fi + # blobless + sparse:只下載 wiki 目錄的內容,但保留完整 commit 歷史 + #(要歷史才算得出「每個檔最後更新是哪天」——淺 clone 會讓所有檔同一天) + if ! err=$(git clone --quiet --filter=blob:none --sparse "$BASE/$repo.git" "$dst" 2>&1 | scrub); then + printf '%s\tclone-failed\t%s\n' "$repo" "$(printf '%s' "$err" | tr '\n' ' ')" >> "$STATUS_TSV" + rm -rf "$dst"; continue + fi + git -C "$dst" sparse-checkout set $WIKI_DIRS >/dev/null 2>&1 || true + fi + printf '%s\tok\t%s\n' "$repo" "$dst" >> "$STATUS_TSV" +done + +# ── 掃檔 + 排版(python3:BSD/GNU 工具差異多,交給它比較穩)──────────── +MD=$(WIKI_DIRS="$WIKI_DIRS" SELF="$SELF" CACHE="$CACHE" \ + TOTAL="$GITEA_TOTAL_REPOS" ASOF="$GITEA_TOTAL_ASOF" \ + python3 - "$STATUS_TSV" <<'PY' +import os, re, subprocess, sys, datetime + +status_tsv = sys.argv[1] +wiki_dirs = os.environ["WIKI_DIRS"].split() +SELF = os.environ["SELF"] +CACHE = os.environ["CACHE"].replace(os.path.expanduser("~"), "~") +TOTAL = int(os.environ["TOTAL"]) +ASOF = os.environ["ASOF"] +OUT_NAME = "PANORAMA.md" # 產生物自己不列進全景 +# 每個 repo 裝 system-dev-template 就會有的骨架檔。全部都有 ⇒ 逐一列出來只是雜訊, +# 對其他 repo 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔」。 +SKELETON = {"INDEX", "TAXONOMY", "decisions-summary", "mistakes", "principles", "status"} + +def run(cwd, *args): + try: + return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=60).stdout + except Exception: + return "" + +def dates_for(repo_dir, wdir): + """一次 git log 走完整個目錄,取每個檔第一次出現(=最後一次被改)的日期。""" + out = run(repo_dir, "git", "log", "--date=short", "--format=@%ad", "--name-only", "--", wdir) + seen, cur = {}, None + for line in out.splitlines(): + if line.startswith("@"): + cur = line[1:].strip() + elif line.strip() and cur: + seen.setdefault(line.strip(), cur) + return seen + +WIDE = re.compile(r'[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]') +def width(s): return sum(2 if WIDE.match(c) else 1 for c in s) +def clip(s, w): + out, acc = "", 0 + for c in s: + cw = 2 if WIDE.match(c) else 1 + if acc + cw > w: return out.rstrip(" 、,·-—") + "…" + out, acc = out + c, acc + cw + return out + +def clean(s): + s = re.sub(r'<!--.*?-->', '', s) + s = re.sub(r'\[\[([^\]]+)\]\]', r'\1', s) + s = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', s) + s = s.replace('**', '').replace('`', '').replace('~~', '') + s = re.sub(r'^[>#\-\*\s]+', '', s) + return re.sub(r'\s+', ' ', s).strip() + +SKIP = ('---', '===', '|', '```', '<!--', '<') +def title_and_hook(path): + try: + text = open(path, encoding="utf-8", errors="replace").read(9000) + except OSError: + return "", "" + lines = text.splitlines() + title, rest = "", lines + for i, ln in enumerate(lines): + if ln.startswith("# "): + title, rest = clean(ln), lines[i+1:] + break + hook = "" + for ln in rest[:60]: + s = ln.strip() + if not s or s.startswith(SKIP) or s.startswith("#"): + continue + c = clean(s) + if len(c) < 4: + continue + # hook 跟標題講同一件事就不重複佔位,往下找一句真的有增量的 + if title and (c[:10] in title or title[:10] in c): + continue + hook = c + break + return title, hook + +def one_line(title, hook, fallback): + """一檔一行的那句話:標題優先,標題太短就補一句 hook。""" + t = re.sub(r'^[\W_]*', '', title or "") or fallback + if hook and width(t) < 46: + t = t + "|" + hook + return clip(t, 78) + +# ── 掃 ──────────────────────────────────────────────────────────────── +repos, rows, cards, misses = [], {}, {}, [] +for line in open(status_tsv, encoding="utf-8"): + parts = (line.rstrip("\n") + "\t\t").split("\t") + repo, st, dirpath = parts[0], parts[1], parts[2] + if not repo: + continue + repos.append(repo) + if st != "ok": + misses.append((repo, {"clone-failed": "clone 失敗", + "no-cache": "沒有快取且指定了 --no-fetch"}.get(st, st))) + continue + found = False + for wdir in wiki_dirs: + full = os.path.join(dirpath, wdir) + if not os.path.isdir(full): + continue + dmap = dates_for(dirpath, wdir) + for name in sorted(os.listdir(full)): + p = os.path.join(full, name) + if not (name.endswith(".md") and os.path.isfile(p)) or name == OUT_NAME: + continue + rel = f"{wdir}/{name}" + t, h = title_and_hook(p) + rows.setdefault(repo, []).append( + dict(rel=rel, name=name, stem=name[:-3], date=dmap.get(rel, "?"), + size=os.path.getsize(p), line=one_line(t, h, name[:-3]))) + found = True + cdir = os.path.join(full, "cards") + if os.path.isdir(cdir): + for bucket in sorted(os.listdir(cdir)): + bp = os.path.join(cdir, bucket) + if not os.path.isdir(bp): + continue + for n in sorted(os.listdir(bp)): + if n.endswith(".md") and not n.startswith("00-INDEX"): + cards.setdefault(repo, []).append(n[:-3]); found = True + if not found: + misses.append((repo, "沒有 wiki")) + +def kb_of(n): return max(1, round(n / 1024)) +def newest(rs): return max([r["date"] for r in rs if r["date"] != "?"] or ["?"]) + +today = datetime.date.today().isoformat() +n_files = sum(len(v) for v in rows.values()) +n_cards = sum(len(v) for v in cards.values()) +have = [r for r in repos if r in rows or r in cards] +nowiki = [r for r, why in misses if why == "沒有 wiki"] +broken = [(r, why) for r, why in misses if why != "沒有 wiki"] + +o = []; w = o.append + +# ── 標記以上:開場注入的那一段 ──────────────────────────────────────── +w("# 全景:各 repo 的 wiki 裡有哪些檔(index,不是內容)") +w("") +w(f"> **{today} 產生**(`bash scripts/wiki-panorama.sh --write`,人發起、不輪詢) · " + f"來源=`git clone/fetch` Gitea `Leo/*` 各 repo 的預設分支,不走 API 列檔。") +w(f"> {SELF} 這段讀的是**本機工作副本**(剛寫完還沒推的也算數)。**改這個檔沒用**,要改去改那個 repo 的 wiki。") +w(">") +w("> 🔴 **這裡沒有內容,只有「有這件事、在哪個 repo 的哪個檔」。**") +w(f"> - 「某件事 wiki 記過沒有」→ `grep -i <關鍵字> system-dev/wiki/{OUT_NAME}`" + f"(本檔下半部有全部 {n_cards} 張卡的名字)") +w(f"> - 要讀內容 → 本機有那個 repo 就直接讀;沒有就看快取 `{CACHE}/<repo>/system-dev/wiki/`") +w("") +w(f"**{n_files} 份主檔 + {n_cards} 張卡,散在 {len(have)} 個 repo**" + f"(點名 {len(repos)} 個 repo,其中 {len(nowiki)} 個掃過確定沒有 wiki)") +w("") + +# 自己這個 repo:逐檔一行(這是你最可能真的去讀的那份) +if SELF in rows: + rs = rows[SELF] + head = f"## {SELF}(你現在站的地方)— {len(rs)} 份主檔" + if cards.get(SELF): head += f"、{len(cards[SELF])} 張卡" + w(head) + for r in rs: + w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}") + if cards.get(SELF): + w(f"- `system-dev/wiki/cards/` {len(cards[SELF])} 張:" + "、".join(sorted(cards[SELF]))) + w("") + +# 其他 repo:一 repo 一行。骨架六檔每個 repo 都有,逐一列是雜訊—— +# 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔 + 卡數」。 +others = [r for r in have if r != SELF] +if others: + w("## 其他 repo — 一 repo 一行") + w("") + w("> 每個 repo 都有 `INDEX`/`TAXONOMY`/`decisions-summary`/`mistakes`/`principles`/`status` " + "這套骨架(裝 system-dev-template 就有),所以只標**新鮮度、最肥的那份、多出來的檔**。") + for repo in others: + rs = rows.get(repo, []) + extra = [r["name"] for r in rs if r["stem"] not in SKELETON] + big = max(rs, key=lambda r: r["size"]) if rs else None + seg = [f"**{repo}** — {len(rs)} 份"] + if cards.get(repo): seg.append(f"{len(cards[repo])} 張卡") + if rs: seg.append(f"最近改 {newest(rs)}") + if big: seg.append(f"最肥 `{big['name']}` {kb_of(big['size'])}kB") + if extra: seg.append("多出來的:" + "、".join(f"`{e}`" for e in extra)) + w("- " + " · ".join(seg)) + w("") + +w("## 涵蓋範圍——**沒掃到的也要看得見**") +w("") +if nowiki: + w(f"- **掃過、確定沒有 wiki 的 {len(nowiki)} 個**:" + "、".join(f"`{r}`" for r in nowiki)) +if broken: + w("- ⚠️ **這次沒抓到的**:" + "、".join(f"`{r}`({why})" for r, why in broken)) +w(f"- 名單=`system-dev/wiki/.panorama-repos.txt`({len(repos)} 個,逐一 `git ls-remote` 驗過存在)。") +gap = TOTAL - len(repos) +if gap > 0: + w(f"- ⚠️ **本圖點不到「名單上沒有的 repo」**:git 只能驗名字、不能枚舉。" + f"Gitea `Leo/*` 在 {ASOF} 實查是 **{TOTAL} 個**,名單 {len(repos)} 個 ⇒ **還有 {gap} 個沒被點名**。") + w(" 補法(人發起,一次呼叫,不排程)——拿到完整清單、把缺的名字加進名單再重跑:") + w(" ```") + w(" TOKEN=$(git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\\1|')") + w(" curl -s -H \"Authorization: token $TOKEN\" \\") + w(" 'https://git.uncle6.me/api/v1/orgs/Leo/repos?limit=100' | python3 -c \\") + w(" 'import sys,json;[print(r[\"name\"]) for r in json.load(sys.stdin)]'") + w(" ```") +w("") + +# ── 標記以下:不進注入,給 grep ──────────────────────────────────────── +w("<!-- panorama:inject-end —— 開場注入只印到這一行為止。以下是給 grep 的完整清單 -->") +w("") +w("## 完整清單(不進開場注入,給 `grep` 用)") +w("") +for repo in have: + rs = rows.get(repo, []) + if repo != SELF and rs: + w(f"### {repo} — 主檔") + for r in rs: + w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}") + w("") + if cards.get(repo): + w(f"### {repo} — cards({len(cards[repo])} 張)") + for c in sorted(cards[repo]): + w(f"- {c}") + w("") +print("\n".join(o)) +PY +) + +if [ "$DO_WRITE" = 1 ]; then + printf '%s\n' "$MD" > "$OUT" + TOTAL_B=$(printf '%s\n' "$MD" | wc -c | tr -d ' ') + INJ_B=$(printf '%s\n' "$MD" | awk '/panorama:inject-end/{exit} {print}' | wc -c | tr -d ' ') + echo "已寫入 $OUT(全檔 ${TOTAL_B} bytes,其中開場注入的那段 ${INJ_B} bytes)" >&2 +else + printf '%s\n' "$MD" +fi diff --git a/skills/deep-recall/SKILL.md b/skills/deep-recall/SKILL.md new file mode 100644 index 0000000..161b601 --- /dev/null +++ b/skills/deep-recall/SKILL.md @@ -0,0 +1,114 @@ +--- +name: deep-recall +description: | + 內部 deep research:把散落在 Gitea 票、各 repo wiki、KBDB 裡的枝葉,**還原成一棵 leo 讀得懂的樹**。 + 在下列時機必讀、必用:leo 問「現在做到哪」「有什麼還沒完成」「幫我看現況」「列今天的大項」; + 要交任何橫跨 3 個以上票或 repo 的回報;重裝/遷移前後要拍對帳快照;接關後要向 leo 交待全局。 + 核心判準:**leo 要的是「他能讀的資訊」,不是「拆得更細的知識」**—— + 交出沒有分組的清單、或沒有邏輯鏈的散文,兩者都算沒交付(2026-08-14 一個 session 內連犯兩次)。 + 收齊:為什麼「靠記得提綱挈領」必定失敗的機械原因/三步流程(分頭讀→只回 schema→先分群再下筆)/ + 輸出樹的硬格式(每格必須有出處+狀態)/五條禁令/快照落地位置與對帳用法。 +--- + +# deep-recall — 內部 deep research + +## 這支存在的原因(先讀,否則你會以為自己不需要它) + +leo 2026-08-14:「給你大量資訊時,你有 2 種反應:1)列出瑣碎資訊沒有分組、重組; +2)總結成幾段缺邏輯鏈的散文。**總管需要提綱挈領**。」 + +**這不是能力問題,是流程問題。** 對外 deep research 能從枝葉還原樹,靠的是三件事—— +而「自己一頁一頁讀完再憑印象寫」這三件一件都沒有: + +| deep research 做的 | 自己硬讀會發生什麼 | +|---|---| +| 每個來源由**獨立的讀者**讀完,只把**結構化摘要**帶回來 | 原文全湧進同一個 context,讀到第 30 筆時前面的細節已在跟新細節爭位置 ⇒ 只剩「還記得的那幾條」=清單 | +| **先產候選主題、把發現掛上去、再砍掉沒支撐的枝** | 邊讀邊寫 ⇒ 輸出順序=讀到的順序 ⇒ 流水帳 | +| 每個節點**回貼出處與狀態** | 沒有出處就只能寫感想 ⇒ 散文 | + +🔴 **判準:你手上有沒有枝葉,跟你交不交得出樹,是兩件事。** +2026-08-14 那次,總管**已經讀完** 134 張票與全部 wiki,交出去的仍然是清單—— +所以「下次記得要提綱挈領」不是解法,**照下面的步驟做**才是。 + +## 三步流程(不准跳步) + +### 步驟 1|先劃範圍與骨架,再讀任何一個字 + +寫下這三行(寫在回覆或 scratchpad,不准只在腦裡): + +1. **問題**:leo 這次要的是什麼決定/什麼判斷(不是「他問了什麼」,是「他要拿它做什麼」) +2. **來源清單**:哪些 repo 的票、哪些 wiki 檔、KBDB 的哪幾個查詢、哪些線上端點 +3. **候選骨架**:先猜 3–5 條主線(**允許猜錯,後面會被證據推翻**)——沒有骨架就會退化成流水帳 + +### 步驟 2|分頭讀,每個讀者只准回固定 schema + +一個來源一個 subagent(或一批)。**原文不進主 context**,只回這個 schema: + +```json +{ + "source": "Leo/Arcrun#87 / system-dev/wiki/mistakes.md:1200-1400 / kbdb_get_map()", + "findings": [ + { + "claim": "一句話講完的事實(人話,不是術語)", + "evidence": "票號+留言 id/檔:行/實測輸出的關鍵那行", + "status": "✅通 | ◐半通 | ❌斷 | 📌事實", + "blocked_by": "誰擋著它(票號/人/前置條件),沒有就 null", + "belongs_to": "你認為它掛在哪條主線(用步驟 1 的骨架,覺得都不對就寫 new:<你的命名>)" + } + ] +} +``` + +- 派給 subagent 時**寫目的不寫做法**(CLAUDE.md 派工鐵律),但**輸出 schema 要寫死**—— + 格式不是做法,是介面。 +- **事實宣稱要自己驗**(規則四之一):subagent 回「X 不存在」時,那多半是「我這裡看不到」。 + +### 步驟 3|先分群,再下筆 + +1. 把所有 `findings` 按 `belongs_to` 攤開,**看哪些 new: 出現超過兩次** ⇒ 那是骨架漏掉的主線,補進去 +2. **砍掉只有一個發現支撐的枝**(那是細節,塞回它的父節點當證據) +3. 每條主線寫一句 **「共通形狀」**——如果寫不出來,那條主線是假的,拆掉重分 +4. 才開始寫輸出 + +## 輸出的硬格式 + +``` +主線 N|<一句話的父項,講「共通形狀」不是講領域> + 現況:✅通 / ◐半通 / ❌斷 ——(一句話:卡在哪) + ├─ <子節點> [狀態] ← <出處> + ├─ <子節點> [狀態] ← <出處> + 完工判準:<可以實測的一句話> + 下一步:<誰做什麼;要 leo 的標 👤> +``` + +**每一格都要有 `← 出處`。** 寫不出出處的節點**不准出現**—— +它不是「我還沒查」,它是「我在編」。 + +## 五條禁令 + +1. **不准交沒有父項的清單**(票號列表=原始資料,不是回報) +2. **不准交沒有出處的散文** +3. **不准把「我沒查」「查不到」「工具回 401/回 0」講成「不存在」**(三者要分開講) +4. **不准把「程式碼寫完了」當成狀態**——狀態只有 ✅/◐/❌(CLAUDE.md Critical Path 鐵律) +5. **不准在同一份輸出裡混「已驗證」與「我推測」而不標** + +## 快照要落地(否則下次又要重跑一次) + +產出的樹存成 `system-dev/wiki/trees/<YYYY-MM-DD>-<主題>.md`,開頭三行寫: +**問題/來源清單/量測時間**。 + +**對帳用法(重裝、遷移、大改之前後必做)**:動手前拍一次、動完拍一次, +`diff` 兩棵樹——**沒有掉東西**才算成功。這是「重裝有沒有弄丟知識」唯一可驗的方法。 + +## 這支的終局:它應該被資料層取代 + +現在這支是 brute force:每次都要把全部重讀一遍,貴且慢。 +**真正的解是讓那棵樹變成 ingest 的產物**——leo 2026-08-14 定的兩條鏈: + +``` +上傳:原文 → 萃 wiki → 同步 wiki+三元組+該庫 index → 組成全局圖 → 算出全庫摘要 +查詢:圖搜索 → 查到幾個有關庫 → 查該庫 index → 從 index 找 wiki → 從 wiki 找原文 +``` + +**「該庫 index」與「全庫摘要」就是這棵樹的持久化版本。** 它們做出來以後, +本 skill 從「每次重算」降級成「驗算與補洞」。在那之前,**每次都要跑這支**。 diff --git a/skills/ship-check/SKILL.md b/skills/ship-check/SKILL.md new file mode 100644 index 0000000..5280ee7 --- /dev/null +++ b/skills/ship-check/SKILL.md @@ -0,0 +1,595 @@ +--- +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 會看的那兩處抓實際畫面複驗。 + 附「常見漏掉的」實撞表與收工前五問。 +--- + +# /ship-check — 改完東西後,讓 leo 看得到版本變了 + +> **這支解什麼病**(leo 2026-08-05 原話): +> 「對人來說,**我雲端看 portal 有沒有更新,本地看 daemon 有沒有更新**, +> 這個更新機制都寫好了,然後你說你改了這麼多居然版本一樣,還要去找怎麼做, +> **這個太危險了**。」 +> +> 🔴 **核心判準:版本號是 leo 唯一的驗收介面。** +> 版本沒動 = 他無從判斷你做了什麼 = 你等於沒交付。 +> 「我在某台實例 `wrangler deploy` 過了」**不算**——那只改了那一台。 + +--- + +## ⚠️ 這支 skill 自己的失效模式(先讀這段) + +leo 2026-08-05:「**你寫完一個 skill 然後每個我要提醒你,表示這個 skill 無效**」 + +**根因**:我寫這支時是**憑印象列步驟**,沒有真的走一遍使用者的路 +⇒ 於是「DMG 打開長什麼樣」「Info.plist 版本對不對」這些**只有真的打開才看得到**的東西全漏了, +每一條都要 leo 問「你檢查了嗎」才補。 + +**因此本 skill 的鐵律**: +1. **每一條檢查都要有可貼的實測輸出**——寫不出指令的條目就是還沒想清楚,不要列。 +2. **凡是「使用者會看到的東西」,一律真的打開來看**(掛載 DMG、解開 zip、抓網頁內容), + 不是確認檔案存在、不是看腳本說成功。 +3. **被 leo 問出來的缺口,當場補進這支 skill**——否則下次還是靠他記得。 + (本檔的 3.5、2.9 兩段都是這樣補進來的,日期都記著。) + +--- + +## 什麼時候跑 + +**改完任何會影響用戶的東西之後**(雲端 worker/portal/daemon/workflow), +在說「做完了」之前。不是收工才跑。 + +--- + +## 🚚 雲端線出貨=**一個指令**,不要照下面的步驟手工做(2026-08-08 起) + +> leo 2026-08-08:「**每次做一樣的事,結果會打錯實例,就是你的出貨閘是錯的。 +> 寫對的應該每次都機械式的做同一件事,寫錯位置也會被它修正。**」 +> 「這個出貨閘門就是廢的,它應該要像 GitHub Actions 一樣 CI/CD。」 + +```bash +cd products/arcrun-rag +node installer/scripts/ship.mjs --list # 有哪些目標 +node installer/scripts/ship.mjs --target stage # 預演:建+算版本+報線上差距 +node installer/scripts/ship.mjs --target stage --confirm # 真的走完 stage +node installer/scripts/ship.mjs --target prod --confirm # prod(先 stage,且需 leo 開閘) +``` + +**九個步驟固定不變**:`preflight → build → version → commit → push → pin → deploy → purge → verify` +每一步只有「執行/跳過(附機械理由)」兩種結果,**斷了後面一步都不跑**。 + +它已經替你做掉的事(**所以下面 §2〜§6 的手工步驟不要再照著做一遍**): + +| 以前靠人記得 | 現在 | +|---|---| +| 記得重打 bundle | `build` 步驟**每次都重打**(含 `build-ui-bundle.mjs`——它跟 `build-bundles.mjs` 是**兩支**,只跑一支就是 portal 改動送不出去) | +| 記得換釘子、且**兩處都要換** | `pin` 步驟同時寫 `wrangler.toml` 的 vars 與 `worker.js` 常數 | +| 記得 purge jsDelivr | `purge` 步驟(prod 才需要),驗到收斂為止 | +| 記得複驗線上 | `verify` 步驟**真的把 daemon 下載下來算 sha256** | +| 手打 `--bundles <路徑>`(會打錯) | 🔴 **已移除**。目標只能 `--target`,座標全來自 `installer/ship.targets.json` | + +🔴 **目標打錯打不進去**:本機 clone 的 `origin` 與登錄簿不符 ⇒ 當場擋; +`CLOUDFLARE_ACCOUNT_ID` 一律由登錄簿覆蓋,不吃環境裡飄來的值; +`prod` 沒有 leo 的 `.github-armed` ⇒ preflight 就斷。 + +📌 **stage 驗證章由管線自己蓋**(`--target stage --confirm` 成功才寫 `/tmp/.stage-verified`)。 + **不要用手 `touch`**——手蓋的章證明不了任何事,那正是這道閘想擋的東西。 + +⚠️ 桌面線(打包 App/DMG/exe)**還沒併進管線**,仍照下面 §3、§3.5 手工做, + 做完把產物放進 bundles repo 的 `daemon/`,再跑 `ship.mjs`(它會驗版本與 sha 對不對得上)。 + +--- + +## 兩條版本線(先分清楚在講哪一條) + +| 線 | 版本號 | leo 從哪看 | 出貨鏈 | +|---|---|---|---| +| **雲端** | `manifest.release`(如 `1.4.11`) | **portal 設定頁的版本卡** | 重打 bundle → 推 bundles repo → release 自動 bump → 換安裝器釘子 → 部署安裝器 | +| **桌面** | `manifest.daemon.version`(如 `v0.18.4`) | **daemon 的「檢查更新」** | 打包 App → 放進 bundles `daemon/` → 改 `manifest.daemon` → purge jsDelivr | + +⚠️ 兩條**各自獨立**。改 kbdb 不會讓 daemon 版本動,反之亦然。 + +--- + +## 步驟 + +### 0. 先查記憶(別重蹈覆轍) + +```bash +grep -rn "版本號\|出貨\|release" system-dev/wiki/decisions-summary.md | head +``` + +必讀 **D39「版本號由內容算出來,不由人宣告」**: +- `MAJOR.MINOR` 在 `RELEASE_LINE` 檔(人只在大改版動) +- **`PATCH` 由機器決定**:內容指紋一變 +1、沒變不動(重跑不虛增) +- **真相源只有 `manifest.release` 一處**,installer/landing/portal 全是讀者 + +> ⇒ **「改了 code 但 release 沒 bump」= bundle 沒重打包**,不是版本機制壞了。 + +### 1. 判斷這次改動影響哪條線 + +```bash +git -C matrix/arcrun log --oneline -5 # 雲端 worker(cypher/kbdb/portal…) +git -C products/arcrun-rag log --oneline -5 # daemon/安裝器/workflow +``` + +- 動到 `matrix/arcrun` 的 worker 或 `console-ui/public/portal/` → **雲端線** +- 動到 `collector/`(含 `cmd/arcrun-app/`) → **桌面線** +- 動到 `workflows/*.yaml` → **要重編 workflows.json**(見步驟 2.5) + +### 2. 雲端線:重打 bundle(**這步最常漏**) + +```bash +cd products/arcrun-rag +ARCRUN_REPO_ROOT=../../matrix/arcrun node installer/scripts/build-bundles.mjs --out <bundles repo>/ +``` + +**複驗你的改動真的進去了**(不要只看腳本說成功): + +```bash +grep -c "<你改的關鍵字>" <bundles repo>/tier2/<worker>/index.js +# 例:改 embed 模型 → grep -c "bge-m3" 應 > 0,且舊模型應為 0 +``` + +> 🔴 實撞(2026-08-05):我改了 kbdb 的 embed 模型並 `wrangler deploy` 到 youlin, +> 但**沒重打 bundle** ⇒ bundles repo 裡仍是 `bge-base-en` +> ⇒ **新用戶安裝/既有用戶重裝都拿到舊的**,而 leo 看到的 release 仍是 1.4.11。 + +### ⛔ 2.9 bundle 沒重打之前,**要主動叫 leo 別按「立即更新」** + +**這是最危險的狀態**,比「沒出貨」更糟: + +- 你直接 `wrangler deploy` 到某台實例 ⇒ **那台**是新的 +- 但 bundle 還是舊的 ⇒ leo 按 portal 的「**立即更新**」=**從舊 bundle 重裝** +- ⇒ **他的實例會被你剛修好的東西「降級」回舊版** + +實例(2026-08-05,leo 說「我要去更新實例驗證」時攔下): +kbdb 已直推 `bge-m3`(1024 維)+已建 1024 維 Vectorize index, +但 bundle 裡仍是 `bge-base-en`(768) ⇒ 一按更新,kbdb 退回舊模型, +**與 1024 維 index 對不上 ⇒ 中文語意搜尋直接壞掉**。 + +📌 **判準**:只要你「直推過實例」但「還沒重打 bundle」, +**主動說一句「先別按立即更新,會裝回舊的」**——不要等 leo 自己踩到。 +兩者狀態不一致的期間,**降級風險是你造成的,說清楚是你的責任**。 + +### 🔁 2.7 動到安裝/更新/認證的話:**stage 要走三段,不准只測 update** + +(leo 2026-08-14 立,全文見頂層 `decisions-summary.md` **D84 之二**) + +> leo 原話:「**以後你的 stage 測試除了測 update 還要用 uninstaller 刪除後再模擬第一次安裝。**」 + +``` +① 測 update(既有 stage 實例) +② 用 uninstaller 拆掉 +③ 從乾淨狀態模擬第一次安裝 +``` + +🔴 **只做 ① 不算測過。** stage 實例是長期存在的,它身上有所有舊 binding 與舊 secret +⇒ **它天生就是「既有實例」,天生看不到全新用戶會撞的坑。** + +**這條的代價是實際發生過的**(2026-08-14 一天三個坑,全是只驗 update 會漏掉的): +- 安裝器從沒種過 `CF_SECRETS_API_TOKEN` ⇒ **全新用戶建不出第一個帳號**(`Arcrun#119`) +- 中心 KV 說「你裝過了」而帳號上什麼都沒有 ⇒ **重裝死結**(`Arcrun#120`) +- 撞牆訊息叫用戶去跑一件做不到的事(`Arcrun#121`) + +**每一個都在既有實例上測不出來**,因為既有實例會沿用舊資源把缺口蓋住。 + +⚠️ **uninstaller 還沒做出來之前**,這條走不完 ⇒ 出貨時要**明講「③ 沒驗」**, +不准因為「工具還沒有」就跳過不提。 + +### 2.5 改過 workflow 的話 + +```bash +CYPHER_BASE=<實例 cypher URL> CYPHER_NS=<namespace> \ + node installer/scripts/compile-workflows.mjs +``` + +⚠️ **flow 變了就不能沿用舊的預編圖**,腳本會 `exit 1` 擋住(這個閘是對的)。 +不帶 `CYPHER_BASE` 就跑=直接失敗,別繞過它。 + +### 3. 桌面線:打包 App + +```bash +cd products/arcrun-rag/collector/cmd/arcrun-app +VERSION=vX.Y.Z bash build-mac.sh && bash build-dmg.sh # Mac +VERSION=vX.Y.Z bash build-win.sh # Windows(Mac 上可交叉編譯) +``` + +**三支機械閘必須全過**(交貨前): + +```bash +for s in check-cis.sh check-render.sh check-tray.sh; do bash "$s" || echo "❌ $s"; done +``` + +**實跑驗證產物**(不是看檔案存在):Mac 用 `open` 真的跑一次; +或用同綑的 collector 跑 `direct --once` 確認你的修復在裡面。 + +#### 3.5 🔴 **把 DMG/zip 真的打開,用「使用者第一次看到的樣子」檢查** + +> leo 2026-08-05 連問三次才問出來的東西——**列步驟不算驗,要真的開起來看**。 +> 「我問你 Mac 打包是不是一個 folder 打開可以把 dmg 拖到 application 去?**你檢查了嗎?**」 + +```bash +# Mac:掛載 DMG,看使用者會看到什麼 +hdiutil attach dist/Arcrun-vX.Y.Z.dmg -nobrowse -quiet -mountpoint /tmp/dmgchk +ls /tmp/dmgchk/ # 應**剛好兩項**:Arcrun.app + Applications 捷徑 +ls /tmp/dmgchk/Arcrun.app/Contents/MacOS/ # ⚠️ 2026-08-14 訂正:現在是**單一二進位**,只有 arcrun-app +/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" \ + /tmp/dmgchk/Arcrun.app/Contents/Info.plist # ← 必須是本次版本,**不是 1.0.0** +/usr/libexec/PlistBuddy -c "Print :LSUIElement" \ + /tmp/dmgchk/Arcrun.app/Contents/Info.plist # 應為 true(不佔 Dock) +codesign -v /tmp/dmgchk/Arcrun.app # 無輸出=簽章有效 +hdiutil detach /tmp/dmgchk -quiet + +# Windows:解開 zip 看內容 +unzip -l dist/ArcrunRAG-win-unsigned-vX.Y.Z.zip # 應有 Arcrun.exe + arcrun-collector.exe +``` + +**逐項判準**: +| 檢查 | 為什麼 | 漏掉的後果 | +|---|---|---| +| DMG 內剛好兩項 | 這就是「拖進 Applications」的標準畫面 | 沒有捷徑 ⇒ 使用者在下載資料夾直接開 ⇒ **更新會蓋錯位置**(t184) | +| ~~`arcrun-collector` 同綑~~ **已過期(2026-08-14 訂正)** | 以前 daemon 是獨立二進位;**現在 collector 內建在 `arcrun-app` 裡**(v0.18.27 實測:`strings` 抓得到 `LoadDirectConfig`/`saveDirectConfig`,direct 模式符號 18 處) | 🔴 **照舊版檢查會發出「你的 App 缺件」的假警報**——總管 08-14 差點對 leo 發出來。要驗「同步能力在不在」改用 `strings` 抓 direct 模式符號 | +| `CFBundleShortVersionString` | Finder/「關於」顯示的版本 | **永遠顯示 1.0.0** ⇒ 用戶無從判斷自己是不是新版 | +| `LSUIElement=true` | 常駐小工具不該佔 Dock | 行為與設計不符 | +| `codesign -v` | 改過 bundle 內容必重簽 | launchd 拒開 | + +> 🔴 實撞(2026-08-05):`CFBundleShortVersionString` 一直是 **`1.0.0`**—— +> `wails.json` 沒有 `info.productVersion`,而 `wails build` **沒有 CLI 旗標**可指定。 +> 解法已寫進 `build-mac.sh`/`build-win.sh`:建置前把版本寫進 `wails.json` 的 `info`、 +> 建完用 `trap` 還原(不污染版控)。 + +### 4. 寫 changelog(**leo 點名要的**) + +`products/arcrun-rag/docs-site/src/content/docs/help/changelog.md` + +格式照既有的(**寫給用戶看,不是寫 commit message**): + +```markdown + +## 📋 出貨對照表:**改了什麼 → 要動什麼**(leo 2026-08-08:「寫清楚不要再改錯」) + +> 這張表是**查的**,不是用判斷的。08-08 一整天的錯全是「憑印象決定要重打什麼」。 +> **每次出貨逐列對,有碰到就一定要做。** + +| 你改了什麼 | 要重打/重部署 | 版本線 | 要一起改的文件 | 最後落在哪 | +|---|---|---|---|---| +| **daemon Go 程式** | 三平台打包(mac/win/msix) | **daemon 版本線** | `changelog.md` **必寫** | 用戶自己的電腦 | +| **portal/console-ui 前端** | `build-ui-bundle.mjs` | **雲端 release** | 有畫面變化就要更 docs | 每個實例的 `arcrun-rag-ui` | +| **cypher / kbdb / registry / mcp** | `build-bundles.mjs` | **雲端 release** | — | 每個實例的對應 worker | +| **安裝器 `worker.js`** | 部署安裝器(帶 `--config`) | 安裝器自己 | — | `install.arcrun.dev` | +| **landing** | 部署 landing | — | — | `rag.arcrun.dev` | +| **說明文件** | `npm run build` → **`dist`→`deploy/docs`** → 部署 | — | — | `rag.arcrun.dev/docs` | +| **錯誤分類/新的失敗原因** | — | — | 🔴 **FAQ 一定要同步** | docs 站 | + +🔴 **兩個最常漏的(都有 08-08 實據)**: +- `build-bundles.mjs` **不會重建 `arcrun-rag-ui`**(它在「沿用非本腳本產」清單裡) + ⇒ 只跑一支=**靜默出半套**。portal 文案傳不出去整整一天就是這個。 +- docs 的 `dist → deploy/docs` **沒有腳本**,漏了它 `wrangler deploy` 會「成功」但只上傳 0.35 KiB。 + +### 版本修改內容(`changelog.md`)怎麼寫 + +`changelog.md` 是**版本號的單一真相源**,也是使用者在「版本與更新」畫面看到的內容。 + +- **一行一句,講「你會看到什麼不一樣」**,不是 commit 訊息翻譯 +- 🔴 **要短。細節去 docs 讀**(leo 08-08 看到 v0.18.24 的更新說明是一整面文字牆: + 「**不要這麼長的散文,簡短講改了什麼,細節去 docs 讀**」) +- **不要帶 markdown 粗體**——那個欄位是純文字,`**` 會原樣顯示給使用者看 +- 沒有對應版本的段落就**不准打包**(`changelog-section.sh --check` 會擋) + +### FAQ 什麼時候一定要動 + +- **新增/改變任何「使用者會看到的失敗原因分類」** ⇒ FAQ 必須同步 + (分類收斂成有限幾類,畫面只出分類與份數,**細節全在 FAQ**——見 t214) +- **改了安裝或更新的做法** ⇒ 對應的說明頁必須同步 + (08-08 實據:Windows 說明還寫「解壓縮 zip、裡面兩個 exe」,而我們早就只出單一 `.exe`) + +📌 **判準:使用者會因為這次改動而問出新問題嗎?會 → FAQ 要有答案。** + +### 什麼地方 → 修哪個實例(別再搞混) + +> 🔴 **AI 的工作迴圈只有兩件事**(leo 2026-08-08 原話): +> 「**實際上你是在 uncle6 修改 bundle,然後你自己在 youlin stage 安裝新版 bundle。**」 +> ⇒ **改 staging bundle(uncle6)→ 裝進 youlin 的 stage 環境 → 自己測**。這一圈可以無限重複。 +> **「uncle6 不碰」指的是 prod 那一側,不是 staging bundle**——別把自己的工作區也擋掉。 + +| 這個東西 | 住在誰的帳號 | AI 能不能動 | +|---|---|---| +| **staging bundle repo** | uncle6(Gitea) | ✅ **這是 AI 的工作區**,隨便改隨便推 | +| **youlin 的 stage 環境** | youlin | ✅ **AI 的測試場**,隨便裝、壞了自己修 | +| **prod bundle repo** | uncle6(GitHub) | 🔴 要 leo arm,且只准「**原封提升**」不准重打 | +| **prod 的安裝器/landing/docs 部署** | uncle6 | 🔴 部署=出貨動作,要 leo 開閘 | +| **youlin 的 prod 環境** | youlin | 👤 leo 用來驗「用戶裝 prod 拿到什麼」 | +| **geek6688(出貨機/第一個顧客)** | geek6688 | 🔴 **不准拿它當測試場**;但**發佈後總管要手動把它更新到最新**——見 `ops-facts.md` §出貨線。**出貨機比貨舊是錯的**(08-12 實撞:停在 1.4.29、落後 12 版) | +| `rag-demo.arcrun.dev` | uncle6 | 🪦 **已退場**,任何文件/腳本不得再導向 | + +📌 **一句話界線**:**staging 側全是我的、prod 側全是 leo 的閘。** + +### 🖥️ 三台機器各自幹嘛(leo 2026-08-12 定義,08-14 再次口頭確認) + +> **youlin =測試機|geek6688 =出貨機|uncle6 =總部機** + +- **youlin =測試場**:**它就是拿來拆的**。裝壞了、要清空重來,**不必問、不必猶豫** + ——把它當成珍貴資產而不敢動,等於讓「只有全新用戶會撞的坑」永遠測不到(D84 之二)。 + 唯一要守的是**時序**:確認沒有別條線正踩在上面。 +- **geek6688 =出貨機/第一個顧客**:**不當測試場**,但**發佈後要更新到最新**(總管手動)。 +- **uncle6 =總部機**:bundle 的家。staging 側是總管的工作區,**prod 側是 leo 的閘**。 + +📌 **總管 2026-08-14 實錯**:leo 交辦拆 youlin 時,總管去問「有沒有資料是重裝也長不回來的」 +——**而這個答案 `ops-facts.md:29` 早就寫著了**。leo 當場點破:「**stage 不就是用來測的?**」 +⇒ **對測試機的謹慎不是美德,是把該做的驗證擋在門外。** + +## 🗺️ 出貨流程(leo 2026-08-08 深夜定死,不得再自創路徑) + +> leo 原話:「**uncle6 的 bundle 要裝在 youlin 的 stage 環境,沒問題後才推 prod 改裝在 +> youlin 的 prod 環境,所以 uncle6 的 bundle 要 2 套,youlin 也要兩套, +> 就像把同樣程式碼打到不同分支一樣。**」 +> 「**CF 可以這樣建立分離的 stage,不是在 prod 環境測 stage。**」 + +### 矩陣(兩軸,四格,缺一格就會像 08-08 那樣炸) + +``` + 產物來源(uncle6) 安裝目標(youlin) + stage → staging bundle repo → youlin 的 stage 環境 + prod → prod bundle repo → youlin 的 prod 環境 +``` +**同一份程式碼打到兩條線,像 git 的兩個分支——彼此不共用任何會互相污染的東西。** + +### 六步,順序不准跳 + +``` +① 改 code → commit + push gitea +② 打 bundle → 版本號由內容指紋算出;內容變了版本一定變 + ⚠️ 要跑兩支:build-bundles.mjs + build-ui-bundle.mjs + (前者不會重建 arcrun-rag-ui,漏跑=靜默出半套) +③ 推 staging bundle → Gitea(不碰 GitHub,無 D20 閘) +④ 裝進 youlin 的 stage 環境 → AI 自己測:登入、首頁、匯出診斷檔(瀏覽器實載) +⑤ 👤 **leo 去 youlin 的 stage 環境**,看修復版**是不是真的修好了** + → 他說可以,跑 scripts/stage-ok.sh +⑥ 👤 leo 跑 scripts/github-arm.sh + → **把 staging bundle「原封提升」成 prod bundle**(uncle6 內部:staging repo → prod repo) +⑦ 👤 leo 在 **youlin 的 prod 環境**裝一次新版 prod 內容 + → 驗的是「**用戶從 prod 安裝器裝,會拿到什麼**」這條路本身 +⑧ 之後任何人從 prod 安裝器裝就拿到新版;leo 想在哪抽查都行(geek6688 或任何實例) +``` + +🔴 **youlin 為什麼一定要兩套環境**(leo 2026-08-08:「**然後我可以在 youlin 測新版的 +prod 內容安裝**」): +- **stage 環境** → 驗「修復到底修好了沒」(發佈前的閘,第 ⑤ 步) +- **prod 環境** → 驗「用戶裝 prod 會拿到什麼」(發佈後的第一手驗證,第 ⑦ 步) +⇒ 兩件事驗的東西不同,**不能共用同一套環境**—— + 共用就會變成 2026-08-08 那樣:為了測而動到正在用的那套,把要驗的東西弄壞。 + +🔴 **⑥ 是「提升」不是「重打」**(leo 2026-08-08 原話: +「**arm 推的是 uncle6 把 stage 的 bundle 推到 prod 的 bundle**」) +⇒ **prod 的產物必須與 stage 上被驗過的那一份逐位元相同**。 + 一旦重跑一次 build 才推 prod,**stage 驗過的東西就不是 prod 上的東西**, + ⑤ 那一關的意義當場歸零。 +⇒ 驗收方式:提升後比對兩邊的**指紋/sha**,不同就是做錯了。 + 這正是 leo 的比喻「**就像把同樣程式碼打到不同分支一樣**」——搬,不是再做一次。 + +🔴 **stage 永遠在 youlin,這是固定的**(leo 2026-08-08 原話: +「**5 是我去 youlin stage 環境看你的修復版是否真的修復,就可以發佈,發佈以後, +我可以在任意地方試 prod,但 stage 一定在 youlin。**」) +⇒ **核實點固定在 stage**,不是「找另一台乾淨的機器來驗」。 + prod 發佈後在哪裡試都行,那是**發佈後的抽查**,不是發佈前的閘。 + +**兩把鑰匙都在 leo 手上(⑤⑥),AI 造不出來,這是刻意的。** + +### CF 官方機制(查證出處,不是憑記憶) + +`https://developers.cloudflare.com/workers/wrangler/environments/` + +- 用 `[env.NAME]` 宣告環境;部署 `npx wrangler deploy --env NAME` +- **Worker 名字自動變成 `<top-level-name>-<env>`**(例:`arcrun-cypher-executor-stage`) +- 🔴 官方原文:**「Non-inheritable keys are configurable at the top-level, but cannot be + inherited by environments and must be specified for each environment.」** + ⇒ **KV/D1/vars 一律不繼承,每個環境必須各自宣告**——這正是環境真正分離的地方, + 也是「漏宣告就會共用到 prod 資源」的風險點。 + +### 🔴 為什麼要把流程寫死(08-08 一整天的代價) + +那天出貨是**用手拼的**:手動 cp 產物 → 手動改 manifest → 手動 sed 換兩處釘子 → 手動 deploy。 +後果四連發,全部有實據: +- 改了 portal 文案,**bundle 版本沒動** ⇒ 改動永遠送不出去 +- 版本沒動 ⇒ 安裝器判「同版整批跳過」⇒ **重裝也修不好**(leo 白重裝一次) +- 兩台都宣稱 `1.4.22` **程式碼卻不同**(一台有診斷端點、一台 404) +- 為了測 stage 而手動部署,**繞過安裝器注入** ⇒ leo 的 portal 畫面壞、登入斷 + ⇒ **他晚上要驗收時,發現要驗的東西被驗收流程本身弄壞了** + +⇒ leo:「**每次做一樣的事,結果會打錯實例⋯⋯寫對的應該每次都機械式的做同一件事, +寫錯位置也會被它修正。**」**這條流程就是那個「機械式」的定義,不准再自創。** + +## 🚦 第 0 步:**先上 stage,不准直達 prod**(leo 2026-08-08 立,封測期起) + +> leo:「現在因為**開始封測**,不直接打到 prod,而是先打到昨天建的 stage⋯⋯ +> 因為**推 prod 就發佈了**,雖然現在人不多,但**要謹慎**。」 + +🔴 **這一段是補進來的,因為本 skill 原本從頭到尾寫的是 prod** +(github-arm → GitHub `arcrun-rag-bundles` → jsDelivr/raw 驗證), +**沒有任何一步是「先上 stage 驗過再上 prod」**;而 D20 那道閘擋的是「寫 GitHub」, +不是「未經 stage 就發佈」⇒ 光「知道有 stage」對行為零作用。 + +順序(**不准跳**): + +1. 打 **staging** bundle → 推 Gitea `arcrun-rag-bundles-staging` + (不碰 GitHub,**無 D20 閘**,不需要 leo) +2. 用 staging 安裝器實裝到測試實例 + `https://arcrun-rag-installer-staging.uncle6-me.workers.dev` +3. 走一次**封測者真的會走的那條路**,貼實測輸出(不是 HTTP 200) +4. 過了才做下面的 prod 步驟 + +⚠️ **身分**:leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」 +⇒ 部署前先 `acr whoami`。 + +⚠️ **stage 與 prod 內容範圍不一樣**(daemon 安裝檔、README、core 顆數) +⇒ 見下面「別再整包蓋」那段,**不要拿 staging manifest 整包覆蓋 prod**。 + +📌 stage 判準(leo 08-07):stage **不需要 custom domain**,`*.workers.dev` 就好—— +「僅預設網域不准標 ✅」的目的是「用戶拿不到=沒交付」,而 stage 的用戶就是 leo 與總管。 +不掛 custom domain 反而是優點(沒人會誤入、不被索引、不會被當正式網址傳出去)。 + +🔧 機械閘:`.claude/hooks/stage-before-prod-guard.sh` +(6 小時內沒 stage 驗證紀錄 ⇒ 擋掉 prod bundle repo/github-arm/publish-github)。 + +📖 stage 救過一次的實例:見頂層 `system-dev/wiki/status.md` +「同一次測試照出一個會炸掉所有用戶的回歸(stage 第一次真的救了我們)」。 + +## vX.Y.Z(YYYY-MM-DD) + +**建議更新**——一句話說「這版解決你什麼問題」。 + +- 🔴 **最重要那項**:用戶語言描述症狀與結果 +- 其他項… +``` + +判準:**用戶讀得懂「這對我有什麼差別」**。 +不要寫「修 t195 的 401」,要寫「修好『一個檔失敗就卡住整個資料夾』」。 + +### 5. 出貨(**需 leo 開 D20 閘**) + +```bash +# leo 親跑(AI 不得代跑) +scripts/github-arm.sh "出貨 <說明>" 30 +``` + +開閘後: + +```bash +cd products/arcrun-rag +node installer/scripts/ship.mjs --bundles <bundles repo> # 先 dry-run 看待辦 +node installer/scripts/ship.mjs --bundles <bundles repo> --confirm # 真出貨 +``` + +`ship.mjs` 會依序做:推 bundle → 換安裝器釘子 → 部署安裝器/landing → **purge jsDelivr**。 + +⚠️ **purge 一次可能不夠**,要驗到收斂(腳本自己會重試,別提前宣稱完成)。 + +### 5.5 說明文件站(**2026-08-08 補:它不在任何腳本裡,最容易整批過期**) + +🔴 **`ship.mjs` 完全不管 docs**(`grep docs` = 0 命中)。docs 是獨立的 `arcrun-docs` worker, +沒人手動部署它就會**一直停在上一次**——leo 2026-08-08 抓到:Windows 安裝說明還寫著 +「解壓縮 zip、裡面有兩個 exe」和「MSIX 要開開發人員模式」,而**我們早就只出單一 `.exe`**。 + +```bash +cd docs-site +npm run build # → dist/ +rm -rf deploy && mkdir -p deploy/docs && cp -R dist/. deploy/docs/ # ⚠️ 見下 +npx wrangler deploy +``` + +⚠️ **`dist → deploy/docs` 這一步沒有腳本,只活在人的記憶裡**(08-08 實撞): +`wrangler.toml` 的 `[assets] directory = "./deploy"`,而 Astro 建到 `dist`。 +**漏了它,`wrangler deploy` 會「成功」但只上傳 0.35 KiB(等於什麼都沒換)。** + +**複驗要抓畫面內容,不是看部署訊息**: +```bash +curl -s "https://rag.arcrun.dev/docs/start/install-windows/?cb=$RANDOM" \ + | grep -oE "<這次該出現的字串>" +``` + +📌 **判準:凡是改動會讓說明文件過期的出貨,docs 就是出貨的一部分。** +版本、安裝方式、畫面長相變了 ⇒ 一起改、一起部署、一起複驗。 + +### 5.8 🔴 驗前端=**用瀏覽器真的載一次**,`curl | grep` 不算(leo 2026-08-08 立) + +> leo 原話:「**你的環境有 web,你應該用 web 驗,你已經開啓了卻沒有完成,你要把這個列入規定。**」 + +**為什麼 `curl | grep` 是假驗證**(08-08 實撞,leo 抓到而不是我發現): +`curl` 拿到的是 **HTML 原始碼**——它**不執行 JS、不載入 `config.js`、不發 API 請求**。 +所以我 grep 到文案就宣稱「前端驗過」,而使用者實際打開看到的是整條紅色錯誤: +「設定檔沒載入(config.js),這個頁面連不到你的服務」。**那個畫面我一次都沒看到。** + +⇒ **判準升級**: +`HTTP 200 不算驗過` → `grep 到字串也不算驗過` → **只有「瀏覽器載入後看起來能用」才算**。 + +``` +mcp__Claude_Browser__preview_start {url: "<用戶會走的網址>"} +mcp__Claude_Browser__computer {action: "screenshot"} ← 看畫面,不是看原始碼 +mcp__Claude_Browser__read_console_messages {onlyErrors: true} ← JS 有沒有炸 +``` + +**要看的是**:有沒有錯誤橫幅/該有的資料是不是還停在「載入中…」「查詢中…」/console 有沒有紅字。 + +⚠️ **順帶一個會騙人的坑(同日實撞)**:`curl` 加 `?cb=$RANDOM` **繞不掉 Cloudflare 邊緣快取**—— +`/config.js` 一直回舊的空值,直到加 `-H "Cache-Control: no-cache"` 才看到真值。 +⇒ 用 curl 查線上狀態時,**沒加 no-cache 就可能是在驗快取,不是在驗線上**。 + +### 6. 複驗:**從 leo 會看的那兩個地方** + +```bash +# ① 雲端線:portal 版本卡讀的是安裝器 /api/latest +curl -s https://install.arcrun.dev/api/latest +# → "release" 應等於 manifest.release + +# ② 桌面線:daemon「檢查更新」讀的是 jsDelivr(**不是 raw**) +curl -s "https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@main/manifest.json" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['daemon']['version'])" + +# ③ 用戶下載的固定檔名真的抓得到、且 sha 與 manifest 相符 +curl -sI "https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/ArcrunRAG-mac.dmg" +``` + +🔴 **`raw` 是新的不代表 `jsDelivr` 是新的**——daemon 讀 jsDelivr, +2026-08-05 實撞:push 完 raw 立刻新版、jsDelivr 仍吐 v0.15.7,purge 後第 2 次才收斂。 + +--- + +## 收工前自問(答不出來就是還沒做完) + +1. **leo 打開 portal,版本卡會顯示新號碼嗎?** 不會 → 雲端線沒出貨完 +2. **leo 按 daemon「檢查更新」,會看到新版嗎?** 不會 → 桌面線沒出貨完 +3. **新用戶現在安裝,拿到的是我改的那份嗎?** 不確定 → 回步驟 2 複驗 bundle 內容 +4. **changelog 有這一版嗎?** 沒有 → 用戶不知道你改了什麼 +5. 以上任一是「否」→ **不准說「完成」,要說「已改,未送達」** + +--- + +## 常見漏掉的(實撞記錄) + +| 漏掉的 | 後果 | 日期 | +|---|---|---| +| 沒重打 bundle | release 不 bump、新用戶拿舊版 | 08-05 | +| 沒 purge jsDelivr | 用戶按「檢查更新」像沒反應 | 08-02 | +| 沒改 `manifest.daemon` | 「檢查更新」回「manifest 沒有 daemon 版本欄位」全體失效 | 08-02 | +| 只推 bundle 沒換釘子 | 安裝器仍裝舊 bundle | 08-01 | +| 🔴 **釘子只改了常數,沒改 `[vars]`** | **部署成功但釘子沒換**——`api/latest` 與 install 頁全是舊版,白部署一次 | 08-07 | +| 用 `rsync --delete` 覆蓋 prod bundle repo | **砍掉 prod 才有的 `daemon/`(20 個安裝檔)與 `README.md`**,封測者下載不到 daemon | 08-07(推之前 `git status` 抓到) | +| 用 staging 的 manifest 整包蓋 prod | **core 從 5 顆變 24 顆**——prod 只裝 5 顆是懶載設計,會讓每個新用戶多裝 19 顆 worker | 08-07(同一個坑 `bbb433e` 有前科) | +| 用 `git worktree` 打 bundle | `0/4 bundled`——worktree 沒有 `.component-builds/` 的預編 wasm(gitignore 產物) | 08-07 | + +### 🔴 換釘子:真身是 `[vars]`,不是常數(08-07 實撞,白部署一次) + +```js +// installer/oauth-prototype/worker.js:74 +bundleBase(env) = env.BUNDLE_BASE ? env.BUNDLE_BASE : DEFAULT_BUNDLE_BASE +``` +⇒ **`wrangler.toml` 的 `[vars] BUNDLE_BASE` 優先於 `worker.js` 的常數。** + +- **兩處都要改**:`wrangler.toml` 的 `[vars] BUNDLE_BASE`+`BUNDLE_BUILT`、 + 以及 `worker.js` 的 `DEFAULT_BUNDLE_BASE` +- **為什麼會漏**:08-07 把「指向」從常數搬進設定(為了 stage/prod 用同一套機制), + **但記載該機制的文件沒跟著改** ⇒ 照舊文件做 = 改了一半 = 等於沒改 +- 🔑 **可推廣**:**改了機制,就要改記載那個機制的文件**—— + 否則下一個人(包括你自己)照過期文件做,會得到「做完了但沒生效」 + +### ✅ 覆蓋 prod bundle repo 的正確動作(08-07 定,別再整包蓋) + +```bash +git reset --hard HEAD && git clean -fd # 先回到 prod 原狀 +cp -R <staging>/<worker>/. <worker>/ # 逐顆覆蓋,不用 rsync --delete +# manifest:只換 prod core 清單裡「原本就有」的那幾顆 + release/built/source +git add -A +git diff --cached --diff-filter=D --name-only # ← 護欄:刪除檔案數必須是 0 +``` +**判準:staging 與 prod 的內容範圍不一樣(daemon 安裝檔、README、core 顆數),不能整包蓋。** +| changelog 沒寫 | 用戶不知道能不能/要不要更新 | 08-05(停在 v0.15.7,v0.18.x 全空) | +| portal 下載連結沒跟著換 | DMG 打好了但按鈕還給 zip | 08-05 |