chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)

頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定,
Gitea private=除機敏值/build 產物/.github 外全 push。
解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。
機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-03 07:13:15 +08:00
parent c830150da1
commit 5d00e71275
190 changed files with 39486 additions and 14 deletions
+80
View File
@@ -0,0 +1,80 @@
---
description: 處理本 repo 的 GitHub issue(讀/回/結案),跨 repo 發要先問人
---
# /issue-handle — GitHub issue 處理指引
你(CC)可以、也該主動用內建的 `gh` CLI 讀寫**自己 repo** 的 GitHub issue。
很多人不知道這件事——`gh` 已內建認證,零開發、零外部依賴。issue 同源於 repo,
比 Notion / Sheets 更適合做交辦與待辦,不必引入外部 SaaS。
這份指引分四層,界線要守住。
---
## 1. 讀 / 回 / 結案(普世基本功 — 直接做,不用問)
對**自己這個 repo**,主動處理 open issue
```bash
gh issue list --state open # 看有哪些待辦
gh issue view <n> # 讀完整內容
# …實作…
gh issue comment <n> --body "[<本 repo> CC] 做了什麼、怎麼決定的、改了哪些檔"
gh issue close <n> # 確認解決後結案
```
回覆要有料:說清楚**做了什麼、為什麼這樣決定、動了哪些檔**,而不是只回「done」。
issue 作者(可能是另一個 repo 的 CC,或人類)要靠你的回覆判斷對不對。
跨 repo 的 issue/comment 開頭一律署名 `[<本 repo> CC]`(見第 3 節鐵律)。
---
## 2. 發 issue 給「別的 repo」(要先問人 — 不可擅自)
當你發現**別的 repo** 有值得修正的地方時:
> ❌ 不要擅自 `gh issue create -R other/repo …`
> ✅ 先問人類:「我發現 X repo 有 Y 問題,要我幫你去那邊發 issue 嗎?」得到同意才發。
理由:通用 template 不知道使用者對那個 repo 有沒有權限、想不想發。留一道人類確認最安全。
(對**自己 repo** 開 issue 記待辦則可直接做——那是自己的 repo。)
---
## 3. 跨 repo 署名(鐵律 — 絕不可漏)
所有 repomira / graph-plugin / ingest-plugin / Arcrun / template…)共用**同一個 GitHub 帳號**發 issue/comment
所以 issue/comment 的 author **全顯示同一個帳號、看不出是哪個 repo 的 CC 發的**
> **跨 repo 的 issue/comment 一律在開頭署名 `[<本 repo> CC]`**,靠內容署名溯源。
- 收件方 CC 回報:`[graph-plugin CC]` / `[mira CC]` / `[ingest CC]` / `[arcrun CC]`
- 總管下令/追問:`[InkStoneCo 總管]`
- 署名放 comment **第一行或標題式開頭**(既有的「## 回報(graph CC)」即合格)。
為什麼只能這樣:GitHub issue/comment 的 author = 發送帳號,**沒有 per-repo 身份這設定**
`git config user.name` 只影響 commit 作者、不影響 issue/comment author
給每個 repo 開獨立帳號 = 多帳號自動化 = 踩下方第 4 節 flag 鐵律,**不可**。
身份只能在**內容層自報**。本 repo 名稱 → 看 `git remote -v` 或 repo 根目錄名。
---
## 4. flag 安全界線(最重要 — 絕不可越)
**「有事才讀」,禁止自動輪詢。**
- ✅ 想看 issue → 當下主動 `gh issue list`
- 🚫 **禁止**掛 GitHub Actions / cron / webhook 去**自動輪詢** issue。
為什麼這條是硬底線:自動輪詢 + 事件 fan-out 正是會觸發 GitHub 異常偵測、
害你的 token 被 rate limit 砍掉的流量模式。template 給很多人用,這條界線必須守住,
保護使用者不踩雷。需要「定期檢查」就由人類主動跑這個指令,不要自動化。
---
## (可選)用 label 分來源
若想用同一套流程同時管「內部交辦」與「外部回報」,可建兩個 label:
`internal`(協作/交辦)、`user`(外部使用者回報),靠 label 分流。
這對通用 template 不預設——你的 repo 需要再建。
+55
View File
@@ -0,0 +1,55 @@
# /sdd-check — 確認當前任務有沒有對應 SDD
動手前執行。確保 CC 有全局觀,不會在沒有設計文件的情況下猛衝。
---
## 執行流程
### 第一步:理解任務
確認使用者要做什麼:
- 涉及哪個子系統?
- 是新功能還是修改現有功能?
- 影響範圍?
### 第二步:尋找對應 SDD
`system-dev/docs/3-specs/` 下尋找對應的子系統目錄,確認有沒有:
- `design.md`(設計文件)
- `tasks.md`(任務清單)
### 第三步:根據結果回應
**情況 A:找到對應 SDD**
```
✅ 找到 SDDsystem-dev/docs/3-specs/[子系統]/
📋 design.md[確認]
📋 tasks.md[確認,列出相關 task]
🎯 對應 task[編號和描述]
繼續嗎?
```
**情況 B:找不到 SDD,任務明確**
```
⚠️ 找不到對應 SDD
任務:[描述]
建議在 system-dev/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 從哪裡開始。
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# .claude/hooks/post-edit-remind-tasks.sh
# arcrun PostToolUse hook for Write / Edit / MultiEdit
#
# 職責:改完 code 後立刻提醒 CC 更新對應 tasks.md,避免批次更新
# 退出 code:不 block,只提醒(exit 0
set -o pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
# 只針對程式碼檔案提醒(不含 tasks.md / CLAUDE.md / rules/*.md 本身)
if [[ "$FILE_PATH" =~ \.(go|ts|tsx|js|jsx|py|rs|wasm)$ ]] && [[ "$FILE_PATH" != *"tasks.md"* ]]; then
cat >&2 <<EOF
📌 PostEdit 提醒(by arcrun hook
剛修改了:${FILE_PATH}
下一步動作:
1. 找到對應的 docs/3-specs/*/tasks.md
2. 如果這個 task 已完成 → 立刻把 [ ] 改成 [x](或把進行中的 [🔄] 改成 [x])
3. 如果發現新的 sub-task → 立刻加入 tasks.md
4. 不要等到 session 結束才批次更新
違反 SDD 協議會在 Stop hook 被提醒,建議現在就處理。
EOF
fi
exit 0
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# .claude/hooks/pre-bash-guard.sh
# arcrun PreToolUse guard for Bash
#
# 職責:擋下會違反 CLAUDE rules 的 shell 指令
# 退出 code
# 0 = 允許
# 2 = 擋下(stderr 訊息會回傳給 CC)
set -o pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
block() {
local rule="$1"
local reason="$2"
local fix="$3"
cat >&2 <<EOF
❌ BLOCKED by arcrun CLAUDE rules
違反項:${rule}
指令:${CMD}
原因:${reason}
正確做法:${fix}
參考:.claude/rules/02-forbidden.md
EOF
exit 2
}
# ─────────────────────────────────────────────────────────────────────────────
# 規則 1.2 / 3.3:禁止用 mkdir 建立違規的 auth/credential worker 目錄
# ─────────────────────────────────────────────────────────────────────────────
if echo "$CMD" | grep -qE "mkdir.*((auth|credential|jwt|oauth)[-_]worker|new[-_](auth|credential|jwt|oauth))"; then
block "1.2/3.3" \
"偵測到嘗試建立新的 auth/credential/jwt/oauth Worker 目錄" \
"auth primitive 放在 registry/components/auth_*/;不需要另建 worker 目錄"
fi
# 禁止建立同名零件的平行目錄
if echo "$CMD" | grep -qE "mkdir.*/(gmail|telegram|google[-_]sheets|line[-_]notify|http[-_]request)[-_](v2|v3|new|worker|backup)"; then
block "3.3" \
"禁止為既有零件建立平行目錄(v2/new/worker/backup" \
"直接改 registry/components/<n>/main.go"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 1.3:禁止 wrangler init / generate auth-* credential-* jwt-*
# ─────────────────────────────────────────────────────────────────────────────
if echo "$CMD" | grep -qE "wrangler[[:space:]]+(init|generate).*[[:space:]](auth|credential|jwt|oauth)[-_]"; then
block "1.3" \
"禁止用 wrangler init/generate 建立 auth/credential/jwt Worker" \
"auth primitive 透過 component-worker-template/ 搭配 WASM binary 部署,不要 wrangler init"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 3.1Service Binding 新增警示
# ─────────────────────────────────────────────────────────────────────────────
# 偵測在 wrangler.toml 新增 [[services]] 的 echo/cat/sed 操作(非 100% 準確,但夠用)
if echo "$CMD" | grep -qE "echo.*\[\[services\]\].*>>"; then
block "3.1" \
"偵測到要在 wrangler.toml 新增 [[services]] binding" \
"零件串接一律走 HTTP URLcypher binding),不新增 service binding。若有特殊需求,先與 richblack 確認"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 一般性危險指令
# ─────────────────────────────────────────────────────────────────────────────
if echo "$CMD" | grep -qE "rm[[:space:]]+-rf[[:space:]]+(/|/\*|~|\\\$HOME|\.)"; then
block "general" \
"偵測到危險的 rm -rf 指令" \
"明確指定要刪的目錄,不要對根目錄 / home / 當前目錄遞迴刪除"
fi
# 禁止 force push 到 main
if echo "$CMD" | grep -qE "git[[:space:]]+push.*--force.*(main|master)"; then
block "general" \
"禁止 force push 到 main/master" \
"用 feature branch,或和 richblack 確認後手動操作"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 資料外流警示(data-exfil-warning SDD R2):AI 動手把資料變成可被外部呼叫前先擋
# `acr push`(部署 webhook/ `acr recipe push`(定義資料去向)= 暴露面動作。
# 不含 `acr creds push`(上傳加密 credential 是保護,非暴露)。
#
# 信任修正(2026-05-30):無「旗標放行」捷徑——AI 自己能加的旗標 = 自己批准自己。
# 這類動作一律擋,必須由人類在終端機親自執行(CLI 會跳互動、要人類輸入資源名確認)。
# ─────────────────────────────────────────────────────────────────────────────
# 只在「指令本身就是執行 acr push / acr recipe push」時擋(錨定到指令開頭,
# 允許前置 cd .. && 或環境變數)。避免誤判 git commit -m "...acr push..." 這類
# 「字串裡剛好提到 acr push」的情況(commit message / echo / grep 不該被擋)。
if echo "$CMD" | grep -qE "(^|&&|;|\|)[[:space:]]*(cd[[:space:]][^&;|]*(&&|;)[[:space:]]*)?([A-Za-z_]+=[^[:space:]]*[[:space:]]+)*acr[[:space:]]+(recipe[[:space:]]+)?push\b" \
&& ! echo "$CMD" | grep -qE "acr[[:space:]]+creds[[:space:]]+push\b"; then
cat >&2 <<'EOF'
❌ BLOCKED:資料外流警示(arcrun data-exfil-warning
原因:acr push / acr recipe push 會把資料/workflow 變成「可被外部呼叫」(暴露面)。
這種動作你(AI)不能自行執行,也沒有旗標捷徑——需人類明示知情同意(法律憑證)。
正確做法:
- 把這件事交給人類:請人類在終端機親自執行(CLI 會跳互動、要人類輸入資源名確認)
- 人類第一次確認後 server 會記住,之後同資源不用再確認
參考:docs/3-specs/data-exfil-warning/
EOF
exit 2
fi
exit 0
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# Harness: Cold 啟動環境檢查
# 用途:init 完成後強制檢查環境就緒,不靠提醒,違反條件直接擋住(exit 2)
# 由 acr init 末尾調用
set -e
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
ARCRUN_CONFIG="${HOME}/.arcrun/config.yaml"
# 顏色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
FAILED=0
echo -e "${YELLOW}🔍 冷啟動環境檢查${NC}"
echo ""
# 檢查 1: config.yaml 存在
if [ ! -f "$ARCRUN_CONFIG" ]; then
echo -e "${RED}❌ 缺少 $ARCRUN_CONFIG${NC}"
echo " 執行:acr init"
FAILED=1
else
echo -e "${GREEN}✓ config.yaml 存在${NC}"
fi
# 檢查 2: api_key 有值
if grep -q "^api_key:" "$ARCRUN_CONFIG" 2>/dev/null; then
api_key=$(grep "^api_key:" "$ARCRUN_CONFIG" | cut -d' ' -f2)
if [ -z "$api_key" ] || [ "$api_key" = "null" ]; then
echo -e "${RED}❌ api_key 未設${NC}"
FAILED=1
else
echo -e "${GREEN}✓ api_key 已設${NC}"
fi
else
echo -e "${RED}❌ config.yaml 缺少 api_key${NC}"
FAILED=1
fi
# 檢查 3: .mcp.json 存在且指向自己(self-hosted 用)
if grep -q "mode.*self-hosted" "$ARCRUN_CONFIG" 2>/dev/null; then
if [ ! -f ".mcp.json" ]; then
echo -e "${RED}❌ .mcp.json 缺失(self-hosted 必需)${NC}"
echo " 執行:acr init 會自動生成"
FAILED=1
else
# 檢查 mcp.json 是否指向自己(不是官方 URL)
if grep -q "mcp.arcrun.dev" ".mcp.json" && ! grep -q "workers.dev" ".mcp.json"; then
echo -e "${RED}❌ .mcp.json 指向官方 mcp.arcrun.dev(應指向自己的 worker${NC}"
FAILED=1
else
echo -e "${GREEN}✓ .mcp.json 配置正確${NC}"
fi
fi
fi
# 檢查 4: acr 能執行
if ! command -v acr &>/dev/null; then
echo -e "${RED}❌ acr 未裝或不在 PATH${NC}"
echo " 執行:npm i -g arcrun"
FAILED=1
else
echo -e "${GREEN}✓ acr 可執行${NC}"
fi
# 檢查 5: D1 database 存在(如果 self-hosted
if grep -q "mode.*self-hosted" "$ARCRUN_CONFIG" 2>/dev/null; then
if grep -q "d1_database_id" "$ARCRUN_CONFIG" 2>/dev/null; then
db_id=$(grep "d1_database_id" "$ARCRUN_CONFIG" | cut -d' ' -f2)
if [ -z "$db_id" ]; then
echo -e "${RED}❌ D1 database_id 未設${NC}"
echo " 執行:acr init 會建立 D1,或手動設定 d1_database_id"
FAILED=1
else
echo -e "${GREEN}✓ D1 database_id 已設${NC}"
fi
fi
fi
echo ""
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}✅ 環境就緒${NC}"
exit 0
else
echo -e "${RED}❌ 環境檢查失敗${NC}"
echo ""
echo "修復步驟:"
echo "1. 檢查 ~/.arcrun/config.yaml 是否完整"
echo "2. 若 self-hosted,確認 .mcp.json 指向自己的 mcp worker(不是官方)"
echo "3. 若缺少 D1,重跑 acr init --self-hosted --account-id=... --api-token=..."
echo ""
exit 2
fi
+257
View File
@@ -0,0 +1,257 @@
#!/bin/bash
# .claude/hooks/pre-write-guard.sh
# arcrun PreToolUse guard for Write / Edit / MultiEdit
#
# 職責:擋下會違反 CLAUDE rules 的檔案寫入操作
# 退出 code
# 0 = 允許
# 2 = 擋下(stderr 訊息會回傳給 CC)
#
# 依賴:jq
set -o pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
# 取得將要寫入的內容(Write: contentEdit: new_stringMultiEdit: edits[].new_string 全部串起來)
CONTENT=$(echo "$INPUT" | jq -r '
.tool_input.content
// .tool_input.new_string
// (.tool_input.edits // [] | map(.new_string // "") | join("\n"))
// ""
')
block() {
local rule="$1"
local reason="$2"
local fix="$3"
cat >&2 <<EOF
❌ BLOCKED by arcrun CLAUDE rules
違反項:${rule}
檔案:${FILE_PATH}
原因:${reason}
正確做法:${fix}
參考:.claude/rules/02-forbidden.md
EOF
exit 2
}
# ─────────────────────────────────────────────────────────────────────────────
# 規則 1.1registry/components/ 下不准 TS(除非是 AssemblyScript
# ─────────────────────────────────────────────────────────────────────────────
if [[ "$FILE_PATH" == *"registry/components/"* && "$FILE_PATH" == *.ts ]]; then
# 允許 asconfig.json 同目錄的 AssemblyScript
COMP_DIR=$(dirname "$FILE_PATH")
if [[ ! -f "$COMP_DIR/asconfig.json" ]]; then
block "1.1" \
"registry/components/ 下禁止 TypeScript(除非是 AssemblyScript 且同目錄有 asconfig.json" \
"零件必須用 TinyGo (main.go) 或 AssemblyScript 實作並編譯成 .wasm"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 1.2:禁止在非法位置新增 auth/credential 實作
# ─────────────────────────────────────────────────────────────────────────────
# 合法位置:registry/components/auth_static_key | auth_oauth2 | auth_service_account | auth_mtls
if [[ "$FILE_PATH" =~ auth[-_](static[-_]key|oauth2|service[-_]account|mtls) ]]; then
if [[ "$FILE_PATH" != *"registry/components/auth_"* ]]; then
block "1.2" \
"auth primitive 實作只能放在 registry/components/auth_<type>/" \
"改去 registry/components/auth_static_key/ 等目錄,用 TinyGo 實作 main.go"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 2.1:禁止新增含特定關鍵字的 TS 檔案(credential-injector / jwt-signer 等)
# ─────────────────────────────────────────────────────────────────────────────
if [[ "$FILE_PATH" == *.ts ]]; then
BASE=$(basename "$FILE_PATH")
# 既有的 credential-injector.ts / jwt-signer.ts 允許修改(為了刪除),但不准新增同名
if [[ "$BASE" =~ ^(credential[-_]injector|jwt[-_]signer)\.ts$ ]]; then
if [[ ! -f "$FILE_PATH" ]]; then
block "2.1" \
"禁止新增 ${BASE}(Phase 1-3 的目標是刪除此類檔案,不是重建)" \
"credential 注入 / JWT signing 屬於 WASM 零件職責,改去 registry/components/auth_*/"
fi
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 2.2cypher-executor TS 裡不准實作業務邏輯(只准 wasi-shim.ts 做 crypto
# ─────────────────────────────────────────────────────────────────────────────
if [[ "$FILE_PATH" == *"cypher-executor/src/"* && "$FILE_PATH" == *.ts ]]; then
BASE=$(basename "$FILE_PATH")
# 通用類別:種子資料檔(*-seeds.ts= installer 要灌進 KV 的 recipe 種子清單。
# 其中的 endpoint / {{template}} 字串是「資料欄位」(宣告 recipe 打哪 / 怎麼注入),
# 會被序列化寫進 RECIPES KV,**不是** TS 裡的呼叫 / 展開實作;真正執行仍走零件 / WASM primitive。
# 故整類豁免 endpoint / template 字串檢查。這是「種子資料檔」這個普遍類別的規則,
# 不是為某個零件 / recipe 開的特例(richblack 2026-06-06 原則:不為單一零件改全域規則)。
IS_SEED_DATA_FILE=false
if [[ "$BASE" == *-seeds.ts ]]; then
IS_SEED_DATA_FILE=true
fi
# crypto.subtle.decrypt:只准在 wasi-shim.ts
if echo "$CONTENT" | grep -qE "crypto\.subtle\.decrypt"; then
if [[ "$BASE" != "wasi-shim.ts" ]]; then
block "2.2" \
"AES-GCM 解密(crypto.subtle.decrypt)只准出現在 wasi-shim.ts 的 crypto_decrypt host function" \
"把解密邏輯移到 wasi-shim.ts 的 host function;或讓 WASM 零件透過 u6u.crypto_decrypt 呼叫"
fi
fi
# crypto.subtle.sign with RSASSA:只准在 wasi-shim.ts
if echo "$CONTENT" | grep -qE "crypto\.subtle\.sign.*RSASSA"; then
if [[ "$BASE" != "wasi-shim.ts" ]]; then
block "2.2" \
"RS256 簽章只准出現在 wasi-shim.ts 的 crypto_sign_rs256 host function" \
"把簽章移到 wasi-shim.ts;或讓 auth_service_account WASM 透過 u6u.crypto_sign_rs256 呼叫"
fi
fi
# Template 展開:{{secret.X}} 或 {{runtime.X}} 屬於 WASM 職責
# 例外:種子資料檔(*-seeds.ts)裡的 {{secret.X}} / {{runtime.X}} 是「資料字面值」而非 TS 展開邏輯,
# 真正的展開仍在 WASM auth primitive 內完成。
if [[ "$IS_SEED_DATA_FILE" == "false" ]] && echo "$CONTENT" | grep -qE "\{\{(secret|runtime)\." ; then
block "2.2" \
"Template 展開({{secret.X}} / {{runtime.X}})屬於 WASM auth primitive 職責" \
"把這段邏輯改寫到 registry/components/auth_static_key/main.goTinyGo"
fi
# Hard-code 的 BUILTIN_API_RECIPES / BUILTIN_CREDENTIALS_MAP 新增
if echo "$CONTENT" | grep -qE "(BUILTIN_API_RECIPES|BUILTIN_CREDENTIALS_MAP)\s*[:=]"; then
# 允許「把它設成空物件」或「刪除」,但不准新增實作
if echo "$CONTENT" | grep -qE "BUILTIN_API_RECIPES.*=.*\{\s*[a-zA-Z]"; then
block "2.2" \
"禁止在 TS 裡新增 BUILTIN_API_RECIPES / BUILTIN_CREDENTIALS_MAP 實作" \
"API 呼叫邏輯屬於各自的 WASM 零件(gmail.wasm / telegram.wasm 等),cypher-executor 只做 routing"
fi
fi
# Hard-code API endpoint 實作
# 例外:種子資料檔(*-seeds.ts)的 endpoint 字串(sheets.googleapis.com 等)是 recipe 的
# 「資料欄位」(宣告這個 recipe 預設打哪),會被序列化寫進 RECIPES KV,**不是** TS 裡的呼叫實作;
# 真正的 API 呼叫仍走原本零件 / http_request 路徑。
HARDCODED_APIS=(
"gmail\.googleapis\.com/gmail/v1/users/me/messages/send"
"api\.telegram\.org/bot.*sendMessage"
"sheets\.googleapis\.com/v4/spreadsheets"
"notify-api\.line\.me/api/notify"
)
for PATTERN in "${HARDCODED_APIS[@]}"; do
if echo "$CONTENT" | grep -qE "$PATTERN"; then
# 允許 wasi-shim.tshttp_request host function proxy+ 種子資料檔(*-seeds.ts
if [[ "$BASE" != "wasi-shim.ts" && "$IS_SEED_DATA_FILE" == "false" ]]; then
block "2.2" \
"禁止在 cypher-executor TS 裡 hard-code API endpoint(偵測到: $PATTERN" \
"把 API 呼叫移到對應的 WASM 零件(registry/components/gmail/main.go 等)"
fi
fi
done
# exchangeGoogleJwt / 類似 token exchange function
if echo "$CONTENT" | grep -qE "(exchangeGoogleJwt|exchangeServiceAccountJwt|signGoogleJwt)"; then
if [[ "$BASE" != "wasi-shim.ts" ]]; then
block "2.2" \
"Token exchange 邏輯屬於 auth_service_account WASM 零件" \
"改到 registry/components/auth_service_account/main.go"
fi
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 3.3:禁止建立 *-v2 / new-* / *-worker 類複製貼上目錄
# ─────────────────────────────────────────────────────────────────────────────
if [[ "$FILE_PATH" =~ /(auth|credential|jwt|oauth|gmail|telegram|google-sheets|line-notify|http-request)[-_](v2|v3|new|worker|backup|temp)/ ]]; then
block "3.3" \
"禁止為同一零件建立平行目錄(v2/new/worker/backup 等)" \
"直接修改 registry/components/<name>/main.go 即可;需要版本管理請用 git branch"
fi
if [[ "$FILE_PATH" =~ /new-(auth|credential|jwt|oauth|gmail|telegram)/ ]]; then
block "3.3" \
"禁止為同一零件建立 new-<name>/ 平行目錄" \
"直接修改 registry/components/<name>/main.go"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 7.x:薄殼原則(能力長在 API,介面只暴露)— 見 .claude/rules/07-thin-shell.md
# 只擋語法層可偵測的反例;recipe 層拼裝 / 藏在 helper 的邏輯需 code review 把關(07 §6)。
# 範圍:薄殼介面 = cli/src/ 與 arcrun-mcp/src/(不含 cypher-executor,那是 API 本體)。
# ─────────────────────────────────────────────────────────────────────────────
block7() {
cat >&2 <<EOF
❌ BLOCKED by arcrun CLAUDE rules
違反項:7(薄殼原則)
檔案:${FILE_PATH}
原因:$1
正確做法:$2
參考:.claude/rules/07-thin-shell.md
EOF
exit 2
}
if [[ ( "$FILE_PATH" == *"/cli/src/"* || "$FILE_PATH" == *"arcrun-mcp/src/"* ) \
&& ( "$FILE_PATH" == *.ts || "$FILE_PATH" == *.js ) ]]; then
# 7.1 seed 邏輯不准寫在介面層(§4.1 反例)。seed 是 API 行為,由 API 端點完成。
# 允許薄殼「呼叫一個 seed 端點」(單一 fetch),但不准在介面層定義 seed 編排函式。
if echo "$CONTENT" | grep -qE "(function|const|async)\s+(seedApiRecipes|seedAuthRecipes|seedRecipes)\b"; then
block7 \
"seed 編排邏輯寫在介面層(CLI/MCP),這是壓測 §4.1 的根因——seed 應是 API 行為" \
"把 seed 改成 API 端點(cypher-executor POST /init/seed 或部署完成時自動 seed),薄殼只呼叫該端點一次"
fi
# 7.2 「先 update 失敗再 insert」/「先 GET 找有則 PATCH 無則 POST」這類 upsert 拼裝
# 在同一函式內同時出現 update/patch 與 insert/post 的條件分支 = 把 API 缺的能力拼在介面層。
if echo "$CONTENT" | grep -qiE "method:\s*['\"]PATCH['\"]" && echo "$CONTENT" | grep -qiE "method:\s*['\"]POST['\"]"; then
if echo "$CONTENT" | grep -qiE "(upsert|找到.*PATCH|沒找到.*POST|exists.*update.*else.*insert|if.*found.*patch)"; then
block7 \
"在介面層拼裝 upsert(先找/PATCH 否則 POST)——這是能力,該在 API 提供單一 upsert 端點" \
"請 cypher-executor 提供 upsert 端點(API 內部做 GET→PATCH/POST),薄殼只呼叫該端點"
fi
fi
# 7.3 client 端「全部成功才做下一步」這類補 API 缺口的 gate(§4.1 deployFullyOk 反例)
if echo "$CONTENT" | grep -qE "(deployFullyOk|全部成功.*test|/全部成功/)"; then
block7 \
"用 client 端字串判斷 gate 後續行為(deployFullyOk 類)——脆弱且把編排責任推給介面層" \
"讓 API 保證後置狀態(部署成功即 recipe 就緒),薄殼不做這種 gate"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# 規則 4.3:禁止自行在 docs/3-specs/ 下建新 SDD 目錄
# ─────────────────────────────────────────────────────────────────────────────
if [[ "$FILE_PATH" == *"docs/3-specs/"* ]]; then
# 檢查是否在已知 SDD 目錄內
KNOWN_SDDS=(
"docs/3-specs/arcrun"
"docs/3-specs/arcrun-core-mvp"
"docs/3-specs/arcrun-platform-evolution"
"docs/3-specs/component-registry-canon"
"docs/3-specs/component-gatekeeping" # 2026-05-29 richblack 確認新建(Phase 3 把關)
"docs/3-specs/data-exfil-warning" # 2026-05-30 richblack 確認新建(資料外流警示)
"docs/3-specs/user-cc-harness" # 2026-06-03 richblack 確認新建(用戶 CC harness 安裝)
"docs/3-specs/llm-interface" # 2026-05-16 richblack 確認新建(AI 操盤手介面)
"docs/3-specs/recipe-system" # richblack 確認新建(recipe 系統)
"docs/3-specs/resumable-workflow" # richblack 確認新建(可恢復工作流)
"docs/3-specs/workflow-discovery" # 2026-06-27 總管 issue #8 交辦新建(工作流 description slot + search_workflow,北極星入口缺口)
"docs/3-specs/thin-shell-alignment" # 2026-06-27 總管 issue #11 交辦新建(CLI/MCP 薄殼漂移全面盤點 + 防複發機制)
)
IN_KNOWN=false
for K in "${KNOWN_SDDS[@]}"; do
if [[ "$FILE_PATH" == *"$K/"* ]]; then
IN_KNOWN=true
break
fi
done
if [[ "$IN_KNOWN" == "false" ]]; then
block "4.3" \
"禁止自行在 docs/3-specs/ 下建立新的頂層 SDD 目錄" \
"正確程序(見 .claude/rules/00-sdd-protocol.md「新增 SDD 的完整程序」):(1) 先與 richblack 確認要新建此 SDD;(2) 取得明確確認後,在本檔 KNOWN_SDDS 陣列加一行 \"docs/3-specs/<目錄名>\"(執行已授權任務的必要步驟);(3) 白名單放行後才寫 design/tasks。若是現有 SDD 的補充檔,改放已知 SDD 目錄下。"
fi
fi
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
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# PreToolUse hook — 動 code 前檢查有沒有對應 SDD
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
#
# 掛在 settings.json 的 PreToolUsematcher: Write|Edit)。
# stdin 收到 JSON{ tool_name, tool_input: { file_path, ... } }
# 行為:動到 code 檔(.ts/.go/...)但 system-dev/docs/3-specs/ 下沒有任何 SDD → 警告(exit 2 擋)。
#
# 誠實限制(抄 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
# 只管 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
# system-dev/docs/3-specs/ 下完全沒有 design.md → 攔
SDD_COUNT=0
if [ -d "system-dev/docs/3-specs" ]; then
SDD_COUNT=$(find system-dev/docs/3-specs -name 'design.md' -not -path '*TEMPLATE*' 2>/dev/null | wc -l | tr -d ' ')
fi
if [ "$SDD_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 system-dev/docs/3-specs/ 下找不到任何 SDD。
絕對鐵律:任何 code 變動前必須有對應 SDDdesign.md)。
請先:
1. 確認這個改動屬於哪個子系統
2. 在 system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助)
3. 在回覆開頭宣告已讀 SDD + 對應 task
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
EOF
exit 2
fi
# 有 SDD:放行,但留痕提醒要宣告(stderr 警告,不擋)
echo "📋 提醒:system-dev/docs/3-specs/ 下有 SDD。動手前請確認已讀對應 design.md 並在回覆宣告。" >&2
exit 0
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# .claude/hooks/session-start-load-sdd.sh
# arcrun SessionStart hook
#
# 職責:session 啟動時注入當前進度、絕對禁令、SDD 位置,避免 CC 靠記憶猜測
# 輸出走 stdout,會注入到 CC 的 context
set -o pipefail
cat <<'EOF'
============================================================
🚨 arcrun 工作規範(SessionStart 注入)
============================================================
📌 絕對禁令(違反會被 pre-write / pre-bash hook 直接 block):
1. registry/components/ 下禁止 TypeScript 實作零件
→ 只能 TinyGo (main.go) 或 AssemblyScript,編譯成 .wasm
2. cypher-executor TS 裡禁止實作 credential/auth/JWT 業務邏輯
→ crypto.subtle.decrypt / sign 只准出現在 wasi-shim.ts 的 host function
→ {{secret.X}} template 展開屬於 WASM 零件職責
→ 禁止 hard-code gmail/telegram/sheets API endpoint
3. 禁止新增 Service Binding 綁零件
→ 零件串接走 HTTP URLcypher binding = YAML 裡的 URL 清單)
→ Cypher binding 不是 Cloudflare 的 binding 機制
4. 每個 WASM 零件 = 獨立 Worker = 公開 URL
→ 不是從 R2 動態讀(平台零件已 bundle 進各自 Worker
→ R2 只用於 Phase 5 的用戶自製零件(未啟用)
📋 任何 code 變動前,必須:
1. 讀 docs/3-specs/arcrun/arcrun.md(總進度)
2. 讀對應 task 的 SDDdesign.md + tasks.md
3. 在回覆開頭宣告(格式見 .claude/rules/00-sdd-protocol.md):
📋 已讀 SDD<檔案清單>
🎯 本次對應 task<task 編號>
📐 本次 task 的 SDD 規範摘要:<重點>
🚧 執行範圍:修改/建立/刪除 <檔案>
4. 每完成一個 task,立刻更新 tasks.md 的 [x],不批次
🔥 當前進行中 PhaseCredential Primitives TS → WASM
SDDdocs/3-specs/arcrun/credential-primitives-wasm/
已完成:Phase 0.1-0.5(核心合併 + u6u-core 刪除)
未完成硬前置:
- Phase 0.6 wasi-shim 加 host functionskv_get / crypto_decrypt / crypto_sign_rs256
- Phase 0.7 component-loader WASM runner 路徑
未完成主要任務:
- Phase 1 auth_static_key WASM 零件(TinyGo
- Phase 2 auth_service_account WASM 零件
- Phase 3 刪除 cypher-executor 的三套違規 TS
* src/actions/credential-injector.ts(整檔刪)
* src/lib/jwt-signer.ts(整檔刪)
* src/lib/component-loader.ts 的 BUILTIN_API_RECIPES + BUILTIN_CREDENTIALS_MAP(整段刪)
⛔ 封測狀態:推遲(richblack 2026-04-19 決定)
原因:違規 TS 未清,不封測。
📚 詳細規範:
.claude/rules/00-sdd-protocol.md — SDD 協議
.claude/rules/01-tech-stack.md — 技術棧硬限制
.claude/rules/02-forbidden.md — 禁止清單(hook 強制)
.claude/rules/03-component-architecture.md — 零件架構(R2/cypher binding/service binding 的正確定義)
.claude/rules/04-current-progress.md — 當前進度 + SDD 索引
.claude/rules/06-mindset.md — mindset(為什麼層):工作流是default/零件稀有/AI→工具/誠實不假綠
============================================================
EOF
exit 0
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# SessionStart hook — 開 session 自動注入 status.md 重點
# wishlist §1 主路徑:不靠 CC 自覺、不用人說,開 session 就把進度推到眼前。
#
# 掛在 settings.json 的 SessionStartmatcher: startup|resume|clear)。
# stdout 會被當成 context 注入給 CC。
#
# 鐵律:status 是 point-in-time 快照,非即時狀態。
# 這個 hook 只負責「把快照推到眼前」,核實快照是 CC 的責任——下面的提醒就是要它別盲信。
set -euo pipefail
STATUS_FILE="system-dev/wiki/status.md"
PRINCIPLES_FILE="system-dev/wiki/principles.md"
MISTAKES_FILE="system-dev/wiki/mistakes.md"
# ── 舊結構防呆(1.9.0 遷移第 2 層保險)──
# 新版 wiki 收進 system-dev/。若偵測到舊位置 .claude/wiki/ 還在、但新位置沒 status
# 代表使用者升級了規則卻沒跑遷移(low-code 使用者常見)→ 出聲提示,讓 CC 當場可代為遷移,
# 不要默默用不到新結構而出錯。
if [ -d ".claude/wiki" ] && [ ! -f "$STATUS_FILE" ]; then
echo "⚠️ 偵測到舊版 wiki 結構(.claude/wiki/),尚未遷移到 system-dev/wiki/。"
echo " 請跑:bash system-dev/scripts/update.sh"
echo " 或直接叫我(CC):「幫我把 wiki 遷移到 system-dev/」——我可以代為搬移。"
echo " (未遷移時接關與 /wiki-init 會找錯位置。)"
exit 0
fi
# 三個 push 檔都沒有 → 安靜退出,不干擾還沒 /wiki-init 的專案
if [ ! -f "$STATUS_FILE" ] && [ ! -f "$PRINCIPLES_FILE" ] && [ ! -f "$MISTAKES_FILE" ]; then
exit 0
fi
# ── push 1/3principles(全文,行動前必服從)──
# 放最前:原則是「會被遺忘的盲區」,要第一眼看見。全文成本低(一行一條、≤15 條)。
if [ -f "$PRINCIPLES_FILE" ] && grep -q '^- ' "$PRINCIPLES_FILE" 2>/dev/null; then
echo "════════════════════════════════════════════════"
echo "📐 設計原則(行動前必服從,來自 principles.md"
echo "════════════════════════════════════════════════"
grep '^- ' "$PRINCIPLES_FILE" # 只注入原則條目本身,不含說明區
# context 保護:原則應 ≤15 條(push 全文)。超過 → 提示該合併或下放成 card,不截斷(截斷會漏原則)。
P_COUNT=$(grep -c '^- ' "$PRINCIPLES_FILE")
if [ "$P_COUNT" -gt 15 ]; then
echo ""
echo "(⚠️ principles 已 ${P_COUNT} 條 > 15:請考慮合併相近原則、或把較細的下放成 card)"
fi
echo ""
fi
# ── push 2/3:status(全文,當前進度)──
if [ -f "$STATUS_FILE" ]; then
echo "════════════════════════════════════════════════"
echo "📍 接關:上次進度(來自 $STATUS_FILE 快照)"
echo "════════════════════════════════════════════════"
echo ""
cat "$STATUS_FILE"
fi
# ── push 3/3mistakes(標題清單 + 一行症狀,全文按需展開)──
# 不全文注入(可能累積很多、含長 context)。只給「標題行」讓 CC 認出「我正撞到某條」→ 再去讀全文。
if [ -f "$MISTAKES_FILE" ] && grep -q 'MISTAKE' "$MISTAKES_FILE" 2>/dev/null; then
echo ""
echo "────────────────────────────────────────────────"
echo "⚠️ 已知踩坑(標題;撞到時讀 $MISTAKES_FILE 全文)"
grep -E 'MISTAKE' "$MISTAKES_FILE" | sed 's/^/ /'
fi
echo ""
echo "────────────────────────────────────────────────"
echo "⚠️ 以上是 point-in-time 快照,非即時狀態。"
echo " 動手前先核實:快照寫的事項是否真的還沒做完?"
echo " 需要完整脈絡(decisions / mistakes / SDD)→ 執行 /wiki-recall"
echo "════════════════════════════════════════════════"
# ── 遷移殘留偵測(1.9.x):wiki 已搬到 system-dev/,但 CLAUDE.md 內容裡可能還寫舊路徑 ──
# update.sh 鐵則「絕不碰 CLAUDE.md」(用戶資料),所以遷移不會自動改它內容裡的死引用。
# 這裡只「偵測 + 提示 CC 代修」——由 CC(看得懂語義、不誤傷用戶自寫內容)來改,不讓腳本盲改。
if [ -f "CLAUDE.md" ] && grep -q '\.claude/wiki' CLAUDE.md 2>/dev/null; then
echo ""
echo "🔧 偵測到 CLAUDE.md 內仍有舊路徑 \`.claude/wiki/\`wiki 已遷移到 system-dev/wiki/)。"
echo " 這些是遷移後的死引用,會讓你(CC)找錯位置。"
echo " 請主動幫使用者把 CLAUDE.md 裡的 \`.claude/wiki/\` 改成 \`system-dev/wiki/\`、"
echo " 工具 docs 路徑改成 \`system-dev/docs/\`(但 raw source 宣告的 \`docs/\` 保留不動)。"
fi
exit 0
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# .claude/hooks/stop-check-sync.sh
# arcrun Stop hook
#
# 職責:session 結束前檢查 code 變動是否有對應的 SDD / tasks.md 更新
# 退出 code:不 block,只警告
set -o pipefail
# 檢查 docs/3-specs 下本次 session 是否有變動
SPECS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/' 2>/dev/null | head -20)
CODE_DIFF=$(git -C "$(pwd)" status --porcelain -- '*.go' '*.ts' '*.tsx' '*.py' 'cypher-executor/' 'registry/' 'cli/' 2>/dev/null | head -20)
if [[ -n "$CODE_DIFF" && -z "$SPECS_DIFF" ]]; then
cat >&2 <<EOF
⚠️ Stop hook 警告(by arcrun hook
偵測到本 session 有程式碼變動,但 docs/3-specs/ 下的 SDD 文件沒有任何變動。
未 commit 的程式碼變動:
$(echo "$CODE_DIFF" | head -10)
請在結束前確認:
1. 對應的 tasks.md 是否已更新 [x]
2. 是否有架構變動需要更新 design.md?
3. 是否有 SDD 範圍外的 change 未標記?
SDD 協議要求:code 和 SDD 必須同步更新。
參考:.claude/rules/00-sdd-protocol.md
EOF
fi
# 若有暫存的 tasks.md 變動,提醒 commit
TASKS_DIFF=$(git -C "$(pwd)" status --porcelain -- 'docs/3-specs/**/tasks.md' 2>/dev/null | head -5)
if [[ -n "$TASKS_DIFF" ]]; then
cat >&2 <<EOF
📝 提醒:tasks.md 有未 commit 的變動
$(echo "$TASKS_DIFF")
記得在結束前 commit。
EOF
fi
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
+99
View File
@@ -0,0 +1,99 @@
# SDD 協議(每次啟動必讀)
## 第零原則:沒讀 SDD 不准動 code
任何 `.go` / `.ts` / `.tsx` / `.wasm` 相關變動,**必須**按以下順序執行。**不得簡化,不得跳過**。
### 步驟 1:讀總進度
先讀 `docs/3-specs/arcrun/arcrun.md`,了解當前 Phase。
### 步驟 2:定位對應 SDD
根據任務性質找對應 SDD
| 任務類型 | 對應 SDD |
|---------|---------|
| Auth primitive WASM 零件(static_key/oauth2/service_account/mtls | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| 清除 cypher-executor 裡的 TS 業務邏輯 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| WASI shim host functionskv_get / crypto_decrypt / crypto_sign_rs256 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| Auth Recipe 系統(recipe schema、KV 格式) | `docs/3-specs/arcrun/auth-recipe.md` |
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
| CLI / SDKPython/JS | `docs/3-specs/arcrun/sdk-and-website/` |
| arcrun-core-mvp 整體架構 | `docs/3-specs/arcrun-core-mvp/` |
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/` |
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
`design.md``tasks.md` 兩份。
### 步驟 3:宣告(強制格式)
開始動手前,在回覆開頭**逐字**貼出以下宣告:
```
📋 已讀 SDD
- docs/3-specs/arcrun/arcrun.md(當前 Phase<phase 名稱>
- <對應 SDD 的 design.md 路徑>
- <對應 SDD 的 tasks.md 路徑>
🎯 本次對應 task<task 編號,例如 "Phase 1.3 實作 auth_static_key main.go">
📐 本次 task 的 SDD 規範摘要:
- <重點 1>
- <重點 2>
- <重點 3>
🚧 執行範圍:
- 會修改:<檔案清單>
- 會建立:<檔案清單>
- 會刪除:<檔案清單>
```
**不做這個宣告 = 違反 SDD 協議 = 停手等 richblack**。
### 步驟 4check tasks.md 狀態
動手前:在 tasks.md 把對應 task 的 `- [ ]` 改成 `- [🔄]`(進行中標記)。
完成後:改成 `- [x]`,不批次更新,每完成一個就立刻改。
## 什麼算「任務超出 SDD 範圍」?
以下情況屬於 **change**,不是 **modify****必須停手並與 richblack 確認**
- SDD 沒寫到的新功能
- 新增頂層目錄
- 新增新的 Worker(不管是 cypher-executor / registry / 零件 worker
- 修改架構決策(例如「改用 xxx 取代 yyy」)
- 跨多個子系統的連鎖修改
**停手不是怯懦,是專業**。猜錯方向比慢一小時更糟。
## 新增 SDD 的完整程序(richblack 確認後怎麼往下走)
> 2026-06-03 補:之前協議只寫「停手等確認」,沒寫「確認後怎麼做」,導致 CC 被
> `pre-write-guard.sh` 規則 4.3 擋下後不知正路、卡死。這節補完整程序。
當「新增一個 SDD 子系統」(在 `docs/3-specs/` 下開新頂層目錄):
1. **停手,向 richblack 說明要新建哪個 SDD、為什麼**change,見上節)。
2. **取得 richblack 明確確認**後,依序:
1. **更新白名單**:在 `.claude/hooks/pre-write-guard.sh``KNOWN_SDDS` 陣列加一行
`"docs/3-specs/<新目錄名>" # YYYY-MM-DD richblack 確認新建(<一句用途>`
(這步是「執行 richblack 已授權任務的必要步驟」,不是 AI 擅自放寬 guardrail——
授權脈絡要明確,分類器才放行;沒有明確授權就改白名單 = 越界。)
2. **建目錄 + 寫 design.md / tasks.md**(白名單放行後才寫得進去)。
3. **若沒先更新白名單就 Write** → 規則 4.3 會擋你(這是對的,表示你跳了步驟 2.i)。
**為什麼白名單而非全放行**:開新 SDD 子系統 = 宣告新架構範圍,是稀有的人類決策點。
白名單讓「AI 自己無中生有開子系統」必停下(AI 改不了白名單,除非 richblack 明確授權該任務)。
已許可的目錄內寫檔零摩擦。
## 發現 SDD 本身有問題怎麼辦?
- SDD 和實作不一致 → 停手,列出矛盾點,與 richblack 確認哪一邊是對的
- SDD 規範之間互相矛盾(例如禁令 A 和設計 B 衝突)→ 停手,引用矛盾原文,與 richblack 確認
- **不可以自行猜哪個是對的**。CC 之前兩天就是這樣走錯的。
## 為什麼這個協議存在
arcrun 規範已經足夠細緻,CC 之前出錯不是因為不懂,而是因為**沒讀**或**讀了覺得「大概是這個意思」就動手**。SDD 協議強制把「先讀 → 定位 → 宣告 → 執行 → 更新」做成一條死規矩,沒有繞過去的路徑。
+76
View File
@@ -0,0 +1,76 @@
# 技術棧硬限制
## 三層語言對應(絕對不可混用)
| 層級 | 語言 | 位置 | 職責 |
|-----|------|------|------|
| 零件(Component | **TinyGo 或 AssemblyScript → WASM** | `registry/components/{name}/` | 所有業務邏輯 |
| 零件 Worker 包裝 | TypeScript(固定模板,不寫業務邏輯) | `.component-builds/{name}/` | WASI shimstdin/stdout JSON |
| Orchestration Worker | TypeScript + Hono | `cypher-executor/` | HTTP routing、workflow 執行排程、host functions |
| CLI | TypeScript + Node.js | `cli/` | `acr` 指令 |
| Python SDK | Python | `python-sdk/` | HTTP thin wrapper + client 端加密 |
| JS SDK | TypeScript + Web Crypto | `js-sdk/` | HTTP thin wrapper + client 端加密 |
| Frontend | React 19 + Vite + Tailwind v4 | `landing/` | Cloudflare Pages |
## 零件實作規範
### 只有兩種合法語言
- **TinyGo**`tinygo build -target=wasi -o {name}.wasm main.go`
- **AssemblyScript**`asc main.ts --target release -o {name}.wasm`
### I/O 模型
- **stdin**JSON input
- **stdout**JSON output
- 不用 HTTP server,不監聽 socketWASI preview1 沒 socket
### Host Functions(零件呼叫外部能力的唯一管道)
`u6u` namespace 下:
| Host Function | 用途 |
|---|---|
| `u6u.http_request` | 發 HTTP 請求 |
| `u6u.kv_get` | 讀 Cloudflare KVWorker 側依 key 前綴路由到正確 KV) |
| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM |
| `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) |
**所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。
## 資料儲存
| 儲存 | 用途 | Key 格式 |
|-----|------|---------|
| Cloudflare KV `WEBHOOKS` | workflow 定義(cypher binding YAML | `webhook:{api_key}:{name}` |
| Cloudflare KV `CREDENTIALS_KV` | 加密 credential | `{api_key}:cred:{name}` |
| Cloudflare KV `RECIPES` | auth recipe / 動態 API recipe | `auth_recipe:{service}`, `rec_{hash}` |
| Cloudflare KV `USERS_KV` | 用戶資料 | `user:{api_key}` |
| Cloudflare KV `SESSIONS_KV` | session | `session:{token}` |
| Cloudflare KV `ANALYTICS_KV` | 執行分析 | `execution:{timestamp}:{id}` |
| Cloudflare KV `EXEC_CONTEXT` | workflow 執行中的 context | `ctx:{execution_id}:{node_id}` |
| Cloudflare R2 `WASM_BUCKET` | **只用於用戶自製零件**Phase 5 以後啟用) | `{api_key}:cmp:{hash}` |
**警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary`[[wasm_modules]]``import ... assert { type: 'webassembly' }`)。
## 加解密規範
- **演算法**AES-GCM 256-bit
- **加密位置**Client 端(CLI / Python SDK / JS SDK
- Python`cryptography` 套件
- JSWeb Crypto API`crypto.subtle`
- **解密位置**Server 端 **WASM primitive**(透過 host function `crypto_decrypt`
- cypher-executor TS **不解密**,只提供 host function
- `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM**
- **傳輸格式**`{ name, encrypted, iv }`iv base64、encrypted base64
## 網路部署
- **平台 API(對外)**`cypher.arcrun.dev`cypher-executor
- **Landing**`arcrun.dev`
- **每個零件 Worker**
- **對內(cypher-executor 用來 fetch component,避開同 zone 死鎖)**`arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev`
- 例:`arcrun-kbdb-get.uncle6-me.workers.dev`
- cypher-executor 從 `wrangler.toml [vars] WORKER_SUBDOMAIN` 組出此 URL
- **對外(可選,零件對全網開放被 curl 用)**`{kebab}.arcrun.dev`
- 例:`gmail.arcrun.dev``kbdb-get.arcrun.dev`
- 仍允許保留,但**禁止 cypher-executor 透過此 URL fetch**(會撞同 zone 自循環,見 [docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)
- **新增 component worker 部署清單**`name = "arcrun-{kebab}"` + `[[routes]]` 對外(可選)+ dashboard 啟用 workers.dev(必須)
- **部署工具**Wrangler
+154
View File
@@ -0,0 +1,154 @@
# 禁止行為清單(零容忍)
**這份清單由 `.claude/hooks/*.sh` 強制執行。違反會 block 工具呼叫(exit 2**。
---
## 第一類:零件實作層級的禁令
### 1.1 禁止在 `registry/components/` 下建立 TypeScript 檔案
零件**只能**用 TinyGo`.go`)或 AssemblyScript`.ts` 但需 `asconfig.json`)實作,並編譯成 `.wasm`
cypher-executor/registry Worker 或 `.component-builds/` 內的 TS 不算零件邏輯,那是 WASI shim。
**Hook 會擋**:新增 `registry/components/*/{檔案}.ts`(除非目錄內有 `asconfig.json` 明確標記為 AssemblyScript)。
### 1.2 禁止建立新的 `auth_*` 目錄以外的 auth 實作
所有 auth 邏輯只能在:
- `registry/components/auth_static_key/`
- `registry/components/auth_oauth2/`
- `registry/components/auth_service_account/`
- `registry/components/auth_mtls/`
**不可以**出現 `cypher-executor/src/auth-primitive/``cypher-executor/src/lib/auth-*.ts``auth-worker/``credential-worker/` 等目錄。
**Hook 會擋**`mkdir``Write` 到上述違規路徑。
### 1.3 禁止用 `wrangler init/generate` 建立 auth/credential/jwt 相關的 TS Worker
Auth primitive 必須透過 `component-worker-template/` 搭配 WASM binary 部署。
**Hook 會擋**bash 指令含 `wrangler (init|generate) ... auth_``... credential_``... jwt_` 的 pattern。
---
## 第二類:cypher-executor TS 的禁令
### 2.1 禁止新增任何 credential / auth / jwt 相關的 TS 檔案
**已存在但要刪**Phase 1-3 範圍):
- `cypher-executor/src/actions/credential-injector.ts` → 刪除(走 WASM auth primitive
- `cypher-executor/src/lib/jwt-signer.ts` → 刪除(RS256 移入 auth_service_account WASM
- `cypher-executor/src/lib/component-loader.ts``BUILTIN_API_RECIPES``BUILTIN_CREDENTIALS_MAP` → 整段刪除
**Hook 會擋**:新增任何路徑含以下關鍵字的 `.ts` 檔案:
- `credential-injector``credential_injector`
- `jwt-signer``jwt_signer`
- `auth-dispatcher` 的 TS 若嘗試在裡面實作 credential 解密 / template 展開 / JWT signingblock
### 2.2 禁止在 cypher-executor 任何 TS 裡實作以下邏輯
這些邏輯全部屬於 WASM 零件職責:
- AES-GCM 解密(`crypto.subtle.decrypt`)— 只准出現在 `wasi-shim.ts``crypto_decrypt` host function
- RSA-SHA256 簽章(`crypto.subtle.sign` with RSASSA-PKCS1-v1_5)— 只准出現在 `wasi-shim.ts``crypto_sign_rs256` host function
- Template 展開(`{{secret.X}}` / `{{runtime.X}}` 替換)— 只能在 WASM 零件內
- PEM → PKCS8 解析
- JWT header/payload/signature 組裝
- Token exchange(拿 service account JWT 換 access_token
- 具體 API call 實作(例如 gmail send / telegram sendMessage / google sheets append
**Hook 會擋**
- Write/Edit 到 `cypher-executor/src/` 下的 `.ts` 時,內容含:
- `crypto\.subtle\.decrypt` 且檔名不是 `wasi-shim.ts`
- `crypto\.subtle\.sign.*RSASSA` 且檔名不是 `wasi-shim.ts`
- `interpolateTemplate``\{\{secret\.` 的模板邏輯
- `BUILTIN_API_RECIPES``BUILTIN_CREDENTIALS_MAP`(新增用)
- `gmail.googleapis.com/gmail/v1/users/me/messages/send` 類 hard-code API URL
- `api.telegram.org/bot.*sendMessage`
- `sheets.googleapis.com/v4/spreadsheets`
- `notify-api.line.me/api/notify`
### 2.3 cypher-executor TS 的合法職責(允許)
- HTTP routingHono routes
- workflow 執行排程(`graph-executor.ts`
- 呼叫 WASM 零件(透過 HTTP fetch 到對應 Worker URL,或 Service Binding fallback
- 提供 host function`wasi-shim.ts``kv_get` / `crypto_decrypt` / `crypto_sign_rs256`
- KV/R2/Service Binding 存取封裝
---
## 第三類:架構層級的禁令
### 3.1 禁止新增 Service Binding
**Cypher binding 不是 Cloudflare service binding**。它是 YAML/KV 裡的 URL 清單。
零件串接(workflow 層)一律走 HTTP URL,不走 `[[services]]`
13 個現有的 `SVC_*` 綁定(`cypher-executor/wrangler.toml`,邏輯零件)是歷史遺產(效能優化),**保留但不新增**。
> **2026-06-06 註(credential-primitives-wasm Phase 7**self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `fetch()` 打 auth 觸發 CF **same-zone 1042**(壓測階段 11)。**未用 service binding 解**(評估後廢:service binding 靜態、加/改要重 deploy cypher)。改用 **`global_fetch_strictly_public` compatibility flag**cypher wrangler.toml)讓 same-zone fetch 走公網前門 → 同 zone 也通,**auth 維持 HTTP fetch、不加 binding**。故本禁令不變。
**Hook 會擋**bash 指令含 `wrangler tail` 以外、涉及 `[[services]]` 新增的 patternEdit wrangler.toml 新增 `[[services]]` 區塊時警告確認。
### 3.2 禁止以「從 R2 取 WASM」為設計
平台內建零件已 bundle 進各自 Worker,不從 R2 取。
R2 只在 Phase 5(用戶自製零件)啟用。
**Hook 會警告**TS 中出現 `env.WASM_BUCKET.get(` 的新增 code(除非在明確標註的 Phase 5 user-submit 路徑中)。
### 3.3 禁止複製貼上 Worker 程式碼到新目錄
要改 `gmail` 零件 → 改 `registry/components/gmail/main.go`,重新編譯、部署。
**不准**新建 `gmail-v2/``new-gmail/``gmail-worker/` 等目錄。
**Hook 會擋**`mkdir``Write``{component-name}-v2/``new-{component-name}/``{component-name}-worker/` 類路徑。
### 3.4 禁止在 SDK 內做 server 職責
- **禁止**SDK 裡做 server 端解密、credential-injector 重實作、workflow executor、auth recipe 解析
- **允許**SDK 做 HTTP thin wrapper + client 端加密(AES-GCM
---
## 第四類:流程層級的禁令
### 4.1 禁止沒讀 SDD 就動 code
`00-sdd-protocol.md`
### 4.2 禁止批次更新 tasks.md
每完成一個 task 就立刻 mark `- [x]`。不准「先全部做完再一次更新」。
### 4.3 禁止新建 SDD 而不事先與 richblack 確認
SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/` 底下建新目錄。
例外:在現有 SDD 目錄內新增 `requirements.md` / `design.md` / `tasks.md` 的單檔補充(需在 CLAUDE.md 已註記的 SDD 範圍內)。
---
## 第五類:薄殼原則的禁令(詳見 07-thin-shell.md
### 5.1 禁止在薄殼介面(cli/src/、arcrun-mcp/src/)實作業務邏輯
能力只實作一次,放在 APIcypher-executor 端點)。薄殼只做介面轉換 + 暴露。
**Hook 會擋**(規則 7.x,範圍 `cli/src/``arcrun-mcp/src/` 的 .ts/.js):
- 7.1 新增 `seedApiRecipes` / `seedAuthRecipes` / `seedRecipes` 編排函式(seed 是 API 行為,§4.1 反例)
- 7.2 介面層拼裝 upsert(同函式內 PATCH + POST + upsert/找則改否則建 字樣)
- 7.3 client 端「全部成功才做下一步」gate(`deployFullyOk` 類,§4.1 反例)
### 5.2 禁止用**零件**補 API 缺的能力(污染零件庫)
缺能力 → 先看 07 §3.5 自力救濟階梯分流:**自家** API 缺 → 補 API endpoint**第三方** API 缺(gsheets filter / 無 upsert)→ 走 **workflow/code-node 補丁**(合法,是資料產物非介面層 TS,hook 範圍外);純計算 → code-node;真需新穩定能力才建零件 PR。
禁的是「在介面層拼裝」(5.1,hook 擋)與「亂建零件污染零件庫」,**不是禁任何補丁**(hook 偵測不到語意,靠 code review)。
### 5.3 禁止同一 API 能力在不同介面用不同參數簽名 / 連不同帳號
`validate` 在 CLI 吃 YAML、MCP 卻要 `api_key`+`graph` = 底層分歧。薄殼差異只能來自介面慣例。
所有薄殼讀同一份身份來源(見 07 §4)。
---
## Hook Block 訊息格式
當 hook 擋住一個操作時,訊息格式統一為:
```
❌ BLOCKED by arcrun CLAUDE rules
違反項:<禁令編號,例如 2.2>
原因:<簡短說明>
正確做法:<該改去哪裡、該用什麼方式>
參考:.claude/rules/<對應檔案>
```
這樣 CC 拿到錯誤訊息後有機會自行導正,不是被擋死就愣住。
+165
View File
@@ -0,0 +1,165 @@
# 零件架構與部署模式(必讀,CC 最常搞錯的地方)
## 第一核心概念:每個 WASM 零件 = 一個獨立 Worker = **兩個** URL
**不是**從 R2 即時載入 WASM 執行。
**不是**用 service binding 串零件。
**不是**一個 Worker 裡跑多個零件。
**是**:每個零件都是獨立部署的 Worker,每個都有**兩個 URL**
| URL 類型 | Pattern | 用途 |
|---|---|---|
| 對內(cypher-executor 用)| `arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev` | cypher-executor fetch component 走這個,避開同 zone 自循環死鎖(P0 #9|
| 對外(直接 curl 用,可選)| `{kebab}.arcrun.dev` | 用戶單獨打 component 測試或 self-hosted 用法 |
例:`kbdb_get` 零件:
- 對內:`arcrun-kbdb-get.uncle6-me.workers.dev`cypher-executor 走這個)
- 對外:`kbdb-get.arcrun.dev`(用戶 / 直 curl
**為什麼這樣設計**CF Workers 「同 zone 自循環防護」會讓綁 `cypher.arcrun.dev/*` 的 cypher-executor fetch 同 zone `*.arcrun.dev` 撞 522。完整事件報告:[docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)。改走 workers.dev 子域繞過。
### 零件 Worker 的結構
```
registry/components/{name}/
├── main.go ← TinyGo 原始碼(實際零件邏輯)
├── component.contract.yaml ← 輸入/輸出規格
└── {name}.wasm ← TinyGo 編譯產物
```
部署時,透過 `component-worker-template/` 把 WASM 包進一個 Hono Worker
```
.component-builds/{name}/
├── package.json
├── wrangler.toml ← name = "arcrun-{name}"route = "{name}.arcrun.dev"
├── component.wasm ← 從 registry/components/{name}/ 複製過來
└── src/index.ts ← 固定的 WASI shimPOST / → stdin → WASM → stdout → JSON
```
**src/index.ts 是通用模板**,所有零件都用同一份。這個 TS 只做 WASI runtime,不是業務邏輯。
---
## R2WASM_BUCKET)的真正用途
R2 存 WASM 只是**用戶自製零件上傳**用的。
**平台內建零件不從 R2 讀取**——它們在部署時就已 bundle 進 Worker 的 binary(透過 `[[wasm_modules]]``import` with `assert { type: 'webassembly' }`)。
Phase 5(封測後)才會啟用「用戶 push 自製零件 → 存 R2 → 動態執行」這條路徑。
**結論:當 CC 問「怎麼從 R2 取出 WASM」時,幾乎都是走錯路徑**。平台零件是獨立 Worker,走 HTTP 呼叫,不是 R2 動態載入。
---
## Cypher binding 的正確定義
**Cypher binding 不是 Cloudflare 的任何 binding 機制。**
Cypher binding 是一張 YAML 清單,內容是「一個 workflow 要呼叫哪些零件 URL」。存放在:
- 本地:`workflow.yaml`(用戶寫的 workflow
- KV`WEBHOOKS` KV(用戶 `acr push` 後存入)
Cypher executor 執行 workflow 時:
1. 從 KV 讀出 workflow YAML
2. 按 graph 順序解析每個節點的 `component`
3. 用 HTTP fetch 打對應的零件 URL
4. 把 output 當作下個節點的 input
**這就是 Cypher binding——用 HTTP URL 把零件串起來,存在 YAML/KV 裡**。
### 為什麼不能用 Service Binding
Service binding 需要 `wrangler.toml` 裡寫死 `[[services]]`,且要 redeploy 才生效。arcrun 是類 n8n 服務,用戶建立新 workflow 時**絕對不可能**要他 redeploy。所以 workflow 層一定要 HTTP。
### Service Binding 的僅存合法用途
只在 `cypher-executor` 和**平台內建邏輯零件之間**保留(效能優化,避免公網往返)。看 `cypher-executor/wrangler.toml` 裡的 13 個 `[[services]]` 綁定就是這個用途。
**禁止新增任何 Service Binding**。所有新零件(含 auth primitive)都走 HTTP URL 路徑。
**same-zone 1042 的解(credential-primitives-wasm Phase 72026-06-06**self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `fetch()` 打 auth 觸發 CF **1042**(官方 docs:「fetch from another Worker on the **same zone**」;官方 cypher 在 `cypher.arcrun.dev`、打 `*.workers.dev` 屬跨 zone 故不踩——非官方有 flag)。**解法不是 service binding**(評估後廢:靜態、加/改要重 deploy),而是 cypher wrangler.toml 加 **`global_fetch_strictly_public` flag**——讓 same-zone fetch 走公網前門 → 同 zone 也通。auth 維持 HTTP fetch、不加 binding。官方加此 flag 行為不變(本就跨 zone),self-host 被修好 → **官方與 self-host 共用同一份 toml**
**仍禁止**:為**用戶自製 / 服務專屬零件**(`gmail-worker``notion-worker` 之類)新增 binding——那些是 recipe 的事,不該有 binding。**workflow 層(用戶串零件)一律 HTTP URL 不變。**
---
## 零件之間怎麼串:實際流程
假設 workflow 是:webhook → gmail(要 auth)→ google_sheets(要 auth
```
用戶 POST https://cypher.arcrun.dev/webhooks/named/xxx/trigger
cypher-executorWorker)讀 workflow YAML
├─ 節點 1: component = gmail
│ a. 查 auth_recipe:gmail → primitive = static_key
│ b. HTTP POST https://auth-static-key.arcrun.dev
│ { action: "authenticate", api_key, service: "gmail" }
│ → 回傳 { auth_headers: { Authorization: "Bearer ..." } }
│ c. HTTP POST https://gmail.arcrun.dev
│ { to, subject, body, _auth_headers }
│ → gmail 零件 Worker 執行 WASM → 回傳 { success, data }
└─ 節點 2: component = google_sheets
... 相同模式
```
**cypher-executor 本身不做 credential 解密、不做 JWT signing、不做 auth header 組裝**。這些全在 auth primitive WASM 零件內,cypher-executor 只負責 HTTP routing 和工作流排程。
---
## 實際禁令(CC 看這裡)
### 禁止在 `registry/components/` 下建立 TypeScript 檔案
零件邏輯一律 TinyGo 或 AssemblyScript,編譯成 `.wasm`
### 禁止把 auth 邏輯寫在 `cypher-executor/src/`
credential 解密、JWT signing、template 展開(`{{secret.X}}`)全部屬於 auth primitive WASM 零件的職責。cypher-executor 只呼叫它們。
### 禁止問「怎麼從 R2 取 WASM」
平台內建零件**不從 R2 取**。每個零件已部署成獨立 Worker,走 HTTP URL。用戶自製零件才用 R2(Phase 5,未啟用)。
### 禁止新增 Service Binding
13 個現有的 SVC_*(邏輯零件)是歷史遺產,不新增。新零件(含 auth primitive)一律走 HTTP URL。self-hosted 的 same-zone 1042 用 `global_fetch_strictly_public` flag 解,不靠新增 binding(見上「合法用途」段,Phase 7)。
### 禁止重建已存在的零件 Worker
要改 `gmail` 零件邏輯 → 改 `registry/components/gmail/main.go`,重新編譯 `.wasm`,重新部署對應 Worker。**不要**在 `cypher-executor/src/lib/` 或其他地方建「新的 gmail 實作」。
---
## 部署一個新零件的完整步驟(auth_static_key 為例)
1. 建立 `registry/components/auth_static_key/`
- `main.go`TinyGo 實作)
- `component.contract.yaml`IO 規格)
2. 編譯:`cd registry/components/auth_static_key && tinygo build -target=wasi -o auth_static_key.wasm main.go`
3. 建立 `.component-builds/auth_static_key/`
- 複製 `component-worker-template/src/index.ts`
- 複製 `component-worker-template/package.json`
- 新建 `wrangler.toml`
```toml
name = "arcrun-auth-static-key"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "auth_static_key"
[[routes]]
pattern = "auth-static-key.arcrun.dev/*"
zone_name = "arcrun.dev"
```
- 複製 `auth_static_key.wasm` 到此目錄為 `component.wasm`
4. `cd .component-builds/auth_static_key && pnpm install && pnpm deploy`
5. **Dashboard 啟用 workers.dev URL**(必須,否則 cypher-executor fetch 不到):
- Workers & Pages → `arcrun-auth-static-key` → Settings → Domains & Routes → workers.dev → Enable
- 啟用後 URL`arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev`
6. 驗證對外:`curl https://auth-static-key.arcrun.dev` → 應回 `{ok: true, component: "auth_static_key"}`
7. 驗證對內:`curl https://arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` → 應同樣回 200
8. cypher-executor 透過 `wasmWorkerUrl()` 自動組對內 URL 呼叫(不用手動註冊)
**這是唯一正確的部署流程**。任何偏離這個流程的「替代方案」都要先和 richblack 確認。
**Step 5 為什麼必須**:見 arcrun.md P0 #92026-05-13)。cypher-executor 走對內 URL 避開同 zone 自循環死鎖;若 workers.dev 未啟用,cypher-executor fetch 該 component 會 404。
+77
View File
@@ -0,0 +1,77 @@
# 當前進度(SessionStart 會注入此檔重點)
> 更新時間:2026-04-19
> 權威來源:`docs/3-specs/arcrun/credential-primitives-wasm/tasks.md`
> 此檔僅摘要,詳細狀態以 tasks.md 為準。
---
## 封測狀態
**原定明天封測,richblack 決定推遲**,原因:cypher-executor 有三套 TS 業務邏輯違反「零件一律 WASM」架構原則(Phase 1-3 要清除的程式碼),在清除前不封測。
---
## 目前 PhaseCredential Primitives TS → WASM
**SDD 位置**`docs/3-specs/arcrun/credential-primitives-wasm/design.md` + `tasks.md`
### 已完成
- **Phase 0.10.5**:核心合併(u6u-core 併入 arcrun、21 個零件 contract 完整、刪除重複 `credentials/` 目錄、CREDENTIALS_KV binding 確認、刪除 `matrix/u6u-core/`
- `registry/components/` 下 21 個零件(邏輯 + API)都有 `main.go` + `.wasm`
### 進行中 / 未完成
| Task | 狀態 | 阻擋關係 |
|-----|------|---------|
| 0.6 wasi-shim 新增 `kv_get` / `crypto_decrypt` / `crypto_sign_rs256` host functions | ⬜ 未開始 | **Phase 1-3 的硬前置** |
| 0.7 component-loader 新增 WASM runner 路徑 | ⬜ 未開始 | **Phase 1-3 的硬前置** |
| 1.1-1.8 `auth_static_key` WASM 零件(TinyGo | ⬜ 未開始 | 涵蓋 80% 服務 |
| 2.1-2.6 `auth_service_account` WASM 零件(JWT signing | ⬜ 未開始 | Google Service Account 等 |
| 3.1-3.5 清除 `component-loader.ts``BUILTIN_API_RECIPES` | ⬜ 未開始 | 要先有 Phase 1-2 的 WASM 零件 |
| 4.1-4.4 `auth_oauth2` + `auth_mtls`(封測後) | ⬜ 未開始 | 非阻擋項 |
| 5.1-5.7 核心穩定驗證(全域搜尋確認無殘餘 TS) | ⬜ 未開始 | 封測啟動門檻 |
### Phase 1-3 要**徹底刪除**的 TS 檔案(不是搬、不是改,是刪)
| 檔案 | 違反什麼 |
|-----|---------|
| `cypher-executor/src/actions/credential-injector.ts` | AES 解密、template 展開、JWT 邏輯 —— 應在 WASM |
| `cypher-executor/src/lib/jwt-signer.ts` | RS256 JWT 簽章邏輯 —— 應在 `auth_service_account.wasm` |
| `cypher-executor/src/lib/component-loader.ts``BUILTIN_API_RECIPES`~100 行) | gmail/telegram/line/gsheets/http_request/cron 的 TS 實作 —— 應全部走對應 WASM 零件 |
---
## 下一個 session 第一件要做的事
**讀 `docs/3-specs/arcrun/credential-primitives-wasm/tasks.md`**,然後決定從 Phase 0.6 還是 0.7 開始。
0.6host functions)和 0.7WASM runner)是並列的前置工作,哪個先都可以,但都要在 Phase 1 開始之前完成。
---
## SDD 索引
| 子系統 | SDD |
|--------|-----|
| **主要(正在動)** Credential Primitives WASM 改寫 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| **LI (LLM Interface)** — AI 操盤手使用體驗(2026-05-16 新建,mira dogfood 痛點轉化) | `docs/3-specs/llm-interface/` |
| arcrun 總進度 | `docs/3-specs/arcrun/arcrun.md` |
| Auth Recipe 系統(schema、預建 20 個服務) | `docs/3-specs/arcrun/auth-recipe.md` |
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
| SDK + Website | `docs/3-specs/arcrun/sdk-and-website/design.md` |
| arcrun MVP 整體 | `docs/3-specs/arcrun-core-mvp/design.md` |
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/design.md` |
| Tech Stack 詳細 | `docs/3-specs/tech.md` |
---
## 技術備註(CC 常搞錯的點)
1. **每個 WASM 零件 = 獨立 Worker = 公開 URL**(例:`gmail.arcrun.dev`)。不是從 R2 動態讀。
2. **Cypher binding = YAML 裡寫 URL 清單**。不是 Cloudflare service binding。
3. **cypher-executor 只做 routing + host functions**。業務邏輯全在 WASM 零件。
4. **TinyGo 有限制**`crypto/rsa` 支援不全 → 用 host function `crypto_sign_rs256` 讓 Worker 代簽。
5. 詳見 `.claude/rules/03-component-architecture.md`
+138
View File
@@ -0,0 +1,138 @@
# 部署慣例(CI/CD)
> **核心原則:新增 Worker = 新目錄 + `wrangler.toml`,不用改 workflow。**
`.github/workflows/deploy.yml` 是**通用掃描式** workflow,不該為每個 Worker 手寫 job。
---
## Workflow 如何找到要部署的 Worker?
```
find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.test.toml'
```
每一個命中的目錄 = 一個部署單位。無論是:
- `cypher-executor/` (orchestration Worker)
- `registry/` (合約管理 Worker)
- `.component-builds/{name}/` (零件 Worker,25+ 個)
- 未來新增的任何 Worker
**無需改 workflow,只要符合掃描規則就會自動部署**。
---
## 觸發邏輯
| 觸發 | 部署範圍 |
|------|---------|
| `push` 到 main | diff 涉及的 Worker 目錄才部署 |
| `push` 到 main + 改 `registry/components/{name}/` | 連動 rebuild `.component-builds/{name}/component.wasm` 再 deploy |
| `workflow_dispatch` + `force_all=true` | 全部 Worker |
| `workflow_dispatch` + `only=a,b,c` | 只部署指定清單 |
| `push` 但 base sha 不可及(首次) | 全部 Worker |
---
## 新增 Worker 的步驟
### 如果是新 WASM 零件 Worker
1. 在 `registry/components/{new_name}/``main.go` + `component.contract.yaml`
2. 在 `.component-builds/{new_name}/` 建 Worker 模板:
- `wrangler.toml`(name/routes/bindings)
- `package.json`(hono + workers-types + wrangler 即可,參考 `auth_static_key/package.json`)
- `tsconfig.json`(可直接複製)
- `src/index.ts`(WASI shim,方案 A:import `../../cypher-executor/src/lib/wasi-shim`)
3. 本地跑 `pnpm install``pnpm-lock.yaml`
4. 本地跑 `tinygo build -target=wasi -o {new_name}.wasm main.go` 先驗證 build 通過
5. Commit push → CI 自動 rebuild WASM + deploy
### 如果是新 orchestration/service Worker
1. 在 repo 根建新目錄(類似 `cypher-executor/`)
2. `wrangler.toml` + `package.json` + `pnpm-lock.yaml` + `src/index.ts` + `tsconfig.json`
3. Push → CI 自動部署
---
## Runtime Secret 管理
**CI 只提供 Cloudflare 驗證,不碰 runtime secret**。
- GH Actions secrets:`CLOUDFLARE_API_TOKEN``CLOUDFLARE_ACCOUNT_ID`(一次性設好)
- Runtime secret(例:`ENCRYPTION_KEY``OPENAI_KEY``GOOGLE_API_KEY`):
- **由 richblack 一次性手動** `wrangler secret put <KEY>` 設進各 Worker
- 不進 CI,不進 `wrangler.toml` `[vars]`
- 需要的 Worker:`auth_static_key``auth_service_account`(兩個都要 `ENCRYPTION_KEY`)
---
## Lockfile 規範
- **統一使用 pnpm**。新增 Worker 只放 `pnpm-lock.yaml`,不要 `package-lock.json`
- 若新建 Worker 時用 `npm install` 產出 `package-lock.json`,**刪掉它**,改跑 `pnpm install`
- `cypher-executor/``registry/``package-lock.json` 已於 2026-04-20 刪除
**現存例外**(歷史遺產,混合期不強制遷移):
- `.component-builds/{if_control, switch, ... 16 個舊邏輯零件}/` 仍是 `package-lock.json`,workflow 有 fallback 分支(`pnpm install --no-frozen-lockfile`)可跑
- `builtins/``landing/` 同上
**新增 Worker 一律 pnpm,不要製造新的混合情況**。
---
## WASM 來源
> **⚠️ 慣例變更(richblack 2026-06-02self-hosted 開源策略)**
> 原慣例「`.component-builds/{name}/component.wasm` 不 commit 進 repo」**已推翻**。
> 現在 **commit `.component-builds/*/component.wasm` 進 repo**,因為 self-host 用戶 / `acr init --self-hosted`
> 從 GitHubcodeload tarball)直接拿這份 wasm 部署到自己的 CF——repo 必須自帶可部署的 wasm。
> 決策依據:`docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md §6`
### 現行規則(2026-06-02 起)
- **`.component-builds/*/component.wasm` → commit 進 repo**(部署來源)。`.gitignore` 用否定規則放行:
```
*.wasm # 預設排除
!.component-builds/**/component.wasm # 例外放行部署物
```
- **`registry/components/*.wasm` → 仍不 commit**(build 中間產物,部署不直接用,`.gitignore` 仍排除)。
- 本地開發 build`cd registry/components/{name} && tinygo build -target=wasi -o {name}.wasm main.go && cp {name}.wasm ../../../.component-builds/{name}/component.wasm`**然後 commit `.component-builds/{name}/component.wasm`**。
- CIdeploy.yml):仍在 deploy 前自動 rebuild + copy(部署 prod 用最新 source;與 repo 內 commit 的 wasm 不衝突——前者給 CI deploy prod,後者給 self-host 用戶當部署來源)。
### 誠實 trade-offmindset §7
commit wasm 進 repo → 每次 rebuild 在 git 歷史累積二進位,**repo 長期會膨脹**。
可接受(self-host 體驗優先),未來若膨脹過劇再考慮 git-lfs / 按需安裝(self-hosted-init.md §6.6)。
---
## 並行度
`max-parallel: 5` — 避免觸發 Cloudflare Workers API rate limit。
Worker 數量 > 5 時,deploy 會分批跑。25 個 Worker 大約 5 輪 × ~30 秒 = 2-3 分鐘可完成全部。
---
## 禁止事項
1. **禁止**為新 Worker 手動加 deploy job 到 `deploy.yml`。通用掃描會自動處理,手加就是重複工作。
2. **禁止**把 runtime secret(API key / encryption key / credential)放進 GH Actions secrets 或 `wrangler.toml` `[vars]`,只能用 `wrangler secret put`
3. **禁止**在 CI 裡跑不必要的測試阻擋 deploy。測試在 PR / 本地跑,`main` 推上去就 deploy(trunk-based)。若要測試關,開新 workflow 檔,不要污染 deploy workflow。
4. **禁止**跳過 TinyGo rebuild 直接 deploy 舊 `.wasm`。CI 的 rebuild 步驟是確保部署的是最新 source。
---
## 驗證指令
本地模擬 CI 的掃描結果:
```bash
find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.test.toml' \
| xargs -n1 dirname | sort -u
```
應列出 ~25 個目錄。任何「我新增了 Worker 但沒被 deploy」的問題,先跑這條確認目錄被掃到。
+60
View File
@@ -0,0 +1,60 @@
# arcrun mindset(給 CC 的世界觀 — 走歪前先讀這個)
> 這是「為什麼」層,不是禁令層(禁令在 02-forbidden)。
> 違反禁令會被 hook 擋;違反 mindset 不會被擋,但會讓你做出「技術上對、方向上錯」的東西。
> 來源:2026-05 richblack 與 CC 的一連串判準釐清(權威記錄在 DECISIONS.md)。
>
> 註:這是步驟 7「arcrun-mindset Skill」的前身。Skill 正式建立前,本檔是 CC 的 mindset 來源。
---
## 1. 工作流是 default,零件是稀有例外
AI 開發時的預設順序:
1. **預設寫工作流**(串服務 / 自用 / 給少數人用 → 全部先工作流,用 http_request + 流程控制 primitive 串)。
2. **要打的服務不提供串接但有 API,且有必要讓全 arcrun 生態重用** → 才建零件(零件 = API 薄殼,只打一個 endpoint)。
3. **建零件前必問「你有必要嗎?」**:看到「有 API 可包成零件」≠「該包」。自用 → 工作流。
**CC 的典型走歪**:把「需要一個能力」直接翻成「做一個零件」,把「能包」當「該包」。
mira 的 claude_api / km_writer 就是這樣被錯做成零件的(其實是自用服務膠水,該是工作流)。
## 2. Arcrun 是 AI 呼叫的工具(AI → 工具),不是工具回頭呼叫 AI
需要 AI 判斷 / 轉換時,是**操盤的 CC 自己做**,再呼叫工作流做確定性的下一步。
**不要在工作流裡放零件回頭呼叫一個 LLM**。n8n 需要 AI 節點是因為它沒大腦;arcrun 的大腦就是 CC。
ai_transform_compile/run 因此被刪除。)
## 3. arcrun 不做授權判斷
「能不能打通」由發 API key 的服務裁決,不是 arcrun。401/403 是對方服務在行使授權,不是 arcrun 的 bug。
auth_recipe 只定義「怎麼認證」,不含「誰准用」清單。不要加「arcrun 替用戶擋掉某些 endpoint」的功能。
## 4. 零件投稿走 GitHub PR(人 merge = 人類閘門)
零件投稿不是 registry self-service,是 GitHub PR。人 merge = 天然人類閘門(AI 偽造不了 GitHub approve),
把關(假零件偵測 / 純WASI / Gherkin)由 CI PR check 跑(CI 能 runtime 跑 wasmCF Worker 不能)。
§8「不依賴 CI」指執行鏈路(高頻);零件投稿稀有,走 PR/CI 是例外、不違反。
## 5. 發佈安全的底氣是純 WASI 沙箱,不是 Gherkin
Gherkin 全綠 ≠ 零件安全(投稿者可寫避重就輕的 Gherkin)。真正框死破壞力的是**純 WASI 沙箱**
(零件只能 stdin→stdout、無網路 syscall、無檔案系統)。Gherkin 驗契約 + 沙箱框死 + 市場補長尾 = 風險可控,非零風險。
## 6. 暴露 / 送資料的動作 → 人類明示同意(資料外流警示)
把資料 / workflow 變成「可被外部呼叫」(部署 webhook、recipe push= 暴露面 → 需人類明示同意,不分公私庫。
**不禁止**用戶公開(他的自由),但要**確定他自己明示同意**(不是 AI 替他決定)。
警示同時是「保護措施入口」(提示可加 API Key / 權限 / 限流)。
## 7. 誠實限制(最重要的 mindset:不假裝、不假綠)
- **AI 技術上能偽造人類確認**confirmed_by_human、exposure_consent、gherkin_evidence 都能塞)。
這些機制的價值是**法律歸責 + 軌跡可審**,不是技術防偽。**絕不在文件 / 程式裡聲稱「不可能繞過」。**
- **絕不代替人類做有風險的確認**(建零件、暴露資料)。非 TTY(你直跑)就拒絕,不要自己塞 flag 假裝人類同意了 —— 那是明確越界。
- **禁假綠**DECISIONS §3c/§7):stub / 未實作就回 success:false 或明確標 unimplemented,不要回傳假資料假裝成功。
缺 credential 打不到 2xx 就誠實標「未驗收:缺 X」,不 mock 充綠燈。
- **完成 = 客觀證據**(編譯 exit code / HTTP status + trace),不是口頭宣布「我做好了」。
---
詳細判準與來龍去脈見 `DECISIONS.md`。每條都有對應的慘痛教訓,不是憑空規定。
+133
View File
@@ -0,0 +1,133 @@
# 薄殼原則(鐵律)— 能力長在 API,介面只暴露
> 來源:`docs/壓測報告.md` §5.4/§5.5(設計者本人於壓測中釐清)+ DECISIONS §1。
> 違反此原則的典型後果:每改一個能力要同步多份介面、介面間漂移(壓測 §5.1:CLI 改了讀
> 全域/專案/.env,MCP 沒跟上,兩者打不同帳號)、能力被某個介面綁架後別的介面用不到。
> 這條由 `.claude/hooks/pre-write-guard.sh` 規則 7.x 部分強制(見下「hook 強制範圍」)。
---
## 0. 一句話
**所有能力(business logic)只實作一次,放在 APIcypher-executor HTTP 端點)。
CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」,不含任何商業邏輯。**
```
CLI ─┐
MCP ─┤ ← 全是薄殼:參數解析 / 格式轉換 / 暴露,不含商業邏輯
Python lib ─┤
JS lib ─┘
↓ 全部呼叫同一個
┌──────────────────────────────┐
│ API(唯一真相,能力都在這) │
└──────────────────────────────┘
```
---
## 1. 什麼是「能力下沉到 API」(正例 vs 反例)
### 正例:upsert
- ✅ **API 提供 `upsert` 端點**(內部 GET 找→有則 update 無則 insert)。CLI/MCP/lib 只呼叫它。
- ❌ 在 MCP 裡自製「先 call update API、失敗再 call insert API」的拼裝邏輯。
- ❌ 在 recipe 層拼湊 upsertrecipe/零件補 API 缺的能力 = 走歪;正解是補在 API)。
### 正例:seed recipe(壓測 §4.1 的反例修正)
- ✅ **API 在「部署/註冊完成」時保證 recipe 就緒**(seed 是 API 行為,由一個端點完成 `POST /init/seed`)。
- ✅ **種子資料(清單)放 server**`cypher-executor/src/lib/*-seeds.ts`。「裝好後預設有哪些 recipe」
是 API 的能力,種子資料是這能力的一部分。薄殼只呼叫 `/init/seed` 一次。
- ❌ 在 CLI `init.ts` 裡用迴圈 POST 11 個 recipe + 客戶端「全部成功才 seed」的 if 判斷
(這正是 §4.1 seed 永遠不被 seed 的根因:邏輯被寫進了某個介面)。
> **種子資料檔(`*-seeds.ts`)是一個普遍類別,不是某個零件的特例。**
> 它含 endpoint / `{{template}}` 字串(recipe 的資料欄位),rule 02 §2.2 hook 對**整類** `*-seeds.ts`
> 豁免 endpoint/template 檢查——因為那是「資料宣告」不是「呼叫實作」。新增任何 `xxx-seeds.ts`
> 自動適用,**不需為個別零件/recipe 改 hook**richblack 原則:不為單一零件改全域規則)。
### 判準口訣
> **「這段邏輯換一個介面(CLI→MCP)要不要重寫?」**
> 要重寫 → 它是能力,該在 API。
> 不用重寫(只是把 API 回傳值換個格式印出來)→ 它是薄殼該做的事。
---
## 2. 薄殼「允許」做的事(窮舉)
1. 解析介面慣例的輸入(CLI 吃檔案路徑 / MCP 吃 JSON 參數)→ 轉成 API 期望的 payload。
2. 呼叫 APIHTTP fetch / service binding)。
3. 把 API 回傳值轉成該介面的輸出格式(CLI 印彩色文字 / MCP 回 structured JSON)。
4. **client 端加密**(AES-GCM)——唯一例外,因 API 期望收到已加密 payload(見 rule 01 加解密)。
5. 讀取「身份設定」(哪個帳號 / 哪個 cypher URL)——但所有薄殼必須讀**同一份**身份來源
(見 §4 統一帳號來源)。
## 3. 薄殼「禁止」做的事
1. ❌ **在薄殼介面(CLI/MCP/lib)裡用多個 API 呼叫拼裝**出一個 API 沒有的能力(upsert / seed / 任何 N-step 編排)。
> **界線(2026-06-26 收窄,issue #4**:禁的是「**把編排邏輯寫進介面層 TS**」,不是禁「用資料方式(workflow/code-node)自救」。
> 自家 API 缺能力 → 補進 API(你能改);**第三方 API 缺能力 → 走 workflow/code-node 補丁是合法的**(你改不了第三方 API,不能被規則卡死)。詳見 §3.5 自力救濟階梯。
2. ❌ **用零件**補 API 缺的能力 → 污染零件庫(缺能力 → 先看 §3.5 階梯,真需新穩定能力才走零件 PR)。
> 這條的原始精神(要保留):當初是 AI 把多步驟工作寫成**零件**污染零件庫,才訂此禁令。**禁的是「亂建零件」,不是「禁止任何補丁」。**
3. ❌ 寫死判斷來補 API 缺口(例:`deployFullyOk` 那種 client 端 gate)。
4. ❌ 同一 API 能力在不同介面用不同參數簽名(`validate` 在 CLI 吃 YAML、在 MCP 卻要 `api_key`+`graph` = 底層分歧,違反「同一 API」)。差異只能來自介面慣例(檔案路徑 vs 字串),不能來自底層實作。
5. ❌ 任一薄殼連的帳號 / 後端與別的薄殼不同(CLI 連自架、MCP 連平台 = 違反「同一 API」前提)。
---
## 3.5 缺能力時怎麼補:自力救濟階梯(普世規則,issue #4
> **問題**:§3 舊版預設「缺能力 → 去補 API」,這**預設 API 是你能改的**。對**第三方 API**(如 Google Sheets:一次只能倒全部、輸出前無法 filter)不成立——若 Google 不開該 API、規則又禁用 workflow「倒出來自己篩」,用戶被自己的規則卡死。
> **解法**:把「補丁」分層,維持零件庫最小,但開放「用資料方式自救」的合法路徑。
**主界線(一句話)**:**「那個 API 你能不能改?」** 自家 API 缺能力 → 補 API;第三方 API 缺能力 → workflow/code-node 補丁。
| 情況 | 正解 | 為何 |
|---|---|---|
| 能打既有 API | **recipe**(沒有就建 recipe) | 單一 API 呼叫的封裝 |
| **自家** APIKBDB / cypher)缺能力 | **補進 API** + 可同時發 issue | 你能改,能力該長在 API |
| **第三方** API 缺能力(gsheets filter / 無 upsert API | **可投稿的 workflow 補丁** + 發 issue 建議原廠加 API | 你改不了第三方 API,但不能被卡死 |
| 非 call-api 的純計算(如整篇文章轉大寫) | **code-node**(空白 code 零件內寫 JS | recipe/workflow 都做不到,又不該為此建一堆專用零件 |
| 真需新穩定能力(極少數) | 自建零件 → PR | 維持零件庫最小,只有非用零件不可才建 |
**三個配套原則**
1. **補丁 workflow 可像 recipe 一樣被呼叫,但明示它是 workflow、且可投稿**(呼應 wishlist C6「工作流即零件」)。讓 AI 一遇阻就「用資料方式」自救,而非建零件。
2. **code-node**:原廠不提供某純計算時,AI 至少能用一個空白 code 零件寫 JS 自救(呼應 wishlist C1;架構決策:JS 在 CF Workers isolate 跑,不嵌 QuickJS/Rust)。
3. **補丁是過渡**:原廠出 API 後,補丁 workflow 因效能較差自然被減少使用、淘汰。
**upsert 範例**:有些服務原廠提供 upsert API(→ recipe 直接打),有些沒有(→ 做一個 upsert workflow 達成,**而非建專用零件**)。§1 把 upsert 當「該補進 API」的正例——那**只對自家 API 成立**;對改不了的第三方 API,arcrun 端永遠補不進去,正解是 workflow 補丁。
> **與 hook 的關係**:§3.1 禁的「介面層拼裝」由 `pre-write-guard.sh` 7.x 擋(範圍 `cli/src/``arcrun-mcp/src/` 的 TS)。**workflow/code-node 補丁是資料產物(YAML / 空白零件內的 JS),不是介面層 TS → 本就在 hook 範圍外,合法不被擋。** hook 防線不變,本次只釐清「資料方式自救」是合法路徑。
---
## 4. 統一帳號來源(薄殼共用同一身份)
所有薄殼讀**同一份**身份設定:
- self-hosted`~/.arcrun/config.yaml` / 專案層 `.arcrun.yaml` / `ARCRUN_*``CLOUDFLARE_*` env(見 `config-layering.md`)。
- standard:平台 api_key。
**MCP 目前的已知違反**(壓測 §5.2):MCP 用 Cloudflare service binding 焊死平台 `arcrun-cypher-executor`
self-hosted 用戶用 MCP 連不到自己的 cypher。修法見
`docs/3-specs/arcrun/sdk-and-website/mcp-account-source.md`SDD proposal)。
---
## 5. 出貨順序(最低出貨標)
- **CLI + MCP 兩個薄殼先到位**(AI 偏好 MCP,故 MCP 不可長期落後),且兩者覆蓋**同一組 API 能力**。
- Python / JS lib 隨後補。
- 出貨順序由「介面被誰用」決定,不是由「哪個好做」決定。
- **介面進度本來就會不一致**(薄殼模型的預期狀態)——這本身不是 bug。
bug 是「**底層 API 能力不齊 / 介面含了不該含的邏輯 / 帳號來源不統一**」這三者。
---
## 6. hook 強制範圍(與「靠人判斷」的邊界)
`pre-write-guard.sh` 規則 7.x 能擋的是**語法層可偵測**的反例:
- CLI/MCP 檔案內出現「迴圈 POST 多個 recipe」「先 update 失敗再 insert」這類拼裝 pattern 的明顯特徵 → 警告/擋。
- 新增 `seedApiRecipes` / `seedAuthRecipes` 這類「seed 邏輯寫在介面層」的函式 → 擋(改去 API)。
**hook 擋不了的**(需 CC 自律 + code review):
- 把商業邏輯藏在看似無害的 helper 裡。
- recipe 層拼裝(recipe 是資料,hook 不解析語意)。
→ 故本檔是 mindset,hook 是底線;兩者都不可省。誠實限制見 mindset §7(不假裝 hook「不可能繞過」)。
+66
View File
@@ -0,0 +1,66 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/session-start-load-sdd.sh",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/pre-write-guard.sh",
"timeout": 5
},
{
"type": "command",
"command": "bash .claude/hooks/wiki-secret-scan.sh",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/pre-bash-guard.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/post-edit-remind-tasks.sh",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/stop-check-sync.sh",
"timeout": 5
}
]
}
]
}
}
+6 -14
View File
@@ -23,21 +23,13 @@ docs/test_credentials/
*.sa.json
*-service-account*.json
# ── 開發痕跡 / 思考過程:不對外(本機保留供開發,但不進公開 repo)──
# richblack 2026-06-03:用戶要的是「使用 arcrun」,不是「開發 arcrun」
# 開發規範(.claude/CLAUDE.md/AGENTS.md)、思考過程(.agents/docs/DECISIONS/BACKLOG)只在本機。
# 「用戶 CC harness」另由 acr install-harness 安裝進用戶專案(不是放在這個 repo 給人 clone)。
.claude/
.agents/
docs/
landing/
# ── 開發痕跡進 repoD22 翻案 2026-07-03InkStoneCo 頂層決策)──
# 舊規則(2026-06-03「開發痕跡不對外」)立於 GitHub 終將公開的前提
# 現 repo 真身在自有 Gitea privateclone 者是雲端工人:docs/SDD、wiki、
# CLAUDE.md、.claude/、.agents/ 都必須進 repo(缺=雲端斷糧,T1.5 卡點實證)。
# 未來上 GitHub 公開=一次性發佈篩選,不在日常 gitignore 綁死。
# 例外仍不推:機敏值(本檔各處既有規則照舊)+ .github/(防 Actions 復燃,D20 同源)
.github/
CLAUDE.md
AGENTS.md
DECISIONS.md
BACKLOG.md
BETA_TEST.md
CONTRIBUTING.md
# macOS
.DS_Store
+238
View File
@@ -0,0 +1,238 @@
# Arcrun 待辦(BACKLOG.md
> 流動的待辦清單。做完一項就劃掉(`[x]`)或移到「已完成」。
> 穩定的決策在 `DECISIONS.md`
>
> 原則:一次只推進一步。不在「整理」的慣性裡順手做下一件事。
>
> 最後更新:2026-06-10(與 `.claude/wiki/status.md` 對齊)
---
## 🔥 當前優先序(權威來源:`.claude/wiki/status.md` 2026-06-09
> 此區是「現在該做什麼」的頂層視圖。下方「第一期步驟」多已完成(見各步驟 `[x]`),
> 真正待推進的兩個 P0 在 2026-05 版 BACKLOG 完全沒記,補在這裡。
### 🔴 P0(用戶一用就撞 / 威脅核心承諾)
- [~] **credential 注入 401 修復**`{{credential.X}}` 注入失敗,用戶被迫把 token 明文寫進 workflow。
**根因**:此語法系統沒實裝(三條 template 路徑都不認 `credential.` namespace)。
**修法**design §8richblack 2026-06-10 確認):auth_static_key 加 `resolve_credentials` actionWASM 解密)
+ graph-executor `resolveCredentialRefs` 偵測回填(不碰 ENCRYPTION_KEYrule 02 §2.2)。
**8.1-8.4 done**tinygo build OK + tsc 0 + §2.2 自檢綠)。**待 8.5 端對端驗收**(部署 + 真 OpenAI key + 全新帳號打 2xx)。
- [ ] **§8 P1/P2 recipe/workflow list 遷 D1** — CF KV list 免費僅 1000/日,不修=用戶用一用就 429,
免費承諾破功。D1 現已可建(依賴解除),架構拍板走 kbdb `/entries` HTTP 雙寫不加 binding。
**大、易出錯 → 另開乾淨 session + 壓測**,不要在雜事 session 順手做。
### 🟡 P1(封測門檻 / 技術債)
- [ ] credential-primitives-wasm **Phase 0.7**component-loader WASM runner)→ **Phase 1-2**auth_static_key / auth_service_account WASM 零件)。0.6 已完成。
- [ ] **Phase 3** 清除違規 TScredential-injector.ts / jwt-signer.ts / BUILTIN_*)—— 須先有 Phase 1-2 WASM 頂上。
- [ ] 4 份 inline http_request host fn 抽共用 helperdedup;假綠修是逐份改的)。
### ⚪ P2(不擋封測)
- [ ] `arcrun.dev/llms.txt` servelanding/public 補檔)
- [ ] ENCRYPTION_KEY 冪等性、MCP account-source、recipe submit uuid 回傳
- [ ] 文件遷移階段二/三 + wiki modules/
- [ ] 下方第一期殘項:步驟 2acr recipe test)、步驟 5b(資料外流警示 SDD)、步驟 6(搬家拆 matrix
---
## 第一期(鎖定範圍,依序做,不跳)
### 步驟 1 — 清 cypher-executor 的 KBDB 污染
- [x] 刪除 `cypher-executor/src/lib/kbdb-partner.ts`
- [x] `auth.ts` 移除 kbdb-partner import 與 3 處呼叫
- [x] `wrangler.toml` 移除 `KBDB_BASE_URL``KBDB_INTERNAL_TOKEN` 註解
- [x] `component-loader.ts` 白名單移除 `claude_api` + 6 個 `kbdb_*`
- [x] `component-loader.ts` 修正說謊的 doc comment / 中段註解
- [x] `graph-executor.ts` 註解的 kbdb 範例替換、`types.ts` 移除 `KBDB_BASE_URL` 型別
- [x] `tsc --noEmit` 編譯通過
- [x] **commit 第一步**commit message 寫清楚範圍)
### 步驟 2 — `acr recipe test`recipe 形式的 curl
> 目的很簡單:把一個早就存在的 API endpoint(如 KBDB)變成 Arcrun recipe。
> 流程:看 API 文件 → 申請 API Key → 設進 credential → 寫 recipe → 用 test 打一次。
> `test` 就是「用 recipe 形式 curl 一次」——打通(2xx)就成了。
> 不寫記錄、不是入庫關卡、不防任何東西。防作弊/公共庫那些是第二期。
>
> 順序:先 2c(為 KBDB 建 auth recipe,否則 test 打 KBDB 會 401)→ 再 2a。
- [ ] **2c. 為 KBDB 建 auth recipestatic_key**`POST /auth-recipes`,建 `auth_recipe:kbdb`
讓 auth-dispatcher 能把 KBDB API Key 注進 test 的請求。static_key 新路已支援(見 DECISIONS §3b)。
- [ ] **2a. `acr recipe test <id> [--input k=v]` 指令** — 藄殼:組單節點 ExecutionGraph
→ 走現有 `/execute` → 顯示 HTTP status 與回應。就是 recipe 形式的 curl。
不碰 cypher-executor、不改 RecipeDefinition 型別、不寫任何記錄。
### 步驟 3 — 降級假零件成 recipe2026-05-29 大部分完成)
> 進度(2026-05-29):registry/components/ 33 → 22。引擎加了 `auth_service`(多 recipe 共用 auth
> 與 `inject.path`endpoint 可插 secret,解 telegram URL-path token)。詳見 auth-recipe.md §七 + DECISIONS §1。
- [x] KBDB 降級:建 `kbdb_get/create_block/patch_block/delete/ingest` 5 recipe(共用 auth_service=kbdb),刪 5 零件目錄
- 驗收:get 200 / create 201 / ingest 201 / delete 200 綠;**patch 403 = KBDB PATCH org-check bug**(已交 kbdb/docs,非 recipe 問題)
- `kbdb_upsert_block` **未降**KBDB 無 upsert endpoint,是 client 拼湊 → 已交 KBDB feature request 出 `POST /blocks/upsert`,源碼暫留
- [x] `gmail`/`telegram`/`line_notify`/`google_sheets` 降級:建 recipegmail_send / telegram_send / line_notify_send / google_sheets_read|append+ 補 auth_recipeline_notify / telegram),刪 4 零件目錄
- telegram_send:✅ auth 注入驗收(inject.path400 chat not found = 過認證)
- gmail/sheets/line:⚠️ **未驗收:缺 credential**google SA JSON / line token),recipe + auth 鏈路本身已驗正確
- [x] `claude_api` + `km_writer` **未降,標 deferred**:是 Mira 自用服務的膠水,不該是公共零件/recipe → 交 Mira refactor 文件(收成工作流後刪),源碼暫留
- [x] `ai_transform_compile` + `ai_transform_run` **刪除**:Arcrun 是 AI 呼叫的工具、不該內嵌 AI 節點回頭呼叫 AI(誤搬 n8n 心智模型,見 DECISIONS
- [x] 灰色地帶裁決:`cron` / `platform_crypto` 留(引擎能力,組工作流必要)
- [ ] KBDB recipe 採 Supabase 模式:顯示在公共零件庫,要用需申請 API Key(**未做**,屬服務側/封測後)
- [x] 「降級」定義:萃取 endpoint/method/auth 寫成 API recipe → 刪零件目錄(已照做)
- [ ] **遺留:orphaned 部署 worker 待 `wrangler delete`**richblack 手動,rule 05):
arcrun-kbdb-{get,create-block,patch-block,delete,ingest} / arcrun-{gmail,telegram,line-notify,google-sheets} / arcrun-ai-transform-{compile,run}
- [~] 降級後 registry/components/ 現 22 個:17 白名單 primitive(流程6+資料7+http_request1+auth3
+ cron/platform_crypto(引擎能力)+ claude_api/km_writer/kbdb_upsert_blockdeferred
### 步驟 4 — 補零件庫真把關(2026-05-30 收尾:投稿改走 PR
> 方向修正:零件投稿走 **GitHub PR**(人 merge=閘門、CI 跑把關),廢 registry self-service。
> SDD`.agents/specs/component-gatekeeping/`(含完整決策過程)。
- [x] 假零件偵測(detectFakeComponent.ts):外部 URL/domain + http_request 子集,硬擋退稿指回 recipe
- [x] 純 WASI 把關(wasmImports.ts):import module 白名單(只准 wasi_snapshot_preview1 + u6u
- [x] cold_start / runtime_compat / gherkin 標 unimplemented_steps(不假綠)
- [x] G0 registry 人類閘門(已 commit;投稿改 PR 後非主管道,保留不刪)
- [ ] Gherkin 真跑 + 覆蓋檢查 → 未來接 **CI PR check**CI 能跑 wasm,繞 CF venue 牆)。
richblack:人工 review 就夠,primitive 極少 → CI 暫不做,邏輯已寫好隨時能搬。
- [x] **不做 R5 本機 hook**PR/merge + 假零件偵測 + 純WASI 沙箱已防「未經同意變公共零件」→ hook 過度工程。
- [ ] 黃金向量:人工核對(另起 session 從語義寫,不機器自動化)— 不急
### 步驟 5 — ~~建零件的人類閘門 + 白名單 hook~~2026-05-30 被 PR 方向取代)
> **取代說明**:原規劃「registry submit API 人類閘門 + 四路 client + 本機 hook」。
> 2026-05-30 改:**零件投稿走 GitHub PR**(人 merge = 天然人類閘門,AI 偽造不了 GitHub approve)。
> → registry self-service 主閘門、四路 client、本機 hook **都不做**PR/merge + 假零件偵測 +
> 純WASI 沙箱已防「未經同意變公共零件」,hook 過度工程)。並進步驟 4。
>
> **真正的裸奔風險不在零件,在「資料外流」**recipe/webhook 把資料送出去,不分公私庫)。
> → 另開新 SDD「資料外流警示」(見下方第一期新增項)。
**ABC 配套仍有效**(讓 AI 不選難路):A 假零件偵測(步驟 4)+ B 工作流範本(步驟 7 acr new)
+ C mindset「工作流是 default、零件稀有例外」(步驟 7 mindset Skill)。
### 步驟 5b — 資料外流警示(2026-05-30 新增,richblack:先做)
> 風險根源:arcrun 讓「產生 API」變很簡單(資料+webhook trigger=API)→ AI 可能不知不覺把含個資
> 的東西變成可被呼叫的 endpoint。不分公私庫(私人=公司用也會把個資 POST 到公司群)。
- [ ] 新建 SDDrequirements/design 交 richblack review 才動 code
- [ ] **API 層警示**:任何「把資料送出去」的動作(recipe push / webhook 等)不論哪條路都警示/需人類同意
- [ ] **hook**:AI 動手做這類動作前先警告(防在前;API 層防在送出前,兩道互補)
### 步驟 6 — 搬家(拆 matrix)(原步驟 5)
- [ ] 先給 kbdb、ghost(identity/personality-system/persona-sdk) 各自 `git init` + 建 GitHub repo
(它們現在沒有自己的 .git,是被 inkstone-matrix 追蹤的子目錄——刪 matrix/.git 前必須先安置)
- [ ] 分揀 matrix 的孤兒檔案(清單見下方附錄)
- [ ] arcrun / kbdb / ghost 各自 `git status` 確認乾淨、已 push
- [ ] GitHub 上 `inkstone-matrix` 設為 archived(不刪)
- [ ] 本機刪 `matrix/.git`matrix 降級成普通資料夾
- [ ] 比對兩份 cypher-executormatrix 頂層 vs arcrun 底下),釐清死活
### 步驟 7 — 收尾(原步驟 6)
- [x] `arcrun-mindset` Skill(給 AI 操盤手的世界觀,見 DECISIONS §7 層三)
`skills/arcrun-mindset/SKILL.md`(2026-05-30)。涵蓋:工作流是default/零件稀有、
AI→工具、arcrun不做授權、暴露需人類同意、誠實(不假綠/不假防偽/不代替人類確認/完成=客觀證據)。
同步 `.claude/rules/06-mindset.md`arcrun repo 內 CC 開發時讀)。
- [ ] 會回嘴的 CLIacr 偵測走歪 → exit 2 + 指回正路,見 DECISIONS §7 層二)
→ 部分已做:pre-bash hook 擋假零件/暴露動作 + 指回正路;CLI 本身的 exit-2 回嘴待補
- [ ] README 重寫成單一路徑(砍掉「玩法一/二/三」三選一)
- [ ] `acr init --self-hosted`:貼 CF token → 自動建 KV、部署 Worker、自動 workers.dev
subdomain、寫回 config(用戶自己建 tokenCLI 不代管)
---
## 第一期之後 / 待決策(不要現在做)
- [ ] **用戶自己的 API 保護機制(入站認證)**richblack 2026-05-30)— 做成零件或功能:
(1) 用戶可發 API Key 給別人;(2) 不同權限設定。現況缺口:webhook 只有 X-Arcrun-API-Key
(誰有我的 key 誰能打),沒有「發受限 key 給別人 / per-caller 認證 / rate limit」。
**這是 arcrun 資安優勢**n8n 用簡單 USN/PWD 做不好,AI coding 又怕資安沒做好,我們系統幫搞定、
一個動作能用。注意區分:credential/auth recipe 是出站(呼叫別人 API 帶我的 token),這是入站(保護我的 API)。
- [ ] **架構詞彙釐清(recipe / part / function / 工作流組合)**richblack 2026-05-30,要思考一下):
- recipe:用零件去打的設置文件(endpoint+設定)
- part(零件):需 PR + update/upgrade
- function:把一批功能做好可用 cypher 拉進來(例:API 認證,我做好幾個讓用戶拉)。是 part+recipe
- **多零件/工作流組合成新工作流**(第一個走完連第二個)—「好像還沒有」,要思考
- [ ] **砦 `injectCredentials` 舊路 + `BUILTIN_CREDENTIALS_MAP`** — credential 系統現為新舊兩路並存,
舊路是 TS 裡解密的半成品(註解自認 Phase 1.9 刪)。是獨立清理,**不擋降級**。見 DECISIONS §3b
- [ ] **決策:開源版 cypher-executor 是否保留「KBDB block 展開」功能**
`recipe-expander.ts` 仍讀 `env.KBDB_BASE_URL``prompt-recipe-schema.ts``kbdb_block` 型別)
按定位應移除(屬 KBDB 訂閱層),但牽涉型別,需單獨想清楚
- [ ] **決策:開源版遙測去 KBDB 化**
`telemetry.ts``kbdbCreateBlockUrl` + fetch 到 `arcrun-kbdb-create-block`
開源版遙測寫去哪?本地?關掉?寫用戶自己的 ANALYTICS_KV
- [ ] 新增 `kv_store` 暫存 primitive:解決「workflow 中途暫存」需求(像 n8n data table)。
用 Arcrun 執行環境內建暫存,不依賴外部服務 → 可為 primitive。
介面要抽象(CF 上是 KV,wazero 上是本地檔/記憶體),才能三層通用
- [ ] 「專案」概念落地:CLI 的 `acr project` 指令、專案=引用 workflow 的三元組
- [ ] 公共零件庫 KBDB 語義搜尋:AI 查意圖 → 回傳零件選項;self-hosted 用本地關鍵字比對
- [ ] 事後機制第二層:不變式測試套件(核心原則寫成自動測試,接 CI / pre-commit
- [ ] 事後機制第一層:執行軌跡完整化(基於現有 `executions.ts`
- [ ] `acr project init`:把閉環三件(mindset Skill + CLAUDE.md 判準 + exit-2 hook +
不變式測試)一次裝進用戶專案
- [ ] 視覺化 Skill:固定格式的「給人看的圖」(取代 arcrun-gui 拖拉畫布)。
目的是「讓人看完放心地說『跑吧』」,是信任產出物不是美術產出物
- [ ] arcrun-gui 處置:降級為「零件庫 / recipe 貢獻者牆」展示站,掛 arcrun.dev(非工具)
- [ ] arcrun-mcp 對齊:`u6u_*` → arcrun 命名、`finally.click` → arcrun.dev、
移除 GUIDE.md 教 `api_config` 的反模式、確認 MCP 是薄殼
- [ ] `acr update` 設計:常態只拉 recipe;動到 primitive 一定要用戶確認,不可默默 deploy
- [ ] recipe 「貢獻回公眾」的路徑:別人測好的 recipe 怎麼變成所有人能用(飛輪的關鍵)
---
## 附錄:matrix 孤兒檔案分揀清單(步驟 5 用)
**移進 arcrun**
- `.agents/specs/arcrun/``.agents/specs/u6u-core-mvp/``.agents/specs/u6u-platform-evolution/`
- `.agents/steerings/`tech.md / coding-rules.md / product.md / structure.md
- `docs/user_requirements/arcrun/`
- `cypher-executor/`(matrix 頂層那份——先比對死活)
**移進 arcrun-gui(若保留):**
- `.agents/specs/u6u-gui-platform/``.agents/specs/prototype-editor/`(先看一眼確認)
**移進 ghost / 其他:**
- `ghost.md``identity/``personality-system/``persona-sdk/` → ghost
- `.agents/specs/matrix-admin-api-fixes/``inkstone-admin/` → inkstone-admin
**需判斷(多半過時,留在 archived 的 inkstone-matrix 即可):**
- `API-CATALOG.md``AVM-PROTOCOL.md``MODULE-CONTRACT.md`、頂層 `CLAUDE.md` / `GEMINI.md`
- `docs/user_requirements/rearrange.md` / `test.md`
- `.claude/` / `.github/` / `.vscode/` / `.wrangler/` / `.swarm/` / `ruvector.db`
## 未來方向:本機零帳戶試玩(local playground)— leo 2026-06-15 提,記錄不插隊
**洞察**self-hosted 最低門檻入口=裝 wrangler → 本機 miniflare 跑 arcrun → **連 CF 帳戶都不用**先玩玩看。
**現有障礙**(為何現在不開箱即用):
- `acr init --self-hosted` 第一步強制貼 CF Account ID + API Tokenself-hosted-init.md),無「無帳戶本機模式」入口。
- `wasmWorkerUrl()`component-loader.ts:59)寫死組 `https://arcrun-{x}.{subdomain}.workers.dev`,假設零件部署在 workers.dev,非 localhost。
- 多 workercypher/kbdb/registry + 零件)串接 + service binding 要本機編排。
**要做**(未來,非現在):
- `acr init --local`:跳過 CF tokensubdomain/URL 走 localhost。
- `wasmWorkerUrl()` local 模式組 `http://localhost:port`
- 多 worker 用 wrangler 本機多服務模式編排(D1/KV miniflare 模擬,已支援)。
**現況可行的最小片**:單 workerkbdb`wrangler dev` + D1 模擬本機跑得起來(=KBDB 插件整合測試路徑)。全系統一鍵本機跑才是新功能。
**優先級**:未來方向,不插現在隊列(mira 解耦 + KBDB 缺口優先)。
## 未來方向:SaaS ⇄ self-hosted 雙向遷移(一等公民)— issue #3 待辦 2 / wishlist C72026-06-24
**緣由**14-Eleo 把 KBDB 資料從官方 SaaS 搬回 leo21c self-hosted458,357 筆 gap=0 完成)暴露框架級缺口。
過程因 `KBDB_BASE_URL` fallback bug(已修,issue #2 commit 9c4333d+ **沒有遷移工具**,資料默默寫錯庫
~11 萬筆 `owner_id=leo` 誤寫進官方 prod kbdb,清理 SOP 見 `docs/5-records/2026-06-24-official-kbdb-cleanup-leo-misdelete.md`)。
今天是 leo dogfood 踩了能修;**若是未來真實用戶 = 數據災難 + 信任崩塌**。
**缺口本質**arcrun 沒有「SaaS ⇄ self-hosted 雙向遷移」的框架級一等公民支持。
leo 今天靠 dump + 手寫翻譯腳本 + 階梯式踩雷(`d1 import` 被 wrangler 4 移除 / statement 切斷 /
SQLite 深度上限 / SQLITE_TOOBIG / 超大 content)硬趟過去。
**要做**backlog,不急):
- `acr migrate` 一等公民路徑:schema 翻譯 + 資料層整包灌 + 驗數 + **隔離保證(絕不寫錯庫)**
- 遷移前**庫歸屬驗證**(probe:寫一筆確認落在預期庫)→ 把今天的 fallback 災難從源頭擋掉。
- **雙向**self-hosted → SaaS(上雲)和 SaaS → self-hosted(下雲)都要順。
**牽動**arcrun-cli + cypher-executor + kbdb。**優先級**:未來方向,不插現在隊列。
**頂層願景源**`docs/1-vision/product-wishlist.md` C7。
+97
View File
@@ -0,0 +1,97 @@
# CLAUDE.md — arcrun
> **上游約束(InkStoneCo 總管)**:本 repo 是 InkStoneCo 大專案的子專案,受頂層知識庫約束。
> 動工前讀 `github.com/uncle6me-web/InkStoneCo` 的 CLAUDE.md + `docs/3-specs/`
> 關鍵鐵律:禁止跨 repo 同步 Actions / Mira≠Arcrun 分層(arcrun.dev 是框架域名,非產品入口)/ self-hosted 用 namespace 明碼非 api key / 部署繞開 GitHubwrangler 直推)。
> 本 repo 的待整合交棒見 `docs/HANDOFF-matrix-rearrange.md`
> **與 InkStoneCo 總管的溝通走本 repo 的 GitHub issue**:總管交辦框架 bug / 跨專案需求 →
> 在本 repo 開 issueCC 用 `gh` 讀(`gh issue list/view`)、修完在同一 issue `comment` 回報修法 + 驗證證據。
> issue thread = 雙向溝通 + 永久記錄。**有事才讀,禁自動輪詢**(flag 安全界線)。
> 結案時機由「end-to-end 實證綠燈」決定——尚待人/總管確認的留 open。詳見 `/issue-handle` skill。
> 發 issue 給**別的 repo** 要先問人類,不可擅自。
> 本檔是**索引 + 最高原則**,詳細規範拆到 `.claude/rules/`
> Hook 強制機制在 `.claude/hooks/`,違反會直接 blockexit 2)。
---
## 絕對鐵律(違反 = 停手)
1. **任何 code 變動前必須先讀對應 SDD**,在回覆開頭宣告已讀清單與對應 task 編號(格式見 `.claude/rules/00-sdd-protocol.md`
2. **零件只能用 TinyGo 或 AssemblyScript 編譯成 WASM**`registry/components/` 下禁止 TypeScript
3. **cypher-executor TS 禁止實作 credential / auth / JWT / template 展開業務邏輯**;這些全在 WASM 零件
4. **Cypher binding = YAML 裡的 URL 清單**,不是 Cloudflare service binding;零件串接走 HTTP URL(含 auth primitive)。self-hosted same-zone 1042 用 `global_fetch_strictly_public` flag 解,不新增 bindingcredential-primitives-wasm Phase 7
5. **每個 WASM 零件 = 獨立 Worker = 公開 URL**;不從 R2 動態讀(R2 只 Phase 5 啟用)
6. **修改現有程式碼,不是新建資料夾重做**
7. **每完成一個 task 立刻更新 tasks.md 的 `[x]`**,不批次
8. **薄殼原則**:能力只實作一次放在 API;CLI/MCP/lib 是薄殼,不得自帶業務邏輯 / 拼裝 API / 用 recipe 補 API 缺口(見 `.claude/rules/07-thin-shell.md`hook 7.x 部分強制)
---
## 工作流程(強制)
開始任一任務,按順序:
1. **讀 wiki**3 分鐘快速進度)→ `.claude/wiki/status.md`
2. 讀 `docs/3-specs/arcrun/arcrun.md`(總進度)
3. 讀對應的 SDD `design.md` + `tasks.md`
4. 在回覆開頭貼出:
```
📋 已讀 SDD<清單>
🎯 本次對應 task<編號>
📐 本次 task 的 SDD 規範摘要:<重點>
🚧 執行範圍:修改/建立/刪除 <檔案>
```
5. 動手前把 tasks.md 對應 task 標為 `[🔄]`,完成後標 `[x]`
6. 完成後確認:是否需要同步更新 design.md?
找不到對應 SDD → **停手問 richblack**,不要自行建立。
---
## Wiki(每次 session 的讀取順序)
| 檔案 | 時機 | 用途 |
|------|------|------|
| `.claude/wiki/status.md` | session 開始第一件事 | 當前 Phase、進度、下一步 |
| `.claude/wiki/mistakes.md` | 做新功能前 | 10 大常犯錯誤 + 快速檢查清單 |
| `.claude/wiki/decisions-summary.md` | 遇到設計判斷時 | 6 大架構決策 + trade-off |
| `.claude/wiki/INDEX.md` | 找不到東西時 | wiki 導引 + 快速導航 |
---
## 詳細規範索引
| 檔案 | 內容 |
|-----|------|
| `.claude/rules/00-sdd-protocol.md` | SDD 讀取協議(強制流程) |
| `.claude/rules/01-tech-stack.md` | 技術棧硬限制(語言/儲存/加解密) |
| `.claude/rules/02-forbidden.md` | 禁止清單(hook 強制執行) |
| `.claude/rules/03-component-architecture.md` | 零件架構(R2 用途 / cypher binding / service binding 邊界) |
| `.claude/rules/04-current-progress.md` | 當前進度 + SDD 索引 |
| `.claude/rules/05-deploy-convention.md` | 部署慣例(新 Worker = 新目錄 + wrangler.toml |
| `.claude/rules/06-mindset.md` | mindset(為什麼層) |
| `.claude/rules/07-thin-shell.md` | 薄殼原則鐵律(能力長在 API,介面只暴露) |
---
## SDD 位置速查
| 子系統 | 路徑 |
|-------|------|
| **進行中** Credential Primitives WASM | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| arcrun 總進度 | `docs/3-specs/arcrun/arcrun.md` |
| Auth Recipe 系統 | `docs/3-specs/arcrun/auth-recipe.md` |
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
| SDK + Website | `docs/3-specs/arcrun/sdk-and-website/` |
| arcrun MVP 整體 | `docs/3-specs/arcrun-core-mvp/` |
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/` |
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
| Tech Stack 詳細 | `docs/3-specs/tech.md` |
---
## 封測狀態
**推遲**richblack 2026-04-19 決定)。先完成 Phase 1-3 清除違規 TS,再啟動封測。
+100
View File
@@ -0,0 +1,100 @@
# HANDOFF: arcrun config 寫入端 + 讓 AI 看懂 scope + KBDB Vectorize 開關
> 來源:InkStoneCo 頂層總管,2026-06-15。mira self-hosted dogfood 踩出的兩條框架缺口。
> 兩條都是 `change`,走 SDD 協議(先讀對應 SDD、宣告、改 tasks.md)。
> 觸發實證:mira(一個 AI)被交代遷移時**沒看懂 scope 存在**,差點誤部官方帳號。
---
## 缺口 1:config 讀分層完整、寫只能全域 + AI 看不懂 scope
### C.1 補 `acr init` 專案層寫入端
`cli/src/lib/config.ts` 讀取分層完整(`findProjectConfig()` env > 專案層 > 全域,8/8 測試),但**寫入只有 `saveConfig()`config.ts:208)寫死全域** `~/.arcrun/config.yaml`config.ts:38)。`acr init` 4 處呼叫(init.ts:93/142/278)全寫全域。
- → `acr init` 無法寫專案層。leo 意圖 project scope 卻被迫污染全域 → 怕誤打官方 uncle6me。
**修法**
- `saveConfig()` 加 scope 參數(`'global' | 'project'`);project 寫 `./.arcrun.yaml``process.cwd()`)。
- `acr init` / `acr config set``--project` flag。
- 預設是否翻轉(init 預設寫專案層)由 richblack 定(影響既有使用者)。
- 參考既有範式:`mcp-setup.ts` §7.8 已有 project scope 寫 `.mcp.json` 概念,比照。
### C.2 ★ 改 `cli/harness/CLAUDE.block.md` 讓操作 AI 看懂 scope(最重要)
**病根**arcrun 是 AI 操作的工具(mindset §2),但 harness block(全文 41 行)對 scope / 帳號歸屬 / 誤部風險 **0 字提及**。AI 讀完完全不知道有 scope 這層 → mira 把「資料遷移」跟「在哪 scope 部署」混為一談。
在 harness block 加「部署落點與帳號安全」段:
1. **scope 模型**:分層 `env > 專案層 .arcrun.yaml > 全域 ~/.arcrun/config.yaml`。**部到哪個 CF 帳號取決於當前資料夾解析到哪份設定**。動手前先 `acr config --where`
2. **多帳號警示**:同機可能多個 CF 帳號(dogfood / prod);`.env` 可能並存多組 `CLOUDFLARE_*`(含 `*_UNCLE6` 後綴)。部署/遷移前必須明確目標 account_id,挑錯=打到正式環境(不可逆)。
3. **遷移 ≠ scope**:搬資料(A 帳號→B 帳號)和「我用哪個帳號」是兩件獨立的事。
4. **fallback 陷阱**:任一層都沒 `mode``acr` 靜默當 `local`config.ts:176),不報錯。看到「成功」但無雲端 trace 先查 `acr config --where`
5. **無設定就停手**`--where` 顯示無設定 / fallback local 而意圖 self-hosted → 停下問人。
> harness block 是 AI 的 mindset 入口,比 CLI `--help` 重要(AI 偏好讀 harness)。scope 安全屬「動手前世界觀」,必在這層。
**對應 SDD**`docs/3-specs/arcrun/sdk-and-website/config-layering.md`
---
## 缺口 2(任務 D):KBDB Vectorizeembed)無環境開關
### 病根
KBDB 分 `base`D1 only,免費)+ `embed` moduleVectorize+AI binding,語意搜尋)。但 `acr init/update` 完全沒處理 embedgrep `vectorize|embed``cli/src/` = 0 命中);只活在 `kbdb/src/index.ts:5` 註解。→ **沒有 config 欄位 / 指令讓使用者或 AI 表達「要不要開 Vectorize」**
### 設計哲學(leo 2026-06-15):降級要優雅,但「完整體驗」要有門
> 「最好是我自己這套可以語義搜尋;不開就降級;**但想完整體驗卻沒有門**。」
- **預設降級且能跑**:沒開 embed → base 仍可關鍵字搜尋(不是壞掉)。
- **降級要明示不能無聲**:跑 base 時要讓人知道「語意搜尋未開」。無聲降級=用戶以為這就是全部=bug。
- **★ 升級要有門**:config 欄位 + `acr` 提示「想語意搜尋?這樣開」。現在連「有更完整版本可選」都不可見。
### 修法(config 欄位 + 部署條件注入,比照 MULTI_TENANT
1. **config 加欄位**`ArcrunConfig`config.ts:11)加 `kbdb_embed?: boolean`(預設 falsebase 免費)。leo dogfood 設 true。
2. **部署條件注入**`acr update` 部 KBDB worker 時 `kbdb_embed: true` → 注入 Vectorize+AI binding + 部 embed modulefalse 只部 base。比照 `deploy.ts:395` MULTI_TENANT 注入、`deploy.ts:380` WORKER_SUBDOMAIN 注入。
3. **降級可見**`acr config --where` / 部署摘要顯示「KBDB: base(語意搜尋未開)」或「embedVectorize 啟用)」;base 時提示升級門。
4. **AI 認知**harness blockC.2 那段)一併說「KBDB 預設 base(D1 免費僅關鍵字);語意搜尋需 `kbdb_embed: true`(Vectorize 計費)。功能需語意搜尋但這套是 base → 別假裝有,明示降級指出升級門」。呼應 mindset §7 誠實。
**對應 SDD**KBDB 模組化屬 `docs/3-specs/` kbdb spec;部署注入屬 `sdk-and-website/self-hosted-init.md`
---
## 任務 E(★ 急,擋 mira 14-E load):給 mira「leo21c kbdb worker 實際綁的 D1 id」
> 總管裁定(2026-06-15):mira 14-E load 撞 Workers 每日 10萬 request 牆(逐筆 API 病根)→ 改走 `wrangler d1 import`(資料層搬遷=用戶 CF 權限,繞 API)。**但 mira 需要 D1 id 才能灌對庫。**
**為什麼 mira 自己查不到**
- cypher-executor **無** D1 binding,轉發給 **kbdb worker**
- kbdb worker 的 D1 id 是 **deploy.ts 部署時動態注入**`kbdb/wrangler.toml` 靜態值 `0c580910…` 是官方 prodself-hosted 部署會覆蓋)。
- → leo21c 上 kbdb worker 實際綁的 D1 id ≠ mira `wrangler` 直連的 `arcrun-kbdb``1099d0f3…`)。這是「wrangler 查 0 筆、cypher 有 11 萬筆」的真相(兩個同名不同 id 的 D1)。
**你要做**:用 leo21c CF token 查 leo21c 上 **kbdb worker 部署時實際注入的 D1 database_id**(看 deploy.ts 注入邏輯 / 查 leo21c worker runtime binding),告訴 mira。mira 拿到才能 `wrangler d1 import <該 id>` 灌對庫。
## 任務 F(B,框架缺口,非急):cypher `/kbdb/entries` 補批次端點
> 總管裁定 B:應用層大批寫入該有批次 API。**未來每個 self-hosted 用戶大批匯入都撞 10萬 request 牆**(不只 mira)——逐筆 POST 每筆 1 request~5萬筆燒光免費 worker 當日配額。
- `POST /kbdb/entries` 收陣列(`[{...},{...}]``{entries:[...]}`),1 request 寫 N 筆 → 45.8萬筆只要 ~900 requests。
- 守鐵律(只轉發 API、不開 SQL/建表;owner_id 仍自動注入每筆)。
- 對應 SDDkbdb-proxy 屬既有範圍。
## 任務 G(雜項,mira 14-E 踩出的 cypher 框架特性,記著)
mira load 踩出,記給框架修(非急):
1. CF WAF 1010Python urllib 預設 UA 被擋 → 文件提醒帶 UA。
2. 重複 id → 500(非 409):UNIQUE 衝突宜回 409 友善處理。
3. 零星單筆 500:某些 block 穩定 500、同 content 換 id 卻 200=邊緣 bug(疑 metadata_json/refs_json 某字元觸發,未定位)。
4. `?id=` query 被忽略(回前 100 筆不過濾);查單筆只能 `/entries/:id` 路徑——文件宜明示。
5. **DELETE `/kbdb/entries/:id` route 缺**OPTIONS 卻宣告支援 DELETECORS 宣告與實作不一致)。
6. cypher 綁的 D1 與同名 `arcrun-kbdb` database_id 不一致(見任務 E)——self-hosted 文件宜說明「wrangler 直連 ≠ cypher 綁的庫」。
## 共通教訓
任務 C、D 同類:**把「該是環境設定的選擇」從 code 註解提升到 AI/使用者可表達的層級**。arcrun 是 AI 操作的工具,凡影響「部到哪、開什麼功能」的選擇都必須在 config + harness 可見,不能只活在實作裡。
任務 E/F 同類:**資料層搬遷(用戶 CF 權限 d1 importvs 應用層大批寫入(cypher 批次 API)是不同層級,都該存在**——逐筆 API 推大批資料是把「資料遷移」做成「應用層寫入」,撞 worker 請求上限。
## 依賴
mira 側(建專案層 .arcrun.yaml + 移全域)先行,見 mira repo `docs/HANDOFF-arcrun-config-project-scope.md`。本框架補強讓「未來不再污染全域 / AI 不再看不懂 scope」,是根治,非阻擋 mira 當前解耦。
+151
View File
@@ -0,0 +1,151 @@
# HANDOFF: Matrix 重整交棒給 arcrun2026-06-13
來源:InkStoneCo 頂層 `.agents/specs/matrix-rearrange/`。本檔是該重整交給 arcrun 的待辦清單。
**指針式考古**:整合素材真身在 InkStoneCo `_archive/`,照路徑去挖,不複製進此檔。
---
## 1. cypher-executor 整合進 arcrun 後**關掉**leo 2026-06-13:整合後只剩 arcrun
`matrix/cypher-executor` 是 diverged 副本,**整合進 arcrun/cypher-executor 後就關掉/封存**,之後 cypher 只有 arcrun 一份。
### 如何整合(勘查 2026-06-13
**A. matrix 版獨有、arcrun 缺的 5 檔 → 補進 arcrun**(皆非 SaaS 遺留,是 self-hosted 核心):
- `src/actions/version-selector.ts`:零件版本選擇策略 floating/stable/pinned
- `src/actions/autoPublishMissing.ts`missing 零件用 Workers AI 自動生成上架
- `src/lib/component-dispatcher.ts`:雙模式路由 wasm/cypher_binding/service_binding
- `src/lib/wasm-executor.ts`WASM 執行
- `src/routes/proxy.ts`proxy 路由
**B. 兩邊都有但內容不同的 10 檔 → 逐檔比對合併**(取較完整/正確的一邊,保留 arcrun 較新的 auth 演進):
`cypher-handlers` / `execution-evaluator` / `execution-logger` / `graph-builder` / `search-nodes` / `triplet-parser` / `webhook-graph-resolver` / `webhook-handlers` / `graph-executor` / `index`
**C. 保留 arcrun 獨有的**(不要被舊版覆蓋):`credential-injector` / `auth-dispatcher` / `auth-recipe-seeds` / `api-recipe-seeds`arcrun 較新的 Auth Recipe 演進)。
**素材真身**`matrix/cypher-executor/`(降級後仍在原地)。整合完成、驗證通過後,**封存 matrix/cypher-executor 進 `_archive/`cypher 之後只有 arcrun 一份**。先讀對應 SDD 再動。
## 2. KBDB 插件化 + 補 CLI/MCP 薄殼(arcrun 端只留基本盤 + 暴露能力)
`arcrun/kbdb` **留 3 表基本盤 + API**(已完整:templates/entries/records/search)。它是刻意設計的基本盤(0001_base.sql 註釋 plugin model),**不升 v3、不加 blocks 表**。triplet/graph 由 `matrix/kbdb-graph-plugin` 抽成獨立 repo KBDB-graph。
**KBDB 鐵律(leo 2026-06-14**:任何人不准動表;新類型=建 template(走 API);插件/AI/人全走 API,禁 SQL;基本盤不提供建表 API。詳見頂層 `DECISION-kbdb-v3-baseplane.md`
**arcrun 端待辦(核實:CLI/MCP 現在完全沒 KBDB 能力)**:
- **補 MCP 薄殼**AI 用,含插件):`kbdb_create_template`(name+slots)、`kbdb_create_record`(填 slot)、`kbdb_query`/`kbdb_search` 等,調基本盤現有 API。**不提供建表 tool,只給 template/slot**——類 Supabase 萬用表,AI 想建表時只有 template/slot 可用。
- **補 CLI 薄殼**(人用,後補):對應命令。
- 能力真身在基本盤 API(已有),CLI/MCP 只薄殼暴露(arcrun 薄殼原則)。
對方交棒見 `matrix/kbdb-graph-plugin/docs/HANDOFF-kbdb-plugin.md`
## 3. leo21c self-hosted 部署(Mira dogfood 用)
leo 用 leo21c CF 帳號部署 self-hosted arcrun`MULTI_TENANT=false`),Mira 改 dogfood 這套。
依現有 `scripts/local-deploy.sh` + `docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md`。namespace 明碼非 api key。
### 3b. ⚠️ MCP self-hosted 認證失敗(mira CC 2026-06-14 回報,跨專案問題)
**一句話**MCP worker 還走舊 partner-key 認證(`mcp/src/middleware/partner-auth.ts`,每個端點都掛 `partnerAuthMiddleware`),但 self-hosted 認證是 namespace 明碼。導致 Claude Code 連 self-hosted MCP 一律 401。**CLI 全通**(走 cypher-executor,已支援 `MULTI_TENANT=false`)。
**根因**MCP 的 `partner-auth.ts` 沒跟上 cypher-executor 的 self-hosted 認證改版。這是 `07-thin-shell.md §4` 已記的「MCP 帳號來源違反」的具體症狀(SDD proposal 已存在:`docs/3-specs/arcrun/sdk-and-website/mcp-account-source.md`)。
**診斷證據(curl `arcrun-mcp.leo21c.workers.dev/mcp`mira CC 實測)**
- GET /mcp → 200worker 活);MCP initialize 無 auth → 401Bearer ak_(舊 uncle6 key)→ 401 Invalid partner keyBearer leonamespace 明碼)→ 401X-Namespace header → 401
- cypher-executor / → 200CLI 走這條,通)
- `acr mcp-setup` 生成的 `.mcp.json` 是裸的(無 headers),就算有 key 也沒地方帶
**修法(三選一,arcrun 端決策)**:
1. MCP worker 加 self-hosted 認證:接受 namespace 明碼(與 cypher-executor 一致),或 self-hosted 模式 MCP 免 partner key。
2. `acr mcp-setup` 把有效認證寫進 `.mcp.json` headers(前提:self-hosted 有可用 key 機制)。
3. 對齊完成前,官方文件明說 self-hosted MCP 暫不可用、請用 CLI(避免使用者困惑)。
**影響範圍**:任何 self-hosted dogfoodmira、未來 product)都踩,非 mira 獨有。屬 arcrun 框架側待修,走 SDD 協議(對應 `mcp-account-source.md`,動工前宣告)。
**mira 現況**:全靠 acr CLI 即可推進,不卡。`.mcp.json` 留著等上游修好自動能連。
---
### 3b-2. ⚠️ 第一次端到端實測:修補 code 對,但 `MULTI_TENANT` 沒注入 MCP worker2026-06-14 晚,mira 推 leo21c + 總管核實)
mira 把 release@main 推上 leo21cdeployment 14:31version 7de919d6),**仍 401 `Invalid or expired partner key`**。總管核實了真因(不是 code bug,是部署機制 bug):
- ✅ **修補 code 在且正確**`mcp/src/middleware/partner-auth.ts:19` `if (c.env.MULTI_TENANT === 'false')` 確實擋在 partner-key 查詢之前。
- ☠️ **真因:`mcp/wrangler.toml` 的 `MULTI_TENANT = "false"` 是注釋掉的(第 13-14 行 `# [vars]` / `# MULTI_TENANT`**。部署後 worker 的 `c.env.MULTI_TENANT === undefined ≠ 'false'` → if 不成立 → 走 partner-key 查詢 → 401。
- 🔍 **更深層:self-hosted 部署(`acr init`/`acr update`)沒把 `MULTI_TENANT=false` 注入 MCP worker 的 vars**`cli/src/` grep `MULTI_TENANT` 只有 config 定義 + 註釋,**無「部署時注入 worker env」的 code**。對照:cypher-executor 通是因它把 key 當不驗證 opaque(不依賴 MULTI_TENANT);MCP 依賴此 env 才走 namespace 分支,故漏注入就斷。
- 類比:self-hosted-init.md 注入了 `WORKER_SUBDOMAIN`,但**漏注入 `MULTI_TENANT`**(同一類部署注入機制的缺口)。
**修法(arcrun 端,二選一或都做)**:
1. `acr init/update` 部署 MCP(及需要的 worker)時,依 config `multi_tenant: false` 注入 `MULTI_TENANT=false` 到 worker vars(與注入 WORKER_SUBDOMAIN 同套機制,self-hosted-init.md)。
2. 短期:文件指引 self-hosted 用戶手動 `wrangler secret put MULTI_TENANT`(或取消 mcp/wrangler.toml 那兩行註釋)——但這違反「用戶零填寫」,①才是正解。
**驗收**leo21c MCP worker 設好 MULTI_TENANT=false 後,`curl -H "Authorization: Bearer leo" .../mcp` initialize → 200(非 401)。
## 4. arcrun-gui 併入 + arcrun.dev 降官網
arcrun-gui 不再獨立,GUI 併入 arcrun repo(用戶下載即有)。arcrun.dev 降級為框架官網,移除 /mira/ 寄居(Mira 搬 mira.uncle6.me)。
## 5. arcrun-mcp → 已整進 arcrun/mcp**直接關掉**leo 2026-06-13
勘查確認(2026-06-13):`arcrun/mcp` 已是真身,**比 matrix/arcrun-mcp 多 `arcrun_recipe.ts` + `arcrun_whoami.ts`,且 matrix/arcrun-mcp 無任何 arcrun/mcp 缺的東西**`Only in arcrun-mcp` 為空)= **已全部整進去**
動作:`matrix/arcrun-mcp` **直接關掉**——封存進 `_archive/` 即可,不遷帳號(舊 repo richblack/arcrun-mcp 留歷史)。無需整合,arcrun/mcp 就是現役。
## 6. KBDB 資料層遷移:3 個框架缺口(mira CC 2026-06-14 回報 + 總管核實,擋 mira 遷移)
mira 實測 leo21c self-hosted KBDB**好消息 entries 表 block-compatible**content/entry_type/parent_id/page_name/refs_json/tags_json/task_status/metadata_json = mira block 模型),河道/wiki/triplet 可直接落 entries,不必改資料模型。但卡 3 個 arcrun 缺口:
**① 主缺口(擋遷移):cypher proxy 漏 `/kbdb/entries`** — 核實屬實。
- `cypher-executor/src/routes/kbdb-proxy.ts` 只實作 `/kbdb/templates` + `/kbdb/records`(注釋自稱含 entries,實際沒有)。基本盤 `arcrun/kbdb``/entries`index.ts:18),proxy 沒轉發。
- 結果 mira 三者湊不齊:直連 kbdb worker /entries=有 block CRUD 但裸開無隔離;cypher /kbdb/*=有認證+owner_id 隔離但無 /entries。
- **修法**:比照 `/kbdb/records` 的 owner_id 注入模式,補 `/kbdb/entries`POST/GET filters/GET :id/PATCH/DELETE)。守鐵律(只轉發 API,不開 SQL/建表)。補好 mira `_kbdb_client.py` 改走 `cypher.leo21c/kbdb/entries` + X-Arcrun-API-Key namespace 隔離 → 完成解耦。
**② MCP 的 KBDB service binding 壞** — 核實屬實。
- mcp/wrangler.toml 有 `{ binding="KBDB", service="arcrun-kbdb" }`kbdb-client 用 `env.KBDB.fetch`,但 mira 報 `Cannot read properties of undefined (reading 'fetch')` = **`env.KBDB` undefined**self-hosted 部署時 binding 沒正確建/worker 名對不上 leo21c 的 kbdb)。
- 連帶:官方回報管道 `arcrun_report_feedback`(MCP tool)也因此送不出 → 這份回報只能靠總管轉。**修這個才恢復 self-hosted 的 MCP 回報能力。**
- **修法**self-hosted 部署確保 KBDB service binding 正確指向 leo21c kbdb worker(或改 HTTP fetch via KBDB_BASE_URL,與插件同模式,避免 self-hosted service binding 名稱耦合)。
**③(非阻擋)mcp-setup namespace 不一致** — 核實屬實。
- `cli/src/commands/mcp-setup.ts:53``config.api_key`。self-hosted 若 api_key 存的是舊 ak_(非 namespace),則 MCP 與 CLI 讀寫不同分區。
- **修法**self-hosted 下 mcp-setup 優先用 NAMESPACEconfig.namespace),與 CLI 同一分區。
對應 SDDmira 端記在 mira SDD 14-A(標 🚧 待對端);arcrun 端走協議(kbdb-proxy 屬既有 SDD 範圍)。
---
## 6b. ⚠️ 部署斷層:code 已補但 leo21c 未重部署(總管本地模擬核實 2026-06-15)
總管不靠 Hetzner、從本機直接 curl leo21c 端點驗證,發現 **①② 的 code 已 commit 進 arcrun repo,但對應 worker 沒部署到 leo21c CF**
| 證據(本機 curl leo21cnamespace=leo | 結果 | 判讀 |
|---|---|---|
| `GET cypher/kbdb/templates` | 200 | cypher-executor 活著(舊版) |
| `GET cypher/kbdb/entries` | **404** | 新 route 未上線(`b1e302b``/kbdb/entries` 沒部署) |
| `GET cypher/kbdb/records` | **404** | 連既有 records proxy 都 404 → leo21c 上的 cypher 版本落後 |
| 直連 `kbdb.leo21c/entries`(裸開) | 200 | kbdb worker 本體活著,entries 表在 |
| arcrun repo `kbdb-proxy.ts` | 含完整 `/kbdb/entries` CRUD(行 144-184 | **source 正確,純粹是沒 deploy** |
**結論**:缺口①②的 code 修正屬實(commit `b1e302b` `/kbdb/entries` + `1af7655` KBDB service binding),但**卡在「部署到 leo21c」這一步**。在 leo21c cypher-executor 重新部署前,mira `_kbdb_client.py` 即使改好也 smoke 必 404。
**交棒 taskarcrun CC,依協議走既有 SDD + 鐵律「部署繞開 GitHub、wrangler 直推 CF」)**
1. 確認 leo21c 帳號(`CLOUDFLARE_API_TOKEN` 指 leo21c `51a01bfa…`)下 cypher-executor 是哪個版本、為何落後(`acr update` 漏部署 cypher?還是只部署了部分 worker?)。
2. 重新部署 cypher-executor+ 確認 mcp worker 的 KBDB service binding 一併上線,缺口②)到 leo21c。
3. 自驗:`curl -H 'X-Arcrun-API-Key: leo' https://arcrun-cypher-executor.leo21c.workers.dev/kbdb/entries?limit=1` 應回 200(非 404)。回 200 才算缺口①真正清空,mira 14-A 才解鎖。
**接力鏈**arcrun 部署 cypher6b)→ 端點 200 → mira 改 `_kbdb_client.py`14A.1)→ smoke 讀寫 leo21c → 解耦完成。
### 6b-解決(arcrun CC 2026-06-15,已部署 + 自驗 200
**根因不是 GitHub lag**`origin/main` == 本地 4d6e77f,含 `/kbdb/entries` route)。兩層真因:
1. **`acr update` 的 content-hash manifest 跳過機制**`cli/src/lib/deploy.ts:198-225`)把 cypher 當「未變動」跳過 → 落後。解:`acr update --force` 清空 manifest 強制全部重部。
2. **`.env` line 3 的 `CLOUDFLARE_ACCOUNT_ID=58309bb9…`(官方帳號)被 CLI 載入並覆蓋 config.yaml 的 leo21c `51a01bfa…`**env > 全域 config`config.ts:174`)→ leo21c token 對官方帳號認證 → KV 解析「Authentication error」→ update 中止。解:部署時 `CLOUDFLARE_ACCOUNT_ID=51a01bfa… node cli/dist/index.js update --force` 強制 account 對齊 leo21c token。
- ⚠️ **遺留陷阱**repo `.env` 是「官方帳號」部署脈絡用的;對 leo21c self-hosted 部署必須覆蓋 `CLOUDFLARE_ACCOUNT_ID`,否則 leo21c token vs 官方 account 不匹配。見記憶 [[cf-account-official-vs-loadtest]]。
**部署結果**23/23 worker 全部 ✓(含 cypher-executor / kbdb / mcp),seed ✓(10 API + 23 auth recipe),cron index migrate ✓。用本地 build CLI 1.3.12(全域 acr 仍 1.3.11,未 npm publish)。
**自驗(本機 curl leo21cnamespace=leo**
- `GET /kbdb/entries?limit=1`**200** `{"success":true,"entries":[],"count":0}`(真轉發 kbdb worker,非假綠)✅ ← 缺口①清空,**mira 14-A 解鎖**
- `GET /kbdb/templates` → 200 ✅
- `GET /kbdb/records?limit=1`**404(非回歸,by design**proxy 只有 `POST /records``GET /records/by-template/:t``GET /records/:id`**本就無 bare list route**。HANDOFF 原以 records 404 當「cypher 舊版」訊號,但該路由從未存在。
- 缺口②:MCP `initialize``Bearer leo`)→ **200**(非 401`MULTI_TENANT=false` 已注入,KBDB binding 隨 mcp worker 上線)✅
---
> 每項動工前依 `.claude/rules/00-sdd-protocol.md` 宣告已讀 SDD。本 HANDOFF 是「有哪些事」,不是「繞過 SDD 的捷徑」。
+120
View File
@@ -0,0 +1,120 @@
# 交辦文件:修復 Mira 的 arcrun workflow(給 Mira 的 CC
> 建立:2026-06-03(由 arcrun 端的 CC 撰寫)
> 對象:接手修復 mira 的 CC
> 位置:`/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/polaris/mira/arcrun/*.yaml`
>
> **這份文件已把調查做完**:每個 workflow 用的 component 都對照過 arcrun prod 現況,
> 標清楚「哪些不用改、哪些要改、哪些要降級」。你照著改即可,不必重跑調查。
---
## 0. 為什麼 mira 壞了(一句話)
arcrun 做了大整修:把「打固定 endpoint 卻被做成零件」的**假零件降級成 recipe + 刪掉零件目錄**
33 → 22 個零件,DECISIONS §1:零件 = endpoint 薄殼,打固定 API 的是 recipe 不是零件)。
mira 當初**自己把一堆東西錯做成假零件**,整修後那些零件名的解析方式變了,所以 mira workflow 斷了。
好消息:**arcrun prodcypher.arcrun.dev)活著、降級後的 recipe 都在 KV**(已驗證),
mira 大部分 workflow 只需小改,不需重寫。
---
## 1. 前提(已驗證,你不用重查)
- ✅ cypher-executor prod 活著(`cypher.arcrun.dev`
- ✅ 降級 recipe 在 prod KV`kbdb_get` / `kbdb_create_block` / `kbdb_patch_block` /
`kbdb_ingest` / `kbdb_delete` / `telegram_send` / `gmail_send` / `google_sheets_*` / `line_notify_send`
- mira 跑在你(richblack)的 prod 帳號上,**不依賴 self-host installer**(那是給外部工程師的另一條線)
---
## 2. 逐 component 分類(mira 6 個 workflow 全部掃過)
| component | 狀態 | 動作 |
|---|---|---|
| `kbdb_get` | ✅ prod recipe 存在 | **不用改**`component: kbdb_get` 走解析鏈 step 6 查 `recipe:kbdb_get`,正確)|
| `kbdb_create_block` | ✅ prod recipe 存在 | **不用改** |
| `kbdb_patch_block` | ✅ prod recipe 存在 | **不用改** |
| `cron` | ✅ 引擎能力 | **不用改** |
| `http_request` | ✅ primitive | **不用改** |
| `if_control` / `set` / `filter` | ✅ primitive | **不用改** |
| `trigger_workflow` | ✅ 平台 orchestration | **不用改** |
| `comp_passthrough` | ✅ 引擎內建純函式(constants.ts:43 `(ctx)=>ctx`| **不用改** |
| **`telegram`** | ⚠️ prod **沒有** `telegram` recipe,只有 `telegram_send` | **要改**`component: telegram``component: telegram_send`(見 §3|
| **`claude_api`** | ❌ 錯做成零件(非薄殼)| **要降級**(見 §4|
| **`kbdb_upsert_block`** | ❌ 錯做成零件(非薄殼)| **要降級**(見 §4|
---
## 3. 要改:`telegram``telegram_send`
**影響的檔**4 個用到 `component: telegram`):
- `project_detector.yaml`
- `agent_feedback_weekly_review.yaml`
- `wiki_synthesis.yaml`
- `wiki_giveup_scanner.yaml`
**改法**:把 `component: telegram` 改成 `component: telegram_send`
⚠️ **不只是改名**——`telegram_send` recipe 的介面:endpoint 是
`https://api.telegram.org/bot{{auth.bot_token}}/sendMessage`body 帶 `chat_id` + `text`
auth 走 `auth_service: telegram`static_key path 注入)。確認 mira 的節點 config 傳的是
`chat_id` / `text`,且有設好 telegram 的 credential`acr creds push`)。
---
## 4. 要降級:`claude_api` / `kbdb_upsert_block`(錯做成零件)
> ⚠️ **這兩個不是「待刪」,是「做錯了」**richblack 2026-06-03 定性):
> 它們**不是 endpoint 薄殼,是把工作流硬塞進零件**(違反 DECISIONS §1)。
> arcrun 已把這兩個 wasm 排除在 self-host 部署來源外(不 commit 進 repo)。
> mira 要把它們**還原成本來該有的樣子**:工作流 / recipe。
### 4.1 `claude_api`(影響:agent_feedback_weekly_review / project_detector / wiki_synthesis
**問題**:arcrun 是「AI 呼叫的工具」,**工作流裡不該有零件回頭呼叫 LLM**(mindset §2 /
DECISIONSn8n 需要 AI 節點是因為它沒大腦,arcrun 的大腦就是操盤的 CC)。
`claude_api` 把「呼叫 Claude」做成零件,方向就錯了。
**正解**mira 要自己設計,arcrun 端只給方向):
- 需要 AI 判斷/轉換的步驟,應該是**操盤的 CC(mira 自己)做**,再呼叫 workflow 做確定性的下一步。
- 若真的需要在 workflow 內打 Claude API(例如非同步 cron 場景無 CC 在場),那它是**打一個固定外部
endpointapi.anthropic.com= recipe**,不是零件 → 建一個 `claude_api` 的 **API recipe**
http_request + endpoint + auth_service),不是零件目錄。
- 哪條對,mira 的 CC 要依 mira 的實際場景判斷(這是 mira 的設計決策,不是 arcrun 能替決的)。
### 4.2 `kbdb_upsert_block`(影響:agent_feedback_weekly_review / wiki_synthesis
**問題**upsert 的邏輯(找到則 PATCH、沒找到則 POST)被整段塞進零件。
按 DECISIONS §1upsert 應該是 **KBDB API 那邊提供的 endpoint**,零件/recipe 只該「驅動它」。
**正解(兩條,mira/KBDB 端決定)**
- **首選**KBDB API 出一個 `POST /blocks/upsert` endpointrichblack 已交 KBDB feature request
→ 然後在 arcrun 建一個 `kbdb_upsert` **recipe**(打那個 endpoint),mira workflow 用 recipe。
- **過渡**:若 KBDB 還沒出 upsert endpoint,把「GET 找 → 有則 kbdb_patch_block、無則 kbdb_create_block」
這段**用 workflow 表達**mira workflow 層用 if/branch 串現有的 kbdb_get + patch + create recipe),
而不是塞進一個零件。
---
## 5. 修復順序建議
1. **先改 telegram**(§3)——機械改名 + 確認介面,最快,4 個檔。
2. **驗證 kbdb_* / 其他不用改的 workflow 真的跑通**——`acr run <workflow>` 或 trigger
確認 §2 標「不用改」的真的 2xx(誠實驗證,不假設)。
3. **再處理 claude_api / kbdb_upsert_block 降級**(§4)——這兩個要 mira 依場景做設計決策,較花時間。
## 6. 驗收(客觀證據,mindset §7)
- 改完的 workflow `acr run` / trigger → HTTP 2xx + execution trace 證明跑通,不是口頭宣布。
- claude_api / kbdb_upsert_block 降級後:workflow 不再引用這兩個零件名(grep 確認)。
- 缺 credential 打不通就誠實標「未驗收:缺 X」,不 mock 充綠燈。
---
## 7. 參考
- arcrun 決策:`matrix/arcrun/DECISIONS.md`(§1 零件 vs recipe、§3b credential
- arcrun mindset`matrix/arcrun/.claude/rules/06-mindset.md`(§1 工作流是 default、§2 AI→工具)
- arcrun 故障/整修脈絡:`matrix/arcrun/docs/HANDOFF-self-host-harness.md`
+212
View File
@@ -0,0 +1,212 @@
# 交辦文件:完成 arcrun self-hosted harness(給接手的 CC
> 建立:2026-06-01(由前一個 CC 調查後撰寫)
> 對象:接手的外部 CC
> 目的:把 arcrun 補到「任何 CC 在自己的 CF 帳號上 self-host 後就能順暢開發、且不可能重蹈 mira 的錯」的程度。
>
> **先讀**`DECISIONS.md`(穩定決策)、`.claude/rules/06-mindset.md`mindset)、`BACKLOG.md`(流動待辦)。
> 本文件不取代它們,只是把「今天要做的三件事」連同已查證的實況整理好,讓你不用重跑調查。
---
## 0. 戰法已轉變(最重要的背景)
richblack 2026-06-01 決定:**從 SaaS 改成 self-hosted 開源策略。**
這直接改變 harness 的成功定義:
- **舊定義**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑。
- **新定義**:**任何 CC 在自己的 CF 帳號上 `acr init --self-hosted` 後就能跑通一個含 recipe 的 workflow,而且寫錯時會被程式擋住。**
richblack 會用另一個 CF 帳號實測 self-host。所以「self-hosted 一鍵起得來」從「第一期重要但非阻擋項」**升為今日第一優先**。
### arcrun 現在的核心心智(richblack 2026-06-01 校準,比 DECISIONS §1 更硬)
- 核心**零件數量少、由 richblack 維護、不接受 CC 自製**(可投稿 PR,人 merge = 閘門)。
- 其他人做的一律是 **recipe**= http_request + 一組 YAML 設定,不用 deploy)。
- arcrun 是**一套給 CC 的 harness**:事前提醒 CC 能用什麼 / 不能做什麼,事後用程式擋住讓它**無法犯錯**。
- **你不用管 mira。** mira 是錯誤做法的源頭(見 §1),它自己會修。你的目標是讓**任何** CC 都能用,且絕不會發生 mira 的錯。
---
## 1. mira 故障 = 症狀樣本(已定位,不用你修)
mira`/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/polaris/mira/arcrun/*.yaml`)的 workflow 寫 `component: kbdb_get` / `claude_api` / `telegram` 等。這些是 **mira 當初自己錯做的「假零件」**DECISIONS §1 判準:打固定 endpoint 的東西是假零件,該是 recipe)。
本次整修(BACKLOG 步驟3)已把這些假零件**降級成 recipe + 刪掉零件目錄**registry/components 從 33 → 22)。所以 mira 斷了。
**這證明的事**mira 的錯,正是當時 harness 沒擋住的漏洞。零件刪了,但 harness 還缺「**事前告訴 CC 別這樣做 + 事後擋住 CC 這樣做**」的完整機制 → 下一個 CC 還會犯同樣的錯。**這就是你要補的(§3 task 2)。**
---
## 2. 已查證的實況(你不用重查,2026-06-01 實打 prod
### 2.1 降級後的 recipe 鏈路是「活的」✅
實打 `https://cypher.arcrun.dev/recipes`richblack prod)確認以下 recipe 都在 KV
| canonical_id | hash | endpoint | auth_service |
|---|---|---|---|
| `kbdb_get` | rec_4c7dcf9b | `https://kbdb.finally.click{{_path}}` | kbdb |
| `gmail_send` | rec_cd426129 | gmail.googleapis.com/.../send | google_gmail_sa |
| `google_sheets_append` | rec_9fd1b662 | sheets.googleapis.com{{_path}} | google_sheets_sa |
**「對的用法」(`component: kbdb_get` 走解析鏈 step 6 查 `recipe:kbdb_get`)本身是通的。** 不需要重建 recipe。
> 注意:這是 richblack 的 prod KV。**self-host 的新帳號 KV 是空的**,需要 seed 這些 recipe(見 §3 task 1 的 seed 步驟)。
### 2.2 component-loader 解析鏈(`cypher-executor/src/lib/component-loader.ts`
`resolveComponent` 依序嘗試 8 層(行號近似,以實際檔案為準):
```
0. 平台 orchestration 零件(trigger_workflow line ~88
1. 內建零件(純 JS) line ~96
2. 外部 URLhttp(s)://... line ~100
3. cmp_hash → WEBHOOKS KV idx → 邏輯 Worker line ~105
4. rec_hash → RECIPES KV idx → recipe 執行 line ~115
5. 邏輯零件 canonical_id → Service Binding (SVC_*) line ~122
5.5 auth recipe (auth_recipe:{service}) line ~127
6. KV recipe canonical_id → RECIPES KV → fetch 外部 API line ~130 ← kbdb_get 等降級 recipe 命中這層
7. WASM HTTP runner(白名單 WASM_HTTP_RUNNER_IDS line ~134
8. 找不到 → 報錯 line ~142
```
`WASM_HTTP_RUNNER_IDS` 白名單(line ~36)現只剩:`http_request` / `cron` / 4 個 `auth_*` primitive。
`claude_api``kbdb_upsert_block`BACKLOG 標 deferred、源碼暫留)**不在白名單也沒 recipe** → 用到它們的 workflow 會落到 step 8 報錯。這是 mira 自己的問題,不在你範圍。
### 2.3 `acr init --self-hosted` 現況:純手動問答,差很遠
`cli/src/commands/init.ts``initSelfHosted()`line 105-131**只是問 6 個問題後寫進 config**:
要求 CC 自己**事先**部署好 Worker、建好 KV、再手填 Account ID / cypher URL / 兩個 KV namespace ID / WASM bucket / CF token。
BACKLOG 步驟7 要的是「**貼 CF token → 自動建 KV、部署 Worker、自動 workers.dev、寫回 config**」。**這是最大缺口,task 1 的主體。**
config 讀取端已支援 self-hosted`cli/src/lib/config.ts:52` 已能用 `cypher_executor_url`),所以你只要把「自動部署」這段補上,config/執行端不用動。
### 2.4 CI/CD 已是通用掃描式(可重用於 self-host 部署)
`.github/workflows/deploy.yml` 掃所有含 `wrangler.toml` 的目錄自動部署(見 `.claude/rules/05-deploy-convention.md`)。
self-host 自動部署可以參考同一套掃描邏輯(`find . -name wrangler.toml`),對每個目錄跑 `wrangler deploy`
---
## 3. 今天要做的三件事(按序,全在 harness 主線)
> richblack 指示:「全部要做」(含 `acr init --self-hosted`)。
> 三件都做完 = 今天可交付:外部 CC 能 self-host 起來、用對的方式開發、犯錯被擋。
### 🔴 Task 1:完成 `acr init --self-hosted` 一鍵自動化(第一優先)
> ✅ **實作狀態(2026-06-02,已大致完成)**:定稿形態為 **installer 模式**richblack 拍板)——
> 用戶只做:申請 CF 帳號 → 裝 wrangler → 裝 acr → `acr init --self-hosted`(貼 token),其餘自動。
> 已實作(typecheck 過):`cli/src/lib/api-recipe-seeds.ts`10 recipe 種子)、`cf-api.ts`
> `CfAccountClient`(建 KV/R2/查 subdomain/驗 token)、`deploy.ts`(常數 + downloadAndDeploy)、
> `initSelfHosted()` 改寫、`acr update``cypher-executor/scripts/seed-api-recipes.ts`
> **唯一剩餘前置(13.6**:repo 沒有含預編譯 wasm 的 GitHub release.wasm 不 commitrule 05)→
> `downloadAndDeploy()` 目前**誠實回 implemented:false 不假裝部署**mindset §7)。建 KV/R2/seed/config
> 已可跑;release 產製管道補上後部署即自動化。定稿設計見 SDD `self-hosted-init.md`(含 §6 前置依賴)。
> **以下原始子步驟保留供對照**KBDB recipe 採 Supabase 模式進 seedrichblack 2026-06-02)。
**目標**CC 只需提供「CF Account ID + CF API Token」,CLI 自動完成其餘一切。
**SDD**:定稿 `docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md`installer 模式,已與 richblack 對齊)。
**子步驟**
1. 改 `cli/src/commands/init.ts``initSelfHosted()`
- 收 CF Account ID + CF API Token(要 KV Edit + Workers Scripts Edit + R2 權限)。
- 用 CF API(或 shell out `wrangler`**自動建 7 個 KV namespace**WEBHOOKS / CREDENTIALS_KV / RECIPES / USERS_KV / SESSIONS_KV / ANALYTICS_KV / EXEC_CONTEXT(清單見 `.claude/rules/01-tech-stack.md` 資料儲存表)+ R2 WASM_BUCKET。
- **自動部署所有 Worker**cypher-executor + registry + 22 個 `.component-builds/*`。可重用 §2.4 的 `wrangler.toml` 掃描。每個 worker 的 `wrangler.toml` 已含 `workers_dev = true`BACKLOG 步驟 P1#2 已做),部署後 workers.dev URL 自動啟用。
- **把 cypher-executor 的 `[vars] WORKER_SUBDOMAIN` 改成 CC 自己的帳號 subdomain**self-host 關鍵,見 P0 #9cypher-executor 走 `arcrun-{name}.{subdomain}.workers.dev` 對內 URL)。
- **seed 降級 recipe + auth recipe 進 RECIPES KV**:新帳號 KV 是空的。把 §2.1 那些 recipekbdb_get/gmail_send/...+ auth recipe seed 寫進去。auth recipe seed 已有 `cypher-executor/scripts/seed-auth-recipes.ts`API recipe 需確認有對應 seed 機制(routes/recipes.ts 是動態 push,可能要寫一份 seed 腳本或用 `acr recipe push`)。
- 寫回 config(現有欄位已足夠)。
2. **runtime secret 不進 CLI 自動化**`ENCRYPTION_KEY` 等由 CC 自己 `wrangler secret put`rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。
**驗收(客觀證據,不是口頭宣布 — mindset §7)**
- richblack 用全新 CF 帳號跑 `acr init --self-hosted` → 全程無手動建 KV / 部署。
- 跑完後 `acr push` 一個含 `component: kbdb_get`(或 http_request + 自建 recipe)的 workflow → trigger → HTTP 2xx + execution trace 證明跑通。
### 🔴 Task 2(已重定義 2026-06-01):封鎖自製零件 + recipe 入庫把關
> ✅ **實作狀態(2026-06-02,第一期部分完成)**:(1) 封鎖自製零件 = 靠 GitHub PR 人 merge,無需新做
> (矛盾已釐清)。(2a) 資料外流提醒 = **既有實作已涵蓋**recipe.ts `obtainExposureConsent` + exposure-warning.ts
> 非 TTY 拒絕)。(2b) 打通檢查 = **新增** `probeRecipeEndpoint`recipe.tstypecheck 過):push 後實打
> endpoint,提醒級不硬擋,含 {{模板}} 誠實說明待 run 才知,401/403 標「多半缺 credential 非 bug」。
> 公共庫 relay 檢核(--public= 第一期後。SDD `recipe-push-gatekeeping.md` + tasks.md W2。
> ⚠️ **方向修正(richblack 2026-06-01**:原 Task 2「acr validate 擋假零件名」**作廢**。
> 理由:自製/修改零件的路已封鎖(CC 造不出零件)→「擋假零件」這件事不存在;workflow 引用
> recipe`component: kbdb_get`)是合法且未來唯一的擴充方式,不該被當假零件擋。
> 把關點**從 workflow validate 移到 recipe 入庫(push)那一刻**。
> 已動的 yaml-parser.tsLEGAL_PRIMITIVES / findSuspectComponents**已回退**。
**新目標**
1. **封鎖自製零件**:靠「零件投稿走 GitHub PR + 人 merge」天然閘門(DECISIONS §8)。零件數量少、
絕大多數是 recipe → 不為零件 PR 蓋自動化把關(量少,人工檢查即可;爆量才回頭想自動化)。
2. **recipe 入庫把關**(CC 唯一能擴充的是 recipe,一律用 push,自有庫/公共庫同一套指令):
- **自有庫(self-hosted= 提醒級**:(a) 資料外流提醒——會讓資料/服務對外可見的動作需人類明示同意;
(b) 打通檢查——push 時實打 endpoint 回報 2xx 與否(誠實標原因,不假綠,不硬擋)。
- **公共庫 = 維護者 relay 檢核**(實際打通、真收到成功回傳)— 第一期後。
**SDD**:已寫 design 給 richblack review →
`docs/3-specs/component-gatekeeping/recipe-push-gatekeeping.md`+ tasks.md W2 節)。**review 通過才動 code。**
**動到的檔案(待 review**`cli/src/commands/recipe.ts`(push 加提醒 + 打通檢查)、確認 data-exfil hook 涵蓋 recipe push 路徑。
**驗收**
- `acr recipe push` 會產對外 webhook 的東西 → 印資料外流警示 + 要人類同意;非 TTY → 拒絕。
- `acr recipe push` endpoint 可達的 recipe → 回報「✓ HTTP 2xx」。
- `acr recipe push` 缺 credential → 回報「⚠️ 未打通:缺 credential」(誠實),仍允許 push。
- workflow 引用 recipe`component: kbdb_get`**不被任何 validate 步驟當假零件擋**。
### 🔴 Task 3README 重寫成單一路徑 — harness「事前提醒」
**目標**self-hosted 開源後,README 是外界 CC 唯一入口。砍掉「玩法一/二/三」三選一,講清楚單一正確路徑。
**子步驟**(改根 `README.md`):
1. 砍三選一玩法,留**一條路**`acr init --self-hosted` → 寫 workflowprimitive 串 + recipe)→ `acr push` → trigger。
2. 明示心智(呼應 mindset §1):「零件就這固定幾個由我們維護、不接受自製;要打外部 API 就寫 recipe;要編排就寫工作流。」
3. 連到 `.claude/rules/06-mindset.md` / arcrun-mindset Skill,讓 CC 一開始就有正確世界觀。
**驗收**README 讀完,一個沒看過 arcrun 的 CC 知道:能用什麼、不能自製零件、打外部 API 要寫 recipe、怎麼 self-host 起來。
---
## 4. 今天「不要做」的(避免你走偏)
| 項目 | 為何不做 |
|---|---|
| 修 mira | richblack 明示不用管,mira 自己修 |
| 步驟2 `acr recipe test` / relay / credits | DECISIONS §3c 明確劃為服務側、非第一期 |
| 步驟6 搬家拆 matrix | 純 repo 整理,不影響 CC 能否用 |
| 砍 `injectCredentials` 舊路 / `BUILTIN_CREDENTIALS_MAP` | 獨立清理,不擋交付(DECISIONS §3b / BACKLOG「第一期之後」)|
| 新 primitive / Gherkin 真跑 / 入站認證 | richblack 已標「不要現在做」 |
---
## 5. 鐵律提醒(違反會被 hook block)
- 任何 code 變動前先讀對應 SDD + 在回覆開頭宣告(`.claude/rules/00-sdd-protocol.md`)。
- `registry/components/` 下禁止 TScypher-executor TS 禁止 credential/auth/JWT 業務邏輯(`.claude/rules/02-forbidden.md`hook 強制)。
- 每完成一個 task 立刻更新對應 tasks.md / BACKLOG.md 的 `[x]`,不批次。
- 誠實限制(mindset §7):stub / 未完成就標 unimplemented**不假綠**;完成 = 客觀證據(exit code / HTTP status + trace),不是口頭宣布。
---
## 6. 交棒:CLAUDE.block.md / README 表達優化(InkStoneCo 總管 2026-06-15
**背景**:總管與 leo 釐清 Mira 自製零件衝突時,實測一個現象——**連 Opus 讀著 `cli/harness/CLAUDE.block.md` + rules/,都把 recipe 誤解了四輪**(以為 recipe 是私有腳本、以為缺能力可用工作流拼裝補)。leo 的標準是「**Haiku 都能輕易理解**」。block 是被 `acr install-harness` 裝進各子 repo(含 mira)的**唯一入口**,細節雖在 `rules/` 但子 repo CC 預設只看 block → block 不夠清楚 = 下一個 CC 還會誤解。**這是表達缺口,不是讀者問題。**
**請 arcrun CC 做(改 `cli/harness/CLAUDE.block.md`,順帶對齊 §3 README)**:把現有「不要自製零件 / 打外部 API 寫 recipe」兩句,補成「該做什麼 + 做完去哪 + 已違規的怎麼退場」。三個已核實的盲點要講清楚(論證在下,成品文案你按自己 rules 寫):
1. **recipe 是公共資產,不是私人腳本**`acr recipe push` = 投稿公共庫(`DEFAULT_PUBLIC_LIBRARY_URL`)。公私是**屬性**(UUID 身份模型,`cypher-executor/src/routes/recipes.ts` §7.5.5:身份=uuid、公私/author 是屬性、多作者版本並存),不是兩條路。**「發現缺某 stock recipe → 自己做一個 push 上去」是被鼓勵的,不必問人。** 現 block 只寫「寫 recipe」「不用部署」→ 讀者誤以為私有自用、不上傳(正好反了)。
2. **缺能力 → 補 API,不准用 recipe/工作流拼裝**。這條 rules 其實寫得很對(07-thin-shell §1 upsert 正反例、02-forbidden §5.2、01 §8),但 **block 完全沒提** → 子 repo CC 看不到。請把「缺單一 API 能力 = 去補 API endpoint,不拼裝」這條判準帶進 block(口訣:「換個介面要不要重寫?要→是能力該在 API」)。
3. **零件的退場路徑空白**。現 block 只說「不要自製零件」,沒說「**已經有自製零件的(如 mira 的 6 個)該怎麼辦**」。請補:已自製零件 → 映射到 stock(`claude_api`→自己做 AI 判斷別放工作流,mindset §2;`kbdb_*`→走已備的 `acr kbdb` 薄殼/MCP)或改寫成 recipe;無 stock 對應的單 API 缺口 → 寫 recipe 投稿。
**驗收(leo 標準)**:改完後,一個 **Haiku** 級 CC 讀 block 就能答對:recipe 要不要投稿(要)、缺 upsert 怎麼辦(補 API 不拼裝)、已有自製零件怎麼退場(映射 stock / 改 recipe)。
**連帶(非 arcrun 範圍,總管已記)**:「真正可複用的多步驟業務流程能否投稿共享」是 wishlist C6InkStoneCo 頂層),與「拼裝補 API 缺口」是兩回事(C6 紅線已劃清),arcrun 不必現在處理。
> 注:本交棒由總管寫**判準與論證**,**成品文案 + hook(若要)由 arcrun CC 按自己 rules 產出**。總管不直接改 arcrun 實作檔(職責邊界 D9)。
@@ -0,0 +1,340 @@
# arcrun.dev Pages 規格
> **讀者**CC(可直接照做)
> **部署**Cloudflare Pages + Workers
> **語言**:英文為主,中文切換
> **技術棧**Astro(靜態生成)+ Cloudflare Pages + D1(使用統計)
---
## 0. 這個 Pages 的三個角色
1. **門面**:第一次看到 arcrun 的人,30 秒內要懂「這是什麼、對我有什麼用」
2. **轉換漏斗**:工程師 → 試用 lib → 申請 API Key;小白 → 看榮譽牆 → 問 AI 能不能用
3. **社群磁鐵**:榮譽牆讓工程師有動機貢獻 recipe,貢獻越多服務越多,用戶越多
---
## 1. 網站結構(五個頁面)
```
arcrun.dev/
├── / 首頁(門面 + 轉換)
├── /docs 用法文件
├── /integrations 榮譽牆(服務目錄)
├── /api Swagger UI(原始 API
└── /changelog 版本記錄
```
---
## 2. 首頁(/
### 2.1 Hero Section
**英文**
```
Stop fighting OAuth.
One API key. Every service. Works anywhere.
arcrun handles Google, Notion, GitHub, Slack authentication
so your Python / JS code doesn't have to.
[Get API Key — Free] [View on GitHub]
```
**中文切換後**
```
不要再跟 OAuth 搏鬥了。
一個 API Key,接通所有服務,在哪跑都行。
[免費取得 API Key] [查看 GitHub]
```
語言切換按鈕放右上角,用 `?lang=zh` query paramCloudflare Worker 記住偏好存 cookie。
### 2.2 三行說清楚(Why arcrun
```
┌────────────────────┬────────────────────┬────────────────────┐
│ Before │ │ After │
│ │ │ │
│ 40 行 OAuth 程式 │ →→→ │ 1 行 │
│ GCP Console 設定 │ │ arcrun.auth.bind │
│ debug 兩天 │ │ ("google_drive") │
└────────────────────┴────────────────────┴────────────────────┘
```
### 2.3 Code Demo(互動式 tab
三個 tab 切換:Python / JavaScript / HTTP(給 n8n 小白)
**Python tab**
```python
pip install arcrun-auth
from arcrun import auth
# 就這樣,Google Drive 認證完成
drive = auth.bind("google_drive")
resp = drive.get("/files")
```
**JavaScript tab**
```javascript
npm install arcrun-auth
import { auth } from 'arcrun-auth'
const drive = await auth.bind('google_drive')
const resp = await drive.get('/files')
```
**HTTP tab(給 n8n 用戶)**
```
POST https://api.arcrun.dev/v1/auth/bind
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"service": "google_drive",
"secret": "{{ $env.GOOGLE_SA_JSON }}"
}
```
下方加一行小字:「n8n 用戶:用 HTTP Request 節點貼上這段,不需要安裝任何東西」
### 2.4 數字牆(social proof
```
127 個認證服務 1,247,832 次呼叫 89 位貢獻者
```
這三個數字從 D1 即時讀,每小時更新一次(Cloudflare KV cache)。
### 2.5 CTA
```
[免費取得 API Key]
註冊後立即可用,不需要信用卡
```
---
## 3. 榮譽牆(/integrations)★ 核心頁面
### 3.1 頁面頂部
```
127 個已驗證的認證服務
由社群工程師貢獻並測試,每個 recipe 都有真實使用數據
[搜尋服務...] [全部] [AI] [Google] [社群媒體] [生產力] [台灣]
```
### 3.2 服務卡片
每個 recipe 一張卡:
```
┌──────────────────────────────────────────┐
│ [圖示] Google Drive ★ 官方 │
│ │
│ 認證方式:Service Account │
│ 貢獻者:@richblack ──→ GitHub profile │
│ 驗證日期:2026-03-15 │
│ │
│ 使用次數:██████████ 12,847 次 │
│ │
│ [查看 Recipe] [複製 Python 範例] │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ [圖示] OpenRouter │
│ │
│ 認證方式:API Key (Header) │
│ 貢獻者:@some_engineer ──→ GitHub │
│ 驗證日期:2026-04-01 │
│ │
│ 使用次數:██░░░░░░░░ 89 次 │
│ │
│ [查看 Recipe] [複製 Python 範例] │
└──────────────────────────────────────────┘
```
badge 規則:
- `★ 官方`arcrun 團隊維護
- `✓ 社群驗證`:100+ 次使用 + 30 天無錯誤回報
- `🆕 新加入`30 天內合併的 PR
### 3.3 貢獻者排行(頁面底部)
```
Top Contributors
🥇 @some_engineer 23 個 recipe 89,234 次呼叫
🥈 @another_dev 15 個 recipe 45,123 次呼叫
🥉 @third_person 8 個 recipe 12,456 次呼叫
...
[我也要貢獻 →] (連到 CONTRIBUTING.md
```
### 3.4 「我要貢獻」的 CTA
```
找不到你要的服務?
大部分 API Key 類的服務,填一份 YAML 就能加進來。
把 API 文件丟給 AI,五分鐘生成,開 PR 送出。
[查看 Recipe 格式] [開始貢獻]
```
---
## 4. 用法文件(/docs
### 結構
```
快速開始
├── 取得 API Key
├── Python 安裝與第一個範例
├── JavaScript 安裝與第一個範例
└── 直接用 HTTP(n8n / 任何工具)
認證方式
├── API Key 類服務
├── OAuth2 類服務
├── Google Service Account
└── mTLS
進階用法
├── 多帳號(multi-instance
├── 只取 tokenescape hatch
└── 錯誤處理
貢獻 Recipe
├── Recipe YAML 格式說明
├── 讓 AI 幫你寫 Recipe
└── 提交流程
```
### 「讓 AI 幫你寫 Recipe」這一節特別重要
```markdown
## 讓 AI 幫你寫 Recipe
把下面這段丟給 Claude / ChatGPT
再把目標服務的 API 文件一起貼進去:
---
請根據以下 API 文件,
生成一份符合 arcrun recipe schema 的 YAML。
Schema 文件:https://arcrun.dev/docs/recipe-schema
目標服務:[貼上 API 文件]
---
AI 生成後,你只需要:
1. 把 YAML 存成 recipes/community/服務名.yaml
2. 跑 `acr recipe test 服務名.yaml`
3. 開 PR
通常整個過程不超過十分鐘。
```
這一節讓「貢獻門檻」從「工程師才能做」變成「任何人叫 AI 做」。
---
## 5. API 文件(/api
直接嵌入 Swagger UI,連到 `https://api.arcrun.dev/swagger.json`
頁面頂部加一行說明:
```
這是 arcrun 的原始 API。
Python / JS lib 是它的包裝,
任何能發 HTTP request 的工具都能直接用。
```
這一句話讓 n8n 用戶、Make 用戶、甚至 Excel 用戶都知道「我也能用」。
---
## 6. 技術實作
### 6.1 技術選型
**Astro**(靜態生成)是首選,原因:
- 頁面大部分是靜態內容(docs / recipe 卡片),Astro 的 SSG 完美對應
- 動態數字(使用次數、貢獻者排行)用 Astro 的 `client:load` island 局部更新
- 部署到 Cloudflare Pages 零配置
**不用 Next.js**,因為你已在 Cloudflare 生態,Next.js 的 SSR 在 CF Pages 有摩擦。Astro + CF Pages 是更自然的組合。
### 6.2 資料來源
| 資料 | 來源 | 更新頻率 |
|---|---|---|
| Recipe 清單、metadata | GitHub repo `recipes/` 目錄 | CI merge 時觸發 rebuild |
| 使用次數 | Cloudflare D1API call log | 每小時從 D1 聚合 → KV cache |
| 貢獻者排行 | 同上 | 每小時 |
| 總呼叫次數 | 同上 | 每小時 |
### 6.3 多語言
用 Astro 的 i18n routing
- `/` → 英文
- `/zh/` → 中文
語言切換按鈕寫入 cookie `arcrun_lang`CF Worker 在 edge 讀 cookie 做 redirect。
不用 JS framework 的 i18n library,保持輕量。
### 6.4 部署流程
```
GitHub push to main
→ GitHub Actions 跑 astro build
→ 產出 dist/
→ 自動部署到 Cloudflare Pages
→ Pages 掛 arcrun.dev domain
```
recipe YAML 有變動時(PR merge)額外觸發一次 rebuild。
---
## 7. CC 的實作任務
### Phase 1:靜態骨架(3-5 天)
- [ ] Astro 專案初始化,設定 CF Pages 部署
- [ ] 首頁 Hero + Code Demo tab(靜態版,數字先寫死)
- [ ] `/integrations` 靜態版(先手動列 5-10 個服務)
- [ ] `/docs` 基本結構(快速開始 + Python 範例)
- [ ] `/api` 嵌入 Swagger UI
- [ ] 中英切換機制
### Phase 2:動態資料(3-5 天)
- [ ] D1 schema`recipe_calls(recipe_id, count, last_updated)`
- [ ] CF WorkerAPI call 時寫入 D1
- [ ] 每小時聚合 WorkerD1 → KV cache(總數 / per recipe / per contributor
- [ ] 首頁數字牆:從 KV 讀即時數字
- [ ] `/integrations` 卡片:使用次數從 KV 讀,進度條動態顯示
### Phase 3:社群功能(2-3 天)
- [ ] 貢獻者排行從 KV 讀
- [ ] Recipe 頁面:點「查看 Recipe」展開 YAML
- [ ] 點「複製 Python 範例」自動生成對應 code snippet
- [ ] GitHub PR merge webhook → 觸發 Pages rebuild
---
## 8. 一個不能省的細節
榮譽牆的貢獻者欄位**一定要連到他的 GitHub profile**,不是只顯示名字。
工程師貢獻的動機之一是「這個會出現在我的公開作品集」。連到 GitHub 就意味著他的 followers 可能看到他貢獻了 arcrun,這比任何 badge 都有效。
@@ -0,0 +1,486 @@
# arcrun-pyPython Lib 策略分析
> **核心問題**arcrun 的 auth 層要不要獨立成 Python lib?AI 會主動選它嗎?
> **決策前提**:本地為主雲端選配 + 免費引流 SaaS + 三維度分析
---
## 0. TL;DR
做。但要做對——它不是「arcrun SDK」,是 **獨立的 OAuth 地獄解藥**SaaS 帳號只是 optional power-up。
**產品名建議**`arcrun-auth`PyPI 套件名),import 時叫 `authkit``arc`
**定位**`pip install arcrun-auth` → 一行搞定 Google/GitHub/Slack/Notion/... 的認證。本地 keyring 存 secret,零註冊可用。註冊 arcrun 帳號才能多機同步 / 團隊共用 / 獲得平台代管的 OAuth App。
---
## 1. 你的直覺:為什麼這可能是大招
你觀察到的現象:
- **寫個「本地檔案 → Google Drive 備份」這種小腳本**,光認證花 2 天,日後還反覆壞。
- **這個痛點 99% 的 Python 使用者都遇過**。Stack Overflow 上 Google OAuth 的問題累計回答數十萬。
- **AI 寫程式時,這部分它也不會寫**——訓練資料裡的標準範本就是 `InstalledAppFlow.from_client_secrets_file()`,還是要人類先去 GCP Console 建 OAuth Client、下載 JSON、設 redirect URI、加測試用戶。
**如果 arcrun 能把這整套濃縮成 `auth.bind("google_drive")`,它就是開發者工具裡的「自來水」——沒人在乎水源廠長怎麼運作,大家只想打開水龍頭有水喝。**
而且你無意中踩到一個時代轉折點:**AI 在寫程式,AI 不想 debug OAuth**。這個 lib 是給 AI 用的,比給人類用的更重要。
---
## 2. 三維度分析:AI 到底會不會選 arcrun-auth
### 維度一:零註冊摩擦(最重要)
**現實**AI coding assistant 預設會產出訓練資料裡最常見的程式碼。Google Drive 的標準答案就是官方 quickstart——那段 40 行的 `InstalledAppFlow` 樣板碼,加上「去 GCP Console 建 OAuth Client」的一串步驟。
**AI 選擇替代 lib 的條件**(觀察 Copilot / Cursor / Claude Code 的實際行為):
| 條件 | 是否能壓制預設答案 |
|---|---|
| Lib 名稱出現在 prompt 裡 | ✅ 絕對會用 |
| 專案已有 `pyproject.toml` 宣告該 lib | ✅ 會用 |
| 該 lib 是 PyPI 下載量 Top N(訓練資料權重高) | ⚠️ 或許 |
| 使用者口頭說「用 XX 比較好」 | ✅ 會用(單次) |
| 該 lib 明顯更短更容易 | ⚠️ 有機會但不保證 |
**結論**:**零註冊摩擦是生死線**。
如果 `pip install arcrun-auth` 之後必須先去註冊 arcrun 帳號、拿 API token、設環境變數——AI 看到這一步就會放棄,回去寫 Google 官方那套,因為「官方的至少不用註冊第二家廠商」。
**對策**
- 本地模式必須是**零配置可用**的 default path。
- Secret 存在 `~/.arcrun/secrets.db`(加密,用系統 keyring 保護 master key)。
- 註冊 arcrun 帳號是 **opt-in** 的升級,不是必要。
- 第一次用 OAuth 時,**平台提供免費共用的 OAuth App**(下面 §3 詳細說)。
這個設計讓 AI 看到的心智路徑是:
```
目標:備份檔案到 Google Drive
選項 Agoogle-api-python-client(官方,但使用者要先去 GCP 設 OAuth)
選項 Barcrun-authpip install 完直接可用)
```
在這個對比下,AI 會選 B——**特別是它被告知「使用者不想 debug 認證」或類似的 prompt 時**。
### 維度二:跟 stdlib / 主流 lib 的對比
Python Google API 的主流選項:
| Lib | PyPI 週下載(估) | AI 熟悉度 | 認證難度 |
|---|---|---|---|
| `google-api-python-client` | ~30M | 極高 | 高(要自己設 OAuth) |
| `pydrive2` | ~200k | 中 | 高 |
| `gspread`(只 Sheets | ~2M | 高 | 高(OAuth 或 SA |
| **arcrun-auth** | 0 | 0 | **極低** |
**冷啟動難題**:新 lib 要進入 AI 的選擇集合,需要:
1. **量**PyPI 下載 + GitHub stars 進到「被訓練資料收錄」的級別(大概 GitHub 5k+ stars 是門檻)。
2. **品牌**:有代表性部落格文、教學影片、官方 API 文件連結到它。
3. **簡潔**:代碼範例比主流短 5 倍以上,讓使用者「一眼就想用」。
**arcrun 的優勢**
- 你本來就在教 n8n 課程,有現成學員管道可以鋪「這是 n8n 的 Python 版 auth」。
- 「AI 寫程式的 auth lib」是個還沒被佔領的定位詞。`langchain` 做了 LLM 層,但 auth 層還沒有明顯贏家。
- Claude Code 對 lib 選擇特別敏感——它會實際讀 `pyproject.toml` 並尊重已有宣告。
**對策**
- **第一批 adopter 是你的學員**(n8n 課 + AI 自動化課),他們會在實戰中用,累積 GitHub issues 和 blog post。
- **SEO 主打**:「Python Google Drive OAuth 簡化」「AI 自動化 Python 認證」這些長尾關鍵字現在沒有明顯答主。
- **Claude Code 優化**:寫一份 `AGENTS.md``.cursorrules` 範本,示範怎麼在 prompt 裡引導 AI 選 arcrun-auth。
### 維度三:痛點強度(OAuth 地獄避免)
**量化你那兩天 debug**
| 階段 | 時間成本 | 典型錯誤 |
|---|---|---|
| GCP 註冊 + 啟用 API | 15 min | 找不到哪個 API |
| 建 OAuth Client ID | 15 min | Desktop / Web / iOS 選錯 |
| 設 OAuth Consent Screen | 30 min | External / Internal 選錯;scope 加錯 |
| 加測試用戶 | 10 min | 漏加自己的 email |
| 寫 Python flow | 30 min | `run_local_server` vs `run_console` |
| 第一次跑遇到 `redirect_uri_mismatch` | 30-120 min | port 衝突、URI 沒加 |
| Token 過期處理 | 60 min | `creds.expired``refresh_token` 沒保存 |
| Service Account 模式(如果需要) | 120 min | domain-wide delegation 設定 |
| **合計** | **5-8 hrs(順的人)** | **2 天(不順的人,你當時的情況)** |
**arcrun-auth 對應版本**
```python
from arcrun import auth
# 首次執行:自動打開瀏覽器完成 OAuth,結果存本地 keyring
drive = auth.bind("google_drive")
# 直接呼叫 API
drive.post("/upload/drive/v3/files", params={"uploadType": "media"},
data=open("backup.zip", "rb"))
```
**時間成本:首次 2 min,之後 0 min**。
這個壓倒性的體驗差距是產品的核心競爭力。**只要使用者試過一次,就不會再回去寫 `InstalledAppFlow`**——即使 AI 預設會產出官方版本。
---
## 3. 關鍵設計決策
### 3.1 OAuth App 誰擁有?(核心問題)
傳統做法:使用者自己去 GCP Console 註冊自己的 OAuth App,拿 client_id/client_secret。**這就是痛點來源**。
arcrun-auth 要消滅這步,只有兩條路:
**Option A:平台提供共用 OAuth App(推薦 default**
- arcrun 註冊一個 Google OAuth App,命名類似「arcrun Auth Broker」。
- 所有 arcrun-auth 使用者共用這個 App 的 client_id/secret。
- 使用者在 Google 授權頁面看到的是「arcrun Auth Broker 想存取您的 Google Drive」。
- **好處**:使用者零配置,arcrun 品牌曝光。
- **成本**Google 有 OAuth App 的限額(Verified App 才能超過 100 users),需要申請 Google OAuth Verification(要提供隱私政策、網域驗證、可能要付 $75 安全審查)。
**Option B:使用者 BYO OAuth App**
- 企業客戶或注重稽核的人需要這個。
- 在 `~/.arcrun/config.toml` 放自己的 client_id/secret。
**Option Carcrun SaaS 代管**(付費)
- 使用者註冊 arcrun 帳號,平台幫你管 OAuth App、token、團隊共用、audit log。
- 這是付費 tier 的主要價值。
**建議**:A + B + C 三種都支援,默認 A;免費無限制 B;付費享受 C。
### 3.2 Secret 儲存層級(本地為主雲端選配)
```
優先級 1 (default):本地 keyring
- macOS Keychain / Windows Credential Manager / Linux libsecret
- zero config,安全性靠 OS
優先級 2 (opt-in):本地加密檔
- ~/.arcrun/secrets.enc
- master key 走 keyring 或 passphrase
- 給沒有 keyring 的環境(Docker、CI
優先級 3 (opt-in)arcrun 雲端
- 多機同步、團隊共用、audit log
- 需註冊 arcrun 帳號
- 本地 lib 只保存 arcrun API token,實際 service secret 存雲端
```
### 3.3 Secret 初始化流程
**靜態 key 模式(Notion、OpenAI、Stripe...**
```bash
# 選項 A:互動式
$ arcrun setup notion
? Notion Integration Token (hidden): ***
✓ Testing connection... OK
✓ Saved to keyring as notion/default
# 選項 B:環境變數
$ export ARCRUN_NOTION_TOKEN=secret_xxx
$ python script.py # arcrun-auth 自動讀
# 選項 C:程式碼內
notion = auth.bind("notion", secret={"token": os.environ["NOTION_TOKEN"]})
```
**OAuth 模式(Google、GitHub、Slack...**
```python
drive = auth.bind("google_drive")
# 如果是第一次:
# 1. 本地啟動一個臨時 HTTP server (http://localhost:random_port)
# 2. 開瀏覽器到 Google authorize URL
# 3. 使用者點同意
# 4. Google redirect 到 localhostlib 接到 code
# 5. 換 token,存 keyring
# 6. 回傳可用的 client
```
這個流程和 `InstalledAppFlow.run_local_server()` 本質上一樣——但差別是:
- **Client ID 不用使用者自己去 GCP Console 註冊**(由 arcrun 平台提供)。
- **Scope 由 recipe 宣告**(不用使用者自己查文件)。
- **Token 儲存自動化**(不是散落在 `token.json`)。
### 3.4 Recipe 來源
Python lib 和 Cloudflare Worker 版本**共用同一份 recipe YAML**。
```
arcrun-recipes/ # GitHub repo,公開
├── recipes/
│ ├── official/
│ │ ├── google_drive.yaml
│ │ ├── notion.yaml
│ │ └── ...
│ └── community/
│ └── ...
```
Python lib 啟動時檢查本地 `~/.arcrun/recipes/` 快取,過期就從 GitHub 或 arcrun 平台 API 拉最新。
**這是關鍵架構優勢**recipe 寫一次,Web 和 CLI 和 Python lib 全部受益。社群貢獻一份 Notion recipe,所有 runtime 自動支援。
---
## 4. API 設計(Python 版)
### 4.1 最簡路徑
```python
from arcrun import auth
# 取得認證好的 HTTP client(基於 httpx
client = auth.bind("google_drive")
# 相對 base_url 的路徑
resp = client.get("/files", params={"q": "name = 'backup.zip'"})
files = resp.json()["files"]
# 上傳
client.post("/upload/drive/v3/files",
params={"uploadType": "multipart"},
files={"file": ("backup.zip", open("backup.zip", "rb"))})
```
### 4.2 進階:非同步
```python
from arcrun import auth
async with auth.bind_async("google_drive") as client:
resp = await client.get("/files")
```
### 4.3 進階:多 instance
```python
# 同一個服務,多個帳號
personal = auth.bind("google_drive", instance="personal")
work = auth.bind("google_drive", instance="work")
```
### 4.4 進階:直接取 token(給不想透過 wrapper 的情況)
```python
# 取 raw access token,自己丟進任何 lib
token = auth.get_token("google_drive")
# 丟給 googleapiclient
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
creds = Credentials(token=token.access_token)
service = build("drive", "v3", credentials=creds)
```
這個 escape hatch 很重要——不強制使用者放棄他熟悉的官方 lib,只是把**認證這一層**剝離出來。這是你想要的「避免麻煩直接用 arcrun 的 auth 功能」的精確實作。
### 4.5 服務發現
```python
# CLI
$ arcrun list
google_drive Google Drive OAuth2
notion Notion API Key
github GitHub OAuth2
openai OpenAI API Key
...
# Python
from arcrun import auth
auth.list_services() # 回傳 dict
```
---
## 5. 商業模式:免費引流 → SaaS 付費
### 5.1 免費永久可用(本地模式)
| 功能 | 免費 | 付費 |
|---|---|---|
| `pip install arcrun-auth` | ✅ | ✅ |
| 本地 keyring 儲存 secret | ✅ | ✅ |
| 所有 recipe 可用 | ✅ | ✅ |
| 平台代管 OAuth App(免自己註冊) | ✅ | ✅ |
| 單機使用 | ✅ | ✅ |
### 5.2 付費 tier 提供的
| 功能 | 免費 | Pro ($9/mo) | Team ($29/user/mo) |
|---|---|---|---|
| 多機同步 secret | ❌ | ✅ | ✅ |
| 團隊共用 credential | ❌ | ❌ | ✅ |
| Audit log(誰在何時用了什麼 secret) | ❌ | ✅ | ✅ |
| Secret rotation 提醒 | ❌ | ✅ | ✅ |
| 企業 OAuth App BYO | ❌ | ✅ | ✅ |
| SSO / SCIM | ❌ | ❌ | ✅ |
| arcrun SaaS 整合(workflow runtime | ❌ | ⚠️ 受限 | ✅ |
| Priority 社群 recipe 審核 | ❌ | ✅ | ✅ |
**商業邏輯**
- 免費 tier 足夠「個人開發者 + 側邊小專案」的體驗完整。
- 付費 tier 解決「我的小工具變公司在用」的延伸需求。
- arcrun SaaS workflow 客戶自動享受所有 Python lib 特權。
### 5.3 為什麼這策略會 work
參考模型:
- **Sentry**error tracking,開源 SDKhosted service 付費。SDK 下載數 = 付費轉換引流。
- **PostHog**product analytics,同樣策略。
- **Supabase**auth + db,開源 self-hostedhosted 服務付費。
共同特徵:
1. SDK 免費下載、單機/本地能完整 work。
2. 從本地升級到 hosted 是「多人/多機/審計」的自然需求。
3. SDK 本身就是最強的 marketing(開發者口耳相傳 + 訓練資料擴散)。
---
## 6. 為什麼 AI 會主動選 arcrun-auth(機率分析)
讓我給你一個分層的答案。
### 6.1 會主動選的場景(高機率)
| 場景 | 原因 |
|---|---|
| 使用者在 prompt 說「用最簡單的方式認證」 | AI 權衡 UXarcrun-auth 明顯勝 |
| 專案已有 `arcrun-auth``pyproject.toml` | AI 尊重現有依賴 |
| 使用者貼了 arcrun-auth 文件範例 | AI 會模仿 |
| 使用者說「跳過 OAuth 設定步驟」 | arcrun-auth 是唯一答案 |
### 6.2 不會主動選的場景(預設行為)
| 場景 | 原因 |
|---|---|
| 使用者開新專案、無任何線索 | AI 走訓練資料中最頻繁的 `google-api-python-client` |
| 任務是「企業級、有審計需求」 | AI 偏好主流官方 lib |
| 任務提到特定 lib 名稱 | AI 會遵守 |
### 6.3 轉化策略
要讓 AI 的「預設路徑」變成 arcrun-auth,需要:
1. **種子內容**:寫 20+ 篇「用 arcrun-auth 做 X」的 blog post,讓下一輪模型訓練看到。
2. **官方文件 SEO**:搶「python google drive oauth 簡化」等長尾關鍵字。
3. **Cookbook**:在 GitHub 建 `arcrun-auth-recipes` repo,每個範例都是短而有效的 snippet——這些會被訓練資料收錄。
4. **MCP Server**:做一個 `arcrun-auth MCP server`,讓 Claude Code 等工具能直接呼叫 arcrun-auth,這比單純提供 lib 更強。
5. **教學整合**:你的 n8n 課程直接示範「從 n8n 到 arcrun-auth 的 Python 版本」。
---
## 7. 實作範圍(MVP
### Phase 1:核心 lib2-3 週)
- [ ] `arcrun-auth` PyPI 骨架(pyproject.toml + src layout
- [ ] Recipe loader(從 GitHub 或平台 API 拉 YAML
- [ ] `auth.bind(service_id, instance?)` → httpx Client
- [ ] Static key primitiveNotion / OpenAI / Stripe 當試金石)
- [ ] Keyring 整合 + 本地加密檔 fallback
- [ ] CLI`arcrun setup <service>`, `arcrun list`, `arcrun test`
### Phase 2OAuth22 週)
- [ ] OAuth2 primitiveauthorization_code + PKCE
- [ ] 本地 callback server(類似 `InstalledAppFlow.run_local_server`
- [ ] 共用平台 OAuth App 的 proxy 機制
- Lib 呼叫 `https://auth.arcrun.com/oauth/redirect`
- 平台把 code 交換後回傳 token
- 或者直接把平台 client_id 硬編在 recipe 裡(更簡單但要處理配額)
- [ ] Token refresh 自動化
- [ ] RecipeGoogle Drive / Gmail / GitHub / Slack
### Phase 3Service Account1-2 週)
- [ ] Google Service AccountJWT signing
- [ ] AWS SigV4
- [ ] Recipe 繼承(`extends: _google_base`
### Phase 4:雲端同步(2 週)
- [ ] `arcrun login` → 綁定雲端帳號
- [ ] Secret sync 協議(本地加密後上傳,平台只存密文)
- [ ] 多機同步
- [ ] Audit log
### Phase 5AI 生態整合(1-2 週)
- [ ] MCP server(讓 Claude Code 能直接用)
- [ ] VS Code Extension(一鍵設定 credential
- [ ] `AGENTS.md` 範本(引導 AI 選 arcrun-auth
---
## 8. 風險與坑
### 8.1 Google OAuth Verification
**問題**:共用 OAuth App 要申請 Google Verification,否則會有「未驗證 App」警告 + 100 user 上限。
**對策**
- MVP 階段接受警告頁面(使用者自己點「進階 → 前往」)。
- 到 user 量接近 100 時申請 Verification。
- 企業客戶走 BYO OAuth App 路徑,不受影響。
- 若平台 OAuth App 卡關,有 fallbacklib 自動引導使用者建自己的 OAuth App(提供 CLI wizard)。
### 8.2 其他服務的 OAuth App 政策
- **GitHub**:免費建 OAuth App,無上限。✅
- **Slack**:免費建,但安裝到使用者 workspace 需管理員同意。⚠️
- **Microsoft / Azure**:相對嚴格,需 tenant admin consent。⚠️
- **Notion**Internal Integration 可以完全走 API key,免 OAuth。✅(最簡單)
### 8.3 keyring 在 Linux server / Docker 的問題
Linux server 沒 GUI keyring daemon。對策:
- Fallback 到加密檔案(用 env var 或 CLI 互動提供 master key)。
- Docker 場景有 `docker secret`、Kubernetes Secretlib 支援直接讀這些來源。
### 8.4 競品
目前沒有完全對標的產品,但相鄰玩家:
- **[keyring](https://pypi.org/project/keyring/)**:只做儲存,不做認證流程。我們用它當底層。
- **[httpx-auth](https://pypi.org/project/httpx-auth/)**:只做認證,不做 secret 管理,也沒有 recipe。
- **[authlib](https://pypi.org/project/authlib/)**OAuth 實作 lib,低階,還是要自己組。
- **各家 SDKgoogle-auth, slack-sdk**:綁特定家,不 unify。
**arcrun-auth 的差異化定位**
> **Unified credential broker for AI-era Python apps**
> 一個 lib 搞定所有服務、所有認證機制、所有 secret 儲存後端。
---
## 9. 最後的判斷
### 9.1 這個 lib 該不該做?
**該做**。原因:
1. 你描述的痛點是真的,而且規模巨大(Python + Google API 下載量是千萬級)。
2. 技術可行,也跟既有 arcrun 架構共用 recipe,邊際成本低。
3. 對 arcrun SaaS 是完美引流——免費 lib 的使用者是精準的付費轉換潛在客戶。
4. 時間窗口正確:AI 寫程式時代剛開始,這個定位還沒被佔領。
### 9.2 跟主 SaaS 的優先順序
**建議****主 SaaS 的 credential 系統先做(前一份規劃),arcrun-auth 當後續 Phase**。
原因:
- Cloudflare Worker 版的 primitives + recipes 是基礎建設,Python lib 是其 consumer。
- 先做 Python lib 會逼你在 recipe schema 上做二次修改,不划算。
- 主 SaaS 的 recipe 累積到 20-30 個服務後,開放 Python lib 體驗最好。
時程建議:
- **Month 1-2**:主 SaaS 的 4 個 primitive + 15 個 recipe(前一份規劃)。
- **Month 3-4**arcrun-auth Phase 1-2static key + OAuth2),私人 alpha。
- **Month 5**:公開 release,寫部落格、SEO、社群推廣。
- **Month 6+**:雲端同步、MCP、AI 生態整合。
### 9.3 一句話總結
> **arcrun-auth 不是「arcrun 的 Python 綁定」,是「OAuth 地獄的解藥」**。SaaS 是延伸。這個敘事才能在 AI 寫程式的時代站住腳。
@@ -0,0 +1,55 @@
# Arcrun 推廣策略修正
20260418 by Leo
## Arcrun 的最近幾次變化
- Arcrun 原是 Matrix 的原子化純雲端 CF 程式框架,有 MCP 讓 AI 使用
- 轉以 WASM + WASI + TinyGo 成為未來具有雲端、地端、邊緣端的執行能力,可以用來做到無人機等終端。
- 再解耦成獨立的 Open Source 專案,脫離 KBDB 用 YAML 即可,允許整個 Fork,但推廣 SaaS 模式
- 今天的變化是發現成為 Lib 和 n8n 社群節點的用法。說明如下。
## 推廣方式 1:寫成 Lib
參考文件:docs/user_requirements/arcrun/ADR-lib-and-landingPage/arcrun-py-strategy-analysis.md
Leo 教 n8n 時常舉例我叫 AI 幫我寫個簡單的程式把 server 的檔案備份到 Google Drive 後刪除,光是 OAuth 用 Service Account 就花了 2 天測試,後來還發生好幾次出錯重修。
網路設定對 vibe coder 太複雜了,看到 AI 一次次修改一次次出錯,知道一週後才穩定,而 n8n 只需要申請 credential 填入,10 分鐘搞定,差距太大了。
問題是,n8n 實在太慢了,全圖像界面也難以跟程式碼整合,vibe coder 就算知道了也無法整合,他會繼續用 python 就是無法忍受 n8n 的慢速、沉重,及對 AI 的不友善。
Arcrun 用幾個基礎 Auth 零件取代 n8n 幾百個不同的 credentials 設定,arcrun 完全可以跟程式碼整合,只是目前沒有整合工具,也就是說,工程師不想面對一個認證花好幾天,他只要用 Arcrun 打 API 就幾行解決了。
用這個策略,讓具有程式知識的人會:
- 對 Arcrun 有好感,在 GitHub 按星
- 成為 Arcrun 用戶,增加會員 base
- 寫文分享,讓 Arcrun 被傳出去
反之,小白初學 vibe coding,看到老工程師說 2 天變成 10 分鐘,百行程式碼變成 2 行,也會被影響。
所以,把 API 包裝成 Lib 或 SDK 是個 Get Member 的好方法,不過一定要有 Email。
## 推廣方式 2: 讓 n8n 來 call
身為 n8n 講師,我非常清楚 n8n 有個大缺陷(或說是安全設計),就是它只能呼叫在工作流實例化前就寫好的程式,無法注入新的。
所以,雖然它可以安裝一個實作 langchain AI Agent 的 AI Agent 節點,但 AI 卻不可能在遇到問題時立刻寫個程式運行解決問題,甚至使用它內建的 task runners 或使用外部的 Python runtime 也無法。
但只要使用 http request 節點呼叫外部的 Arcrun tool,它就可以輸出三元組寫一個程式解決它現在遇到的問題,並且運算完後回傳,只需要取得一個 API Key。
## 可能的效應
工程師是意見領袖,他們會去按星,幫忙教育市場,而小白需要被教育,但他們是較大的社群,這些星數和聲量會在未來變成 AI 訓練素材,甚至推薦用戶使用最 AI Friendly 的 Arcrun。
而實際上 Arcrun 的設計讓 AI 更輕鬆。
## 要做什麼
docs/user_requirements/arcrun/ADR-lib-and-landingPage/arcrun-pages-spec.md 這是 claude.ai 寫的規劃,因為它看不到 codebase,所以你要斟酌如何開發。
另外,當前的 API Key 雖然很容易取得,卻有個問題,用戶無法管理,所以頁面應該要有取得 API Key 的界面,可以 Google, GitHub 等 OAuth 或 Email + Password 登入,用來管理它的 API Key 的 CRUD。
或許是可以外接一個 SMTP 服務來確認他的 Email 真實,SaaS 服務還是有成本,雖然成本不高。
另外,既然是 OpenSource,成本雖然不高,似乎可以銜接 Donate 服務?
@@ -0,0 +1,56 @@
// App root screen switcher with persistent route
const { useState, useEffect } = React;
const SCREENS = [
{ id: 'landing', label: 'Landing' },
{ id: 'auth', label: 'Auth' },
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'keys', label: 'API Keys' },
{ id: 'workflow', label: 'Workflow' },
];
// Synonyms from sidebar ids
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
function App() {
const [screen, setScreen] = useState(() => {
const saved = localStorage.getItem('arcrun:screen');
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
});
useEffect(() => {
localStorage.setItem('arcrun:screen', screen);
window.scrollTo(0, 0);
}, [screen]);
const nav = (id) => {
const resolved = aliases[id] || id;
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
};
const Current = {
landing: Landing,
auth: Auth,
dashboard: Dashboard,
keys: ApiKeys,
workflow: WorkflowViewer,
}[screen];
return (
<div className="app">
<Current onNav={nav} />
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
{SCREENS.map(s => (
<button key={s.id}
className={screen === s.id ? 'active' : ''}
onClick={() => nav(s.id)}>
{s.label}
</button>
))}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,92 @@
// Top nav and sidebar
const TopNav = ({ onNav, current }) => {
const [scrolled, setScrolled] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo onClick={() => onNav('landing')} />
<div className="nav-links" style={{marginLeft: 20}}>
<a>Product</a>
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
</div>
</div>
<div className="flex gap-8" style={{alignItems: 'center'}}>
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
<button className="btn btn-primary" onClick={() => onNav('auth')}>
Get started <Icon name="arrow_right" size={14} />
</button>
</div>
</nav>
);
};
const Footer = ({ onNav }) => (
<footer className="footer">
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo size="sm" />
<span>© 2026 Arcrun Labs</span>
</div>
<div className="footer-links">
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
<a>Status</a>
<a>Privacy</a>
</div>
</footer>
);
// App shell with sidebar for logged-in screens
const Sidebar = ({ current, onNav }) => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
{ id: 'keys', label: 'API Keys', icon: 'key' },
{ id: 'docs', label: 'Docs', icon: 'book' },
];
const bottom = [
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
return (
<aside className="sidebar">
<div className="sidebar-head">
<Logo size="md" onClick={() => onNav('landing')} />
</div>
<div className="sidebar-section">Workspace</div>
{items.map(it => (
<div key={it.id}
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
{it.count != null && <span className="sb-count">{it.count}</span>}
</div>
))}
<div style={{flex: 1}} />
{bottom.map(it => (
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
</div>
))}
<div className="sidebar-foot">
<div className="avatar-circ">MR</div>
<div className="meta">
<div className="name">Maya Rivera</div>
<div className="email">maya@northwind.co</div>
</div>
</div>
</aside>
);
};
Object.assign(window, { TopNav, Footer, Sidebar });
@@ -0,0 +1,86 @@
// Shared primitives: icons, logo, etc.
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
const paths = {
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
plus: <path d="M12 5v14M5 12h14" />,
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
check: <path d="M20 6L9 17l-5-5" />,
close: <path d="M18 6L6 18M6 6l12 12" />,
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
chevron_right: <path d="M9 6l6 6-6 6" />,
chevron_down: <path d="M6 9l6 6 6-6" />,
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
{paths[name]}
</svg>
);
};
// Arcrun wordmark custom "arc" glyph made of an arc stroke + ascending dot/node
const Logo = ({ size = 'md', onClick }) => {
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
return (
<div className="logo" onClick={onClick}>
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
<circle cx="19" cy="17" r="2.2" fill="white" />
</svg>
</span>
<span>Arcrun</span>
</div>
);
};
// App icon with gradient background
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
const tones = {
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
green: 'linear-gradient(135deg, #10B981, #22C55E)',
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
slate: 'linear-gradient(135deg, #475569, #334155)',
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
};
return (
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
{children}
</div>
);
};
Object.assign(window, { Icon, Logo, AppIcon });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
const ApiKeys = ({ onNav }) => {
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
const [keys, setKeys] = React.useState([
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
]);
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
const copyKey = () => {
setNewKeyCopied(true);
setTimeout(() => setNewKeyCopied(false), 1800);
};
const toggleKey = (id) => {
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
};
return (
<div className="shell">
<Sidebar current="keys" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Workspace</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Settings</span>
</div>
<h1>API Keys</h1>
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
</div>
</div>
<div className="main-body" style={{maxWidth: 1080}}>
<div className="new-key-box">
<div className="warn-row">
<span className="warn-icon"><Icon name="warn" size={12} /></span>
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
</div>
<h3>Your new API key</h3>
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
<div className="key-display">
<span className="key-val">{newKey}</span>
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
{newKeyCopied ? 'Copied' : 'Copy'}
</button>
</div>
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes </span></span>
</div>
</div>
<div className="section-head">
<div>
<h2>All keys</h2>
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '32%'}}>Name</th>
<th>Key</th>
<th>Created</th>
<th>Last used</th>
<th>Status</th>
<th style={{width: 60, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td>
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
</td>
<td className="mono">{k.prefix}{k.id.slice(-4)}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
<td>
{k.revoked ? (
<span className="pill revoked"><span className="pdot" /> Revoked</span>
) : (
<div className="flex gap-8" style={{alignItems: 'center'}}>
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
</span>
</div>
)}
</td>
<td style={{textAlign: 'right'}}>
{!k.revoked && (
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
<Icon name="warn" size={12} />
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
</div>
</div>
</div>
</div>
);
};
window.ApiKeys = ApiKeys;
@@ -0,0 +1,90 @@
const Auth = ({ onNav }) => {
const [mode, setMode] = React.useState('signin');
const [email, setEmail] = React.useState('');
const [pw, setPw] = React.useState('');
const [remember, setRemember] = React.useState(true);
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
return (
<div className="auth-wrap">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
<Logo onClick={() => onNav('landing')} />
</div>
<div className="auth-card">
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
<div className="tabs">
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
</div>
<div className="oauth-row">
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
</button>
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
<Icon name="google" size={15} stroke={0} /> Continue with Google
</button>
</div>
<div className="divider">or continue with email</div>
<form onSubmit={submit}>
{mode === 'signup' && (
<div className="field">
<label>Full name</label>
<input className="input" type="text" placeholder="Maya Rivera" />
</div>
)}
<div className="field">
<label>Work email</label>
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="field">
<div className="field-row">
<label>Password</label>
{mode === 'signin' && <span className="link">Forgot password?</span>}
</div>
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
</div>
{mode === 'signin' && (
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
<div onClick={() => setRemember(!remember)}
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
background: remember ? 'var(--primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
{remember && <Icon name="check" size={11} />}
</div>
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
</div>
)}
<button className="btn btn-primary auth-submit btn-lg" type="submit">
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
</button>
</form>
{mode === 'signup' && (
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
</p>
)}
<div className="auth-foot">
{mode === 'signin'
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
</div>
</div>
</div>
);
};
window.Auth = Auth;
@@ -0,0 +1,126 @@
const Dashboard = ({ onNav }) => {
const apps = [
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
];
const workflows = [
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
];
return (
<div className="shell">
<Sidebar current="dashboard" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Northwind</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Dashboard</span>
</div>
<h1>Welcome back, Maya</h1>
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
</div>
</div>
<div className="main-body">
{/* Apps grid */}
<div className="section-head">
<div>
<h2>My Apps</h2>
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
</div>
<span className="subtle">{apps.length} apps</span>
</div>
<div className="apps-grid">
{apps.map(a => (
<div key={a.id} className="app-card">
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
<h4>{a.name}</h4>
<p className="dsc">{a.desc}</p>
<div className="row">
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
<button className="chip-btn">
<Icon name="spark" size={11} /> Edit in Claude
</button>
</div>
</div>
))}
<div className="app-card app-empty">
<div className="plus"><Icon name="plus" size={16} /></div>
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
</div>
</div>
{/* Workflows */}
<div className="wf-table">
<div className="section-head">
<div>
<h2>My Workflows</h2>
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '34%'}}>Workflow</th>
<th>Nodes</th>
<th>Last modified</th>
<th>Activity</th>
<th>Status</th>
<th style={{width: 100, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{workflows.map(w => (
<tr key={w.id}>
<td>
<div className="wf-row-name">
<span className="dot" />
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
</div>
</td>
<td className="dim">{w.nodes}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
<td>
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
<span className="pdot" /> {w.status}
</span>
</td>
<td style={{textAlign: 'right'}}>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
window.Dashboard = Dashboard;
@@ -0,0 +1,168 @@
const Landing = ({ onNav }) => {
const [installer, setInstaller] = React.useState('npm');
const installCmds = {
npm: '$ npm install arcrun',
pip: '$ pip install arcrun',
bun: '$ bun add arcrun',
};
return (
<div>
<TopNav onNav={onNav} current="landing" />
<div className="container">
<section className="hero">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div className="hero-eyebrow">
<span className="dot" />
<span>Now in public beta MCP-native</span>
</div>
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code Arcrun handles auth, retries, and state.</p>
<div className="hero-ctas">
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
Start free <Icon name="arrow_right" size={15} />
</button>
<button className="btn btn-secondary btn-lg">
<Icon name="book" size={14} /> Read the docs
</button>
</div>
</section>
<section className="paths">
{/* Developer path */}
<div className="path-card">
<div className="path-label">
<Icon name="terminal" size={13} /> For Developers
</div>
<h3>Three lines, any runtime.</h3>
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
<div className="install-tabs">
{Object.keys(installCmds).map(k => (
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
))}
</div>
<div className="terminal" style={{marginBottom: 12}}>
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">terminal</div>
</div>
<div className="terminal-body">
<div><span className="dim">{installCmds[installer]}</span></div>
</div>
</div>
<div className="terminal">
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
</div>
<div className="terminal-body">
{installer === 'pip' ? (
<>
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
<div className="sp-4"/>
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
</>
) : (
<>
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
<div className="sp-4"/>
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
</>
)}
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
</div>
</div>
{/* Everyone path */}
<div className="path-card">
<div className="path-label">
<Icon name="spark" size={13} /> For Everyone
</div>
<h3>Talk to your workflows.</h3>
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages in plain English.</p>
<div className="chat-preview">
<div className="chat-head">
<span className="brand-dot">AI</span>
<span>Your assistant Arcrun connected</span>
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
</div>
<div className="chat-body">
<div className="chat-msg user">
<div className="avatar">M</div>
<div className="bubble">Send this week's customer digest to the revenue team.</div>
</div>
<div className="chat-msg ai">
<div className="avatar">A</div>
<div className="bubble">
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
<div className="tool-card">
<div className="tool-icon">AR</div>
<div className="tool-meta">
<div className="tool-name">arcrun · digest/weekly</div>
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
</div>
<span className="pill active"><span className="pdot" />running</span>
</div>
</div>
</div>
</div>
<div className="chat-input">
<span>Reply to your assistant</span>
<span className="caret" />
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
</div>
</div>
</section>
<section className="strip">
<div className="cell">
<div className="ico"><Icon name="bolt" size={15} /></div>
<h4>Run anywhere</h4>
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="workflow" size={15} /></div>
<h4>Composable steps</h4>
<p>Model calls, HTTP, database, branching wire them visually or in code.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="key" size={15} /></div>
<h4>Scoped keys</h4>
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="eye" size={15} /></div>
<h4>Observable</h4>
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
</div>
</section>
</div>
<Footer onNav={onNav} />
</div>
);
};
window.Landing = Landing;
@@ -0,0 +1,255 @@
const WorkflowViewer = ({ onNav }) => {
const nodes = [
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
];
const edges = [
['trigger', 'fetch'],
['trigger', 'events'],
['fetch', 'summarize'],
['events', 'summarize'],
['summarize', 'filter'],
['summarize', 'log'],
['filter', 'slack'],
['filter', 'mail'],
];
const [selectedId, setSelectedId] = React.useState('summarize');
const [title, setTitle] = React.useState('digest/weekly');
const [zoom, setZoom] = React.useState(100);
const selected = nodes.find(n => n.id === selectedId);
// Edit triplet inline (for the summarize node's prompt config)
const [triplet, setTriplet] = React.useState({
model: 'claude-haiku-4-5',
temperature: '0.3',
prompt: 'Summarize this week\'s account activity for the revenue team.',
});
// Measure node widths for edge endpoint accuracy
const nodeRefs = React.useRef({});
const [sizes, setSizes] = React.useState({});
React.useEffect(() => {
const ns = {};
for (const n of nodes) {
const el = nodeRefs.current[n.id];
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
}
setSizes(ns);
}, []);
const getPort = (id, side) => {
const n = nodes.find(x => x.id === id);
const sz = sizes[id] || { w: 200, h: 60 };
return {
x: side === 'out' ? n.x + sz.w : n.x,
y: n.y + sz.h / 2,
};
};
return (
<div className="wf-viewer">
<div className="wf-topbar">
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
<Icon name="arrow_left" size={16} />
</div>
<Logo size="sm" onClick={() => onNav('landing')} />
<div className="sep" />
<div className="wf-breadcrumb">
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
<Icon name="chevron_right" size={11} />
<input
className="wf-title mono"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<span className="wf-saved">
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
Saved · 2m ago
</span>
<div className="spacer" />
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
<button className="wf-edit-in-claude">
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
</button>
</div>
<div className="wf-canvas">
<svg className="wf-edges" width="100%" height="100%">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
</marker>
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
</marker>
</defs>
{edges.map(([a, b], i) => {
const p1 = getPort(a, 'out');
const p2 = getPort(b, 'in');
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
const highlight = a === selectedId || b === selectedId;
return (
<path key={i} d={d}
stroke={highlight ? '#6366F1' : '#3a3a3a'}
strokeWidth={highlight ? 2 : 1.5}
fill="none"
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
opacity={highlight ? 0.95 : 0.6} />
);
})}
</svg>
<div className="wf-nodes">
{nodes.map(n => (
<div key={n.id}
ref={el => (nodeRefs.current[n.id] = el)}
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
style={{left: n.x, top: n.y}}
onClick={() => setSelectedId(n.id)}>
{n.inputs.length > 0 && <span className="port in" />}
{n.outputs.length > 0 && <span className="port out" />}
<div className="node-row-top">
<span className="node-icon" style={{background: n.tone}}>
<Icon name={n.icon} size={12} />
</span>
<span className="node-title">{n.title}</span>
<span className="node-badge">{n.badge}</span>
</div>
<div className="node-sub">{n.type}</div>
</div>
))}
</div>
{/* Detail panel */}
{selected && (
<div className="wf-detail">
<div className="dt-head">
<span className="dt-icon" style={{background: selected.tone}}>
<Icon name={selected.icon} size={15} />
</span>
<div className="dt-meta">
<h3>{selected.title}</h3>
<div className="dt-type">{selected.type}</div>
</div>
<button className="close-btn" onClick={() => setSelectedId(null)}>
<Icon name="close" size={14} />
</button>
</div>
<div className="dt-body">
<div className="dt-section">
<h4>Input schema</h4>
{selected.inputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs this is a trigger.</div>
) : selected.inputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
<div className="dt-section">
<h4>Output schema</h4>
{selected.outputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs terminal node.</div>
) : selected.outputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
{selected.id === 'summarize' && (
<div className="dt-section">
<h4>Configuration</h4>
<div className="triplet">
<div className="trow">
<div className="tkey">model</div>
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">temp</div>
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">prompt</div>
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
</div>
</div>
</div>
)}
<div className="dt-section">
<h4>Last run</h4>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
</div>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
</div>
</div>
</div>
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
<Icon name="spark" size={13} /> Edit this node in Claude
</button>
</div>
</div>
)}
{/* Minimap */}
<div className="wf-minimap">
<div className="mini-label">Overview</div>
{nodes.map(n => {
const sz = sizes[n.id] || {w: 180, h: 60};
return (
<div key={n.id} className="mini-box" style={{
left: 8 + (n.x / 1400) * 164,
top: 18 + (n.y / 500) * 80,
width: Math.max(6, (sz.w / 1400) * 164),
height: Math.max(4, (sz.h / 500) * 80),
opacity: selectedId === n.id ? 1 : 0.5,
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
}} />
);
})}
</div>
{/* Zoom controls */}
<div className="wf-controls">
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
<div className="zoom-val">{zoom}%</div>
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
<button><Icon name="maximize" size={13} /></button>
</div>
</div>
</div>
);
};
window.WorkflowViewer = WorkflowViewer;
File diff suppressed because it is too large Load Diff
+761
View File
@@ -0,0 +1,761 @@
# arcrun Credential System 設計規格
20260418
> **讀者**Claude CodeCC),負責實作
> **作者**richblack(架構決策)
> **版本**v1.0
> **狀態**:Draft — 等 CC 確認技術可行性後開工
---
## 0. TL;DR(給 CC 的三句話版)
1. **不要**為每個服務寫一個 credential 零件,n8n 是錯的。
2. 做**四個 TinyGo/WASM 零件**primitives),每個服務只需要一份 **YAML recipe** + 用戶自己的 **secret**
3. Recipe 存 arcrun 平台 KV(公共),secret 存 tenant KV(私有),兩者在 runtime 由 `AuthBroker` 組裝成可用的 HTTP client。
---
## 1. 設計目標與反目標
### 目標
- **新增一個服務的成本 = 寫一份 YAML**,不需要 rebuild、不需要改 code。
- **AI agent 理解成本 ≈ 0**:recipe 就是呼叫該服務的完整說明書。
- **人類設定成本 < 10 分鐘**:即使是對 OAuth 不熟的使用者,UI 只問「你的 API Key 是什麼」這類 secret 層級問題。
- **Secret 隔離**:每個 tenant 的 secret 絕對不互相可見,arcrun 平台本身也無法明文讀取(用 Cloudflare Secrets Store 或加密儲存)。
### 反目標(明確不做的事)
- ❌ 不做 n8n 那種「每個服務一個 credential type」的視覺化面板。
- ❌ 不支援 OAuth1(2026 年還在用的服務極少,真遇到再加)。
- ❌ 不做 credential sharing 的複雜 ACL(全 tenant scope 即可,未來再擴充)。
- ❌ 不在 arcrun 內部明文持久化任何長期 secret(只有加密過的密文或 Secrets Store reference)。
---
## 2. 核心架構:三層模型
```
┌─────────────────────────────────────────────────────────┐
│ Layer 3: Service Recipe (YAML) │
│ arcrun 平台共享,describe "如何呼叫這個服務" │
│ 存在 Workers KV: arcrun-recipes │
│ 例:recipe/notion.yaml, recipe/google_calendar.yaml │
└─────────────────────────────────────────────────────────┘
↓ 引用
┌─────────────────────────────────────────────────────────┐
│ Layer 2: Auth Primitive (TinyGo → WASM) │
│ 四個通用認證零件,實作注入邏輯與 token 交換 │
│ 1. static_key 2. oauth2 │
│ 3. service_account 4. mtls │
└─────────────────────────────────────────────────────────┘
↑ 使用
┌─────────────────────────────────────────────────────────┐
│ Layer 1: Tenant Secret (KV + Secrets Store) │
│ 每個 tenant 自己的 KV namespace │
│ 存 encrypted secret 或 Secrets Store reference │
│ 例:secret/{tenant_id}/notion-prod │
└─────────────────────────────────────────────────────────┘
```
### 為什麼這樣切?
| 切分維度 | Recipe | Primitive | Secret |
|---|---|---|---|
| **誰擁有** | arcrun 平台 | arcrun 平台 | tenant 自己 |
| **變化頻率** | 中(新服務時) | 低(認證機制穩定) | 高(rotate、revoke |
| **敏感度** | 公開 | 公開 | 最高機密 |
| **儲存位置** | 平台 KV`arcrun-recipes` | WASM binary | tenant KV + Secrets Store |
| **可否社群貢獻** | ✅ PR | ⚠️ 核心團隊 | ❌ 永遠不 |
---
## 3. 四個 Primitive 詳細規格
### 3.1 `static_key`
**適用**API Key、Bearer Token、Basic Auth、任何「一組 secret 不會自動過期」的認證。
**涵蓋 n8n 的**API Key、Basic Auth、Header Auth、Query Auth、Custom Auth、Digest Auth~80% 服務)。
**Recipe 欄位**
```yaml
primitive: static_key
inject:
# 四個注入位置,可以同時用多個
header: # HTTP headers
<key>: <value template>
query: # URL query string
<key>: <value template>
body: # request bodyJSON 欄位)
<key>: <value template>
basic_auth: # HTTP Basic Auth(會自動 base64 編碼)
username: <value template>
password: <value template>
```
**Value template 語法**`{{secret.xxx}}` 取 secret 欄位,`{{const.yyy}}` 取 recipe 內定義的常數。
**Secret schema**tenant 存 JSON,欄位由 recipe 的 `required_secrets` 宣告。
**範例(Notion**
```yaml
# arcrun-recipes KV: recipe/notion
service: notion
version: 1
primitive: static_key
base_url: https://api.notion.com/v1
required_secrets:
- key: token
label: "Internal Integration Token"
help_url: https://www.notion.so/my-integrations
inject:
header:
Authorization: "Bearer {{secret.token}}"
Notion-Version: "2022-06-28"
test:
method: GET
path: /users/me
expect_status: 200
```
**Secret 範例**
```json
// tenant KV: secret/tenant_123/notion-prod
{
"token": "secret_abc123..."
}
```
---
### 3.2 `oauth2`
**適用**:需要人類首次授權、之後用 refresh token 續命的場景。
**Grant types 支援**
- `authorization_code`(最常見:GitHub、Slack、Google 用戶授權)
- `client_credentials`(機器對機器)
- `pkce`SPA、行動應用)
- ❌ 不支援:password grant2026 已被多數 OAuth 提供者棄用)、implicit(已棄用)
**Recipe 欄位**
```yaml
primitive: oauth2
grant: authorization_code # or client_credentials, pkce
base_url: <service API base>
oauth:
authorize_url: <IdP authorize endpoint>
token_url: <IdP token endpoint>
scopes:
- <default scope 1>
- <default scope 2>
client_auth: header # or body
# 是否使用 refresh token
refresh: true
# PKCE 時額外參數
pkce_method: S256 # only for grant: pkce
required_secrets:
- key: client_id
label: "Client ID"
- key: client_secret
label: "Client Secret"
secret: true
inject:
header:
Authorization: "Bearer {{runtime.access_token}}"
```
**Runtime 欄位**primitive 自動維護,存在 tenant KV 的 `oauth_state/{secret_id}` key):
- `access_token`
- `refresh_token`
- `expires_at`
**首次授權流程**(人類要做的部分):
1. arcrun UI 呼叫 `AuthBroker.startAuth(recipe_id, tenant_id)` 回傳 authorize URL。
2. 使用者瀏覽器跳轉到 IdP,同意授權。
3. IdP redirect 回 arcrun callback endpoint(固定一個 URL,無論哪個服務)。
4. `AuthBroker` 用 authorization code 換 token,寫入 tenant KV。
**之後 agent 呼叫時完全自動**primitive 檢查 `expires_at`,過期自動用 refresh token 續,失敗再觸發重新授權通知。
---
### 3.3 `service_account`
**適用**Google Service Account、AWS IAM Roleassume role)、任何需要「私鑰簽 JWT 換短期 token」的機器身份。
**這個就是讓你 debug 兩天那個爆炸點。** 我們用 primitive 把地雷全部包起來。
**Recipe 欄位**
```yaml
primitive: service_account
kind: google_jwt # or aws_sigv4, generic_jwt
base_url: <service API base>
token_exchange:
# Google 的 JWT → OAuth access token 流程
endpoint: https://oauth2.googleapis.com/token
audience: https://oauth2.googleapis.com/token
scopes:
- https://www.googleapis.com/auth/calendar
# JWT claims
issuer_from_secret: client_email
subject_from_secret: client_email # optional, for domain-wide delegation 改成其他 user
ttl_seconds: 3600
required_secrets:
- key: service_account_json
label: "Service Account JSON"
type: json_blob # 特別型別,UI 可以接受貼整個 JSON
help: "到 GCP Console → IAM → Service Accounts → Keys → Add Key (JSON) 下載整份 JSON 貼上"
inject:
header:
Authorization: "Bearer {{runtime.access_token}}"
```
**為什麼不是每個服務一個 recipe?**
- Google Calendar、Gmail、Drive、Sheets 全部可以共用同一個 `service_account` primitive。
- 差別只在 `scopes``base_url`
- Recipe 本身可以 import 共通片段(見 §5 recipe 繼承)。
**AWS SigV4kind: aws_sigv4**:這是特例,不是 JWT-based,但概念一樣——用 access_key_id + secret_access_key 在每次 request 上簽章。Primitive 內建處理,recipe 只要宣告 region 和 service name。
---
### 3.4 `mtls`
**適用**mTLS / client certificate。銀行 API、企業內部服務、醫療系統。
**Recipe 欄位**
```yaml
primitive: mtls
base_url: <service API base>
required_secrets:
- key: client_cert
label: "Client Certificate (PEM)"
type: pem_cert
- key: client_key
label: "Client Private Key (PEM)"
type: pem_key
secret: true
- key: ca_cert
label: "CA Certificate (PEM) — optional"
type: pem_cert
optional: true
# mtls 通常不需要額外 inject,憑證在 TLS 層
inject: {}
```
**實作注意**Cloudflare Workers 有原生 mTLS 支援(`mTLSCertificate` binding),primitive 只需要把 secret 轉成 Cloudflare mTLS binding 即可。
---
## 4. Recipe YAML Schema(完整版)
```yaml
# 必填
service: string # 唯一識別,snake_casee.g. "notion", "google_calendar"
version: integer # recipe schema versionbreaking change 要升版
primitive: enum # static_key | oauth2 | service_account | mtls
base_url: string # service API base URL
# primitive 相關(依 primitive 不同)
inject: object # 如何把 secret 注入 HTTP request
oauth: object # 僅 oauth2 primitive
token_exchange: object # 僅 service_account primitive
# Secret 宣告(讓 UI 知道要問什麼)
required_secrets:
- key: string # secret 欄位名
label: string # UI 顯示
secret: boolean # 是否遮蔽顯示(default: true
type: enum # text | json_blob | pem_cert | pem_key | url
optional: boolean # default: false
help: string # 給使用者的提示
help_url: string # 導向服務文件
# 測試(驗證 credential 是否有效)
test:
method: GET | POST
path: string # 相對 base_url
expect_status: integer
expect_json: object # 選填,JSON path assertion
# Metadata
display_name: string # UI 顯示名
description: string
icon_url: string
docs_url: string
tags:
- communication
- crm
- ai
maintainers:
- github: username
# 可選:共通片段繼承
extends: string # recipe name,繼承其 schema 後覆寫
```
---
## 5. Recipe 繼承(reduce 重複)
Google 家族的 API 長得很像,重複寫 15 次太蠢。支援 `extends`
```yaml
# recipe/_google_base.yaml(底線開頭 = 抽象 recipe,不能直接用)
service: _google_base
version: 1
primitive: service_account
token_exchange:
endpoint: https://oauth2.googleapis.com/token
audience: https://oauth2.googleapis.com/token
ttl_seconds: 3600
required_secrets:
- key: service_account_json
type: json_blob
inject:
header:
Authorization: "Bearer {{runtime.access_token}}"
```
```yaml
# recipe/google_calendar.yaml
extends: _google_base
service: google_calendar
version: 1
base_url: https://www.googleapis.com/calendar/v3
token_exchange:
scopes:
- https://www.googleapis.com/auth/calendar
test:
method: GET
path: /users/me/calendarList
expect_status: 200
```
繼承規則:scalar 覆寫,object 深度合併,array 預設覆寫(可用 `!append` 標記 append)。
---
## 6. TinyGo WASM Primitive 實作介面
### 6.1 統一介面(四個 primitive 都實作這個)
```go
// primitive/interface.go
package primitive
type AuthRequest struct {
Method string
URL string
Headers map[string]string
Body []byte
}
type AuthContext struct {
Recipe Recipe // parsed YAML
Secret map[string]any // decrypted secret
Runtime RuntimeState // oauth token cache 等
Now int64 // for testing
}
type Primitive interface {
// 在 HTTP request 上注入認證資訊
Authenticate(req *AuthRequest, ctx *AuthContext) error
// 檢查是否需要 refreshoauth2 / service_account 用)
NeedsRefresh(ctx *AuthContext) bool
// 執行 refresh / token exchange,回傳新的 RuntimeState
Refresh(ctx *AuthContext) (RuntimeState, error)
// 驗證 credential 是否有效(執行 recipe.test
Test(ctx *AuthContext) error
}
```
### 6.2 編譯與部署
```bash
# 四個 primitive 各自編譯成獨立 WASM
tinygo build -o dist/static_key.wasm -target=wasi ./primitive/static_key
tinygo build -o dist/oauth2.wasm -target=wasi ./primitive/oauth2
tinygo build -o dist/service_account.wasm -target=wasi ./primitive/service_account
tinygo build -o dist/mtls.wasm -target=wasi ./primitive/mtls
# 部署時放到 Cloudflare Workers 的 Assets 或直接內嵌
```
### 6.3 Runtime 載入(Worker 端)
```typescript
// worker/src/auth-broker.ts
import staticKeyWasm from "../dist/static_key.wasm"
import oauth2Wasm from "../dist/oauth2.wasm"
// ...
const primitives = {
static_key: await instantiate(staticKeyWasm),
oauth2: await instantiate(oauth2Wasm),
service_account: await instantiate(serviceAccountWasm),
mtls: await instantiate(mtlsWasm),
}
```
> **為什麼 WASM 而不是直接 TS**
> 1. 跨 runtime 可攜性(未來若 arcrun 要跑在 Fly.io、local、或客戶自建環境,同一個 primitive 能用)。
> 2. 配合 u6u/arcrun 既定的 WASM 架構方向,不破壞統一性。
> 3. 沙箱化:primitive 只能透過明確的 host function 存取外部世界(網路、KV),降低惡意 recipe 攻擊面。
---
## 7. AuthBroker API(給 arcrun 其他部分調用)
```typescript
interface AuthBroker {
// Agent 執行時用的主要 API
bind(serviceId: string, secretRef: string, tenantId: string): Promise<AuthenticatedClient>
// 首次授權(僅 oauth2 用)
startAuth(serviceId: string, tenantId: string): Promise<{ authorizeUrl: string, state: string }>
completeAuth(state: string, code: string): Promise<{ secretRef: string }>
// 測試 credential
test(serviceId: string, secretRef: string, tenantId: string): Promise<TestResult>
// 管理
listRecipes(): Promise<Recipe[]>
getRecipe(serviceId: string): Promise<Recipe>
}
interface AuthenticatedClient {
fetch(path: string, init?: RequestInit): Promise<Response>
}
```
**使用範例(agent 端)**
```typescript
const notion = await authBroker.bind("notion", "notion-prod", ctx.tenantId)
const res = await notion.fetch("/databases/abc/query", {
method: "POST",
body: JSON.stringify({ filter: {...} })
})
```
**Agent 完全不需要知道是 API Key 還是 OAuth**——`authBroker.bind()` 回傳的 client 已經注入好認證,fetch 路徑用相對 base_url 的路徑即可。
---
## 8. Storage Layout
### 8.1 Recipe 儲存(arcrun 平台共享)
**Cloudflare KV namespace**`arcrun-recipes`
```
key: recipe/{service_id}
value: <recipe YAML 的 JSON 化版本>
key: recipe-list
value: [{ service_id, display_name, icon_url, tags }, ...] # 加速 UI 列表
```
**更新流程**
1. Recipe YAML 存在 arcrun 主 repo 的 `recipes/` 目錄下(version control + PR review)。
2. CI 跑 schema validator,通過後上傳到 KV。
3. UI 的 recipe 列表 5 分鐘 cache。
### 8.2 Secret 儲存(tenant 私有)
**雙層策略**
- **短期、低敏感** → tenant KV,用 AES-256-GCM 加密,key 從 Cloudflare Secrets Store 拿。
- **高敏感(如 service account JSON、private key** → 直接存 Cloudflare Secrets Storetenant KV 只存 reference。
```
# tenant KV namespace: arcrun-tenant-{tenant_id}
key: secret/{service_id}/{instance_name}
value: {
"recipe_version": 1,
"storage_mode": "kv_encrypted" | "secrets_store_ref",
"data": <encrypted blob> | { "ref": "secrets-store-id" },
"created_at": "...",
"last_verified_at": "..."
}
# oauth2 runtime stateprimitive 自動管理)
key: oauth_state/{service_id}/{instance_name}
value: {
"access_token": "...", # encrypted
"refresh_token": "...", # encrypted
"expires_at": 1234567890
}
```
**secretRef 格式**`{service_id}/{instance_name}`,例如 `notion/prod``google_calendar/workspace-a`
一個 tenant 可以同一個服務存多個 instance(多帳號場景)。
### 8.3 KBDB 整合(可選,但建議)
**按照 KBDB 架構,recipe metadata 可以用 Block + Template 表達**(不是 credential 本體,只是 metadata):
建立一個 `service_recipe` Template
```json
{
"name": "service_recipe",
"display_name": "服務 Recipe Metadata",
"schema": {
"fields": [
{"key": "service_id", "type": "text", "required": true, "description": "服務識別"},
{"key": "primitive", "type": "text", "required": true, "description": "使用的 primitive"},
{"key": "version", "type": "number", "required": true, "description": "Recipe 版本"},
{"key": "display_name", "type": "text", "required": false, "description": "顯示名稱"},
{"key": "docs_url", "type": "text", "required": false, "description": "文件 URL"},
{"key": "kv_key", "type": "text", "required": true, "description": "KV 實際存取 key"}
]
}
}
```
Secret **不**進 KBDBKBDB 不該存敏感資料),只有 metadata 在 KBDB 裡方便搜尋和關聯。
---
## 9. 首次授權 UI Flow(給人類看的部分)
這是「學員不知道該選哪個 credential 的痛點」的終結方案。
### 9.1 Static Key 的 UI
```
┌──────────────────────────────────────────┐
│ 連接 Notion │
├──────────────────────────────────────────┤
│ │
│ Internal Integration Token │
│ ┌────────────────────────────────────┐ │
│ │ secret_••••••••••••• │ │
│ └────────────────────────────────────┘ │
│ ↳ 如何取得?→ 開啟 Notion 整合設定頁 │
│ │
│ [ 測試連線 ] [ 儲存 ] │
└──────────────────────────────────────────┘
```
**零選項。** UI 從 recipe 的 `required_secrets` 動態生成。使用者不用選「這是 Header Auth 還是 Query Auth 還是 Custom Auth」——那是 recipe 的事,不是使用者的事。
### 9.2 OAuth2 的 UI
```
┌──────────────────────────────────────────┐
│ 連接 GitHub │
├──────────────────────────────────────────┤
│ │
│ [ 🔗 使用 GitHub 帳號登入 ] │
│ │
│ 將跳轉到 GitHub,授權後自動返回 │
│ │
└──────────────────────────────────────────┘
```
**一個按鈕。** Client ID / Secret 由 arcrun 平台統一管理(OAuth App 註冊在 arcrun 這邊),使用者看不到也不用知道。
### 9.3 Service Account 的 UI
```
┌──────────────────────────────────────────┐
│ 連接 Google Calendar │
├──────────────────────────────────────────┤
│ │
│ Service Account JSON │
│ ┌────────────────────────────────────┐ │
│ │ 將整份 JSON 貼到這裡 │ │
│ │ │ │
│ │ { │ │
│ │ "type": "service_account", │ │
│ │ "project_id": "...", │ │
│ │ ... │ │
│ │ } │ │
│ └────────────────────────────────────┘ │
│ │
│ 如何取得?→ 展開步驟說明 ▼ │
│ 1. 打開 GCP Console │
│ 2. IAM → Service Accounts │
│ 3. 建立 Service Account │
│ 4. Keys → Add Key → JSON │
│ 5. 下載後整份貼到上方 │
│ │
│ [ 測試連線 ] [ 儲存 ] │
└──────────────────────────────────────────┘
```
**貼 JSON + 按鈕**。不用寫任何程式碼,不用 debug 兩天。
---
## 10. 實作任務分解(CC 的 TODO list
### Phase 1:核心骨架(1-2 週)
- [ ] **T1.1** Recipe YAML schema 定義 + JSON Schema validator(放 `arcrun/schemas/recipe.schema.json`
- [ ] **T1.2** Recipe loader:從 `recipes/` 目錄讀 YAML → validate → 轉 JSON 存入 KV namespace `arcrun-recipes`
- [ ] **T1.3** TinyGo WASM 專案骨架(`arcrun/primitives/`),四個子目錄,統一 interface
- [ ] **T1.4** Worker runtime 的 WASM loader + host function(網路、KV 讀寫)
- [ ] **T1.5** `AuthBroker` TypeScript 類別骨架 + unit test
### Phase 2Static Key1 週)
- [ ] **T2.1** `static_key.wasm` 實作(header/query/body/basic_auth 四種注入)
- [ ] **T2.2** 寫三個 recipe`notion.yaml`, `openai.yaml`, `stripe.yaml`
- [ ] **T2.3** Tenant KV secret 加密寫入 + `AuthBroker.bind()` 整合
- [ ] **T2.4** `recipe.test` 執行器(驗證 credential 有效性)
- [ ] **T2.5** E2E test:存 secret → bind → fetch Notion API → assert
### Phase 3OAuth21-2 週)
- [ ] **T3.1** `oauth2.wasm` 實作(authorization_code + client_credentials + pkce
- [ ] **T3.2** OAuth callback endpoint(統一 URL,用 state 路由到正確 tenant/recipe
- [ ] **T3.3** Refresh token 自動續命邏輯(rate-limit 保護:同一 token 不能 1 秒內 refresh 多次)
- [ ] **T3.4** 寫三個 recipe`github.yaml`, `slack.yaml`, `google_oauth_user.yaml`
- [ ] **T3.5** UI flowstartAuth → 跳轉 → callback → 寫 secret
### Phase 4Service Account1 週)
- [ ] **T4.1** `service_account.wasm` 實作(google_jwt
- [ ] **T4.2** Google JWT signingES256 / RS256)— **這個 TinyGo 需要注意 crypto 支援**
- [ ] **T4.3** AWS SigV4 簽章實作(kind: aws_sigv4
- [ ] **T4.4** Recipe 繼承機制(`extends` 支援)
- [ ] **T4.5** 寫 recipes`_google_base`, `google_calendar`, `google_drive`, `gmail`, `aws_s3`
### Phase 5mTLS + 收尾(1 週)
- [ ] **T5.1** `mtls.wasm` 實作(對接 Cloudflare `mTLSCertificate` binding
- [ ] **T5.2** Cloudflare Secrets Store 整合(高敏感 secret 用)
- [ ] **T5.3** Recipe marketplace UI(列出可用 recipe,搜尋,一鍵設定)
- [ ] **T5.4** Observability:每次 bind / refresh / test 記錄到 KBDBmetadata,不含 secret
- [ ] **T5.5** Docs:recipe 撰寫指南(讓社群能貢獻)
### Phase 6Recipe 生成器(選配,1 週)
- [ ] **T6.1** 給 Claude 一份 API doc,自動產 recipe YAML 草稿 + 人類 review 介面
- [ ] **T6.2** 從 OpenAPI spec 自動推論 recipe
- [ ] **T6.3** 從 n8n credential file 反向轉譯(擷取 400+ 現成整合)
---
## 11. 關鍵技術風險與對策
| 風險 | 對策 |
|---|---|
| **TinyGo 的 crypto 支援不完整**ES256 / RS256 JWT 簽章) | 先用 `crypto/rsa` + `crypto/ecdsa` 確認 TinyGo 版本支援;若不行,fallback 用 Worker runtime 的 `crypto.subtle` 實作這部分,WASM 透過 host function 呼叫 |
| **Recipe 被惡意提交**(如 inject 內含 `https://evil.com` 當 token_url | Recipe 走 PR review + CI 自動檢查 URL 白名單;社群貢獻的 recipe 預設隔離在 `community/` 目錄,使用者明確選擇才啟用 |
| **OAuth state CSRF** | state 用 `crypto.randomUUID()` + 5 分鐘 TTL,存在 KVcallback 時比對 |
| **Secret 在 Worker log 外洩** | `AuthContext.Secret` 禁止 `toString` / `JSON.stringify`,用 Proxy 攔截;log 層強制 redact |
| **Token refresh 風暴**100 個並發 request 同時發現過期) | 用 Durable Object 單執行緒化每個 secret 的 refresh,其他 request 等結果 |
| **TinyGo WASM bundle size** | 四個 primitive 分開編譯,最大 500KB/個;lazy load |
| **Recipe 版本升級破壞相容** | `version` 欄位 semvertenant secret 記錄 `recipe_version`primitive 內處理遷移 |
---
## 12. 對比 n8n(給內部 review / 行銷用)
| 維度 | n8n | arcrun |
|---|---|---|
| Credential types 數量 | 400+(一個服務一個) | 4primitive + N recipe |
| 新增一個服務 | 寫 TypeScript class + rebuild + npm publish | 寫一份 YAML + PR merge |
| AI agent 使用 | 需要讀 node 文件 + 猜參數 | 讀 recipe YAML 即可 |
| 使用者首次設定 | 從 400+ 選項選一個(常選錯) | 搜尋服務名,只問必要 secret |
| OAuth App 管理 | 使用者自己註冊 OAuth app | arcrun 平台統一管理(使用者只需點「授權」) |
| 社群貢獻成本 | 高(TS + 編譯 + 測試) | 低(YAML + 測試) |
---
## 13. 接下來的決策點(需要 richblack 確認)
- [ ] **Recipe 版本管理策略**:採用 semver?每個 recipe 獨立版本?還是整個 recipe set 一個版本?
- [ ] **OAuth App 註冊**:arcrun 平台要統一註冊幾個主流服務的 OAuth AppGitHub、Google、Slack、Microsoft)?還是讓 tenant 自己帶 client_id/secret
- 建議:**雙模式**——平台模式(方便)+ BYO 模式(企業客戶用自己的 OAuth app 有稽核好處)
- [ ] **Recipe registry 的審核流程**:完全開放 PR 還是僅核心團隊維護?
- 建議:`recipes/official/`(核心維護)+ `recipes/community/`(PR 審核後 merge,使用者需明確啟用)
- [ ] **Secret rotation 政策**:要不要內建提醒 / 自動 rotate?(Phase 7+
---
## 14. 附錄:完整範例
### A. 最小可行 recipeOpenAI
```yaml
service: openai
version: 1
primitive: static_key
display_name: OpenAI
base_url: https://api.openai.com/v1
required_secrets:
- key: api_key
label: API Key
help_url: https://platform.openai.com/api-keys
inject:
header:
Authorization: "Bearer {{secret.api_key}}"
test:
method: GET
path: /models
expect_status: 200
tags: [ai]
```
### B. OAuth2 recipeSlack
```yaml
service: slack
version: 1
primitive: oauth2
display_name: Slack
base_url: https://slack.com/api
grant: authorization_code
oauth:
authorize_url: https://slack.com/oauth/v2/authorize
token_url: https://slack.com/api/oauth.v2.access
scopes:
- chat:write
- channels:read
refresh: true
client_auth: header
inject:
header:
Authorization: "Bearer {{runtime.access_token}}"
test:
method: POST
path: /auth.test
expect_json:
ok: true
tags: [communication]
```
### C. Service Account recipeGoogle Calendar
```yaml
extends: _google_base
service: google_calendar
version: 1
display_name: Google Calendar
base_url: https://www.googleapis.com/calendar/v3
token_exchange:
scopes:
- https://www.googleapis.com/auth/calendar
test:
method: GET
path: /users/me/calendarList
expect_status: 200
tags: [calendar, google]
```
---
## 15. 給 CC 的行動指引
1. **先不要動既有 arcrun 的 credential 相關 code**,保持現狀到 Phase 2 完成再切換。
2. **Phase 1 + 2 是 MVP**,做完可以接 80% 服務(API key 類)。
3. **遇到 TinyGo 的技術阻礙(特別是 crypto),立刻回報**,不要自己 workaround 兩天。
4. 每個 Phase 完成後寫一份 brief report(能跑什麼、不能跑什麼、下一步)。
5. Recipe 撰寫先做 3-5 個手工範例,**確認 schema 夠用再開始批量生成**。
@@ -0,0 +1,521 @@
# u6u-core 獨立開源 Repo 需求規格 v3
## 背景與定位
### 為什麼開源
u6u-core 是 AI 工作流執行引擎,開源的護城河邏輯如下:
```
開源(u6u-core 閉源(InkStone 付費服務)
──────────────────────── ──────────────────────────────
cypher-executor(執行引擎) KBDB 向量搜尋
WASM 零件庫(Gmail / GSheets…) KBDB graph 查詢
credentials Worker Persona SDK / Mini-me
CLI MatchGPT
→ 需要訂閱,不需要 YAML / KV
```
用戶自架版:YAML + CF KV,完全免費。
升級版:不需要 YAML,直接用自然語言查 KBDB 圖譜組 workflow,這是差異化。
### 目前 matrix repo 狀況
```
matrix/
├── cypher-executor/ ← 要搬進 u6u-core
├── u6u-core/
│ ├── builtins/
│ ├── credentials/
│ └── registry/
│ └── components/21 個 WASM 零件)
└── ...(其他 InkStone 內部服務,不搬)
```
---
## 任務一:搬移 cypher-executor 進 u6u-core
### 目標結構
```
u6u-core/(新獨立 repo,開源)
├── README.md
├── cypher-executor/ ← 從 matrix/cypher-executor 搬入
│ ├── src/
│ ├── wrangler.toml ← 需要清理(移除 InkStone 內部 bindings
│ └── ...
├── credentials/ ← 從 matrix/u6u-core/credentials 搬入
├── builtins/ ← 從 matrix/u6u-core/builtins 搬入
└── registry/
└── components/ ← 從 matrix/u6u-core/registry/components 搬入
```
### wrangler.toml 清理(重要)
現有 `cypher-executor/wrangler.toml` 有大量 InkStone 內部 Service Bindings,開源版要移除:
**移除(InkStone 專屬,不公開):**
```toml
# 移除這些 services bindings
KBDB → inkstone-kbdb-api
REGISTRY → inkstone-component-registry
CLINIC_GDRIVE → clinic-gdrive
CLINIC_EXCEL → clinic-excel
CLINIC_ANALYSIS
CLINIC_RENDER
CLINIC_GSHEETS
AICEO → inkstone-aiceo-bot
MINI_ME → inkstone-mini-me
```
**保留(用戶自己部署需要的):**
```toml
[[kv_namespaces]]
binding = "EXEC_CONTEXT" # 執行上下文暫存
[[kv_namespaces]]
binding = "WEBHOOKS" # workflow YAML 儲存
[[r2_buckets]]
binding = "WASM_BUCKET" # WASM 零件二進位
[ai]
binding = "AI" # Workers AIauto-publish 用)
```
**新增(開源版用戶需要的):**
```toml
[[kv_namespaces]]
binding = "CREDENTIALS_KV" # credential 加密存儲
```
### 清理後的 component-loader
現有 component-loader 可能有 InkStone 內部查詢邏輯(KBDB HTTP fetch),
開源版改為:**直接從 WASM_BUCKET R2 讀取 `.wasm` 檔案**,不依賴任何外部服務。
---
## 任務二:零件完成度審查與補充
### 完成度標準
每個零件的 `component.contract.yaml` 必須包含:
```yaml
# 已有(現狀)
canonical_id: "gmail"
input_schema: ...
output_schema: ...
gherkin_tests: ...
# 需要補充
credentials_required: # 需要 token 的零件才需要此欄位
- key: gmail_token # 對應 credentials.yaml 的 key 名稱慣例
type: google_oauth # token 類型
description: "Google OAuth access tokengmail.send scope"
inject_as: access_token # 執行時自動注入到 input 的哪個欄位
config_example: | # scaffold 指令產出的範本,帶說明註解
send_email: # 節點名稱(可自訂)
to: "" # 收件人 Email(必填)
subject: "" # 主旨(必填)
body: "" # 內文(必填)
# access_token 由 credentials.yaml 的 gmail_token 自動注入
```
### 需要 credentials_required 的零件
| 零件 | 需要的 token | inject_as |
|------|-------------|-----------|
| gmail | google_oauth | access_token |
| google_sheets | google_oauth | access_token |
| telegram | telegram_bot_token | bot_token |
| line_notify | line_token | token |
| http_request | 不固定(用戶自訂) | 不適用 |
### 不需要 credentials_required 的零件
set, filter, merge, switch, wait, if_control, foreach_control,
try_catch, validate_json, string_ops, number_ops, array_ops,
date_ops, cron, ai_transform_compile, ai_transform_run
### 審查任務(給 CC
對 21 個零件逐一檢查,**只回報,不修改**:
```
路徑:u6u-core/registry/components/
檢查四項:
1. contract.yaml 存在?
2. 有 credentials_required?(需要 token 的才需要)
3. 有 config_example
4. main.go required 欄位與 contract input_schema required[] 一致?
回報格式:表格(✓ / ✗ / N/A)+ 每個零件缺少什麼
不修改任何檔案。
```
審查完成後,再逐一補充缺少的欄位。
---
## 任務三:workflow YAML 格式定義
### 格式設計原則
- `flow:``>>` 三元組描述資料流,人類直接看懂
- 關係詞使用有語意的詞,**不使用 PIPE**(PIPE 等於什麼都沒說)
- `config:` 用零件名稱對應參數,欄位從 contract 的 config_example 來
- credential 全部集中在 `credentials.yaml`workflow 只寫 `{{creds.KEY}}`
### 可用關係詞
| 關係詞 | 語意 | 使用時機 |
|--------|------|---------|
| `完成後` | 前一個成功後執行 | 最常用的串接 |
| `失敗時` | 前一個失敗後執行 | 錯誤處理 |
| `對每個` | 對陣列每個元素執行 | 迭代 |
| `條件滿足時` | 條件分支 | 判斷 |
| `ON_SUCCESS` | 同「完成後」 | 英文版 |
| `ON_FAIL` | 同「失敗時」 | 英文版 |
| `FOREACH` | 同「對每個」 | 英文版 |
| `IF` | 同「條件滿足時」 | 英文版 |
| `ON_CLICK` | 前端按鈕觸發 | UI 互動 |
| `CALLS_SUBFLOW` | 呼叫子工作流 | 模組化 |
**禁止使用 PIPE** — 任何串接都應該用有語意的關係詞。
### workflow.yaml 範例
```yaml
name: newsletter_subscribe
description: 訂閱電子報,發感謝信並記錄到 GSheets
flow:
- "input >> 完成後 >> send_thanks"
- "input >> 完成後 >> save_to_sheet"
- "send_thanks >> 完成後 >> output"
- "send_thanks >> 失敗時 >> notify_error"
- "save_to_sheet >> 完成後 >> output"
config:
send_thanks: # componentId: gmail(由 cypher-executor 語意搜尋對應)
to: "{{input.email}}"
subject: "感謝訂閱!"
body: "歡迎加入!"
# access_token 由 credentials.yaml 的 gmail_token 自動注入
save_to_sheet: # componentId: google_sheets
action: write
spreadsheet_id: "{{creds.sheet_id}}"
range: "訂閱者!A:B"
values: [["{{input.email}}", "{{input.timestamp}}"]]
# access_token 由 credentials.yaml 的 google_oauth 自動注入
notify_error: # componentId: telegram
chat_id: "{{creds.telegram_chat_id}}"
text: "發信失敗:{{input.email}}"
# bot_token 由 credentials.yaml 的 telegram_bot_token 自動注入
```
### credentials.yaml 範例
```yaml
# credentials.yaml — 類似 .env,加入 .gitignore,不進 git
# u6u creds push 時逐一加密上傳到 CREDENTIALS_KV
gmail_token: "ya29.a0AfB_..."
google_oauth: "ya29.a0AfB_..."
sheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
telegram_bot_token: "123456:ABC-..."
telegram_chat_id: "987654321"
```
### 執行時 credential 注入流程
```
u6u run newsletter_subscribe
cypher-executor 讀 workflow YAML
遇到節點 send_thanks → 查 contractcredentials_required.inject_as = access_token
去 CREDENTIALS_KV 讀 gmail_token → 解密
注入到 WASM input{ to, subject, body, access_token: "ya29..." }
WASM 執行,用戶的 config 裡完全不出現 token
```
---
## 任務四:CLI 開發
### 技術選型
- **語言**Node.jsTypeScript
- **安裝**`npm i -g u6u`
- **依賴**`commander``js-yaml``chalk``ora`
### 指令規格
#### `u6u init`
互動式初始化,產生 `~/.u6u/config.yaml` 和本機 `credentials.yaml`
```
$ u6u init
? Cloudflare Account ID: abc123
? KV Namespace ID (WEBHOOKS): xyz789
? KV Namespace ID (CREDENTIALS_KV): abc456
? R2 Bucket name (WASM_BUCKET): u6u-wasm
? Cypher Executor Worker URL: https://cypher-executor.xxx.workers.dev
? Credentials Worker URL: https://u6u-credentials.xxx.workers.dev
? Cloudflare API Token: ***
✓ 設定完成 → ~/.u6u/config.yaml
✓ 建立 credentials.yaml(已加入 .gitignore
```
---
#### `u6u creds push [credentials.yaml]`
讀取 credentials.yaml,逐一加密上傳到 CREDENTIALS_KV。
```
$ u6u creds push
讀取 ./credentials.yaml...
✓ gmail_token → 已加密上傳
✓ google_oauth → 已加密上傳
✓ sheet_id → 已上傳
✓ telegram_bot_token → 已加密上傳
✓ telegram_chat_id → 已上傳
共上傳 5 個 credentials
```
---
#### `u6u push <workflow.yaml>`
解析 `flow:` 三元組,轉換成 triplets 陣列,上傳到 WEBHOOKS KV。
```
$ u6u push newsletter_subscribe.yaml
✓ 已上傳 newsletter_subscribe → WEBHOOKS KV
Webhook: https://cypher-executor.xxx.workers.dev/webhook/abc123
```
轉換邏輯(CLI 負責):
```
flow[] 三元組
POST /cypher/search(取得 ExecutionGraph
連同 config 存入 WEBHOOKS KV
```
---
#### `u6u run <workflow_name> [--input key=value...]`
觸發執行,顯示結果。
```
$ u6u run newsletter_subscribe --input email=test@example.com
⏳ 執行中...
✓ 完成(2.3s
結果:
send_thanks: { success: true, data: { message_id: "xxx" } }
save_to_sheet: { success: true, data: { range: "訂閱者!A2" } }
```
錯誤時給出具體修復步驟:
```
✗ 執行失敗:節點 send_thanks
原因: access_token 無效(401 Unauthorized
修復方式:
1. 更新 credentials.yaml 的 gmail_token
2. 執行 u6u creds push
3. 重新執行 u6u run newsletter_subscribe
取得 Google OAuth token
→ https://developers.google.com/oauthplayground
```
---
#### `u6u validate <workflow.yaml>`
執行前完整驗證,提前發現問題。
```
$ u6u validate newsletter_subscribe.yaml
✓ YAML 格式正確
✓ flow 三元組語法正確
✓ 所有關係詞有效(無 PIPE
✓ 所有節點名稱在 config 有對應
✓ 所有零件存在於 WASM_BUCKET
✓ credentials 對應:
gmail_token ✓ 已上傳
google_oauth ✓ 已上傳
sheet_id ✓ 已上傳
telegram_bot_token ✗ 缺少
⚠ 缺少 1 個 credential
telegram_bot_token → 請加入 credentials.yaml 並執行 u6u creds push
```
---
#### `u6u parts`
列出可用零件。
```
$ u6u parts
可用零件(21):
[整合]
• gmail Gmail 發信
需要: to, subject, body
credential: gmail_tokengoogle_oauth
• google_sheets 讀寫 Google 試算表
需要: spreadsheet_id, range, action
credential: google_oauth
• telegram Telegram Bot 發訊息
需要: chat_id, text
credential: telegram_bot_token
• line_notify LINE Notify
需要: message
credential: line_token
• http_request 任意 HTTP 請求
需要: url
[控制]
• if_control 條件分支
• foreach_control 迭代執行
• try_catch 錯誤處理
• switch 多路路由
• wait 等待 N 毫秒
[資料]
• set / filter / merge / string_ops / number_ops / array_ops / date_ops
[AI]
• ai_transform_compile 自然語言 → JS 轉換函式
• ai_transform_run 執行已編譯的轉換
```
---
#### `u6u parts scaffold <component>`
從 contract 的 config_example 產出可直接貼入 workflow 的 config 範本。
```
$ u6u parts scaffold gmail
貼入 workflow.yaml 的 config 區塊:
send_email: # 節點名稱(可自訂)
to: "" # 收件人 Email(必填)
subject: "" # 主旨(必填)
body: "" # 內文(必填)
# access_token 由 credentials.yaml 的 gmail_token 自動注入
貼入 credentials.yaml
gmail_token: "" # Google OAuth token
# 取得方式:https://developers.google.com/oauthplayground
```
---
#### `u6u list`
列出 WEBHOOKS KV 中所有 workflow。
```
$ u6u list
• newsletter_subscribe (更新: 2026-04-16)
• daily_summary (更新: 2026-04-15)
```
---
#### `u6u logs <workflow_name>`
查看最近執行記錄。
```
$ u6u logs newsletter_subscribe
2026-04-16 14:30 ✓ 成功 2.1s
2026-04-16 09:00 ✗ 失敗 send_thanks: 401 Unauthorized
2026-04-15 09:00 ✓ 成功 1.8s
```
---
## 開發順序
### Phase 1:搬移與清理(先做)
```
1. 建立新的獨立 repou6u-core
2. 從 matrix 搬入:
- cypher-executor/
- u6u-core/credentials/
- u6u-core/builtins/
- u6u-core/registry/
3. 清理 cypher-executor/wrangler.toml(移除 InkStone 內部 bindings
4. 確認 component-loader 只依賴 WASM_BUCKET,不依賴 KBDB / REGISTRY
5. 本機部署測試
```
### Phase 2:零件完成度(搬移後)
```
6. 審查 21 個零件的 contract.yaml
7. 補充 credentials_requiredgmail, google_sheets, telegram, line_notify
8. 補充 config_example(全部 21 個)
9. 驗證 main.go required 欄位與 contract 一致
```
### Phase 3CLI(完成度補充後)
```
10. u6u init
11. u6u creds push
12. u6u push
13. u6u run(含 credential 自動注入)
14. u6u parts / u6u parts scaffold
15. u6u validate
16. u6u list / u6u logs
```
### Phase 4:開源發布
```
17. 撰寫 README.md(快速開始、零件列表、workflow 語法說明)
18. 撰寫 CONTRIBUTING.md(如何新增零件)
19. 發布到 GitHub
20. npm publishu6u CLI
```
---
## 不在此次範圍
- KBDB 整合(未來付費服務)
- 向量搜尋 / graph 查詢
- 前端管理介面
- Webhook trigger 設定(用戶自行設定 CF Cron)
- 新增 WASM 零件(現有 21 個先做完整,之後再擴充)
@@ -0,0 +1,89 @@
# **u6u 智慧前端與工匠 AI 開發藍圖 (v4.0)**
## **一、 核心設計理念:意圖導向的雙面畫布**
u6u 的前端不是傳統的平面繪圖板,而是一個類似 Android Studio 或 Figma 的\*\*「結構化標籤編輯器」**。 畫布上的每一個元件(Web Component),本質上都是一個**「意圖發射器 (Intent Emitter)」\*\*。前端只負責「長得好看」與「收集人類動作」,後端全權負責「業務邏輯」。
### **人機協作的「雙向同步」**
* **AI 詠唱修改:** 人類說「把按鈕改醒目一點」,CEO AI 在背景將 \<u6u-btn color="blue"\> 修改為 \<u6u-btn color="neon-red"\>,畫面瞬間更新。
* **人類手動覆寫:** 人類覺得 AI 調的紅色太暗,直接在右側屬性面板 (Properties Panel) 點選色碼器,底層 HTML 屬性隨之改變。**AI 也能「看見」這個改變,從中學習人類的審美偏好。**
## **二、 畫布介面設計與運作機制**
### **1\. 正反面翻轉機制 (The "Flip" Interface)**
每個 UI 零件在畫布上都有「一體兩面」:
* **正面 (UI 視圖)** 顯示 HTML 渲染的視覺結果(按鈕、溫度計、圖表)。人類可以在此調整 CSS 屬性、對齊方式與主題顏色。
* **反面 (邏輯視圖)** 點擊「翻面」按鈕後,會進入底層的工作流設定。這裡使用 u6u 的自定義 Cypher 視覺化語法(例如 \>\> 符號)。
* *範例:* \[ UI\_Button: "緊急停機" \] \>\> (Intent: emergency\_stop) \>\> \[ WASM: gsheets\_create \]
### **2\. 智慧容器與區域感知 (Smart Zone Awareness)**
為了消滅傳統 iPaaS(如 n8n)最痛苦的「手動變數綁定」,u6u 畫布具備「區域感知」能力。
* **底層邏輯(獨立元件):** 畫布上的 TextInput 與 Button 都是各自獨立的原子元件。
* **麻瓜體驗(智慧表單):** 當使用者將這兩個元件拖入同一個排版容器(例如 \<u6u-card\>)時,系統會自動建立上下文關聯。當按下按鈕並觸發 Webhook 時,按鈕會**自動打包同容器內所有輸入框的值**一併送出:
{
"intent": "query\_attendance",
"payload": { "employee\_id": "A1234" } // 自動從旁邊的 TextInput 抓取
}
使用者完全不需要理解「表單傳值」或「變數綁定」,拖拉組合即生效。
### **3\. 多重事件插槽與靜態屬性 (Multi-Event Slots)**
一個前端元件可以具備多種觸發行為,系統透過介面將「視覺」與「後端邏輯」徹底分流:
* **靜態視覺註釋:** 例如 mouseover 顯示提示。使用者只需在屬性面板輸入 Tooltip 文字,底層僅修改 HTML 屬性 \<u6u-btn tooltip="..."\>,不消耗任何伺服器資源或 Webhook。
* **動態意圖綁定:** 在「反面」邏輯視圖中,使用者可以針對不同事件綁定不同的工作流:
* ⚡ When: 點擊 (onClick) ➡️ \[ 綁定至 Webhook A:送出查詢 \]
* ⚡ When: 獲得焦點 (onFocus) ➡️ \[ 綁定至 Webhook B:載入歷史紀錄 \]
### **4\. 智慧上下文替換 (Smart Contextual Substitution)**
當主管在畫布上對著一個已連接 Webhook 的「按鈕」點擊右鍵選擇「替換元件」時:
* 系統讀取反面的 Cypher 連線,發現需要發射一個 trigger 意圖。
* 系統過濾 KBDB 零件宇宙,**只顯示相容的 UI 零件**(如下拉選單、開關)。不具備 trigger 能力的元件(如純文字標籤)會被自動隱藏,確保替換後系統絕對不會報錯。
## **三、 原子化組裝與極致解耦:CEO AI 與工匠 AI 的分工**
當企業主管提出需求:「我需要一個『輸入工號即可查詢員工打卡紀錄』的工具」時,這在 u6u 中**並不是一個單一零件**,而是一個由多個「原子零件」構成的**工作流 (Workflow)**。
### **1\. CEO AI 的動態組裝 (Macro Assembly)**
面對需求,大腦 AI (CEO AI) 會快速從 KBDB 挑選現成積木進行組合:
* **前端 Prototype 組合:** \<u6u-text-input\> \+ \<u6u-btn\> \+ \<u6u-text-field\>。
* **後端 Pseudo Code 組合:** webhook\_receiver \>\> check\_kbdb(template\_name, value)。
AI 會自動用 Cypher 將前端的表單意圖連線到後端工作流。對不懂程式的主管來說,前端就是 Prototype,翻面的 Cypher 就是 Pseudo Code,整套系統瞬間組合完畢。
### **2\. 工匠 AI (Forge AI) 的原子生產線**
只有當現有零件庫缺乏特定原子時,機甲才會喚醒工匠 AI 進行開發。
**解耦哲學:** 後端零件開發時,根本不需要管前端零件長什麼樣子(是按鈕還是輸入框)。只要前端送來的 JSON 它能吃,就是合法的候選零件。
* **Step 1: 規格定義 (Interface Contract)**
工匠 AI 只在乎接收與回傳的 JSON 格式。
* **Step 2: 打造純粹的邏輯黑箱 (後端 TinyGo WASM)**
工匠 AI 撰寫 Go 程式,編譯成 .wasm。絕對純粹的後端邏輯,沒有任何介面程式碼。
* **Step 3: 獨立的前端零件生產 (若需要)**
獨立生成 Web Component(如 \<u6u-3d-pie-chart\>),只負責接收特定 JSON 來渲染畫面。
* **Step 4: 註冊與編目 (Cataloging into KBDB)**
新積木註冊到圖資料庫,未來的 CEO AI 即可將其與任何既有的前端或後端元件進行無限的叉積組合。
## **四、 架構總結與終極產品體驗**
這套前端架構讓 u6u 成為一個\*\*「表裡如一」**的系統,成功創造了**「麻瓜的 ERP 幻覺」\*\*:
使用者不需要知道什麼是「前後端分離」、什麼是「API 串接」。他們只是覺得:
1. 拖拉了一個溫度計。
2. 翻面把「數值更新」連線到「機台感測器」。
3. 拖拉了一個紅按鈕放在旁邊。
4. 翻面把「點擊」連線到「發送 Line 警報」。
在十分鐘的「繪圖」過程中,沒有寫一行程式碼,也沒有設定任何變數。但透過**前端 Web Components** 的視覺封裝、**智慧容器**的自動資料打包,以及**後端 TinyGo WASM \+ Cypher** 的無縫承接,他們在不知不覺中,就搭建出了一套具備微服務架構、高擴展性、且可部署至極限邊緣的企業級系統。
@@ -0,0 +1,136 @@
# **u6u 系統與零件宇宙全景規劃白皮書 (The u6u Ecosystem Blueprint)**
## **1\. 核心理念與願景**
u6u 旨在解決傳統 Workflow 軟體 (如 n8n) 存在的「單線程、沈重、複雜、難以組成系統」的痛點。
透過結合 Cloudflare Workers (輕量邊緣運算) 與 Cypher (圖形資料庫關係),u6u 提供一個由 AI 驅動的「意圖到系統」生成平台。所有的系統功能皆被拆解為可複用、可組合的「零件 (Components)」,並在一個會自然淘汰、自我修復的「零件宇宙 (Component Universe)」中演化。
## **2\. 四層架構拆解 (Four-Tier Architecture)**
u6u 的工作模式採取由上到下 (Top-Down) 的 Break-down 機制:
1. **Polaris (北極星層 / 意圖層):**
* 用戶以自然語言描述商業模式與想法(例如:「我要做一個 AI 客服表單系統」)。
* 這是整個系統的起點,AI 會根據 Polaris 將意圖拆解為 Prototype。
2. **Prototype (原型 / 前端層):**
* 定義前端的版型、頁面描述、UI 元件以及它們的屬性。
* 作為使用者互動的入口,透過觸發事件 (Triggers) 連接到後端 Workflow。
3. **Workflow (工作流層):**
* 系統的 Orchestrator (編排者),定義業務邏輯的走向。
* 透過 Cypher 語法與三元組,定義每個節點 (Component) 的執行順序與條件分支。
4. **Component (零件層 / 節點):**
* 最底層的執行單元,主要分為兩類:
* **功能型 (Logic):** 迴圈、條件判斷、資料轉換、統計等 (透過 CF Workers 執行 JS 邏輯)。
* **介接型 (API):** 呼叫外部服務 (Webhook, HTTP Request)。
## **3\. 統一描述語言:擴展三元組與跨層級 YAML**
為了解決跨 YAML 檔案串接的問題,u6u 採用易於人類閱讀與 AI 生成的 **「A \>\> 關係 \>\> B」** 三元組語法,結合自定義的 URI 協議 (workflow://, component://, ui://),實現跨層級的連結。
### **綜合 YAML 範例與三元組串接**
*\# 1\. Prototype YAML (描述前端)*
kind: Prototype
id: ui\_dashboard
triplets:
*\# 結構與版型零件*
\- "ui\_dashboard \>\> CONTAINS \>\> layout\_admin"
\- "layout\_admin \>\> CONTAINS \>\> btn\_submit"
*\# UI 零件與屬性零件 (CSS/行為)*
\- "btn\_submit \>\> IS\_A \>\> ui://components/Button"
\- "btn\_submit \>\> HAS\_STYLE \>\> style://tokens/GlowEffect"
\- "btn\_submit \>\> HAS\_BEHAVIOR \>\> anim://motions/Pulse"
*\# 跨層級串接:前端觸發 Workflow*
\- "btn\_submit \>\> ON\_CLICK \>\> workflow://workflows/process\_data.yaml"
*\# 2\. Workflow YAML (描述工作流編排)*
kind: Workflow
id: wf\_process\_data
triplets:
*\# 跨層級串接:Workflow 呼叫 Component*
\- "START \>\> TRIGGERS \>\> step\_validate"
\- "step\_validate \>\> IS\_A \>\> component://components/validate\_json"
*\# Workflow 節點間的流轉 (轉譯為 Cypher 關係)*
\- "step\_validate \>\> ON\_SUCCESS \>\> step\_call\_api"
\- "step\_validate \>\> ON\_FAIL \>\> step\_notify\_error"
*\# 跨 Workflow 串接*
\- "step\_call\_api \>\> CALLS\_SUBFLOW \>\> workflow://workflows/save\_to\_db.yaml"
## **4\. 零件宇宙 (Component Universe) 的審核與淘汰機制**
在 u6u 中,所有的 UI、Style、Logic、API 都是「零件」。當 AI 發現缺乏所需零件時,會自動創造它。為了確保生態系的健康,必須建立嚴格的**審核標準**與**自然淘汰機制**。
### **4.1 零件的創建與審核標準 (Pass/Fail Criteria)**
當 AI 或開發者提交一個新零件時,系統會啟動自動化沙盒測試。必須完全通過以下標準,零件才能進入「宇宙」供他人使用:
1. **功能型零件 (Logic Components):**
* **Gherkin BDD 驗收:** 必須附帶 Feature/Scenario 測試規格,且執行結果 100% 通過 (例如:Given input JSON, When split, Then returns Array)。
* **效能門檻:** 邊緣運算 (CF Workers) 執行時間需低於設定閾值 (例如 \< 50ms),無記憶體洩漏。
2. **介接型零件 (API Components):**
* **連線驗證:** 端點 (Endpoint) 必須能 ping 通,或回傳正確的 2xx HTTP Status (提供 Mock Payload 測試)。
* **Credential 安全:** 不可將 Token 或 Secret 寫死在代碼中,必須嚴格宣告所需的 Environment Variables 規格。
3. **前端與屬性零件 (UI & Style Components):**
* **渲染驗證:** CSS / 組件代碼不能導致瀏覽器 Crash。
* **相容性檢查:** 不可包含嚴格衝突的樣式 (例如寫死 \!important 破壞全域版型)。
### **4.2 零件宇宙的自然淘汰 (Natural Selection)**
零件一旦上架,將面臨殘酷的達爾文機制:
* **AI 偏好權重:** AI (透過 MCP 搜尋時) 會優先選擇「成功率高、執行速度快、被調用次數多」的零件。
* **降級與墓地:** 連續 30 天無人/無 AI 使用,或錯誤率飆升的零件,會被降級 (Deprecated)。最終轉入「零件墓地」,從首選搜尋清單中剔除。
## **5\. 系統自癒與 AI 避坑機制 (Auto-Healing & Pitfall Avoidance)**
這是 u6u 維持系統穩定運作的最核心機制。工作流不只要能跑,跑完後還必須經歷 **「強制 AI 評價 (Mandatory AI Evaluation)」**。
### **5.1 運行後的強制評價迴圈**
每當一個 Workflow 在 CF Workers 上執行完畢 (或發生異常中斷),系統攔截日誌並強制啟動 AI 評價代理 (Evaluator Agent)。
* **評估維度:**
* **狀態:** 成功 / 失敗 (Crash) / 逾時 (Timeout)。
* **效能:** 耗時是否合理 (例如 API 突然變得很慢)。
* **警告訊息:** 資源消耗過大、API 回傳即將停用的 Warning。
### **5.2 自癒與避坑流程 (The Feedback Loop)**
當 Evaluator Agent 發現問題時,會觸發以下流程:
1. **回報與通知 (Notify):** 系統自動生成修復 Ticket,並通知當初建立該零件/工作流的製作人 (或系統管理員)。
2. **AI 嘗試修復 (Auto-Fix):** 系統派遣「修復型 AI」嘗試讀取錯誤日誌並修復代碼 (例如:API 規格變更導致 JSON 解析錯誤,AI 自動修改解析邏輯)。
3. **驗收與部署:** 修復後的代碼若通過 Gherkin 驗收,則無縫熱更新。
4. **避坑標記 (Pitfall Marking):** \- 如果 AI 無法修復 (例如:外部第三方 API 永久倒閉,或邏輯存在根本性死結)。
* 系統會在 Cypher 圖形資料庫中,將該零件或該特定的三元組關係標記為 \[HAS\_PITFALL\]。
* **結果:** 下一個生成系統的 AI 在透過 MCP 搜尋時,會讀取到這個坑的紀錄,並**強制繞道**,改用其他方案或生成新的零件,實現「前人踩坑,後 AI 避坑」的群體智慧。
## **6\. 結論**
u6u 不是一個單純的開發工具,它是一個**生物體積木系統**。
透過「三元組」統一語言打破系統壁壘,透過「零件審核」保證基因優良,再透過「強制評價與避坑機制」實現演化。當這套系統運轉起來,AI 就能在其中無止盡地為人類組裝出越來越強大、越來越穩定的商業應用。
@@ -0,0 +1,99 @@
# **u6u 自動演化 ERP:全端統一架構規格書 (v3.0)**
## **1\. 架構核心思想 (The Core Philosophy)**
u6u ERP 是一套具備自我修復與功能擴充能力的「有機體」系統。
為確保系統在跨國雲端、機密地端與斷網邊緣皆能無縫運作,系統採用\*\*「向下相容的絕對標準化」\*\*:由最嚴苛的無人機環境來定義全域零件標準。
系統運作依賴三位一體的語言與載體:
1. **大腦戰略層 (Markdown / Gherkin)** CEO AI 負責閱讀與撰寫,定義全域戰略、系統設計文件 (SDD) 與商業演算法則 (如 ROI 門檻)。
2. **神經編排層 (Cypher)** u6u 引擎的核心。AI 透過撰寫 Cypher 語法來進行業務邏輯的動態編排、狀態流轉與意圖攔截。
3. **肌肉執行層 (TinyGo WASM)** 系統中**唯一合法**的零件規格。負責所有具體的 I/O、資料轉換與運算,保證極小體積與極速冷啟動。
## **2\. 實戰演練:離岸風機巡檢的黑天鵝事件 (三層架構實踐)**
為了具體理解這套系統如何運作,我們以一次「離岸風電場巡檢」的突發事件為例,展示雲、地、邊三層架構的完美協同。
### **第一階段:戰略下達與沙盤推演 (Tier 1 ➡️ Tier 2 ➡️ Tier 3\)**
跨國能源集團的**雲端總部 (Tier 1\)** 收到年度檢修排程。雲端的 **CEO AI** 讀取了全局的 Markdown 戰略文件,向遠在海岸線的**地端指揮中心 (Tier 2\)** 下達指令。
地端指揮中心(配備強大伺服器與 workerd 叢集)的**部門主管 AI** 將任務拆解給 50 台即將出海的無人機。無人機 07 號 **(Tier 3\)** 的小腦 AI 透過本地的 Cypher 引擎進行沙盤推演,從地端資料庫下載了 rgb\_vision.wasm (光學影像)、lidar\_scan.wasm (光達) 等 60 個可能會用到的 TinyGo 零件,存入本地記憶體後隨船出航。
### **第二階段:邊緣的極限生存 (Tier 3 獨立運作)**
無人機 07 號來到海上 50 公里處,完全失去對外網路。突然,海上濃霧降臨。
原本執行中的 Cypher 圖譜卡住了,因為 rgb\_vision.wasm 回報「無法獲取清晰影像」。07 號沒有驚慌,它內建的輕量級 Go \+ Wazero 引擎在 0.1 秒內動態重組了圖譜邏輯:剔除光學零件,瞬間載入並執行 lidar\_scan.wasm,不需人類介入,繼續在濃霧中精準貼行。
### **第三階段:游擊網與地端代工 (Tier 3 ↔️ Tier 2\)**
巡檢中途,07 號發現風機葉片上有極罕見的「蜂巢狀熱應力微裂紋」,但它帶出來的 60 個零件中沒有對應的分析工具。
07 號飛昇至濃霧上方,短暫連上母船的微弱區域網路發起「短點射傳輸 (Burst)」:{"intent": "計算蜂巢狀熱應力微裂紋擴散率"},拿到任務單號後立刻斷網潛回霧中。
海岸線的**地端指揮中心 (Tier 2\)** 收到需求。強大的**工匠 AI** 瞬間啟動,生成了一段 TinyGo 程式碼,並在本地編譯與測試。三分鐘後,07 號再次探頭連網,下載了熱騰騰的 honeycomb\_analyzer.wasm,並將其編織進 Cypher 圖譜中完成測量。
### **第四階段:CEO AI 的全局戰略覆寫 (Tier 2 ➡️ Tier 1\)**
同時,地端指揮中心匯整了無人機傳回的陣風數據,同步給**雲端總部 (Tier 1\)**。雲端的 CEO AI 呼叫 roi\_calculator.wasm 進行試算,發現風暴將造成設備重大損壞(ROI 極低)。
CEO AI 立刻修改總部的 Markdown 戰略文件,新增一條 BDD 規則:「風速大於 22m/s,立刻轉為陣列抗風模式」。新的最高指導 Cypher 範本瞬間下發至地端,再廣播給所有無人機。07 號收到新命令,掛起原任務,與機群組成抗風陣型,安全度過危機。
## **3\. 物理拓撲與技術棧 (The 3-Tier Tech Stack)**
透過 **KBDB Adapter** 抽象層,AI 在任何環境中呼叫的 API 介面皆一致,但底層基礎設施依據物理環境的豐饒度進行適配。
### **Tier 1: 雲端總部 (Cloud \- The Global Brain)**
* **場景:** 跨國集團資料整合、全域戰略備份、對外公開 API、跨國部門協調。
* **AI 角色:** **CEO AI (大型語言模型)**。負責解析 Markdown、跨區資源調度、修改全域演算法參數。
* **技術規格:**
* **調度引擎:** Cloudflare Workers (原生執行 TinyGo WASM)。
* **圖資料庫 (狀態/關聯)** Cloudflare D1 \+ u6u Cypher 轉換層。
* **零件與儲存:** Cloudflare R2 / KV。
* **向量檢索 (意圖/型錄)** Cloudflare Vectorize。
* **架構優勢:** 無限橫向擴展 (Serverless),無須維運硬體,扛載全球級別的 API 併發。
### **Tier 2: 企業地端/基地台 (On-Premise \- The Basecamp & Forge)**
* **場景:** 高機密廠房內網、財務核心系統、無人機/機器人的母艦基地。
* **AI 角色:** **部門主管 AI** (廠區派工);**工匠 AI** (專職接收規格,透過 TDD 閉環動態生成 TinyGo 程式碼)。
* **技術規格 (企業級高可用架構)**
* **負載平衡:** Nginx 或 HAProxy (負責將請求分發給後端叢集)。
* **調度引擎:** **workerd 叢集 (Cloudflare 開源執行環境)**。在本地實體伺服器或 VM 上平行部署多個 workerd 行程,完美相容雲端環境,提供極高的並發處理能力 (V8 JIT 極限算力)。
* **圖資料庫 (狀態/關聯)** **Kùzu** (單機極速圖庫) 或 PostgreSQL \+ AGE (超高併發)。
* **零件與儲存:** 企業本地 NVMe 硬碟叢集 / MinIO (S3 相容)。
* **向量檢索 (意圖/型錄)** pgvector 或 Milvus。
* **架構優勢:** 兼具資料不出網的「絕對資安」與雲端級別的「叢集擴展性」。內建「代工坊 (Forge)」,是推動企業系統自動演化的核心引擎。
### **Tier 3: 邊緣載具 (Extreme Edge \- The Operatives)**
* **場景:** 無網環境的巡檢無人機、工廠無軌導引車 (AGV)、機械手臂。
* **AI 角色:** **導航/執行 AI (極小參數 SLM)**。不具備寫程式能力,只負責解讀現場狀況、執行 Cypher 圖譜,並透過 DTN 呼叫地端請求新零件。
* **技術規格 (極限微縮架構)**
* **調度引擎:** 輕量級 Go 排程引擎 \+ **內嵌 Wazero**。不依賴 V8 或 workerd,確保在極低 RAM 的晶片上流暢運行,實例化延遲僅需數微秒。
* **圖資料庫 (狀態/關聯):** 嵌入式 Kùzu 或 SQLite。
* **零件與儲存:** SD 卡 / eMMC 實體檔案系統。
* **向量檢索 (意圖/型錄)** sqlite-vss (極輕量本地向量)。
* **架構優勢:** 絕對的離線生存能力。只帶必要的 TinyGo WASM 零件出門,無任何編譯環境,體積最小化。
## **4\. 自動演化工作流 (The Auto-Evolution Loop)**
當企業環境發生變化(例如:新增硬體規格、外部 API 變更),u6u 的演化路徑如下:
1. **遭遇未知 (Anomaly Detection)**
無人機 (Tier 3\) 或雲端服務 (Tier 1\) 在執行 Cypher 任務時,發現本地 KBDB 向量庫中缺乏對應的工具零件。
2. **意圖攔截與 ROI 評估 (CEO/Manager AI)**
機甲 (Harness) 攔截缺失意圖,呼叫 roi\_calculator.wasm 等評估零件。若認定具備開發價值,系統會生成一份標準的 Input/Output JSON Schema。
3. **地端代工 (The Forge @ Tier 2)**
規格需求透過網路或 DTN 送達 Tier 2 地端機房的「工匠 AI」。
工匠 AI 生成 TinyGo 程式碼 \-\> 在沙盒中執行 tinygo build \-target=wasi \-\> 通過測試迴圈 \-\> 輸出正式的 .wasm 檔案。
4. **全域派發 (Distribution & Versioning)**
新零件註冊進入企業的零件圖資料庫 (KBDB)。
* **雲端:** 同步至 R2。
* **邊緣:** 載具下次連網時,透過游擊網 (Burst Transmission) 下載更新檔。
5. **動態編織 (Execution)**
各端 AI 獲知新零件上線,瞬間將其編入新的 Cypher 圖譜中執行,完成企業能力的自動擴展。
@@ -0,0 +1,360 @@
# u6u 系統規格書 v1.0
## 給 AI 的架構思考指引
> 本文件用途:讓 AI 理解 u6u 的完整設計意圖、現況、與未來路徑,
> 在實作決策時能自行判斷方向正確性,而不只是執行單一任務。
---
## 一、系統本質(先理解再動手)
u6u 不是 workflow 工具,不是 no-code 平台,不是 iPaaS。
u6u 是一個**「意圖到系統」的生物體積木平台**:
- 人類說出意圖(自然語言)
- AI 從零件宇宙組裝出可運行的系統
- 系統會自動評價、演化、淘汰舊零件
- 累積的零件就是核心資產,越積越有價值
**設計的終極體驗:** 工廠主管拖拉十分鐘,組出具備微服務架構的企業系統,零程式碼,但底層是真正的分散式系統。
---
## 二、四層邏輯架構
```
Polaris(意圖層)
↓ 自然語言 → AI 拆解
Prototype(前端層)
↓ UI 元件 + 觸發事件
Workflow(編排層)
↓ Cypher 語法定義執行順序
Component(零件層)
↓ .wasm 實際執行
```
每一層向下只透過標準介面溝通,層與層之間完全解耦。
---
## 三、物理三層部署
```
Tier 1:雲端總部(Cloudflare Workers
- CEO AI 讀取 Markdown 戰略文件
- 全域零件同步至 R2
- Cloudflare D1 + VectorizeKBDB
Tier 2:企業地端(workerd 叢集)
- 部門主管 AI 派工
- 工匠 AI 生成並測試新零件
- Kùzu 或 PostgreSQL + AGE(圖資料庫)
- pgvector 或 Milvus(向量搜尋)
Tier 3:邊緣載具(無人機、AGV、工廠設備)
- 極小參數 SLM
- Go 排程引擎 + 內嵌 Wazero(無 V8
- SQLite + sqlite-vss
- 離線生存,DTN 短點射傳輸
```
**關鍵約束:** Tier 3 沒有 V8,沒有 Node.js,沒有網路。
所有零件必須在 Wazero 上跑,所有資料傳輸透過 stdin/stdout JSON。
---
## 四、零件規格(Component Contract
這是整個系統最核心的不變量。零件規格定錯,累積的資產會變成技術債。
### 4.1 零件的本質定義
**一個零件只做一件事。**
```
✅ gsheets_create_table
✅ gsheets_delete_table
✅ gsheets_get_entries
❌ gsheets_manager(做太多事,禁止)
```
### 4.2 零件合約格式(component.contract.yaml
每個零件必須附帶此合約,這是 AI 讀取零件的唯一介面描述:
```yaml
id: "gsheets_get_entries" # 功能合約名稱(永久不變)
version: "v2" # 實作版本
wasi_target: "preview1" # 明確標記 WASI 版本,未來升級用
stability: "floating" # floating | stable | pinned
runtime_compat:
- "cf-workers"
- "workerd"
- "wazero"
constraints:
max_size_kb: 2048 # 超過視為打包了 runtime
max_cold_start_ms: 50
no_network_syscall: true # 禁止零件自己發 HTTP
no_filesystem_syscall: true # 只能 stdin/stdout
io_model: "stdin_stdout_json" # 唯一合法的 I/O 模型
input_schema:
type: object
required: ["spreadsheet_id", "sheet_name"]
properties:
spreadsheet_id: { type: string }
sheet_name: { type: string }
limit: { type: integer, default: 100 }
output_schema:
type: object
properties:
rows: { type: array }
total: { type: integer }
error: { type: string }
gherkin_tests:
- scenario: "正常取得資料"
given: '{"spreadsheet_id":"abc","sheet_name":"Sheet1"}'
then_contains: '{"total":1}'
- scenario: "不存在的表格回傳錯誤"
given: '{"spreadsheet_id":"abc","sheet_name":"不存在"}'
then_contains: '{"error":'
tags: ["google", "sheets", "data", "read"]
description: "從 Google Sheets 取得指定工作表的所有資料列"
```
### 4.3 語言無限制原則
**零件開發語言完全不限制**,只要輸出符合以上合約的 .wasm 即可。
可接受語言(非排他):TinyGo、Rust、AssemblyScript、C/C++
注意事項(不是禁止,是要求自行驗證):
- TypeScript via Extism:會打包 QuickJS,體積通常超過 2MB 限制
- 標準 Go(非 TinyGo):runtime 過肥,通常超過體積限制
- 任何語言:不可在 .wasm 內部呼叫網路或檔案系統 syscall
**驗收標準只有一個:通過沙盒測試。** 語言是零件作者自己的事。
### 4.4 零件的前後端分類
| 類型 | 執行位置 | I/O | 範例 |
|------|----------|-----|------|
| 後端邏輯零件 | Workers/workerd/Wazero | JSON stdin/stdout | validate_json, http_request |
| 前端 UI 零件 | 瀏覽器 | HTML attributes / DOM events | u6u-btn, u6u-chart |
| **混合零件** | **禁止** | — | **強制拆成兩個** |
---
## 五、零件版本控制策略
### 5.1 命名規則
```
gsheets_get_entries ← 功能合約名稱(搜尋用,永遠存在)
gsheets_get_entries_v1 ← 第一個實作(慢但能用)
gsheets_get_entries_v2 ← 更快的實作(由另一個 AI/用戶提交)
```
### 5.2 穩定性標籤
Workflow 引用零件時可指定穩定性需求:
```
gsheets_get_entries → 預設 floating,AI 自動選最優版本
gsheets_get_entries@stable → 有更好版本時提示,人工確認才換
gsheets_get_entries@pinned:v1 → 版本凍結,宇宙怎麼演化都不影響
```
| 標籤 | 適用情境 | 更新行為 |
|------|----------|----------|
| `floating` | 一般企業應用 | AI 自動換成最優版本 |
| `stable` | 重要業務流程 | 有更好版本時提示,人工確認 |
| `pinned` | 工廠控制器、嵌入式設備 | 永遠不動,即使進入墓地也保留 .wasm |
### 5.3 淘汰機制
- AI 搜尋零件時,KBDB 依「成功率 × 速度 × 被調用次數」排序
- 連續 30 天無使用且評價下降 → Deprecated
- Deprecated 後繼續 90 天無復活 → 進墓地(從搜尋清單移除)
- **墓地的 .wasm 永遠保留**pinned 的 Workflow 永遠能拉到
---
## 六、零件製造指引書(給用戶 AI 的規範)
u6u 不限制誰來造零件,任何 AI(用戶自己的 Claude、GPT、本地模型)都可以。
但必須遵守此指引書,否則沙盒測試不過,無法上架。
### Step 1:理解介面合約
造零件前,先定義合約 YAML。
**零件只在乎輸入 JSON 和輸出 JSON,完全不管前端長什麼樣子。**
```
人類:我要一個可以查 Google Sheets 的零件
AI 的第一步:定義 input_schema 和 output_schema,不是寫程式
```
### Step 2:選擇開發語言
選擇你最熟悉的、能產出 WASI preview1 相容 .wasm 的語言。
建議:
- 小型邏輯零件(轉換、計算)→ TinyGo 或 AssemblyScript(體積小)
- 效能敏感零件 → Rust(生態最成熟)
- 任何語言都可以,只要通過合約限制
### Step 3:實作規則
```
✅ 只用 stdin 讀取輸入 JSON
✅ 只用 stdout 輸出結果 JSON
✅ 錯誤也用 stdout 輸出:{"error": "說明"},不要 panic/crash
✅ 無狀態:每次呼叫都是獨立的,不依賴上一次執行的結果
✅ 需要打外部 API?透過 host function 注入,不在 .wasm 裡自己發 HTTP
❌ 禁止網路 syscall
❌ 禁止檔案系統 syscall
❌ 禁止打包 runtimeQuickJS、Node.js 等)
❌ 禁止超過 2MB
```
### Step 4:本地測試方式
```bash
# 用任何 WASI runtime 本地測試
echo '{"spreadsheet_id":"abc","sheet_name":"Sheet1"}' | \
wasmtime gsheets_get_entries.wasm
# 預期輸出
{"rows":[...],"total":5}
```
### Step 5:提交審核
提交 `.wasm` + `component.contract.yaml`,系統自動執行:
1. 體積檢查(< 2MB
2. 冷啟動時間(< 50ms
3. Syscall 掃描(不能有網路/檔案系統呼叫)
4. Gherkin 測試(合約裡的所有 scenario 必須 100% 通過)
5. 多 runtime 相容測試(cf-workers / workerd / wazero
全部通過 → 上架進入零件宇宙,開始累積評價。
---
## 七、Cypher 編排語言
Workflow 使用擴展三元組語法描述執行邏輯:
```yaml
kind: Workflow
id: wf_query_attendance
triplets:
# 基本流程
- "START >> TRIGGERS >> step_receive"
- "step_receive >> IS_A >> component://webhook_receiver_v1"
# 條件分支
- "step_receive >> ON_SUCCESS >> step_validate"
- "step_receive >> ON_FAIL >> step_notify_error"
# 跨 Workflow 串接
- "step_validate >> CALLS_SUBFLOW >> workflow://save_to_db"
# 前端觸發後端
- "btn_submit >> ON_CLICK >> workflow://wf_query_attendance"
```
**URI 協議規範:**
- `component://` → 引用零件
- `workflow://` → 引用子 Workflow
- `ui://` → 引用前端零件
- `style://` → 引用樣式零件
---
## 八、KBDB 在 u6u 的角色
u6u 的所有狀態都在 KBDB 裡:
| KBDB Block 類型 | 存放內容 |
|-----------------|----------|
| Component Block | 零件合約、.wasm 位置、版本、評價指標 |
| Workflow Block | Cypher 三元組、依賴零件清單 |
| Prototype Block | 前端結構、UI 零件樹 |
| Pitfall Block | 避坑記錄,AI 搜尋時強制讀取 |
| Evaluation Block | 每次 Workflow 執行後的強制評價結果 |
**KBDB 不變量:永遠只有三張表(blocks/templates/slots),不新增表。**
所有以上類型都用 Template + Slot 實現。
---
## 九、自動演化迴圈
```
執行 Workflow
強制 AI 評價(Evaluator Agent
↓ 發現問題
生成修復 Ticket → 通知製作人
↓ AI 嘗試修復
通過 Gherkin 驗收 → 熱更新
↓ 無法修復
標記 [HAS_PITFALL] 到 Cypher 圖
下一個 AI 搜尋時讀到坑,強制繞道
```
---
## 十、現況與未來路徑
### 現在已有
- KBDBblocks/templates/slots + Vectorize
- IS-Squad MCPexecute_cypher 等工具)
- Cloudflare Workers 環境
### 最小可 demo 路徑
1. **Cypher 執行引擎**:三元組 → 實際執行順序(確認 execute_cypher 邊界)
2. **首批核心零件**5 個):
- `webhook_receiver`
- `json_transform`
- `http_request`(透過 host function
- `notify_line`
- `validate_json`
3. **機甲最小版本**:意圖 → 零件搜尋 → 組裝 Workflow(先用硬編碼路由)
4. **前端畫布 MVP**:靜態 HTML 模擬雙面翻轉體驗
### 技術監控項目
- **WASI Component Modelpreview2**:目前用 preview1,未來 3-5 年會有遷移壓力。
合約裡已有 `wasi_target: "preview1"` 標記,升級時知道要改什麼。
- **Kùzu 成熟度**:地端圖資料庫首選,持續觀察 v1.0 穩定性。
---
## 十一、實作決策原則(CC 行動準則)
遇到不確定的實作決策時,依序問自己:
1. **這個決策會影響零件合約嗎?** 如果是,停下來討論,不要自行決定。
2. **這個實作是否限制了未來換 runtime 的自由?** 如果是,重新設計介面。
3. **這個零件做超過一件事嗎?** 如果是,拆成兩個零件。
4. **這個設計在 Tier 3 離線環境能跑嗎?** 如果不能,重新考慮。
5. **有沒有現成零件可以組合?** 先搜尋 KBDB,不要重造輪子。
---
*本文件版本:v1.0*
*綜合自:u6u 系統與零件宇宙全景規劃白皮書、自動演化 ERP 架構藍圖、智慧前端與工匠開發藍圖,加入技術評論與補充建議。*
+116
View File
@@ -0,0 +1,116 @@
# u6u 系統與零件宇宙全景規劃白皮書 (The u6u Ecosystem Blueprint)
## 1. 核心理念與願景
u6u 旨在解決傳統 Workflow 軟體 (如 n8n) 存在的「單線程、沈重、複雜、難以組成系統」的痛點。
透過結合 Cloudflare Workers (輕量邊緣運算) 與 Cypher (圖形資料庫關係),u6u 提供一個由 AI 驅動的「意圖到系統」生成平台。所有的系統功能皆被拆解為可複用、可組合的「零件 (Components)」,並在一個會自然淘汰、自我修復的「零件宇宙 (Component Universe)」中演化。
## 2. 四層架構拆解 (Four-Tier Architecture)
u6u 的工作模式採取由上到下 (Top-Down) 的 Break-down 機制:
1. Polaris (北極星層 / 意圖層):
- 用戶以自然語言描述商業模式與想法(例如:「我要做一個 AI 客服表單系統」)。
- 這是整個系統的起點,AI 會根據 Polaris 將意圖拆解為 Prototype。
2. Prototype (原型 / 前端層):
- 定義前端的版型、頁面描述、UI 元件以及它們的屬性。
- 作為使用者互動的入口,透過觸發事件 (Triggers) 連接到後端 Workflow。
3. Workflow (工作流層):
- 系統的 Orchestrator (編排者),定義業務邏輯的走向。
- 透過 Cypher 語法與三元組,定義每個節點 (Component) 的執行順序與條件分支。
4. Component (零件層 / 節點):
- 最底層的執行單元,主要分為兩類:
- 功能型 (Logic): 迴圈、條件判斷、資料轉換、統計等 (透過 CF Workers 執行 JS 邏輯)。
- 介接型 (API): 呼叫外部服務 (Webhook, HTTP Request)。
## 3. 統一描述語言:擴展三元組與跨層級 YAML
為了解決跨 YAML 檔案串接的問題,u6u 採用易於人類閱讀與 AI 生成的 「A >> 關係 >> B」 三元組語法,結合自定義的 URI 協議 (workflow://, component://, ui://),實現跨層級的連結。
綜合 YAML 範例與三元組串接
```YAML
# 1. Prototype YAML (描述前端)
kind: Prototype
id: ui_dashboard
triplets:
# 結構與版型零件
- "ui_dashboard >> CONTAINS >> layout_admin"
- "layout_admin >> CONTAINS >> btn_submit"
# UI 零件與屬性零件 (CSS/行為)
- "btn_submit >> IS_A >> ui://components/Button"
- "btn_submit >> HAS_STYLE >> style://tokens/GlowEffect"
- "btn_submit >> HAS_BEHAVIOR >> anim://motions/Pulse"
# 跨層級串接:前端觸發 Workflow
- "btn_submit >> ON_CLICK >> workflow://workflows/process_data.yaml"
# 2. Workflow YAML (描述工作流編排)
kind: Workflow
id: wf_process_data
triplets:
# 跨層級串接:Workflow 呼叫 Component
- "START >> TRIGGERS >> step_validate"
- "step_validate >> IS_A >> component://components/validate_json"
# Workflow 節點間的流轉 (轉譯為 Cypher 關係)
- "step_validate >> ON_SUCCESS >> step_call_api"
- "step_validate >> ON_FAIL >> step_notify_error"
# 跨 Workflow 串接
- "step_call_api >> CALLS_SUBFLOW >> workflow://workflows/save_to_db.yaml"
```
## 4. 零件宇宙 (Component Universe) 的審核與淘汰機制
在 u6u 中,所有的 UI、Style、Logic、API 都是「零件」。當 AI 發現缺乏所需零件時,會自動創造它。為了確保生態系的健康,必須建立嚴格的審核標準與自然淘汰機制。
### 4.1 零件的創建與審核標準 (Pass/Fail Criteria)
當 AI 或開發者提交一個新零件時,系統會啟動自動化沙盒測試。必須完全通過以下標準,零件才能進入「宇宙」供他人使用:
1. 功能型零件 (Logic Components):
- Gherkin BDD 驗收: 必須附帶 Feature/Scenario 測試規格,且執行結果 100% 通過 (例如:Given input JSON, When split, Then returns Array)。
- 效能門檻: 邊緣運算 (CF Workers) 執行時間需低於設定閾值 (例如 < 50ms),無記憶體洩漏。
2. 介接型零件 (API Components):
- 連線驗證: 端點 (Endpoint) 必須能 ping 通,或回傳正確的 2xx HTTP Status (提供 Mock Payload 測試)。
- Credential 安全: 不可將 Token 或 Secret 寫死在代碼中,必須嚴格宣告所需的 Environment Variables 規格。
3. 前端與屬性零件 (UI & Style Components):
- 渲染驗證: CSS / 組件代碼不能導致瀏覽器 Crash。
- 相容性檢查: 不可包含嚴格衝突的樣式 (例如寫死 !important 破壞全域版型)。
## 4.2 零件宇宙的自然淘汰 (Natural Selection)
零件一旦上架,將面臨殘酷的達爾文機制:
- AI 偏好權重: AI (透過 MCP 搜尋時) 會優先選擇「成功率高、執行速度快、被調用次數多」的零件。
- 降級與墓地: 連續 30 天無人/無 AI 使用,或錯誤率飆升的零件,會被降級 (Deprecated)。最終轉入「零件墓地」,從首選搜尋清單中剔除。
## 5. 系統自癒與 AI 避坑機制 (Auto-Healing & Pitfall Avoidance)
這是 u6u 維持系統穩定運作的最核心機制。工作流不只要能跑,跑完後還必須經歷 「強制 AI 評價 (Mandatory AI Evaluation)」。
### 5.1 運行後的強制評價迴圈
每當一個 Workflow 在 CF Workers 上執行完畢 (或發生異常中斷),系統攔截日誌並強制啟動 AI 評價代理 (Evaluator Agent)。
- 評估維度:
- 狀態: 成功 / 失敗 (Crash) / 逾時 (Timeout)。
- 效能: 耗時是否合理 (例如 API 突然變得很慢)。
- 警告訊息: 資源消耗過大、API 回傳即將停用的 Warning。
### 5.2 自癒與避坑流程 (The Feedback Loop)
當 Evaluator Agent 發現問題時,會觸發以下流程:
- 回報與通知 (Notify): 系統自動生成修復 Ticket,並通知當初建立該零件/工作流的製作人 (或系統管理員)。
- AI 嘗試修復 (Auto-Fix): 系統派遣「修復型 AI」嘗試讀取錯誤日誌並修復代碼 (例如:API 規格變更導致 JSON 解析錯誤,AI 自動修改解析邏輯)。
- 驗收與部署: 修復後的代碼若通過 Gherkin 驗收,則無縫熱更新。
- 避坑標記 (Pitfall Marking): - 如果 AI 無法修復 (例如:外部第三方 API 永久倒閉,或邏輯存在根本性死結)。
- 系統會在 Cypher 圖形資料庫中,將該零件或該特定的三元組關係標記為 [HAS_PITFALL]。
- 結果: 下一個生成系統的 AI 在透過 MCP 搜尋時,會讀取到這個坑的紀錄,並強制繞道,改用其他方案或生成新的零件,實現「前人踩坑,後 AI 避坑」的群體智慧。
## 6. 結論
u6u 不是一個單純的開發工具,它是一個生物體積木系統。
透過「三元組」統一語言打破系統壁壘,透過「零件審核」保證基因優良,再透過「強制評價與避坑機制」實現演化。當這套系統運轉起來,AI 就能在其中無止盡地為人類組裝出越來越強大、越來越穩定的商業應用。
+19
View File
@@ -0,0 +1,19 @@
# u6u Design
u6u 是一個 AI Friendly 的 n8n。
- 用 workers 天生比 n8n 速度快
- 用 Cypher binding,不需 deploy 就可以隨時修改執行,不然原生 workers 的 binding 要 deploy
- 未來要有一個 GUI 可以解析 YAML 產生畫面,反之人拉的圖會產生 YAML
- 內建核心元件,http request, webhook, cron, if, switch, set, credential 等功能
- 用戶自建功能多數是 http request 只是去 call 不同的 API,可以隨時建立,它的「配方」recipe 可以分享
- 每個 API Call 獨立但搜尋會整合,例如有人實作 call google sheets create table API,它不用做完整的,因為另一人要 delete table 時發現沒有,AI 直接做一個,下次搜尋 google sheets 時,就提供了 create table, delete sheets 兩個端點,也就是哪些是大家需要的功能自然產生
- marketplace 機制,但是是給 AI 的,強制 AI 使用後要回覆使用的評價,如果一個零件被幾次評為不佳,其他的 AI 就可以避開這個零件
- 自動審核:如果是 call API,只要成功 Call 通就是通過,如果是功能性的,只要通過他設置的 Gherkin 就是通過,省去人工審核的麻煩
## 觀念想法
- u6u 通過前端網頁開發功能,每個元件是一個零件
- 視覺優先的開發:要解釋什麼是 webhook 很難,但一般用戶做一個前端的按鈕、輸入框... 後面就會綁定某個 webhook,點擊這個前端界面就看到後端邏輯的工作流,這樣就不用解釋太多。
- 系統功能:我是一個用戶,我建立不同的功能,例如我建立 CRM,又建立 ERP,這些系統有很多流程是共用的,但當我建立多個工作流時,zoom out 就會看到我的公司內不同流程間的關係,因為 cypher 放大就是 graph,但每個功能要可以摺疊成一個點,又可以 zoom in 展開來調整某一段工作流,再 zoom in 調整一個零件
- 考慮讓他自己 OWN,就是企業版可以讓資料是獨立的
+9
View File
@@ -0,0 +1,9 @@
# Wishlist — 已移至 InkStoneCo 頂層(2026-06-15
> wishlist 是**跨專案的**(一個願望常牽動 arcrun + kbdb + gui + 多個 product),
> 不該埋在單一 repo。已移到頂層總計劃:
>
> **真身**`github.com/uncle6me-web/InkStoneCo``docs/1-vision/product-wishlist.md`
>
> arcrun 相關的願望(code 零件、標準流程圖、CLI 多語系/logs)在該檔 C 區。
> 動到 arcrun 願望前讀那邊,不要在此重記(單一真相源)。
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+100
View File
@@ -0,0 +1,100 @@
'use client';
import { useEffect, useRef } from 'react';
import Link from 'next/link';
import SiteNav from '../components/SiteNav';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
export default function ApiDocsPage() {
const containerRef = useRef<HTMLDivElement>(null);
const initialized = useRef(false);
useEffect(() => {
if (initialized.current || !containerRef.current) return;
initialized.current = true;
// Dynamically load Swagger UI from CDN
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui.css';
document.head.appendChild(link);
const script = document.createElement('script');
script.src = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js';
script.onload = () => {
const SwaggerUIBundle = (window as unknown as { SwaggerUIBundle: (opts: unknown) => void }).SwaggerUIBundle;
if (!SwaggerUIBundle || !containerRef.current) return;
SwaggerUIBundle({
url: `${API_BASE}/openapi.json`,
dom_id: '#swagger-ui',
presets: [(window as unknown as { SwaggerUIBundle: { presets: { apis: unknown } } }).SwaggerUIBundle.presets.apis],
layout: 'BaseLayout',
defaultModelsExpandDepth: -1,
docExpansion: 'list',
filter: true,
tryItOutEnabled: true,
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
requestInterceptor: (request: { headers: Record<string, string> }) => {
// Inject API key from localStorage if present
const key = localStorage.getItem('arcrun_api_key');
if (key) request.headers['X-Arcrun-API-Key'] = key;
return request;
},
});
};
document.head.appendChild(script);
return () => {
// cleanup not strictly needed for page navigation
};
}, []);
return (
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
<SiteNav currentPath="/api-docs" />
{/* Header */}
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-white mb-2">API Reference</h1>
<p className="text-[#555] text-sm mb-2">
arcrun APIPython / JS lib HTTP request
</p>
<p className="text-[#444] text-xs mb-6">
Endpoint: <span className="font-mono text-[#666]">{API_BASE}</span>
</p>
{/* API Key hint */}
<div className="bg-[#111] border border-[#222] rounded-lg p-4 mb-8 text-sm">
<p className="text-[#666] mb-2">
API API Key
</p>
<ApiKeyInput />
</div>
{/* Swagger UI */}
<div className="bg-white rounded-xl overflow-hidden" ref={containerRef}>
<div id="swagger-ui" className="min-h-96"></div>
</div>
</div>
</div>
);
}
function ApiKeyInput() {
return (
<div className="flex gap-2">
<input
type="text"
placeholder="ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="flex-1 bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-1.5 text-xs font-mono text-[#cdd6f4] focus:outline-none focus:border-indigo-700"
onChange={(e) => {
if (e.target.value.startsWith('ak_')) {
localStorage.setItem('arcrun_api_key', e.target.value);
}
}}
/>
<span className="text-[#444] text-xs self-center"> requests</span>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
// 九宮格 App Launcher(受 Google Apps menu 啟發)
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
// 非白名單 user 看到 mira 等受限 app 顯示為灰色 + tooltip「即將開放」
import { useEffect, useRef, useState } from 'react';
import { MATRIX_APPS, isAppAccessible, type AppEntry } from './apps';
export default function AppLauncher({ userEmail }: { userEmail: string | null }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-label="切換應用"
className="flex items-center justify-center w-9 h-9 rounded-md text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors"
>
{/* 九宮格 icon */}
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<circle cx="3" cy="3" r="1.5" /><circle cx="9" cy="3" r="1.5" /><circle cx="15" cy="3" r="1.5" />
<circle cx="3" cy="9" r="1.5" /><circle cx="9" cy="9" r="1.5" /><circle cx="15" cy="9" r="1.5" />
<circle cx="3" cy="15" r="1.5" /><circle cx="9" cy="15" r="1.5" /><circle cx="15" cy="15" r="1.5" />
</svg>
</button>
{open && (
<div
className="absolute right-0 mt-2 w-72 bg-[#0f0f0f] border border-[#222] rounded-lg shadow-xl p-2 z-50"
role="menu"
>
<div className="grid grid-cols-3 gap-1">
{MATRIX_APPS.map(app => (
<AppTile key={app.id} app={app} userEmail={userEmail} onClose={() => setOpen(false)} />
))}
</div>
</div>
)}
</div>
);
}
function AppTile({
app,
userEmail,
onClose,
}: {
app: AppEntry;
userEmail: string | null;
onClose: () => void;
}) {
const accessible = isAppAccessible(app, userEmail);
const tooltip = !accessible ? (app.locked_tooltip ?? '即將開放') : (app.description ?? '');
if (!accessible) {
return (
<div
title={tooltip}
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md cursor-not-allowed opacity-40"
>
<span className="text-2xl grayscale">{app.icon ?? '📦'}</span>
<span className="text-xs text-[#666] text-center">{app.name}</span>
</div>
);
}
return (
<a
href={app.url}
onClick={onClose}
title={tooltip}
className="flex flex-col items-center justify-center gap-1 p-3 rounded-md hover:bg-[#1a1a1a] transition-colors text-[#ccc] hover:text-white"
>
<span className="text-2xl">{app.icon ?? '📦'}</span>
<span className="text-xs text-center">{app.name}</span>
</a>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import AppLauncher from './AppLauncher';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type NavUser = {
display_name: string;
email: string;
avatar_url?: string;
};
export default function SiteNav({ currentPath }: { currentPath?: string }) {
const [user, setUser] = useState<NavUser | null | undefined>(undefined);
useEffect(() => {
fetch(`${API_BASE}/me`, { credentials: 'include' })
.then(r => r.ok ? r.json() as Promise<NavUser> : null)
.then(u => setUser(u))
.catch(() => setUser(null));
}, []);
const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', credentials: 'include' });
window.location.href = '/';
};
const linkCls = (path: string) =>
`transition-colors text-sm ${currentPath === path ? 'text-white' : 'text-[#666] hover:text-white'}`;
return (
<nav className="flex items-center justify-between px-6 py-4 border-b border-[#1a1a1a]">
<Link href="/" className="text-white font-bold text-lg tracking-tight hover:opacity-80 transition-opacity">
arcrun
</Link>
<div className="flex items-center gap-4 text-sm">
<Link href="/integrations" className={linkCls('/integrations')}>Integrations</Link>
<Link href="/api-docs" className={linkCls('/api-docs')}>API</Link>
<a
href="https://github.com/richblack/arcrun"
target="_blank"
rel="noopener noreferrer"
className="text-[#666] hover:text-white transition-colors"
>
GitHub
</a>
{user === undefined ? (
// Loading — placeholder to prevent layout shift
<div className="w-20 h-7" />
) : user ? (
<>
<AppLauncher userEmail={user.email} />
<Link href="/dashboard" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
{user.avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatar_url} alt="" width={26} height={26} className="rounded-full" />
)}
<span className="text-[#aaa]">{user.display_name}</span>
</Link>
<button
onClick={logout}
className="text-[#555] hover:text-[#888] transition-colors cursor-pointer"
>
</button>
</>
) : (
<Link
href="/login"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-md font-medium transition-colors"
>
Get API Key
</Link>
)}
</div>
</nav>
);
}
+54
View File
@@ -0,0 +1,54 @@
// Matrix App Launcher 九宮格清單
// 來源:matrix/identity/.agents/specs/identity/apps.jsonv0 過渡複製,未來 v1 抽進 @matrix/identity-ui
// 規範:matrix/identity/.agents/specs/identity/design.md §2.5
export type AppEntry = {
id: string;
name: string;
url: string;
icon?: string;
description?: string;
access?: 'public' | 'allowlist';
allowlist_emails?: string[];
locked_tooltip?: string;
};
export const MATRIX_APPS: AppEntry[] = [
{
id: 'arcrun',
name: 'Arcrun',
url: 'https://arcrun.dev',
icon: '🔄',
description: '工作流引擎與零件平台',
},
{
id: 'dashboard',
name: 'Dashboard',
url: 'https://arcrun.dev/dashboard',
icon: '🔑',
description: 'API Key 管理',
},
{
id: 'integrations',
name: 'Integrations',
url: 'https://arcrun.dev/integrations',
icon: '🧩',
description: '服務目錄',
},
{
id: 'mira',
name: 'Mira',
url: 'https://arcrun.dev/mira',
icon: '🌊',
description: '個人化 KM 河道',
access: 'allowlist',
allowlist_emails: ['leo21c@gmail.com'],
locked_tooltip: '即將開放',
},
];
export function isAppAccessible(app: AppEntry, userEmail: string | null): boolean {
if (app.access !== 'allowlist') return true;
if (!userEmail) return false;
return (app.allowlist_emails ?? []).includes(userEmail);
}
+215
View File
@@ -0,0 +1,215 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import SiteNav from '../components/SiteNav';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type User = {
email: string;
display_name: string;
avatar_url?: string;
api_key: string;
provider: string;
created_at: string;
};
export default function DashboardPage() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const [rotating, setRotating] = useState(false);
const [revoking, setRevoking] = useState(false);
const [error, setError] = useState('');
const [showKey, setShowKey] = useState(false);
const fetchUser = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/me`, { credentials: 'include' });
if (res.status === 401) {
window.location.href = '/login?redirect=/dashboard';
return;
}
if (!res.ok) throw new Error('Failed to fetch user');
const data = await res.json() as User;
setUser(data);
} catch {
setError('無法載入用戶資訊,請重新整理。');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
const copyKey = async () => {
if (!user) return;
await navigator.clipboard.writeText(user.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const rotateKey = async () => {
if (!confirm('確定要 Rotate API Key 嗎?舊 Key 的 workflow credentials 不會自動遷移。')) return;
setRotating(true);
setError('');
try {
const res = await fetch(`${API_BASE}/me/api-key/rotate`, {
method: 'PUT',
credentials: 'include',
});
if (!res.ok) throw new Error('rotate failed');
const data = await res.json() as { api_key: string; message: string };
setUser(prev => prev ? { ...prev, api_key: data.api_key } : null);
setShowKey(true);
} catch {
setError('Rotate 失敗,請稍後重試。');
} finally {
setRotating(false);
}
};
const revokeKey = async () => {
if (!confirm('確定要 Revoke API Key 嗎?所有使用此 Key 的服務將立即失效。')) return;
setRevoking(true);
setError('');
try {
const res = await fetch(`${API_BASE}/me/api-key`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error('revoke failed');
window.location.href = '/login?revoked=1';
} catch {
setError('Revoke 失敗,請稍後重試。');
} finally {
setRevoking(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="text-[#444] text-sm animate-pulse">...</div>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex flex-col items-center justify-center gap-4">
<p className="text-[#666]">{error || '請先登入。'}</p>
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 text-sm"></Link>
</div>
);
}
const maskedKey = showKey ? user.api_key : user.api_key.slice(0, 8) + '••••••••••••••••••••••••';
return (
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
<SiteNav currentPath="/dashboard" />
<main className="max-w-2xl mx-auto px-6 py-12">
<h1 className="text-2xl font-bold text-white mb-1">{user.display_name}</h1>
<p className="text-[#555] text-sm mb-10">
{user.provider} · {new Date(user.created_at).toLocaleDateString('zh-TW')}
</p>
{error && (
<div className="bg-red-950/50 border border-red-900/50 text-red-400 text-sm px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* API Key Card */}
<div className="bg-[#111] border border-[#222] rounded-2xl p-6 mb-6">
<h2 className="text-white font-semibold mb-4"> API Key</h2>
<div className="flex items-center gap-2 mb-4">
<div className="flex-1 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-3 font-mono text-sm text-[#cdd6f4] overflow-hidden text-ellipsis whitespace-nowrap">
{maskedKey}
</div>
<button
onClick={() => setShowKey(v => !v)}
className="px-3 py-3 text-[#555] hover:text-[#aaa] text-xs border border-[#2a2a2a] rounded-lg transition-colors cursor-pointer whitespace-nowrap"
title={showKey ? '隱藏' : '顯示'}
>
{showKey ? '隱藏' : '顯示'}
</button>
<button
onClick={copyKey}
className="px-4 py-3 bg-[#1e1e2e] hover:bg-[#2a2a3e] text-indigo-400 text-xs border border-indigo-900/30 rounded-lg transition-colors cursor-pointer whitespace-nowrap"
>
{copied ? '已複製!' : '複製'}
</button>
</div>
<div className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg p-4 mb-6 text-xs font-mono text-[#666] space-y-1">
<div className="text-[#444] mb-2"># 使</div>
<div>Authorization: Bearer {user.api_key.slice(0, 8)}...</div>
<div># </div>
<div>X-Arcrun-API-Key: {user.api_key.slice(0, 8)}...</div>
</div>
<div className="flex gap-3 flex-wrap">
<button
onClick={rotateKey}
disabled={rotating}
className="flex-1 border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{rotating ? 'Rotating...' : 'Rotate Key'}
</button>
<button
onClick={revokeKey}
disabled={revoking}
className="flex-1 border border-red-900/50 hover:border-red-700/50 text-red-500 hover:text-red-400 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{revoking ? 'Revoking...' : 'Revoke Key'}
</button>
</div>
</div>
{/* Quick Start */}
<div className="bg-[#111] border border-[#222] rounded-2xl p-6">
<h2 className="text-white font-semibold mb-4"></h2>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<span className="text-indigo-500 font-mono mt-0.5">1.</span>
<div>
<div className="text-[#aaa]"> CLI</div>
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">npm install -g arcrun</pre>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-indigo-500 font-mono mt-0.5">2.</span>
<div>
<div className="text-[#aaa]"> API Key </div>
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">acr init</pre>
</div>
</div>
<div className="flex items-start gap-3">
<span className="text-indigo-500 font-mono mt-0.5">3.</span>
<div>
<div className="text-[#aaa]"></div>
<pre className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg px-3 py-2 mt-1 text-xs text-[#cdd6f4] font-mono">acr auth-recipe scaffold notion</pre>
</div>
</div>
</div>
</div>
<div className="flex gap-4 mt-6 text-sm">
<Link href="/integrations" className="text-indigo-400 hover:text-indigo-300 transition-colors">
20
</Link>
<Link href="/api-docs" className="text-indigo-400 hover:text-indigo-300 transition-colors">
API
</Link>
</div>
</main>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+22
View File
@@ -0,0 +1,22 @@
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
body {
background: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
pre, code {
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
}
+164
View File
@@ -0,0 +1,164 @@
export const runtime = 'edge';
import Link from 'next/link';
import SiteNav from '../components/SiteNav';
type Recipe = {
id: string;
name: string;
primitive: 'static_key' | 'service_account';
category: string;
secrets: string[];
badge?: 'official';
};
const RECIPES: Recipe[] = [
// AI / LLM
{ id: 'openai', name: 'OpenAI', primitive: 'static_key', category: 'AI', secrets: ['OPENAI_API_KEY'], badge: 'official' },
{ id: 'anthropic', name: 'Anthropic', primitive: 'static_key', category: 'AI', secrets: ['ANTHROPIC_API_KEY'], badge: 'official' },
// Productivity
{ id: 'notion', name: 'Notion', primitive: 'static_key', category: 'Productivity', secrets: ['NOTION_TOKEN'], badge: 'official' },
{ id: 'airtable', name: 'Airtable', primitive: 'static_key', category: 'Productivity', secrets: ['AIRTABLE_TOKEN'], badge: 'official' },
{ id: 'typeform', name: 'Typeform', primitive: 'static_key', category: 'Productivity', secrets: ['TYPEFORM_TOKEN'], badge: 'official' },
{ id: 'jira', name: 'Jira', primitive: 'static_key', category: 'Productivity', secrets: ['JIRA_DOMAIN', 'JIRA_EMAIL', 'JIRA_API_TOKEN'], badge: 'official' },
// Communication
{ id: 'slack', name: 'Slack', primitive: 'static_key', category: 'Communication', secrets: ['SLACK_TOKEN'], badge: 'official' },
{ id: 'discord', name: 'Discord', primitive: 'static_key', category: 'Communication', secrets: ['DISCORD_BOT_TOKEN'], badge: 'official' },
{ id: 'twilio', name: 'Twilio', primitive: 'static_key', category: 'Communication', secrets: ['TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN'], badge: 'official' },
{ id: 'sendgrid', name: 'SendGrid', primitive: 'static_key', category: 'Communication', secrets: ['SENDGRID_API_KEY'], badge: 'official' },
{ id: 'resend', name: 'Resend', primitive: 'static_key', category: 'Communication', secrets: ['RESEND_API_KEY'], badge: 'official' },
// Dev / Code
{ id: 'github', name: 'GitHub', primitive: 'static_key', category: 'Dev', secrets: ['GITHUB_TOKEN'], badge: 'official' },
{ id: 'linear', name: 'Linear', primitive: 'static_key', category: 'Dev', secrets: ['LINEAR_API_KEY'], badge: 'official' },
{ id: 'supabase', name: 'Supabase', primitive: 'static_key', category: 'Dev', secrets: ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY'], badge: 'official' },
// Commerce
{ id: 'stripe', name: 'Stripe', primitive: 'static_key', category: 'Commerce', secrets: ['STRIPE_SECRET_KEY'], badge: 'official' },
{ id: 'shopify', name: 'Shopify', primitive: 'static_key', category: 'Commerce', secrets: ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_ACCESS_TOKEN'], badge: 'official' },
{ id: 'hubspot', name: 'HubSpot', primitive: 'static_key', category: 'Commerce', secrets: ['HUBSPOT_ACCESS_TOKEN'], badge: 'official' },
// Google Service Account
{ id: 'google_drive_sa', name: 'Google Drive', primitive: 'service_account', category: 'Google', secrets: ['GOOGLE_SERVICE_ACCOUNT_JSON'], badge: 'official' },
{ id: 'google_gmail_sa', name: 'Gmail', primitive: 'service_account', category: 'Google', secrets: ['GOOGLE_SERVICE_ACCOUNT_JSON'], badge: 'official' },
{ id: 'google_sheets_sa', name: 'Google Sheets', primitive: 'service_account', category: 'Google', secrets: ['GOOGLE_SERVICE_ACCOUNT_JSON'], badge: 'official' },
];
const CATEGORIES = ['All', 'AI', 'Productivity', 'Communication', 'Dev', 'Commerce', 'Google'];
export default function IntegrationsPage({
searchParams,
}: {
searchParams: Promise<{ cat?: string }>;
}) {
return <IntegrationsContent searchParamsPromise={searchParams} />;
}
async function IntegrationsContent({
searchParamsPromise,
}: {
searchParamsPromise: Promise<{ cat?: string }>;
}) {
const params = await searchParamsPromise;
const cat = params.cat ?? 'All';
const filtered = cat === 'All' ? RECIPES : RECIPES.filter(r => r.category === cat);
const staticCount = RECIPES.filter(r => r.primitive === 'static_key').length;
const saCount = RECIPES.filter(r => r.primitive === 'service_account').length;
return (
<div className="min-h-screen bg-[#0a0a0a] text-[#ededed]">
<SiteNav currentPath="/integrations" />
<div className="max-w-5xl mx-auto px-6 py-12">
{/* Header */}
<h1 className="text-3xl font-bold text-white mb-2">
{RECIPES.length}
</h1>
<p className="text-[#555] mb-2">
arcrun recipe
</p>
<div className="flex gap-4 text-sm text-[#444] mb-8">
<span>{staticCount} API Key </span>
<span>·</span>
<span>{saCount} Service Account </span>
</div>
{/* Category filter */}
<div className="flex gap-2 flex-wrap mb-8">
{CATEGORIES.map(c => (
<Link
key={c}
href={c === 'All' ? '/integrations' : `/integrations?cat=${c}`}
className={`px-3 py-1.5 rounded-full text-sm transition-colors ${
cat === c
? 'bg-indigo-600 text-white'
: 'bg-[#111] border border-[#222] text-[#666] hover:text-white hover:border-[#444]'
}`}
>
{c}
</Link>
))}
</div>
{/* Recipe grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
{filtered.map(recipe => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
{/* Contribute CTA */}
<div className="bg-[#111] border border-[#222] rounded-2xl p-8 text-center">
<h2 className="text-white font-semibold text-xl mb-2"></h2>
<p className="text-[#555] text-sm mb-4 max-w-lg mx-auto">
API Key YAML
API AI PR
</p>
<div className="flex gap-3 justify-center flex-wrap">
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
</a>
<a href="https://github.com/richblack/arcrun/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer"
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors">
Recipe
</a>
</div>
</div>
</div>
</div>
);
}
function RecipeCard({ recipe }: { recipe: Recipe }) {
const primitiveLabel = recipe.primitive === 'static_key' ? 'API Key' : 'Service Account';
const primitiveColor = recipe.primitive === 'static_key' ? 'text-blue-400' : 'text-orange-400';
return (
<div className="bg-[#111] border border-[#1e1e1e] hover:border-[#333] rounded-xl p-5 transition-colors">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-white font-medium">{recipe.name}</h3>
<span className={`text-xs font-mono mt-0.5 ${primitiveColor}`}>{primitiveLabel}</span>
</div>
{recipe.badge === 'official' && (
<span className="text-xs bg-indigo-950/50 text-indigo-400 border border-indigo-900/30 px-2 py-0.5 rounded-full">
</span>
)}
</div>
<div className="text-xs text-[#444] space-y-1">
{recipe.secrets.map(s => (
<div key={s} className="font-mono flex items-center gap-1">
<span className="text-[#333]"></span> {s}
</div>
))}
</div>
<div className="mt-4 text-xs">
<code className="text-[#555] bg-[#0a0a0a] px-2 py-1 rounded font-mono">
acr auth-recipe scaffold {recipe.id}
</code>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "arcrun — Stop fighting OAuth",
description: "One API key. Every service. Works anywhere. arcrun handles Google, Notion, GitHub, Slack authentication so your code doesn't have to.",
openGraph: {
title: "arcrun — Stop fighting OAuth",
description: "One API key. Every service. Works anywhere.",
url: "https://arcrun.dev",
siteName: "arcrun",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full">
<body className="min-h-full flex flex-col bg-[#0a0a0a] text-[#ededed]">
{children}
</body>
</html>
);
}
+102
View File
@@ -0,0 +1,102 @@
export const runtime = 'edge';
import Link from 'next/link';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
export default function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; redirect?: string }>;
}) {
return (
<LoginContent searchParamsPromise={searchParams} />
);
}
async function LoginContent({
searchParamsPromise,
}: {
searchParamsPromise: Promise<{ error?: string; redirect?: string }>;
}) {
const params = await searchParamsPromise;
const error = params.error;
const redirect = params.redirect ?? '/dashboard';
const googleUrl = `${API_BASE}/auth/google/start?redirect=${encodeURIComponent(redirect)}`;
const githubUrl = `${API_BASE}/auth/github/start?redirect=${encodeURIComponent(redirect)}`;
const errorMessages: Record<string, string> = {
cancelled: '登入已取消。',
invalid_state: '安全性驗證失敗,請重試。',
server_error: '伺服器錯誤,請稍後重試。',
github_email_required: 'GitHub 帳號需要設定公開 Email 才能登入。',
};
return (
<div className="min-h-screen bg-[#0a0a0a] flex flex-col items-center justify-center px-6">
{/* Logo */}
<div className="mb-8 text-center">
<Link href="/" className="text-white font-bold text-2xl tracking-tight hover:opacity-80 transition-opacity">
arcrun
</Link>
</div>
{/* Card */}
<div className="w-full max-w-sm bg-[#111] border border-[#222] rounded-2xl p-8">
<h1 className="text-xl font-semibold text-white mb-2 text-center"></h1>
<p className="text-[#555] text-sm text-center mb-8"> API Key使</p>
{error && (
<div className="bg-red-950/50 border border-red-900/50 text-red-400 text-sm px-4 py-3 rounded-lg mb-6">
{errorMessages[error] ?? '登入時發生錯誤,請重試。'}
</div>
)}
<div className="flex flex-col gap-3">
{/* Google */}
<a href={googleUrl}
className="flex items-center justify-center gap-3 bg-white hover:bg-gray-100 text-gray-900 px-4 py-3 rounded-lg font-medium text-sm transition-colors">
<GoogleIcon />
Continue with Google
</a>
{/* GitHub */}
<a href={githubUrl}
className="flex items-center justify-center gap-3 bg-[#24292e] hover:bg-[#2f363d] text-white border border-[#444] px-4 py-3 rounded-lg font-medium text-sm transition-colors">
<GitHubIcon />
Continue with GitHub
</a>
</div>
<p className="text-[#444] text-xs text-center mt-6 leading-relaxed">
</p>
</div>
<Link href="/" className="mt-6 text-[#444] hover:text-[#888] text-sm transition-colors">
</Link>
</div>
);
}
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
}
function GitHubIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
</svg>
);
}
+199
View File
@@ -0,0 +1,199 @@
'use client';
// Mira 對話核心元件(河道右側 dock 與 /mira/chat 單頁共用)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5
// RAG:提問先語義搜尋 KBDBwiki + 河道)取 context → claude-api daemon。
// 重要:context 空時 prompt 明確要求「只說沒有相關筆記」,避免 daemon 自由發揮(曾幻想 OpenWebUI
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { MarkdownView } from './markdown';
const KBDB_BASE = 'https://kbdb.finally.click';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const CLAUDE_API = 'https://claude-api.arcrun.dev';
type Source = { label: string; href: string };
type Msg = { role: 'user' | 'mira'; text: string; sources?: Source[]; pending?: boolean };
type SearchMatch = {
score: number;
type: 'block' | 'triplet';
block: { id: string; page_name: string | null; content: string | null; type: string } | null;
triplet: { subject?: string; predicate?: string; object?: string } | null;
};
// 繁體異體字正規化(臺→台 等),讓 query 跟 KB 內容(多用「台」)對得上
function normalizeQuery(q: string): string {
return q.replace(/臺/g, '台');
}
async function fetchContext(apiKey: string, query: string): Promise<{ context: string; sources: Source[] }> {
try {
const res = await fetch(`${KBDB_BASE}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ query: normalizeQuery(query), type: 'semantic', topK: 8 }),
});
if (!res.ok) return { context: '', sources: [] };
const data = (await res.json()) as { matches?: SearchMatch[] };
const parts: string[] = [];
const sources: Source[] = [];
for (const m of data.matches ?? []) {
if (m.block?.content) {
const b = m.block;
parts.push(`### ${b.type}${b.page_name ?? b.id}\n${b.content}`);
if (b.type === 'wiki-page' && b.page_name) {
sources.push({ label: `📚 ${(b.content || b.page_name).slice(0, 20)}`, href: `/mira/wiki/${encodeURIComponent(b.page_name)}` });
} else if (b.page_name) {
sources.push({ label: `🌊 ${(b.content || '').slice(0, 20) || b.page_name}`, href: `/mira/feed#page=${encodeURIComponent(b.page_name)}` });
}
} else if (m.triplet) {
const t = m.triplet;
parts.push(`關係:${t.subject} >> ${t.predicate} >> ${t.object}`);
}
}
const seen = new Set<string>();
const uniq = sources.filter(s => (seen.has(s.href) ? false : (seen.add(s.href), true)));
return { context: parts.join('\n\n'), sources: uniq.slice(0, 5) };
} catch {
return { context: '', sources: [] };
}
}
function buildPrompt(history: Msg[], context: string, question: string): string {
const convo = history
.filter(m => !m.pending)
.map(m => `${m.role === 'user' ? 'leo' : 'Mira'}${m.text}`)
.join('\n');
const persona =
`你是 Mira,leo 的個人知識庫副駕 AI。你只能根據下方「知識庫」與「對話脈絡」回答,` +
`沒有任何外部系統存取權(沒有 OpenWebUI、沒有檔案系統、沒有別的工具)。\n\n`;
const kb = context
? `## 知識庫(跟本次提問相關的 wiki / 河道內容)\n\n${context}\n\n`
: `## 知識庫\n(這次在 leo 的筆記裡找不到相關內容。)\n\n`;
const rules = context
? `規則:繁體中文(台灣用語)、務實不客套、優先引用上方知識庫並說「你之前寫過⋯」、簡短切題。`
: `規則:繁體中文。**明確告訴 leo「你的筆記裡目前沒有關於這個的內容」**,` +
`可以再用常識補一兩句(要標明那不是來自他的筆記),不要假裝有資料、不要編造系統或工具。`;
return (
persona +
kb +
(convo ? `## 對話脈絡\n${convo}\n\n` : '') +
`---\n\nleo 現在問:「${question}\n\n${rules}`
);
}
export default function MiraChat({ compact = false }: { compact?: boolean }) {
const [apiKey, setApiKey] = useState<string | null>(null);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch(`${API_BASE}/me`, { credentials: 'include' })
.then(r => (r.ok ? r.json() : null))
.then((me: { api_key: string } | null) => { if (me?.api_key) setApiKey(me.api_key); })
.catch(() => {});
}, []);
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: 'smooth' });
}, [msgs]);
const send = async () => {
const question = input.trim();
if (!question || sending || !apiKey) return;
setInput('');
setSending(true);
const history = msgs;
setMsgs(m => [...m, { role: 'user', text: question }, { role: 'mira', text: '', pending: true }]);
try {
const { context, sources } = await fetchContext(apiKey, question);
const prompt = buildPrompt(history, context, question);
const res = await fetch(CLAUDE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, timeout_ms: 60000 }),
});
const data = (await res.json()) as { success?: boolean; pending?: boolean; data?: { text?: string }; error?: string };
let text: string;
if (!res.ok || !data.success) text = `(回答失敗:${data.error ?? res.status}`;
else if (data.pending) text = 'Mira 還在想,daemon 切到背景模式,請稍後再問一次)';
else text = data.data?.text ?? '(沒有內容)';
setMsgs(m => {
const next = [...m];
next[next.length - 1] = { role: 'mira', text, sources: sources.length ? sources : undefined };
return next;
});
} catch (e) {
setMsgs(m => {
const next = [...m];
next[next.length - 1] = { role: 'mira', text: `(錯誤:${e instanceof Error ? e.message : String(e)}` };
return next;
});
} finally {
setSending(false);
}
};
return (
<div className={`mira-chat${compact ? ' is-compact' : ''}`}>
<div className="mira-chat-log" ref={logRef}>
{msgs.length === 0 && (
<div className="empty-state" style={{ marginTop: 28 }}>
<div style={{ fontSize: 40, marginBottom: 8 }}>💬</div>
<p style={{ color: 'var(--mira-text-2)', fontSize: 13 }}> Mira </p>
</div>
)}
{msgs.map((m, i) => (
<div key={i} className={`mira-chat-msg ${m.role === 'user' ? 'is-user' : 'is-mira'}`}>
<div className="mira-chat-bubble">
{m.pending ? (
<span className="mira-thinking-dots">Mira </span>
) : m.role === 'mira' ? (
<MarkdownView text={m.text} />
) : (
m.text
)}
{m.sources && m.sources.length > 0 && (
<div className="mira-chat-sources">
{m.sources.map((s, j) => (
<span key={j}>
{j > 0 && ' · '}
<Link href={s.href}>{s.label}</Link>
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
<div className="mira-chat-input-row">
<textarea
className="mira-chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); void send(); }
}}
placeholder="問 Mira…(⌘+Enter 送出)"
rows={2}
disabled={sending || !apiKey}
/>
<button
type="button"
className="mira-btn-primary"
onClick={() => void send()}
disabled={sending || !apiKey || !input.trim()}
>
{sending ? '⋯' : '送出'}
</button>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
// Mira 右側常駐對話 dock(桌機,所有頁簽都在;手機隱藏改走 /mira/chat 單頁)
// 可收合,狀態存 localStorage。SDD: design.md §3.6.5 對話內建化
import { useEffect, useState } from 'react';
import MiraChat from './MiraChat';
const LS_KEY = 'mira-chat-dock-open';
export default function MiraChatDock() {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setOpen(localStorage.getItem(LS_KEY) === '1');
}, []);
const toggle = () => {
setOpen(o => {
const next = !o;
localStorage.setItem(LS_KEY, next ? '1' : '0');
return next;
});
};
// SSR/首渲不輸出,避免 hydration 閃爍
if (!mounted) return null;
return (
<div className={`mira-chat-dock${open ? ' is-open' : ''}`}>
{open ? (
<>
<header className="mira-chat-dock-head">
<span>💬 Mira </span>
<button type="button" className="mira-chat-dock-close" onClick={toggle} aria-label="收合對話"></button>
</header>
<MiraChat compact />
</>
) : (
<button type="button" className="mira-chat-dock-fab" onClick={toggle} title="開啟 Mira 對話">
💬
</button>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
'use client';
// Mira 側邊欄(桌機左固定欄 / 手機底部 tab bar)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 v2.1 + §3.7.4
// 對應 task: 10B0.1 / 10B0.2 / 10B0.4
// 現階段入口手寫;detector framework#8)上線後改讀 detector view 規格動態生成
import { useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
type NavItem = {
href: string;
icon: string;
label: string;
status: 'live' | 'planned';
};
// 側邊欄入口 = 各 detector 的 view(§3.7.4)。河道是固定輸入入口,其餘是 detector 產物頁。
const NAV_ITEMS: NavItem[] = [
{ href: '/mira/feed', icon: '🌊', label: '河道', status: 'live' },
{ href: '/mira/chat', icon: '💬', label: '對話', status: 'live' },
{ href: '/mira/wiki', icon: '📚', label: 'Wiki', status: 'live' },
{ href: '/mira/projects', icon: '📋', label: '專案', status: 'live' },
{ href: '/mira/dissent', icon: '⚔️', label: '異見牆', status: 'planned' },
];
function isActive(pathname: string | null, href: string): boolean {
if (!pathname) return false;
if (href === '/mira/feed') return pathname === '/mira/feed' || pathname === '/mira';
return pathname === href || pathname.startsWith(href + '/');
}
export default function MiraSidebar() {
const pathname = usePathname();
const router = useRouter();
const [q, setQ] = useState('');
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const query = q.trim();
if (!query) return;
router.push(`/mira/search?q=${encodeURIComponent(query)}`);
};
return (
<nav className="mira-sidebar" aria-label="Mira 導覽">
<Link href="/mira/feed" className="mira-sidebar-logo">
<span className="mira-sidebar-logo-icon">🦔</span>
<span className="mira-sidebar-logo-text">Mira</span>
</Link>
{/* 桌機:側欄輸入框 */}
<form className="mira-sidebar-search" onSubmit={submitSearch}>
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="🔍 搜尋 wiki…"
aria-label="搜尋"
/>
</form>
{/* 手機:底部 bar 已滿,搜尋收成一個放大鏡 icon → 去搜尋頁輸入 */}
<Link
href="/mira/search"
className={`mira-sidebar-search-icon${isActive(pathname, '/mira/search') ? ' is-active' : ''}`}
aria-label="搜尋"
title="搜尋"
>
🔍
</Link>
<ul className="mira-sidebar-list">
{NAV_ITEMS.map(item => {
const active = isActive(pathname, item.href);
const planned = item.status === 'planned';
const className = `mira-sidebar-item${active ? ' is-active' : ''}${planned ? ' is-planned' : ''}`;
const inner = (
<>
<span className="mira-sidebar-icon">{item.icon}</span>
<span className="mira-sidebar-label">{item.label}</span>
{planned && <span className="mira-sidebar-badge"></span>}
</>
);
return (
<li key={item.href}>
{planned ? (
<span className={className} aria-disabled="true" title="即將開放">
{inner}
</span>
) : (
<Link href={item.href} className={className} aria-current={active ? 'page' : undefined}>
{inner}
</Link>
)}
</li>
);
})}
</ul>
</nav>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
// Mira 共用 Markdown 渲染器(河道 + Wiki 共用)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.7
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function MarkdownView({ text }: { text: string }) {
// 三階段預處理:1. strip Logseq metadata2. [[entity]] 轉 link3. raw:<uuid> 轉河道 deep-link
const cleaned = useMemo(
() => expandRawRefs(expandWikilinks(stripLogseqMeta(text))),
[text],
);
return (
<div className="mira-md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...rest }) => {
const isInternal =
typeof href === 'string' &&
(href.startsWith('/mira/wiki/') || href.startsWith('/mira/feed'));
return (
<a
href={href}
{...(isInternal ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
className="wiki-link"
{...rest}
>
{children}
</a>
);
},
// 圖片不直接 inline 顯示(避免大圖打亂 feed),改成連結
img: ({ src, alt }) => {
const href = typeof src === 'string' ? src : '';
return href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="wiki-link"
style={{ fontStyle: 'italic' }}
>
🖼 {alt || 'image'}
</a>
) : null;
},
}}
>
{cleaned}
</ReactMarkdown>
</div>
);
}
// Strip Logseq 專屬語法
// - 屬性行:`xxx:: yyy`、`collapsed:: true`、`id:: ...`、`logseq.order-list-type:: ...`
// - block ref`((uuid))` 暫時保留為純文字
export function stripLogseqMeta(text: string): string {
return text
.split('\n')
.filter((line) => {
const trimmed = line.trimStart();
if (/^[a-zA-Z][a-zA-Z0-9_.-]*::\s/.test(trimmed)) return false;
return true;
})
.join('\n');
}
// 把 [[entity]] 轉成 markdown link 指向 /mira/wiki/wiki-{entity}
// 對應 mira-app design.md §3.6.2 + tasks.md backlog #12
export function expandWikilinks(text: string): string {
return text.replace(/\[\[([^\[\]\n]+?)\]\]/g, (_, entity: string) => {
const e = entity.trim();
if (!e) return '[[]]';
const url = `/mira/wiki/${encodeURIComponent('wiki-' + e)}`;
return `[${e}](${url})`;
});
}
// index-entry / wiki backlink 區塊內的 `raw:<uuid>` bare 文字轉成可點河道 deep-link。
// 對應 leo 反饋 #3index 顯示 uuid 無法點擊。河道 hash handler 認得 #raw=<id> 並解析回 page_name。
// 已在 markdown link 內([..](..))的不重複處理;只抓裸 raw:uuid。
const UUID_RE = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
export function expandRawRefs(text: string): string {
return text.replace(
new RegExp(`(?<![\\(\\[\\w])raw:(${UUID_RE})`, 'g'),
(_m, id: string) => `[raw:${id.slice(0, 8)}…](/mira/feed#raw=${id})`,
);
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
export const runtime = 'edge';
// Mira 對話單頁(手機用;桌機改用 layout.tsx 的右側 dock
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5
import Link from 'next/link';
import MiraChat from '../_shared/MiraChat';
import '../mira.css';
export default function MiraChatPage() {
return (
<main className="mira-page mira-chat-page">
<header style={{ padding: '16px 0 4px' }}>
<Link href="/mira/feed" style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}> </Link>
<h1 style={{ fontSize: 20, fontWeight: 700, color: '#fff', margin: '6px 0 0' }}>💬 Mira </h1>
<p style={{ color: '#888', fontSize: 12, marginTop: 2 }}> wiki / </p>
</header>
<MiraChat />
</main>
);
}
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
'use client';
// Mira 子應用 layout
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.5
// 規範:白名單 user 進得去;非白名單 user 看到「即將開放」頁
// middleware 已做未登入跳 /loginredirect=/mira 檢查(不在這裡重做)
import { useEffect, useState } from 'react';
import SiteNav from '../components/SiteNav';
import { MATRIX_APPS } from '../components/apps';
import MiraSidebar from './_shared/MiraSidebar';
import MiraChatDock from './_shared/MiraChatDock';
import './mira.css';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type Me = { email: string; display_name: string; api_key: string };
const MIRA = MATRIX_APPS.find(a => a.id === 'mira');
const ALLOWED = new Set(MIRA?.allowlist_emails ?? []);
export default function MiraLayout({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<Me | null | undefined>(undefined);
useEffect(() => {
fetch(`${API_BASE}/me`, { credentials: 'include' })
.then(r => r.ok ? r.json() as Promise<Me> : null)
.then(u => setMe(u))
.catch(() => setMe(null));
}, []);
if (me === undefined) {
return (
<>
<SiteNav currentPath="/mira" />
<div className="flex-1 flex items-center justify-center text-[#666]"></div>
</>
);
}
if (me === null) {
// 理論上 middleware 已擋住,但保險
if (typeof window !== 'undefined') {
window.location.href = '/login?redirect=/mira';
}
return null;
}
if (!ALLOWED.has(me.email)) {
return (
<>
<SiteNav currentPath="/mira" />
<BetaBlocked email={me.email} />
</>
);
}
return (
<>
<div className="mira-topnav-sticky">
<SiteNav currentPath="/mira" />
</div>
<div className="mira-app mira-shell">
<MiraSidebar />
<div className="mira-shell-content">{children}</div>
<MiraChatDock />
</div>
</>
);
}
function BetaBlocked({ email }: { email: string }) {
return (
<main className="flex-1 flex items-center justify-center px-6">
<div className="max-w-md text-center space-y-4">
<div className="text-6xl mb-2">🌊</div>
<h1 className="text-3xl font-bold text-white">Mira </h1>
<p className="text-[#888] leading-relaxed">
Mira arcrun KM
</p>
<p className="text-sm text-[#555]">
<span className="font-mono text-[#888]">{email}</span>
</p>
<div className="pt-4">
<a
href="/dashboard"
className="inline-block bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2 rounded-md text-sm font-medium transition-colors"
>
Dashboard
</a>
</div>
</div>
</main>
);
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from 'next/navigation';
// Mira 首頁 → redirect 到河道
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 v2.1
// v2.1 改側邊欄式版面後,原本的卡片入口導覽移到側邊欄(MiraSidebar),
// 首頁不再需要列入口,直接進河道(feed = 主要輸入/瀏覽頁)
export default function MiraHubPage() {
redirect('/mira/feed');
}
+300
View File
@@ -0,0 +1,300 @@
'use client';
// Mira repo 總管 + 工作台
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.9.1.2 + §5.2 v2.1
// 對應 task: 階段 10-B
// GitHub = repo 全清單 SSOTclone 到 Hetzner = 激活進工作態
// 資料源:mira daemon GET /projects(轉發 AI-Meka GitHub 掃描器)
import { useEffect, useMemo, useState } from 'react';
import '../mira.css';
// mira daemonnginx mira.uncle6.me/mira/ → 容器)
const DAEMON = process.env.NEXT_PUBLIC_MIRA_DAEMON ?? 'https://mira.uncle6.me/mira';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const KBDB_BASE = 'https://kbdb.finally.click'; // 既有技術債(同 feed/wiki),KI-3 未解前沿用
export type RepoSummary = {
name: string;
full_name: string;
cloned: boolean;
has_sdd: boolean;
archived: boolean;
fork: boolean;
total: number;
done: number;
in_progress: number;
ai_count: number;
leo_count: number;
blocked_count: number;
last_activity: string | null;
};
// project_detector 拆出的 todo blocktype=note, source=ai-project-detector
// G1:一篇河道筆記可拆多條 todo,各自掛 suggested-repodesign.md §3.7.5.7
type Todo = {
id: string;
content: string;
suggested_repo: string; // repo name 或 "new"
raw_id: string; // 溯源回河道原 raw(raw: tag),給「來自河道」連結
};
type Filter = 'sdd' | 'all';
// 從 tags_json 解析 detector 打的 tag
function parseTodoTags(tagsJson: string | null): { isTodo: boolean; repo: string; rawId: string } {
let tags: string[] = [];
try { tags = JSON.parse(tagsJson || '[]'); } catch { /* */ }
const isTodo = tags.includes('is_todo:true');
const repoTag = tags.find(t => t.startsWith('suggested-repo:'));
const repo = repoTag ? repoTag.slice('suggested-repo:'.length) : '';
const rawTag = tags.find(t => t.startsWith('raw:'));
const rawId = rawTag ? rawTag.slice('raw:'.length) : '';
return { isTodo, repo, rawId };
}
export default function ProjectsPage() {
const [repos, setRepos] = useState<RepoSummary[] | null>(null);
const [todos, setTodos] = useState<Todo[]>([]);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<Filter>('sdd');
const [cloning, setCloning] = useState<string | null>(null);
const [newRepo, setNewRepo] = useState('');
const load = () => {
setError(null);
fetch(`${DAEMON}/projects`)
.then(async r => {
if (!r.ok) throw new Error(`daemon 回 ${r.status}`);
return r.json() as Promise<{ repos: RepoSummary[] }>;
})
.then(d => setRepos(d.repos ?? []))
.catch(e => setError(e instanceof Error ? e.message : String(e)));
void loadTodos();
};
// 撈 project_detector 拆出的 todo blocksource=ai-project-detectorG1 一篇拆多條)
// client 過濾 is_todo:trueKI-3 tag query bug 故用 source filter 而非 tag query
const loadTodos = async () => {
try {
const me = await fetch(`${API_BASE}/me`, { credentials: 'include' }).then(r => r.ok ? r.json() : null);
if (!me?.api_key) return;
const res = await fetch(`${KBDB_BASE}/blocks?type=note&source=ai-project-detector&limit=300`, {
headers: { Authorization: `Bearer ${me.api_key}` },
});
if (!res.ok) return;
const data = await res.json() as { blocks?: Array<{ id: string; content: string; tags_json: string | null }> };
const list: Todo[] = [];
for (const b of data.blocks ?? []) {
const { isTodo, repo, rawId } = parseTodoTags(b.tags_json);
if (isTodo) list.push({ id: b.id, content: b.content, suggested_repo: repo || 'new', raw_id: rawId });
}
setTodos(list);
} catch { /* todos best-effort */ }
};
useEffect(load, []);
const clone = async (fullName: string) => {
setCloning(fullName);
setError(null);
try {
const r = await fetch(`${DAEMON}/projects/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: fullName }),
});
if (!r.ok) throw new Error(`clone 失敗 ${r.status}`);
setNewRepo('');
load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setCloning(null);
}
};
const addRepo = () => {
const v = newRepo.trim();
if (!v) return;
// 允許輸入 "name" 或 "owner/name"
const full = v.includes('/') ? v : `richblack/${v}`;
clone(full);
};
const shown = useMemo(() => {
if (!repos) return [];
const arr = repos.filter(r => {
if (filter === 'sdd') return r.has_sdd;
return true;
});
// 有進度等你處理的優先,其次已 clone,其次最近活動
arr.sort((a, b) =>
b.leo_count - a.leo_count ||
Number(b.cloned) - Number(a.cloned) ||
(b.last_activity ?? '').localeCompare(a.last_activity ?? ''),
);
return arr;
}, [repos, filter]);
// 待辦按 suggested_repo 分組(outlinerRepo 父 → todo 子)
const todosByRepo = useMemo(() => {
const m = new Map<string, Todo[]>();
for (const t of todos) {
const key = t.suggested_repo || 'new';
if (!m.has(key)) m.set(key, []);
m.get(key)!.push(t);
}
return m;
}, [todos]);
const newTodos = todosByRepo.get('new') ?? [];
return (
<main className="mira-page">
<div className="mira-content">
<header className="mira-proj-header">
<h1 className="mira-proj-title">📋 </h1>
<p className="mira-proj-sub"> clone repo · 🤖 Mira / 👤 </p>
<div className="mira-proj-controls">
<div className="mira-proj-sort">
{([['sdd', '有進度'], ['all', '全部']] as [Filter, string][]).map(([k, label]) => (
<button
key={k}
className={`mira-proj-sort-btn${filter === k ? ' is-active' : ''}`}
onClick={() => setFilter(k)}
>
{label}
</button>
))}
</div>
<div className="mira-proj-add">
<input
className="mira-proj-add-input"
placeholder="加 reponame 或 owner/name"
value={newRepo}
onChange={e => setNewRepo(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addRepo(); }}
/>
<button className="mira-proj-add-btn" onClick={addRepo} disabled={!!cloning || !newRepo.trim()}>
{cloning ? 'clone 中…' : '↓ clone'}
</button>
</div>
</div>
</header>
{error && (
<div className="mira-card mira-proj-error">
<strong> repo </strong>{error}
<div className="mira-proj-error-hint">
mira daemon{DAEMON}/projects daemon endpoint 10-A
</div>
</div>
)}
{!error && repos === null && <div className="mira-proj-loading"></div>}
{!error && repos?.length === 0 && (
<div className="mira-card mira-proj-empty">GitHub repo</div>
)}
<div className="mira-proj-grid">
{shown.map(r => (
<RepoCard
key={r.full_name}
r={r}
cloning={cloning === r.full_name}
onClone={() => clone(r.full_name)}
todos={todosByRepo.get(r.name) ?? []}
/>
))}
</div>
{newTodos.length > 0 && (
<div className="mira-card mira-proj-new-group">
<div className="mira-proj-new-title">💡 {newTodos.length}</div>
<ul className="mira-proj-todo-list">
{newTodos.map(t => (
<li key={t.id} className="mira-proj-todo-item">
<span className="mira-proj-todo-dot">·</span> {t.content}
{t.raw_id && (
<a className="mira-proj-todo-src" href={`/mira/feed#raw=${t.raw_id}`}> </a>
)}
</li>
))}
</ul>
</div>
)}
</div>
</main>
);
}
function pct(r: RepoSummary): number {
return r.total === 0 ? 0 : Math.round((r.done / r.total) * 100);
}
function relTime(iso: string | null): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '—';
const days = Math.floor((Date.now() - t) / 86400000);
if (days <= 0) return '今天';
if (days === 1) return '昨天';
if (days < 30) return `${days} 天前`;
return `${Math.floor(days / 30)} 個月前`;
}
function RepoCard({ r, cloning, onClone, todos }: { r: RepoSummary; cloning: boolean; onClone: () => void; todos: Todo[] }) {
const percent = pct(r);
return (
<div className="mira-card mira-proj-card">
<div className="mira-proj-card-top">
<span className="mira-proj-name">
{r.name}
{r.fork && <span className="mira-proj-tag">fork</span>}
{r.archived && <span className="mira-proj-tag">archived</span>}
</span>
<span className="mira-proj-time mira-en">{relTime(r.last_activity)}</span>
</div>
<div className="mira-proj-path mira-en">{r.full_name}</div>
{/* 河道偵測到、建議整進此專案的待辦(outliner 子項)*/}
{todos.length > 0 && (
<ul className="mira-proj-todo-list">
{todos.map(t => (
<li key={t.id} className="mira-proj-todo-item">
<span className="mira-proj-todo-dot">·</span> {t.content}
{t.raw_id && (
<a className="mira-proj-todo-src" href={`/mira/feed#raw=${t.raw_id}`}> </a>
)}
</li>
))}
</ul>
)}
{!r.cloned ? (
<div className="mira-proj-uncloned">
<span className="mira-proj-uncloned-hint"> clone Hetzner</span>
<button className="mira-proj-clone-btn" onClick={onClone} disabled={cloning}>
{cloning ? 'clone 中…' : '↓ clone 進工作態'}
</button>
</div>
) : !r.has_sdd ? (
<div className="mira-proj-nosdd"> clone · SDD .agents/specs</div>
) : (
<>
<div className="mira-proj-bar">
<div className="mira-proj-bar-fill" style={{ width: `${percent}%` }} />
</div>
<div className="mira-proj-stats">
<span className="mira-proj-pct mira-en">{percent}%</span>
<span className="mira-en">{r.done}/{r.total}</span>
{r.leo_count > 0 && <span className="mira-proj-chip mira-chip-leo">👤 {r.leo_count} </span>}
{r.ai_count > 0 && <span className="mira-proj-chip mira-chip-ai">🤖 {r.ai_count} Mira</span>}
{r.blocked_count > 0 && <span className="mira-proj-chip mira-chip-blocked"> {r.blocked_count}</span>}
</div>
</>
)}
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
'use client';
export const runtime = 'edge';
// Mira 搜尋頁 — Karpathy index pattern 為 primaryleo 2026-05-23
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4「Karpathy index pattern(不用 vector embedding)」
// 三層(C 混合):
// 1. Index 即時文字比對:掃 index-entry(entity 名 + 摘要)子字串命中 → 列 entity(零 token
// 2. LLM 路由(選用):整個 index 餵 Claude,問「leo 想找哪些 entity」→ 最貼 Karpathy 本意
// 3. 向量兜底(折疊):KBDB /search semanticSDD 明文「不是 primary,當保險」
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { MarkdownView } from '../_shared/markdown';
import '../mira.css';
const KBDB_BASE = 'https://kbdb.finally.click';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const CLAUDE_API = 'https://claude-api.arcrun.dev';
// 繁體異體字正規化(臺→台),讓 query 對得上多用「台」的 KB 內容
function normalizeQuery(q: string): string {
return q.replace(/臺/g, '台');
}
type IndexEntry = {
entity: string; // H1 / page_name 去 index- 前綴
pageName: string; // index-entry 自己的 page_nameindex-{entity}
oneLiner: string; // 「一句話定義」
outline: string; // facet outline 全文(拿來比對 + 餵 LLM)
raw: string; // 完整 content(餵 LLM 用,截斷)
};
// 解析 index-entry markdown → 結構
function parseIndexEntry(content: string, pageName: string): IndexEntry {
const entity = (content.match(/^#\s+(.+)$/m)?.[1] ?? pageName.replace(/^index-/, '')).trim();
const oneLiner = (content.match(/##\s*一句話定義\s*\n+([^\n#]+)/)?.[1] ?? '').trim();
const outlineMatch = content.match(/##\s*段落 outline[^\n]*\n([\s\S]*?)(?=\n##|$)/);
const outline = (outlineMatch?.[1] ?? '').trim();
return { entity, pageName, oneLiner, outline, raw: content.slice(0, 700) };
}
// entity 名 → wiki page 路由(wiki-{entity}
function wikiHref(entity: string): string {
return `/mira/wiki/${encodeURIComponent('wiki-' + entity)}`;
}
// ── 向量兜底型別 ──
type SearchMatch = {
score: number;
type: 'block' | 'triplet';
metadata?: { entity?: string;[k: string]: unknown };
block: { id: string; page_name: string | null; content: string | null; type: string; source: string | null } | null;
triplet: { id: string; subject?: string; predicate?: string; object?: string } | null;
};
function SearchInner() {
const router = useRouter();
const params = useSearchParams();
const initialQ = params.get('q') ?? '';
const [apiKey, setApiKey] = useState<string | null>(null);
const [input, setInput] = useState(initialQ);
const [query, setQuery] = useState(initialQ.trim());
const [index, setIndex] = useState<IndexEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
// LLM 路由結果
const [llmEntities, setLlmEntities] = useState<{ entity: string; reason: string }[] | null>(null);
const [llmLoading, setLlmLoading] = useState(false);
// 向量兜底
const [vecMatches, setVecMatches] = useState<SearchMatch[] | null>(null);
const [vecLoading, setVecLoading] = useState(false);
const [vecOpen, setVecOpen] = useState(false);
// 載入:me + 全部 index-entry
useEffect(() => {
(async () => {
try {
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
if (!meRes.ok) { window.location.href = '/login?redirect=/mira/search'; return; }
const me = (await meRes.json()) as { api_key: string };
setApiKey(me.api_key);
const r = await fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=300`, {
headers: { Authorization: `Bearer ${me.api_key}` },
});
if (!r.ok) { setError(`index 讀取失敗:${r.status}`); return; }
const data = (await r.json()) as { blocks?: { content: string; page_name: string }[] };
setIndex((data.blocks ?? []).map(b => parseIndexEntry(b.content || '', b.page_name || '')));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
})();
}, []);
// 即時 index 文字比對(純 client,零 token
const indexHits = useMemo(() => {
if (!index || !query) return [];
const q = normalizeQuery(query).toLowerCase();
const terms = q.split(/\s+/).filter(Boolean);
const scored = index
.map(e => {
const hay = normalizeQuery(`${e.entity}\n${e.oneLiner}\n${e.outline}`).toLowerCase();
let score = 0;
for (const t of terms) {
if (e.entity.toLowerCase().includes(t)) score += 10; // entity 名命中權重高
else if (hay.includes(t)) score += 3;
}
return { e, score };
})
.filter(x => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 12);
return scored.map(x => x.e);
}, [index, query]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const q = input.trim();
setQuery(q);
setLlmEntities(null);
setVecMatches(null);
setVecOpen(false);
router.replace(`/mira/search?q=${encodeURIComponent(q)}`);
};
// LLM 路由:整個 index 餵 Claude
const runLlmRoute = useCallback(async () => {
if (!index || !query || llmLoading) return;
setLlmLoading(true);
setLlmEntities(null);
try {
const indexDigest = index
.map(e => `- ${e.entity}${e.oneLiner || '(無摘要)'}`)
.join('\n');
const prompt =
`你是 leo 知識庫的索引導航員。以下是所有 wiki entity 的索引(entity:一句話定義):\n\n` +
`${indexDigest}\n\n---\n\n` +
`leo 想找:「${query}\n\n` +
`請從上面索引挑出最相關的 entity(最多 6 個,可能 0 個)。` +
`只輸出 JSON 陣列,格式 [{"entity":"<完全照抄索引裡的名稱>","reason":"<為何相關,20字內>"}],不要其他文字。`;
const res = await fetch(CLAUDE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, timeout_ms: 45000 }),
});
const data = (await res.json()) as { success?: boolean; data?: { text?: string } };
const text = data.data?.text ?? '';
const jsonMatch = text.match(/\[[\s\S]*\]/);
const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) as { entity: string; reason: string }[] : [];
// 只留實際存在於 index 的 entity
const known = new Set(index.map(e => e.entity));
setLlmEntities(parsed.filter(p => known.has(p.entity)));
} catch {
setLlmEntities([]);
} finally {
setLlmLoading(false);
}
}, [index, query, llmLoading]);
// 向量兜底
const runVecSearch = useCallback(async () => {
if (!apiKey || !query || vecLoading) return;
setVecOpen(true);
setVecLoading(true);
try {
const res = await fetch(`${KBDB_BASE}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ query: normalizeQuery(query), type: 'semantic', topK: 12 }),
});
const data = (await res.json()) as { matches?: SearchMatch[] };
setVecMatches(data.matches ?? []);
} catch {
setVecMatches([]);
} finally {
setVecLoading(false);
}
}, [apiKey, query, vecLoading]);
return (
<main className="mira-page">
<div className="mira-content">
<header style={{ padding: '24px 0 8px' }}>
<Link href="/mira/feed" style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}> </Link>
<h1 style={{ fontSize: 26, fontWeight: 700, color: '#fff', margin: '8px 0 0' }}>🔍 Wiki </h1>
<p style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
{index?.length ?? '…'} wiki Karpathy index
</p>
</header>
<form className="mira-search-form" onSubmit={submit}>
<input
className="mira-search-input"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="找主題(例:特化、台北大學、本地模型)"
autoFocus
/>
<button type="submit" className="mira-btn-primary" disabled={!index}></button>
</form>
{error && <div className="mira-error" style={{ marginBottom: 10 }}>{error}</div>}
{!index && !error && <div className="empty-state"></div>}
{index && query && (
<>
{/* 第 1 層:index 即時命中 */}
<section style={{ marginTop: 8 }}>
<div className="mira-search-section-head">📇 {indexHits.length}</div>
{indexHits.length === 0 ? (
<div className="empty-state" style={{ padding: '16px 0' }}></div>
) : (
indexHits.map(e => (
<Link key={e.pageName} href={wikiHref(e.entity)} className="mira-search-result">
<div className="mira-search-result-meta"><span>📚 {e.entity}</span></div>
{e.oneLiner && <div className="mira-search-snippet">{e.oneLiner}</div>}
</Link>
))
)}
</section>
{/* 第 2 層:LLM 路由(選用) */}
<section style={{ marginTop: 18 }}>
{!llmEntities && (
<button type="button" className="mira-search-llm-btn" onClick={runLlmRoute} disabled={llmLoading}>
{llmLoading ? '🧠 Mira 翻索引中…' : '🧠 找不到?讓 Mira 讀整個索引幫你找'}
</button>
)}
{llmEntities && (
<>
<div className="mira-search-section-head">🧠 Mira {llmEntities.length}</div>
{llmEntities.length === 0 ? (
<div className="empty-state" style={{ padding: '12px 0' }}>Mira </div>
) : (
llmEntities.map(p => (
<Link key={p.entity} href={wikiHref(p.entity)} className="mira-search-result">
<div className="mira-search-result-meta"><span>📚 {p.entity}</span></div>
<div className="mira-search-snippet" style={{ color: 'var(--mira-text-3)' }}>{p.reason}</div>
</Link>
))
)}
</>
)}
</section>
{/* 第 3 層:向量兜底(折疊) */}
<section style={{ marginTop: 18 }}>
{!vecOpen ? (
<button type="button" className="mira-search-vec-toggle" onClick={runVecSearch}>
/
</button>
) : (
<>
<div className="mira-search-section-head">🧬 </div>
{vecLoading && <div className="empty-state" style={{ padding: '12px 0' }}></div>}
{vecMatches && vecMatches.length === 0 && !vecLoading && (
<div className="empty-state" style={{ padding: '12px 0' }}></div>
)}
{vecMatches && vecMatches.map((m, i) => <VecResult key={i} match={m} />)}
</>
)}
</section>
</>
)}
</div>
</main>
);
}
function VecResult({ match }: { match: SearchMatch }) {
const pct = Math.round((match.score ?? 0) * 100);
if (match.triplet) {
const { subject, predicate, object } = match.triplet;
return (
<div className="mira-search-result">
<div className="mira-search-result-meta"><span></span>{pct > 0 && <span className="mira-search-score">{pct}%</span>}</div>
<div className="mira-search-snippet" style={{ fontFamily: 'monospace' }}>{subject} {predicate} {object}</div>
</div>
);
}
const b = match.block;
if (!b) return null;
const snippet = (b.content ?? '').replace(/\n+/g, ' ').slice(0, 200);
const href = b.type === 'wiki-page' && b.page_name
? `/mira/wiki/${encodeURIComponent(b.page_name)}`
: b.page_name ? `/mira/feed#page=${encodeURIComponent(b.page_name)}` : `/mira/feed#raw=${encodeURIComponent(b.id)}`;
return (
<Link href={href} className="mira-search-result">
<div className="mira-search-result-meta">
<span>{b.type === 'wiki-page' ? '📚 Wiki' : '🌊 河道'}</span>
{pct > 0 && <span className="mira-search-score">{pct}%</span>}
</div>
<div className="mira-search-snippet"><MarkdownView text={snippet + ((b.content ?? '').length > 200 ? '…' : '')} /></div>
</Link>
);
}
export default function MiraSearchPage() {
return (
<Suspense fallback={<div className="empty-state"></div>}>
<SearchInner />
</Suspense>
);
}
+467
View File
@@ -0,0 +1,467 @@
'use client';
export const runtime = 'edge';
// Mira Wiki 單篇頁
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 + §3.5.12
// 對應 task: 7C.2 + 7B.3g
// 路由:/mira/wiki/[pageName]
// 顯示:wiki-page parent → wiki-paragraph children (按 facet 分區) → triplet grandchildren
// 7B.3g 升級:樹狀渲染 + 折疊 + triplet 跨 wiki 連結化
import { useEffect, useMemo, useState, use } from 'react';
import Link from 'next/link';
import { MarkdownView } from '../../_shared/markdown';
import '../../mira.css';
const KBDB_BASE = 'https://kbdb.finally.click';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type Block = {
id: string;
page_name: string | null;
content: string;
type: string;
parent_id: string | null;
tags_json: string | null;
source: string | null;
created_at: number;
updated_at: number;
};
type FacetGroup = {
facet: string;
paragraphs: Array<{
block: Block;
triplets: Block[];
}>;
};
export default function WikiPagePage({
params,
}: {
params: Promise<{ pageName: string }>;
}) {
const { pageName } = use(params);
const decodedName = decodeURIComponent(pageName);
const [block, setBlock] = useState<Block | null>(null);
const [paragraphs, setParagraphs] = useState<Block[]>([]);
const [triplets, setTriplets] = useState<Block[]>([]);
const [entitySet, setEntitySet] = useState<Set<string>>(new Set());
// Backlinks:所有提到此 entity 的 raw noteV3 wiki_synthesis 在 wiki-page tags 寫 raw:XXX
// 對應 leo 2026-05-17 #2 反饋:「從這本書的條目應該反向連到那篇筆記去」
const [backlinkRaws, setBacklinkRaws] = useState<Block[]>([]);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
if (!meRes.ok) throw new Error('未登入');
const me = (await meRes.json()) as { api_key: string };
const headers = { Authorization: `Bearer ${me.api_key}` };
// 1. 抓 wiki-page parent block by page_name
const pageRes = await fetch(
`${KBDB_BASE}/blocks?page_name=${encodeURIComponent(decodedName)}&limit=1`,
{ headers },
);
if (!pageRes.ok) throw new Error(`KBDB ${pageRes.status}`);
const pageData = await pageRes.json();
const wikiPage: Block | undefined = pageData.blocks?.[0];
if (cancelled) return;
if (!wikiPage) {
setError(`找不到 wiki page${decodedName}`);
return;
}
setBlock(wikiPage);
// 2. 平行撈所有 wiki-paragraph + triplet + wiki-page(跨 wiki 連結用),客戶端 filter by parent_id
// KBDB 沒 parent_id server filter(兼 tag filter 還有 KI-3 bug),用 source+type 取再 client-side filter
const [paraRes, tripRes, pageListRes] = await Promise.all([
fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=wiki-paragraph&limit=500`, { headers }),
fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=triplet&limit=1000`, { headers }),
fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=wiki-page&limit=500`, { headers }),
]);
if (!paraRes.ok || !tripRes.ok || !pageListRes.ok) {
throw new Error('KBDB tree fetch failed');
}
const paraData = await paraRes.json();
const tripData = await tripRes.json();
const pageListData = await pageListRes.json();
if (cancelled) return;
const allParas: Block[] = paraData.blocks ?? [];
const allTrips: Block[] = tripData.blocks ?? [];
const allPages: Block[] = pageListData.blocks ?? [];
// 該 wiki-page 的 paragraphs
const myParas = allParas
.filter((p) => p.parent_id === wikiPage.id)
.sort((a, b) => a.created_at - b.created_at);
setParagraphs(myParas);
// 該 wiki-page 範圍內所有 paragraph 的 triplets
const paraIdSet = new Set(myParas.map((p) => p.id));
const myTrips = allTrips.filter((t) => t.parent_id && paraIdSet.has(t.parent_id));
setTriplets(myTrips);
// 跨 wiki 連結用:所有 wiki-page 的 entity 名稱(content 就是 entity
// 額外把 page_name 也加入(page_name=wiki-{entity}
const eset = new Set<string>();
for (const p of allPages) {
if (p.content) eset.add(p.content.trim());
if (p.page_name?.startsWith('wiki-')) {
eset.add(p.page_name.slice(5).trim());
}
}
setEntitySet(eset);
// Backlinks:找此 entity 的所有 wiki-page (可能多次寫入),提取 raw:XXX tag → fetch raw blocks
if (wikiPage.type === 'wiki-page' && wikiPage.content) {
const sameEntity = allPages.filter((p) => p.content?.trim() === wikiPage.content?.trim());
const rawIds = new Set<string>();
for (const wp of sameEntity) {
try {
const tags = JSON.parse(wp.tags_json || '[]') as string[];
for (const t of tags) {
if (typeof t === 'string' && t.startsWith('raw:')) {
rawIds.add(t.slice(4));
}
}
} catch { /* skip */ }
}
if (rawIds.size > 0) {
// 一次撈 raw blockspage_name 是 unique 一次 query 一個
const rawBlocks: Block[] = [];
await Promise.all(
Array.from(rawIds).map(async (rawId) => {
try {
// KBDB GET /blocks/:id 直接 by id (走 list with block_id filter)
const r = await fetch(`${KBDB_BASE}/blocks/${rawId}`, { headers });
if (r.ok) {
const data = await r.json();
const b = data.blocks?.[0] ?? data;
if (b?.id) rawBlocks.push(b as Block);
}
} catch { /* skip */ }
}),
);
if (!cancelled) {
setBacklinkRaws(rawBlocks.sort((a, b) => b.updated_at - a.updated_at));
}
}
}
} catch (e: any) {
if (!cancelled) setError(e?.message ?? 'load failed');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [decodedName]);
// 按 facet 分區
const facetGroups = useMemo<FacetGroup[]>(() => {
const groups: Map<string, Array<{ block: Block; triplets: Block[] }>> = new Map();
for (const p of paragraphs) {
const facet = extractFacet(p.tags_json) ?? '未分類';
const myTrips = triplets.filter((t) => t.parent_id === p.id);
if (!groups.has(facet)) groups.set(facet, []);
groups.get(facet)!.push({ block: p, triplets: myTrips });
}
return Array.from(groups.entries()).map(([facet, paragraphs]) => ({ facet, paragraphs }));
}, [paragraphs, triplets]);
const isWikiPage = block?.type === 'wiki-page';
// 標題:wiki-page 用 contententity 名稱),其他(index-entry/schema/log/...)用 page_name 剝 prefix
// 修 bug:原本一律用 block.content,但 index-entry 的 content 是整篇 markdown,會把整個 content render 成 h1
const entity = isWikiPage
? (block?.content?.trim() || decodedName.replace(/^wiki-/, ''))
: decodedName.replace(/^(wiki|index)-/, '');
function toggleCollapse(key: string) {
setCollapsed((c) => ({ ...c, [key]: !c[key] }));
}
return (
<main className="mira-page">
<div className="mira-content mira-wiki-detail">
<header style={{ padding: '24px 0 16px', borderBottom: '1px solid #2a2a2a' }}>
<Link
href="/mira/wiki"
style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}
>
Wiki
</Link>
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: '8px 0 4px' }}>
{entity}
</h1>
{block && (
<div style={{ color: '#666', fontSize: 12 }}>
{block.type} updated {new Date(block.updated_at * 1000).toLocaleString('zh-TW')}
</div>
)}
</header>
{loading && <div style={{ padding: 24, color: '#666' }}></div>}
{error && <div style={{ padding: 24, color: '#e66' }}>{error}</div>}
{block && !loading && !error && (
<>
{/* wiki-page tree view */}
{isWikiPage && facetGroups.length > 0 && (
<article style={{ padding: '8px 0 24px' }}>
{facetGroups.map((group) => (
<FacetSection
key={group.facet}
group={group}
entitySet={entitySet}
collapsed={collapsed}
toggleCollapse={toggleCollapse}
/>
))}
</article>
)}
{/* wiki-page 但沒 childrenfallback render content */}
{isWikiPage && facetGroups.length === 0 && (
<article style={{ padding: '8px 0 24px', color: '#888' }}>
<em>wiki_synthesis children</em>
<MarkdownView text={block.content} />
</article>
)}
{/* 非 wiki-pageschema / index / log / index-entry 等):直接 render content */}
{!isWikiPage && (
<article style={{ padding: '20px 0' }}>
<MarkdownView text={block.content} />
</article>
)}
{/* Backlinks:提到此 entity 的 raw notes */}
{isWikiPage && backlinkRaws.length > 0 && (
<section
style={{
margin: '24px 0 16px',
padding: '12px 14px',
borderLeft: '3px solid #4a3a2a',
background: 'rgba(80, 60, 40, 0.08)',
}}
>
<h3 style={{ margin: '0 0 8px', fontSize: 14, color: '#aab', fontWeight: 600 }}>
📎 entity ({backlinkRaws.length})
</h3>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, lineHeight: 1.6 }}>
{backlinkRaws.map((raw) => {
const preview = (raw.content || '').replace(/\n/g, ' ').slice(0, 100);
const href = `/mira/feed#page=${encodeURIComponent(raw.page_name || raw.id)}`;
return (
<li key={raw.id} style={{ marginBottom: 4 }}>
<a
href={href}
style={{ color: '#9ab', textDecoration: 'none' }}
title={raw.content || ''}
>
{preview}
{(raw.content || '').length > 100 && '…'}
</a>
</li>
);
})}
</ul>
</section>
)}
<footer
style={{
padding: '20px 0',
borderTop: '1px solid #1f1f1f',
color: '#555',
fontSize: 12,
}}
>
<div>id: <span style={{ fontFamily: 'monospace' }}>{block.id}</span></div>
<div>type: {block.type}</div>
{block.source && <div>source: {block.source}</div>}
{block.parent_id && (
<div>
parent: <span style={{ fontFamily: 'monospace' }}>{block.parent_id}</span>
</div>
)}
{paragraphs.length > 0 && (
<div>
{paragraphs.length} paragraph(s) {triplets.length} triplet(s)
</div>
)}
</footer>
</>
)}
</div>
</main>
);
}
function FacetSection({
group,
entitySet,
collapsed,
toggleCollapse,
}: {
group: FacetGroup;
entitySet: Set<string>;
collapsed: Record<string, boolean>;
toggleCollapse: (key: string) => void;
}) {
const key = `facet:${group.facet}`;
const isCollapsed = collapsed[key] ?? false; // 預設展開(leo 看一篇 wiki 時要看內容)
return (
<section style={{ margin: '16px 0', borderLeft: '3px solid #2a3a4a', paddingLeft: 14 }}>
<button
onClick={() => toggleCollapse(key)}
style={{
background: 'transparent',
border: 'none',
color: '#aab',
fontSize: 16,
fontWeight: 600,
padding: '4px 0',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
}}
>
{isCollapsed ? '▸' : '▾'} {group.facet}
<span style={{ color: '#555', fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
({group.paragraphs.length})
</span>
</button>
{!isCollapsed &&
group.paragraphs.map((p) => (
<ParagraphBlock
key={p.block.id}
block={p.block}
triplets={p.triplets}
entitySet={entitySet}
collapsed={collapsed}
toggleCollapse={toggleCollapse}
/>
))}
</section>
);
}
function ParagraphBlock({
block,
triplets,
entitySet,
collapsed,
toggleCollapse,
}: {
block: Block;
triplets: Block[];
entitySet: Set<string>;
collapsed: Record<string, boolean>;
toggleCollapse: (key: string) => void;
}) {
const tripKey = `trip:${block.id}`;
const tripsCollapsed = collapsed[tripKey] ?? true; // triplets 預設折疊
return (
<div style={{ margin: '12px 0 16px', paddingLeft: 4 }}>
<div style={{ color: '#ddd', lineHeight: 1.7 }}>
<MarkdownView text={block.content} />
</div>
{triplets.length > 0 && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => toggleCollapse(tripKey)}
style={{
background: 'transparent',
border: 'none',
color: '#666',
fontSize: 12,
padding: '2px 0',
cursor: 'pointer',
}}
>
{tripsCollapsed ? '▸' : '▾'} ({triplets.length})
</button>
{!tripsCollapsed && (
<ul style={{ listStyle: 'none', padding: '4px 0 0 12px', margin: 0 }}>
{triplets.map((t) => (
<li
key={t.id}
style={{
color: '#888',
fontSize: 13,
padding: '2px 0',
fontFamily: 'monospace',
}}
>
<TripletRender content={t.content} entitySet={entitySet} />
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
/** Render triplet "A >> 關係 >> B" with A/B linkified if they match an existing wiki entity */
function TripletRender({
content,
entitySet,
}: {
content: string;
entitySet: Set<string>;
}) {
// 切「>>」分 A / 關係 / B
const parts = content.split('>>').map((s) => s.trim());
if (parts.length !== 3) {
return <>{content}</>;
}
const [a, rel, b] = parts;
return (
<>
<EntityLink name={a} entitySet={entitySet} />{' '}
<span style={{ color: '#666' }}>&gt;&gt; {rel} &gt;&gt;</span>{' '}
<EntityLink name={b} entitySet={entitySet} />
</>
);
}
function EntityLink({ name, entitySet }: { name: string; entitySet: Set<string> }) {
if (entitySet.has(name)) {
return (
<Link
href={`/mira/wiki/${encodeURIComponent(`wiki-${name}`)}`}
style={{ color: '#88c0ff', textDecoration: 'none' }}
>
{name}
</Link>
);
}
return <span style={{ color: '#ccc' }}>{name}</span>;
}
function extractFacet(tags_json: string | null | undefined): string | null {
if (!tags_json) return null;
try {
const tags = JSON.parse(tags_json) as string[];
const facetTag = tags.find((t) => t.startsWith('facet:'));
return facetTag ? facetTag.slice(6) : null;
} catch {
return null;
}
}
+346
View File
@@ -0,0 +1,346 @@
'use client';
// Mira Wiki 索引頁
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 + §3.5.10
// 對應 task: 7C.1
// 階段 7-A 已建:mira-wiki-schema、mira-wiki-index(+4 children)、mira-wiki-log(+1 child)
// 此頁列出這些 infra block 與既有 wiki-page,方便 leo 在瀏覽器確認 schema 寫得對不對
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import '../mira.css';
const KBDB_BASE = 'https://kbdb.finally.click';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type Block = {
id: string;
page_name: string;
content: string;
type: string;
parent_id: string | null;
tags_json: string | null;
created_at: number;
};
export default function WikiIndexPage() {
const [schema, setSchema] = useState<Block | null>(null);
const [indexChildren, setIndexChildren] = useState<Block[]>([]);
const [logEntries, setLogEntries] = useState<Block[]>([]);
const [otherWikiPages, setOtherWikiPages] = useState<Block[]>([]);
const [indexEntries, setIndexEntries] = useState<Block[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
// 先拿 ak_ partner key(同 page.tsx pattern
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
if (!meRes.ok) throw new Error('未登入');
const me = (await meRes.json()) as { api_key: string };
const headers = { Authorization: `Bearer ${me.api_key}` };
// 撈所有 type=wiki-page,再 client 端過濾 tags 含 'mira-wiki'
// 原本 ?tag=mira-wiki 撞 KBDB worker D1 bugmalformed JSON),改 type filter
// 待 KBDB 修 tag filter 後可改回(SDD 待開 kbdb-tag-filter-fix
const res = await fetch(
`${KBDB_BASE}/blocks?type=wiki-page&limit=200`,
{ headers },
);
if (!res.ok) throw new Error(`KBDB ${res.status}`);
const data = await res.json();
if (cancelled) return;
const allWikiBlocks: Block[] = data.blocks ?? [];
// Client 端過濾:只留 tags 含 'mira-wiki'
const blocks: Block[] = allWikiBlocks.filter((b) => {
if (!b.tags_json) return false;
try {
const tags = JSON.parse(b.tags_json) as string[];
return tags.includes('mira-wiki');
} catch {
return false;
}
});
const tagsOf = (b: Block): string[] => {
if (!b.tags_json) return [];
try {
return JSON.parse(b.tags_json) as string[];
} catch {
return [];
}
};
const hasSubtype = (b: Block, st: string) =>
tagsOf(b).includes(`subtype:${st}`);
const hasAnyInfraSubtype = (b: Block) =>
['schema', 'index', 'index-child', 'log', 'log-child'].some((st) => hasSubtype(b, st));
const hasMetaTag = (b: Block) =>
tagsOf(b).some((t) => t === 'data-source-config' || t === 'source-skill');
setSchema(blocks.find((b) => hasSubtype(b, 'schema')) ?? null);
setIndexChildren(
blocks
.filter((b) => hasSubtype(b, 'index-child'))
.sort((a, b) => a.page_name.localeCompare(b.page_name)),
);
setLogEntries(
blocks
.filter((b) => hasSubtype(b, 'log-child'))
.sort((a, b) => b.page_name.localeCompare(a.page_name)),
);
// 真正的 wiki-page paragraphs(排除 infra 跟 meta 配置)
setOtherWikiPages(
blocks
.filter((b) => !hasAnyInfraSubtype(b) && !hasMetaTag(b))
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)),
);
// 平行撈 index-entry blocksper-entity 摘要,CC navigation entry point
// 對應 design.md §3.5.12.4.1 / 7B.3f
const idxRes = await fetch(
`${KBDB_BASE}/blocks?type=index-entry&limit=200`,
{ headers },
);
if (idxRes.ok) {
const idxData = await idxRes.json();
if (!cancelled) {
const idxBlocks: Block[] = idxData.blocks ?? [];
setIndexEntries(
idxBlocks.sort((a, b) => (a.page_name ?? '').localeCompare(b.page_name ?? '')),
);
}
}
} catch (e: any) {
if (!cancelled) setError(e?.message ?? 'load failed');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
// Dedupe wiki-pages by entitycontent)— 累積式設計每個 raw 各建一個 wiki-page
// 同 entity 多版只在 listing 顯示最新一張卡 + 版本數提示
const dedupedWikiPages = useMemo(() => {
const groups = new Map<string, { entity: string; latest: Block; versionCount: number }>();
for (const p of otherWikiPages) {
const entity = (p.content || '').trim() || p.page_name || '?';
const existing = groups.get(entity);
if (!existing) {
groups.set(entity, { entity, latest: p, versionCount: 1 });
} else {
existing.versionCount++;
if ((p.created_at ?? 0) > (existing.latest.created_at ?? 0)) {
existing.latest = p;
}
}
}
return Array.from(groups.values()).sort(
(a, b) => (b.latest.created_at ?? 0) - (a.latest.created_at ?? 0),
);
}, [otherWikiPages]);
return (
<main className="mira-page">
<div className="mira-content">
<header style={{ padding: '24px 0 16px', borderBottom: '1px solid #2a2a2a' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
<Link
href="/mira"
style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}
>
Mira
</Link>
</div>
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: 0 }}>
📚 Mira Wiki
</h1>
<p style={{ color: '#888', fontSize: 14, marginTop: 4 }}>
leo Karpathy LLM Wiki
</p>
</header>
{loading && <div style={{ padding: 24, color: '#666' }}></div>}
{error && (
<div style={{ padding: 24, color: '#e66' }}>{error}</div>
)}
{!loading && !error && (
<>
<Section title="📋 Schema(合成規則)">
{schema ? (
<WikiCardLink page_name={schema.page_name} title="mira-wiki-schema" excerpt="ingest 規則手冊:cypher binding、17 predicates、entity normalize⋯" />
) : (
<Empty> schema</Empty>
)}
</Section>
<Section title="🗂 Index4 個分類)">
{indexChildren.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{indexChildren.map((b) => {
const tags = b.tags_json ? (JSON.parse(b.tags_json) as string[]) : [];
const key = tags.find((t) => t.startsWith('index-key:'))?.replace('index-key:', '') ?? '?';
return (
<WikiCardLink
key={b.id}
page_name={b.page_name}
title={`${iconForKey(key)} ${key}`}
excerpt={firstLineOf(b.content)}
/>
);
})}
</div>
) : (
<Empty>index children </Empty>
)}
</Section>
<Section title={`🧭 Index Entries${indexEntries.length})— CC 看的 entity 摘要`}>
{indexEntries.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{indexEntries.map((b) => {
const entity = (b.page_name ?? '').replace(/^index-/, '');
const firstLine = firstLineOf(b.content)
.replace(/^#+\s*/, '')
.slice(0, 80);
return (
<WikiCardLink
key={b.id}
page_name={b.page_name ?? ''}
title={entity}
excerpt={firstLine || `index for ${entity}`}
/>
);
})}
</div>
) : (
<Empty> index-entrywiki_synthesis </Empty>
)}
</Section>
<Section title="📜 Log(每月一筆)">
{logEntries.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{logEntries.map((b) => (
<WikiCardLink
key={b.id}
page_name={b.page_name}
title={b.page_name}
excerpt={firstLineOf(b.content)}
/>
))}
</div>
) : (
<Empty> log</Empty>
)}
</Section>
<Section title={`📖 Wiki Pages${dedupedWikiPages.length},原 ${otherWikiPages.length} 筆累積版本)`}>
{dedupedWikiPages.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{dedupedWikiPages.map((g) => (
<WikiCardLink
key={g.latest.id}
page_name={g.latest.page_name}
title={g.entity}
excerpt={
g.versionCount > 1
? `${g.versionCount} 版累積 ・ 最新 ${new Date((g.latest.created_at ?? 0) * 1000).toLocaleString('zh-TW')}`
: `建立 ${new Date((g.latest.created_at ?? 0) * 1000).toLocaleString('zh-TW')}`
}
/>
))}
</div>
) : (
<Empty> wiki page 7-B ai-canon-wiki workflow </Empty>
)}
</Section>
</>
)}
</div>
</main>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={{ padding: '20px 0', borderBottom: '1px solid #1f1f1f' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: '#ddd', marginBottom: 12 }}>
{title}
</h2>
{children}
</section>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div style={{ color: '#555', fontStyle: 'italic', fontSize: 13 }}>{children}</div>
);
}
function WikiCardLink({
page_name,
title,
excerpt,
}: {
page_name: string;
title: string;
excerpt: string;
}) {
return (
<Link
href={`/mira/wiki/${encodeURIComponent(page_name)}`}
style={{
display: 'block',
padding: '12px 14px',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: 6,
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ color: '#ddd', fontWeight: 500, marginBottom: 4 }}>{title}</div>
{excerpt && (
<div
style={{
color: '#888',
fontSize: 13,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{excerpt}
</div>
)}
</Link>
);
}
function iconForKey(key: string): string {
return (
{
entities: '🧩',
topics: '📂',
sources: '🔗',
stale: '⚠️',
}[key] ?? '•'
);
}
function firstLineOf(content: string): string {
if (!content) return '';
const firstNonHeader = content
.split('\n')
.map((l) => l.trim())
.find((l) => l && !l.startsWith('#') && !l.startsWith('>'));
return firstNonHeader ?? '';
}
+199
View File
@@ -0,0 +1,199 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import SiteNav from './components/SiteNav';
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const CODE_DEMOS = {
python: `pip install arcrun
from arcrun import Arcrun
client = Arcrun() # reads ARCRUN_API_KEY from env
# One-time setup: upload credential
client.auth.setup("notion", token="secret_xxx")
# Every time after: just bind and use
notion = client.auth.bind("notion")
pages = notion.get("/pages").json()
# Works with any of 20+ services
drive = client.auth.bind("google_drive_sa")
files = drive.get("/files").json()`,
javascript: `npm install arcrun
import { Arcrun } from 'arcrun'
const client = new Arcrun() // reads ARCRUN_API_KEY from env
// One-time setup: upload credential
await client.auth.setup('notion', { token: 'secret_xxx' })
// Every time after: just bind and use
const notion = await client.auth.bind('notion')
const pages = await (await notion.get('/pages')).json()
// Run a deployed workflow
const result = await client.workflows.run('my-flow', {
email: 'user@example.com'
})`,
http: `# Works with any HTTP tool — curl, n8n, Make, Postman
POST ${API_BASE}/webhooks/named/my-workflow/trigger
X-Arcrun-API-Key: YOUR_API_KEY
Content-Type: application/json
{
"email": "user@example.com"
}
# n8n: HTTP Request 西`,
};
export default function HomePage() {
const [activeTab, setActiveTab] = useState<'python' | 'javascript' | 'http'>('python');
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
fetch(`${API_BASE}/me`, { credentials: 'include' })
.then(r => { if (r.ok) setIsLoggedIn(true); })
.catch(() => {});
}, []);
return (
<div className="flex flex-col min-h-screen bg-[#0a0a0a] text-[#ededed]">
<SiteNav currentPath="/" />
{/* Hero */}
<section className="flex flex-col items-center text-center px-6 pt-24 pb-16">
<div className="inline-flex items-center gap-2 bg-[#1a1a2e] text-indigo-400 text-xs px-3 py-1 rounded-full mb-6 border border-indigo-900/50">
<span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-pulse inline-block"></span>
Open Source · Free API Key · No Credit Card
</div>
<h1 className="text-5xl md:text-6xl font-bold text-white mb-4 leading-tight max-w-3xl">
Stop fighting OAuth.
</h1>
<p className="text-xl md:text-2xl text-[#888] mb-3 max-w-2xl">
One API key. Every service. Works anywhere.
</p>
<p className="text-[#555] max-w-xl mb-10">
arcrun handles Google, Notion, GitHub, Slack authentication
so your Python / JS code doesn&apos;t have to.
</p>
<div className="flex gap-3 flex-wrap justify-center">
<Link href={isLoggedIn ? '/dashboard' : '/login'}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-3 rounded-lg font-medium transition-colors">
{isLoggedIn ? 'Go to Dashboard' : 'Get API Key — Free'}
</Link>
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
className="border border-[#333] hover:border-[#555] text-[#aaa] hover:text-white px-6 py-3 rounded-lg font-medium transition-colors">
View on GitHub
</a>
</div>
</section>
{/* Before / After */}
<section className="max-w-4xl mx-auto px-6 pb-16 w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<div className="bg-[#111] border border-[#222] rounded-xl p-6">
<div className="text-[#555] text-xs mb-3 font-mono uppercase tracking-wider">Before</div>
<div className="text-red-400 text-sm font-mono space-y-1 opacity-70">
<div>40 OAuth </div>
<div>GCP Console </div>
<div>debug </div>
<div>Service Account JSON</div>
<div>token </div>
</div>
</div>
<div className="flex items-center justify-center text-[#333] text-4xl font-thin select-none"></div>
<div className="bg-[#111] border border-indigo-900/50 rounded-xl p-6">
<div className="text-indigo-400 text-xs mb-3 font-mono uppercase tracking-wider">After</div>
<pre className="text-green-400 text-sm font-mono leading-relaxed">
{`from arcrun import auth
drive = auth.bind(
"google_drive"
)
# done.`}
</pre>
</div>
</div>
</section>
{/* Code Demo */}
<section className="max-w-3xl mx-auto px-6 pb-20 w-full">
<div className="bg-[#111] border border-[#222] rounded-xl overflow-hidden">
<div className="flex border-b border-[#1e1e1e]">
{(['python', 'javascript', 'http'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-5 py-3 text-sm font-medium transition-colors cursor-pointer ${
activeTab === tab
? 'text-white border-b-2 border-indigo-500 bg-[#0d0d1a]'
: 'text-[#555] hover:text-[#aaa]'
}`}
>
{tab === 'python' ? 'Python' : tab === 'javascript' ? 'JavaScript' : 'HTTP / n8n'}
</button>
))}
</div>
<div className="p-6 overflow-x-auto">
<pre className="text-sm text-[#cdd6f4] leading-relaxed">
<code>{CODE_DEMOS[activeTab]}</code>
</pre>
</div>
</div>
{activeTab === 'http' && (
<p className="text-[#444] text-sm mt-3 text-center">
n8n HTTP Request 西
</p>
)}
</section>
{/* Features */}
<section className="max-w-4xl mx-auto px-6 pb-20 w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ title: '20+ 服務開箱即用', desc: 'Google、Notion、GitHub、Slack、OpenAI、Stripe... 全部一個 bind() 搞定。', icon: '🔌' },
{ title: 'AI-first 設計', desc: 'Token 消耗極小,YAML workflow 幾十個 token,讓 AI 直接讀寫執行。', icon: '🤖' },
{ title: '完全開源', desc: 'MIT 授權。Self-host 在你自己的 Cloudflare,或使用我們的 hosted 服務。', icon: '🔓' },
].map(f => (
<div key={f.title} className="bg-[#111] border border-[#1e1e1e] rounded-xl p-6">
<div className="text-2xl mb-3">{f.icon}</div>
<h3 className="text-white font-semibold mb-2">{f.title}</h3>
<p className="text-[#555] text-sm leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</section>
{/* CTA */}
<section className="border-t border-[#1a1a1a] py-16 text-center px-6">
<h2 className="text-3xl font-bold text-white mb-3"></h2>
<p className="text-[#555] mb-8"> API Key</p>
<Link href={isLoggedIn ? '/dashboard' : '/login'}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-8 py-3 rounded-lg font-medium transition-colors">
{isLoggedIn ? 'Go to Dashboard' : '免費取得 API Key'}
</Link>
</section>
{/* Footer */}
<footer className="border-t border-[#1a1a1a] py-8 px-6 text-center text-[#333] text-sm mt-auto">
<div className="flex items-center justify-center gap-6">
<Link href="/integrations" className="hover:text-[#777] transition-colors">Integrations</Link>
<Link href="/api-docs" className="hover:text-[#777] transition-colors">API Docs</Link>
<a href="https://github.com/richblack/arcrun" target="_blank" rel="noopener noreferrer"
className="hover:text-[#777] transition-colors">GitHub</a>
</div>
<p className="mt-4">arcrun MIT License</p>
</footer>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Protect /dashboard and /mira (login required; mira 額外白名單檢查在 layout)
if (pathname.startsWith('/dashboard') || pathname.startsWith('/mira')) {
const session = request.cookies.get('arcrun_session');
if (!session?.value) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard', '/dashboard/:path*', '/mira', '/mira/:path*'],
};
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Cloudflare Pages edge runtime compatibility
};
export default nextConfig;
+4965
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "landing",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build:pages": "npx @cloudflare/next-on-pages",
"start": "next start",
"deploy": "npm run build:pages && wrangler pages deploy"
},
"dependencies": {
"@cloudflare/next-on-pages": "^1.13.16",
"next": "^15.5.15",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"wrangler": "^4.83.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+42
View File
@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}
+7
View File
@@ -0,0 +1,7 @@
name = "arcrun-landing"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
[vars]
NEXT_PUBLIC_API_BASE = "https://cypher.arcrun.dev"
+19
View File
@@ -0,0 +1,19 @@
# 1. Vision — 設計哲學與前瞻
> 「為什麼」層:arcrun 的核心價值、設計目標、未來方向。
## 包含內容(待補充)
- arcrun 與傳統 low-code/n8n 的差異
- Haiku 就能搞定是設計目標,不是 Sonnet 妥協
- 薄殼原則:能力長在 API,介面只暴露
- 市場機制:recipe 信任靠量和星數,不靠人工審核
- 坦誠限制:不假綠、不偽造人類確認
相關詳細內容暫存於:
- `.claude/rules/06-mindset.md`(待搬來)
- `.claude/wiki/decisions-summary.md`(架構決策)
---
更新時間:2026-06-08
@@ -0,0 +1,99 @@
# SDD 協議(每次啟動必讀)
## 第零原則:沒讀 SDD 不准動 code
任何 `.go` / `.ts` / `.tsx` / `.wasm` 相關變動,**必須**按以下順序執行。**不得簡化,不得跳過**。
### 步驟 1:讀總進度
先讀 `docs/3-specs/arcrun/arcrun.md`,了解當前 Phase。
### 步驟 2:定位對應 SDD
根據任務性質找對應 SDD
| 任務類型 | 對應 SDD |
|---------|---------|
| Auth primitive WASM 零件(static_key/oauth2/service_account/mtls | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| 清除 cypher-executor 裡的 TS 業務邏輯 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| WASI shim host functionskv_get / crypto_decrypt / crypto_sign_rs256 | `docs/3-specs/arcrun/credential-primitives-wasm/` |
| Auth Recipe 系統(recipe schema、KV 格式) | `docs/3-specs/arcrun/auth-recipe.md` |
| Landing Page | `docs/3-specs/arcrun/landing-page.md` |
| CLI / SDKPython/JS | `docs/3-specs/arcrun/sdk-and-website/` |
| arcrun-core-mvp 整體架構 | `docs/3-specs/arcrun-core-mvp/` |
| Platform Evolution | `docs/3-specs/arcrun-platform-evolution/` |
| Credential 長期規格(需求源) | `docs/user_requirements/credential_parts.md` |
`design.md``tasks.md` 兩份。
### 步驟 3:宣告(強制格式)
開始動手前,在回覆開頭**逐字**貼出以下宣告:
```
📋 已讀 SDD
- docs/3-specs/arcrun/arcrun.md(當前 Phase<phase 名稱>
- <對應 SDD 的 design.md 路徑>
- <對應 SDD 的 tasks.md 路徑>
🎯 本次對應 task<task 編號,例如 "Phase 1.3 實作 auth_static_key main.go">
📐 本次 task 的 SDD 規範摘要:
- <重點 1>
- <重點 2>
- <重點 3>
🚧 執行範圍:
- 會修改:<檔案清單>
- 會建立:<檔案清單>
- 會刪除:<檔案清單>
```
**不做這個宣告 = 違反 SDD 協議 = 停手等 richblack**。
### 步驟 4check tasks.md 狀態
動手前:在 tasks.md 把對應 task 的 `- [ ]` 改成 `- [🔄]`(進行中標記)。
完成後:改成 `- [x]`,不批次更新,每完成一個就立刻改。
## 什麼算「任務超出 SDD 範圍」?
以下情況屬於 **change**,不是 **modify****必須停手並與 richblack 確認**
- SDD 沒寫到的新功能
- 新增頂層目錄
- 新增新的 Worker(不管是 cypher-executor / registry / 零件 worker
- 修改架構決策(例如「改用 xxx 取代 yyy」)
- 跨多個子系統的連鎖修改
**停手不是怯懦,是專業**。猜錯方向比慢一小時更糟。
## 新增 SDD 的完整程序(richblack 確認後怎麼往下走)
> 2026-06-03 補:之前協議只寫「停手等確認」,沒寫「確認後怎麼做」,導致 CC 被
> `pre-write-guard.sh` 規則 4.3 擋下後不知正路、卡死。這節補完整程序。
當「新增一個 SDD 子系統」(在 `docs/3-specs/` 下開新頂層目錄):
1. **停手,向 richblack 說明要新建哪個 SDD、為什麼**change,見上節)。
2. **取得 richblack 明確確認**後,依序:
1. **更新白名單**:在 `.claude/hooks/pre-write-guard.sh``KNOWN_SDDS` 陣列加一行
`"docs/3-specs/<新目錄名>" # YYYY-MM-DD richblack 確認新建(<一句用途>`
(這步是「執行 richblack 已授權任務的必要步驟」,不是 AI 擅自放寬 guardrail——
授權脈絡要明確,分類器才放行;沒有明確授權就改白名單 = 越界。)
2. **建目錄 + 寫 design.md / tasks.md**(白名單放行後才寫得進去)。
3. **若沒先更新白名單就 Write** → 規則 4.3 會擋你(這是對的,表示你跳了步驟 2.i)。
**為什麼白名單而非全放行**:開新 SDD 子系統 = 宣告新架構範圍,是稀有的人類決策點。
白名單讓「AI 自己無中生有開子系統」必停下(AI 改不了白名單,除非 richblack 明確授權該任務)。
已許可的目錄內寫檔零摩擦。
## 發現 SDD 本身有問題怎麼辦?
- SDD 和實作不一致 → 停手,列出矛盾點,與 richblack 確認哪一邊是對的
- SDD 規範之間互相矛盾(例如禁令 A 和設計 B 衝突)→ 停手,引用矛盾原文,與 richblack 確認
- **不可以自行猜哪個是對的**。CC 之前兩天就是這樣走錯的。
## 為什麼這個協議存在
arcrun 規範已經足夠細緻,CC 之前出錯不是因為不懂,而是因為**沒讀**或**讀了覺得「大概是這個意思」就動手**。SDD 協議強制把「先讀 → 定位 → 宣告 → 執行 → 更新」做成一條死規矩,沒有繞過去的路徑。
@@ -0,0 +1,76 @@
# 技術棧硬限制
## 三層語言對應(絕對不可混用)
| 層級 | 語言 | 位置 | 職責 |
|-----|------|------|------|
| 零件(Component | **TinyGo 或 AssemblyScript → WASM** | `registry/components/{name}/` | 所有業務邏輯 |
| 零件 Worker 包裝 | TypeScript(固定模板,不寫業務邏輯) | `.component-builds/{name}/` | WASI shimstdin/stdout JSON |
| Orchestration Worker | TypeScript + Hono | `cypher-executor/` | HTTP routing、workflow 執行排程、host functions |
| CLI | TypeScript + Node.js | `cli/` | `acr` 指令 |
| Python SDK | Python | `python-sdk/` | HTTP thin wrapper + client 端加密 |
| JS SDK | TypeScript + Web Crypto | `js-sdk/` | HTTP thin wrapper + client 端加密 |
| Frontend | React 19 + Vite + Tailwind v4 | `landing/` | Cloudflare Pages |
## 零件實作規範
### 只有兩種合法語言
- **TinyGo**`tinygo build -target=wasi -o {name}.wasm main.go`
- **AssemblyScript**`asc main.ts --target release -o {name}.wasm`
### I/O 模型
- **stdin**JSON input
- **stdout**JSON output
- 不用 HTTP server,不監聽 socketWASI preview1 沒 socket
### Host Functions(零件呼叫外部能力的唯一管道)
`u6u` namespace 下:
| Host Function | 用途 |
|---|---|
| `u6u.http_request` | 發 HTTP 請求 |
| `u6u.kv_get` | 讀 Cloudflare KVWorker 側依 key 前綴路由到正確 KV) |
| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM |
| `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) |
**所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。
## 資料儲存
| 儲存 | 用途 | Key 格式 |
|-----|------|---------|
| Cloudflare KV `WEBHOOKS` | workflow 定義(cypher binding YAML | `webhook:{api_key}:{name}` |
| Cloudflare KV `CREDENTIALS_KV` | 加密 credential | `{api_key}:cred:{name}` |
| Cloudflare KV `RECIPES` | auth recipe / 動態 API recipe | `auth_recipe:{service}`, `rec_{hash}` |
| Cloudflare KV `USERS_KV` | 用戶資料 | `user:{api_key}` |
| Cloudflare KV `SESSIONS_KV` | session | `session:{token}` |
| Cloudflare KV `ANALYTICS_KV` | 執行分析 | `execution:{timestamp}:{id}` |
| Cloudflare KV `EXEC_CONTEXT` | workflow 執行中的 context | `ctx:{execution_id}:{node_id}` |
| Cloudflare R2 `WASM_BUCKET` | **只用於用戶自製零件**Phase 5 以後啟用) | `{api_key}:cmp:{hash}` |
**警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary`[[wasm_modules]]``import ... assert { type: 'webassembly' }`)。
## 加解密規範
- **演算法**AES-GCM 256-bit
- **加密位置**Client 端(CLI / Python SDK / JS SDK
- Python`cryptography` 套件
- JSWeb Crypto API`crypto.subtle`
- **解密位置**Server 端 **WASM primitive**(透過 host function `crypto_decrypt`
- cypher-executor TS **不解密**,只提供 host function
- `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM**
- **傳輸格式**`{ name, encrypted, iv }`iv base64、encrypted base64
## 網路部署
- **平台 API(對外)**`cypher.arcrun.dev`cypher-executor
- **Landing**`arcrun.dev`
- **每個零件 Worker**
- **對內(cypher-executor 用來 fetch component,避開同 zone 死鎖)**`arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev`
- 例:`arcrun-kbdb-get.uncle6-me.workers.dev`
- cypher-executor 從 `wrangler.toml [vars] WORKER_SUBDOMAIN` 組出此 URL
- **對外(可選,零件對全網開放被 curl 用)**`{kebab}.arcrun.dev`
- 例:`gmail.arcrun.dev``kbdb-get.arcrun.dev`
- 仍允許保留,但**禁止 cypher-executor 透過此 URL fetch**(會撞同 zone 自循環,見 [docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)
- **新增 component worker 部署清單**`name = "arcrun-{kebab}"` + `[[routes]]` 對外(可選)+ dashboard 啟用 workers.dev(必須)
- **部署工具**Wrangler
@@ -0,0 +1,153 @@
# 禁止行為清單(零容忍)
**這份清單由 `.claude/hooks/*.sh` 強制執行。違反會 block 工具呼叫(exit 2**。
---
## 第一類:零件實作層級的禁令
### 1.1 禁止在 `registry/components/` 下建立 TypeScript 檔案
零件**只能**用 TinyGo`.go`)或 AssemblyScript`.ts` 但需 `asconfig.json`)實作,並編譯成 `.wasm`
cypher-executor/registry Worker 或 `.component-builds/` 內的 TS 不算零件邏輯,那是 WASI shim。
**Hook 會擋**:新增 `registry/components/*/{檔案}.ts`(除非目錄內有 `asconfig.json` 明確標記為 AssemblyScript)。
### 1.2 禁止建立新的 `auth_*` 目錄以外的 auth 實作
所有 auth 邏輯只能在:
- `registry/components/auth_static_key/`
- `registry/components/auth_oauth2/`
- `registry/components/auth_service_account/`
- `registry/components/auth_mtls/`
**不可以**出現 `cypher-executor/src/auth-primitive/``cypher-executor/src/lib/auth-*.ts``auth-worker/``credential-worker/` 等目錄。
**Hook 會擋**`mkdir``Write` 到上述違規路徑。
### 1.3 禁止用 `wrangler init/generate` 建立 auth/credential/jwt 相關的 TS Worker
Auth primitive 必須透過 `component-worker-template/` 搭配 WASM binary 部署。
**Hook 會擋**bash 指令含 `wrangler (init|generate) ... auth_``... credential_``... jwt_` 的 pattern。
---
## 第二類:cypher-executor TS 的禁令
### 2.1 禁止新增任何 credential / auth / jwt 相關的 TS 檔案
**已存在但要刪**Phase 1-3 範圍):
- `cypher-executor/src/actions/credential-injector.ts` → 刪除(走 WASM auth primitive
- `cypher-executor/src/lib/jwt-signer.ts` → 刪除(RS256 移入 auth_service_account WASM
- `cypher-executor/src/lib/component-loader.ts``BUILTIN_API_RECIPES``BUILTIN_CREDENTIALS_MAP` → 整段刪除
**Hook 會擋**:新增任何路徑含以下關鍵字的 `.ts` 檔案:
- `credential-injector``credential_injector`
- `jwt-signer``jwt_signer`
- `auth-dispatcher` 的 TS 若嘗試在裡面實作 credential 解密 / template 展開 / JWT signingblock
### 2.2 禁止在 cypher-executor 任何 TS 裡實作以下邏輯
這些邏輯全部屬於 WASM 零件職責:
- AES-GCM 解密(`crypto.subtle.decrypt`)— 只准出現在 `wasi-shim.ts``crypto_decrypt` host function
- RSA-SHA256 簽章(`crypto.subtle.sign` with RSASSA-PKCS1-v1_5)— 只准出現在 `wasi-shim.ts``crypto_sign_rs256` host function
- Template 展開(`{{secret.X}}` / `{{runtime.X}}` 替換)— 只能在 WASM 零件內
- PEM → PKCS8 解析
- JWT header/payload/signature 組裝
- Token exchange(拿 service account JWT 換 access_token
- 具體 API call 實作(例如 gmail send / telegram sendMessage / google sheets append
**Hook 會擋**
- Write/Edit 到 `cypher-executor/src/` 下的 `.ts` 時,內容含:
- `crypto\.subtle\.decrypt` 且檔名不是 `wasi-shim.ts`
- `crypto\.subtle\.sign.*RSASSA` 且檔名不是 `wasi-shim.ts`
- `interpolateTemplate``\{\{secret\.` 的模板邏輯
- `BUILTIN_API_RECIPES``BUILTIN_CREDENTIALS_MAP`(新增用)
- `gmail.googleapis.com/gmail/v1/users/me/messages/send` 類 hard-code API URL
- `api.telegram.org/bot.*sendMessage`
- `sheets.googleapis.com/v4/spreadsheets`
- `notify-api.line.me/api/notify`
### 2.3 cypher-executor TS 的合法職責(允許)
- HTTP routingHono routes
- workflow 執行排程(`graph-executor.ts`
- 呼叫 WASM 零件(透過 HTTP fetch 到對應 Worker URL,或 Service Binding fallback
- 提供 host function`wasi-shim.ts``kv_get` / `crypto_decrypt` / `crypto_sign_rs256`
- KV/R2/Service Binding 存取封裝
---
## 第三類:架構層級的禁令
### 3.1 禁止新增 Service Binding
**Cypher binding 不是 Cloudflare service binding**。它是 YAML/KV 裡的 URL 清單。
零件串接(workflow 層)一律走 HTTP URL,不走 `[[services]]`
13 個現有的 `SVC_*` 綁定(`cypher-executor/wrangler.toml`,邏輯零件)是歷史遺產(效能優化),**保留但不新增**。
> **2026-06-06 註(credential-primitives-wasm Phase 7**self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `fetch()` 打 auth 觸發 CF **same-zone 1042**(壓測階段 11)。**未用 service binding 解**(評估後廢:service binding 靜態、加/改要重 deploy cypher)。改用 **`global_fetch_strictly_public` compatibility flag**cypher wrangler.toml)讓 same-zone fetch 走公網前門 → 同 zone 也通,**auth 維持 HTTP fetch、不加 binding**。故本禁令不變。
**Hook 會擋**bash 指令含 `wrangler tail` 以外、涉及 `[[services]]` 新增的 patternEdit wrangler.toml 新增 `[[services]]` 區塊時警告確認。
### 3.2 禁止以「從 R2 取 WASM」為設計
平台內建零件已 bundle 進各自 Worker,不從 R2 取。
R2 只在 Phase 5(用戶自製零件)啟用。
**Hook 會警告**TS 中出現 `env.WASM_BUCKET.get(` 的新增 code(除非在明確標註的 Phase 5 user-submit 路徑中)。
### 3.3 禁止複製貼上 Worker 程式碼到新目錄
要改 `gmail` 零件 → 改 `registry/components/gmail/main.go`,重新編譯、部署。
**不准**新建 `gmail-v2/``new-gmail/``gmail-worker/` 等目錄。
**Hook 會擋**`mkdir``Write``{component-name}-v2/``new-{component-name}/``{component-name}-worker/` 類路徑。
### 3.4 禁止在 SDK 內做 server 職責
- **禁止**SDK 裡做 server 端解密、credential-injector 重實作、workflow executor、auth recipe 解析
- **允許**SDK 做 HTTP thin wrapper + client 端加密(AES-GCM
---
## 第四類:流程層級的禁令
### 4.1 禁止沒讀 SDD 就動 code
`00-sdd-protocol.md`
### 4.2 禁止批次更新 tasks.md
每完成一個 task 就立刻 mark `- [x]`。不准「先全部做完再一次更新」。
### 4.3 禁止新建 SDD 而不事先與 richblack 確認
SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/` 底下建新目錄。
例外:在現有 SDD 目錄內新增 `requirements.md` / `design.md` / `tasks.md` 的單檔補充(需在 CLAUDE.md 已註記的 SDD 範圍內)。
---
## 第五類:薄殼原則的禁令(詳見 07-thin-shell.md
### 5.1 禁止在薄殼介面(cli/src/、arcrun-mcp/src/)實作業務邏輯
能力只實作一次,放在 APIcypher-executor 端點)。薄殼只做介面轉換 + 暴露。
**Hook 會擋**(規則 7.x,範圍 `cli/src/``arcrun-mcp/src/` 的 .ts/.js):
- 7.1 新增 `seedApiRecipes` / `seedAuthRecipes` / `seedRecipes` 編排函式(seed 是 API 行為,§4.1 反例)
- 7.2 介面層拼裝 upsert(同函式內 PATCH + POST + upsert/找則改否則建 字樣)
- 7.3 client 端「全部成功才做下一步」gate(`deployFullyOk` 類,§4.1 反例)
### 5.2 禁止用 recipe / 客製零件補 API 缺的能力
缺能力 → 去補 API endpoint,不是在 recipe 層拼湊或用零件繞過(hook 偵測不到語意,靠 code review)。
### 5.3 禁止同一 API 能力在不同介面用不同參數簽名 / 連不同帳號
`validate` 在 CLI 吃 YAML、MCP 卻要 `api_key`+`graph` = 底層分歧。薄殼差異只能來自介面慣例。
所有薄殼讀同一份身份來源(見 07 §4)。
---
## Hook Block 訊息格式
當 hook 擋住一個操作時,訊息格式統一為:
```
❌ BLOCKED by arcrun CLAUDE rules
違反項:<禁令編號,例如 2.2>
原因:<簡短說明>
正確做法:<該改去哪裡、該用什麼方式>
參考:.claude/rules/<對應檔案>
```
這樣 CC 拿到錯誤訊息後有機會自行導正,不是被擋死就愣住。
@@ -0,0 +1,165 @@
# 零件架構與部署模式(必讀,CC 最常搞錯的地方)
## 第一核心概念:每個 WASM 零件 = 一個獨立 Worker = **兩個** URL
**不是**從 R2 即時載入 WASM 執行。
**不是**用 service binding 串零件。
**不是**一個 Worker 裡跑多個零件。
**是**:每個零件都是獨立部署的 Worker,每個都有**兩個 URL**
| URL 類型 | Pattern | 用途 |
|---|---|---|
| 對內(cypher-executor 用)| `arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev` | cypher-executor fetch component 走這個,避開同 zone 自循環死鎖(P0 #9|
| 對外(直接 curl 用,可選)| `{kebab}.arcrun.dev` | 用戶單獨打 component 測試或 self-hosted 用法 |
例:`kbdb_get` 零件:
- 對內:`arcrun-kbdb-get.uncle6-me.workers.dev`cypher-executor 走這個)
- 對外:`kbdb-get.arcrun.dev`(用戶 / 直 curl
**為什麼這樣設計**CF Workers 「同 zone 自循環防護」會讓綁 `cypher.arcrun.dev/*` 的 cypher-executor fetch 同 zone `*.arcrun.dev` 撞 522。完整事件報告:[docs/incidents/2026-05-13-cypher-outbound-522.md](../../docs/incidents/2026-05-13-cypher-outbound-522.md)。改走 workers.dev 子域繞過。
### 零件 Worker 的結構
```
registry/components/{name}/
├── main.go ← TinyGo 原始碼(實際零件邏輯)
├── component.contract.yaml ← 輸入/輸出規格
└── {name}.wasm ← TinyGo 編譯產物
```
部署時,透過 `component-worker-template/` 把 WASM 包進一個 Hono Worker
```
.component-builds/{name}/
├── package.json
├── wrangler.toml ← name = "arcrun-{name}"route = "{name}.arcrun.dev"
├── component.wasm ← 從 registry/components/{name}/ 複製過來
└── src/index.ts ← 固定的 WASI shimPOST / → stdin → WASM → stdout → JSON
```
**src/index.ts 是通用模板**,所有零件都用同一份。這個 TS 只做 WASI runtime,不是業務邏輯。
---
## R2WASM_BUCKET)的真正用途
R2 存 WASM 只是**用戶自製零件上傳**用的。
**平台內建零件不從 R2 讀取**——它們在部署時就已 bundle 進 Worker 的 binary(透過 `[[wasm_modules]]``import` with `assert { type: 'webassembly' }`)。
Phase 5(封測後)才會啟用「用戶 push 自製零件 → 存 R2 → 動態執行」這條路徑。
**結論:當 CC 問「怎麼從 R2 取出 WASM」時,幾乎都是走錯路徑**。平台零件是獨立 Worker,走 HTTP 呼叫,不是 R2 動態載入。
---
## Cypher binding 的正確定義
**Cypher binding 不是 Cloudflare 的任何 binding 機制。**
Cypher binding 是一張 YAML 清單,內容是「一個 workflow 要呼叫哪些零件 URL」。存放在:
- 本地:`workflow.yaml`(用戶寫的 workflow
- KV`WEBHOOKS` KV(用戶 `acr push` 後存入)
Cypher executor 執行 workflow 時:
1. 從 KV 讀出 workflow YAML
2. 按 graph 順序解析每個節點的 `component`
3. 用 HTTP fetch 打對應的零件 URL
4. 把 output 當作下個節點的 input
**這就是 Cypher binding——用 HTTP URL 把零件串起來,存在 YAML/KV 裡**。
### 為什麼不能用 Service Binding
Service binding 需要 `wrangler.toml` 裡寫死 `[[services]]`,且要 redeploy 才生效。arcrun 是類 n8n 服務,用戶建立新 workflow 時**絕對不可能**要他 redeploy。所以 workflow 層一定要 HTTP。
### Service Binding 的僅存合法用途
只在 `cypher-executor` 和**平台內建邏輯零件之間**保留(效能優化,避免公網往返)。看 `cypher-executor/wrangler.toml` 裡的 13 個 `[[services]]` 綁定就是這個用途。
**禁止新增任何 Service Binding**。所有新零件(含 auth primitive)都走 HTTP URL 路徑。
**same-zone 1042 的解(credential-primitives-wasm Phase 72026-06-06**self-hosted 的 cypher 與 auth worker 同在 `{sub}.workers.dev` zonecypher `fetch()` 打 auth 觸發 CF **1042**(官方 docs:「fetch from another Worker on the **same zone**」;官方 cypher 在 `cypher.arcrun.dev`、打 `*.workers.dev` 屬跨 zone 故不踩——非官方有 flag)。**解法不是 service binding**(評估後廢:靜態、加/改要重 deploy),而是 cypher wrangler.toml 加 **`global_fetch_strictly_public` flag**——讓 same-zone fetch 走公網前門 → 同 zone 也通。auth 維持 HTTP fetch、不加 binding。官方加此 flag 行為不變(本就跨 zone),self-host 被修好 → **官方與 self-host 共用同一份 toml**
**仍禁止**:為**用戶自製 / 服務專屬零件**(`gmail-worker``notion-worker` 之類)新增 binding——那些是 recipe 的事,不該有 binding。**workflow 層(用戶串零件)一律 HTTP URL 不變。**
---
## 零件之間怎麼串:實際流程
假設 workflow 是:webhook → gmail(要 auth)→ google_sheets(要 auth
```
用戶 POST https://cypher.arcrun.dev/webhooks/named/xxx/trigger
cypher-executorWorker)讀 workflow YAML
├─ 節點 1: component = gmail
│ a. 查 auth_recipe:gmail → primitive = static_key
│ b. HTTP POST https://auth-static-key.arcrun.dev
│ { action: "authenticate", api_key, service: "gmail" }
│ → 回傳 { auth_headers: { Authorization: "Bearer ..." } }
│ c. HTTP POST https://gmail.arcrun.dev
│ { to, subject, body, _auth_headers }
│ → gmail 零件 Worker 執行 WASM → 回傳 { success, data }
└─ 節點 2: component = google_sheets
... 相同模式
```
**cypher-executor 本身不做 credential 解密、不做 JWT signing、不做 auth header 組裝**。這些全在 auth primitive WASM 零件內,cypher-executor 只負責 HTTP routing 和工作流排程。
---
## 實際禁令(CC 看這裡)
### 禁止在 `registry/components/` 下建立 TypeScript 檔案
零件邏輯一律 TinyGo 或 AssemblyScript,編譯成 `.wasm`
### 禁止把 auth 邏輯寫在 `cypher-executor/src/`
credential 解密、JWT signing、template 展開(`{{secret.X}}`)全部屬於 auth primitive WASM 零件的職責。cypher-executor 只呼叫它們。
### 禁止問「怎麼從 R2 取 WASM」
平台內建零件**不從 R2 取**。每個零件已部署成獨立 Worker,走 HTTP URL。用戶自製零件才用 R2(Phase 5,未啟用)。
### 禁止新增 Service Binding
13 個現有的 SVC_*(邏輯零件)是歷史遺產,不新增。新零件(含 auth primitive)一律走 HTTP URL。self-hosted 的 same-zone 1042 用 `global_fetch_strictly_public` flag 解,不靠新增 binding(見上「合法用途」段,Phase 7)。
### 禁止重建已存在的零件 Worker
要改 `gmail` 零件邏輯 → 改 `registry/components/gmail/main.go`,重新編譯 `.wasm`,重新部署對應 Worker。**不要**在 `cypher-executor/src/lib/` 或其他地方建「新的 gmail 實作」。
---
## 部署一個新零件的完整步驟(auth_static_key 為例)
1. 建立 `registry/components/auth_static_key/`
- `main.go`TinyGo 實作)
- `component.contract.yaml`IO 規格)
2. 編譯:`cd registry/components/auth_static_key && tinygo build -target=wasi -o auth_static_key.wasm main.go`
3. 建立 `.component-builds/auth_static_key/`
- 複製 `component-worker-template/src/index.ts`
- 複製 `component-worker-template/package.json`
- 新建 `wrangler.toml`
```toml
name = "arcrun-auth-static-key"
main = "src/index.ts"
compatibility_date = "2025-02-19"
[vars]
COMPONENT_ID = "auth_static_key"
[[routes]]
pattern = "auth-static-key.arcrun.dev/*"
zone_name = "arcrun.dev"
```
- 複製 `auth_static_key.wasm` 到此目錄為 `component.wasm`
4. `cd .component-builds/auth_static_key && pnpm install && pnpm deploy`
5. **Dashboard 啟用 workers.dev URL**(必須,否則 cypher-executor fetch 不到):
- Workers & Pages → `arcrun-auth-static-key` → Settings → Domains & Routes → workers.dev → Enable
- 啟用後 URL`arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev`
6. 驗證對外:`curl https://auth-static-key.arcrun.dev` → 應回 `{ok: true, component: "auth_static_key"}`
7. 驗證對內:`curl https://arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` → 應同樣回 200
8. cypher-executor 透過 `wasmWorkerUrl()` 自動組對內 URL 呼叫(不用手動註冊)
**這是唯一正確的部署流程**。任何偏離這個流程的「替代方案」都要先和 richblack 確認。
**Step 5 為什麼必須**:見 arcrun.md P0 #92026-05-13)。cypher-executor 走對內 URL 避開同 zone 自循環死鎖;若 workers.dev 未啟用,cypher-executor fetch 該 component 會 404。

Some files were not shown because too many files have changed in this diff Show More