今天要解的問題

Claude Code 在 2026-10-02 推出了 mod(v2.1.287 起預設開啟),這篇是我試著把它導入 llm-wiki 的紀錄。

在這之前,Claude Code 能擴充的方式已經有四種:skill、settings hook、MCP、plugin。llm-wiki 每一種都用到了:十幾個 skill、Day18 那套擋主 checkout 寫入的守門 hook、.claude/ 由 git 讓兩台共用。新東西進來,當然就要來做比較拉:mod 能做什麼,是其他四種做不到的?

我的答案從一個一直存在的不便開始。Day18 之後,每個 session 都關在自己的 worktree 裡,我在 session 裡工作時看不到 vault 現在的樣子,要知道只能開 Obsidian,或叫 Claude 去讀。這是「看不到」的問題,skill 解不了:skill 能讓 Claude 讀得更好,但不會讓我看得到。

所以這篇的主軸是「為什麼要用 mod」,順序照我認識它的順序:先從最直觀、看得到的介面開始,再往下看它的 hook,跟我現有的守門 hook 有什麼不同。

同一個 mod 畫在三個位置

上圖是示意(Pane、橫條、狀態列各自在哪),不是截圖。

想法與取捨

先分清楚:五種擴充,用「要改的是什麼」來分

官方文件有一張比較表,我把它壓成一句話:

改 Claude 的腦(知識、流程)→ skill;給 Claude 手(外部系統)→ MCP;守門(擋或放)→ settings hook;改 Claude Code 這個 app 本身(介面、事件流、免 turn 的指令)→ mod;要打包帶走 → plugin。

幾個容易混的點:

  • mod 就是 plugin:準確說是「hooks/hooks.json 裡有 modules 這個 key 的 plugin」。plugin 是容器,skill、agent、MCP、settings hook、mod 都能裝進去
  • mod 跟 settings hook 最像,但跑法不同:settings hook 每次另外開一個 shell 程式;mod 是 Claude Code 行程裡直接呼叫的函式,所以能畫介面、能在 hook 之間共享變數。官方明說兩者並存,settings hook 沒有被淘汰
  • 吃不吃 context:skill 的名稱與描述每個 turn 都佔 context;mod 不吃(除非它主動往 prompt 塞東西)

只有 mod 做得到的有五件:

  1. 畫可操作的介面
  2. 重畫 Claude Code 自己的介面(tool call 列、spinner…)
  3. 介入 tool call 或 request
  4. 跑不開 Claude turn 的 /command
  5. hook 之間共享變數。

放到 llm-wiki,第一個結論反而是「不改的」:/note-enrich、/wiki-build、/book-review 是教 Claude 一套判斷,留在 skill,mod 不會讓 Claude 判斷得更好;frontmatter 檢查、根目錄禁寫這些守門規則已經有 settings hook 在擋,沒有介面需求,也不必改。mod 真正補的洞是另一種:把 vault 的狀態變成畫面。叫 Claude 去讀 TODO.md,每次要花一個 turn 和一堆 token;mod 畫的東西不經過模型、不吃 context,這是我選擇先拿它當儀表板的原因。

從介面開始:mod 能畫在哪

mod 是一個 TypeScript 模組,export register(on),用 on('事件', hook) 掛進 Claude Code 的各種事件。能畫的位置有側邊面板(Pane)、輸入框上方的橫條、狀態列,還有輸入框本身。改檔存檔就熱重載,不用重開 session。

我第一個做的是 TODO 面板,做著做著問了一個問題:每次都要打 /todo 才叫得出來,還是應該把 UI 分開?

答案是本來就分開了,只是我沒意識到:橫條、狀態列一直都在,不用叫;只有側邊面板要開。所以第一版就分成兩層:常駐的橫條放今日三頁、狀態列放幾個數字、輸入框標連結;面板放 TODO 和近期新增的 wiki。每個位置各放什麼、要我關注什麼,留到 Day27 細講。

指令也從 /todo 改掉了——面板早就不只 TODO。mod 本身後來也改名叫 navi(薩爾達傳說裡林克的妖精夥伴,「Hey! Listen!」正是它在做的事),起因是狀態列:Claude Code 會在每個 mod 的狀態列前面加上 mod 名稱,vault-todo: 太長。改名時我才知道資料夾、mod 名稱、指令、面板標題是四個各自獨立的設定,但我選擇全部統一叫 navi:一致對我來說比較好管理,少記一個名字。所以指令是 /navi,順手加了參數:close 關面板、refresh 立即重算、reroll 重抽今日三頁。

「近期新增」從哪裡算

最直接的是看 wiki 檔的修改時間。但 session 都在 worktree 裡,worktree 是新切出來的,所有檔案都是同一個時間點,這個資訊沒用。

改用 git 歷史:git log --diff-filter=A 只列「新增」的檔案,rename 會被 git 判成 R,不會混進來。

已知代價有兩個:還沒 commit 的新頁不會出現;看到的是當下 worktree 那條分支的歷史,不一定等於 main。

我後來又把「今天新增」改成「本週新增」——這是我自己改的,今天新增大多數時候是 0,看了沒感覺。

怎麼「點了就在 Obsidian 開」

mod 的 Link 元素只收 https: 網址(或 http://localhost),obsidian:// 不行。

所以改走 $.process.run,讓作業系統去開 URI:Windows 用 rundll32 url.dll,FileProtocolHandler,macOS 用 open。選 rundll32 而不是 cmd /c start,是因為 URI 裡有 &(vault=llm-wiki&file=...),經過 cmd 會被當成指令分隔符吃掉;rundll32 不經 shell。

mod 裡沒有現成的「現在是哪個作業系統」可以問,所以寫成:先試 rundll32,起不來就是 Mac,換 open。

TODO 項目點了要做什麼

可以做的有兩種:直接把 - [ ] 改成 - [x],或把對應指令填進輸入框。

勾掉看起來比較爽,但 session 在 worktree 裡,勾掉的是這條分支的 TODO.md,要等合回 main,Obsidian 才看得到。我選了填輸入框:點「note-explore:為何而寫 → ideas/raw/為何而寫-explore.md」,輸入框就出現 /note-explore ideas/raw/為何而寫-explore.md,按 Enter 才開工,決定權還在我手上。

wiki-build 的項目常常只寫了 ideas/wiki/ 這種目錄,填進去也沒用,所以這種只填指令、topic 留給我補。

「框有點醜」

第一版每個項目都是預設 Button,終端機上畫成 [ 文字 ],一整面方括號。我的感想就一句:「目前這個框有點醜」。

改法是 Button 加 plain,變回純文字,focus 時才反白;→ 路徑 收進一個 display="none" 的 Box,滑鼠滑過才出現。

輸入框標色

這是我看 mod 能做什麼時最意外的一項:輸入框本身也能畫。我打 [[頁名]] 時,頁面存在就變綠,不存在就畫紅色底線——斷鏈在打字的當下就看得到,不用等 /vault-maintain 巡檢。

再往下:mod 的 hook,旁觀、改寫、接手

介面做出來之後,我才搞懂「常駐」的意思:mod 整個 session 都載著,但不是一直在跑,只有它掛的事件發生時才被呼叫。畫面也是一種事件(ui.render),所以 mod 的本體其實是 hook,介面只是其中幾個事件的結果。

同一個事件的所有 hook 串成 middleware 鏈,每一個都拿到 next,能做的事只有三種,navi 正好各用到一種:

行為寫法navi 裡的例子
旁觀await next(e) 之後再做事tool.call:Claude 每次用工具都叫一次,跑完看有沒有碰到 TODO.md,有就刷新
改寫改 next 回來的結果prompt.edit:我每按一個鍵叫一次,在結果上加顏色
接手不呼叫 next,直接回結果/navi:直接開面板,不經過 Claude、不開 turn

它排在我的守門 hook 前面

這是導入前最需要停下來看的一段。mod 以使用者權限執行:讀寫任意檔案、啟動程式、連網、看到每個 prompt 和 tool call、改寫它們,還能在你被詢問前就核准 tool call。它不是 sandbox。

更直接的是執行順序:組織 prepend → 使用者的 mod → 組織 append → 內建 mod,而非 managed 的 PreToolUse settings hook 在最後一個 mod 的 next 之後才跑。vault 的 write_guard 正是非 managed 的 PreToolUse hook。所以只要某個 mod 在 tool.call 直接接手、不呼叫 next,Day18 那套守門就根本不會被執行。

這條決定了三件事:

  1. navi 的 tool.call 一定呼叫 next,並把結果原樣回傳,write_guard 照常在後面跑
  2. 裝別人的 mod 前先 claude plugin validate,看它掛了哪些事件、呼叫了哪些 API,特別是不呼叫 next 的 tool.call
  3. 守門要改寫成 mod 時,settings hook 先別拔。兩版並行一段時間再決定;而且 mod 不是每個環境都會載入:
在哪裡hooks 會跑介面畫得出來
終端機、Desktop Code 分頁✓✓
Desktop 的 WSL session✗✗
VS Code、claude -p、Agent SDK✓✗
雲端 session要能帶進雲端✗

如果守門只剩 mod 版,雲端 session 可能根本沒載到它,等於沒有護欄。

mod 放哪:只有這個 session、本機,還是跟著 vault

寫 mod 的預設位置是 ~/.claude/dev-mods/<session-id>/,只在當下這個 session 有效。要長期用有三條路:

放法代價
--plugin-dir / 環境變數指到本機資料夾兩台各設一次,改了要手動同步
~/.claude/ 使用者層不進 git,Mac 拿不到
vault 的 .claude/skills/navi/只在 vault 裡有效

我選了第三個,原話是「搬進 vault 的 .claude/ 讓兩台共用」。理由在 Day18 已經鋪好了:.claude/ 本來就由 git 同步,skill 和 hook 兩台是同一份,Claude Code 也會自動載入專案 .claude/skills/<name>/ 底下的 mod。

「只在 vault 裡有效」反而是對的:這個 mod 讀的是 TODO.md 和 */wiki/,在別的專案開只會顯示「找不到 TODO.md」。

代價是 vault 的維護規則:/vault-maintain 會比對 .claude/skills/ 每個資料夾在 CLAUDE.md 的 skill 表裡有沒有一列。mod 不是 SKILL.md 型的 skill,但住在同一個資料夾,所以表裡補了一列(現在是 /navi),註明「mod,非 SKILL.md 型」。

實作

整個 mod 是三個檔:

{"name":"navi","version":"0.2.0","description":"Hey! Listen! — llm-wiki vault 的夥伴:/navi 面板、今日三頁、狀態列、[[連結]] 標色","types":"./types/index.d.ts"}
{ "modules": ["./register.tsx"] }

第三個是 hooks/register.tsx,最後長到 507 行。下面挑跟取捨對得上的幾段。

狀態放在 host,不放在模組變數

const sections = atom({ plugin: 'navi', key: 'sections' } as const, [])
const recent = atom({ plugin: 'navi', key: 'recent' } as const, [])
const domains = atom({ plugin: 'navi', key: 'domains' } as const, [])
const picks = atom({ plugin: 'navi', key: 'picks' } as const, [])
const names = atom({ plugin: 'navi', key: 'linkIndex' } as const, [])
const overview = atom({ plugin: 'navi', key: 'overview' } as const, {
  todo: 0,
  inbox: 0,
  week: 0,
})

熱重載時模組會整個重跑,模組裡的變數歸零;atom 的值存在 Claude Code 那邊,重載後還在。畫面用 read($, atom) 讀、別處用 update($, atom, fn) 寫,寫了就自動重畫讀它的那幾塊,不用自己呼叫 redraw。

「重載後還在」也有反面:今日一頁原本存成單一物件、沒有時是 null,改成三頁的陣列後,舊的 null 還留在 state 裡。後來換成新的 key picks,乾脆不跟舊值打交道。

兩個 refresh:輕的 30 秒、重的 30 分鐘

async function refreshStats($: $) {
  // 每一步各自 try:一步失敗(例如讀不到指令清單)不拖垮其他統計,並用 toast 說出原因
  let pages: WikiPage[] = []
  try {
    const scanned = await scanDomains($)
    pages = scanned.pages
    await update($, domains, () => scanned.stats)
  } catch (err) {
    $.ui.toast(`navi:各域統計失敗 ${String(err).slice(0, 80)}`)
  }
  try {
    const hubList = await scanHubs($)
    await update($, hubs, () => hubList)
  } catch (err) {
    $.ui.toast(`navi:HUB 統計失敗 ${String(err).slice(0, 80)}`)
  }
  try {
    const all = await collectNames($)
    await update($, names, () => all)
    try {
      const fx = await scanFacts($, all, await $.clock.now())
      await update($, facts, () => fx)
    } catch (err) {
      $.ui.toast(`navi:趣味統計失敗 ${String(err).slice(0, 80)}`)
    }
  } catch (err) {
    $.ui.toast(`navi:頁名清單失敗 ${String(err).slice(0, 80)}`)
  }
  try {
    const cmds = (await $.command.list()).map(c => c.name.replace(/^\//, ''))
    await update($, commands, () => cmds)
  } catch (err) {
    $.ui.toast(`navi:指令清單失敗 ${String(err).slice(0, 80)}`)
  }
  const current = (await read($, picks)) ?? []
  if (current.length === 0 && pages.length > 0) {
    const id = await $.session.id()
    await update($, picks, () => pickFor(pages, id, 3))
  }
  await refresh($)
}
    $.clock.every(30_000, () => void refresh($))
    $.clock.every(1_800_000, () => void refreshStats($))

第一版只有一個 refresh,每 30 秒連統計一起算。統計要讀全部 wiki 頁(下面「踩到的坑」第 4 點),後來拆開,先 10 分鐘、最後放寬到 30 分鐘。每一步各自包 try:有一次某一步出錯,後面的今日三頁、狀態列全都沒更新,現在一步失敗只跳 toast 說原因,其他照跑。狀態列需要「幾個域堆積」,輕量那條就直接讀上一次統計留在 state 裡的結果。

輕量 refresh 另外在 Claude 的工具呼叫碰到 TODO.md 或 /wiki/ 時跑:

  on('tool.call', async ($, e, next) => {
    const ran = await next(e)
    const s = JSON.stringify(e)
    if (s.includes(FILE) || s.includes('/wiki/')) await refresh($)

    return ran
  })

await next(e) 先讓工具真的跑完,再刷新。這個 hook 不擋任何東西,只是旁觀;也因為一定呼叫 next,排在後面的 write_guard 照常會跑。如果哪天把 await next(e) 拿掉、直接回一個結果,守門就被繞過了。

本週新增:git log 加日期比對

    const log = await $.process.run([
      'git', '-c', 'core.quotepath=false', 'log', '--diff-filter=A',
      '--name-only', '--format=@%ad', '--date=short', '-n', '200',
      '--', '*/wiki/*.md',
    ])
    const added = parseLog(log.stdout)
    const monday = mondayOf(now)
    week = added.filter(p => p.date >= monday).length

core.quotepath=false 是因為 vault 大部分檔名是中文,不加的話 git 會吐 "ideas/wiki/\346\210\221..." 這種八進位跳脫。--format=@%ad 讓每個 commit 以 @日期 開頭一行,後面接檔名,parseLog 照這個格式拆。

今日三頁:每個 session 一組

function pickFor(pages: WikiPage[], key: string, n: number): WikiPage[] {
  const pool = [...pages]
  const out: WikiPage[] = []
  let seed = 2166136261
  for (let i = 0; i < key.length; i++) seed = Math.imul(seed ^ key.charCodeAt(i), 16777619) >>> 0
  while (out.length < n && pool.length > 0) {
    out.push(pool.splice(seed % pool.length, 1)[0])
    seed = (seed * 1103515245 + 12345) >>> 0
  }
  return out
}

種子是 session id 的 FNV-1a 雜湊:同一個 session 怎麼重算都是同一組,換一個 session 就換一組。而且只在 picks 是空的時候才抽,30 分鐘一輪的統計不會把它換掉,要換就 /navi reroll 清空重抽。挑過的從 pool 拿掉,三頁不會重複。

演變是:一頁、以日期為種子 → 三頁、以日期為種子 → 三頁、以 session 為種子。改成三頁的理由在心得。

輸入框裡的 [[連結]] 標色

  on('prompt.edit', async ($, e, next) => {
    const r = await next(e)
    // ...
    return { ...r, decorations: [...(r.decorations ?? []), ...mine] }
  })

prompt.edit 在每次按鍵時觸發。先 await next(e) 讓編輯照常套用,再在結果上加 decorations:一組「第幾個字到第幾個字怎麼畫」,只影響顯示,不改我打的字。保留 r.decorations 是因為別的 mod 也可能在畫。

第一版只分兩色:找得到就綠、找不到就紅。實際用起來,我在輸入框打 [[cfgc-cupping-form]],它真的變綠了——這是整個 mod 裡我覺得最實用的一個,斷鏈以前要等 /vault-maintain 巡檢才抓得到,現在打字的當下就知道。之後擴充成依「連到哪一層」上色,順序由上往下、符合就停:

function linkDecorations(text: string, entries: string[]): Deco[] {
  const where = new Map<string, string[]>()
  for (const e of entries) {
    const [loc, name] = e.split('\t')
    where.set(name, [...(where.get(name) ?? []), loc])
  }
  const out: Deco[] = []
  for (const m of text.matchAll(/\[\[([^\[\]]+?)\]\]/g)) {
    const link = m[1].split('|')[0].split('#')[0].trim()
    const parts = link.split('/')
    const base = parts.pop()!.trim()
    const dir = parts.join('/')
    if (!base) continue
    const start = m.index!
    const end = start + m[0].length
    const locs = (where.get(base) ?? []).filter(l => !dir || l === dir || l.endsWith(`/${dir}`))
    if (locs.length === 0) out.push({ start, end, color: 'red', underline: true })
    else if (!dir && locs.includes('books/wiki') && locs.includes('books/raw')) out.push({ start, end, color: 'yellow' })
    else if (locs.some(l => l.endsWith('/wiki'))) out.push({ start, end, color: 'green' })
    else if (locs.includes('hubs')) out.push({ start, end, color: 'cyan', bold: true })
    else if (locs.some(l => l.endsWith('/raw') || l.endsWith('/logs'))) out.push({ start, end, dimColor: true })
    else out.push({ start, end, color: 'green' })
  }
  return out
}

entries 是 vault 每個 .md 記成「所在資料夾+檔名」。Obsidian 靠 basename 解析連結,所以同一個名字可能同時在 books/raw 和 books/wiki——這正是 CLAUDE.md 要求連書必須寫路徑的原因,打裸的 [[書名]] 就標黃提醒。後來又加了兩種:vault 路徑不存在畫紅底線、開頭的指令拼錯畫刪除線(指令清單來自 $.command.list())。面板的「說明」分頁用實際顏色畫了一份圖例。

TODO 項目 → skill 指令

const COMMANDS: [RegExp, string][] = [
  [/^enrich/, '/note-enrich'],
  [/^note-explore/, '/note-explore'],
  [/^wiki-build/, '/wiki-build'],
]
function fillFor(item: Item): string {
  const cmd = COMMANDS.find(([re]) => re.test(item.summary))?.[1]
  // wiki-build 的路徑常是目錄(`ideas/wiki/`)或缺漏,交給人補 topic
  if (cmd && item.path && !item.path.endsWith('/')) return `${cmd} ${item.path}`
  if (cmd) return `${cmd} `
  return item.path ?? item.summary
}

能這樣對應,是因為 /inbox-triage 寫進 TODO.md 的格式是固定的:- [ ] {動作}:{摘要} → \{路徑}``。triage 當初定下這個格式是為了人讀,現在剛好也讓程式讀得懂。

畫面那邊:

<Button
  plain
  label={`· ${item.summary}`}
  onPress={() => void $.prompt.fill({ text: fillFor(item) })}
/>
{item.path && (
  <Box display="none" hover={{ display: 'flex' }} paddingLeft={2}>
    <Text dimColor wrap="truncate-end">
      → {item.path}
    </Text>
  </Box>
)}

在 Obsidian 開

async function openInObsidian($: $, p: WikiPage) {
  const file = encodeURIComponent(`${p.domain}/wiki/${p.name}`)
  const uri = `obsidian://open?vault=llm-wiki&file=${file}`
  try {
    await $.process.run(['rundll32', 'url.dll,FileProtocolHandler', uri])
  } catch {
    try {
      await $.process.run(['open', uri])
    } catch {
      $.ui.toast(`無法開啟 Obsidian:${p.name}`)
      return
    }
  }
  $.ui.toast(`Obsidian 開啟:${p.name}`)
}

vault=llm-wiki 直接寫死:Obsidian 的 vault 名稱就是資料夾名,兩台都叫 llm-wiki。

踩到的坑

1. claude plugin validate 說 hooks.json 格式不對

第一版寫完驗證:

Validating hooks: ...\vault-todo\hooks\hooks.json

✘ Found 1 error:

  ❯ hooks: Invalid input: expected record, received undefined

hooks.json 是照文件寫的 { "modules": ["./register.tsx"] }。查了一下版本:PATH 上的 claude 是 2.1.154,而跑這個 session 的引擎是 2.1.286。舊版 CLI 只認得舊式 command hooks 的 { "hooks": {...} },根本不知道 modules 是什麼。

winget upgrade --id Anthropic.ClaudeCode 升到 2.1.286 後,這個錯消失了——然後露出底下兩個真的錯。

2. 正則被跳脫壞掉,整個檔案 parse 不了

register.tsx does not parse: Syntax Error (line 33, column 33)

第 33 行是 parseLog 的 text.split(/\r?\n/)。加「近期新增 wiki」那次,是用 bash heredoc 包 Python 去改檔的,\\r?\\n 經過兩層跳脫,落地變成真的 CR 和 LF 字元:

0000040   /  \r   ?  \n   /   )   )       {  \n

也就是 /、CR、?、換行、/,正則被拆成兩行。這段在舊版 CLI 驗證時被第一個錯蓋住,所以那時其實整個 mod 都載不起來。

接著試了好幾次 sed -i、Python、Node 去修那一行,檔案修改時間都沒動,原因到最後沒查清楚。最後改用 Write 整檔重寫,才過。

3. $ 不能傳給 register 裡的區域函式

compiled line 74 `await refresh($);`: $ is passed to "refresh", which is not a function
declared at the top of this file (a function declaration, or a const bound to one)

我原本把 refresh 寫成 register 裡面的 const refresh = async ($) => {...}。驗證器會靜態追蹤 $ 被傳去哪、呼叫了哪些 API(輸出裡會列出 $.fs.read (via refresh) 這種東西),所以只接受檔案最上層宣告的函式。把 refresh 搬到最外層就過了。之後的 openInObsidian、scanDomains 也都照這個規矩寫在最上層。

4. raw 堆積的第一版:七個域裡五個亮黃燈

CLAUDE.md 有一條「某域 raw/ 同主題聚集 ≥ 5 檔且無對應 wiki → 提議 /wiki-build」。第一版直接翻成程式:「raw 檔名沒有同名 wiki 頁」就算未消化。跑出來:

art wiki=14 orphanRaw=4
books wiki=47 orphanRaw=233
coffee wiki=16 orphanRaw=95
engineer wiki=34 orphanRaw=80
finance wiki=1 orphanRaw=1
ideas wiki=93 orphanRaw=28
others wiki=2 orphanRaw=15

books 233、coffee 95,等於全部亮燈,沒有資訊量。原因是 raw 和 wiki 本來就不是一對一:books/raw 是讀書筆記的沉澱,很多書不會另外寫心得頁;一頁 wiki 常常是好幾份 raw 合成的,檔名不會一樣。

改成「raw 的檔名有沒有出現在該域任何 wiki 頁的內文裡」(wiki-build 產出的頁面 sources 會連回 raw),並排除 books:

async function countUndigested($: $, domain: string, wiki: string[]): Promise<number> {
  if (domain === 'books') return 0
  const raw = await mdNames($, `${domain}/raw`)
  if (raw.length === 0) return 0
  let corpus = ''
  for (const name of wiki) {
    try {
      corpus += await $.fs.read(`${domain}/wiki/${name}.md`)
    } catch {}
  }
  return raw.filter(n => !wiki.includes(n) && !corpus.includes(n)).length
}
art unreferenced=1 of 7
coffee unreferenced=21 of 95
engineer unreferenced=34 of 83
finance unreferenced=1 of 1
ideas unreferenced=12 of 31
others unreferenced=15 of 15

還是有四個域亮燈,但這次的數字是真的:engineer 有 34 份 raw 從沒被任何 wiki 提過。這不是「同主題聚集」——判斷同主題要語意,mod 裡做不到——比較接近「還沒消化的量」。

5. h is not a function:變數名稱蓋掉 JSX

加了 HUB 表之後,打 /navi 整個面板壞掉:

ui.render hook skipped: threw HooksError: h is not a function. (In 'h(Box, {

mod 裡寫的 <Box> 會編譯成全域函式 h(Box, …)。我寫了 hubList.map(h => <Box>…),迴圈參數 h 在那段裡把全域的 h 蓋掉,h(Box, …) 變成拿一筆 hub 資料當函式呼叫。驗證器只看語法與 API 用法,抓不到;我自己的測試用空資料畫面板,hub 清單是空的、map 根本沒執行,也抓不到。參數改名 hub 就好了。

6. 表格跑版

統計表第一版每格給固定 width,我看了說「表格有點跑版」:中文與 emoji 在 Desktop 和終端機的顯示寬度不一,欄位就歪掉。改成以「欄」為單位排——每一欄是一個直的 Box,同欄的格子自然靠齊,欄與欄之間再放一欄 │;按鈕的高度跟文字不一定一樣,所以 wiki-build 按鈕移到表格下方,不放進表格裡。

小結

今天在 Claude Code 裡長出一塊 vault 儀表板:

  • 常駐:橫條(今日三頁)、狀態列、輸入框 [[連結]] 標色
  • /navi 側邊面板:TODO(分區配色,點了填 skill 指令)、近期新增 wiki(點了在 Obsidian 開)、各域 wiki 數與 raw 堆積

面板後來長成五個分頁,每一塊要我關注什麼、看到畫面之後又改了哪些,留到 Day27。

改動落在 3 個檔,register.tsx 長到 1131 行,放在 vault 的 .claude/skills/navi/,兩台共用。

還沒做完的事:Mac 那條 open 還沒在 Mac 上跑過,Mac 的 claude CLI 也要先升版。

儀表板之外,mod 還能攔截工具呼叫、改寫送出的 prompt、給 Claude 加一個工具。Day18 那套在主目錄擋寫入的 hook,現在是 sh、PowerShell、Python 三種版本各一份;改寫成 mod 就能兩台共用一份。這些我都想陸續導入,先在 TODO.md 開了一個 ## Mods 區,下一步從守門 hook 開始。

心得

mod 非常適合用在 llm-wiki:把整個 vault 的狀態攤在 session 旁邊,很實用。

最直接的收穫是明確知道自己缺什麼。看到儀表板,才知道還有很多 llm-wiki 的事情沒做——engineer 34 份 raw 從沒被任何 wiki 提過,這個數字平常是看不到的。

每日抽的頁也比預期好用:它會隨機把舊的 wiki 挖出來,看到某一頁時常常又能接上新的想法。這也是後來把一頁改成三頁的原因。

至於做法,這次幾乎都是「先問 Claude 有哪些可以選,再挑」。原因很單純:我對 mod 這個新技術完全不熟,連它能畫在哪些位置都不知道,所以想透過 AI 先把可能性發散出來,我再決定要哪些。


本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day26-vault-mod/