ISEP 0.1.0:環境設定收成一個 plugin,本機與雲端共用一份

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:41:46 +08:00
commit c2638668e3
85 changed files with 11879 additions and 0 deletions
+15
View File
@@ -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 —— 機械閘/commandskill/腳本的唯一真相源",
"author": { "name": "Leo" },
"category": "productivity",
"source": "./"
}
]
}
+6
View File
@@ -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"]
}
+48
View File
@@ -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` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 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}`
+306
View File
@@ -0,0 +1,306 @@
---
name: cp-write
description: |
要寫或改任何 CPCritical Path)檔案之前必讀——動 system-dev/docs/3-specs/critical-paths/
底下任何檔案、標記某關卡 ✅/◐/❌、或新增一條 CP 時自動載入。
leo 2026-07-30:「上次已經跟你討論一次並且寫了正確範本,立刻全部忘光了」。
三條鐵律:寫目的不寫功能/每步交出 deliverable/code 寫完沒部署不准標 ✅。
另含 Logseq outliner 格式(給 leo 讀的 md 一律巢狀 bullet、禁表格平攤)。
---
# /cp-write — 寫或改一條 CPCritical 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/unknownfound 附 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 指向 issuesCP 的 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 那邊才是對的;總管花一整輪在對帳)
- **定址:指到「一張票」,不指票裡的某一行**(D58leo 2026-08-10
> leo:「**充分利用 gitea 的機制,不要硬做個不支援的機制,容易出錯。**」
Gitea 原生就會把 issue 引用自動變成可點連結 ⇒ **CP 不必手貼網址、不必造錨點。**
```markdown
- `w=9` Leo/arcrun-rag#52 — 重裝到已有資料的帳號,登入與資料庫一起壞
```
- 🔴 **最好用的判準**:**需要指到票裡的某一行 = 那張票該拆了。**
這個限制反而**強迫出正確的顆粒度**
- **票的刀口不是「大小」,是「狀態」**:問「它會不會需要跟隔壁那條不同的狀態?」
會 → 獨立成執行票;不會 → 留在該票裡當 checkbox
- **討論票 vs 執行票**:討論票是脈絡(不當工單追),執行票才是 CP 該指的東西
- ⚠️ **已作廢:隱形錨點 `<a id="tNNN">`**(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/<repo>/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**(鐵律 2leo 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. 子項是**從池子撈的**(有「出處」欄)?撈不到的標了 ` 待加 <SDD>`
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/`
+346
View File
@@ -0,0 +1,346 @@
---
description: Gitea issuemilestone/看板的實際操作流程——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 idint**,傳名字會 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 cookieCSRF
- 理由不只是升級風險:**那是別人家 app 的內部 schema/未公開契約**,
用了等於在系統裡多一條沒人維護的隱性契約——與「兩份真相」同病
## 兩個會咬人的坑
- **`labels` 在兩支 API 是兩種型別**
- 建 issue `POST /issues` → 吃 **idint**,傳名字會 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}` 或逐一傳參
## 🔴 怎麼寫一張 issueleo 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`)一律降到細節區。
- **③ 一句話講完「誰+卡在哪」**,看標題就知道要不要點進去。
### 內文:問題 → 解法 → 細節(細節收進 `<details>`
```markdown
## 問題
<用戶視角一句話:他想做什麼、卡在哪、結果怎樣>
## 解法
<一句話:要做出什麼>
## 怎麼驗
<走用戶真的會走的那條路;貼實際畫面,HTTP 200 不算>
---
<details><summary>細節</summary>
現況實查(指令+輸出)/真身在哪個 repo 哪個檔/可照抄的既有寫法/
leo 原話/不屬於本 issue 的(已拆到 #N)
</details>
```
**為什麼細節要收起來**: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 打開就看不下去**
### ⚠️ 已作廢:隱形錨點 `<a id="tNNN">`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=prodyoulinstage)。
- **票上該有的三件**:**它現在怎麼運作/為什麼這樣設計/動它會影響誰**——不只是「要做什麼」。
- **順序**:先查票 → 查不到才翻源碼 → **翻完回頭補進票**(與 wiki 同一階梯)。
- **判準一句話**:**如果我剛剛翻了源碼才知道答案,那就是一條該補進票的設計記錄。**
## 界線(沿用,未變)
- **跨 repo 的 issuecomment 一律開頭署名** `[<本 repo> CC]``[InkStoneCo 總管]`
- 所有 repo 共用同一個帳號,author 看不出是誰發的,身份只能在內容層自報
- **發 issue 給別的 repo 要先問人**;對自己 repo 開 issue 記待辦則直接做
- 🔴 **有事才讀,禁止自動輪詢**——禁 Actionscronwebhook 因 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 000DNS 不解析)**。正確的是 `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 想測,但那張測試卡被刪掉了 ⇒ **無證據,不編**
若答案是「會」,拖拉就天然被機器看見,這條縫自動消失。
+65
View File
@@ -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**
```
✅ 找到 SDDdocs/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
+69
View File
@@ -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 檔案。
### 第四步:確認
告知存到哪些檔案,共幾條記錄。
+230
View File
@@ -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 vaultraw source = `pages/` + `journals/`
- 根目錄有 `.obsidian/` → Obsidian vaultraw 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 行內標記**:檔案要編入但某段不要 → 遇到 `<!-- wiki:ignore -->` … `<!-- wiki:end -->` 之間的內容**略過**,只留「(此處機敏,已略過)」
> - **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 筆記、決策、概念…
└── <bucket>/ ← 儲存桶(分類由 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/<bucket>/` 一律先讀它,不必猜。檔內 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 pullwiki 是給 AI 看的)
整理任何內容前,先判斷它該 **push****pull**——判準是「**CC 做事時會不會被動看見**」:
- **push**CC 行動前必須主動出現在 contextsession 開始就由 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 | 寫成 cardsCC 面對時自然會查,INDEX 提供角度入口 |
**principles 維護規則**:一行一條精煉準繩(如「不污染用戶根目錄」「目標用戶 low-code」「wiki 主要給 AI 看」)。發現新的跨全局原則 → append 一行;超過 ~15 條代表某些該合併或下放成 card。**累積原則只改 principles.md,不必問用戶開新檔。**
### 第四步:訪談(每次一個問題)
依序問:
1. 這個專案做什麼?(一句話)
2. 有哪些絕對不能違反的限制?(技術棧、架構原則等)
3. 現在進行到哪個階段?
4. 有沒有 CC 曾經犯過的錯要先記下來?
把答案填進 `CLAUDE.md`(如果存在)或建立新的。
### 第五步:改寫成 wiki(AI 當總編輯)
(第二步確認後執行)
**不搬動原文**。逐份讀 raw source,改寫萃取成 `cards/<bucket>/` 裡的自包含原子卡:
- **概念原子化**:一張卡講一個概念,不是一篇原文對一張卡。原文太雜就拆,多份相關原文就合。
- **自包含**:讀卡就懂,不必回去翻原文。把口語、重複、流水帳改寫成結構化知識,**不寫「詳見原文」**。
- **保留來源指針**:每卡標 `**來源**:原文相對路徑`,為可追溯,不是要使用者回去讀。
- **frontmatter 標籤分類**(見下方):分類走 frontmatter `tags:`,不靠資料夾、不靠行內 `#tag`
- **互相連結(typed-edge 三元組)**`## 關聯` 不只列裸 `[[頁面]]`,改寫成帶語義的三元組(見下方)。
- **萃 glossnode 一句說明)**frontmatter 放 `gloss:` —— 這張卡(= 一個 entity / graph node)的一句話定義,供下游語義 normalize(見下方)。
卡片格式(每張卡):
```markdown
---
tags: [知識管理, AI協作, 方法論]
gloss: 一句話定義這個概念是什麼(給下游語義 normalize 用,選填、deep tier 才產)
---
# 概念全名
← [[<bucket>/00-INDEX]]
**來源**`[raw source 相對路徑]`
**最後更新**YYYY-MM-DD
## 摘要
[一句話核心]
## 重點
- [自包含改寫的要點,不依賴原文]
## 實體
> 本卡內文的關鍵實體(也是 graph node)。名+描述供下游 embedding normalize。集中放、一行一個、不縮排、不重複。
- **原子筆記**atomic note/卡片原子化)— 每張卡只承載一個不可再分論點的知識記錄單元。
- **傳統筆記**(大鍋炒筆記)— 把多主題混雜在同一篇、難精確引用的記錄方式。
## 關聯
### 內文知識關係(內文實體間;端點=上方 `## 實體` 正規名,一字不差)
- 原子筆記 >> 對立於 >> 傳統筆記
### 卡片關係(卡對卡)
- [[本卡]] >> 謂詞(動詞短語) >> [[他卡]]
```
**麵包屑用帶路徑 wikilink**issue #7):H1 次行放 `← [[<bucket>/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/<bucket>/`,明寫「絕不寫入 pages/journals/docs 原稿」;事後用上面的 `git status` 驗。
> 2. **檔名 = 卡片全名**,否則 `[[全名]]` 對不到檔。冒號用全形「:」、斜線用全形「/」,**全程一種字元**,避免 `/`、`∕`、`:` 混用斷鏈。
> 3. **量大用 Haiku 並行改寫**,主模型只切概念邊界+審稿+修跨資料夾斷鏈。
告知:
```
✅ wiki-init 完成
建立了:[列出新建的目錄和檔案]
跳過了:[列出已有因此不動的]
改寫了:[N 份原文 → M 張原子卡、K 條 typed-edge、M 條 glossdeep tier]
原文驗證:pages/ journals/ git status 0 異動 ✅
下一步:用 /wiki-capture 把重要決策存進 wiki
```
+58
View File
@@ -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,再動手。
+50
View File
@@ -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 從哪裡開始。
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# arcrun-intent-guard.sh — 意圖工作流「回饋 hook」(D382026-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"
+85
View File
@@ -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
+82
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# component-guard.sh — 零件/service-binding 建立保險(D27/D282026-07-06
# 背景:AI 一再自建 domain 零件(km_wiki_card_parse)、錯用 service bindingdrainer 修 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 workflowcypher 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
+116
View File
@@ -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 的 PreToolUsematcher: 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:247WASM 執行前才解析)。"
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
+135
View File
@@ -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/5v2 覆蓋前為 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= / <title>)② 壞消息碼(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
+128
View File
@@ -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
+203
View File
@@ -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**:這個回合直接呼叫 AgentTask 派出去。
沒有票 → 先 \`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,不是 Leo2026-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
+144
View File
@@ -0,0 +1,144 @@
#!/bin/bash
# github-contact-guard.sh — GitHub 接觸保險(D202026-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-09arcrun-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 寫入動詞指向 githubpush / 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 寫入動詞指向 githubpush / remote add 為 push 鋪路)—— 寫入,擋
elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\.com)'; then
HIT="git 寫入 → githubpush/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 → githubpush 的目標字面就是 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 → githubremote「$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
+93
View File
@@ -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_pathWrite/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=1harness 內建 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" # 產品組裝 repo2026-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
+153
View File
@@ -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 wikitasks
# 有歷史紀錄就把命中摘要推到眼前並擋一次(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.shPostToolUsematcher 對 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
+314
View File
@@ -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"
}
]
}
]
}
}
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# irreversible-dispatch-guard.sh — PreToolUse(Agent|Task):擋「把不可逆動作寫成選項」的派工單
#
# 🔴 事故(Gitea Leo/arcrun-rag#332026-08-09):subagent 未經 leo 同意刪掉兩條遠端分支。
# 根因不是它亂來——是總管的派工單寫了「作廢就刪掉分支」,等於預先授權了一個不可逆動作。
# 它刪掉的那條裡,還有一件它自己標明「等 leo 排序」的工作,一併蒸發。
#
# 【對照組,同一天同一個總管】#14 的派工單寫
# 「🔴 刪資料不可逆。動手前先把清單寫在 issue 留言,等總管回覆確認才執行」
# ⇒ 那個 agent 真的停下來等。同一個人一次寫對一次寫錯 ⇒ 靠自律不行,要機械閘。
#
# 判準(細節與正則見 irreversible_dispatch_check.py):
# 派工單裡出現「不可逆動作」的動詞+對象(刪分支/drop tablerm -rfforce 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#332026-08-09)】subagent 未經 leo 同意刪掉兩條遠端分支,
根因是派工單寫了「作廢就刪掉分支」——判斷可以,動手不行,這份單子卻兩個都給了。
其中一條被刪的分支裡,還有一件它自己標明「等 leo 排序」的工作,一併蒸發。
【對照組,同一天同一個總管】#14 寫「🔴 刪資料不可逆。動手前先把清單寫在 issue 留言,
等總管回覆確認才執行」⇒ 那個 agent 真的停下來等。同一個人一次寫對一次寫錯,
證明這件事不能只靠自律,要靠這道閘。
【命中的不可逆動作】
$HITS
【怎麼改】判斷可以,動手不行——把授權句改成「列清單、標明理由、停在這裡等回覆」:
❌「作廢就刪掉分支」
✅「盤點出作廢的分支,列成清單留言在 issue,動手前等總管回覆確認才執行」
真有必要略過(極少數情況):在命中那一行尾巴加 irreversible-ok 留痕,並在派工單裡寫清楚為什麼。
改完重送即放行。
EOF
exit 2
+63
View File
@@ -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
+225
View File
@@ -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 是誰**
# ——三個 bindingCREDENTIALS_DBANALYTICS_DBkbdb 的 DB)全指向同一顆 `arcrun-kbdb`。
# ② **subagent 照著做,而且更進一步直接 `ANALYTICS_DB.prepare()` 下原生 SQL**。
# 它沒建表(對),但繞過 API 牆讀寫 KBDB 的 D1,同樣違規。
# ⇒ 靠「我記得有這條鐵律」會失守,因為**想不到要載入它的時候,它就不存在**。
# 這個 hook 不看你記不記得,只看你寫了什麼。
#
# 攔三種違規:
# A. 對 KBDB 下 DDLCREATE/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-guardBash):偵測到繞過 API 牆、直接對那顆資料庫執行 SQL 的命令。
🔴 D38leo 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**D91seed 一列 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 總管親手違反過):
【D38leo 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**
D91D93seed 一列 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. 偵測到 DDLCREATE/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。**注意別把概念名當表名**——
BlockTemplateSlot 是「三層概念」;entries 是萬用主表,靠 entry_type
區分 blocktemplateslotprojectworkflowrecipe_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),而這個檔在「牆外」
🔴 **D38leo 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_DBANALYTICS_DBkbdb 的 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/資料表/migrationschema),
而鐵律掛在**領域的詞**(KBDB)上 ⇒ 詞對不上,skill 就不會被載入。
⇒ **碰到「D1」時的第一個動作,是先問「這顆 D1 是誰的」**,
不是先想「要建什麼表」。三個 binding 全指向 arcrun-kbdb。
【真的有例外】該行尾加 \`kbdb-sql-ok\`(留痕),並在 commit 訊息說明理由。
EOF
exit 2
+20
View File
@@ -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
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# leo21c-write-guard.sh — 不准寫 leo 的個人帳號(leo 2026-08-20 立)
#
# leo 原話:
# 「youlin = stagegeek6688 = 測試 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 的三個座標(帳號 idnamespaceworker 網域)
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 的個人帳號 leo21cleo 2026-08-20 立)
leo 原話:「**leo21c 就是我這個普通用戶,不應該讓你去操控,我只用公開的更新。**」
youlin ← 🟢 你的 stage:做實驗、跑驗證,**沒指定就用這個**
geek6688 ← 測試 prod 出貨機
uncle6 ← 中心服務(安裝器/文件站/bundle),出貨線的目的地
leo21c ← 🔴 leo 本人在用的知識庫。**讀可以,寫不行。**
改法:把目標明寫成 youlin,不要吃 `~/.arcrun/config.yaml` 的預設——
那個檔至今仍指著 leo21cKV 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
+312
View File
@@ -0,0 +1,312 @@
#!/bin/sh
# main-and-prod-push-guard.sh — PreToolUseBash):**兩層手動確認閘**
#
# 🔴 立這道閘的來由(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-firstKBDB-first/派工-firststage-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"*)
# 只擋打到 mainmaster 的;推自己的 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` 指過去後的第 1719 站**
# ⇒ 規則早就存在,只是這道閘不知道。本段就是把它教給機器。
#
# 為什麼該放行: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`,而且命令裡沒有任何 stagestaging 字樣 ⇒ 視同**打 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
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
# micromanage-guard.sh — PreToolUseAgent):**擋微管理的派工單**。
#
# 🔴 為什麼要有這道(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 區塊
alivecrash_loopingheadline/秒數),**比我心裡想的「加一個時間戳」好**。
⇒ **微管理會把對方的判斷力關掉**,而你不是那個 repo 的專家,它才是。
偵測到的訊號:
指令要它照跑=$n1 指名檔案/函式=$n2 編號步驟=$n3 過程控制=$n4 行數=$lines
改法(不是刪短,是**換層次**):
· 把「怎麼做」整段刪掉——它會自己查 wiki、查 skill、讀 code
· 保留並寫清楚:**這件事成功長什麼樣**、**怎麼驗**、**不准碰什麼**
· 已知的坑可以留,但寫成「這裡有前科」而不是「你要下這個指令」
· 不要規定它的回報節奏——要成果,不要進度條
改完重送即放行(同一張單只擋一次)。
EOF
exit 2
+108
View File
@@ -0,0 +1,108 @@
#!/bin/sh
# mistake-needs-ticket-guard.sh — PreToolUseWrite|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
+124
View File
@@ -0,0 +1,124 @@
#!/bin/sh
# no-ticket-no-dispatch.sh — PreToolUseAgent|Task):**無票不發包。**
#
# 🔴 為什麼是硬擋而不是提醒(leo 2026-08-16):
# 「**提醒可以忽略,deny 不能**——被 deny 的動作沒有發生,不存在「忽略」這個選項。
# 所以問題化簡為:找出『起任務』在物理上必經的瓶頸,把閘設在那裡。」
# 「因為我發現**你不會去更新票**,所以定義 tags 完全是裝飾⋯⋯
# 唯一辦法是,你不准寫 code,要 subagent 寫**一定要有票號**,它自己去拿任務,
# 回覆在票裡⋯⋯這樣你就會去更新狀態,否則永遠會『我忘了』,
# **我就無法知道你做到哪裡**。」
#
# 瓶頸就是這個工具呼叫:總管要讓 subagent 動起來,唯一的機制是它,繞不過去。
# ⇒ 「先開票」不是 SOP 第一條,是**發包動作的前置條件**,
# 跟「不插鑰匙車不會發動」同一性質。
#
# 🔴 同日實錯(本閘的來由):總管說了三次「下一步要查 X」都沒有派人;
# 真的派人時又**沒搜就開新票**arcrun-rag#110),而那條線早有 hubInkStoneCo#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
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# not-my-branch-guard.sh — PreToolUseBash):**不准 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 動作(statuslogdiffcheckout…)
#
# 真的要在那條分支上 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
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# pending-changes-retired.sh — `pending-changes.md` 已廢除,寫它一律擋下(leo 2026-08-19
#
# leo 原話:
# 「那種形式根本難以回覆,每個都長篇大論也不易閱讀,**而且寫入後不會提醒**,
# 如果要寫入 pending changes,一律改用票,這個檔案廢除,
# 可以改成一張 index 指向票號及連結。」
#
# 🔴 為什麼要有這道閘,而不是只改文件:
# 規則躺在 CLAUDE.mdSDD-LIFECYCLE 裡,已經被證明不夠——
# wiki mistakes 有一整條「以為『規範存在』=『AI 會照做』」。
# 而這個檔的特性讓違規特別難發現:**寫進去不會通知任何人**,
# 所以「寫了沒人擋」與「寫了沒人看」會同時成立,債靜靜地長。
#
# 擋什麼:WriteEditMultiEdit 指向任何 repo 的 `3-specs/pending-changes.md`。
# 不擋什麼:archive/ 底下的存底(那是唯讀歷史,路徑不同)、以及讀取。
#
# 誠實界線(兩條,都要留著):
# ① 它擋的是**這個路徑**。有人另立一個叫 `pending-changes-v2.md` 的檔照樣繞得過——
# 那屬於語意層,這道閘不假裝擋得到。
# ② 它掛在 WriteEditMultiEdit 上,**用 bashheredocsedcat >)寫檔不會經過它**。
# 這不是疏漏是取捨: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
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# PreToolUse hook 範本骨架 —— 專案自訂禁令
# wishlist §2 可選:讓使用者自訂專案禁令(例:「KBDB 禁動表」「某目錄唯讀」)。
#
# 預設不啟用。要用時:
# 1. 在下面 FORBIDDEN_PATTERNS 填入禁改的路徑/檔名 pattern
# 2. 到 .claude/settings.json 的 PreToolUse 加掛這支
#
# 掛在 PreToolUsematcher: 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
+64
View File
@@ -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 加掛這支
#
# 掛在 PreToolUsematcher: 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
+264
View File
@@ -0,0 +1,264 @@
#!/bin/sh
# prod-write-guard.sh — PreToolUseBash 會寫到線上實例的 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-closedpython 掛掉不准變成靜默放行 ⇒ 退回舊的整串比對,寧可誤攔
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
# stagestaging 一律自由——擋的是 prod,不是所有部署
#
# 🔴 2026-08-12 補:`youlin`youlin-hsieh-dev**就是測試場**,不是 prod。
# arcrun-rag CLAUDE.md 白紙黑字:「要看範例或做任何測試 → **只在 youlinD37 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.devarcrun.dev)+**帶寫入語意**才擋。
# 刻意不擋的:讀(沒有 -X 寫入動詞也沒有 body)、打 Giteagit.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
+169
View File
@@ -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 的 PreToolUsematcher: 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.mdstatus: 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 / closedclosed 且被取代者填 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
+176
View File
@@ -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 成熟度門檻只登記觀察。
# ⇒ 本 hookcommand 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.mdjourneys.md/派工表——下一站就是答案
C. decisions / ADR / pending-changes 已裁決區——這個做法定案過嗎?
D. KBDB 語意搜尋——換句話說的同一題(grep 猜不中用詞時的保險)
📌 真要升級到 leo,格式是:「<檔案:行> 記錄 X,我判斷走 A 因為 Y,回『好』我就繼續」
——裸問句、空白選擇題=視同沒查。
EOF
exit 2
;;
esac
exit 0
+165
View File
@@ -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. **wikitasks.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:promptHaiku 判語意),settings.json 寫 {"type":"prompt","prompt":"判斷這回合是否在推卸/未查證就停…"}。
# 擋「換句話說的推卸」,但每次停叫一次 Haiku(便宜)。
# L3:改 type:agentspawn subagent 真查 wiki/SDD/派工表客觀驗「規劃是否做完」(像 Ralph 的 passes)。最強最貴。
+152
View File
@@ -0,0 +1,152 @@
#!/bin/bash
# SessionStart hook — 開 session 就把「全局」推到眼前(InkStoneCo#17 / D69 / D70 / D71
#
# 掛在 settings.json 的 SessionStartmatcher: 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 " GiteaGET https://git.uncle6.me/api/v1/repos/Leo/<repo>/issues?state=open"
echo " (五個 repoInkStoneCo / 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/5principles(全文,行動前必服從)────────────────────────────────
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/5mistakes(只推最近 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
+110
View File
@@ -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))
# Cseed 一張宣告了欄位的 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)
'
+52
View File
@@ -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
+193
View File
@@ -0,0 +1,193 @@
#!/bin/sh
# stage-before-prod-guard.sh — PreToolUseBash):**未經 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 的 --dataprod-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
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單
# SubagentStopleo 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
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
# subagent-first-guard.sh — PreToolUseWrite|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
+17
View File
@@ -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
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# subagent-wiki-guard.sh — PreToolUse(Task) hooksubagent 聽到「查」就自己先查 wiki
#
# 病根(2026-07-20):總管兩次派 agent 查 ENCRYPTION_KEYprompt 都只叫它「去查 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 原本只教「查 wikigrep」,
> 反而把它導向 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
+190
View File
@@ -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-installfeat/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
+176
View File
@@ -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
+182
View File
@@ -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 用 patternGlob/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:
# Bash2026-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
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# PreToolUse hook — 寫入 wiki 前掃機敏資訊(L3 硬攔截)
#
# 為什麼存在:wiki 的 ignore 規則(.wikiignore + 行內標記)是「協議層」,靠 CC 遵守。
# 但密碼/金鑰/個資外洩是「不可逆」後果——只靠口頭約束太危險。
# 這支 hook 是機械式底線:CC 真的把機敏資訊寫進 system-dev/wiki/ 的那一刻 → exit 2 擋下。
#
# 掛在 settings.json 的 PreToolUsematcher: 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 用 contentEdit 用 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
+56
View File
@@ -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
+70
View File
@@ -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 "① UIconsole-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 "② cyphercypher-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 其餘 tier2kbdb/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 "③ daemoncollector 產物 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
+85
View File
@@ -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 "✅ 無漂移"
+11
View File
@@ -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-armedcomponent-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,並留痕。用完自動失效。"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""daemon-selfcheck.py — 桌面小幫手(daemon)本機狀態自檢,唯讀。
為什麼有這支(leo 2026-08-19):
「現在本機的資料夾跟雲端已經不相連了。」
這句話有好幾種可能的形狀,而它們的處置完全不同:
① 帳號指的那台實例被重裝過(網址沒變、鑰匙換了) ← arcrun-rag#103
② 監看資料夾在本機被搬走/改名/刪掉
③ 資料夾是空的,雲端因此「什麼都沒有」 ← arcrun-rag#106
④ 真的連不上網路
這支不猜,只把**分得出這四種**的事實印出來。
🔴 唯讀:不寫任何檔、不連網、不印任何金鑰(api_keygemini_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]))
+128
View File
@@ -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 部署源必須落在 git07-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 未推上 Giteagit 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"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# B1 備援三件套之二:每日 git bundle 備份(de-Gitea brief B1
# 對本機所有 repogit 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
+211
View File
@@ -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 打不到/回應非 200curl_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
+145
View File
@@ -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_leoTelegram 轉告 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 \"出 prodxxx\"" >&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 掛 Humanleo 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"
+51
View File
@@ -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
+55
View File
@@ -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"
+80
View File
@@ -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 # 指定 repotoken 仍從當前 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/doneclosed 就是 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
+96
View File
@@ -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"
+472
View File
@@ -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 WikiCC 記憶系統 + 機敏防護)
--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"
# Coworkclaude.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 讀取順序(pushhook 開 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 變動前必須有對應 SDDsystem-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 issueCC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \
" GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)"
echo ""
+1
View File
@@ -0,0 +1 @@
__pycache__/
@@ -0,0 +1,6 @@
{
"account": {
"id": "58309bb90fd93ad6d0fe0aae99170e9d",
"name": "Uncle6.me@gmail.com's Account"
}
}
+349
View File
@@ -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 CLIleo 2026-08-15 指定)══════════════
# 「你可以叫它用 CLI 考試。」CLIMCPportal 是同一套 API 的三個薄殼
# `cli/src/commands/kbdb.ts` 檔頭:能力長在基本盤 API,CLI 只做介面轉換)。
# `acr kbdb` 的動作**剛好就是那六個按鈕**,多一個少一個都沒有。
#
# 🔴 **它打哪一台,由 cwd 決定**:解析順序是
# env > 資料夾層 `.arcrun.yaml`(就近往上找)> 全域 `~/.arcrun/config.yaml`
# 而**全域指的是 leo21cleo 的真庫,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()
+65
View File
@@ -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`
+153
View File
@@ -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()
+391
View File
@@ -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()
+28
View File
@@ -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])
+113
View File
@@ -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()
+217
View File
@@ -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()
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# kitesurf-mcp.sh — 把 Cloudflare Kitesurf 無頭瀏覽器掛成一個 MCP serverstdio)。
#
# 【為什麼需要這支包裝,不能直接把 leo 給的那段 JSON 貼進設定】
# leo 給的那段是 **CDPChrome 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_FILEleo21c 的 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" \
"$@"
+394
View File
@@ -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-dataCF 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 現況綁定存成 JSONrollback 的唯一依據)")
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()
+103
View File
@@ -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 一個常數,OWNERREPOAPPROVER_LOGIN 三個維持寫死、
# 不接受環境變數覆蓋,且 ISSUE 也只能透過腳本參數指定,不接受環境變數。
# ── 安全邊界常數(OWNERREPOAPPROVER_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)"
}
+174
View File
@@ -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")→…→會議紀錄)"
@@ -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#85embed 補算)、Leo/mira#6Mira 主線)。
**那三張修好之後這段要拿掉**,否則它會變成下一個「看起來像現況的舊東西」。
--- 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 " 所以 arcrunmiraarcrun-ragarcrun-harnessinkstoneco 等庫會顯示 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#85embed)|Leo/mira#6Mira 主線)"
+echo "────────────────────────────────────────────────"
+echo ""
+
# ── push 2/5:各 repo 的 wiki 全景(leo 2026-08-12「一次看到全景」的另一半)──
# 上面那段是**票**(雲端現算)+ KBDB 藏書地圖;這段是**各 repo 真正的 wiki**。
# 為什麼不塞進雲端那支工作流:工作流跑在 Cloudflare 上,要拿 repo 樹只有
+54
View File
@@ -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"
Executable
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""ticket — 讓 Gitea 變成「可追蹤的線」,不是「越積越大的池子」。
leo 2026-08-16 三句話,本工具就是它們的機械化:
「不要每個開新票,現有的票開在它下面的對話裡」
「我希望你把 gitea 變成可以追蹤,不是變成一個池子」
「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
當天實錯(本工具的來由):總管要派人查一個部署擋路石,**沒有搜尋就直接開新票**
arcrun-rag#110),而那條線早就有 hubInkStoneCo#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 Storyleo 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:])
+332
View File
@@ -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"
# Coworkclaude.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.md1.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 ""
+350
View File
@@ -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 真正的 wikisystem-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/fetchblobless + 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
+114
View File
@@ -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 從「每次重算」降級成「驗算與補洞」。在那之前,**每次都要跑這支**。
+595
View File
@@ -0,0 +1,595 @@
---
name: ship-check
description: |
改完任何會影響用戶的東西之後、說「做完了」之前必讀(改雲端 workerportaldaemon
workflowinstaller 都算)。也在下列時機自動載入:要打包 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 兩段都是這樣補進來的,日期都記著。)
---
## 什麼時候跑
**改完任何會影響用戶的東西之後**(雲端 workerportaldaemonworkflow),
在說「做完了」之前。不是收工才跑。
---
## 🚚 雲端線出貨=**一個指令**,不要照下面的步驟手工做(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` 一處**installerlandingportal 全是讀者
> ⇒ **「改了 code 但 release 沒 bump」= bundle 沒重打包**,不是版本機制壞了。
### 1. 判斷這次改動影響哪條線
```bash
git -C matrix/arcrun log --oneline -5 # 雲端 workercypher/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 # WindowsMac 上可交叉編譯)
```
**三支機械閘必須全過**(交貨前):
```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` **必寫** | 用戶自己的電腦 |
| **portalconsole-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 bundleuncle6)→ 裝進 youlin 的 stage 環境 → 自己測**。這一圈可以無限重複。
> **「uncle6 不碰」指的是 prod 那一側,不是 staging bundle**——別把自己的工作區也擋掉。
| 這個東西 | 住在誰的帳號 | AI 能不能動 |
|---|---|---|
| **staging bundle repo** | uncle6Gitea | ✅ **這是 AI 的工作區**,隨便改隨便推 |
| **youlin 的 stage 環境** | youlin | ✅ **AI 的測試場**,隨便裝、壞了自己修 |
| **prod bundle repo** | uncle6GitHub | 🔴 要 leo arm,且只准「**原封提升**」不准重打 |
| **prod 的安裝器/landingdocs 部署** | 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 repogithub-armpublish-github)。
📖 stage 救過一次的實例:見頂層 `system-dev/wiki/status.md`
「同一次測試照出一個會炸掉所有用戶的回歸(stage 第一次真的救了我們)」。
## vX.Y.ZYYYY-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.7purge 後第 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/` 的預編 wasmgitignore 產物) | 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.7v0.18.x 全空) |
| portal 下載連結沒跟著換 | DMG 打好了但按鈕還給 zip | 08-05 |