我一直以為 Claude Code 在靜默觀測我做的每件事。裝了 continuous-learning-v2 這個 skill,規則寫著「每輪對話自動抽取模式」、「任務結束時主動寫入知識庫」,加上 auto-skill 把產出綁到 Obsidian Vault——聽起來就像我敲的每一行指令都會被默默萃取成經驗。
然後我打開 Vault 的 auto-skill/experience/ 看一眼。
7 筆。
9 天 7 筆,其中 6 筆是某個下午當場叫 Claude 記的。真正「自動」產出的是 0 筆。
我愣了一下——這兩週敲出來的幾千次工具呼叫到底去了哪裡?還是根本沒被記?
規則沒壞,但產出為零
auto-skill 的規則是這樣設計的:每輪對話抽關鍵詞、判斷話題切換、符合條件才主動問使用者要不要寫入。理論上很精巧,每次任務結束都會評估一下「這次解決的問題下次還能用嗎」,可以就寫。
問題是這個評估是我執行的,而我是一個對話結束就消失的程序。每一代 session 用自己那輪的「品質標準」判斷,標準會漂移,多數日常工作我會覺得「這沒什麼特別」就跳過。結果 9 天產出 1 筆自動紀錄。
規則沒有壞。規則就是這樣設計的。壞的是「相信規則會自動產出東西」這個預設。我真正想要的不是更低的閾值——改低會變得很吵——而是一個觀測日誌層:每輪默默 append 一筆結構化資料,再由另一個有穩定標準的流程批次處理。auto-skill 規則裡完全沒有這一層。
另一條線有資料,但資料在別的地方
正準備自己刻一個觀測 log 時,我想到另一個裝了很久的東西:continuous-learning-v2。它跟 auto-skill 是完全不同的兩套系統,描述看起來很像——「instinct-based learning」、「100% reliable capture via PreToolUse/PostToolUse hooks」。
跑去 ~/.claude/homunculus/projects/ 看一眼:
1 | total 7574 |
7.7 MB。5481 筆。 裡面每一行是一個 tool_complete 事件,cl-v2 的 hook 在每次 Edit/Bash/Read/Grep 之後都老實 append 一筆。
資料在這裡。但它沒有自己跑到 Obsidian,也沒有任何機制會讓它跑過去。grep 整個 ~/.claude/skills/continuous-learning-v2/ 找「Obsidian」或「auto-skill」——零個匹配。兩者名字像、描述像、都在講「持續學習」,但中間沒有一行 code 把它們接起來。
那 cl-v2 自己至少有產出 instincts 吧?這是它的核心功能。
1 | instincts/personal/ → 空 |
全是空的。看 observe.sh 原始碼找原因:cl-v2 的 observer agent 是一個背景 process,由 hook lazy-start,那段 code 靠 flock(Linux)或 lockfile(macOS)做原子檢查。我這台 Git Bash 跑 command -v flock 和 command -v lockfile 兩個都找不到——MSYS2 的 util-linux 其實可以裝上 flock.exe,只是我沒裝。整段 if block 不會執行,observer 從沒啟動。
實際狀況是:cl-v2 觀測層收了 5481 筆,instinct 層 0 產出,auto-skill 知識庫幾乎 0 產出,兩邊完全沒有接起來。
我需要的東西是一條從 cl-v2 的 jsonl 通往 Obsidian auto-skill 的管道,它不存在,我得自己寫。
為什麼不直接修 cl-v2
邏輯上應該先問——cl-v2 自己就有 observer,為什麼不去修它?
- 上游覆蓋風險:cl-v2 是 plugin,下次
omc update時我動過的observe.sh會被覆蓋 - 目的地不同:即使修好 observer,它產出的是 instinct 檔案放在
homunculus/projects/<hash>/instincts/,不是 Obsidian。我想要的終點是 Obsidian,中間還是得有一層翻譯 - 可回滾性:獨立腳本壞了只要停 Stop hook 就回到原狀;修上游要
git checkout或重灌 plugin - Never break userspace:cl-v2 將來可能自己把 observer 做對(換成純 Python lock),我不想現在就卡住它未來的路
自己拉一條獨立管道明顯比較便宜。
第一版設計
我開了 ~/.claude/skills/project-digest/,放三個檔案。
之所以叫 project-digest,是因為當下我只打算處理一個專案——手邊那個正在改版的 Vue 3 前端。config.json 裡三個欄位很自然地寫死了:
1 | "project_id": "<project-hash>", |
這個決定後來會被打臉,但當下它看起來合理——先把一個專案的管道打通再說。
digest.py 的邏輯很直接:讀 jsonl、按 session_id 分組、打分(編輯多檔案加分、同檔多次編輯加分、Bash output 有 error 加分、有 Skill/Agent 呼叫加分),分數超過閾值的 session 才會被送進 LLM。然後把每個 session 的事件序列壓縮成一段摘要——工具使用次數、編輯過的檔案清單、幾個 diff 片段——丟給 Haiku 4.5,讓它判斷「這個 session 有沒有值得記錄的經驗」,有就輸出 markdown 條目。
trigger.py 在 Stop hook 裡被呼叫,用 subprocess.Popen 啟動 digest.py 然後自己立刻 exit——Popen 本來就是非阻塞的,這層不需要特殊 flag 就能避免阻塞 session 收尾。我額外帶上 DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW 是為了讓子程序脫離父 console、不吃父 process group 的訊號,避免 session 退出時把還在跑 LLM 呼叫的 digest 一起收走。非阻塞是 Popen 給的,存活是 detach flag 給的,兩件事不要混。
設計上預留四個關卡:file lock(同時只能有一份 digest)、throttle(距上次 10 分鐘內 skip)、checkpoint(last_processed_ts)、processed sessions list(避免同一個 session 重複萃取)。
踩坑一:--bare flag 害我一頭栽進認證坑
我的 digest 要呼叫 claude -p 萃取,但絕對不能讓那個 sub-session 又觸發 hook 寫進 observations.jsonl(遞迴)、也不想讓 CLAUDE.md 污染 prompt。Claude Code 有個 --bare 選項,我當下執行 claude --help 看到的描述大意是「minimal mode:跳過 hooks、auto-memory、CLAUDE.md 自動發現」等(實際 flag 列表依你當下版本為準)。一石二鳥,我加了。
第一次跑:exit code 1,stderr 空。我以為是 prompt 13k 字透過 argv 傳給 claude.exe 被 Windows CreateProcess 砍掉,改用 stdin 傳。還是 exit 1。這次把 stdout 也 log 出來:
1 | Credit balance is too low |
等等——我有 Max 20x 訂閱,帳戶上還有 140 多美金的 API credit,怎麼會 too low?
重看那份 --help 的 --bare 段落末尾,關鍵那行(你的版本可能不同):
Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read)
--bare 會強制繞過 OAuth,只認 ANTHROPIC_API_KEY 環境變數。我的 Max 訂閱走的是 OAuth,帳戶上那 140 塊也綁在 OAuth。--bare 繞過去之後,剩下那個 env var 指向的是我以前隨便試用留下、早就耗光的 key。
一個 flag 同時解決兩件事聽起來很棒,代價是你不會注意到它順手幫你改了第三件事——而第三件事剛好是認證路徑。解法是不用 --bare,改用明確的 env 控制:
1 | def call_claude_cli(prompt, model): |
這次乾淨跑過。
踩坑二:Stop hook 會無限遞迴
digest.py 呼叫 claude -p 會開一個新的 Claude session,那個新 session 結束時又觸發 Stop hook 再跑一次 trigger.py——理論上無限迴圈。digest.py 本身有 throttle 和 file lock 擋著,最糟只會浪費幾個 Python 啟動成本,但我想從 trigger 層就擋掉。
解法:digest.py 呼叫 claude -p 時設 ECC_SKIP_OBSERVE=1,trigger.py 開頭檢查這個 env:
1 | if os.environ.get("ECC_SKIP_OBSERVE") == "1": |
這招我一開始沒寫,是在看 observe.sh 時發現它自己就有處理 ECC_SKIP_OBSERVE 的邏輯,才想到我的 trigger 應該沿用同一套約定。
踩坑三:filePath regex 解析失敗
observations.jsonl 裡每筆 Edit 的 output 是 JSON 字串,但 cl-v2 的 observe.sh 會對 input 和 output 兩個欄位各自套 [:5000]——不是整個 tool_use JSON 被砍,而是兩個欄位獨立截前 5000 字。oldString 太長時尾端被切掉,我原本的 regex 在某些邊界情況下會被 oldString 裡 escape 過的 "filePath" 字串騙到,回傳錯誤路徑。dry-run 的摘要裡出現一堆 ?。
這個我留著沒修——就算有 ?,LLM 還是從其他線索推出合理條目。修這個要嘛改 regex、要嘛改 cl-v2,成本對應收益太低。Linus 那句「解決真問題,拒絕理論完美」。
第一次跑通
關最後一道閘:把 Stop hook 接上 settings.json。第一次完整跑 67 秒:讀 5500+ 筆觀測、挑出 5 個高分 session、丟 Haiku 萃取,回來 1125 字。Obsidian 裡看到三條經驗:一條關於瀏覽器 Object URL 的釋放順序(先 revokeObjectURL 舊的再 create 新的)、一條關於內部 SQL 參數化 helper 的用法、一條關於某個 GIS 套件的 drawing widget 在版本升級後 import 路徑變更。
全部都是 Haiku 從純工具呼叫紀錄推出來的,有檔案路徑、有步驟、細節大致可信。本來預期會最失望的一步,結果比預期好。
看起來完成了。我準備開始寫這篇部落格。
然後被一個簡單問題打臉
寫到一半,跟朋友確認部署狀態時他順口問了一句:
project-digest為啥會用這個名字?只記錄這個專案嗎?
這是一個簡單問題。我寫的時候完全沒想過。
重新審視自己的設計,三個錯同時浮出來:
命名騙人。 檔案位置是全域的(~/.claude/skills/),跟專案無關。但第一版的資料夾名字讓人以為它專屬於某個特定專案。未來任何人看到都會困惑。
scope 寫死了。 config.json hardcode 了 project_id,digest.py 只會讀那一個專案的 jsonl。Stop hook 掛在全域 settings.json,每個 session 結束都會觸發——但 digest 只處理一個專案。開別的專案時 cl-v2 會繼續收觀測到另一個目錄,digest 一筆都不看,那些觀測會默默躺在硬碟裡,完美地重蹈我們原本發現的那個坑,只是換個角色。
敏感過濾是空的。 我在第一版的文章草稿裡把「敏感資料過濾」寫成 TODO:「目前這條管道最大的未補洞」。單專案版本剛好這個專案是公司內部的程式碼,不急——但一旦開啟多專案,任何一個帶 .env 或 API key 的 repo,只要 cl-v2 的 hook 收到觀測,就會在下一次 digest 被整筆送給 LLM。遮罩從 nice-to-have 變成 must-have。
這三個錯扣在一起:命名誤導 → scope 寫死掩蓋了「這其實可以處理所有專案」的意圖 → 沒有多專案 scope 就沒有強烈動機做敏感過濾。如果我一開始就想做多專案,遮罩層會自然冒出來變成必要部分。
當天就重構。
第二版設計:session-digest
新名字、新目錄:~/.claude/skills/session-digest/。三個關鍵改動。
自動掃所有專案。 不再 hardcode project_id,改成啟動時讀 ~/.claude/homunculus/projects.json 拿到所有專案列表,對每個有觀測的專案獨立處理:
1 | def list_projects(cfg): |
projects.json 是 cl-v2 維護的專案註冊表(以我這版 cl-v2 的行為來看,它會在每個被偵測到的 cwd 加一筆 metadata)。我沒動 cl-v2,只是讀它暴露出來的東西。
state.json 也重組成 per-project:projects.<id>.{last_processed_ts, processed_sessions},每個專案獨立 checkpoint 不互相干擾。另外加了 min_observations_to_process: 50 自動濾掉「cwd 碰巧被 cl-v2 註冊成獨立專案但其實是子資料夾」的雜訊。
project_name → skill_id 自動轉換。 不再手動 map,寫一個函數:
1 | def project_name_to_skill_id(name): |
兩層 regex 的順序很重要:先切連續大寫後接小寫的邊界(FTPService → FTP-Service),再切小寫接大寫的邊界(serviceV3 → service-V3)。順序反了 FTPService 會被切成 f-t-p-service。
保留 \u4e00-\u9fff 是讓中文專案名活下來——Obsidian 吃得了中文 filename。
敏感過濾層,整筆丟棄。 這次直接上,不再 TODO:
1 | _SENSITIVE_PATH_RE = re.compile( |
一個設計決定我想解釋:為什麼整筆丟掉而不是只遮罩敏感部分?
局部遮罩有兩個問題。一是正則永遠追不上所有可能的 secret 形式——今天擋了 sk-ant-,明天新的 key prefix 出現就漏了。二是即使遮罩了 secret 本身,上下文也會洩漏:一個檔案的其他內容 + 它碰觸過 .env 這個事實,就足以讓人知道「這個專案有在處理哪些環境變數」。這是一個比單純洩漏 key 字串更細緻但同樣敏感的資訊。
整筆丟棄的代價是那些 session 的其他有用 observation 也跟著沒了——但如果這個 session 真的碰到 .env,它大機率本來就不適合被 LLM 萃取。預設不安全的東西就不要 opt-in 送進 LLM,等日後確定要細調再放寬。
維運問題怎麼處理? 誤判(把本來無害的 observation 整筆丟掉)的代價就是萃取品質輕微下降,沒有產生錯誤資料,可以容忍。漏判(把敏感資料放行)才是要處理的——我沒做自動檢測,依賴兩個習慣:一是每次看 Obsidian 的新條目時順手掃一下有沒有奇怪的 key 或路徑,二是 digest.log 每次會印「skipped N sensitive observations」,N 突然變 0 的那天代表正則可能失效或 cl-v2 output 格式變了,當下就去看原始 jsonl 確認。這不是嚴謹的維運,但對個人工作流夠用。
切換
剩下的都是機械活:舊 state 搬到新格式(避免已處理過的 session 重複萃取)、改 settings.json 把 Stop hook 指到新路徑、舊目錄放一個 DEPRECATED.md 標記。
dry-run 跑一次,log 讓我滿意:
1 | found 22 projects: <proj-1>, <proj-2>, ... (+20 more) |
兩個重要數字:某個專案被擋了 92 筆觀測、另一個擋了 22 筆。第一版沒有敏感過濾層,這些筆只要落在分數過閾值的 session 裡就會被送給 LLM。目的地是我自己的 Max 訂閱這件事不等於「沒事」——它仍然是把資料送進外部模型服務,只是攻擊面比送去第三方 API 小一點。風險邊界是我當下沒定義的,這是運氣不是設計。
後記:把 cl-v2 observer 也補修了
文章寫完之後我把上面「為什麼不修 cl-v2」那段理由重看一次,發現自己其實是把「最小可用」跟「完全不碰」混為一談。獨立管道是對的,但觀測層上游一個兩行可修的 Git Bash bug,修它並不違反獨立管道的設計——它只是讓資料源更完整。
具體改動是在 observe.sh 的 lazy-start 區塊加一個 elif mkdir "$lock.d" 2>/dev/null 分支,mkdir 是 POSIX 標準的原子操作,Git Bash 也吃。三分鐘寫完。缺點是下次 omc update 會把它蓋掉,我在 LOCAL_PATCHES.md 標了要重打的位置,patch 本身 grep [LOCAL PATCH] 就能定位。
修完之後 instincts/personal/ 終於不是空的了,但這是另一條軌道的成果——session-digest 的管道不受影響,它本來就只讀 raw observations。兩條線並行、互不耦合,這是從頭到尾沒變的設計。
已知限制
寫這篇的時候 session-digest 才跑了不到 24 小時,有幾件事我自己還沒親眼看到:
- 敏感過濾涵蓋 observation 的
input和output兩個欄位(不是只有 output——tool_start 事件的 input 欄位也會掃到,這樣 Bash command 裡的 API key 才不會漏)。正則清單擋了sk-ant-/sk-proj-/ghp_/AKIA/JWT/-----BEGIN PRIVATE KEY-----等常見格式,但這永遠是個軍備競賽——每當新服務有新的 token 前綴我就得補。第一版先擋已知的,下一輪會把它擴成detect-secrets等級的清單 - 粒度是整個 session:任一筆 observation 觸發敏感 pattern,整個 session 的所有事件都一起丟掉。代價是乾淨的部分也沒了,但敏感 session 通常整段 context 都圍繞那個 secret,丟整包比較保守
- 只處理
tool_complete的 summary,不處理對話原文:Haiku 能從工具呼叫推出很多東西,但真正的「為什麼這樣做」在對話裡。下一步會接UserPromptSubmithook 把 prompt 也收起來 - Edit 的 oldString/newString 被 cl-v2 截到前 5000 字:敏感資料如果剛好藏在 4500 字之後的尾端會逃過過濾,窗口很窄,短期不處理
- 沒有內容層去重:靠
processed_sessionslist 避免同一個 session_id 被二次萃取,但如果session-digest自動寫入的條目跟我手動記錄的條目講同一件事,兩邊語意重複時不會合併 - 只跑了一輪 throttle window:22 個專案裡只有 3 個真的被處理過,另外 14 個卡在
min_observations_to_process: 50或等排隊。soak 到一週再說「穩定運作」比較誠實
收尾
寫這條管道的兩次設計,最讓我學到東西的不是 --bare 踩坑或遞迴守門,是那句「這個名字為什麼叫這個名字」。
第一版我自己看不出問題,因為我寫的時候 context 就是那個單一專案。命名是當下最合理的選擇,寫死 project_id 是當下最快的方式。如果沒有人問那句,第一版會直接部署——它不是 bug,是我定義的正確行為。但那個定義是錯的。
被問到之後,我花不到一個小時重構:多專案自動掃、name→slug 轉換、敏感過濾整筆丟棄。這些東西第一版都沒想過,不是因為難寫,而是因為我沒看到需要。需要是被問題暴露出來的,不是規劃階段能全部預測的。
實際寫的 digest.py 大約 350 行,上面貼的是整理過的版本。要自己做類似的東西的話:
- 先從最小版本起步:讀 jsonl、印 session 分組、先不接 LLM。Parsing 錯很容易被誤判成 LLM 問題
- 第一天就加敏感過濾,不要留 TODO。正則可以粗,但要有
- 跟別人聊一下你寫的東西。簡單問題最有殺傷力
下一步會接一個 UserPromptSubmit hook 把對話原文也收起來,萃取品質應該會再上一層。但 observations 已經有 5481 筆了,不急。
