一次性量測:fankeel 改走 mod 之前的四項實測(session c0d39e91),量完即關。

A keel is the one structural member a hull cannot lose.
fankeel is a Claude Code plugin that carries a development discipline and states it on every prompt — and again on every answer — rather than once at the top of a session. It holds a task, moves it along a route it picked through seven stages, keeps a capped note of what has been tried, and shows which other live sessions are in the same files.
It exists because long-running projects rot in ways that are invisible from inside any one session: components rebuilt because nobody knew an equivalent existed, design documents piling up after the work they described has shipped, conventions that hold for a month and then quietly stop, and two terminals editing one file because neither knows the other is there.
claude plugin marketplace add FanFantom9452/FanKeel
claude plugin install fankeel@fankeel
Restart Claude Code afterwards. Nothing else is installed: no dependencies, and the tests run on node --test, which is built in. Then, in any project:
/fankeel
It looks before it asks — what is under this directory, which of them is a repository, which was touched today — and then asks at most two questions with the options already on screen: which project, skipped when there is only one, and what the task is, read from the project's TODO — its todo folder, or a root TODO.md. It never asks which files you will touch; those are recorded as the edits land.
The repository is
FanKeeland everything you type isfankeel. Plugin and marketplace ids have to be kebab-case — Claude Code accepts anything else, and the Claude.ai marketplace sync does not — so the id, the command, the badge word and the.fankeel/directory are all lowercase.
It is also one of the plugins claude-kit installs, if you would rather take a whole machine's worth in one command — and that kit wires up TokenBar, which is what draws the badge.
claude plugin marketplace update fankeel
claude plugin update fankeel@fankeel
Restart Claude Code afterwards, the same as installing. The marketplace line comes first because plugin update compares against the listing already on disk — skip it and there is nothing newer to find. Given no name, marketplace update refreshes every marketplace at once. Re-running claude plugin install is not the update path: the plugin is already installed, and what needs refreshing is the marketplace listing behind it.
claude plugin uninstall fankeel@fankeel
claude plugin marketplace remove fankeel
.fankeel/ is left in place — it is the project's, not the plugin's. Delete it by hand if you want it gone. Stale ~/.claude/modes/<session_id>/fankeel flags are pruned after 30 days while the plugin is installed; after uninstalling, remove any that remain.
Seven stages, each named for what it produces rather than how it feels:
flowchart LR
S["<b>survey</b><br/>search the code, read the docs —<br/>does this already exist?"]
D["<b>design</b><br/>one approach, its trade-offs,<br/>and the test that settles it"]
P["<b>plan</b><br/>split into tasks you can test<br/>and review one at a time"]
B["<b>build</b><br/>write it, test it, commit it —<br/>one review per task"]
V["<b>verify</b><br/>run the tests, check that what<br/>you changed actually changed"]
A["<b>audit</b><br/>find the pages that stopped<br/>being true"]
L["<b>land</b><br/>close the TODOs, rewrite the map,<br/>then merge, PR, or keep"]
S --> D --> P --> B --> V --> A --> L
A route is the stages one task actually needs, in order. Not every task is seven. A class picks one when the task starts, and every prompt from then on carries it with your position bracketed, so a two-stage task is never reported as permanently unfinished at 2 of 7:
spike route: [survey] → build (1 of 2)
bounded route: survey → design → [build] → verify → land (3 of 5)
architectural route: survey → design → plan → [build] → verify → audit → land (4 of 7)
Only the current stage's rules are sent, and they are sent again every turn — a pointer is only as strong as the salience of what it points at. What each stage produces, what happens inside one, and how a class picks a route are in docs/pipeline.md.
flowchart LR
W["do the work"] --> R["report in this<br/>stage's shape"]
R --> Q{"AskUserQuestion"}
Q -- "1 · approve, move on" --> N["next stage<br/>on the route"]
Q -- "1 · at the last stage" --> D["stand the task down"]
Q -- "2 · stay here" --> W
Q -- "3 · pause" --> P["next is written down;<br/>the task outlives the session"]
N --> W
The gate is not conditional on there being something to decide: finishing a stage is the moment the next decision exists, and the answer being predictable is not the same as it having been given. Picking option one is the approval, so its description says what is being approved — after design, that is the approach itself. Each stage also ships the shape of its report, not only a description of one — the shape build ships:
- path +12/-3 — what changed
- path (new) — what it is
done: <n> of <m> — ledger or file table
docs-check: clean, or <n> fixed and <n> left
deferred: <heading> — <TODO entry, or omit this line>
then AskUserQuestion
fankeel writes one word to ~/.claude/modes/<session_id>/fankeel, and TokenBar renders any flag it finds there — so the two work together with no wiring on either side:
[FANKEEL:BUILD] | Opus 5 | my-project | main ↑2 +42/-7 ?1
ctx ███▊░░░░░░ 38% · 5h ██████▌░░░ 66% ↻ 1h 46m · 7d █████▊░░░░ 58%
The word is the stage, not an intensity. clash takes the slot when another live session is in your files, and init is the gap between /fankeel being submitted and a task existing. The seven stage colours ship with TokenBar from v1.4.0 on; the palette, both config formats and what each colour is doing are in docs/statusline.md.
Every session this machine has run, live or abandoned or stood down, is one page: the station — less any project whose profile sets station.hide, which appears on no row, in no total and in no detail file. node scripts/station.js --open opens the newest, and serve in place of that is the live form — docs/station.md.
node scripts/docs-check.js | Every reference still resolves. A second to run, and the verify and audit rules call for it. |
node scripts/residue.js | What is in this tree that nobody decided about: untracked and unignored, a worktree whose branch is merged and has nothing uncommitted, an environment nothing can rebuild or run, the weight of what is ignored, directories holding no files. It never deletes. |
node scripts/docs-audit.js | The fortnightly deep pass: which pages have stopped being true, and which two of them disagree. /fankeel-audit is the whole sweep — it runs all five, reads the shortlist they produce, then offers the cleanup. |
node scripts/memory-check.js | Claude Code's own memory for this project: the index and the directory agreeing, cited paths that still exist, path:line inside its file. |
node scripts/input-check.js | Every file loaded into every session's input — global and project CLAUDE.md, each project's MEMORY.md — largest first with bytes and estimated tokens, then what could be trimmed. Lists, never fails, never edits. |
None of them decides that two documents contradict each other, because nothing mechanical can. What the cap is, and why comparing two runs beats comparing two headline counts, is in docs/documents.md.
| I want to know | Page |
|---|---|
What /fankeel asks me, the seven stages, and how a route is chosen | docs/pipeline.md |
What .fankeel/map.md holds, and why a page marked design-intent is not drift | docs/pipeline.md |
What gets written to disk, what is committed, and what notes and next are for | docs/registry.md |
What [FANKEEL:CLASH] means, and how to stop a collision raising a prompt | docs/collisions.md |
What docs.json declares, and why an archive naming deleted code is not a bug | docs/documents.md |
What a subagent is told when it starts, and when /fankeel-ask is worth the money | docs/subagents.md |
| The badge word, and how to colour each stage | docs/statusline.md |
| Every session on this machine on one page, and how to put an abandoned one down | docs/station.md |
| Why fankeel ships no output style, and where its voice lives instead | docs/decisions/2026-09-13-no-output-styles.md |
| How the plugin is built and checked, and the four scripts that stop a claim drifting | docs/development.md |
How to run the behaviour eval, and what to do when claude plugin eval says early access | docs/evals.md |
| Why any of it was built this way | docs/decisions/fankeel-shell.md |
The full index, question by question, is docs/README.md.
One row per directory, and under the three that run, every hook and the lib/ and scripts/ files worth opening first: hooks/ is what Claude Code calls, scripts/ is what a person or a skill runs, and lib/ is what both of them call. node scripts/layout.js prints the half of this a listing can derive; the right-hand column is the half it cannot.
fankeel/
├── .claude-plugin/ plugin.json — the skills, the eight agents, every hook and its timeout — and marketplace.json
├── .fankeel/ this repository's own settings: docs.json files each page, profile.json answers gates, .gitignore; and, generated and ignored, .fankeel/station.bat and station.sh, which open this machine's station
├── agents/ the eight subagents the stages dispatch — reader, reviewer, verifier, judge, fixer, brain, render-reviewer, mockup — with their tools and model
├── assets/ the station page: index.html, station.css and station.js, copied beside every page a write produces; tour.html, tour.css and tour*.js, the one-minute promo film
├── docs/ reference pages by audience: 01-guide/, 02-architecture/, 03-decisions/, and 90-agent/ (reference/, plans/, reports/, judgements/) with 99-archive/ for what it retires
├── evals/ behaviour eval cases, one directory each, graded by scripts/eval.js with claude -p
├── hooks/ every hook Claude Code runs; each reads stdin, exits 0 on every path and leaves the work to lib/
│ ├── inject.js UserPromptSubmit: the block on every prompt, the init block on /fankeel, the badge
│ ├── resume.js PostToolUse on AskUserQuestion: the stage's rules again once a gate is answered
│ ├── gate.js PreToolUse on AskUserQuestion: stamps when a gate opened, so the wait can be timed; on a controlled stage it also validates that the controller's own question copies the handoff's gate word for word, denying a botched attempt or a malformed file gate; with `gate.station` set, it first waits that many seconds for the station's answer
│ ├── guard.js PreToolUse on writes, shells and subagent dispatches: the scope guard, read-only agents kept read-only, and no fankeel-brain for a stage stage.agents does not name; and every git commit scanned for the words in .fankeel/sensitive.txt
│ ├── touch.js PostToolUse on Edit, Write, NotebookEdit: the files this task touched
│ ├── brief.js SubagentStart: what a subagent is told about the task it was sent from
│ ├── carry.js SessionStart on clear or fork: offers the task a /clear left behind
│ └── leave.js SessionEnd: how the session ended and what it spent, and the station rewritten
├── lib/ the logic, as functions tested directly; nothing here reaches into scripts/ or hooks/
│ ├── registry.js one entry per session under .fankeel/sessions/, written by rename so no read is torn
│ ├── stages.js the seven stages, the three classes and their routes, every stage's rules and output shape
│ ├── render.js the injected blocks: every prompt, after a gate, on /fankeel, when a task is renamed, for a subagent, after /clear
│ ├── live.js which sessions are running, read from Claude Code's own sessions/<pid>.json
│ ├── overlap.js which live sessions have touched the same files
│ ├── guard.js the scope guard's answer to an edit in another live session's files: nothing, ask or deny
│ ├── badge.js the statusline word and lead line TokenBar draws
│ ├── map.js .fankeel/map.md: the signpost, the filing, this tree, the planned and retired pages
│ ├── docs.js docs.json: buckets, roles, and which pages may be out of date
│ ├── onboard.js the three cheap onboarding checks task.js start runs on a task's project
│ ├── sensitive.js the words in .fankeel/sensitive.txt, found in what a commit carries
│ ├── station.js the station's model: finding every registry, the rows, the data and detail scripts; detail.js takes one session apart, cached by its files' size and mtime
│ ├── usage.js what a transcript spent: requests, models, agents and every dispatch; replay.js is a session's events in time order, and one agent's own steps
│ ├── plantasks.js a plan's tasks, and which of them may run at once
│ ├── handoff.js a stage agent's report and the user's answer, under .fankeel/build/task-<started>/
│ └── profile.js the project and machine profile: the standing answers to a gate
├── scripts/ the command line, thin wrappers over lib/
│ ├── task.js start a task, move its stage, note, pause, stand it down
│ ├── orient.js what is under this directory, before /fankeel asks anything
│ ├── map.js writes .fankeel/map.md
│ ├── onboard.js prints the onboarding checks, pass or fail; --full adds docs-check and drift
│ ├── layout.js prints the half of this tree a listing can derive
│ ├── survey.js what already exists here, for the survey stage
│ ├── ledger.js the build ledger: init, complete, groups, lint, brief
│ ├── station.js writes the station page, or serves it live
│ ├── docs-check.js every reference in the documents still resolves
│ ├── docs-audit.js which pages stopped being true, and which two disagree
│ ├── residue.js what is in the tree that nobody decided about
│ ├── todo-check.js whether the TODO entries are still an index
│ ├── judge.js files what a fankeel-judge answered, verbatim
│ ├── upgrade.js which migrations an older project still needs, and --apply for the safe one
│ └── version.js the release number, in every place that carries it
├── skills/ one directory per skill — fankeel, one per stage, ask, explain, init, station, upgrade — and registry.json
└── tests/ node --test, one file per module or behaviour; tmp.js is where every scratch directory comes from
npm test
claude plugin validate .
lib/ is pure logic, tested directly; hooks/ is where stdin, stdout and process exit live, and every hook exits 0 on every path, because a hook that throws blocks the thing it was called for and a plugin that can wedge your terminal is worse than no plugin. todo-check.js, version.js, skills-check.js and stage-registry.js each hold one written claim to the code it describes, and docs/development.md says what each of them checks. The behaviour eval and its runner are in docs/evals.md.
hooks/probe.ts 210 lines1import type { Register, EngineInterface } from 'claude-code'
2
3// 一次性量測 mod(docs/90-agent/plans/2026-10-03-mod-probe-design.md)。
4// 每次 hook 觸發寫一個單行 JSON 記錄檔;$.fs.write 只能整檔寫入,所以一事件一檔,
5// 檔名依時間排序就是事件順序。共三處改寫:prompt.compose 對每個系統提示尾端加標記段,
6// agent.spawn 與 turn.step 只動帶 mod-probe 標記的派遣。
7const LOG = 'F:/ymlab/fankeel/.fankeel/build/task-20261003T060232/probe2/records'
8const EFFORT = /MOD-PROBE-EFFORT=(low|medium|high|xhigh)/
9
10let nonce = ''
11const effortOf = new Map<string, string | null>()
12let probedTurn: string | undefined
13
14// 啟用與熱重載都會讓 session.start 再跑一次(d.ts:4057),模組變數也會清空;
15// nonce 存在 $.store 的 'probe.nonce3',沿用舊值才不會中途換號(換新鍵是為了讓主 session 沒看過新值)。
16async function getNonce($: EngineInterface): Promise<string> {
17 if (nonce) return nonce
18 const kept = await $.store.get('probe.nonce3')
19 if (typeof kept === 'string' && kept) {
20 nonce = kept
21 return nonce
22 }
23 const fresh = crypto.randomUUID()
24 await $.store.set('probe.nonce3', fresh)
25 nonce = fresh
26 return nonce
27}
28
29function suffix(): string {
30 return Math.random().toString(36).slice(2, 6).padEnd(4, '0')
31}
32
33// 寫失敗不讓 hook 失敗:hook 一失敗就被跳過,連帶丟掉它的改寫。
34async function record($: EngineInterface, event: string, fields: Record<string, unknown>): Promise<void> {
35 const at = Date.now()
36 const name = `${String(at).padStart(13, '0')}-${event}-${suffix()}.json`
37 try {
38 await $.fs.write(`${LOG}/${name}`, JSON.stringify({ event, at, ...fields }) + '\n')
39 } catch (err) {
40 $.ui.log(`fankeel-mod-probe: ${event} record not written: ${String(err)}`)
41 }
42}
43
44function textOf(content: unknown): string {
45 if (typeof content === 'string') return content
46 if (!Array.isArray(content)) return ''
47 return content
48 .map((b: { type?: string; text?: string }) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
49 .join('')
50}
51
52export const register: Register = (on) => {
53 on('session.start', async ($, e, next) => {
54 const n = await getNonce($)
55 await record($, 'session.start', { nonce: n, isInteractive: e.isInteractive })
56 return next(e)
57 })
58
59 // 改寫一:系統提示尾端加一段固定的被動標記,scope 為 session,放在所有 shared 之後。
60 on('prompt.compose', async ($, e, next) => {
61 try {
62 const n = await getNonce($)
63 await record($, 'prompt.compose', {
64 model: e.model,
65 promptModel: e.promptModel,
66 traits: e.traits,
67 tools: e.tools,
68 surfaces: e.surfaces,
69 })
70 const out = await next(e)
71 const idsOf = (list: readonly { id: string; scope: string; text: string }[]) =>
72 list.map((s) => ({ id: s.id, scope: s.scope, len: s.text.length }))
73 // shared 段必須排在所有 session 段之前,所以第二個標記插在第一個 session 段前面。
74 const firstSession = out.sections.findIndex((s) => s.scope === 'session')
75 const at = firstSession === -1 ? out.sections.length : firstSession
76 const shared = { id: 'fankeel-mod-probe:marker-shared', text: `MOD-PROBE-COMPOSE-SHARED-${n}:量測標記,不需理會。`, scope: 'shared' as const }
77 const sections = [
78 ...out.sections.slice(0, at),
79 shared,
80 ...out.sections.slice(at),
81 { id: 'fankeel-mod-probe:marker', text: `MOD-PROBE-COMPOSE-${n}:量測標記,不需理會。`, scope: 'session' as const },
82 ]
83 await record($, 'prompt.compose.after', {
84 sectionsLengthAfterNext: out.sections.length,
85 lastIdAfterNext: out.sections.length ? out.sections[out.sections.length - 1].id : null,
86 sectionsLengthWithMarker: sections.length,
87 idsAfterNext: idsOf(out.sections),
88 idsReturned: idsOf(sections),
89 texts: sections.map((s) => s.text.slice(0, 40)),
90 })
91 $.ui.log(`fankeel-mod-probe compose returned ids=${sections.map((s) => s.id).join(',')}`, { to: 'debug' })
92 return { ...out, sections }
93 } catch (err) {
94 await record($, 'prompt.compose.error', {
95 errName: err instanceof Error ? err.name : typeof err,
96 errMessage: err instanceof Error ? err.message : String(err),
97 })
98 throw err
99 }
100 })
101
102 // 純旁路:只記 next 之後的結果,原樣回傳。
103 on('prompt.section', async ($, e, next) => {
104 const out = await next(e)
105 await record($, 'prompt.section', {
106 name: e.name,
107 textLengthBefore: e.text?.length ?? null,
108 textLengthAfter: out.text?.length ?? null,
109 head: (out.text ?? '').slice(0, 60),
110 })
111 return out
112 })
113
114 // 改寫二:只對 description 含 "mod-probe b" 的派遣,在 prompt 尾端加一行。
115 // 加在尾端,因為 hooks/brief.js:95 的 stageOfPrompt 靠 prompt 的第一個字判斷站名。
116 on('agent.spawn', async ($, e, next) => {
117 const n = await getNonce($)
118 const isB = e.description.includes('mod-probe b')
119 const prompt = isB ? `${e.prompt}\nMOD-PROBE-SPAWN-${n}` : e.prompt
120 const startedAt = Date.now()
121 const res = await next(isB ? { ...e, prompt } : e)
122 await record($, 'agent.spawn', {
123 startedAt,
124 tool_use_id: e.tool_use_id,
125 description: e.description,
126 subagentType: e.subagentType,
127 parentAgentId: e.parentAgentId ?? null,
128 promptLengthBefore: e.prompt.length,
129 promptLengthAfter: prompt.length,
130 agentId: res.agentId ?? null,
131 model: res.model ?? null,
132 deny: res.deny ?? null,
133 })
134 return res
135 })
136
137 // 改寫三:子代理的請求,若它第一則 assistant 之前的 user 列帶 MOD-PROBE-EFFORT=<level>,
138 // 就把 effort 改成該等級。串流事件,只能寫成 async function*(reference.md:28-30)。
139 on('turn.step', async function* ($, e, next) {
140 let effort = e.effort
141 let note: string | null = null
142 if (e.agentId !== undefined) {
143 let level = effortOf.get(e.agentId)
144 if (level === undefined) {
145 const found = await $.session.messages({ agentId: e.agentId })
146 if (!Array.isArray(found)) {
147 note = `messages denied: ${String((found as { deny?: string }).deny)}`
148 } else {
149 const head: string[] = []
150 for (const m of found) {
151 if (m.role === 'assistant') break
152 head.push(m.text)
153 }
154 if (head.length) {
155 const hit = EFFORT.exec(head.join('\n'))
156 level = hit ? hit[1] : null
157 effortOf.set(e.agentId, level)
158 }
159 }
160 }
161 if (level) effort = level as typeof e.effort
162 }
163 // 每個 turn 只在主 loop 第一步探一次:$.prompt.compose() 走每個外掛的 compose hook,
164 // 只會重入 prompt.compose,不會重入 turn.step。任何失敗只記錄,不影響 step。
165 if (e.agentId === undefined && e.index === 0 && probedTurn !== e.turnId) {
166 probedTurn = e.turnId
167 try {
168 const n = await getNonce($)
169 const final = await $.prompt.compose()
170 const secs = final.sections
171 await record($, 'compose.final', {
172 ids: secs.map((s) => ({ id: s.id, scope: s.scope, len: s.text.length })),
173 hasSession: secs.some((s) => s.text.includes(`MOD-PROBE-COMPOSE-${n}`)),
174 hasShared: secs.some((s) => s.text.includes(`MOD-PROBE-COMPOSE-SHARED-${n}`)),
175 })
176 } catch (err) {
177 await record($, 'compose.final', { error: err instanceof Error ? err.message : String(err) })
178 }
179 }
180 const result = yield* next(effort === e.effort ? e : { ...e, effort })
181 await record($, 'turn.step', {
182 agentId: e.agentId ?? null,
183 index: e.index,
184 model: e.model,
185 effortBefore: e.effort ?? null,
186 effortSent: effort ?? null,
187 outputTokens: result && result.usage ? result.usage.output_tokens : null,
188 note,
189 })
190 return result
191 })
192
193 on('classic.SubagentStart', async ($, e, next) => {
194 await record($, 'classic.SubagentStart', { agent_id: e.agent_id, agent_type: e.agent_type })
195 return next(e)
196 })
197
198 // 只記 hook-context 列:fankeel 的 command hooks(inject.js、brief.js)的輸出從這裡進來。
199 on('session.append', { door: 'hook-context' }, async ($, e, next) => {
200 const text = textOf(e.message.content)
201 await record($, 'session.append', {
202 agentId: e.agentId ?? null,
203 origin: e.origin,
204 head: text.slice(0, 80),
205 hasFankeel: text.includes('FANKEEL'),
206 })
207 return next(e)
208 })
209}
210