4 Commits

Author SHA1 Message Date
Leo 4137043be2 chore(sdd): SDD 生命週期鐵律遷移(canonical template v1.15.0)
- 鋪檔 SDD-LIFECYCLE.md / pending-changes.md / 新版 sdd-guard.sh / sdd-check.md / sdd-active-check.sh
- ingest-pipeline design.md 掛 status: paused(實作 18/19 完成、部署收尾懸置、無現行開發)→ 0 active 合法
- CLAUDE.md 加 SDD 鐵律段(濃縮五條+現況註記)
- wiki status 更新+記 PR #3 src 管線只在 github-dead/main 的斷層

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:05:49 +08:00
Leo 6500d7fda4 merge: template 1.9.x 遷移分支併入 main(PR #4 收尾,HANDOFF 檔隨遷移搬進 system-dev/docs/)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:03:57 +08:00
Leo fe9fbe1dab feat(cli): 路徑 B 裸原文萃取 + ANTHROPIC_API_KEY 直打 API(整分庫完整 ingest)
首版只做路徑 A(3 張策展卡)。本版加:
- 路徑 B:掃 journals/*.md、pages/*.md 原始筆記,Haiku 精耕(gloss)+萃三元組
  → 對齊 design §1 路徑 B,這是「不再只 3 卡、整 vault 完整 ingest」所需的線。
- callHaiku() 抽象:有 ANTHROPIC_API_KEY 直打 Anthropic Messages API(正式型態,
  不借 CC session);缺 key fallback `claude -p --model haiku` 並在 stderr 明確
  警告非正式型態(誠實,不假裝正式)。
- 空 Logseq 筆記(僅「-」)自動跳過;彙總印 processed/skipped/ingested 統計。

實測(總管 2026-07-05,notes 全庫):3 卡冪等跳過 + 07_02 3 三元組入庫 +
07_01 11 三元組入庫(total 17→31)。撞牆記於 mira#1:07_01 的 node gloss 層因
「Too many subrequests by single Worker invocation」在 persistNodes 未寫入
(三元組層在該步之前已落地,未造假、未覆蓋)。
2026-07-05 07:57:00 +00:00
Claude 773e382141 feat(cli): 最小可行 ingest CLI — walking skeleton 補跑首版(T-kb-skeleton②)
repo clone 下來是純 SDD 骨架(tasks.md T0.5~T5 全未開始,零程式碼)。這支
scripts/ingest-cli.mjs 是打穿「Leo/notes → Haiku 萃取 triples → POST
kbdb-graph-plugin /triplets/ingest」這條線的最小版本,走路徑 A 簡化版
(拉之前 cloud-worker 精耕好的 wiki 卡,而非裸 journal 原文)。

Haiku 呼叫走 `claude -p --model haiku` CLI 子行程(沙盒無 ANTHROPIC_API_KEY,
用已登入 CC session 授權繞過,仍是真 Haiku 推論,非直打 API — 細節見
docs/HANDOFF-cloud-worker-2026-07-03.md)。

實測對 3 張卡跑過,2 張乾淨端到端成功(curl 驗證見 HANDOFF),1 張因
第一輪跑批次時 180s client timeout 中途砍掉,留下 2/7 的半殘資料 +
暴露一個真實設計坑:POST /triplets/ingest 非原子、幂等 dedup 用
content_hash 比對會讓半殘狀態被永久當「已處理」跳過,不會自動補完。
沒有為了好看而重送覆蓋或補假資料,半殘狀態原樣留著當證據。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 09:10:19 +00:00
10 changed files with 644 additions and 28 deletions
+20
View File
@@ -4,6 +4,26 @@
---
## 生命週期(單一活性鐵律,全文見 `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)。
---
## 執行流程
### 第一步:理解任務
+82 -14
View File
@@ -1,10 +1,16 @@
#!/bin/bash
# PreToolUse hook — 動 code 前檢查有沒有對應 SDD
# PreToolUse hook — 動 code 前檢查 SDD 單一活性 SDD 鐵律(issue #6
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
#
# 掛在 settings.json 的 PreToolUsematcher: Write|Edit)。
# stdin 收到 JSON{ tool_name, tool_input: { file_path, ... } }
# 行為:動到 code 檔(.ts/.go/...)但 system-dev/docs/3-specs/ 下沒有任何 SDD → 警告(exit 2 擋)。
# 行為:
# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2),
# 先收斂到一份再說。
# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。
# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度)
# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。
#
# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。
# 藏在 helper 裡、用 bash 繞道的改動擋不到。
@@ -24,6 +30,42 @@ 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) ;;
@@ -36,28 +78,54 @@ case "$FILE_PATH" in
*_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
# ── 向下相容:整個 3-specs 沒有任何帶 frontmatter 的 design.md ──
# =老 repo 還沒遷移生命週期制度 → 退回舊行為(有 design.md 就放行+提醒),
# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。
if [ "$FM_COUNT" -eq 0 ]; then
SDD_COUNT=0
if [ -d "$SPECS_DIR" ]; then
SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ')
fi
if [ "$SDD_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 system-dev/docs/3-specs/ 下找不到任何 SDD。
if [ "$SDD_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下找不到任何 SDD。
絕對鐵律:任何 code 變動前必須有對應 SDDdesign.md
絕對鐵律:任何 code 變動前必須有對應 SDDdesign.md,且遵守單一活性生命週期
system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
請先:
1. 確認這個改動屬於哪個子系統
2. 在 system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助)
2. 在 ${SPECS_DIR}/[子系統]/ 建立 design.md(可用 /sdd-check 協助)frontmatter 標 status: active
3. 在回覆開頭宣告已讀 SDD + 對應 task
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
EOF
exit 2
fi
# 舊行為放行 + 提醒遷移(stderr 警告,不擋)
echo "📋 提醒:${SPECS_DIR}/ 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
exit 0
fi
# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ──
if [ "$ACTIVE_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下沒有任何 status: active 的 SDD。
單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
請先(擇一,都是人的決定,CC 不得自行建 SDD):
1. 把現行規格的 design.md frontmatter 標成 status: active(一份、只能一份)
2. 或依 SDD-LIFECYCLE.md 第 3、4 條:proposal 進 pending-changes.md → 使用者 confirm → 開新 SDD 標 active
然後在回覆開頭宣告已讀 active SDD + 對應 task。
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
EOF
exit 2
fi
# 有 SDD:放行,留痕提醒要宣告(stderr 警告,不擋)
echo "📋 提醒:system-dev/docs/3-specs/ 下有 SDD。動手前請確認已讀對應 design.md 並在回覆宣告。" >&2
# 恰好 1 份 active:放行,留痕提醒要宣告(stderr 警告,不擋)
printf '📋 提醒:現行 active SDD\n%s動手前請確認已讀它的 design.md、對應到 tasks,並在回覆宣告。\n' "$ACTIVE_LIST" >&2
exit 0
+12
View File
@@ -9,6 +9,18 @@
---
## 📐 SDD 生命週期鐵律(全文:`system-dev/docs/3-specs/SDD-LIFECYCLE.md`2026-07-17 leo 拍板)
1. **單一活性**:全 repo 任何時刻最多一份 `status: active` 的 SDD,所有開發任務唯一對應它的 tasks;找不到對應任務→停下來問。
2. **禁止自建 SDD**:CC 任何情況不得主動開新 SDD;任務層變更改現行 tasks 標日期,規格層變更走第 3 條。
3. **規格變更只有一條路**proposal 寫入 `system-dev/docs/3-specs/pending-changes.md` 後**停止**,等使用者明說 confirm;沒 confirm 就照現行 SDD 繼續。
4. **開新 SDD 先搬任務**confirm 後先把舊 SDD 未完成任務逐條搬入新 SDD——搬完前不准寫任何程式碼;舊的標 closed+superseded_by 移入 archive/。
5. **session 開始三數字回報**:「現行規格〈名稱〉+未完成任務 N+待裁決 proposal M」,回報出現兩份 active=當場糾正。
> **本 repo 現況(2026-07-17**:唯一 SDD `ingest-pipeline` 標 `status: paused`(實作 18/19 完成,剩 Worker 部署+端到端驗證懸置)→ **目前 0 份 active,合法**(無現行開發)。要恢復開發=由人把 ingest-pipeline 升回 active。硬約束:`.claude/hooks/sdd-guard.sh`PreToolUse 擋無 active 時寫 code 檔)+ `system-dev/scripts/sdd-active-check.sh`(獨立檢查,可掛 pre-commit/CI)。
---
## 🔒 ingest 鐵律(leo 2026-06-26 拍板)
1. **純餵食器,不碰儲存** — ingest 只 POST 候選 envelope 給 graph 的寫入 API**不直連 base、不碰 D1/Vectorize、不碰任何表**。牆是「儲存」不是「運算」:准做萃取(LLM 呼叫),不准碰儲存。
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env node
// KBDB-ingest 薄 ops CLI — 第二版(2026-07-05,總管:整分庫完整 ingest
//
// 相對於首版(2026-07-03 walking skeleton,僅路徑 A + 3 張策展卡)新增:
// 1. 路徑 B(裸原文萃取):掃 journals/*.md、pages/*.md 這類「未精耕的原始筆記」,
// 用 Haiku 直接 extract 成 (s,p,o)+node glossgloss 即精耕摘要,對齊 design §1 路徑 B)。
// → 這是「不再只 3 卡、把整個 notes vault 完整 ingest」所需的那條線。
// 2. Haiku 呼叫抽象化 callHaiku()
// - 有 ANTHROPIC_API_KEY → 直打 Anthropic Messages API(正式型態,不借 CC session)。
// - 沒有 → fallback `claude -p --model haiku` 子行程(沿用本機 CC session 授權)。
// 兩者都是真 Haiku 推論;差別只在授權路徑。缺 key 時會在 stderr 明確警告「非正式型態」,
// 不假裝正式(誠實:正式版需 Environment 注入 ANTHROPIC_API_KEY)。
//
// 預設行為(不帶 --card/--raw):路徑 Asystem-dev/wiki/cards/**/*.md
// + 路徑 Bjournals/*.md、pages/*.md,跳過空檔)=整分庫一次過。
//
// 用法:
// node scripts/ingest-cli.mjs --notes-repo <clone路徑> --graph-url <graph plugin base URL>
// [--card <relpath>]... [--raw <relpath>]... [--cards-only] [--raw-only] [--dry-run]
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import path from 'node:path';
const HAIKU_MODEL = 'claude-haiku-4-5';
function parseArgs(argv) {
const out = { cards: [], raws: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--notes-repo') out.notesRepo = argv[++i];
else if (a === '--graph-url') out.graphUrl = argv[++i];
else if (a === '--card') out.cards.push(argv[++i]);
else if (a === '--raw') out.raws.push(argv[++i]);
else if (a === '--cards-only') out.cardsOnly = true;
else if (a === '--raw-only') out.rawOnly = true;
else if (a === '--dry-run') out.dryRun = true;
}
return out;
}
const args = parseArgs(process.argv.slice(2));
if (!args.notesRepo || !args.graphUrl) {
console.error('用法: node ingest-cli.mjs --notes-repo <path> --graph-url <url> [--card <relpath>]... [--raw <relpath>]... [--cards-only] [--raw-only] [--dry-run]');
process.exit(1);
}
// --- Haiku 呼叫:正式 API 優先,缺 key fallback CC session CLI ---
const HAS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
if (HAS_API_KEY) {
console.error('[haiku] 使用 ANTHROPIC_API_KEY 直打 Anthropic API(正式型態)。');
} else {
console.error('[haiku] ⚠️ 環境無 ANTHROPIC_API_KEYfallback `claude -p --model haiku`(借 CC session,非正式型態)。');
}
async function callHaikuApi(prompt) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: HAIKU_MODEL,
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
}),
});
const json = await res.json();
if (!res.ok) {
throw new Error(`Anthropic API ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
}
const text = (json.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('');
if (!text) throw new Error(`Anthropic API 回應無 text block: ${JSON.stringify(json).slice(0, 300)}`);
return text;
}
function callHaikuCli(prompt) {
return execFileSync('claude', ['-p', prompt, '--model', 'haiku'], {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
});
}
async function callHaiku(prompt) {
return HAS_API_KEY ? await callHaikuApi(prompt) : callHaikuCli(prompt);
}
// 標記本次萃取實際走的授權路徑,寫進 envelope.extractor.model(可追溯)。
const EXTRACTOR_MODEL = HAS_API_KEY
? `${HAIKU_MODEL} (Anthropic API, 總管 2026-07-05)`
: `${HAIKU_MODEL} (via 'claude -p --model haiku' CLI subprocess, 總管 2026-07-05)`;
// --- 卡片/原文列舉 ---
function walkMd(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walkMd(p));
else if (entry.name.endsWith('.md') && entry.name !== '.gitkeep') out.push(p);
}
return out;
}
function defaultCards(notesRepo) {
return walkMd(path.join(notesRepo, 'system-dev', 'wiki', 'cards'));
}
// 路徑 B 預設來源:journals/、pages/Logseq vault 的原始筆記)。
function defaultRaws(notesRepo) {
const out = [];
for (const sub of ['journals', 'pages']) {
const dir = path.join(notesRepo, sub);
if (existsSync(dir)) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith('.md')) out.push(path.join(dir, entry.name));
}
}
}
return out;
}
// Logseq 空檔=內容只有「-」或空白。跳過(送空 envelope 無意義且 contract 要 triplets≥1)。
function isEmptyNote(content) {
return content.replace(/[-\s]/g, '').length === 0;
}
function gitCommit(repoPath) {
try {
return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {
return undefined;
}
}
function sha256(text) {
return createHash('sha256').update(text).digest('hex');
}
// contract 的 nodes[].entity_type 是 strict enumHaiku 偶爾猜 enum 外值(如 "skill")會讓 graph 端
// Zod strict() 422 整批。寧可拿掉這選填欄位也不要整批被拒(不確定就不填,比亂填誠實)。
const ALLOWED_ENTITY_TYPES = new Set(['person', 'event', 'product', 'market', 'org']);
function sanitizeNodes(nodes) {
if (!Array.isArray(nodes)) return nodes;
for (const n of nodes) {
if (n.entity_type && !ALLOWED_ENTITY_TYPES.has(n.entity_type)) delete n.entity_type;
}
return nodes;
}
function parseHaikuJson(result, label) {
// Haiku 有時包 ```json fence,保守剝一層。
const cleaned = result.trim().replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
try {
return JSON.parse(cleaned);
} catch (e) {
throw new Error(`${label} 輸出非合法 JSON${e.message}\n原始輸出:${result.slice(0, 500)}`);
}
}
// 路徑 A:已精耕卡 → 萃三元組。
async function extractFromCard(cardText, cardTitle) {
const prompt = `你是知識圖譜萃取器。讀以下一張「精耕 wiki 卡」(已經是人類編輯過的摘要,不是裸筆記),
從中萃取 (subject, predicate, object) 三元組,捕捉卡片講的核心關係/主張/因果鏈。
規則:
- 只輸出 JSON,不要任何其他文字、不要 markdown code fence。
- 格式:{"nodes":[{"name":"...","gloss":"...","entity_type":"person|event|product|market|org"(選填,不確定就不填)}],"triplets":[{"subject":"...","predicate":"...","object":"...","confidence":0.0~1.0}]}
- triplets 至少 1 條,抓卡片「重點」段落的核心關係即可,不用鉅細靡遺。
- entity_type 沒把握就不要填這個欄位(比亂填更誠實)。
- subject/object 用簡短名詞短語(可當圖節點),不要整句話塞進去。
卡片標題:${cardTitle}
卡片內容:
${cardText}`;
return parseHaikuJson(await callHaiku(prompt), 'Haiku(card)');
}
// 路徑 B:裸原始筆記 → 精耕(節點 gloss 即摘要)+ 萃三元組(design §1 路徑 B)。
async function extractFromRaw(rawText, noteTitle) {
const prompt = `你是知識圖譜萃取器,處理「裸筆記」——這是 Logseq 日記/頁面的原始條列(尚未精耕),
可能口語、跳躍、含個人反思。你的工作分兩步在心裡完成,只輸出最終 JSON:
(1) 精耕:先把這則裸筆記在心裡濃縮成幾個核心概念/主張(每個概念寫一句 gloss 摘要)。
(2) 萃取:從精耕結果萃出 (subject, predicate, object) 三元組,捕捉主張/因果/類比關係。
規則:
- 只輸出 JSON,不要任何其他文字、不要 markdown code fence。
- 格式:{"nodes":[{"name":"...","gloss":"精耕出的一句摘要","entity_type":"person|event|product|market|org"(選填,不確定就不填)}],"triplets":[{"subject":"...","predicate":"...","object":"...","confidence":0.0~1.0}]}
- 忽略純生活流水帳/無知識含量的條目;若整則都無可萃取的概念,回 {"nodes":[],"triplets":[]}。
- triplets 抓真正有洞見的關係即可(寧缺勿濫);每個節點盡量給 gloss(那是精耕產物)。
- subject/object 用簡短名詞短語(可當圖節點),不要整句話塞進去。
- entity_type 沒把握就不要填。
筆記標題:${noteTitle}
裸筆記內容:
${rawText}`;
return parseHaikuJson(await callHaiku(prompt), 'Haiku(raw)');
}
async function postEnvelope(graphUrl, envelope) {
const res = await fetch(graphUrl.replace(/\/$/, '') + '/triplets/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(envelope),
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
return { status: res.status, body: json };
}
// --- 組裝待處理清單 ---
const commit = gitCommit(args.notesRepo);
const cardPaths = args.rawOnly
? []
: (args.cards.length ? args.cards.map((c) => path.join(args.notesRepo, c)) : defaultCards(args.notesRepo));
const rawPaths = args.cardsOnly
? []
: (args.raws.length ? args.raws.map((r) => path.join(args.notesRepo, r)) : defaultRaws(args.notesRepo));
if (cardPaths.length === 0 && rawPaths.length === 0) {
console.error('找不到任何卡片或原文可處理。');
process.exit(1);
}
const items = [
...cardPaths.map((p) => ({ p, kind: 'card' })),
...rawPaths.map((p) => ({ p, kind: 'raw' })),
];
const summary = { processed: 0, skipped_empty: 0, no_triplet: 0, failed: 0, ingested: 0, deprecated: 0, posted_ok: 0, total_triplets: 0 };
for (const { p: itemPath, kind } of items) {
const relPath = path.relative(args.notesRepo, itemPath);
const content = readFileSync(itemPath, 'utf8');
const title = path.basename(itemPath, '.md');
console.log(`\n=== [${kind}] ${relPath} ===`);
if (kind === 'raw' && isEmptyNote(content)) {
console.log(' ↷ 空筆記(無實質內容),跳過');
summary.skipped_empty++;
continue;
}
let extracted;
try {
extracted = kind === 'card'
? await extractFromCard(content, title)
: await extractFromRaw(content, title);
} catch (e) {
console.error(` ✗ 萃取失敗: ${e.message}`);
summary.failed++;
continue;
}
if (!extracted.triplets || extracted.triplets.length === 0) {
console.log(' ↷ 無可萃取的三元組(裸筆記無知識含量或全為流水帳),跳過');
summary.no_triplet++;
continue;
}
sanitizeNodes(extracted.nodes);
const envelope = {
source: {
uri: `gitea:Leo/notes@${relPath}`,
content_hash: sha256(content),
commit,
},
extractor: {
model: EXTRACTOR_MODEL,
tier: 'shallow',
extracted_at: Math.floor(Date.now() / 1000),
},
nodes: extracted.nodes,
triplets: extracted.triplets,
};
console.log(` 萃出 ${envelope.triplets.length} triplets, ${(envelope.nodes ?? []).length} nodes`);
summary.processed++;
summary.total_triplets += envelope.triplets.length;
if (args.dryRun) {
console.log(' [dry-run] envelope:', JSON.stringify(envelope, null, 2));
continue;
}
const { status, body } = await postEnvelope(args.graphUrl, envelope);
console.log(` POST /triplets/ingest → HTTP ${status}`, JSON.stringify(body));
if (status >= 200 && status < 300) {
summary.posted_ok++;
if (typeof body.ingested === 'number') summary.ingested += body.ingested;
if (typeof body.deprecated === 'number') summary.deprecated += body.deprecated;
}
}
console.log('\n===== 彙總 =====');
console.log(JSON.stringify(summary, null, 2));
+39
View File
@@ -0,0 +1,39 @@
# 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,3 +1,12 @@
---
status: paused
superseded_by: ""
---
<!-- status 判定(2026-07-17 SDD 生命週期遷移):實作 18/19 完成(PR #3 merge 於舊 GitHub main、
gitea main 另有 CLI 路徑 B 07-05),剩「Worker 部署+端到端 ingest→graph 驗證」自 06-26 懸置,
目前無進行中開發 session → paused。恢復部署收尾時由人升回 active(全 repo 同時最多一份 active)。 -->
# ingest pipeline — Design
> 對應 requirements.md。**架構設計(envelope 契約、職責切割、normalize 歸屬、MCP 邊界、模型策略)在 InkStoneCo `docs/3-specs/mira-dissolve/design.md`。本檔只放 ingest 內部設計。**
@@ -0,0 +1,15 @@
# Pending Changes(規格變更緩衝區)
> 規則來源:`SDD-LIFECYCLE.md` 第 3、4 條。
> 規格層變更(核心設計/方向改變)**只有這一條路**CC 把 change proposal 寫進「待裁決」——
> 變更摘要與觸發原因+影響分析(現行 SDD 哪些任務作廢/修改/不受影響/尚未完成)——然後**停止**,
> 等使用者明說「confirm」才依第 4 條開新 SDD;沒 confirm 就繼續依現行 SDD 工作。
> 多個 proposal 可並存,由人一次裁決。本檔不是 SDD,不掛 status。
## 待裁決
(無)
## 已裁決
(無——裁決後從「待裁決」移到這裡留底,標 confirmed / rejected 日期。)
@@ -0,0 +1,95 @@
# HANDOFFcloud-worker 補跑 T-kb-skeleton2026-07-03
補跑今天 06:30 沒跑成的 cloud-worker routine,代做 sprint P3「T-kb-skeleton」(walking skeleton
notes → wiki 卡 → triples → kbdb-graph-plugin → 可查)。這份記錄只講**本 repo(ingest)份內**
撞的坑;graph 插件那邊的坑記在 `kbdb-graph-plugin` repo 自己的 commit message 裡。
## 現況起點(撞牆①):這個 repo 之前是空殼
clone 下來只有 `CLAUDE.md` / `README.md` / `contracts/ingest-candidate.json` / SDD 三件式
`docs/3-specs/ingest-pipeline/`),`tasks.md` 的 T0.5 到 T5 全部未打勾 —— **沒有任何程式碼**
不是「文件缺漏」而是「還沒開始寫」。任務指示裡「讀部署/使用文件,CLI 形態跑一次」的前提
(已有 CLI 可跑)不成立,只能自己把最小可行的 CLI 生出來才能往下走。
## 撞牆②:沒有 ANTHROPIC_API_KEY
沙盒環境變數裡沒有 `ANTHROPIC_API_KEY`(也搜過 `~/.arcrun/config.yaml` 等常見位置,
沒找到)。CLAUDE.md 說預設用 Haiku,但沒有直打 Anthropic API 的憑證。
**繞法(誠實記錄,非硬繞)**:這個沙盒本身跑在已登入的 Claude Code session 裡,
`claude` CLI 二進位可用且已授權。改用 `claude -p <prompt> --model haiku` 子行程呼叫,
這**是**真的 Haiku 推論(同一套 Anthropic 模型),只是呼叫路徑是「經 CC session 授權的
CLI 子行程」而非「直打 Anthropic API + API key」。正式版本上線前應該換回直打 API
(需要 leo 補 `ANTHROPIC_API_KEY` credential),因為:
- CLI 子行程呼叫有 session/交互開銷,不適合大量批次跑。
- 依賴一個已登入的 CC session 存在,不是獨立、可無人值守跑的服務憑證。
## 撞牆③:Haiku 偶爾吐 enum 外的 entity_type,會被 graph 端 422 擋整批
`contracts/ingest-candidate.json``nodes[].entity_type` 是 strict enum
person/event/product/market/org)。Haiku 萃取時偶爾猜出 enum 外的值(例如 "skill"),
graph 端 Zod `strict()` 驗證會直接拒收,422 打回整個 envelope(不是只丟該欄位)。
`scripts/ingest-cli.mjs` 加了送出前的過濾:非白名單值直接刪掉該欄位(寧可欄位缺,不要
整批被拒)。這是 ingest 端的責任(契約寫的很清楚「entity_type 沒把握就不要填」,
但沒堵住模型亂填的可能)。
## 撞牆④(比較重要):POST /triplets/ingest 不是原子的,client 中斷會留半殘資料
第一輪批次跑(3 張卡)用 `timeout 180` 包整支 CLI,結果卡 2`Prompt能力即拆解自己邏輯的能力.md`
萃取完 7 個 triplets 後,POST 到一半整支 node 行程被 `timeout` 砍掉 —— 但 graph 端已經
把其中 2 個 triplet 寫進去了(因為 `ingestEnvelope` 是 for-loop 逐條 `createTriplet`
不是一次性交易)。事後查 `GET /triplets` 證實:這張卡的 source_uri 底下只有 2/7 條,
不是 0 條也不是 7 條,卡在中間。
**更麻烦的是幂等性设计跟这个情境对不上**`ingestEnvelope` 的 dedup 邏輯是
「同 `source.uri` 下若已有 `content_hash` 相同的 active 記錄 → 整批 skip」。
因為檔案內容沒變(同一份卡片重跑),`content_hash` 一定相同 —— 意味著**這 2/7 的半殘狀態
會被後續重跑永久當成「已處理過」直接跳過,不會自動補完**。目前唯一的修復方式是人工介入
(改內容強制 hash 變化,或直接呼叫 graph 的單條 `POST /triplets` 補寫缺的 5 條)。
這次沒有為了掩蓋而重送假造一致的資料——是老實留著,另外用**沒有 timeout 限制**重新跑了
第 3 張卡(乾淨的 5/5),第 2 張的半殘狀態原樣留在 base 裡當作真實證據,可用下面的
curl 驗證。
建議記入正式設計(不是這次補跑範圍,留給 leo/graph 端評估):
- `POST /triplets/ingest` 對於「同 hash 但實際 triplet 數量對不上已寫入數量」的情況,
應該要能偵測並允許補完,而不是無條件 skip。
- 或者 ingest 端自己在 POST 前後做一次「數量核對」,不一致就重試/告警,而不是默默放過。
## 這次做了什麼(scripts/ingest-cli.mjs
最小可行 CLI,只走「路徑 A 簡化版」:不是拉「已存在的裸三元組」(notes 裡沒有這種東西),
而是拉**已經被之前一輪 cloud-worker 精耕過的 wiki 卡**`Leo/notes`
`system-dev/wiki/cards/notes/*.md`2026-07-02 產出的 3 張),對每張卡用 Haiku 萃取
triples + node gloss,組 `contracts/ingest-candidate.json` 規定的 envelope
POST 給 graph 插件的 `/triplets/ingest`
`source.uri` 格式從契約範例的 `github:<owner>/<repo>@<path>` 改成
`gitea:Leo/notes@<path>`(因為整個堆疊都在 Gitea 不是 GitHub,契約本身沒有嚴格要求
一定要 `github:` 前綴,只要求非空字串 + 穩定識別)。
## 未做(老實列出,不是這次範圍)
- T1 SourceAdapter 自動化(GitHub/Gitea API 拉 repo + per-file content-hash 自動判斷變動)— 這次是手動 clone + 手動指路徑。
- T2.2 cherry-pick `polaris/mira/tools/_kbdb_client.py` — 沒做,`ingest-cli.mjs` 是重新寫的最小版本,不是 cherry-pick 移植。
- T3.1/3.4 完整 extract SOPJSON-fail 升級 deep tier 等)— 這次沒做失敗重試/升級邏輯,Haiku JSON 解析失敗就直接跳過該卡。
- T4 跨 repo 織網 — 完全沒碰,這次只餵了 `Leo/notes` 一庫。
- 沒建 `package.json`/`wrangler.toml`T0.5)— `ingest-cli.mjs` 是純 Node script,不是 Worker,跟原規劃的「插件也是 CF Worker」形態不同,值得之後討論薄 CLI 到底要不要是 Worker。
## 驗證用 curl(供複驗)
```bash
BASE=https://kbdb-graph-plugin.leo21c.workers.dev
# 完整批次總覽
curl -sS "$BASE/triplets/stats" | jq .
# 卡1Gitea 卡)— 應該 7/7 乾淨
curl -sS "$BASE/triplets?subject=Gitea" | jq '.count'
# 卡2(Prompt能力卡)— 應該只有 2/7(半殘證據,見撞牆④)
curl -sS "$BASE/triplets?limit=100" | jq '[.triplets[] | select(.source_uri == "gitea:Leo/notes@system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md")] | length'
# 卡3(程式化邏輯卡)— 應該 5/5 乾淨(第二輪無 timeout 補跑)
curl -sS "$BASE/triplets?limit=100" | jq '[.triplets[] | select(.source_uri == "gitea:Leo/notes@system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md")] | length'
```
+49
View File
@@ -0,0 +1,49 @@
#!/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
+14 -14
View File
@@ -1,32 +1,32 @@
# 當前狀態
> 更新時間:2026-06-26
> 更新時間:2026-07-17
> 每次 session 結束必須更新此檔。
---
## 正在做
T0.5T5 ingest 純餵食器管線**實作完成**issue #2,已 close)。程式碼在兩個待 merge 的 PR
- **PR #3**(核心管線):`src/**`index/source-adapter/harvest/extract/graph-client/weave/pipeline/endpoint-check/envelope/types+ `tests/**`28 passed+ contract + CLI + config。mergeable/CLEAN,待總管 merge
- **PR #4**template 1.9.x 遷移,本分支):`system-dev/**` + `.claude/**` + SDD 搬 `docs/3-specs/``system-dev/docs/3-specs/`。本 repo 自行 merge。
**SDD 生命週期鐵律遷移完成(2026-07-17canonicalsystem-dev-template v1.15.0**
- 鋪檔:`SDD-LIFECYCLE.md``pending-changes.md`3-specs/)、新版 `sdd-guard.sh`hooks/)、`sdd-check.md`commands/)、`sdd-active-check.sh`system-dev/scripts/
- `ingest-pipeline/design.md` 掛 frontmatter **`status: paused`**(實作 18/19 完成、剩部署+端到端驗證懸置、無進行中開發)→ 目前 **0 active,合法**。恢復開發=人升回 active。
- CLAUDE.md 新增 SDD 鐵律段(濃縮五條+現況註記)。
- **PR #4template 1.9.x 遷移分支)已併入 main 收尾**6500d7f,舊 status 懸了三週的 merge),HANDOFF-cloud-worker 隨遷移搬進 `system-dev/docs/`
gate 全綠:vitest 28 / tsc clean / wrangler dry-run 只 env-var 綁定 / 零直連 base·SQL·migration。
## ⚠️ 已知斷層:PR #3 的 src 管線不在 gitea main
PR #3T0.5T5 src/** 管線+testscommit 34869bc)只 merge 進**已死的 GitHub main**github-dead remote),gitea main 上沒有 `src/`。gitea main 後來另長出 `scripts/ingest-cli.mjs`07-03 walking skeleton、07-05 路徑 B 萃取),與 PR #3`scripts/ingest-cli.mjs` 是**兩套不同實作**。34869bc 本機 remote-tracking 還在,救援(cherry-pick + 解 CLI 衝突)需另案,恢復 active 時一併裁決。
## 下次 session 第一件事
1. **merge 順序**:先請總管 merge PR #3(核心),再 rebase + merge PR #4(遷移)——#4 對 tasks.md 做位置搬移,#3 在原位更新它,先後 merge 免衝突
2. merge 後進**部署待驗**:部署 ingest Workerwrangler,繞 Actions+ 設 `GRAPH_BASE_URL` → 跑端到端 `GitHub→ingest→graph`(需 leo21c
## 待負責人確認
- PR #3 merge(總管)|PR #4 merge(本 repo / leo)。
1. 照 SDD-LIFECYCLE.md 第 5 條回報三數字(現行規格=無 activeingest-pipeline paused
2. 若要恢復部署收尾(Worker 部署+端到端 ingest→graph):人先把 ingest-pipeline 升回 active,並裁決 PR #3 src 管線救援
## 已知問題
| 問題 | 優先級 | 狀態 |
|------|--------|------|
| 端到端 ingest→graph 未實證 | 🟡 | 待部署 + `GRAPH_BASE_URL`graph receiver 已補對齊 full contract |
| refresh 端 extractWorkers AI)未接 | | 第一版只走採取(路徑 A);深萃留 CLI/CC |
| PR #3 src 管線只在 github-dead/maingitea main 缺 src/ | 🟡 | 待救援裁決(34869bc 本機可查 |
| 端到端 ingest→graph 未實證 | 🟡 | 待部署 + `GRAPH_BASE_URL`SDD paused 中 |
| embed 實際 embedding | ⚪ | 只打標;等 base vectorizeArcrun #7 |
| T3.3 模型測試集(中文+暗示樣本) | ⚪ | deferred;護欄 + parse 已單元測試 |
| T3.3 模型測試集(中文+暗示樣本) | ⚪ | deferred |