SLOPSHOPPER

mod

No description.

newpromptagents
A shopper browsing a rack in a slop shop
README

FanKeel

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.

Install

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 FanKeel and everything you type is fankeel. 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.

Update

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.

Uninstall

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.

The pipeline

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.

Every stage ends at a gate

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

The badge, and the station

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.

The five scanners

node scripts/docs-check.jsEvery reference still resolves. A second to run, and the verify and audit rules call for it.
node scripts/residue.jsWhat 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.jsThe 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.jsClaude 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.jsEvery 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.

Where to find things

I want to knowPage
What /fankeel asks me, the seven stages, and how a route is chosendocs/pipeline.md
What .fankeel/map.md holds, and why a page marked design-intent is not driftdocs/pipeline.md
What gets written to disk, what is committed, and what notes and next are fordocs/registry.md
What [FANKEEL:CLASH] means, and how to stop a collision raising a promptdocs/collisions.md
What docs.json declares, and why an archive naming deleted code is not a bugdocs/documents.md
What a subagent is told when it starts, and when /fankeel-ask is worth the moneydocs/subagents.md
The badge word, and how to colour each stagedocs/statusline.md
Every session on this machine on one page, and how to put an abandoned one downdocs/station.md
Why fankeel ships no output style, and where its voice lives insteaddocs/decisions/2026-09-13-no-output-styles.md
How the plugin is built and checked, and the four scripts that stop a claim driftingdocs/development.md
How to run the behaviour eval, and what to do when claude plugin eval says early accessdocs/evals.md
Why any of it was built this waydocs/decisions/fankeel-shell.md

The full index, question by question, is docs/README.md.

What lives where

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

Development

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.

Source 1 files
hooks/probe.ts 210 lines
1import 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