4 Commits

Author SHA1 Message Date
Claude 8ec6948918 fix(graph): backfill 讀 entity 不帶 owner 過濾(存量 owner=None,濾了讀0);owner 只套建立的 gloss entry 2026-07-05 11:26:16 +00:00
Claude 65075b4615 feat(graph): 收斂 gloss-bridge + owner-threading 為單一可部署分支
合併 arcrun-7-gloss-bridge(A1 gloss 橋)與 owner-threading(owner_id 寫入鏈必經):
- node-persist / triplet-ingest 衝突解為「兩者都要」:persistNodes 既寫 entity
  record(帶真 owner)又 upsert gloss entry(帶真 owner、帶 source);ingestEnvelope
  既帶 source.uri 又 thread 必填 owner 到底。
- gloss 路徑補 owner 必填(配合 owner-mandatory D28):
  * upsertGlossEntry(..., owner_id: string) owner 改必填
  * backfillGlossEntries(client, owner_id: string) owner 改必填
  * POST /backfill-gloss-entries 缺 owner_id → 400(存量 record owner=None,
    owner 由 caller 明確指定,不從 record 帶)
- 合併兩支測試:gloss 測試補 owner 參數以配合 base owner-required mock。

tsc:除 index.ts:57 swaggerUI 既存 baseline 外 0 error。
vitest:34 passed(gloss 7 + owner/graph 27)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ck8bjuBTLFkiFxqLGHMpqG
2026-07-05 11:20:47 +00:00
Claude 4190fbcf90 feat(graph): owner_id 寫入鏈必經(根治無主資料)——配合 base owner-mandatory(D28)
- kbdb-client requireOwner 守衛:漏 owner 插件端即 throw,不再靜默送 owner:''
- owner 一路 thread:persistNodes/ingestEnvelope/entity-crud/triplet-crud 全必填
- 病灶修:POST /triplets/ingest 原本沒傳 owner→加 owner_id query+400
- 寫入 route 缺 owner→400;vitest 23→27
- 註:gloss-bridge 分支的 backfill/gloss-entry 另需套 owner 必填(附說明),不跨支疊改
2026-07-05 11:00:30 +00:00
Claude 4cc7057cea feat(graph): node gloss→base embeddable entry 橋——Arcrun#7 A1
修「打標≠讀標」:node gloss+embed 標在 entity record、但 base embed 只掃 entries.metadata.embed
→ persistNodes 另落一筆 content=canonical:gloss、metadata.embed=true 的 base entry(走 API/零SQL)。
- gloss-entry.ts: upsertGlossEntry 冪等(確定性 page_name + list-then-write)
- backfill-gloss-entries.ts + route: 對既有 entity record 補 gloss entry
- triplet-ingest 帶 source.uri 進 metadata;kbdb-client 暴露 metadata_json(base 早支援)
- vitest 30/30(+7);[→arcrun] base 缺 upsert entry 端點,暫以 list-then-write 冪等
2026-07-05 09:16:26 +00:00
53 changed files with 500 additions and 1003 deletions
+3 -22
View File
@@ -8,7 +8,7 @@ description: 處理本 repo 的 GitHub issue(讀/回/結案),跨 repo 發
很多人不知道這件事——`gh` 已內建認證,零開發、零外部依賴。issue 同源於 repo,
比 Notion / Sheets 更適合做交辦與待辦,不必引入外部 SaaS。
這份指引分層,界線要守住。
這份指引分層,界線要守住。
---
@@ -20,13 +20,12 @@ description: 處理本 repo 的 GitHub issue(讀/回/結案),跨 repo 發
gh issue list --state open # 看有哪些待辦
gh issue view <n> # 讀完整內容
# …實作…
gh issue comment <n> --body "[<本 repo> CC] 做了什麼、怎麼決定的、改了哪些檔"
gh issue comment <n> --body "做了什麼、怎麼決定的、改了哪些檔"
gh issue close <n> # 確認解決後結案
```
回覆要有料:說清楚**做了什麼、為什麼這樣決定、動了哪些檔**,而不是只回「done」。
issue 作者(可能是另一個 repo 的 CC,或人類)要靠你的回覆判斷對不對。
跨 repo 的 issue/comment 開頭一律署名 `[<本 repo> CC]`(見第 3 節鐵律)。
---
@@ -42,25 +41,7 @@ issue 作者(可能是另一個 repo 的 CC,或人類)要靠你的回覆
---
## 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 安全界線(最重要 — 絕不可越)
## 3. flag 安全界線(最重要 — 絕不可
**「有事才讀」,禁止自動輪詢。**
-20
View File
@@ -4,26 +4,6 @@
---
## 生命週期(單一活性鐵律,全文見 `system-dev/docs/3-specs/SDD-LIFECYCLE.md`
五條鐵律摘要:
1. **單一活性**:任何時刻整個 repo 只允許一份 `status: active` 的 SDD;所有開發任務對應它的 tasks,找不到對應任務 → 停下來問,不准直接做。
2. **禁止自行建立 SDD**:澄清問題→回答不動文件;任務層變更→更新現行 SDD 的 tasks(標日期與原因);規格層變更→走第 3 條。
3. **規格變更只有一條路**change proposal 寫進 `system-dev/docs/3-specs/pending-changes.md`(摘要+觸發原因+影響分析),然後**停止**等使用者「confirm」。
4. **開新 SDD 的唯一時機**:使用者 confirm 後——先把舊 SDD 未完成任務逐條搬入新 SDD(做完前不准寫 code)→ 舊的標 `closed` + `superseded_by` 移入 `archive/` → 新 SDD changelog 記繼承 → 列搬移/作廢清單請最終確認。
5. **每次 session 開始**先讀 active SDD 與 pending-changes.md,回報三個數字:
```
📐 現行規格:〈SDD 名稱〉
📋 未完成任務:N
⚖️ 待裁決 proposalM
```
若出現**兩份 active=規則已被違反,當場糾正**(收斂到一份,其餘 paused/closed)。
---
## 執行流程
### 第一步:理解任務
-161
View File
@@ -1,161 +0,0 @@
# /wiki-extract — vault 增量萃取(Logseq / Obsidian → system-dev/wiki
把**筆記 vault**Logseq graph 如 `notes`/`kb`、或 Obsidian)的原始筆記,**增量、冪等**地
萃成 `system-dev/wiki/` 的精耕卡+`[[wikilink]]`。這是知識一庫 ingest 的**前段**
AI 只產卡片檔,下游 Arcrun ingest 再從 wikilink 機械拉三元組進 KBDB。
> **跟 `/wiki-init` 的分工**
> - `/wiki-init` 是**首次**建結構 + 全庫首萃(一次性)。
> - `/wiki-extract` 是**之後每次**的增量重萃——vault 會被 Syncthing/cron 持續灌新筆記,
> 這支負責「只萃變動的、沒變的不碰、不浪費 AI run」。給 Routine / cloud-worker 反覆跑。
> - **跑它的是你(CC / Routine)=LLM 本人,不需任何 token**。
> **邊界(硬規矩,別越界)**
> - 只往 `system-dev/wiki/` 寫。**絕不寫入 KBDB、絕不拉三元組紀錄**——三元組是下游
> Arcrun 從你產的 `[[wikilink]]` + `## 關聯` 機械映射(另一張 issue),不是這支的事。
> - **原始筆記唯讀**`journals/`、`pages/`、Obsidian 根 `.md` 是 leo 的手寫真身,
> 改了會被 Syncthing 推回他手機污染筆記 App。萃取=只讀原文、只寫 wiki。
> - **D16 精耕非 RAG**:萃「知識點」成自包含原子卡 + 建 wikilink,**不地毯灌原文全文**。
---
## 執行流程
### 第一步:確認這是 vault repo,定位 raw source
偵測邏輯**同 install.sh / wiki-init**
| 偵測到 | 型態 | raw source(要掃的原文) |
|--------|------|--------------------------|
| 根目錄有 `logseq/` | Logseq vault | `journals/*.md` + `pages/*.md` |
| 根目錄有 `.obsidian/` | Obsidian vault | vault 根下所有 `.md` |
| 都沒有 | **不是 vault** | → 停手。這支只處理 vault;一般 dev repo 開發時就手寫 `.claude`/`system-dev/wiki`,不需萃取 |
沒有 `system-dev/wiki/`?→ 先跑 `/wiki-init`(首次建結構+首萃),再回來用這支做增量。
### 第二步:content_hash 冪等 —— 決定哪些檔要萃(省 run 的核心)
讀萃取 manifest`system-dev/wiki/.extract-manifest.json`(不存在=首次,視同全部要萃)。
格式:
```json
{
"version": 1,
"algo": "sha256",
"sources": {
"journals/2026_07_01.md": {
"content_hash": "<sha256 of file bytes>",
"extracted_at": "2026-07-06",
"cards": ["Prompt能力即拆解自己邏輯的能力", "程式化邏輯可圖解任何主題不限AI"],
"skipped_reason": null
},
"journals/2026_06_25.md": {
"content_hash": "<sha256>",
"extracted_at": "2026-07-06",
"cards": [],
"skipped_reason": "空檔/訊息量不足,無可萃知識點"
}
}
}
```
對每個 raw source 檔:
1. 算目前 `content_hash``sha256sum <file>`,取檔案 bytes 的 hash)。
2. 跟 manifest 裡該檔的 `content_hash` 比:
- **相同 → skip,不讀不萃、不呼叫任何 AI 推理**(就算它上次 `cards: []` 也 skip——空檔沒變還是空)。
- **不同或不在 manifest → 這檔要(重)萃**。
3. manifest 有、但檔已不存在 → 該檔被刪,把它的 entry 從 manifest 移除(卡片是否連帶處理見第五步)。
> **這一步是「省 run」的重點**:vault 每天可能只動 1~2 個 journal,其餘幾十個檔 hash 沒變
> 就整批跳過,AI 只對真正變動的檔動腦。**重跑一個沒變動的 vault = 零 AI 呼叫、零 diff。**
### 第三步:對「要萃」的檔,抓知識點 + 任務
逐個變動檔讀原文,分兩類抽取:
**(a) 知識點 → 概念原子卡**
判準與卡片格式**完全依 `/wiki-init` 第五步**frontmatter `tags:`/`gloss:`、H1、麵包屑
`← [[<bucket>/00-INDEX]]``**來源**``## 摘要``## 重點``## 實體``## 關聯`
typed-edge 三元組、TAXONOMY 受控標籤、硬自檢等)——**不在這裡重寫格式,一律回去讀那份**。
廢話/訊息量薄的段落略過(在 manifest 記 `skipped_reason`,誠實留痕、不留卡)。
**(b) Logseq 任務 marker → 任務卡(task_status**
解析**完全依** `system-dev/docs/4-guides/logseq-markers.md`(單一真相源,與 template#4
tasks 投影共用同一套;**別自己另寫 mapping**)。摘要:
- 任務行 regex`^\s*- (TODO|DOING|NOW|LATER|WAITING|DONE|CANCELED|CANCELLED)\s+`
- 狀態正規化:TODO/LATER→`todo`、DOING/NOW→`in-progress`、WAITING→`blocked`
DONE→`done`、CANCELED/CANCELLED→`closed`
- 跳過 `:LOGBOOK:…:END:` 區塊與 `key:: value` 屬性行(`collapsed::``id::`
`SCHEDULED::``DEADLINE::`…),**別把 marker 或屬性當任務內文**。
有實質內容的任務 → 產一張任務卡進 `cards/tasks/` bucketfrontmatter 帶 `task_status`
```markdown
---
tags: [<領域標籤,依 TAXONOMY>]
task_status: todo # ← 依上表正規名;這是任務卡才有的欄位
gloss: 一句話定義這個任務要達成什麼(供下游 normalize)
---
# <任務一句話標題(marker 後的內文,去掉 marker>
← [[tasks/00-INDEX]]
**來源**`journals/2026_07_01.md`TODO block
**最後更新**YYYY-MM-DD
## 摘要
[任務要做什麼、脈絡]
## 實體
- **<關鍵實體正規名>**<同義詞>)— <一句描述>
## 關聯
### 內文知識關係(端點=上方 `## 實體` 正規名,一字不差)
- <實體A> >> <謂詞> >> <實體B>
### 卡片關係(卡對卡)
- [[本任務卡]] >> 涉及 >> [[相關概念卡]]
```
> 純瑣事任務(「買菜」這種無知識量)不必成獨立卡——可在 `cards/tasks/00-INDEX.md`
> 列一行帶狀態即可,避免灌垃圾卡。判準同 D16:有沒有知識/專案價值。
### 第四步:更新桶索引與 INDEX
- 每個動到的 bucket(如 `cards/notes/``cards/tasks/`)更新其 `00-INDEX.md`
(容器:只連不重寫,H2/H3 分節)。
- 更新 `system-dev/wiki/INDEX.md` 的標籤視圖與卡片清單。
- 任務卡可在 INDEX 開一個「任務視圖」按 `task_status` 聚類。
### 第五步:寫回 manifest + 驗證原文 0 動
1. 把這次萃過的每個檔的**新 `content_hash`**、`extracted_at`、產出的 `cards`
(或 `skipped_reason`)寫回 `system-dev/wiki/.extract-manifest.json`
**沒動到的檔的 entry 原樣保留**(別整檔重寫掉別人的 hash)。
2. 驗證原文零異動(踩過的坑):
```
git status --short journals/ pages/ # Obsidian 則看根目錄 .md ——須 0 新增 0 修改
```
有任何原文變動 → 你誤寫了 raw source,回滾。
### 第六步:完成報告
```
✅ wiki-extract 完成(增量)
掃描:N 個 raw source 檔
萃取:M 個(content_hash 變動)→ 產出 X 張概念卡 + Y 張任務卡
跳過:K 個(hash 未變,零 AI 呼叫)
任務狀態分布:todo A / in-progress B / done C / …
原文驗證:journals/ pages/ git status 0 異動 ✅
manifestsystem-dev/wiki/.extract-manifest.json 已更新
```
---
## 冪等自檢(Routine 反覆跑必守)
- [ ] 跑之前先讀 manifesthash 相同的檔**完全不進 AI**(不是「讀了才發現一樣」,是靠 hash 先擋)。
- [ ] 對「同一個沒變動的 vault」連跑兩次:第二次應是**零萃取、零卡片 diff、零 manifest 變化**。
- [ ] 只有 `system-dev/wiki/` 有寫入;`journals/`、`pages/` git status 全乾淨。
- [ ] 任務狀態用正規名,marker/屬性沒混進內文(照 `logseq-markers.md` 自檢)。
+10 -27
View File
@@ -158,16 +158,9 @@ gloss: 一句話定義這個概念是什麼(給下游語義 normalize 用,
## 重點
- [自包含改寫的要點,不依賴原文]
## 實體
> 本卡內文的關鍵實體(也是 graph node)。名+描述供下游 embedding normalize。集中放、一行一個、不縮排、不重複。
- **原子筆記**atomic note/卡片原子化)— 每張卡只承載一個不可再分論點的知識記錄單元。
- **傳統筆記**(大鍋炒筆記)— 把多主題混雜在同一篇、難精確引用的記錄方式。
## 關聯
### 內文知識關係(內文實體間;端點=上方 `## 實體` 正規名,一字不差)
- 原子筆記 >> 對立於 >> 傳統筆記
### 卡片關係(卡對卡)
- [[本卡]] >> 謂詞(動詞短語) >> [[他卡]]
- [[原子筆記]] >> 是其最小單元 >> [[卡片盒筆記法]]
```
**麵包屑用帶路徑 wikilink**issue #7):H1 次行放 `← [[<bucket>/00-INDEX]]` 指回桶子索引。
@@ -182,25 +175,20 @@ gloss: 一句話定義這個概念是什麼(給下游語義 normalize 用,
- 一般開發專案的軸可不同(如 子系統/層級/決策類型),由 AI 依專案性質提出、寫進 TAXONOMY.md。
- **遇到現有軸裝不下的內容**:先查是否只是現有標籤的同義詞;確實是新軸才加進 TAXONOMY.md(附定義)再用——**禁止繞過字典在卡片直接冒新標籤**。字典是 per-repo,跨 repo 不必共用。
**typed-edge 規則**issue #5/#11,把「關係」也預編譯,下游 ingest 直接 parse 出帶類型的有向邊):
- **重點抓內文實體關係,不只卡對卡**:卡對卡(`[[卡A]] >> 謂詞 >> [[卡B]]`)只是既有雙鏈加動詞、資訊量幾乎沒增加;價值在內文概念關係(`原子筆記 >> 對立於 >> 傳統筆記`A/B 是內文概念非卡標題)。
**typed-edge 規則**issue #5,把「關係」也預編譯,下游 ingest 直接 parse 出帶類型的有向邊):
1. **方向性**`A >> 謂詞 >> B` 必須讀成「A(謂詞)B」一句通順的話;A、B 順序就是主→賓真實方向。
2. **謂詞用動詞 / 動詞短語**(反駁、奠基於、犧牲)。**禁名詞當謂詞**——`>> 存儲格式 >>``>> 操作體驗 >>` 讀不通,是錯的
3. **謂詞自由但別太天馬行空**:「參考/參照」皆可(下游 embed 自動聚類),別寫「瞄了一眼」這種抓不到同義的
4. **內文三元組端點用裸文字**(非 `[[wikilink]]`),避免 Logseq 紅色斷鏈;卡對卡那層才用 `[[]]`
5. **向後相容**:純 `[[A]]` 仍合法(視為無類型邊),盡量補謂詞。
2. **謂詞用動詞 / 動詞短語**(反駁、奠基於、是…的實作),動詞天然帶方向
3. **謂詞自由書寫,不受控詞彙**:下游對謂詞 embedding 時同義謂詞會自動聚類;但方向仍靠書寫順序保證
4. **向後相容**:純 `[[A]]` 仍合法(視為無類型邊),盡量補謂詞
> **★ 硬自檢(Haiku 量產必備)★** 內文三元組端點必須與 `## 實體` 某粗體正規名【一字不差】。**寫完逐條把 A、B 拿去 `## 實體` 比對**,沒有完全相同的 → 這條錯了,改用實體表已有的詞、或把端點補進 `## 實體` 再指它。禁止端點帶括號註解/整句補語/形容詞短語。(實證:光寫規則 Haiku 會略過,端點對不齊 14 條;寫成自檢動作後 14→0。跑 12 張才暴露。)
> `>>` 是分隔語法,repo 可自選符號,但全程一致。
**萃 gloss 規則**issue #9/#11,把「node 的一句說明」也預編譯,供下游 KBDB 語義 normalize):
**萃 gloss 規則**issue #9,把「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 直接取用。
- **在知識生產的當下、由 local CC 建**:gloss 跟三元組同階段萃,**不留給下游 ingest 臨時補**——下游只有單檔 / 跨庫視角,編不出貼合的 gloss(=胡扯)。local scope 才有完整脈絡寫對
- **選填、deep tier 才產**:淺萃(只要結構)時不浪費;deep 改寫時每張卡補一句 `gloss:`
- **gloss ≠ 摘要**`gloss` 是 frontmatter 裡給機器 normalize 用的定義句(「X 是…」),求精準可 embedding;`## 摘要` 是給人讀的核心一句。可相近但分屬兩處、兩用途
- **格式對齊下游 envelope**frontmatter `gloss:` 對應下游 ingest envelope 的 `nodes[].gloss` 欄位,ingest 直接取用、不再回頭補。
**INDEX.md 是標籤視圖**(非資料夾列表),`00-INDEX.md` 是桶內容器(只連不重寫,H2/H3 分節)。
頂層索引指桶子索引帶路徑:`[[pkm/00-INDEX]]`
@@ -228,8 +216,3 @@ git status --short pages/ journals/ # 或一般專案的 docs/ ——須 0
原文驗證:pages/ journals/ git status 0 異動 ✅
下一步:用 /wiki-capture 把重要決策存進 wiki
```
> **vault repo 首萃後的增量重萃**Logseq / Obsidian vault 會被持續灌新筆記。首萃(本命令)
> 之後,改用 **`/wiki-extract`** 做增量——它靠 content_hash 只萃變動的檔(沒變=零 AI 呼叫),
> 並解析 Logseq 大寫任務 markerTODO/DOING/DONE…→ `task_status`,見
> `system-dev/docs/4-guides/logseq-markers.md`)。適合掛給 Routine / cloud-worker 反覆跑。
+14 -82
View File
@@ -1,16 +1,10 @@
#!/bin/bash
# PreToolUse hook — 動 code 前檢查 SDD 單一活性 SDD 鐵律(issue #6
# PreToolUse hook — 動 code 前檢查有沒有對應 SDD
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
#
# 掛在 settings.json 的 PreToolUsematcher: Write|Edit)。
# stdin 收到 JSON{ tool_name, tool_input: { file_path, ... } }
# 行為:
# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2),
# 先收斂到一份再說。
# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。
# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度)
# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。
# 行為:動到 code 檔(.ts/.go/...)但 system-dev/docs/3-specs/ 下沒有任何 SDD → 警告(exit 2 擋)。
#
# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。
# 藏在 helper 裡、用 bash 繞道的改動擋不到。
@@ -30,42 +24,6 @@ fi
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
[ -z "$FILE_PATH" ] && exit 0
SPECS_DIR="system-dev/docs/3-specs"
# ── 統計 active / frontmatter ──────────────────────
# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數——
# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。
# frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。
ACTIVE_COUNT=0
FM_COUNT=0
ACTIVE_LIST=""
if [ -d "$SPECS_DIR" ]; then
while IFS= read -r f; do
[ -n "$f" ] || continue
HEAD10=$(head -10 "$f" 2>/dev/null || true)
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then
FM_COUNT=$((FM_COUNT + 1))
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then
ACTIVE_COUNT=$((ACTIVE_COUNT + 1))
ACTIVE_LIST="${ACTIVE_LIST}${f}
"
fi
fi
done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null)
fi
# ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ──
if [ "$ACTIVE_COUNT" -gt 1 ]; then
cat >&2 <<EOF
🚫 SDD 單一活性鐵律違反:偵測到 ${ACTIVE_COUNT} 份 status: active 的 SDD(任何時刻整個 repo 最多一份):
${ACTIVE_LIST}
請先收斂到一份:其餘改 status: paused / closedclosed 且被取代者填 superseded_by 並移入 3-specs/archive/)。
規則全文見 system-dev/docs/3-specs/SDD-LIFECYCLE.md。收斂前擋下所有寫檔。
(本 hook 攔 Write/Edit;修 frontmatter 可用 bash 直改,或由人裁決哪份是現行。)
EOF
exit 2
fi
# 只管 code 檔。docs/markdown/設定檔等放行。
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.go|*.py|*.rs|*.java|*.rb|*.php|*.c|*.cpp|*.h|*.hpp|*.swift|*.kt) ;;
@@ -78,54 +36,28 @@ case "$FILE_PATH" in
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
esac
# ── 向下相容:整個 3-specs 沒有任何帶 frontmatter 的 design.md ──
# =老 repo 還沒遷移生命週期制度 → 退回舊行為(有 design.md 就放行+提醒),
# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。
if [ "$FM_COUNT" -eq 0 ]; then
SDD_COUNT=0
if [ -d "$SPECS_DIR" ]; then
SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ')
fi
# 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),但 ${SPECS_DIR}/ 下找不到任何 SDD。
if [ "$SDD_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 system-dev/docs/3-specs/ 下找不到任何 SDD。
絕對鐵律:任何 code 變動前必須有對應 SDDdesign.md,且遵守單一活性生命週期
system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
絕對鐵律:任何 code 變動前必須有對應 SDDdesign.md
請先:
1. 確認這個改動屬於哪個子系統
2. 在 ${SPECS_DIR}/[子系統]/ 建立 design.md(可用 /sdd-check 協助)frontmatter 標 status: active
2. 在 system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助)
3. 在回覆開頭宣告已讀 SDD + 對應 task
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
EOF
exit 2
fi
# 舊行為放行 + 提醒遷移(stderr 警告,不擋)
echo "📋 提醒:${SPECS_DIR}/ 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
exit 0
fi
# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ──
if [ "$ACTIVE_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下沒有任何 status: active 的 SDD。
單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
請先(擇一,都是人的決定,CC 不得自行建 SDD):
1. 把現行規格的 design.md frontmatter 標成 status: active(一份、只能一份)
2. 或依 SDD-LIFECYCLE.md 第 3、4 條:proposal 進 pending-changes.md → 使用者 confirm → 開新 SDD 標 active
然後在回覆開頭宣告已讀 active SDD + 對應 task。
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
EOF
exit 2
fi
# 恰好 1 份 active:放行,留痕提醒要宣告(stderr 警告,不擋)
printf '📋 提醒:現行 active SDD\n%s動手前請確認已讀它的 design.md、對應到 tasks,並在回覆宣告。\n' "$ACTIVE_LIST" >&2
# 有 SDD:放行,留痕提醒要宣告(stderr 警告,不擋)
echo "📋 提醒:system-dev/docs/3-specs/ 下有 SDD。動手前請確認已讀對應 design.md 並在回覆宣告。" >&2
exit 0
-76
View File
@@ -1,76 +0,0 @@
#!/bin/bash
# subagent-wiki-guard.sh — PreToolUse(Task) hooksubagent 聽到「查」就自己先查 wiki
#
# 病根(2026-07-20):總管兩次派 agent 查 ENCRYPTION_KEYprompt 都只叫它「去查 repo 程式碼」。
# agent 於是從**稿子**推論出「這東西還活著、不能動」,總管照單全收去擋 leo 三輪。
#
# 🔑 設計轉向(leo 2026-07-21):
# 第一版是「上游沒交代讀 wiki 就擋下」——但那**還是依賴上游記得寫**,
# 跟「我記得讀 wiki」是同一個病。leo 點破:
# 「subagent 的問題跟你一樣。你叫它去查,就算你沒說要先查 wiki,
# 但它**只要聽到查,就應該主動查 wiki**,因為每個 repo 都有維護自己的 wiki。」
# → 改成 **注入式**:不擋、不要求上游改 prompt,直接把「先查 wiki」這條
# 以 additionalContext 注入給 subagent,讓它自己做。零依賴任何人記得。
#
# 行為:偵測到查證/實作類任務 → exit 0 並用 hookSpecificOutput 注入指示。
# 已含 wiki 指示、或非查證類任務 → 靜默放行(不重複注入)。
set -euo pipefail
INPUT=$(cat)
PROMPT=$(printf '%s' "$INPUT" | python3 -c "
import json,sys
try:
d=json.load(sys.stdin)
print(d.get('tool_input',{}).get('prompt',''))
except Exception: print('')
" 2>/dev/null || echo "")
[ -z "$PROMPT" ] && exit 0
# 上游已經交代了 → 不必重複注入
if printf '%s' "$PROMPT" | grep -qiE "wiki|agent-memory|mistakes\.md|decisions-summary"; then
exit 0
fi
# 只對「查證/實作」類任務注入(純寫作、計算、潤稿等不需要)
if ! printf '%s' "$PROMPT" | grep -qiE "查|盤點|核實|確認|調查|研究|找出|repo|程式碼|原始碼|source|實作|移除|刪除|重構|修|grep|codebase|\.ts|\.go|src/"; then
exit 0
fi
python3 - <<'PY'
import json
guidance = """【自動注入:查任何東西之前,先查 wiki】
你所在的 repo 有維護自己的 wiki(通常在 `system-dev/wiki/`,舊結構在 `.claude/wiki/`)。
**接到「查/盤點/核實/實作」類任務時,第一個動作是搜尋 wiki,不是翻程式碼。**
做法(30 秒,省下大量白工):
grep -rin "<本題關鍵字>" system-dev/wiki/ 2>/dev/null || grep -rin "<關鍵字>" .claude/wiki/
為什麼這是划算的:
• wiki 是前人已經查過、驗證過、被負責人糾正過的結論——**判準**。
• 程式碼與歷史文件是**稿子**:它反映「還沒清乾淨」,不等於「還在用」。
從稿子推論會系統性得出過時結論。
• wiki 沒記載,才值得花力氣翻原文。
三條硬規則:
1. **wiki 與程式碼衝突 → 以 wiki 為準**,並在回報中明確指出衝突,
不要自行用 code 推翻 wiki。
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。
那是當時狀態,不是永久禁令;條件已滿足就是可動。
2026-07-20 實際事故:agent 只看到「不可動」就回報不能動,
實際上解除條件早已滿足,害負責人被擋三輪。)
3. 翻原文後若得到**新結論**,回報時明講「wiki 該更新」——wiki 過時是債,要還。
"""
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": guidance
}
}, ensure_ascii=False))
PY
exit 0
-88
View File
@@ -1,88 +0,0 @@
#!/bin/bash
# wiki-first-search.sh — PreToolUse hook:要去翻原文/程式碼前,先把 wiki 命中結果推到眼前
#
# 病根(2026-07-20 leo 點破,mistakes 第一鐵律):
# 總管 session 開頭讀了 agent-memory 前 50 行就開工,關鍵那條在第 56 行 → 拿過期記憶擋了 leo 三輪。
# leo:「如果你不是讀而是**搜尋** wiki 就不會只讀 50 行就下定論,
# 而是就像我直接在頁面 cmd+F,那些都會高亮。」
#
# 設計要點(為什麼是這個形狀):
# 1. **搜尋 ≠ 通讀**:開場 push 全文(session-start-recall.sh)解決不了這題——量大必然只讀開頭。
# 這支反過來:在「你正要去查 code/原文」的當下,用你自己的關鍵字 grep wiki,只推命中行。
# 2. **時機是關鍵**:不是開場推、不是寫入時擋,而是**查詢動作發生的那一刻**介入。
# 3. **提醒不阻擋**exit 0):wiki 沒記載時本來就該去翻原文,擋下來反而礙事。
# 唯一目的是消滅「不知道 wiki 有寫」這件事。
#
# 觸發:Grep / Glob / Read 打向 code 或 docs 時(見下方 should_check)。
# 輸出:stdout 注入 context(命中的 wiki 行 + 檔名:行號)。
set -euo pipefail
INPUT=$(cat)
TOOL=$(printf '%s' "$INPUT" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
# 取出這次查詢的關鍵字:Grep 用 patternGlob/Read 用路徑的檔名部分
QUERY=$(printf '%s' "$INPUT" | python3 -c "
import json,sys,os,re
try:
d=json.load(sys.stdin); ti=d.get('tool_input',{})
q = ti.get('pattern') or ''
if not q:
p = ti.get('file_path') or ti.get('path') or ''
q = os.path.splitext(os.path.basename(p))[0] if p else ''
if not q:
# Bash2026-07-21 補的破口——原版只掛 Grep|Glob|Read
# 但「用 curl/wrangler 亂試部署方法」走的是 Bash,整支 hook 不觸發。
# leo 當場點破:wiki 早記著「寄信已驗證可用」,我卻沒查又自創方法。
# 只認「會動到外部系統/部署」的高風險指令,避免每個 ls 都洗版。
cmd = ti.get('command') or ''
if re.search(r'\b(wrangler|curl|npx|acr|gh|deploy|push)\b', cmd):
# 取指令中最具識別度的詞(worker 名/資源名/子命令)當搜尋詞
cand = re.findall(r'[A-Za-z_][A-Za-z0-9_-]{4,}', cmd)
skip = {'https','http','client','accounts','workers','scripts',
'application','content','Authorization','Bearer','python3',
'curl','npx','bash','echo','grep','local','branch','origin'}
cand = [c for c in cand if c not in skip and not c.startswith('-')]
q = max(cand, key=len) if cand else ''
# grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞
words = re.findall(r'[A-Za-z_][A-Za-z0-9_]{3,}', q)
print(max(words, key=len) if words else '')
except Exception:
print('')
" 2>/dev/null || echo "")
[ -z "$QUERY" ] && exit 0
WIKI_DIR="system-dev/wiki"
[ -d "$WIKI_DIR" ] || exit 0
# 只在「查程式碼/文件」時提醒;查 wiki 本身就不用了(已經在讀了)
TARGET=$(printf '%s' "$INPUT" | python3 -c "
import json,sys
try:
d=json.load(sys.stdin); ti=d.get('tool_input',{})
print(ti.get('file_path') or ti.get('path') or '')
except Exception: print('')
" 2>/dev/null || echo "")
case "$TARGET" in
*system-dev/wiki*) exit 0 ;;
esac
# grep wiki(不分大小寫、含行號),最多 12 行避免洗版
HITS=$(grep -rin --include="*.md" -- "$QUERY" "$WIKI_DIR" 2>/dev/null | head -12 || true)
[ -z "$HITS" ] && exit 0
COUNT=$(printf '%s\n' "$HITS" | wc -l | tr -d ' ')
echo "════════════════════════════════════════════════"
printf '📚 wiki 已有「%s」的記載(%s 處,先看這裡再翻原文)\n' "$QUERY" "$COUNT"
echo "════════════════════════════════════════════════"
printf '%s\n' "$HITS" | sed 's|^system-dev/wiki/| |'
echo ""
echo "⚠️ wiki 是判準,程式碼與歷史文件只是稿子(mistakes 第一鐵律)。"
echo " • 上面若與你將要查的原文衝突 → **以 wiki 為準**,別用 code 推翻 wiki。"
echo " • 看到「不可動/待廢除/進行中」→ 先讀它的**解除條件**並逐條核對,"
echo " 那是當時狀態不是永久禁令;條件已滿足就是可動。"
echo " • wiki 沒答案才值得翻原文——翻完若得到新結論,**回頭更新 wiki**。"
echo ""
exit 0
+1 -24
View File
@@ -8,11 +8,6 @@
"type": "command",
"command": "bash .claude/hooks/pre-write-guard-no-table.sh",
"timeout": 5
},
{
"type": "command",
"command": "bash .claude/hooks/sdd-guard.sh",
"timeout": 5
}
]
},
@@ -25,25 +20,7 @@
"timeout": 5
}
]
},
{
"matcher": "Grep|Glob|Read|Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/wiki-first-search.sh"
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-wiki-guard.sh"
}
]
}
]
}
}
}
+6 -41
View File
@@ -3,24 +3,12 @@
> **上游約束(InkStoneCo 總管)**:此目錄(原 matrix/kbdb,已改名 kbdb-graph-plugin)是 InkStoneCo 子專案,受頂層知識庫約束。
> 動工前讀 `github.com/uncle6me-web/InkStoneCo` 的 CLAUDE.md + `docs/3-specs/matrix-rearrange/`。
> **定位(leo 2026-06-13**:此 repo = **KBDB-graph 插件**triplet 採集 + graph 查詢),類比 **Apache AGE 之於 Postgres**。基本盤 = `arcrun/kbdb`D1 三表 + CRUD API),**不在這裡、不動它**。
> **本目錄專屬交棒見 `docs/HANDOFF-kbdb-plugin.md`**SDD 見 `system-dev/docs/3-specs/`2026-07-17 已從舊 `docs/3-specs/` 遷移統一)
> **本目錄專屬交棒見 `docs/HANDOFF-kbdb-plugin.md`**SDD 見 `docs/3-specs/kbdb-graph-extraction/`
> 本檔案由 Claude Code 自動讀取。所有在此目錄下的開發必須遵守以下規則。
---
## 📐 SDD 生命週期鐵律(leo 2026-07-17 拍板,全文見 `system-dev/docs/3-specs/SDD-LIFECYCLE.md`
1. **單一活性**:任何時刻整個 repo 最多一份 `status: active` 的 SDD,所有開發任務唯一對應它的 tasks;找不到對應任務 → 停下來問,不准直接做。
2. **禁止自行建立 SDD**:澄清問題→回答不動文件;任務層變更→更新現行 SDD 的 tasks(標日期原因);規格層變更→走第 3 條。
3. **規格變更只有一條路**proposal 寫進 `system-dev/docs/3-specs/pending-changes.md`(摘要+原因+影響分析)→ **停止**等使用者「confirm」。
4. **開新 SDD 的唯一時機**confirm 後——先把舊 SDD 未完成任務逐條搬入新 SDD(做完前不准寫 code)→ 舊的標 closed + superseded_by 移入 `archive/` → 列搬移/作廢清單請最終確認。
5. **每次 session 開始**:讀 active SDD 與 pending-changes.md,回報三個數字(現行規格〈名稱〉/未完成任務 N/待裁決 proposal M)。
> **本 repo 現況(2026-07-17****0 份 active**(無現行開發,實作面已收斂、剩跨 repo 接通)——0 份 active 合法。三份 `paused`ingest-contract / kbdb-graph-extraction / plugin-install,等基本盤部署/跨 repo 接通),兩份 `closed` 入 `archive/`。**重啟開發時先挑一份升 active**(一份、只能一份),再動 code——sdd-guard hook 會擋 0-active 下的 code 寫入。
---
## 🔒 KBDB 鐵律(leo 2026-06-14 拍板,最高原則,違反會被 hook exit 2 擋下)
決策全文:`InkStoneCo/docs/3-specs/matrix-rearrange/DECISION-kbdb-v3-baseplane.md`
@@ -45,43 +33,20 @@
GET /records/by-template/:tpl ─ entity 正規化 → template='entity'exact match
─ 唯一對外通道 = src/lib/kbdb-client.ts
```
基本盤 API 契約詳見 `system-dev/docs/3-specs/kbdb-graph-extraction/design.md`
基本盤 API 契約詳見 `docs/3-specs/kbdb-graph-extraction/design.md`
**基本盤缺口**base 無 `PUT/DELETE /records/:id`、無 vectorize 語意搜尋)標 `[→arcrun]`,不得為此自建表。embedding/語意搜尋屬基本盤 optional embed 模組,**不是插件職責**。
---
## 🔴 第一鐵律:wiki 是判準,不准跳過(2026-07-20/21 leo 兩度點破)
**要查任何東西之前,先搜尋 wiki——用 grep,不是只讀開頭幾行。**
> leo:「花很多力氣去產生 wiki,最重要的就是要可以查詢,**結果要查的時候就跳過,那就白寫了**。」
> 「重點是你自己的記憶對嗎?而你有按照規定去切實讀 wiki 嗎?」
```bash
grep -rin "<本題關鍵字>" system-dev/wiki/
```
**三條硬規則**
1. **wiki 與程式碼/歷史文件衝突 → 以 wiki 為準**。程式碼反映「還沒清乾淨」,不等於「還在用」。
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。那是當時狀態,不是永久禁令。
3. 翻原文後得到新結論 → **回頭更新 wiki**wiki 過時是債,要還)。
**動外部系統(部署/curlwrangleracrgh)前**:先找 repo 有沒有**現成腳本或 README 部署段**
別自創方法。(實例:2026-07-21 明明有 `npx wrangler deploy` 這條驗過的路,卻自己 curl 硬幹踩坑。)
> hook `wiki-first-search.sh` 會在你查 code/下高風險指令時自動推 wiki 命中行;
> **但機制只是提醒,判斷是你的責任**。
## Wiki 讀取順序(LLM 記憶系統,CC 維護)
| 檔案 | 時機 | 用途 |
|------|------|------|
| `system-dev/wiki/status.md` | session 開始第一件事 | 當前進度、下一步 |
| `system-dev/wiki/mistakes.md` | 做新功能前 | 已知誤解、避坑清單 |
| `system-dev/wiki/decisions-summary.md` | 設計判斷時 | 架構決策摘要 |
| `.claude/wiki/status.md` | session 開始第一件事 | 當前進度、下一步 |
| `.claude/wiki/mistakes.md` | 做新功能前 | 已知誤解、避坑清單 |
| `.claude/wiki/decisions-summary.md` | 設計判斷時 | 架構決策摘要 |
文件分類規則見 `docs/README.md`SDD 在 `system-dev/docs/3-specs/[子系統]/`design.md + tasks.md),動手前必須有這兩個檔案,且遵守上方生命週期鐵律
文件分類規則見 `docs/README.md`SDD 在 `docs/3-specs/[子系統]/`design.md + tasks.md),動手前必須有這兩個檔案。
session 結束用 `/wiki-update` 更新 status.md,被糾正後 `/wiki-capture` 寫進 mistakes.md。
---
-9
View File
@@ -1,9 +0,0 @@
# 已遷移
本目錄的 SDD 已於 2026-07-17 全數遷移至 **`system-dev/docs/3-specs/`**(依 2026-06-14 位置統一規則)。
- 現行/暫停 SDD`system-dev/docs/3-specs/`ingest-contract / kbdb-graph-extraction / plugin-install
- 已封存死件:`system-dev/docs/3-specs/archive/`arcrun-key-auth / blocks-edit-api
- 生命週期鐵律:`system-dev/docs/3-specs/SDD-LIFECYCLE.md`
請勿在此新增 SDD。
@@ -1,10 +1,3 @@
---
status: closed # active | draft | paused | closed(生命週期鐵律見 ../../SDD-LIFECYCLE.md
superseded_by: ""
---
> **封存(2026-07-17**:舊 KBDB 時代草稿(等 richblack review,帳號已 suspend)。key auth 屬基本盤 arcrun/kbdb 職責,非本插件範圍(2026-06-14 API-as-Wall 改寫後失效)。
# KBDB — Arcrun Key Auth
> 建立:2026-05-05
@@ -1,10 +1,3 @@
---
status: closed # active | draft | paused | closed(生命週期鐵律見 ../../SDD-LIFECYCLE.md
superseded_by: ""
---
> **封存(2026-07-17**:基於舊「萬物皆 Block/blocks 表」架構,該架構已判定為違規殘留並刪除(2026-06-14 改寫);base `PATCH /records/:id` 已由 Arcrun #6 實作取代本需求。
# KBDB — Blocks Edit API
> **建立**2026-05-06
@@ -1,8 +1,3 @@
---
status: paused # active | draft | paused | closed(生命週期鐵律見 ../SDD-LIFECYCLE.md
superseded_by: ""
---
# ingest-contract — 設計
> **藍圖在頂層**:本 SDD 只放 **kbdb-graph-plugin 內部實作細節**。跨專案脈絡(為什麼拆 ingest/graph、mira 蒸發、整體資料流)見 InkStoneCo `docs/3-specs/mira-dissolve/`design + requirements)。
@@ -1,8 +1,3 @@
---
status: paused # active | draft | paused | closed(生命週期鐵律見 ../SDD-LIFECYCLE.md
superseded_by: ""
---
# KBDB-graph 抽出 — Design
> 建立:2026-06-14
@@ -1,8 +1,3 @@
---
status: paused # active | draft | paused | closed(生命週期鐵律見 ../SDD-LIFECYCLE.md
superseded_by: ""
---
# KBDB-graph 插件安裝 — Design
## 目標
+43
View File
@@ -0,0 +1,43 @@
// 存量回填:對「已存在的 entity records」補上對應的 embeddable base entry。
// 讀 base 既有 entity recordsGET /records/by-template/entity)→ 為每筆有 gloss 的落一筆 embeddable entry
// (同 persistNodes 前向路徑的格式與冪等)。讓既有知識立刻可被 base embed backfill 嵌到。
// 鐵律:走 base APIAPI-as-Wall)、零 SQL、樂高法(<100 行、一檔一事、第一參數收 KbdbClient)。
import type { KbdbClient } from '../lib/kbdb-client';
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
import { upsertGlossEntry, type GlossEntryOutcome } from './gloss-entry';
export type BackfillGlossResult = {
scanned: number; // 掃到的 entity record 數
created: number; // 新落的 embeddable entry 數
updated: number; // gloss 變 → 更新內容的數
unchanged: number; // 已存在且內容相同(冪等 no-op)
skipped: number; // embed=false / 空 gloss / 空 canonical → 不落
};
/**
* 對既有 entity records 補 embeddable entry。冪等:重跑不造重複(同 node → 同 page_name → no-op)。
* embed 標為 'false' 的 record 略過(明確不入向量庫)。source 未知(存量)→ 留空。
*/
export async function backfillGlossEntries(
client: KbdbClient,
owner_id: string, // 必經:存量 entity record 本身 owner=Noneowner 由 caller 明確指定(配合 owner-mandatory D28
): Promise<BackfillGlossResult> {
await ensurePluginTemplates(client);
// 讀「全部」存量 entity record(不帶 owner 過濾)——存量 entity 本身 owner=None,拿 owner 去濾來源會讀到 0。
// owner 只套在下面建立的 gloss entry(caller 明確指定),不套來源讀取。
const records = await client.listRecordsByTemplate(TPL_ENTITY);
const res: BackfillGlossResult = { scanned: records.length, created: 0, updated: 0, unchanged: 0, skipped: 0 };
for (const r of records) {
const v = r.values;
if (v.embed === 'false') { res.skipped++; continue; } // 明確標不嵌 → 略過
const outcome: GlossEntryOutcome = await upsertGlossEntry(
client,
{ canonical: v.canonical ?? '', node_id: v.node_id || '', gloss: v.gloss ?? '', source: '' },
owner_id,
);
res[outcome]++; // 'created' | 'updated' | 'unchanged' | 'skipped' 都是 res 的 number 欄位
}
return res;
}
+8 -6
View File
@@ -13,12 +13,12 @@ const norm = (s: string): string => s.toLowerCase().trim();
// ─── Entity ──────────────────────────────────────────────────────────────────
/** 建立 Entitycanonical name)。底層 = 一筆 entity template record。 */
export async function createEntity(client: KbdbClient, canonical: string, owner?: string): Promise<Entity> {
export async function createEntity(client: KbdbClient, canonical: string, owner: string): Promise<Entity> {
await ensurePluginTemplates(client);
const id = await client.createRecord(
TPL_ENTITY,
{ canonical, aliases_json: '[]', entity_type: '', owner: owner ?? '' },
owner,
{ canonical, aliases_json: '[]', entity_type: '', owner }, // 真 owner 落 slot(不再 ?? ''
owner, // 必經:createRecord 內 requireOwner 守衛
);
return { id, canonical, aliases: [] };
}
@@ -45,21 +45,23 @@ export async function listEntities(client: KbdbClient, limit = 100, owner?: stri
* 新增 alias。base 無 PUT /records/:id → 改「重建一筆新 entity record」覆寫(含舊 canonical + 既有 aliases + 新 alias)。
* [→arcrun] base 缺 PUT /records/:id:補上後改為原地 patch aliases_json,省一次重建。
*/
export async function addAlias(client: KbdbClient, entityId: string, alias: string, owner?: string): Promise<void> {
export async function addAlias(client: KbdbClient, entityId: string, alias: string, owner: string): Promise<void> {
const rec = await client.getRecord(entityId);
if (!rec) throw new Error(`Entity ${entityId} not found`);
const ent = recordToEntity(rec);
if (ent.aliases.includes(alias)) return;
const aliases = [...ent.aliases, alias];
await ensurePluginTemplates(client);
// 重建時沿用原 record 既有 owner;原 record 無主(舊資料 owner=None)才退回 caller 指定的 owner。
const effectiveOwner = (rec.values.owner || '').trim() || owner;
await client.createRecord(
TPL_ENTITY,
{
canonical: ent.canonical,
aliases_json: JSON.stringify(aliases),
entity_type: rec.values.entity_type ?? '',
owner: rec.values.owner ?? owner ?? '',
owner: effectiveOwner,
},
owner,
effectiveOwner, // 必經:createRecord 內 requireOwner 守衛
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ import type { KbdbClient } from '../lib/kbdb-client';
export async function normalizeEntity(
client: KbdbClient,
rawName: string,
owner?: string,
owner: string, // 必經:未命中會建新 entity,必帶 owner
): Promise<string> {
try {
const exact = await findEntityByName(client, rawName, owner);
+4 -4
View File
@@ -16,7 +16,7 @@ export async function createPendingAlias(
candidateEntityId: string,
candidateCanonical: string,
similarity: number,
owner?: string,
owner: string, // 必經:pending record 也不可無主
): Promise<PendingAlias> {
await ensurePluginTemplates(client);
const id = await client.createRecord(
@@ -27,7 +27,7 @@ export async function createPendingAlias(
candidate_canonical: candidateCanonical,
similarity: String(similarity),
},
owner,
owner, // 必經:createRecord 內 requireOwner 守衛
);
return {
id,
@@ -56,14 +56,14 @@ export async function getPendingAliases(client: KbdbClient, limit = 100, owner?:
}
/** 確認 → addAlias 到候選 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
export async function confirmPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise<void> {
export async function confirmPendingAlias(client: KbdbClient, pendingId: string, owner: string): Promise<void> {
const rec = await client.getRecord(pendingId);
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
await addAlias(client, rec.values.candidate_entity_id, rec.values.raw_name, owner);
}
/** 拒絕 → 以 raw_name 建新 entity。pending soft 保留([→arcrun] base 缺 DELETE record)。 */
export async function rejectPendingAlias(client: KbdbClient, pendingId: string, owner?: string): Promise<Entity> {
export async function rejectPendingAlias(client: KbdbClient, pendingId: string, owner: string): Promise<Entity> {
const rec = await client.getRecord(pendingId);
if (!rec || !rec.values.raw_name) throw new Error(`Pending alias ${pendingId} not found`);
return createEntity(client, rec.values.raw_name, owner);
+66
View File
@@ -0,0 +1,66 @@
// node gloss → base embeddable entry。把 node 的 canonical+gloss 落成一筆 base entry
// metadata.embed=true,供 base optional embed 模組讀標 → 嵌進 Vectorize(見 arcrun/kbdb src/embed.ts)。
// 鐵律:走 base APIAPI-as-Wall)、零 SQL、不綁 Vectorizeembedding 是 base 職責,graph 只落標)。
//
// 為何要這檔:node 打標存進 entity **record**node-persist),但 base embed 只掃 **entries**.metadata_json
// .$.embed===true → 標在 record、讀在 entry 對不上,語意查不到圖內容。這裡補「同一 gloss 也落成 entry」。
//
// 冪等(base 無 upsert entry 端點):以 node 去重鍵派生確定性 page_namebase 文件化的 idempotency key),
// 先 listEntries({entry_type,page_name}) 查存 → 無則 create、內容變則 patch、同則 no-op。
// [→arcrun] 若 base 日後補「POST /entries upsertpage_name 為鍵)」,這裡可收斂成單一呼叫、免 list-then-write。
import type { KbdbClient } from '../lib/kbdb-client';
// base embed 對「內容語意」無知,只認通用 embed 旗標;entry_type 供向量 metadata 過濾/辨識這批是圖節點 gloss。
export const NODE_GLOSS_ENTRY_TYPE = 'graph_node_gloss';
export type GlossEntryInput = {
canonical: string;
node_id?: string;
gloss?: string;
source?: string; // 沿用 node 的 ingest 來源(envelope source.uri);存量回填未知則留空
};
export type GlossEntryOutcome = 'created' | 'updated' | 'unchanged' | 'skipped';
/** node 去重鍵(同 persistNodesid 優先、無則 canonical)→ 確定性 idempotency key。 */
function glossKey(canonical: string, node_id?: string): string {
return `gloss:${(node_id || canonical).toLowerCase().trim()}`;
}
/**
* 把一個 node 的 gloss 落成 embeddable base entry(冪等)。
* - 空 gloss(或空 canonical)→ 跳過:base 對空 content 本就跳過 embed,且裸名的語意召回價值低、
* keyword 搜尋已覆蓋 → 不造無意義 entry(契約允許「空 gloss 可跳過」)。
* - content = `canonicalgloss`(名+描述一起 embed,利於語意召回)。
* - metadata_json.embed=truebase 讀此旗標)、source、node_id、canonical(回連原 node)。
*/
export async function upsertGlossEntry(
client: KbdbClient,
node: GlossEntryInput,
owner_id: string, // 必經:owner 從 persistNodes / backfill caller 帶真 owner,不得為 None(配合 owner-mandatory D28
): Promise<GlossEntryOutcome> {
const canonical = (node.canonical || '').trim();
const gloss = (node.gloss || '').trim();
if (!canonical || !gloss) return 'skipped';
const page_name = glossKey(canonical, node.node_id);
const content = `${canonical}${gloss}`;
const metadata_json = JSON.stringify({
embed: true,
source: node.source || '',
node_id: node.node_id || '',
canonical,
});
// 確定性 key 去重:同一 node 重複 ingest 不造重複 entry。
const existing = await client.listEntries({ entry_type: NODE_GLOSS_ENTRY_TYPE, page_name, owner_id });
if (existing.length > 0) {
const e = existing[0];
if (e.content === content) return 'unchanged'; // 同內容 → no-op
await client.updateEntry(e.id, { content, metadata_json }); // gloss 變 → 更新(base PATCH 觸發重嵌)
return 'updated';
}
await client.createEntry({ content, entry_type: NODE_GLOSS_ENTRY_TYPE, owner_id, page_name, metadata_json });
return 'created';
}
+12 -3
View File
@@ -4,6 +4,7 @@
import type { KbdbClient } from '../lib/kbdb-client';
import { TPL_ENTITY, ensurePluginTemplates } from '../lib/templates';
import { upsertGlossEntry } from './gloss-entry';
export type IngestNode = {
name: string;
@@ -22,7 +23,8 @@ export type IngestNode = {
export async function persistNodes(
client: KbdbClient,
nodes: IngestNode[],
owner_id?: string,
owner_id: string, // 必經:owner 一路從 ingest envelope / route 帶到底,不得掉成空字串
source?: string, // envelope source.uri,帶進 gloss entry 的 metadata.source(供 base backfill 依 source 過濾)
): Promise<void> {
if (!nodes || nodes.length === 0) return;
await ensurePluginTemplates(client);
@@ -42,9 +44,16 @@ export async function persistNodes(
gloss: n.gloss ?? '',
// contract 預設 true;只在明確 false 時存標(base 看 'false' 跳過 embed)。
embed: n.embed === false ? 'false' : 'true',
owner: owner_id ?? '',
owner: owner_id, // 真 owner 落 slot(不再 ?? ''
},
owner_id,
owner_id, // createRecord 內 requireOwner 守衛:缺→throw
);
// 另落一筆 embeddable base entrymetadata.embed=true)——record 的 gloss 標 base embed 讀不到,
// 必須也落成 entry base embed 模組才掃得到(打標≠讀標的修補)。只在要 embed(embed !== false)時落;
// 空 gloss 由 upsertGlossEntry 自行跳過。冪等:同 node 重複 ingest 不造重複 entry。
if (n.embed !== false) {
await upsertGlossEntry(client, { canonical: n.name, node_id: n.id, gloss: n.gloss, source }, owner_id);
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export type CreateTripletData = {
object: string;
source_block_id?: string;
confidence?: number;
owner_id?: string;
owner_id: string; // 必經:三元組寫入必帶 ownercreateRecord 內 requireOwner 守衛)
clusters?: string[];
bridge_score?: number;
subject_entity_type?: string;
+2 -2
View File
@@ -55,7 +55,7 @@ export async function extractTripletsViaLLM(ai: Ai, chunks: string[]): Promise<L
export async function writeTripletToDb(
client: KbdbClient,
t: { subject: string; predicate: string; object: string; confidence?: number },
owner: string | null,
owner: string, // 必經:萃取寫入必帶 owner(不再收 null → 不會 owner_id: undefined
): Promise<boolean> {
// 查重:以 S-P-O 三欄精確比對(queryTriplets 取 template record 後在插件層 filter
const { count } = await queryTriplets(client, {
@@ -71,7 +71,7 @@ export async function writeTripletToDb(
predicate: t.predicate,
object: t.object,
confidence: t.confidence ?? 0.8,
owner_id: owner ?? undefined,
owner_id: owner,
});
return true;
}
+3 -2
View File
@@ -53,7 +53,7 @@ export type IngestResult = { skipped: boolean; ingested: number; deprecated: num
export async function ingestEnvelope(
client: KbdbClient,
env: IngestEnvelope,
owner_id?: string,
owner_id: string, // 必經:owner 從 route 帶入,一路 thread 進 triplet + node 寫入,不得掉成空
): Promise<IngestResult> {
await ensurePluginTemplates(client);
@@ -84,7 +84,8 @@ export async function ingestEnvelope(
// 1b) 落地 node 層打標(embed / gloss / aliases),供 base embed 模組讀標執行 embedding。
// graph 自己不算向量(鐵律一致)。id 作去重鍵:同一卡(同 id/檔名)只存一筆 entity,不以邊數重複。
await persistNodes(client, env.nodes ?? [], owner_id);
// 並為每個 node 另落一筆 embeddable base entrysource.uri 帶進 metadata.source)。
await persistNodes(client, env.nodes ?? [], owner_id, env.source.uri);
// 2) 後翻舊批 status=deprecated(指向本批 source_uriappend 在前 → 無空窗)。
for (const old of priorActive) {
+30 -4
View File
@@ -13,6 +13,9 @@ export type BaseEntry = {
owner_id: string | null;
parent_id?: string | null;
page_name?: string | null;
// base entries 的通用 metadataTEXTJSON 字串)。base embed 模組讀 metadata_json.$.embed / $.source
// 決定要不要嵌、嵌進哪個 source 分組(見 arcrun/kbdb src/embed.ts)。插件只落標、不算向量。
metadata_json?: string | null;
created_at?: number;
updated_at?: number;
};
@@ -26,11 +29,32 @@ export type BaseRecord = {
export type CreateEntryInput = {
content: string | null;
entry_type: string;
owner_id?: string;
// owner_id 必經(D27/D28):型別上必填、且執行期缺→throw。不再靜默送空 → 不寫出無主資料。
owner_id: string;
parent_id?: string;
// page_name = base 文件化的 idempotency keyentry-crud.tsexact-match lookup)。
page_name?: string;
// metadata_json = base 既有欄位(JSON 字串)。標 { embed:true, source, ... } 供 base embed 模組讀標執行。
metadata_json?: string;
};
/**
* owner_id 必經守衛(D27/D282026-07-05)。
* 病根:漏 owner → 靜默送 owner:'' → base 寫成 owner=None → mira owner 過濾濾掉 → 使用者查不到
* (實測回填 16 筆 gloss entry owner=None)。base 即將把 owner 設必填(缺→400);插件端先擋,fail loud。
* 任何寫入前呼叫此守衛:漏 owner 在插件端就爆、不會送到 base,錯誤訊息指名該從最外層 route/envelope 帶入。
*/
export function requireOwner(owner_id: string | null | undefined, op: string): string {
const v = (owner_id ?? '').trim();
if (!v) {
throw new Error(
`[kbdb-graph] ${op} 缺 owner_id:owner 為必經欄位,不可寫出無主資料。` +
`請從最外層(route 參數 / ingest envelope)把真 owner thread 到底。`,
);
}
return v;
}
/** 基本盤 API client。所有方法 = 一個 HTTP 呼叫,零 SQL。 */
export class KbdbClient {
constructor(
@@ -70,7 +94,8 @@ export class KbdbClient {
// --- entries ---
async createEntry(input: CreateEntryInput): Promise<BaseEntry> {
const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', input);
const owner_id = requireOwner(input.owner_id, 'createEntry');
const { entry } = await this.req<{ entry: BaseEntry }>('POST', '/entries', { ...input, owner_id });
return entry;
}
@@ -154,11 +179,12 @@ export class KbdbClient {
// --- records= template 實例,填 slot ---
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
async createRecord(template: string, values: Record<string, string>, owner_id: string): Promise<string> {
const owner = requireOwner(owner_id, `createRecord(template=${template})`);
const { record } = await this.req<{ record: { record_id: string } }>('POST', '/records', {
template,
values,
owner_id,
owner_id: owner,
});
return record.record_id;
}
+20 -2
View File
@@ -6,6 +6,7 @@ import {
rejectPendingAlias,
} from '../actions/entity-pending';
import { listTripletEntities } from '../actions/triplet-entities';
import { backfillGlossEntries } from '../actions/backfill-gloss-entries';
import { makeKbdbClient } from '../lib/kbdb-client';
const entityRoutes = new OpenAPIHono<{ Bindings: Bindings }>();
@@ -54,14 +55,31 @@ entityRoutes.openapi(listPendingRoute, async (c) => {
entityRoutes.post('/pending/:id/confirm', async (c) => {
const id = c.req.param('id');
await confirmPendingAlias(makeKbdbClient(c.env), id);
// owner 必經:confirm 會 addAlias(重建 entity record),缺 owner→400 不寫無主資料。
const owner = c.req.query('owner_id')?.trim();
if (!owner) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400);
await confirmPendingAlias(makeKbdbClient(c.env), id, owner);
return c.json({ success: true, action: 'confirmed', id });
});
entityRoutes.post('/pending/:id/reject', async (c) => {
const id = c.req.param('id');
const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id);
// owner 必經:reject 會 createEntity,缺 owner→400 不寫無主資料。
const owner = c.req.query('owner_id')?.trim();
if (!owner) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400);
const newEntity = await rejectPendingAlias(makeKbdbClient(c.env), id, owner);
return c.json({ success: true, action: 'rejected', newEntity });
});
// POST /backfill-gloss-entries — 存量回填:對既有 entity records 補 embeddable base entrymetadata.embed=true)。
// route 只驗參數 + 呼叫 action(樂高法:無業務邏輯)。冪等,可重複呼叫。
entityRoutes.post('/backfill-gloss-entries', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { owner_id?: string };
// owner 必經:存量 entity record 本身 owner=Noneowner 由 caller 明確指定(不從 record 帶)。缺→400 不寫無主資料。
const owner_id = c.req.query('owner_id') || body?.owner_id;
if (!owner_id) return c.json({ success: false, error: 'owner_id required' }, 400);
const result = await backfillGlossEntries(makeKbdbClient(c.env), owner_id);
return c.json({ success: true, ...result });
});
export { entityRoutes };
+8 -2
View File
@@ -77,7 +77,7 @@ const createRouteDefinition = createRoute({
subject: z.string().min(1),
predicate: z.string().min(1),
object: z.string().min(1),
owner_id: z.string().optional(),
owner_id: z.string().min(1), // 必填:缺→400(資料不可無主)
source_block_id: z.string().optional(),
confidence: z.number().optional(),
clusters: z.array(z.string()).optional(),
@@ -103,10 +103,13 @@ const ingestRoute = createRoute({
method: 'post',
path: '/ingest',
request: {
// owner_id 走 route 參數(envelope 是 .strict() 凍結契約,不塞 owner 進去)。缺→400。
query: z.object({ owner_id: z.string().optional().describe('資料所有者(必填,缺→400') }),
body: { content: { 'application/json': { schema: IngestEnvelopeSchema } } },
},
responses: {
200: { description: 'Envelope ingested (or skipped if same content_hash)' },
400: { description: 'Missing owner_id (資料不可無主)' },
422: { description: 'Invalid envelope (forbidden field or shape mismatch)' },
},
tags: ['Triplets'],
@@ -115,8 +118,11 @@ const ingestRoute = createRoute({
tripletRoutes.openapi(
ingestRoute,
async (c) => {
// owner 必經:缺→400(不寫出無主 triplet/node;避免再現 owner=None 被 mira 過濾)。
const owner_id = c.req.query('owner_id')?.trim();
if (!owner_id) return c.json({ error: 'owner_id query parameter required(資料不可無主)' }, 400);
const env = c.req.valid('json');
const result = await ingestEnvelope(makeKbdbClient(c.env), env);
const result = await ingestEnvelope(makeKbdbClient(c.env), env, owner_id);
return c.json(result, 200);
},
// strict() 驗證失敗(如送禁止欄位 bridge_score)→ 422,不是預設 400。
+1 -1
View File
@@ -1 +1 @@
1.16.1
1.10.0
-39
View File
@@ -1,39 +0,0 @@
# SDD 生命週期鐵律(不可違反)
> 來源:leo 2026-07-17 拍板。
> 適用:`system-dev/docs/3-specs/` 下的「規格 SDD」(requirements/design/tasks 三件式資料夾)。
> **不適用**:派工表/sprint 檔、journeys/ 卷宗、TEMPLATE-sdd、README、pending-changes.md——它們不是 SDD,不掛 status。
## 狀態標記(機器可查)
每個 SDD 資料夾的 `design.md` 最上方掛 YAML frontmatter
```yaml
---
status: active # active | draft | paused | closed
superseded_by: "" # closed 且被取代時填接替的 SDD 資料夾名
---
```
- `active`:現行規格,全 repo 開發任務唯一對應源。**任何時刻整個 repo 最多一份。**
- `draft`:起草中,尚未採納。
- `paused`:動過工、暫停中;恢復=升回 active(先收掉現任 active)或被新 SDD 繼承。
- `closed`:已完成或被取代;被取代者填 `superseded_by` 並移入 `3-specs/archive/`
## 五條鐵律
1. **單一活性**:任何時刻只允許一份 `status: active`。所有開發任務必須對應這份 SDD 的 tasks。找不到對應任務 → 停下來問,不准直接做。
2. **禁止自行建立 SDD**:CC 在任何情況下不得主動建新 SDD。收到使用者意見先分類:澄清問題→回答即可不動文件;任務層變更(不影響核心設計)→更新現行 SDD 的 tasks 區段並標日期與原因;規格層變更(核心設計/方向改變)→走第 3 條,不准直接改 spec。
3. **規格變更只有一條路**:產出 change proposal 寫入 `system-dev/docs/3-specs/pending-changes.md`(變更摘要與觸發原因+影響分析:現行 SDD 哪些任務作廢/修改/不受影響/尚未完成),然後**停止**,等使用者明說「confirm」。沒 confirm 就繼續依現行 SDD 工作。多個 proposal 可並存緩衝區、由人一次裁決——CC 的速度導向影響分析,不是規格增生。
4. **開新 SDD 的唯一時機**:使用者 confirm 一份規格層 proposal 時,依序:
a. 舊 SDD 未完成且仍有效的任務**逐條搬入**新 SDD 的 tasks——**這步做完前不准寫任何程式碼**(強迫顯式盤點,遺漏會在 d 的清單被看到,而不是三天後才發現)。
b. 舊 SDD frontmatter 改 `status: closed, superseded_by: <新SDD>`,資料夾移入 `3-specs/archive/`
c. 新 SDD 的 changelog 首行記錄:繼承自哪份、為何取代。
d. 向使用者列出「已搬移任務清單」與「已作廢任務清單」請求最終確認。
5. **每次 session 開始**:先讀現行 active SDD 與 pending-changes.md,回報三個數字——「現行規格〈名稱〉+未完成任務 N+待裁決 proposal M」——再開始工作。若回報出現兩份 active=規則已被違反,當場糾正。
## 硬約束(不信任單點自律,用結構保證不變量)
- `.claude/hooks/sdd-guard.sh`PreToolUse Write|Edit):active 數 >1 → 任何寫檔一律擋;寫 code 檔需恰好 1 份 active。
- `scripts/sdd-active-check.sh`:獨立檢查,pre-commit / CI 可掛,違反 exit 1。
- 誠實限制:hook 只擋語法層明顯違規,繞道可行但留痕可審;不聲稱不可繞過。
@@ -1,10 +1,6 @@
---
status: draft # active | draft | paused | closed(生命週期鐵律見 ../SDD-LIFECYCLE.md
superseded_by: "" # closed 且被取代時填接替的 SDD 資料夾名
---
# [子系統名稱] — Design
> 狀態:[草稿 / 審核中 / 已採納 / 已廢棄]
> 建立:[YYYY-MM-DD] | 最後更新:[YYYY-MM-DD]
> 負責人:[名稱]
@@ -1,15 +0,0 @@
# Pending Changes(規格變更緩衝區)
> 規則來源:`SDD-LIFECYCLE.md` 第 3、4 條。
> 規格層變更(核心設計/方向改變)**只有這一條路**CC 把 change proposal 寫進「待裁決」——
> 變更摘要與觸發原因+影響分析(現行 SDD 哪些任務作廢/修改/不受影響/尚未完成)——然後**停止**,
> 等使用者明說「confirm」才依第 4 條開新 SDD;沒 confirm 就繼續依現行 SDD 工作。
> 多個 proposal 可並存,由人一次裁決。本檔不是 SDD,不掛 status。
## 待裁決
(無)
## 已裁決
(無——裁決後從「待裁決」移到這裡留底,標 confirmed / rejected 日期。)
@@ -1,81 +0,0 @@
# Logseq 任務 marker 解析(單一真相源)
> **這是「Logseq 原生任務語法」解析的唯一權威規格。** 任何要從 Logseq graph
> 抓任務狀態的功能,一律 import 這份、不得各寫一份自己的 mapping。
>
> **已知兩個消費者**(共用同一套解析,見各自 issue):
> 1. **vault 萃取**`/wiki-extract`template#5):marker → 卡片 frontmatter `task_status`。
> 2. **tasks→Project 投影**`system-dev/workflows/tasks-project-sync.*`template#4):
> 當投影來源是 Logseq graphnotes/kb)時,用這份判斷任務與狀態。
>
> 兩者**只共用「怎麼 parse」**(哪幾行是任務、marker 是什麼、正規狀態是什麼、跳過什麼);
> parse 完各自要「拿狀態做什麼」(寫卡 vs 投影 issue)不同,那部分各管各的。
---
## 為什麼不是 GFM checkbox(規格更正,leo 2026-07-04 發現)
Logseq 的原生任務**不是** GFM 的 `- [ ]` / `- [x]`,而是**大寫 marker 開頭的 block**
```
- TODO AI 查看 leo21c 內所有 Repo,找到本地 Repo 搬到 Gitea
- DOING 建立知識總庫,可查所有子庫
- DONE 手機和電腦 Logseq 可以被放進知識總庫
```
若照舊規格只抓 `- [ ]` checkbox**notes / kb 兩個 Logseq graph 的任務會全數漏抓**。
> 兩種源、兩套語法、同一條下游管線:
> - **SDD `tasks.md`**(各 repo `system-dev/docs/3-specs/**`)→ GFM checkbox(現行不變)。
> - **Logseq graphnotes / kb** → 本檔的大寫 marker。
---
## 解析規格
### 1. 任務行辨識(regex
```
^\s*- (TODO|DOING|NOW|LATER|WAITING|DONE|CANCELED|CANCELLED)\s+
```
- marker 必須是 block`-` bullet)的**開頭第一個 token**、全大寫、後接空白。
- `CANCELED` 與英式 `CANCELLED` 皆收(Logseq 兩種都產)。
- marker 後面到行尾(或到子 bullet 之前)是**任務內文**。
### 2. marker → 正規狀態(task_status
| Logseq marker | 正規 task_status |
|---------------|------------------|
| `TODO``LATER` | `todo` |
| `DOING``NOW` | `in-progress` |
| `WAITING` | `blocked` |
| `DONE` | `done` |
| `CANCELED``CANCELLED` | `closed` |
> `LATER`/`NOW` 是 Logseq「排程視圖」用的同義 markerLATER≈TODO、NOW≈DOING),
> 正規化後與 TODO/DOING 併軌,下游不必區分。
### 3. 必須跳過的東西(別當任務內文)
Logseq 的任務 block 底下常掛時間戳與屬性行,這些**不是內文**,解析時整段略過:
- **`:LOGBOOK:``:END:` 區塊**:marker 被點擊計時產生的時間戳紀錄。
遇到 `:LOGBOOK:` 那行起、到 `:END:` 那行止(含兩端),整塊丟掉。
- **屬性行 `key:: value`**:如 `collapsed:: true``id:: 65a...``SCHEDULED:: <...>`
`DEADLINE:: <...>`。凡符合 `^\s*[\w-]+:: ` 的行都是屬性,不是內文。
`SCHEDULED`/`DEADLINE` 的日期若下游要用可另抓,但**不得當任務描述文字**。)
### 4. 巢狀子 bullet
任務 block 底下縮排的子 bullet 是該任務的補充說明(非獨立任務,除非子 bullet 自己也帶 marker)。
萃取時可併入該任務的描述脈絡;投影時只取母 block 那行當任務標題。
---
## 自檢(實作或 LLM 執行前跑一遍)
- [ ] 用的是大寫 marker regex**不是** `- [ ]` checkbox。
- [ ] 八個 marker 全部覆蓋(含 `LATER`/`NOW`/`CANCELLED` 別漏)。
- [ ] `:LOGBOOK:...:END:``key:: value` 屬性行有跳過,沒混進任務文字。
- [ ] 狀態用上表**正規名**`todo`/`in-progress`/`blocked`/`done`/`closed`),不是原始 marker 字面。
+7 -29
View File
@@ -126,23 +126,10 @@ gloss: 一句話定義這個概念是什麼(給下游語義 normalize 用,
- [自包含改寫的要點,不寫「詳見原文」]
## 實體
> 本卡內文的關鍵實體(也是 graph node)。名+描述一起供下游 embedding normalize。
> AI 生產、人不必讀;集中放、一實體一行、不縮排、不重複。
- **原子筆記**atomic note/卡片原子化)— 每張卡只承載一個不可再分論點的知識記錄單元。
- **傳統筆記**(大鍋炒筆記)— 把多主題混雜在同一篇、難精確引用的記錄方式。
## 關聯
### 內文知識關係(內文實體間;端點=上方 `## 實體` 的正規名,一字不差)
- 原子筆記 >> 對立於 >> 傳統筆記
- 傳統筆記 >> 犧牲 >> 精確引用
### 卡片關係(卡對卡)
- [[本卡]] >> 謂詞(動詞短語) >> [[他卡]]
- [[原子筆記]] >> 是其最小單元 >> [[卡片盒筆記法]]
```
### 架構:三層 + 標籤橫切(183 卡實證)
@@ -160,22 +147,15 @@ cards/<bucket>/
- **frontmatter `tags:` 而非行內 `#tag`**:內文常用 `#`(如 `#猜想`),行內標籤會讓 ingest 分不清「分類」與「內文範例」污染 graph;frontmatter 零歧義。標籤只能用 `TAXONOMY.md` 列出的;**禁止繞過字典在卡片直接冒新標籤**,但字典可受控擴充(遇新軸先查重、確認非同義詞,再登記進本 repo 的 TAXONOMY.md)。
- **麵包屑帶路徑**H1 次行 `← [[<bucket>/00-INDEX]]`。指 `00-INDEX` 因固定名跨桶撞名,**一律帶路徑**;卡片間連結用裸 `[[卡名]]`
### 使用 typed-edge 三元組(抓內文實體關係,不只卡對卡
### 使用 typed-edge 三元組(不只裸 `[[wikilink]]`
用**帶語義的三元組** `A >> 謂詞 >> B` 寫進 `## 關聯`。**重點是抓內文裡的實體關係**——卡對卡(`[[卡A]] >> 謂詞 >> [[卡B]]`)只是把既有雙鏈加個動詞、資訊量幾乎沒增加;知識圖譜的價值在內文概念間的關係(`原子筆記 >> 對立於 >> 傳統筆記`,這些 A/B 是內文概念、不是卡標題)
整理時,發現內容與其他頁面有關聯,用**帶語義的三元組**寫進 `## 關聯`,而非只列裸 `[[頁面]]`。裸 `[[A]]` 只說「有關」、沒說關係,下游要建 knowledge graph 還得回讀兩張卡;三元組把關係也預編譯,ingest 直接 parse 出帶類型的有向邊
格式 `A >> 謂詞 >> B`,規則:
1. **方向性**:必須讀成「A(謂詞)B」一句通順的話;A、B 順序=主→賓真實方向。
2. **謂詞用動詞 / 動詞短語**(反駁、奠基於、犧牲),天然帶方向。**禁名詞當謂詞**——`>> 存儲格式 >>``>> 操作體驗 >>` 讀不通,是錯的。
3. **謂詞自由書寫但別太天馬行空**:寫「參考/參照」皆可(下游 embed 自動聚類同義謂詞),別寫「瞄了一眼」這種抓不到同義的
4. **內文三元組端點用裸文字**(非 `[[wikilink]]`),避免在 Logseq 產生大量紅色斷鏈;卡對卡那層才用 `[[]]`
5. **向後相容**:純 `[[A]]` 仍合法(無類型邊),盡量補謂詞。
> **★ 硬自檢(Haiku 量產必備護欄)★** —— 內文三元組的「端點 = `## 實體` 詞條」
> `A >> 謂詞 >> B` 的 A、B 必須與 `## 實體` 某個粗體正規名【一字不差】。**寫完後逐條自檢**:把 A、B 拿去 `## 實體` 找有沒有完全相同的正規名,沒有 → 這條錯了。
> 修法擇一:(a) 改用實體表已有的詞;(b) 端點確是重要實體 → 補進 `## 實體` 再指它。
> 禁止:端點帶括號註解、端點是整句補語、端點是形容詞短語。
> (實證:光寫規則 Haiku 會略過,端點對不齊 14 條;寫成自檢動作後 14→0。跑 1-2 張看不出,跑 12 張才暴露。)
2. **謂詞用動詞 / 動詞短語**(反駁、奠基於、是…的實作),天然帶方向。
3. **謂詞自由書寫**,不受控詞彙;下游對謂詞 embedding 時同義謂詞會自動聚類,但方向仍靠書寫順序保證
4. **向後相容**:純 `[[A]]` 仍合法(無類型邊),盡量補謂詞
`>>` 為分隔語法,全程一致即可。這是 Karpathy LLM Wiki「知識互連」的強化版——連結不只存在,還帶類型與方向。
@@ -186,9 +166,7 @@ cards/<bucket>/
- **在知識生產的當下、由整理者(CC / Cowork)建**:gloss 跟三元組同階段萃,**不留給下游 ingest 臨時補**——下游只有單檔/跨庫視角,編不出貼合的 gloss。
- **選填、deep tier 才產**:淺萃不浪費。
- **gloss ≠ 摘要**`gloss` 是 frontmatter 給機器 normalize 的定義句(「X 是…」);`## 摘要` 是給人讀的核心句。
- **兩層 gloss**frontmatter `gloss:` 描述「卡標題」這個 node;② `## 實體` 區塊的每行描述句,描述「內文實體」這些 node。**內文實體也是 graph node、也需描述句**才能被下游 embedding normalize`黃仁勳` vs `Jensen Huang` 靠描述拉近向量)
- **實體要描述、謂詞不用**:實體同義詞字面差遠需描述拉近;謂詞同義詞字面本就近,裸詞 embed 自動聚類。
- **對齊下游 envelope**frontmatter `gloss:``## 實體` 詞條對應 ingest envelope 的 `nodes[].gloss`
- **對齊下游 envelope**frontmatter `gloss:` 對應 ingest envelope 的 `nodes[].gloss`
> **改寫時必守**:① 絕不寫入 raw source(只往 `cards/<bucket>/` 寫,事後驗 raw source 0 異動);② 檔名=卡片全名,冒號用全形「:」、斜線用全形「/」,全程一種字元避免斷鏈。
+2 -54
View File
@@ -26,9 +26,9 @@ t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2";
# tn = 不換行版(給 prompt 用)
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
REPO_URL="https://git.uncle6.me/Leo/system-dev-template/raw/branch/main/template"
REPO_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/template"
# install.sh / update.sh 住在 main/scripts/(不在 template/)。
SCRIPTS_URL="https://git.uncle6.me/Leo/system-dev-template/raw/branch/main/scripts"
SCRIPTS_URL="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main/scripts"
CREATED=()
SKIPPED=()
@@ -103,25 +103,6 @@ echo ""
t "📦 安裝模組:$MODULE" "📦 Module: $MODULE"
echo ""
# ── 重複安裝防呆(1.10.1):install 只管「全新安裝」,一切後續歸 update ──
# 判準是「裝過沒」,不分新版舊版:
# - 新結構 system-dev/ 已存在,或
# - 舊結構 .claude/wiki/ 或 .claude/VERSION 存在(裝過舊版、待遷移)
# 裝過了還跑 install → 會重複建範本、甚至跟真資料並存(先 install 建空殼,遷移就被擋)。
# 正解:偵測到裝過 → 不動任何東西,導去 update(更新/遷移/補新檔都由它處理)。
if [ -d "system-dev" ] || [ -d ".claude/wiki" ] || [ -f ".claude/VERSION" ]; then
t "🛑 偵測到這個專案已經安裝過 system-dev-template。" \
"🛑 system-dev-template is already installed in this project."
t " 後續的更新、遷移、補新檔,一律由「更新腳本」處理(不要重跑 install):" \
" All updates, migrations, and new-file additions are handled by the UPDATER (don't re-run install):"
echo ""
echo " curl -sSL https://git.uncle6.me/Leo/system-dev-template/raw/branch/main/scripts/update.sh | bash"
echo ""
t " (重跑 install 可能建出空白範本、跟你的真資料並存,故在此停止。)" \
" (Re-running install could create empty templates alongside your real data, so it stops here.)"
exit 0
fi
# ── 偵測 vault 類型 → 決定 raw source(原始文件)路徑 ──────────
# 為什麼:這個模板原本假設「原始文件在 docs/」,但 Logseq / Obsidian
# 這種 PKM vault 有自己的目錄慣例,整理時不能照 docs/ 那套搬動,
@@ -254,10 +235,6 @@ create_dir ".claude/commands"
create_dir ".claude/hooks"
download_if_missing "system-dev/docs/README.md" "$REPO_URL/system-dev/docs/README.md"
# Logseq 任務 marker 解析(單一真相源):vault 萃取(/wiki-extract)與 tasks→Project
# 投影(tasks-project-sync)共用同一套解析,別各寫一份。放共用區,兩模組都拿得到。
download_if_missing "system-dev/docs/4-guides/logseq-markers.md" "$REPO_URL/system-dev/docs/4-guides/logseq-markers.md"
# 工具版號:放 system-dev/,不寄生 .claude/。
download_if_missing "system-dev/VERSION" "$REPO_URL/system-dev/VERSION"
@@ -281,8 +258,6 @@ if $WANT_WIKI; then
download_if_missing ".claude/commands/wiki-capture.md" "$REPO_URL/.claude/commands/wiki-capture.md"
download_if_missing ".claude/commands/wiki-update.md" "$REPO_URL/.claude/commands/wiki-update.md"
download_if_missing ".claude/commands/wiki-recall.md" "$REPO_URL/.claude/commands/wiki-recall.md"
# vault 增量萃取(Logseq/Obsidian → system-dev/wiki,冪等):給 Routine 反覆跑。
download_if_missing ".claude/commands/wiki-extract.md" "$REPO_URL/.claude/commands/wiki-extract.md"
# wiki 相關 hooks:接關 + 機敏掃描
download_if_missing ".claude/hooks/session-start-recall.sh" "$REPO_URL/.claude/hooks/session-start-recall.sh"
@@ -302,18 +277,6 @@ if $WANT_SDD; then
download_if_missing ".claude/commands/sdd-check.md" "$REPO_URL/.claude/commands/sdd-check.md"
download_if_missing ".claude/hooks/sdd-guard.sh" "$REPO_URL/.claude/hooks/sdd-guard.sh"
# SDD 生命週期鐵律(1.14,issue #6):規則真相源 + 規格變更緩衝區 + 獨立單一活性檢查
download_if_missing "system-dev/docs/3-specs/SDD-LIFECYCLE.md" "$REPO_URL/system-dev/docs/3-specs/SDD-LIFECYCLE.md"
download_if_missing "system-dev/docs/3-specs/pending-changes.md" "$REPO_URL/system-dev/docs/3-specs/pending-changes.md"
download_if_missing "system-dev/scripts/sdd-active-check.sh" "$REPO_URL/scripts/sdd-active-check.sh"
# ── tasks⇄Project 投影(optionalissue #16)──────────────────
# 帶檔 ≠ 啟用:workflow yaml 只是「留記錄+手動啟用素材」,啟用=對話答好且 acr push。
# 投影邏輯依附 tasks.md(住 3-specs),故隨 SDD 模組帶下來;裝了不代表開。
create_dir "system-dev/workflows"
download_if_missing "system-dev/workflows/tasks-project-sync.yaml" "$REPO_URL/system-dev/workflows/tasks-project-sync.yaml"
download_if_missing "system-dev/workflows/tasks-project-sync.local.sh" "$REPO_URL/system-dev/workflows/tasks-project-sync.local.sh"
fi
# ── 安裝/更新腳本:一開始就放進 system-dev/scripts/ ──
@@ -330,8 +293,6 @@ download_if_missing ".claude/hooks/pre-write-guard.sh" "$REPO_URL/.claude/hooks/
download_if_missing ".claude/commands/issue-handle.md" "$REPO_URL/.claude/commands/issue-handle.md"
chmod +x .claude/hooks/*.sh 2>/dev/null || true
chmod +x system-dev/workflows/*.sh 2>/dev/null || true
chmod +x system-dev/scripts/*.sh 2>/dev/null || true
# ── 依模組產生 settings.json 的 hooks 區塊 ────────
# settings.json 因模組而異,不能直接下載單一靜態檔,改條件組裝。
@@ -486,19 +447,6 @@ fi
if $WANT_SDD; then
t " 動 code 前先在 system-dev/docs/3-specs/[子系統]/ 建 design.md(可用 /sdd-check 協助)" \
" Before touching code, create design.md under system-dev/docs/3-specs/[subsystem]/ (use /sdd-check to help)"
# ── tasks⇄Project 投影:裝/init 對話 + 一次性廣告(issue #16)──
# 判準=能力(arcrun 裝了沒)+意願,不掃檔。落地成「CC 問一句」,install 只是交代 CC 去問。
echo ""
t " ❓ 待辦同步(optional,需 Arcrun):請你的 CC 問你一句——" \
" ❓ Task sync (optional, needs Arcrun): have your CC ask you once —"
t " 「您需要把本專案的待辦事項(tasks.md)同步到 GitHub 嗎?」" \
" \"Do you want this project's tasks (tasks.md) mirrored to GitHub?\""
t " 答「好」→ CC 查環境有沒有 Arcrunmcp / acr 在 PATH):有就設定同步、沒有就一次性告知" \
" Yes → CC checks for Arcrun (mcp / acr in PATH): set it up if present, otherwise inform you once"
t " 「Arcrun 是免費的 AI-friendly 工作流套件,想裝跟 Claude 說就行;之後也可手動啟用」。" \
" \"Arcrun is a free AI-friendly workflow toolkit — ask Claude to install it; you can also enable sync later.\""
t " 答「不好」→ 不做、不再追問。投影 workflow 在 system-dev/workflows/(帶檔≠啟用)。" \
" No → nothing happens, no nagging. The projection workflow sits in system-dev/workflows/ (shipped ≠ enabled)."
fi
t " GitHub issueCC 可直接 /issue-handle 讀回自己 repo 的 issue(禁自動輪詢)" \
" GitHub issues: CC can use /issue-handle to read issues from its own repo (no auto-polling)"
-49
View File
@@ -1,49 +0,0 @@
#!/bin/bash
# sdd-active-check.sh — 單一活性 SDD 獨立硬約束(SDD 生命週期鐵律,issue #6
# 規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
#
# 用法:bash sdd-active-check.sh [specs目錄]
# 參數 1(可選)=specs 目錄,預設 system-dev/docs/3-specs
#
# 行為:統計 status: active 的 design.mddesign.md 前 10 行有 ^status: active
# 排除 archive/ 與 TEMPLATE)——
# >1 份 → stderr 列出清單,exit 1(違反單一活性)
# ≤1 份 → exit 0
#
# pre-commit 掛法(.git/hooks/pre-commit,記得 chmod +x):
# #!/bin/sh
# bash system-dev/scripts/sdd-active-check.sh || exit 1
# CI 也是同一行,違反即紅。
#
# 誠實限制:與 sdd-guard.sh 同精神——只做語法層機械檢查,繞道可行但留痕可審,
# 不聲稱不可繞過。價值是「不變量被違反時一定有機器出聲」。
set -euo pipefail
SPECS_DIR="${1:-system-dev/docs/3-specs}"
# 沒有 specs 目錄(沒裝 SDD 模組)→ 無事可查,放行
[ -d "$SPECS_DIR" ] || exit 0
ACTIVE_COUNT=0
ACTIVE_LIST=""
while IFS= read -r f; do
[ -n "$f" ] || continue
if head -10 "$f" 2>/dev/null | grep -q '^status:[[:space:]]*active'; then
ACTIVE_COUNT=$((ACTIVE_COUNT + 1))
ACTIVE_LIST="${ACTIVE_LIST}${f}
"
fi
done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null)
if [ "$ACTIVE_COUNT" -gt 1 ]; then
cat >&2 <<EOF
🚫 SDD 單一活性鐵律違反:${SPECS_DIR}/ 下有 ${ACTIVE_COUNT} 份 status: active 的 SDD(任何時刻最多一份):
${ACTIVE_LIST}
請收斂到一份:其餘改 status: paused / closedclosed 且被取代者填 superseded_by 並移入 archive/)。
規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md。
EOF
exit 1
fi
exit 0
+3 -77
View File
@@ -23,7 +23,7 @@ esac
t() { if [ "$IS_ZH" = "yes" ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi; }
tn() { if [ "$IS_ZH" = "yes" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }
REPO_RAW="https://git.uncle6.me/Leo/system-dev-template/raw/branch/main"
REPO_RAW="https://raw.githubusercontent.com/uncle6me-web/system-dev-template/main"
TEMPLATE_URL="$REPO_RAW/template"
UPDATED=()
@@ -31,7 +31,6 @@ KEPT=()
NEW=()
TEMPLATED=()
MIGRATED=()
COEXIST=()
# ── 版本比對:先看本機 vs 遠端,給使用者「值不值得更新」的判斷 ──
# VERSION 新位置在 system-dev/,舊位置在 .claude/(1.8.x 以前)。優先讀新、回退舊。
@@ -83,12 +82,7 @@ migrate_dir() { # $1=舊路徑 $2=新路徑
local from="$1" to="$2"
[ -e "$from" ] || return 0 # 舊的不存在 → 無需遷移
if [ -e "$to" ]; then
# 目的地已存在。兩種可能:
# (a) 已遷移過 → 舊位置不該還在;冪等略過即可。
# (b) 用戶先 install 建了空殼 → 舊位置仍有真資料,現在「並存」。
# 不能靜默跳過 (b),也絕不自動合併(覆蓋風險)。→ 記為「並存待合併」,警告。
COEXIST+=("$from$to")
return 0
return 0 # 新的已存在 → 冪等略過,不覆蓋
fi
mkdir -p "$(dirname "$to")"
if mv "$from" "$to" 2>/dev/null; then
@@ -217,26 +211,15 @@ keep_with_template ".claude/hooks/pre-write-guard.sh" "$TEMPLATE_URL/.claude/hoo
update_file ".claude/commands/issue-handle.md" "$TEMPLATE_URL/.claude/commands/issue-handle.md"
update_file "system-dev/VERSION" "$TEMPLATE_URL/system-dev/VERSION"
# Logseq 任務 marker 解析(單一真相源):vault 萃取(/wiki-extract)與 tasks→Project
# 投影共用同一套;任一模組在用就補/更新(邏輯檔,可覆蓋)。
if $HAS_WIKI || $HAS_SDD; then
update_file "system-dev/docs/4-guides/logseq-markers.md" "$TEMPLATE_URL/system-dev/docs/4-guides/logseq-markers.md"
fi
if $HAS_WIKI; then
# wiki 的「邏輯檔」:導航與 hooks,可覆蓋。wiki 資料在 system-dev/hooks/commands 留 .claude/。
update_file "system-dev/wiki/INDEX.md" "$TEMPLATE_URL/system-dev/wiki/INDEX.md"
update_file ".claude/hooks/session-start-recall.sh" "$TEMPLATE_URL/.claude/hooks/session-start-recall.sh"
update_file ".claude/hooks/wiki-secret-scan.sh" "$TEMPLATE_URL/.claude/hooks/wiki-secret-scan.sh"
# 1.16.0:讓 wiki 真的被讀到的兩支(開場 push 全文解決不了「只讀開頭」,見 CHANGELOG)
update_file ".claude/hooks/wiki-first-search.sh" "$TEMPLATE_URL/.claude/hooks/wiki-first-search.sh"
update_file ".claude/hooks/subagent-wiki-guard.sh" "$TEMPLATE_URL/.claude/hooks/subagent-wiki-guard.sh"
update_file ".claude/commands/wiki-init.md" "$TEMPLATE_URL/.claude/commands/wiki-init.md"
update_file ".claude/commands/wiki-capture.md" "$TEMPLATE_URL/.claude/commands/wiki-capture.md"
update_file ".claude/commands/wiki-update.md" "$TEMPLATE_URL/.claude/commands/wiki-update.md"
update_file ".claude/commands/wiki-recall.md" "$TEMPLATE_URL/.claude/commands/wiki-recall.md"
# vault 增量萃取(Logseq/Obsidian → system-dev/wiki,冪等):邏輯檔,可覆蓋。舊版沒有 → 當新檔補。
update_file ".claude/commands/wiki-extract.md" "$TEMPLATE_URL/.claude/commands/wiki-extract.md"
# Coworkclaude.ai)的 wiki 整理 skill:規則檔,可覆蓋
update_file "system-dev/docs/SKILL.md" "$TEMPLATE_URL/system-dev/docs/SKILL.md"
@@ -257,18 +240,6 @@ if $HAS_SDD; then
update_file "system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md" "$TEMPLATE_URL/system-dev/docs/2-architecture/decisions/TEMPLATE-adr.md"
update_file ".claude/commands/sdd-check.md" "$TEMPLATE_URL/.claude/commands/sdd-check.md"
update_file ".claude/hooks/sdd-guard.sh" "$TEMPLATE_URL/.claude/hooks/sdd-guard.sh"
# SDD 生命週期鐵律(1.14,issue #6):規則檔+獨立檢查腳本=邏輯檔可覆蓋;
# pending-changes.md 裝著用戶的 proposal=用戶資料,只補不覆蓋。
# issue #13 教訓:update 不補新檔會造成結構斷層,新檔必須在這裡鋪。)
update_file "system-dev/docs/3-specs/SDD-LIFECYCLE.md" "$TEMPLATE_URL/system-dev/docs/3-specs/SDD-LIFECYCLE.md"
add_if_missing "system-dev/docs/3-specs/pending-changes.md" "$TEMPLATE_URL/system-dev/docs/3-specs/pending-changes.md"
update_file "system-dev/scripts/sdd-active-check.sh" "$TEMPLATE_URL/scripts/sdd-active-check.sh"
# tasks⇄Project 投影(issue #16):邏輯檔,可覆蓋。舊版沒有 → add_if_missing 補。
# 啟用狀態存遠端(acr push),不在這些檔裡,覆蓋不會關掉誰的同步。
add_if_missing "system-dev/workflows/tasks-project-sync.yaml" "$TEMPLATE_URL/system-dev/workflows/tasks-project-sync.yaml"
add_if_missing "system-dev/workflows/tasks-project-sync.local.sh" "$TEMPLATE_URL/system-dev/workflows/tasks-project-sync.local.sh"
fi
# ── 自我更新:把最新的 update.sh / install.sh 抓到 system-dev/scripts/ ──
@@ -276,7 +247,7 @@ fi
update_file "system-dev/scripts/update.sh" "$REPO_RAW/scripts/update.sh"
update_file "system-dev/scripts/install.sh" "$REPO_RAW/scripts/install.sh"
chmod +x .claude/hooks/*.sh system-dev/scripts/*.sh system-dev/workflows/*.sh 2>/dev/null || true
chmod +x .claude/hooks/*.sh system-dev/scripts/*.sh 2>/dev/null || true
# ── 使用者資料檔:絕不碰,但提醒「設定可能有新欄位要手動補」──
keep_file ".claude/settings.json"
@@ -290,18 +261,6 @@ if [ ${#MIGRATED[@]} -gt 0 ]; then
t "📦 結構遷移(已收進 system-dev/):" "📦 Layout migrated (moved into system-dev/):"
for f in "${MIGRATED[@]}"; do echo "$f"; done
fi
if [ ${#COEXIST[@]} -gt 0 ]; then
echo ""
t "🛑 偵測到 wiki 並存(新舊位置都有資料,需要合併):" \
"🛑 Coexisting wiki detected (both old and new locations have data — needs merging):"
for f in "${COEXIST[@]}"; do echo "$f"; done
t " 成因:先跑過 install(建了空殼)才遷移,舊位置真資料沒被搬。" \
" Cause: install ran first (created an empty shell), so migration skipped your real data in the old location."
t " 不自動合併(避免覆蓋你的資料)。請叫你的 CC:" \
" Not auto-merged (to avoid overwriting your data). Ask your CC:"
t " 「.claude/wiki/ 和 system-dev/wiki/ 並存,請逐檔比對、把真資料合進 system-dev/,再刪舊的」" \
" \"There are two wikis (.claude/wiki/ and system-dev/wiki/) — diff each file, merge the real data into system-dev/, then delete the old one.\""
fi
if [ ${#NEW[@]} -gt 0 ]; then
echo ""
t "🆕 新功能(舊版沒有,已加入):" "🆕 New features (absent in the old version, now added):"
@@ -340,39 +299,6 @@ if [ -f ".claude/settings.json" ]; then
$HAS_WIKI && ! grep -q "session-start-recall.sh" .claude/settings.json && MISSING+=("SessionStart: session-start-recall.sh")
$HAS_WIKI && ! grep -q "wiki-secret-scan.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): wiki-secret-scan.sh")
$HAS_SDD && ! grep -q "sdd-guard.sh" .claude/settings.json && MISSING+=("PreToolUse(Write|Edit): sdd-guard.sh")
# ── 1.16.0:兩支 wiki 讀取 hook 自動註冊(不只提醒)──
# 理由:這兩支的整個存在意義就是「不依賴任何人記得」。
# 若靠人看提醒去手動補 settings.json,等於把同一個病搬到安裝環節。
if $HAS_WIKI && command -v python3 >/dev/null 2>&1; then
python3 - <<'PYEOF' 2>/dev/null || true
import json, os
p = ".claude/settings.json"
try:
with open(p) as f: d = json.load(f)
except Exception:
raise SystemExit(0) # 壞掉的 settings 不碰,交給下方提醒
pre = d.setdefault("hooks", {}).setdefault("PreToolUse", [])
blob = json.dumps(pre)
added = []
# 1.16.1:既有註冊若漏 Bash(原版只掛 Grep|Glob|Read)就地補上——
# 破口實例:用 curl/wrangler 亂試部署方法走 Bash,整支 hook 不觸發。
for _e in pre:
if "wiki-first-search" in json.dumps(_e) and "Bash" not in _e.get("matcher", ""):
_e["matcher"] = "Grep|Glob|Read|Bash"; added.append("wiki-first-search(補Bash)")
if "wiki-first-search" not in blob:
pre.append({"matcher": "Grep|Glob|Read|Bash", "hooks": [
{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/wiki-first-search.sh"}]})
added.append("wiki-first-search")
if "subagent-wiki-guard" not in blob:
pre.append({"matcher": "Task", "hooks": [
{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-wiki-guard.sh"}]})
added.append("subagent-wiki-guard")
if added:
with open(p, "w") as f: json.dump(d, f, ensure_ascii=False, indent=2)
print(" ✅ 已自動註冊 wiki 讀取 hook" + "、".join(added))
PYEOF
fi
if [ ${#MISSING[@]} -gt 0 ]; then
echo ""
t "📌 settings.json 是你的設定(沒動),但偵測到缺以下 hook,請手動補上:" \
+25 -4
View File
@@ -39,6 +39,21 @@
INDEX 是**所有檢索角度的入口**,不只標籤。原文是唯讀 SSoT,wiki 是改寫過的記憶。
新增角度只要在這裡加一節(如「決策角度」「原則角度」),指向對應 cards 或 push 檔——**不必新增實體特殊檔**。
### 快速導航(本專案速覽)
**這個專案是什麼**KBDB-graph —— KBDB 的 graph 插件(triplet 採集 + graph 查詢),類比 Apache AGE 之於 Postgres。已抽成獨立 public repo `uncle6me-web/kbdb-graph-plugin`(leo 產權)。基本盤(block CRUD,D1 三表)在 `arcrun/kbdb`,不在這。
**動工前必讀**
- `docs/HANDOFF-kbdb-plugin.md` —— 本目錄專屬交棒。
- 上游約束見 `CLAUDE.md` 最頂 + `github.com/uncle6me-web/InkStoneCo`
**文件去哪找**
- SDDdesign+tasks)→ `docs/3-specs/`(現有:kbdb-graph-extraction、blocks-edit-api、plugin-install、arcrun-key-auth
- 歷史記錄 / bug 復盤 → `docs/5-records/`PATCH 403 bug、upsert feature request
- 分類規則全表 → `docs/README.md`
**絕對限制**:本目錄只做 graph 插件 / **API-as-Wall(插件絕不碰表、零 SQL、零 migration、零建表)** / 部署繞開 GitHub、禁跨 repo Actions / 樂高法(actions < 100 行)。
### 標籤角度(按 `TAXONOMY.md` 的軸聚類,指向桶子索引)
```markdown
@@ -49,11 +64,17 @@ INDEX 是**所有檢索角度的入口**,不只標籤。原文是唯讀 SSoT
- [[ai/00-INDEX]] — AI 協作(M 卡)
```
### 決策角度(取代舊 decisions-summary.md 的視圖
(尚未建 cards,現有決策見下「決策角度」。
```markdown
- [[某決策卡]] — 一句話結論(YYYY-MM-DD
```
### 決策角度(取代舊 decisions-summary.md 的視圖;完整脈絡見 `decisions-summary.md` + `docs/2-architecture/decisions/`
- **KBDB-graph 定位**2026-06-13)— 本 repo = KBDB 的 graph 插件,獨立成 repo,類比 AGE 之於 Postgres。
- **🔒 KBDB 鐵律 + API-as-Wall**2026-06-14,最高原則)— 插件絕不碰表、零 SQL、零 migration,讀寫全走基本盤 HTTP API;新類型=建 template+填 slot,永不建表。
- **獨立 repo 名**2026-06-14)— public `uncle6me-web/kbdb-graph-plugin`,無 Actions。
- **掛載介面 = 基本盤 API(非共用 D1)**2026-06-14,推翻原判斷)— 圖在插件層記憶體從 records 組裝,不直接 SQL、不建 VIEW。
- **安裝契約:KBDB_BASE_URL 安裝時 AI 填**2026-06-14)— AI 查 CF subdomain 拼 URL → `wrangler secret put` + `deploy`;本地測試用 `.dev.vars`
- **~~萬物皆 Blockv3~~**2026-02-28 提出,2026-06-14 淘汰)— 帶獨立 blocks 表的「v3」是違規殘留已刪;基本盤真身 = arcrun/kbdb 3 表。
- **避免再被 GitHub flag**(上游鐵律)— 禁跨 repo 自動同步 Actions;部署繞開 GitHub。
> 結構:INDEX(多角度入口)→ `cards/<bucket>/00-INDEX.md`(桶子索引,固定名)→ 概念原子卡。
> 指 `00-INDEX` **一律帶路徑** `[[bucket/00-INDEX]]`(固定名跨桶撞名);卡片間用裸 `[[卡名]]`。
+1 -13
View File
@@ -1,22 +1,10 @@
# 當前狀態
> 更新時間:2026-07-17
> 更新時間:2026-06-26
> 每次 session 結束必須更新此檔(用 /wiki-update)。
---
## 最新(2026-07-17SDD 生命週期鐵律遷移 + SDD 位置統一)
leo 2026-07-17 拍板全體系「單一活性 SDD」制度(canonicalsystem-dev-template v1.15.0),本 repo 已遷移:
1. **位置統一**:舊 `docs/3-specs/` 的 5 份 SDD 全數 `git mv``system-dev/docs/3-specs/`,舊位置留 README 指針。
2. **狀態判定****0 份 active**(無現行開發——實作面 2026-06-26 已收斂,剩跨 repo 接通,合法)。三份 `paused`ingest-contractkbdb-graph-extractionplugin-install(等基本盤部署/跨 repo)。兩份 `closed``archive/`arcrun-key-auth(舊 KBDB 時代死件)、blocks-edit-api(舊 blocks 表架構死件,已被 Arcrun #6 取代)。
3. **鋪檔**SDD-LIFECYCLE.mdpending-changes.mdsdd-guard.sh(新版,掛進 settings.json PreToolUse Write|Edit)/sdd-check.mdsdd-active-check.sh。
4. **驗證**sdd-active-check exit 0guard pipe-test——0 active 下寫 code 檔擋(exit 2)、寫 md 放行。
5. **CLAUDE.md**:加 SDD 鐵律段(濃縮五條+0-active 註明「重啟開發先挑一份升 active」),並修正遷移後的舊路徑指針(wiki 路徑、SDD 路徑)。
---
## 最新(2026-06-26issue #1 補對齊 — receiver Zod 追上 contractPR #3 已 merge
[PR #3](https://github.com/uncle6me-web/kbdb-graph-plugin/pull/3) 已 merge 進 maincommit `13db97b`)。對應 [issue #1](https://github.com/uncle6me-web/kbdb-graph-plugin/issues/1) 總管補對齊 comment。
+20 -8
View File
@@ -5,17 +5,29 @@ import { normalizeEntity } from '../src/actions/entity-normalize';
import { mockClient } from './mock-client';
describe('entity-crud', () => {
it('建立後可 exact 查回(大小寫不敏感)', async () => {
it('建立後可 exact 查回(大小寫不敏感)owner slot 帶真 owner', async () => {
const c = mockClient();
await createEntity(c, 'InkStone');
const found = await findEntityByName(c, 'inkstone');
const ent = await createEntity(c, 'InkStone', 'leo');
const found = await findEntityByName(c, 'inkstone', 'leo');
expect(found?.canonical).toBe('InkStone');
// owner 落底:record 掛在 leo 名下(非無主),用錯 owner 查不到。
const rec = await c.getRecord(ent.id);
expect(rec?.values.owner).toBe('leo');
expect(await findEntityByName(c, 'inkstone', 'someone-else')).toBeNull();
});
it('漏 owner → 插件端 fail(不寫出無主 entity', async () => {
const c = mockClient();
await expect(
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
createEntity(c, 'NoOwner'),
).rejects.toThrow(/owner/i);
});
it('listEntities 列出', async () => {
const c = mockClient();
await createEntity(c, 'A');
await createEntity(c, 'B');
await createEntity(c, 'A', 'leo');
await createEntity(c, 'B', 'leo');
const all = await listEntities(c);
expect(all.map((e) => e.canonical).sort()).toEqual(['A', 'B']);
});
@@ -24,8 +36,8 @@ describe('entity-crud', () => {
describe('normalizeEntity', () => {
it('已存在回 canonical,不存在建新回原值', async () => {
const c = mockClient();
await createEntity(c, 'InkStone');
expect(await normalizeEntity(c, 'INKSTONE')).toBe('InkStone');
expect(await normalizeEntity(c, '新公司')).toBe('新公司');
await createEntity(c, 'InkStone', 'leo');
expect(await normalizeEntity(c, 'INKSTONE', 'leo')).toBe('InkStone');
expect(await normalizeEntity(c, '新公司', 'leo')).toBe('新公司');
});
});
+120
View File
@@ -0,0 +1,120 @@
// node gloss → embeddable base entry:走 mock KbdbClientAPI-as-Wall),零 SQL、不打網路。
// 覆蓋:前向 persistNodes 多落 embeddable entry / embed=false 不落 / 空 gloss 不落 / 冪等不重複 /
// gloss 變則更新 / backfill 對既有 entity records 補 entry(含冪等、embed=false 略過)。
import { describe, it, expect } from 'vitest';
import { persistNodes } from '../src/actions/node-persist';
import { backfillGlossEntries } from '../src/actions/backfill-gloss-entries';
import { NODE_GLOSS_ENTRY_TYPE } from '../src/actions/gloss-entry';
import { TPL_ENTITY } from '../src/lib/templates';
import { mockClient } from './mock-client';
const glossEntries = (c: ReturnType<typeof mockClient>) =>
c.listEntries({ entry_type: NODE_GLOSS_ENTRY_TYPE });
describe('persistNodes — 前向:每個 node 另落一筆 embeddable base entry', () => {
it('有 gloss 的 node → 落 entrycontent=名+gloss、metadata.embed=true、帶 source/node_id', async () => {
const c = mockClient();
await persistNodes(
c,
[{ name: '系統動力學', id: 'sd.md', gloss: '用因果圖表解複雜系統的方法論' }],
'leo21c',
'github:uncle6me-web/wiki@sd.md',
);
const entries = await glossEntries(c);
expect(entries.length).toBe(1);
const e = entries[0];
expect(e.content).toBe('系統動力學:用因果圖表解複雜系統的方法論');
expect(e.page_name).toBe('gloss:sd.md');
expect(e.owner_id).toBe('leo21c');
const meta = JSON.parse(e.metadata_json!);
expect(meta.embed).toBe(true); // 關鍵:base embed 模組讀這個
expect(meta.source).toBe('github:uncle6me-web/wiki@sd.md');
expect(meta.node_id).toBe('sd.md');
expect(meta.canonical).toBe('系統動力學');
// entity record 仍照舊存在(兩者並存:record 給圖、entry 給 embed)。
const recs = await c.listRecordsByTemplate(TPL_ENTITY);
expect(recs.find((r) => r.values.canonical === '系統動力學')).toBeTruthy();
});
it('embed=false 的 node → 不落 embeddable entry', async () => {
const c = mockClient();
await persistNodes(c, [{ name: '黃仁勳', id: '黃仁勳', gloss: 'NVIDIA 創辦人', embed: false }], 'leo21c');
expect((await glossEntries(c)).length).toBe(0);
});
it('空 gloss 的 node → 不落 embeddable entry(不造無意義空殼)', async () => {
const c = mockClient();
await persistNodes(c, [{ name: '某卡', id: 'card.md' }], 'leo21c');
expect((await glossEntries(c)).length).toBe(0);
});
it('冪等:同一 node 重複 ingest → 只一筆 embeddable entry', async () => {
const c = mockClient();
const nodes = [{ name: '長壽', id: 'longevity', gloss: '活得更久本身具有價值' }];
await persistNodes(c, nodes, 'leo21c', 'u1');
await persistNodes(c, nodes, 'leo21c', 'u1'); // 再送一次
expect((await glossEntries(c)).length).toBe(1);
});
it('gloss 變更 → 更新既有 entry 內容(仍只一筆)', async () => {
const c = mockClient();
await persistNodes(c, [{ name: '長壽', id: 'longevity', gloss: '舊描述' }], 'leo21c');
await persistNodes(c, [{ name: '長壽', id: 'longevity', gloss: '活得更久本身具有價值' }], 'leo21c');
const entries = await glossEntries(c);
expect(entries.length).toBe(1);
expect(entries[0].content).toBe('長壽:活得更久本身具有價值');
});
});
describe('backfillGlossEntries — 存量:對既有 entity records 補 embeddable entry', () => {
// 直接以 entity record 造存量(模擬既有 16 筆),不經前向路徑。
async function seedEntity(
c: ReturnType<typeof mockClient>,
v: { canonical: string; node_id?: string; gloss?: string; embed?: string },
) {
await c.createRecord(TPL_ENTITY, {
canonical: v.canonical,
node_id: v.node_id ?? '',
aliases_json: '[]',
entity_type: '',
gloss: v.gloss ?? '',
embed: v.embed ?? 'true',
owner: '', // slot 存量 owner=None(模擬 D28 前的無主 record);backfill 的 owner 由 caller 明確指定
}, 'leo21c'); // 記錄層 owner_idbase D28 後 createRecord 一律要 ownercaller 明指
}
it('有 gloss 的既有 record → 落 entryembed=false / 空 gloss → 略過', async () => {
const c = mockClient();
await seedEntity(c, { canonical: '長壽', node_id: 'longevity', gloss: '活得更久本身具有價值' });
await seedEntity(c, { canonical: '系統動力學', node_id: 'sd', gloss: '用因果圖表解複雜系統的方法論' });
await seedEntity(c, { canonical: '無標', node_id: 'x', gloss: '不該嵌', embed: 'false' });
await seedEntity(c, { canonical: '空描述', node_id: 'y' }); // 空 gloss
const res = await backfillGlossEntries(c, 'leo21c');
expect(res.scanned).toBe(4);
expect(res.created).toBe(2);
expect(res.skipped).toBe(2); // embed=false + 空 gloss
const entries = await glossEntries(c);
expect(entries.length).toBe(2);
expect(entries.every((e) => JSON.parse(e.metadata_json!).embed === true)).toBe(true);
expect(entries.map((e) => e.content).sort()).toEqual(
['系統動力學:用因果圖表解複雜系統的方法論', '長壽:活得更久本身具有價值'].sort(),
);
});
it('冪等:連跑兩次 backfill → 不造重複(第二次全 unchanged', async () => {
const c = mockClient();
await seedEntity(c, { canonical: '長壽', node_id: 'longevity', gloss: '活得更久本身具有價值' });
const first = await backfillGlossEntries(c, 'leo21c');
expect(first.created).toBe(1);
const second = await backfillGlossEntries(c, 'leo21c');
expect(second.created).toBe(0);
expect(second.unchanged).toBe(1);
expect((await glossEntries(c)).length).toBe(1);
});
});
+3 -3
View File
@@ -12,7 +12,7 @@ describe('getSource — 回節點的原文來源指標', () => {
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#graph-rag' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'GraphRAG', predicate: '是', object: 'RAG 變體' }],
});
}, 'leo');
const refs = await getSource(c, 'GraphRAG');
expect(refs.length).toBe(1);
@@ -27,12 +27,12 @@ describe('getSource — 回節點的原文來源指標', () => {
source: { uri: 'github:u/w@a.md', content_hash: 'h1', anchor: '#old' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'X', predicate: 'r', object: 'old' }],
});
}, 'leo');
await ingestEnvelope(c, {
source: { uri: 'github:u/w@a.md', content_hash: 'h2', anchor: '#new' },
extractor: { model: 'm', tier: 'deep' },
triplets: [{ subject: 'X', predicate: 'r', object: 'new' }],
});
}, 'leo');
const refs = await getSource(c, 'X');
expect(refs.length).toBe(1);
+4 -4
View File
@@ -7,10 +7,10 @@ import { mockClient } from './mock-client';
import type { KbdbClient } from '../src/lib/kbdb-client';
async function seed(c: KbdbClient) {
// A — B — CD 孤立連 A
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B' });
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C' });
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D' });
// A — B — CD 孤立連 A(owner 必經:所有寫入帶真 owner)
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'B', owner_id: 'leo' });
await createTriplet(c, { subject: 'B', predicate: 'r', object: 'C', owner_id: 'leo' });
await createTriplet(c, { subject: 'A', predicate: 'r', object: 'D', owner_id: 'leo' });
}
describe('graph-nodes', () => {
+20 -2
View File
@@ -15,8 +15,19 @@ export class MockKbdbClient {
}
async createEntry(input: any): Promise<BaseEntry> {
// 對齊基本盤 D27/D28 即將上線的 owner 必填:缺 owner → 400(模擬 base 擋死無主寫入)。
if (!(input?.owner_id ?? '').trim()) throw new Error('[kbdb-base] POST /entries: owner_id required (400)');
const id = this.id('entry');
const entry: BaseEntry = { id, content: input.content ?? null, entry_type: input.entry_type, owner_id: input.owner_id ?? null };
// 對齊 base entry-crud:保留 page_nameidempotency key)、metadata_jsonembed 打標)、parent_id。
const entry: BaseEntry = {
id,
content: input.content ?? null,
entry_type: input.entry_type,
owner_id: input.owner_id ?? null,
parent_id: input.parent_id ?? null,
page_name: input.page_name ?? null,
metadata_json: input.metadata_json ?? null,
};
this.entries.set(id, entry);
return entry;
}
@@ -26,8 +37,13 @@ export class MockKbdbClient {
}
async listEntries(filters: any = {}): Promise<BaseEntry[]> {
// 對齊 base entry-crud listEntries 的 exact-match filters(含 page_name idempotency key、parent_id)。
return [...this.entries.values()].filter(
(e) => (!filters.entry_type || e.entry_type === filters.entry_type) && (!filters.owner_id || e.owner_id === filters.owner_id),
(e) =>
(!filters.entry_type || e.entry_type === filters.entry_type) &&
(!filters.owner_id || e.owner_id === filters.owner_id) &&
(!filters.parent_id || e.parent_id === filters.parent_id) &&
(!filters.page_name || e.page_name === filters.page_name),
);
}
@@ -61,6 +77,8 @@ export class MockKbdbClient {
}
async createRecord(template: string, values: Record<string, string>, owner_id?: string): Promise<string> {
// 對齊基本盤 D27/D28 即將上線的 owner 必填:缺 owner → 400(模擬 base 擋死無主寫入)。
if (!(owner_id ?? '').trim()) throw new Error('[kbdb-base] POST /records: owner_id required (400)');
const id = this.id('rec');
this.records.set(id, { template, values: { ...values }, owner_id });
return id;
+14 -4
View File
@@ -6,7 +6,7 @@ import { mockClient } from './mock-client';
describe('createTriplet → records API', () => {
it('建立後可由 id 取回', async () => {
const c = mockClient();
const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS' });
const r = await createTriplet(c, { subject: 'InkStone', predicate: '是', object: '創業 OS', owner_id: 'leo' });
expect(r.id).toBeDefined();
expect(r.subject).toBe('InkStone');
@@ -14,13 +14,23 @@ describe('createTriplet → records API', () => {
expect(got?.predicate).toBe('是');
expect(got?.object).toBe('創業 OS');
});
it('漏 owner → 插件端 fail(不靜默送空,不寫出無主 triplet)', async () => {
const c = mockClient();
await expect(
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
createTriplet(c, { subject: 'X', predicate: 'p', object: 'Y' }),
).rejects.toThrow(/owner/i);
const { count } = await queryTriplets(c, { includeDeprecated: true });
expect(count).toBe(0);
});
});
describe('queryTriplets → 插件層 filter', () => {
it('by subject 過濾', async () => {
const c = mockClient();
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1' });
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X' });
await createTriplet(c, { subject: 'KBDB', predicate: '使用', object: 'D1', owner_id: 'leo' });
await createTriplet(c, { subject: 'Other', predicate: '使用', object: 'X', owner_id: 'leo' });
const { triplets, count } = await queryTriplets(c, { subject: 'KBDB' });
expect(count).toBe(1);
@@ -29,7 +39,7 @@ describe('queryTriplets → 插件層 filter', () => {
it('limit/offset 分頁', async () => {
const c = mockClient();
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o' });
for (let i = 0; i < 5; i++) await createTriplet(c, { subject: `s${i}`, predicate: 'p', object: 'o', owner_id: 'leo' });
const { triplets } = await queryTriplets(c, { limit: 2, offset: 1 });
expect(triplets.length).toBe(2);
});
+47 -9
View File
@@ -1,10 +1,13 @@
// ingest 寫入端 — 走 mock KbdbClientAPI-as-Wall),零 SQL、不打網路。
// 覆蓋 T3.4 五案:正常 envelope / 同 hash no-op / 新 hash deprecate / 污染 envelope 422 / rollback。
// + owner 必經(D27/D28):漏 owner→插件端 fail;真 owner 一路 thread 進 triplet + node 寫入。
import { describe, it, expect } from 'vitest';
import { ingestEnvelope, IngestEnvelopeSchema, type IngestEnvelope } from '../src/actions/triplet-ingest';
import { queryTriplets } from '../src/actions/triplet-crud';
import { mockClient } from './mock-client';
const OWNER = 'leo';
function envelope(hash: string, triplets: IngestEnvelope['triplets']): IngestEnvelope {
return {
source: { uri: 'github:uncle6me-web/wiki@a.md', content_hash: hash },
@@ -19,7 +22,7 @@ describe('ingestEnvelope — 正常 envelope', () => {
const res = await ingestEnvelope(c, envelope('h1', [
{ subject: 'A', predicate: 'rel', object: 'B' },
{ subject: 'B', predicate: 'rel', object: 'C' },
]));
]), OWNER);
expect(res).toEqual({ skipped: false, ingested: 2, deprecated: 0 });
const { triplets } = await queryTriplets(c, {});
@@ -30,11 +33,46 @@ describe('ingestEnvelope — 正常 envelope', () => {
});
});
describe('ingestEnvelope — owner 必經(D27/D28', () => {
it('漏 owner → 插件端 fail(不靜默送空,不寫出無主 triplet)', async () => {
const c = mockClient();
await expect(
// @ts-expect-error 蓄意漏 owner:型別上 owner 必填,執行期也應 throw
ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }])),
).rejects.toThrow(/owner/i);
// fail loud:一筆都不該寫進去(第一筆寫入即擋)。
const { triplets } = await queryTriplets(c, {});
expect(triplets.length).toBe(0);
});
it('真 owner 一路 thread 到 triplet + node 寫入(owner slot 非空、record owner_id=真 owner', async () => {
const c = mockClient();
const env: IngestEnvelope = {
source: { uri: 'github:uncle6me-web/wiki@own.md', content_hash: 'ho' },
extractor: { model: 'm', tier: 'deep' },
nodes: [{ name: 'Graph RAG', id: 'graph-rag.md', gloss: '關係遍歷檢索' }],
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
};
await ingestEnvelope(c, env, OWNER);
// triplet record 帶真 owner。
const triplets = await c.listRecordsByTemplate('triplet', OWNER);
expect(triplets.length).toBe(1);
// node → entity record 帶真 ownerowner slot + record owner_id 皆為 leo,非 None/空)。
const entities = await c.listRecordsByTemplate('entity', OWNER);
const gr = entities.find((e) => e.values.canonical === 'Graph RAG');
expect(gr).toBeDefined();
expect(gr!.values.owner).toBe(OWNER);
// 用錯 owner 查 → 濾掉(證明 record 確實掛在 leo 名下,非無主)。
expect((await c.listRecordsByTemplate('entity', 'someone-else')).length).toBe(0);
});
});
describe('ingestEnvelope — 同 hash no-op', () => {
it('同 uri+hash 再送 → skipped,不新增', async () => {
const c = mockClient();
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]));
const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]));
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]), OWNER);
const res = await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'B' }]), OWNER);
expect(res.skipped).toBe(true);
const { triplets } = await queryTriplets(c, {});
@@ -45,8 +83,8 @@ describe('ingestEnvelope — 同 hash no-op', () => {
describe('ingestEnvelope — 新 hash deprecate-then-append', () => {
it('同 uri 新 hash → 舊批轉 deprecated、新批 active;查詢 active-only', async () => {
const c = mockClient();
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]), OWNER);
const res = await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 1 });
// active-only 查詢只見新批。
@@ -103,7 +141,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
expect(IngestEnvelopeSchema.safeParse(env).success).toBe(true);
// 落地:triplet 寫入、node 打標存進 entity slot。
const res = await ingestEnvelope(c, env);
const res = await ingestEnvelope(c, env, OWNER);
expect(res).toEqual({ skipped: false, ingested: 1, deprecated: 0 });
const { triplets } = await queryTriplets(c, {});
@@ -131,7 +169,7 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
],
triplets: [{ subject: 'Graph RAG', predicate: 'r', object: 'X' }],
};
await ingestEnvelope(c, env);
await ingestEnvelope(c, env, OWNER);
const entities = await c.listRecordsByTemplate('entity');
expect(entities.filter((e) => e.values.node_id === 'graph-rag.md').length).toBe(1);
});
@@ -160,8 +198,8 @@ describe('ingestEnvelope — 向量化打標欄位(contract 升格,ingest#1
describe('ingestEnvelope — rollback(翻回 status', () => {
it('把 deprecated 翻回 active 後,active 查詢重新見到它', async () => {
const c = mockClient();
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]));
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]));
await ingestEnvelope(c, envelope('h1', [{ subject: 'A', predicate: 'r', object: 'old' }]), OWNER);
await ingestEnvelope(c, envelope('h2', [{ subject: 'A', predicate: 'r', object: 'new' }]), OWNER);
// 取出被 deprecate 的舊批 id,手動 rollback(翻回 active、清 superseded_by)。
const all = await queryTriplets(c, { includeDeprecated: true });