Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


commit 814be9a54e4062da4972978745e63da86572bde8
Spenser Truex <13551856+equwal@users.noreply.github.com>
2026-09-20 21:28:13 -0700

ideamine 0.1.0: an idea inbox for Claude Code

- /idea and /ideas are answered by a UserPromptSubmit hook before any
  API call: capture costs 0 tokens and works on any model, mid-task,
  or at the usage limit.
- One global archive (~/.ideamine/ideas.json), locked atomic writes.
- /idea-triage scores ideas (verdict, impact, size) and picks the
  cheapest capable model, via one minimal headless claude -p call.
- /idea-go builds an idea in a subagent on its recommended model.
- Zero-dependency MCP server, CLI, and plugin marketplace manifest.

 .claude-plugin/marketplace.json |  22 +++
 .claude-plugin/plugin.json      |  13 ++
 .gitattributes                  |   1 +
 .gitignore                      |   4 +
 .mcp.json                       |   8 +
 LICENSE                         |  21 +++
 README.md                       | 139 +++++++++++++++++
 bin/ideamine.js                 | 178 +++++++++++++++++++++
 hooks/hooks.json                |  17 ++
 package.json                    |  43 ++++++
 skills/idea-go/SKILL.md         |  14 ++
 skills/idea-triage/SKILL.md     |  12 ++
 skills/idea/SKILL.md            |  13 ++
 skills/ideas/SKILL.md           |  11 ++
 src/claude.js                   | 139 +++++++++++++++++
 src/hook.js                     |  97 ++++++++++++
 src/mcp.js                      | 309 ++++++++++++++++++++++++++++++++++++
 src/render.js                   | 135 ++++++++++++++++
 src/rubric.js                   | 101 ++++++++++++
 src/store.js                    | 335 ++++++++++++++++++++++++++++++++++++++++
 src/text.js                     |  88 +++++++++++
 tests/fixtures/fake-claude.js   |  34 ++++
 tests/hook.test.js              |  78 ++++++++++
 tests/mcp.test.js               | 116 ++++++++++++++
 tests/store.test.js             |  85 ++++++++++
 tests/text.test.js              |  35 +++++
 tests/triage.test.js            |  68 ++++++++
 27 files changed, 2116 insertions(+)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..df36de1
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,22 @@
+{
+  "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
+  "name": "ideamine",
+  "description": "ideamine: an idea inbox for Claude Code, with model routing",
+  "owner": {
+    "name": "Spenser Truex",
+    "url": "https://github.com/equwal"
+  },
+  "plugins": [
+    {
+      "name": "ideamine",
+      "description": "An idea inbox for Claude Code. /idea saves an idea without a model call. Claude triages the ideas and picks the cheapest model that can build each one.",
+      "version": "0.1.0",
+      "author": {
+        "name": "Spenser Truex"
+      },
+      "source": "./",
+      "category": "productivity",
+      "homepage": "https://github.com/equwal/ideamine"
+    }
+  ]
+}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 0000000..d577439
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,13 @@
+{
+  "name": "ideamine",
+  "version": "0.1.0",
+  "description": "An idea inbox for Claude Code. /idea saves an idea without a model call, so it works at any time, even at your usage limit. Claude triages the ideas and picks the cheapest model that can build each one.",
+  "author": {
+    "name": "Spenser Truex",
+    "url": "https://github.com/equwal"
+  },
+  "homepage": "https://github.com/equwal/ideamine",
+  "repository": "https://github.com/equwal/ideamine",
+  "license": "MIT",
+  "keywords": ["ideas", "backlog", "inbox", "triage", "model-routing", "mcp", "tokens"]
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d56c12b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+*.tmp
+*.log
+.DS_Store
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..4bed0e0
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,8 @@
+{
+  "mcpServers": {
+    "ideamine": {
+      "command": "node",
+      "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ideamine.js", "mcp", "--plugin"]
+    }
+  }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..da3111d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Spenser Truex
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b4e321e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,139 @@
+# ideamine
+
+**An idea inbox for Claude Code.** Save ideas as fast as you have them. Claude sorts them out later.
+
+```
+> /idea let the scroller pause when the mouse hovers over a link
+  💡 Saved #43 · let the scroller pause when the mouse hovers over a link  (6 in inbox)
+```
+
+That line did not call the model. A hook answers `/idea` on your machine before any request goes to Claude, so saving an idea:
+
+- **costs 0 tokens.** `claude -p "/idea …"` reports 0 turns, 0 input tokens, 0 output tokens, $0.
+- **does not interrupt the current task.** Claude never sees the idea, so it cannot get distracted by it.
+- **works on any model, in any session, and when you are out of usage.** Nothing is sent, so there is nothing to rate-limit.
+
+Every session writes to one archive, `~/.ideamine/ideas.json`, whatever project or model it uses. When you are ready, Claude triages the pile. It decides which ideas are worth doing and picks the **cheapest model that can build each one**: Haiku for a typo, Sonnet for a feature, Opus for a redesign. Fable is only for the hardest problems.
+
+## Install
+
+In Claude Code (CLI, desktop, or IDE):
+
+```bash
+claude plugin marketplace add equwal/ideamine
+```
+
+```bash
+claude plugin install ideamine@ideamine
+```
+
+Or from inside a session: `/plugin marketplace add equwal/ideamine`, then `/plugin install ideamine@ideamine`. Start a new session to load it. Requires Node.js 18 or later. There are no dependencies to install. Tested on Claude Code 2.1.224 (CLI) and 2.1.275 (desktop app) on Windows.
+
+Marketplaces you add yourself do not auto-update. To upgrade, run `claude plugin marketplace update ideamine`, then `claude plugin update ideamine@ideamine`.
+
+## Commands
+
+| Command | What it does | Calls the model? |
+|---|---|---|
+| `/idea <text>` | Save an idea. A pasted bulleted list saves one idea per bullet. `#tags` are recorded. | **No** |
+| `/ideas` | Show the board: doing, do (best first), maybe, inbox | **No** |
+| `/ideas #12` | Show one idea in full: brief, model, notes | **No** |
+| `/ideas done 12 shipped it` | Quick edit. Also `drop`, `start`, `reopen` | **No** |
+| `/ideas inbox` · `do` · `maybe` · `skip` · `done` · `all` · `here` | Filter the board. `here` = this project only | **No** |
+| `/ideas <question>` | Ask about your ideas, e.g. "which ones fit in an hour?" | Yes, briefly |
+| `/idea-triage` | Score the inbox and choose a model for each idea | Yes, briefly |
+| `/idea-go [id]` | Build the top idea (or `#id`) with its recommended model | Yes, this is the build |
+
+In the plugin menu the commands also appear as `/ideamine:idea` and so on. Both forms work.
+
+Claude can also save ideas by itself. If you write "idea: dark mode for the popup" or "save that for later", it calls the `idea_add` tool and continues the current task.
+
+## Model routing
+
+`/idea-triage` gives each idea a verdict (`do`, `maybe`, `skip`), an impact from 1 to 5, a size from `xs` to `xl`, a one-line reason, and a short brief that an agent can act on without the original chat. It also picks the cheapest model that is likely to finish the idea in one pass. If a weaker model fails and has to retry, that costs more than using the right model once.
+
+| Model | $ in / out per 1M tokens | Gets ideas like |
+|---|---|---|
+| `haiku` (Haiku 4.5) | $1 / $5 | mechanical, fully specified, local work: typos, renames, config tweaks, boilerplate, small scripts |
+| `sonnet` (Sonnet 5) | $2 / $10 | the default: ordinary features, bug fixes with a clear repro, tests, docs, contained refactors |
+| `opus` (Opus 5) | $5 / $25 | ambiguous or cross-cutting work: architecture, hard debugging, performance, security |
+| `fable` (Fable 5.1) | $10 / $50 | only the hardest long-horizon or research-grade problems |
+
+The triage does not run on your session's model. `/idea-triage` makes one tool call, and the MCP server hands the work to a separate, minimal `claude -p` run on Sonnet (see below). A session on Opus or Fable therefore pays the same few cents as a session on Haiku. If that CLI is not available, Claude does the triage itself.
+
+Recommendations are stored as aliases, so they stay valid when a newer model ships under the same name. `/idea-go` gives the build to a subagent on the recommended model. The subagent starts with a clean context, so the build does not also re-read your whole conversation. To override a recommendation, run `ideamine model 12 opus` or ask Claude.
+
+## Headless triage
+
+```bash
+ideamine triage
+```
+
+`/idea-triage` uses this same engine. It is one `claude -p` call on your normal Claude Code login, with no tools, no MCP servers, no settings, a two-line system prompt, and a JSON schema for the output. Four ideas take about 1,700 input tokens, roughly two cents on Sonnet. A normal model turn in a setup with a few MCP servers can re-read tens of thousands of tokens. To keep the inbox sorted while you sleep, schedule the command with cron or Task Scheduler. `--model haiku` makes it cheaper, and `--dry-run` shows the exact prompt.
+
+## Command line
+
+```bash
+npm install -g github:equwal/ideamine
+```
+
+```
+ideamine add "support vim keys in the popup"      # "-" reads stdin
+ideamine ls [inbox|do|maybe|skip|doing|done|all] [here]
+ideamine show 12
+ideamine done 12 "shipped in v1.4"                # also: drop, start, reopen, note
+ideamine next                                     # what to build next
+ideamine go 12                                    # opens Claude Code on the right model, in the idea's project
+ideamine triage                                   # headless triage (see above)
+ideamine export IDEAS.md                          # Markdown copy of everything
+```
+
+## Other MCP clients
+
+The MCP server works without the plugin. For Claude Desktop, Cursor, or any stdio MCP client:
+
+```json
+{
+  "mcpServers": {
+    "ideamine": { "command": "npx", "args": ["-y", "github:equwal/ideamine", "mcp"] }
+  }
+}
+```
+
+Tools: `idea_add`, `idea_list`, `idea_triage`, `idea_update`, `idea_next`. Outside the plugin, the slash commands are MCP prompts. Without the hook, saving goes through the model, which costs a few tokens.
+
+## Where your ideas live
+
+`~/.ideamine/ideas.json` is plain, readable JSON. Set `IDEAMINE_HOME` to move it, for example into a synced folder. Each write takes a lock and then replaces the file in one step, so many sessions can write at the same time without losing an idea. The previous version is kept as `ideas.json.bak`. If the file becomes damaged, ideamine stops and does not overwrite it. Nothing leaves your machine, except when you run triage or a build, which go through Claude as usual.
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `IDEAMINE_HOME` | `~/.ideamine` | archive location |
+| `IDEAMINE_TRIAGE_MODEL` | `sonnet` | model for `ideamine triage` |
+| `IDEAMINE_CLAUDE_BIN` | `claude` | Claude Code executable |
+| `IDEAMINE_SETTING_SOURCES` | *(empty)* | set to `user` if your login needs `settings.json` (e.g. `apiKeyHelper`) |
+
+## How it works
+
+```
+/idea …       ──► UserPromptSubmit hook ──► ~/.ideamine/ideas.json ──► "💡 Saved #43"   (model never called)
+/ideas        ──► same hook, renders the board locally
+
+/idea-triage  ──► Claude ──► MCP idea_triage ──► claude -p (Sonnet, minimal context)
+                                              ──► verdict · impact · size · cheapest capable model · brief
+/idea-go      ──► Claude ──► subagent on that model ──► builds it ──► idea_update: done
+```
+
+The plugin contains a Node MCP server with no dependencies, four skills (the slash commands), and one hook. The hook ignores every prompt except `/idea` and `/ideas`, and those end before any API call. The hook runs directly, not through a shell, and takes about 130 ms per prompt on Windows. The skills are user-only, so their descriptions add no tokens to your sessions. If the archive cannot be read, the hook lets the prompt through, so the `/idea` skill can still save it with the MCP tool. Your text is never dropped.
+
+## Development
+
+```bash
+npm test
+```
+
+The tests use `node:test` only. They cover the store, including concurrent writers from several processes, the hook, the MCP protocol, and headless triage. Triage runs against a stand-in `claude`, so the tests spend no tokens.
+
+## License
+
+MIT
diff --git a/bin/ideamine.js b/bin/ideamine.js
new file mode 100644
index 0000000..5a38928
--- /dev/null
+++ b/bin/ideamine.js
@@ -0,0 +1,178 @@
+#!/usr/bin/env node
+// ideamine CLI. Also the entry point the plugin uses for its MCP server (`mcp`) and hook (`hook`).
+
+import fs from 'node:fs';
+
+const HELP = `ideamine: an idea inbox for Claude Code
+
+  ideamine add <idea...>          save an idea ("-" reads stdin; a bulleted list = one idea per bullet)
+  ideamine ls [filter] [here]     board. filters: open inbox do maybe skip doing done dropped all
+  ideamine show <id>              one idea in full
+  ideamine done|drop|start|reopen <id> [note...]
+  ideamine note <id> <text...>    append a note
+  ideamine model <id> <haiku|sonnet|opus|fable>   override the recommended model
+  ideamine next [--here]          the idea to build next
+  ideamine go [id] [--print]      open Claude Code on the idea's recommended model, in its project
+  ideamine triage [--model sonnet] [--limit 20] [--dry-run]
+                                  score the inbox with one headless \`claude -p\` call
+  ideamine export [file.md]       Markdown export of the whole archive
+  ideamine path                   where the archive lives (override with IDEAMINE_HOME)
+  ideamine mcp                    run the MCP server on stdio
+  ideamine hook                   run the UserPromptSubmit hook (reads the hook JSON on stdin)`;
+
+function parseArgs(argv) {
+  const flags = {};
+  const words = [];
+  for (let i = 0; i < argv.length; i++) {
+    const a = argv[i];
+    if (a.startsWith('--')) {
+      const [key, inline] = a.slice(2).split('=', 2);
+      if (inline !== undefined) flags[key] = inline;
+      else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && ['model', 'limit', 'budget', 'query'].includes(key)) flags[key] = argv[++i];
+      else flags[key] = true;
+    } else words.push(a);
+  }
+  return { flags, words };
+}
+
+async function readStdin() {
+  let data = '';
+  for await (const chunk of process.stdin) data += chunk;
+  return data;
+}
+
+function fail(message) {
+  process.stderr.write(`ideamine: ${message}\n`);
+  process.exit(1);
+}
+
+async function main() {
+  const [command = 'help', ...rest] = process.argv.slice(2);
+
+  // The two machine entry points load only what they need.
+  if (command === 'mcp') return (await import('../src/mcp.js')).serve({ prompts: !rest.includes('--plugin') });
+  if (command === 'hook') return (await import('../src/hook.js')).runHook();
+
+  const store = await import('../src/store.js');
+  const render = await import('../src/render.js');
+  const { splitIdeas, clip } = await import('../src/text.js');
+  const { flags, words } = parseArgs(rest);
+  const cwd = process.cwd();
+  const needId = () => words[0] || fail(`usage: ideamine ${command} <id>`);
+
+  switch (command) {
+    case 'add': {
+      const text = words.length === 1 && words[0] === '-' ? await readStdin() : words.join(' ');
+      const results = store.addIdeas(splitIdeas(text), { source: 'cli', project: flags['no-project'] ? null : cwd });
+      console.log(render.renderAdded(results, store.load()));
+      break;
+    }
+    case 'ls':
+    case 'list':
+    case 'board': {
+      const lower = words.map((w) => w.toLowerCase());
+      const filter = lower.find((w) => store.FILTERS.includes(w)) || 'open';
+      const here = lower.includes('here') || flags.here;
+      console.log(render.renderBoard(store.load(), { filter, project: here ? cwd : null, query: flags.query || '', cwd }));
+      break;
+    }
+    case 'show': {
+      const idea = store.findIdea(store.load(), needId());
+      if (!idea) fail(`no idea #${words[0]}`);
+      console.log(render.renderIdea(idea));
+      break;
+    }
+    case 'done':
+    case 'drop':
+    case 'start':
+    case 'doing':
+    case 'reopen': {
+      const status = { drop: 'dropped', start: 'doing' }[command] || command;
+      const note = words.slice(1).join(' ');
+      const idea = store.updateIdea(needId(), { status, note: note || undefined });
+      console.log(`#${idea.id} ${clip(idea.title, 60)} → ${store.lane(idea)}`);
+      break;
+    }
+    case 'note': {
+      const note = words.slice(1).join(' ') || fail('usage: ideamine note <id> <text>');
+      const idea = store.updateIdea(needId(), { note });
+      console.log(`#${idea.id}: note added`);
+      break;
+    }
+    case 'model': {
+      const idea = store.updateIdea(needId(), { model: words[1] || fail('usage: ideamine model <id> <haiku|sonnet|opus|fable>') });
+      console.log(`#${idea.id} will be built with ${idea.triage.model}`);
+      break;
+    }
+    case 'next': {
+      const idea = store.pickNext(store.load(), { project: cwd, only: !!flags.here });
+      if (!idea) {
+        console.log('Nothing is ready to build. Triage the inbox first: ideamine triage');
+        break;
+      }
+      console.log(render.renderIdea(idea));
+      break;
+    }
+    case 'go': {
+      const { buildPrompt, launchSession } = await import('../src/claude.js');
+      const db = store.load();
+      const idea = words[0] ? store.findIdea(db, words[0]) : store.pickNext(db, { project: cwd });
+      if (!idea) fail(words[0] ? `no idea #${words[0]}` : 'nothing is ready to build; run: ideamine triage');
+      const model = idea.triage?.model || 'sonnet';
+      const dir = idea.project && fs.existsSync(idea.project) ? idea.project : cwd;
+      const prompt = buildPrompt(idea);
+      if (flags.print) {
+        console.log(`directory: ${dir}\nmodel:     ${model}\n\n${prompt}`);
+        break;
+      }
+      store.updateIdea(idea.id, { status: 'doing' });
+      console.log(`Opening Claude Code (${model}) in ${dir} for #${idea.id}…`);
+      process.exitCode = launchSession({ model, prompt, cwd: dir });
+      break;
+    }
+    case 'triage': {
+      const { headlessTriage } = await import('../src/claude.js');
+      const out = await headlessTriage({
+        model: typeof flags.model === 'string' ? flags.model : undefined,
+        limit: Number(flags.limit) || 20,
+        budget: Number(flags.budget) || 1,
+        dryRun: !!flags['dry-run'],
+      });
+      if (out.message) {
+        console.log(out.message);
+        break;
+      }
+      const ok = out.results.filter((r) => !r.error);
+      const spend = out.tokens ? ` (${out.tokens.input} in / ${out.tokens.output} out tokens, ~$${Number(out.cost || 0).toFixed(4)})` : '';
+      console.log(`Triaged ${ok.length}${spend}:`);
+      for (const r of ok) console.log(`  #${r.id} ${r.verdict.padEnd(5)} ${r.verdict === 'skip' ? '' : `${r.model.padEnd(6)} ${r.size.toUpperCase().padEnd(2)} `}${clip(r.title, 60)}`);
+      for (const r of out.results.filter((x) => x.error)) console.log(`  #${r.id} not saved: ${r.error}`);
+      break;
+    }
+    case 'export': {
+      const md = render.renderMarkdown(store.load());
+      if (words[0]) {
+        fs.writeFileSync(words[0], md);
+        console.log(`Wrote ${words[0]}`);
+      } else process.stdout.write(md);
+      break;
+    }
+    case 'path':
+      console.log(store.dbPath());
+      break;
+    case 'version':
+    case '--version':
+    case '-v':
+      console.log(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
+      break;
+    case 'help':
+    case '--help':
+    case '-h':
+      console.log(HELP);
+      break;
+    default:
+      fail(`unknown command "${command}". Try: ideamine help`);
+  }
+}
+
+main().catch((e) => fail(e.message));
diff --git a/hooks/hooks.json b/hooks/hooks.json
new file mode 100644
index 0000000..88c4d5d
--- /dev/null
+++ b/hooks/hooks.json
@@ -0,0 +1,17 @@
+{
+  "description": "ideamine: answer /idea and /ideas locally, so they never call the model",
+  "hooks": {
+    "UserPromptSubmit": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "node",
+            "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ideamine.js", "hook"],
+            "timeout": 10
+          }
+        ]
+      }
+    ]
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5523c04
--- /dev/null
+++ b/package.json
@@ -0,0 +1,43 @@
+{
+  "name": "ideamine",
+  "version": "0.1.0",
+  "description": "An idea inbox for Claude Code. /idea saves an idea without calling the model; Claude later triages each idea and picks the cheapest model that can build it.",
+  "type": "module",
+  "bin": {
+    "ideamine": "bin/ideamine.js"
+  },
+  "files": [
+    "bin",
+    "src",
+    "skills",
+    "hooks",
+    ".claude-plugin",
+    ".mcp.json",
+    "README.md",
+    "LICENSE"
+  ],
+  "engines": {
+    "node": ">=18"
+  },
+  "scripts": {
+    "test": "node --test"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/equwal/ideamine.git"
+  },
+  "homepage": "https://github.com/equwal/ideamine#readme",
+  "bugs": "https://github.com/equwal/ideamine/issues",
+  "author": "Spenser Truex",
+  "license": "MIT",
+  "keywords": [
+    "claude",
+    "claude-code",
+    "claude-code-plugin",
+    "mcp",
+    "mcp-server",
+    "ideas",
+    "backlog",
+    "vibecoding"
+  ]
+}
diff --git a/skills/idea-go/SKILL.md b/skills/idea-go/SKILL.md
new file mode 100644
index 0000000..2f928f6
--- /dev/null
+++ b/skills/idea-go/SKILL.md
@@ -0,0 +1,14 @@
+---
+name: idea-go
+description: Build the top ideamine idea, or a given #id, with the Claude model it was triaged for.
+argument-hint: "[id]"
+disable-model-invocation: true
+allowed-tools: mcp__plugin_ideamine_ideamine__idea_next, mcp__plugin_ideamine_ideamine__idea_update
+---
+
+Build one idea from the archive, using the model recommended for it. Requested idea: $ARGUMENTS (empty means the best pick).
+
+1. Call `idea_next`. Pass `id` if an id is given. If nothing is ready, say so and suggest /idea-triage. If the idea has no triage yet, ask whether to triage it first, then stop.
+2. Tell the user the title and the recommended model in one line. Then call `idea_update` with status "doing".
+3. Give the build to ONE subagent (Agent tool, general-purpose). Set its `model` to the recommended model (haiku, sonnet, opus, or fable). The purpose of this command is to send each idea to the cheapest model that can do it. In the subagent prompt, include the brief, the original note, and the project directory. Tell the subagent to work only in that directory, and to report what it changed and what is left to do.
+4. When the subagent returns, call `idea_update`. Use status "done" with a one-line note. If work remains, keep status "doing" and write a note that says what remains. Then summarize in 2-3 lines.
diff --git a/skills/idea-triage/SKILL.md b/skills/idea-triage/SKILL.md
new file mode 100644
index 0000000..93cc9de
--- /dev/null
+++ b/skills/idea-triage/SKILL.md
@@ -0,0 +1,12 @@
+---
+name: idea-triage
+description: Triage the ideamine inbox. Decide which ideas are worth doing, and pick the cheapest Claude model that can build each one.
+argument-hint: "[max ideas]"
+disable-model-invocation: true
+allowed-tools: mcp__plugin_ideamine_ideamine__idea_triage
+---
+
+1. Call `idea_triage` with `headless: true`. If a number is given here, pass it as `limit`: $ARGUMENTS
+   This runs the triage in a separate, minimal Claude Code call. It does not use your context, so it costs little whatever model you are on.
+2. If the call succeeds, show its summary as returned and name the single best pick. Stop.
+3. If the call fails (for example, the Claude Code CLI cannot be found), do the triage yourself. Call `idea_triage` with no verdicts to get the rubric and the ideas. Judge every idea. Save all verdicts in ONE `idea_triage` call, with your model name as `by`. Then show the summary.
diff --git a/skills/idea/SKILL.md b/skills/idea/SKILL.md
new file mode 100644
index 0000000..a3de684
--- /dev/null
+++ b/skills/idea/SKILL.md
@@ -0,0 +1,13 @@
+---
+name: idea
+description: Save an idea to the ideamine archive for later without derailing the current task. The ideamine hook usually answers this instantly, with no model call.
+argument-hint: <your idea>
+disable-model-invocation: true
+allowed-tools: mcp__plugin_ideamine_ideamine__idea_add
+---
+
+Save this idea with the `idea_add` tool. Pass it verbatim as `text`:
+
+$ARGUMENTS
+
+Only save it. Do not plan, discuss, or start building it. Reply with the one-line result from the tool, then continue what you were doing before.
diff --git a/skills/ideas/SKILL.md b/skills/ideas/SKILL.md
new file mode 100644
index 0000000..7e0e539
--- /dev/null
+++ b/skills/ideas/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: ideas
+description: Show the ideamine board or one idea, or answer a question about your saved ideas. The ideamine hook answers board and edit requests instantly, with no model call.
+argument-hint: "[open|inbox|do|maybe|done|all|here] | #id | done|drop|start|reopen <id> | a question"
+disable-model-invocation: true
+allowed-tools: mcp__plugin_ideamine_ideamine__idea_list, mcp__plugin_ideamine_ideamine__idea_update
+---
+
+Request: $ARGUMENTS
+
+Call `idea_list`. Pass `filter`, `id`, `query`, or `here` when the request asks for them. Then answer the request from the result. If the request is empty or names only a filter, show the board exactly as returned. To change a status, use `idea_update`. Keep the answer short. Do not start building an idea.
diff --git a/src/claude.js b/src/claude.js
new file mode 100644
index 0000000..cc2bb15
--- /dev/null
+++ b/src/claude.js
@@ -0,0 +1,139 @@
+// Running the Claude Code CLI from ideamine: headless triage, and launching a session to build an idea.
+
+import { spawn, spawnSync } from 'node:child_process';
+import { BATCH_SCHEMA, pendingIdeas, triagePrompt } from './rubric.js';
+import { applyTriage, load, normalizeModel } from './store.js';
+
+/** Claude Code executable: explicit override, else the one running us (desktop app), else PATH. */
+export function claudeBin() {
+  return process.env.IDEAMINE_CLAUDE_BIN || process.env.CLAUDE_CODE_EXECPATH || 'claude';
+}
+
+function command(args) {
+  const bin = claudeBin();
+  // A .js file is run with node, which keeps a stand-in CLI portable (used by the tests).
+  return /\.[cm]?js$/i.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args];
+}
+
+/**
+ * Environment for a separate, independent Claude Code process: drop the variables that tie a
+ * child to the session that started us (nesting guard, host messaging, the parent's effort).
+ */
+function childEnv() {
+  const env = { ...process.env };
+  for (const key of Object.keys(env)) {
+    if (
+      key === 'CLAUDECODE' ||
+      key === 'CLAUDE_PID' ||
+      key === 'CLAUDE_EFFORT' ||
+      key === 'AI_AGENT' ||
+      /^CLAUDE_CODE_(ENTRYPOINT|CHILD_SESSION|HOST_SESSION_ID|SESSION_ID|SESSION_ATTENDED|MESSAGING_.*|SDK_.*|EXECPATH)$/.test(key)
+    ) {
+      delete env[key];
+    }
+  }
+  return env;
+}
+
+function parseLooseJson(text) {
+  const s = String(text || '').replace(/^\s*```(?:json)?\s*|\s*```\s*$/g, '');
+  const start = s.indexOf('{');
+  const end = s.lastIndexOf('}');
+  if (start < 0 || end < start) return null;
+  try {
+    return JSON.parse(s.slice(start, end + 1));
+  } catch {
+    return null;
+  }
+}
+
+function run(args, input, timeoutMs) {
+  const [bin, argv] = command(args);
+  return new Promise((resolve, reject) => {
+    const child = spawn(bin, argv, { env: childEnv(), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
+    let stdout = '';
+    let stderr = '';
+    const timer = setTimeout(() => child.kill(), timeoutMs);
+    child.stdout.on('data', (d) => (stdout += d));
+    child.stderr.on('data', (d) => (stderr += d));
+    child.on('error', (e) => {
+      clearTimeout(timer);
+      reject(e.code === 'ENOENT' ? new Error(`Claude Code CLI not found ("${bin}"). Install it or set IDEAMINE_CLAUDE_BIN.`) : e);
+    });
+    child.on('close', (code) => {
+      clearTimeout(timer);
+      resolve({ code, stdout, stderr });
+    });
+    child.stdin.end(input);
+  });
+}
+
+/**
+ * Triage the inbox with a one-shot `claude -p` call: no tools, no MCP servers, no settings, a
+ * two-line system prompt, and JSON-schema output. Runs on the user's normal Claude Code login and
+ * costs about 400 tokens per idea, whatever model the calling session uses.
+ */
+export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL || 'sonnet', limit = 20, dryRun = false, budget = 1 } = {}) {
+  const alias = normalizeModel(model) || model;
+  const db = load();
+  const pending = pendingIdeas(db, { limit });
+  if (!pending.length) return { message: 'Nothing to triage: the inbox is empty.' };
+
+  const prompt = `${triagePrompt(db, pending)}\n\nReturn one verdict for every idea listed above.`;
+  // Skipping settings drops hooks, plugins, and skill listings: ~4k fewer input tokens per call.
+  // Set IDEAMINE_SETTING_SOURCES=user if your login depends on settings.json (apiKeyHelper, env).
+  const args = [
+    '-p',
+    '--model', alias,
+    '--output-format', 'json',
+    '--json-schema', JSON.stringify(BATCH_SCHEMA),
+    '--tools', '',
+    '--strict-mcp-config',
+    '--setting-sources', process.env.IDEAMINE_SETTING_SOURCES ?? '',
+    '--no-session-persistence',
+    '--max-budget-usd', String(budget),
+    '--system-prompt', 'You triage a developer\'s backlog of ideas. Follow the rubric exactly and answer only with the requested JSON.',
+  ];
+  if (alias !== 'haiku') args.push('--effort', 'low');
+  if (dryRun) {
+    const shown = args.map((a) => (/[\s"{]/.test(a) || !a ? JSON.stringify(a) : a)).join(' ');
+    return { message: `${claudeBin()} ${shown}\n\n${prompt}` };
+  }
+
+  const res = await run(args, prompt, 5 * 60 * 1000);
+  let out;
+  try {
+    out = JSON.parse(res.stdout);
+  } catch {
+    const detail = (res.stderr || res.stdout || '').trim().split(/\r?\n/).slice(-5).join('\n');
+    throw new Error(`claude exited with code ${res.code}: ${detail || 'no output'}`);
+  }
+  if (out.is_error) throw new Error(`claude: ${out.result || out.subtype || 'error'}`);
+  const data = out.structured_output ?? parseLooseJson(out.result);
+  if (!Array.isArray(data?.verdicts)) throw new Error('claude answered without verdicts');
+  const results = applyTriage(data.verdicts, { by: `${alias} (headless)` });
+  const u = out.usage || {};
+  const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
+  return { results, model: alias, cost: out.total_cost_usd, tokens: { input, output: u.output_tokens || 0 } };
+}
+
+/** The opening prompt for a fresh session that builds one idea. */
+export function buildPrompt(idea) {
+  const t = idea.triage;
+  const lines = [`Build idea #${idea.id} from my ideamine archive: ${idea.title}`, ''];
+  if (t?.brief) lines.push(t.brief, '');
+  lines.push('My original note:', idea.text, '');
+  lines.push(
+    `When you finish, record the outcome with the ideamine idea_update tool (id ${idea.id}, status "done", a one-line note), ` +
+      `or run: ideamine done ${idea.id} "<one-line outcome>"`,
+  );
+  return lines.join('\n');
+}
+
+/** Start an interactive Claude Code session on the recommended model. */
+export function launchSession({ model, prompt, cwd }) {
+  const [bin, argv] = command(['--model', model, prompt]);
+  const res = spawnSync(bin, argv, { cwd, stdio: 'inherit', env: childEnv() });
+  if (res.error) throw res.error;
+  return res.status ?? 0;
+}
diff --git a/src/hook.js b/src/hook.js
new file mode 100644
index 0000000..c5b6025
--- /dev/null
+++ b/src/hook.js
@@ -0,0 +1,97 @@
+// UserPromptSubmit hook: answers /idea and /ideas locally and blocks the prompt, so the model is
+// never called. That makes capture free, instant, and possible even when the session is out of
+// usage. Every other prompt passes through untouched.
+
+import { renderAdded, renderBoard, renderIdea } from './render.js';
+import { addIdeas, FILTERS, findIdea, lane, load, updateIdea } from './store.js';
+import { clip, splitIdeas } from './text.js';
+
+// `/idea ...`, `/ideas ...`, and the plugin-qualified `/ideamine:idea ...` forms.
+const COMMAND = /^\s*\/(?:ideamine:)?(ideas?)(?=\s|$)([\s\S]*)$/i;
+const EDIT_VERBS = { done: 'done', finish: 'done', drop: 'dropped', doing: 'doing', start: 'doing', reopen: 'reopen' };
+
+const USAGE =
+  'Usage: /idea <your idea>   saves it without calling the model (a bulleted list saves one idea per bullet)\n' +
+  '       /ideas [open|inbox|do|maybe|skip|doing|done|dropped|all|here] · /ideas #12 · /ideas done|drop|start|reopen 12 [note]';
+
+function readStdin() {
+  return new Promise((resolve) => {
+    if (process.stdin.isTTY) return resolve('');
+    let data = '';
+    process.stdin.setEncoding('utf8');
+    process.stdin.on('data', (chunk) => (data += chunk));
+    process.stdin.on('end', () => resolve(data));
+    process.stdin.on('error', () => resolve(data));
+  });
+}
+
+/** Handle one prompt. Returns the text to show the user, or null to let the prompt through. */
+export function handlePrompt(prompt, { cwd = process.cwd(), session = null } = {}) {
+  const m = COMMAND.exec(prompt || '');
+  if (!m) return null;
+  const command = m[1].toLowerCase();
+  const arg = m[2].trim();
+
+  if (command === 'idea') {
+    if (!arg) return USAGE;
+    const results = addIdeas(splitIdeas(arg), { source: 'hook', project: cwd, session });
+    return renderAdded(results, load());
+  }
+
+  // /ideas
+  const words = arg.split(/\s+/).filter(Boolean);
+  const db = load();
+
+  if (!words.length) return renderBoard(db, { cwd, hints: true });
+
+  if (words.length === 1 && /^#?\d+$/.test(words[0])) {
+    const idea = findIdea(db, words[0]);
+    return idea ? renderIdea(idea) : `No idea ${words[0].startsWith('#') ? words[0] : '#' + words[0]}.`;
+  }
+
+  const verb = EDIT_VERBS[words[0]?.toLowerCase()];
+  if (verb && words[1] && /^#?\d+$/.test(words[1])) {
+    const note = words.slice(2).join(' ');
+    const idea = updateIdea(words[1], { status: verb, note: note || undefined });
+    return `✓ #${idea.id} ${clip(idea.title, 60)} → ${lane(idea)}${note ? ' (note added)' : ''}`;
+  }
+
+  const lower = words.map((w) => w.toLowerCase());
+  const filters = lower.filter((w) => FILTERS.includes(w));
+  const here = lower.includes('here');
+  if (filters.length + (here ? 1 : 0) === lower.length && filters.length <= 1) {
+    return renderBoard(db, { filter: filters[0] || 'open', project: here ? cwd : null, cwd, hints: true });
+  }
+  if (lower[0] === 'help') return USAGE;
+
+  return null; // a question about the ideas: let the model answer it (via the /ideas skill)
+}
+
+export async function runHook() {
+  const raw = await readStdin();
+  let input;
+  try {
+    input = JSON.parse(raw);
+  } catch {
+    return; // not a hook payload; stay out of the way
+  }
+  let message;
+  try {
+    message = handlePrompt(typeof input?.prompt === 'string' ? input.prompt : '', {
+      cwd: input.cwd || process.cwd(),
+      session: input.session_id || null,
+    });
+  } catch (e) {
+    // Never swallow the user's text: let the prompt through so the /idea skill can save it via MCP.
+    process.stderr.write(`ideamine: ${e.message}\n`);
+    return;
+  }
+  if (message == null) return;
+  // Blocking ends the turn before any API request. suppressOriginalPrompt keeps Claude Code from
+  // echoing the command back under our message.
+  process.stdout.write(JSON.stringify({
+    decision: 'block',
+    reason: message,
+    hookSpecificOutput: { hookEventName: 'UserPromptSubmit', suppressOriginalPrompt: true },
+  }));
+}
diff --git a/src/mcp.js b/src/mcp.js
new file mode 100644
index 0000000..d41e83f
--- /dev/null
+++ b/src/mcp.js
@@ -0,0 +1,309 @@
+// Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0). No dependencies, so the plugin
+// runs straight from a git checkout with nothing to install.
+
+import fs from 'node:fs';
+import readline from 'node:readline';
+import { fileURLToPath } from 'node:url';
+import { headlessTriage } from './claude.js';
+import { renderAdded, renderBoard, renderIdea } from './render.js';
+import { BATCH_SCHEMA, MODELS, pendingIdeas, triagePrompt } from './rubric.js';
+import { addIdeas, applyTriage, counts, FILTERS, findIdea, lane, load, pickNext, STATUSES, updateIdea } from './store.js';
+import { clip, splitIdeas } from './text.js';
+
+const ROOT = new URL('..', import.meta.url);
+const VERSION = JSON.parse(fs.readFileSync(new URL('package.json', ROOT), 'utf8')).version;
+const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
+
+const INSTRUCTIONS =
+  'ideamine is the user\'s idea archive, shared by every session, project, and model. When the user tosses out an ' +
+  'idea for later ("idea: ...", "save this idea", "someday we should ..."), save it with idea_add and continue the ' +
+  'current task. Do not start building a saved idea unless the user asks.';
+
+const currentProject = () => process.env.CLAUDE_PROJECT_DIR || process.cwd();
+
+const MODEL_ENUM = MODELS.map((m) => m.alias);
+
+const TOOLS = [
+  {
+    name: 'idea_add',
+    description:
+      'Save an idea to the ideamine archive. Use when the user shares an idea to keep for later. Only save it: ' +
+      'do not plan or build it, and continue what you were doing. A bulleted list becomes one idea per bullet.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        text: { type: 'string', description: 'The idea, verbatim.' },
+        tags: { type: 'array', items: { type: 'string' } },
+        project: { type: 'string', description: 'Project directory the idea belongs to. Default: current project.' },
+      },
+      required: ['text'],
+      additionalProperties: false,
+    },
+    annotations: { title: 'Save idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
+  },
+  {
+    name: 'idea_list',
+    description: 'Show the idea board, or one idea in full when id is given.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        id: { type: 'integer', description: 'Show this idea in full.' },
+        filter: { type: 'string', enum: FILTERS, description: 'Default: open (doing, do, maybe, inbox).' },
+        query: { type: 'string', description: 'Only ideas containing all of these words.' },
+        here: { type: 'boolean', description: 'Only ideas from the current project.' },
+        limit: { type: 'integer' },
+      },
+      additionalProperties: false,
+    },
+    annotations: { title: 'List ideas', readOnlyHint: true, openWorldHint: false },
+  },
+  {
+    name: 'idea_triage',
+    description:
+      'Triage ideas: decide which are worth doing and the cheapest model that can build each. headless=true ' +
+      'does it in a separate minimal Claude Code call (cheapest; your context is not used). Otherwise call with ' +
+      'no verdicts to get the rubric and the untriaged ideas, then call once more with all verdicts.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        headless: { type: 'boolean', description: 'Triage the inbox in a separate low-cost `claude -p` call.' },
+        model: { type: 'string', enum: MODEL_ENUM, description: 'Model for headless triage (default sonnet).' },
+        verdicts: BATCH_SCHEMA.properties.verdicts,
+        ids: { type: 'array', items: { type: 'integer' }, description: 'Re-triage these ideas instead of the inbox.' },
+        limit: { type: 'integer', description: 'Max ideas (default 20 headless, 30 otherwise).' },
+        by: { type: 'string', description: 'Your model name, recorded with the verdicts.' },
+      },
+      additionalProperties: false,
+    },
+    annotations: { title: 'Triage ideas', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
+  },
+  {
+    name: 'idea_update',
+    description: 'Change an idea: status, text, title, tags, model override, project, or append a note.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        id: { type: 'integer' },
+        status: { type: 'string', enum: [...STATUSES, 'reopen'] },
+        note: { type: 'string', description: 'Appended to the idea\'s notes, e.g. the outcome.' },
+        title: { type: 'string' },
+        text: { type: 'string' },
+        tags: { type: 'array', items: { type: 'string' } },
+        model: { type: 'string', enum: MODEL_ENUM },
+        project: { type: 'string' },
+      },
+      required: ['id'],
+      additionalProperties: false,
+    },
+    annotations: { title: 'Update idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
+  },
+  {
+    name: 'idea_next',
+    description:
+      'Pick the idea to build next (verdict "do", best value per effort, current project first), or fetch one ' +
+      'by id. Returns its brief, project, and the model recommended to build it.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        id: { type: 'integer' },
+        here: { type: 'boolean', description: 'Only consider ideas from the current project.' },
+      },
+      additionalProperties: false,
+    },
+    annotations: { title: 'Next idea', readOnlyHint: true, openWorldHint: false },
+  },
+];
+
+// Slash commands for clients that load this server without the plugin (the plugin ships skills instead).
+// The prompt text is the matching SKILL.md body, so both stay in sync.
+const PROMPTS = [
+  { name: 'idea', skill: 'idea', description: 'Save an idea for later', arguments: [{ name: 'text', required: true }] },
+  { name: 'ideas', skill: 'ideas', description: 'Show the idea board', arguments: [{ name: 'request', required: false }] },
+  { name: 'idea-triage', skill: 'idea-triage', description: 'Score the untriaged ideas', arguments: [] },
+  { name: 'idea-go', skill: 'idea-go', description: 'Build the top idea with its model', arguments: [{ name: 'id', required: false }] },
+];
+
+function skillBody(name, args) {
+  const file = new URL(`skills/${name}/SKILL.md`, ROOT);
+  const text = fs.readFileSync(fileURLToPath(file), 'utf8').replace(/^---[\s\S]*?\n---\s*\n/, '');
+  return text.replaceAll('$ARGUMENTS', args || '').trim();
+}
+
+function summarizeTriage(results, how = '') {
+  const ok = results.filter((r) => !r.error);
+  const tally = ['do', 'maybe', 'skip'].map((v) => `${ok.filter((r) => r.verdict === v).length} ${v}`).join(' · ');
+  const lines = [`Saved ${ok.length} verdict${ok.length === 1 ? '' : 's'}: ${tally}${how ? ` (${how})` : ''}`];
+  for (const r of ok) lines.push(`  #${r.id} ${r.verdict}${r.verdict === 'skip' ? '' : ` · ${r.model} · ${r.size.toUpperCase()}`}  ${clip(r.title, 60)}`);
+  for (const r of results.filter((x) => x.error)) lines.push(`  #${r.id} not saved: ${r.error}`);
+  const left = counts(load()).inbox;
+  if (left) lines.push(`${left} still in inbox.`);
+  return lines.join('\n');
+}
+
+// ---------------------------------------------------------------------------------------------
+// Tool handlers return plain text for the model.
+
+const handlers = {
+  idea_add({ text, tags, project }) {
+    const results = addIdeas(splitIdeas(text || ''), { source: 'mcp', project: project || currentProject(), tags });
+    return renderAdded(results, load());
+  },
+
+  idea_list({ id, filter = 'open', query = '', here = false, limit = 0 }) {
+    const db = load();
+    if (id != null) {
+      const idea = findIdea(db, id);
+      if (!idea) throw new Error(`no idea #${id}`);
+      return renderIdea(idea);
+    }
+    return renderBoard(db, { filter, query, limit, project: here ? currentProject() : null, cwd: currentProject() });
+  },
+
+  async idea_triage({ verdicts, ids, limit, by, headless = false, model }) {
+    if (headless) {
+      const out = await headlessTriage({ model, limit: limit || 20 });
+      return out.message || summarizeTriage(out.results, `${out.model}, ${out.tokens.input} in / ${out.tokens.output} out tokens`);
+    }
+    if (Array.isArray(verdicts) && verdicts.length) {
+      return summarizeTriage(applyTriage(verdicts, { by: by ? clip(by, 40) : 'claude' }));
+    }
+    limit ||= 30;
+    const db = load();
+    const pending = pendingIdeas(db, { ids, limit });
+    if (!pending.length) return 'Nothing to triage: the inbox is empty.';
+    return (
+      triagePrompt(db, pending) +
+      '\n\nSave every verdict in ONE idea_triage call: ' +
+      '{"verdicts":[{"id":1,"verdict":"do","impact":3,"size":"s","model":"sonnet","title":"...","why":"...","brief":"..."}]}'
+    );
+  },
+
+  idea_update({ id, ...patch }) {
+    const idea = updateIdea(id, patch);
+    const bits = [`#${idea.id} ${clip(idea.title, 60)} · ${lane(idea)}`];
+    if (patch.model) bits.push(`model ${idea.triage.model}`);
+    if (patch.note) bits.push('note added');
+    return `Updated ${bits.join(' · ')}`;
+  },
+
+  idea_next({ id, here = false }) {
+    const db = load();
+    let idea;
+    if (id != null) {
+      idea = findIdea(db, id);
+      if (!idea) throw new Error(`no idea #${id}`);
+    } else {
+      idea = pickNext(db, { project: currentProject(), only: here });
+    }
+    if (!idea) {
+      const c = counts(db);
+      return c.inbox
+        ? `No idea is ready to build. ${c.inbox} untriaged in the inbox: triage them first.`
+        : 'No idea is ready to build, and the inbox is empty.';
+    }
+    const t = idea.triage;
+    const lines = [renderIdea(idea), ''];
+    if (!t) lines.push('recommended model: none yet (untriaged)');
+    else lines.push(`recommended model: ${t.model}`);
+    lines.push(`project dir: ${idea.project || '(none recorded)'}`);
+    return lines.join('\n');
+  },
+};
+
+// ---------------------------------------------------------------------------------------------
+// JSON-RPC plumbing
+
+function send(msg) {
+  process.stdout.write(JSON.stringify(msg) + '\n');
+}
+
+function result(id, value) {
+  return { jsonrpc: '2.0', id, result: value };
+}
+
+function error(id, code, message) {
+  return { jsonrpc: '2.0', id, error: { code, message } };
+}
+
+async function handleRequest(msg, { prompts }) {
+  const { id, method, params = {} } = msg;
+  switch (method) {
+    case 'initialize': {
+      const asked = params.protocolVersion;
+      const protocolVersion = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
+      const capabilities = { tools: { listChanged: false } };
+      if (prompts) capabilities.prompts = { listChanged: false };
+      return result(id, { protocolVersion, capabilities, serverInfo: { name: 'ideamine', version: VERSION }, instructions: INSTRUCTIONS });
+    }
+    case 'ping':
+      return result(id, {});
+    case 'tools/list':
+      return result(id, { tools: TOOLS });
+    case 'tools/call': {
+      const handler = handlers[params.name];
+      if (!handler) return error(id, -32602, `unknown tool: ${params.name}`);
+      try {
+        const text = await handler(params.arguments || {});
+        return result(id, { content: [{ type: 'text', text }] });
+      } catch (e) {
+        return result(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
+      }
+    }
+    case 'prompts/list':
+      return result(id, { prompts: prompts ? PROMPTS.map(({ skill, ...p }) => p) : [] });
+    case 'prompts/get': {
+      const p = prompts && PROMPTS.find((x) => x.name === params.name);
+      if (!p) return error(id, -32602, `unknown prompt: ${params.name}`);
+      const args = Object.values(params.arguments || {}).filter(Boolean).join(' ');
+      return result(id, {
+        description: p.description,
+        messages: [{ role: 'user', content: { type: 'text', text: skillBody(p.skill, args) } }],
+      });
+    }
+    case 'resources/list':
+      return result(id, { resources: [] });
+    case 'resources/templates/list':
+      return result(id, { resourceTemplates: [] });
+    case 'logging/setLevel':
+      return result(id, {});
+    default:
+      return error(id, -32601, `method not found: ${method}`);
+  }
+}
+
+async function handleMessage(msg, opts) {
+  if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return error(null, -32600, 'invalid request');
+  if (typeof msg.method !== 'string') return null; // a response to a request we never send
+  if (msg.id === undefined) return null; // notification (initialized, cancelled, ...)
+  try {
+    return await handleRequest(msg, opts);
+  } catch (e) {
+    return error(msg.id, -32603, e.message);
+  }
+}
+
+async function handleLine(line, opts) {
+  let msg;
+  try {
+    msg = JSON.parse(line);
+  } catch {
+    return error(null, -32700, 'parse error');
+  }
+  if (!Array.isArray(msg)) return handleMessage(msg, opts);
+  const replies = (await Promise.all(msg.map((m) => handleMessage(m, opts)))).filter(Boolean);
+  return replies.length ? replies : null;
+}
+
+/** Serve MCP on stdin/stdout. `prompts: false` when running as a plugin (skills cover slash commands). */
+export function serve({ prompts = true } = {}) {
+  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+  const inFlight = new Set();
+  // Requests run concurrently (a headless triage must not stall a ping); replies carry their ids.
+  rl.on('line', (line) => {
+    if (!line.trim()) return;
+    const job = handleLine(line, { prompts }).then((reply) => reply && send(reply));
+    inFlight.add(job);
+    job.finally(() => inFlight.delete(job));
+  });
+  rl.on('close', () => Promise.allSettled([...inFlight]).then(() => process.exit(0)));
+}
diff --git a/src/render.js b/src/render.js
new file mode 100644
index 0000000..32b121a
--- /dev/null
+++ b/src/render.js
@@ -0,0 +1,135 @@
+// Plain-text views. Kept compact on purpose: every character here may end up in a model's context.
+
+import path from 'node:path';
+import { counts, lane, listIdeas, samePath } from './store.js';
+import { clip } from './text.js';
+
+const LANE_TITLES = {
+  doing: 'DOING',
+  do: 'DO (best first)',
+  maybe: 'MAYBE',
+  inbox: 'INBOX (untriaged)',
+  skip: 'SKIP',
+  done: 'DONE',
+  dropped: 'DROPPED',
+};
+
+function projectName(p) {
+  return p ? path.basename(p) : '';
+}
+
+function stamp(iso) {
+  if (!iso) return '';
+  const d = new Date(iso);
+  const pad = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+export function ideaLine(idea, { cwd = null } = {}) {
+  const t = idea.triage;
+  const id = `#${idea.id}`.padEnd(5);
+  const meta = t && lane(idea) !== 'inbox' && t.verdict !== 'skip'
+    ? `${t.model.padEnd(6)} ${t.size.toUpperCase().padEnd(2)} â–²${t.impact}  `
+    : '';
+  const where = idea.project && !samePath(idea.project, cwd) ? `  · ${projectName(idea.project)}` : '';
+  return `  ${id}${meta}${clip(idea.title, 64)}${where}`;
+}
+
+export function summaryLine(db) {
+  const c = counts(db);
+  const parts = ['doing', 'do', 'maybe', 'inbox'].filter((k) => c[k]).map((k) => `${c[k]} ${k}`);
+  return `ideamine: ${c.open} open${parts.length ? ` (${parts.join(' · ')})` : ''}${c.done ? ` · ${c.done} done` : ''}`;
+}
+
+/** The board: ideas grouped by lane. */
+export function renderBoard(db, { filter = 'open', project = null, query = '', cwd = null, limit = 0, hints = false } = {}) {
+  const ideas = listIdeas(db, { filter, project, query, limit });
+  const out = [summaryLine(db)];
+  if (project || query || filter !== 'open') {
+    const scope = [filter !== 'open' && filter, project && `project ${projectName(project)}`, query && `"${query}"`];
+    out[0] += `  [showing: ${scope.filter(Boolean).join(', ')}]`;
+  }
+  if (!ideas.length) {
+    out.push('', db.ideas.length ? '  (nothing here)' : '  (empty — add one with /idea <text>)');
+  }
+  let current = null;
+  for (const idea of ideas) {
+    const l = lane(idea);
+    if (l !== current) {
+      current = l;
+      out.push('', LANE_TITLES[l] || l.toUpperCase());
+    }
+    out.push(ideaLine(idea, { cwd }));
+  }
+  if (hints) {
+    const c = counts(db);
+    const tips = ['/idea <text> to add', '/ideas #N for details'];
+    if (c.inbox) tips.push('/idea-triage to score the inbox');
+    if (c.do) tips.push('/idea-go to build the top pick');
+    out.push('', tips.join(' · '));
+  }
+  return out.join('\n');
+}
+
+/** One idea in full. */
+export function renderIdea(idea) {
+  const t = idea.triage;
+  const out = [`#${idea.id} ${idea.title}`];
+  const status = [idea.status];
+  if (t) status.push(t.verdict, t.model, `size ${t.size.toUpperCase()}`, `impact ${t.impact}/5`);
+  out.push(`status   ${status.join(' · ')}`);
+  if (idea.project) out.push(`project  ${idea.project}`);
+  const added = [`${stamp(idea.created)} via ${idea.source}`];
+  if (idea.tags?.length) added.push(`tags: ${idea.tags.join(', ')}`);
+  out.push(`added    ${added.join(' · ')}`);
+  if (idea.dup_of) out.push(`dup of   #${idea.dup_of}`);
+  if (t?.why) out.push(`why      ${t.why}`);
+  if (t?.brief) out.push(`brief    ${t.brief}`);
+  if (t?.by) out.push(`triaged  ${stamp(t.at)} by ${t.by}`);
+  if (idea.text !== idea.title) out.push('', 'text', ...idea.text.split(/\r?\n/).map((l) => `  ${l}`));
+  if (idea.notes?.length) {
+    out.push('', 'notes');
+    for (const n of idea.notes) out.push(`  ${stamp(n.at)}  ${n.text}`);
+  }
+  return out.join('\n');
+}
+
+/** Confirmation after capture, e.g. "💡 Saved #12 · Add dark mode (3 in inbox)". */
+export function renderAdded(results, db) {
+  const inbox = counts(db).inbox;
+  const lines = [];
+  if (results.length === 1) {
+    const { idea } = results[0];
+    lines.push(`💡 Saved #${idea.id} · ${clip(idea.title, 70)}  (${inbox} in inbox)`);
+  } else {
+    lines.push(`💡 Saved ${results.length} ideas: ${results.map((r) => `#${r.idea.id}`).join(', ')}  (${inbox} in inbox)`);
+  }
+  const seen = new Set(results.map((r) => r.idea.id));
+  for (const r of results) {
+    for (const s of r.similar) {
+      if (seen.has(s.id)) continue;
+      seen.add(s.id);
+      lines.push(`   similar to #${s.id} (${s.status}): ${clip(s.title, 60)}`);
+    }
+  }
+  return lines.join('\n');
+}
+
+/** Markdown export of the whole archive. */
+export function renderMarkdown(db) {
+  const out = [`# Ideas`, '', `_${summaryLine(db)} · exported ${stamp(new Date().toISOString())}_`];
+  let current = null;
+  for (const idea of listIdeas(db, { filter: 'all' })) {
+    const l = lane(idea);
+    if (l !== current) {
+      current = l;
+      out.push('', `## ${LANE_TITLES[l] || l}`, '');
+    }
+    const t = idea.triage;
+    const meta = t && l !== 'inbox' ? ` — ${t.verdict}, ${t.model}, size ${t.size.toUpperCase()}, impact ${t.impact}/5` : '';
+    out.push(`- **#${idea.id} ${idea.title}**${meta}${idea.project ? ` · \`${projectName(idea.project)}\`` : ''}`);
+    if (t?.brief) out.push(`  - ${t.brief}`);
+    if (idea.text !== idea.title) out.push(`  - > ${idea.text.replace(/\r?\n/g, ' ')}`);
+  }
+  return out.join('\n') + '\n';
+}
diff --git a/src/rubric.js b/src/rubric.js
new file mode 100644
index 0000000..c28fb33
--- /dev/null
+++ b/src/rubric.js
@@ -0,0 +1,101 @@
+// How ideas are judged and which model builds them. Both triage paths (the MCP tool used by
+// /idea-triage and the headless `ideamine triage`) read this file.
+
+import path from 'node:path';
+import { lane, listIdeas } from './store.js';
+import { clip } from './text.js';
+
+// Aliases are what Claude Code accepts (`claude --model sonnet`, Agent tool `model: "sonnet"`),
+// so a recommendation stays valid when a new generation ships under the same alias.
+// Prices: USD per million input/output tokens.
+export const MODELS = [
+  {
+    alias: 'haiku',
+    name: 'Claude Haiku 4.5',
+    price: '$1/$5',
+    use: 'mechanical, fully specified, local work: typos, renames, config tweaks, boilerplate, small scripts, lookups',
+  },
+  {
+    alias: 'sonnet',
+    name: 'Claude Sonnet 5',
+    price: '$2/$10',
+    use: 'the default: ordinary features, bug fixes with a clear repro, tests, docs, contained refactors',
+  },
+  {
+    alias: 'opus',
+    name: 'Claude Opus 5',
+    price: '$5/$25',
+    use: 'ambiguous or cross-cutting work: architecture, hard debugging, performance, security, unfamiliar domains',
+  },
+  {
+    alias: 'fable',
+    name: 'Claude Fable 5.1',
+    price: '$10/$50',
+    use: 'only the hardest long-horizon or research-grade problems, where Opus would likely fail',
+  },
+];
+
+export const RUBRIC = `Triage each idea. Be decisive: the user wants a short list worth building, not encouragement.
+
+For every idea return:
+- verdict: "do" (clear value, worth building soon) | "maybe" (unclear value, blocked, or needs a decision from the user) | "skip" (low value, already exists, or costs far more than it returns)
+- impact: 1-5, how much it would matter to the user or project if it shipped
+- size: xs (<15 min) | s (<1 h) | m (a few hours) | l (1-2 days) | xl (multi-day, should be split)
+- model: the CHEAPEST model likely to finish it in one pass. A failed attempt on a weaker model plus a retry costs more than the right model once.
+${MODELS.map((m) => `    ${m.alias.padEnd(6)} ${m.price.padEnd(7)} ${m.use}`).join('\n')}
+    (prices in USD per million input/output tokens)
+- title: imperative, at most 60 characters ("Add ...", "Fix ...")
+- why: one sentence with the deciding reason
+- brief: 1-4 sentences an agent can act on without this conversation: goal, scope, and "done when". Leave empty for skip.
+- dup_of: id of an existing idea this one duplicates (use verdict "skip"), if any`;
+
+/** JSON Schema for a batch of verdicts; used for `claude -p --json-schema` and the MCP tool. */
+export const VERDICT_SCHEMA = {
+  type: 'object',
+  properties: {
+    id: { type: 'integer' },
+    verdict: { type: 'string', enum: ['do', 'maybe', 'skip'] },
+    impact: { type: 'integer', minimum: 1, maximum: 5 },
+    size: { type: 'string', enum: ['xs', 's', 'm', 'l', 'xl'] },
+    model: { type: 'string', enum: MODELS.map((m) => m.alias) },
+    title: { type: 'string' },
+    why: { type: 'string' },
+    brief: { type: 'string' },
+    dup_of: { type: 'integer' },
+  },
+  required: ['id', 'verdict', 'impact', 'size', 'model', 'title', 'why', 'brief'],
+};
+
+export const BATCH_SCHEMA = {
+  type: 'object',
+  properties: { verdicts: { type: 'array', items: VERDICT_SCHEMA } },
+  required: ['verdicts'],
+};
+
+/** Ideas waiting for a verdict: the inbox, or the given ids (re-triage). */
+export function pendingIdeas(db, { ids = null, limit = 30 } = {}) {
+  if (Array.isArray(ids) && ids.length) {
+    const wanted = new Set(ids.map(Number));
+    return db.ideas.filter((i) => wanted.has(i.id));
+  }
+  return listIdeas(db, { filter: 'inbox' }).reverse().slice(0, limit); // oldest first
+}
+
+/** The rubric plus the ideas to judge, as one prompt. */
+export function triagePrompt(db, pending) {
+  const pendingIds = new Set(pending.map((i) => i.id));
+  const existing = db.ideas
+    .filter((i) => !pendingIds.has(i.id) && ['do', 'maybe', 'doing', 'done'].includes(lane(i)))
+    .slice(-60)
+    .map((i) => `#${i.id} ${clip(i.title, 70)} (${lane(i)})`);
+  const lines = [RUBRIC, ''];
+  if (existing.length) lines.push('Existing ideas, for duplicate checks:', ...existing, '');
+  lines.push(`Ideas to triage (${pending.length}):`);
+  for (const i of pending) {
+    const ctx = [i.project && `project: ${path.basename(i.project)}`, i.tags?.length && `tags: ${i.tags.join(', ')}`]
+      .filter(Boolean)
+      .join('; ');
+    lines.push(`#${i.id}${ctx ? ` [${ctx}]` : ''}: ${i.text.replace(/\r?\n/g, ' / ')}`);
+  }
+  return lines.join('\n');
+}
diff --git a/src/store.js b/src/store.js
new file mode 100644
index 0000000..56742c3
--- /dev/null
+++ b/src/store.js
@@ -0,0 +1,335 @@
+// The idea archive: one JSON file shared by every Claude session, project, and model.
+// Writers take a directory lock and replace the file atomically, so concurrent sessions,
+// the hook, and the CLI can all write at once without losing ideas.
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { clip, deriveTitle, extractTags, isSimilar, normalizeTags, wordSet } from './text.js';
+
+export const VERDICTS = ['do', 'maybe', 'skip'];
+export const SIZES = ['xs', 's', 'm', 'l', 'xl'];
+export const SIZE_COST = { xs: 1, s: 2, m: 3, l: 5, xl: 8 };
+export const MODEL_ALIASES = ['haiku', 'sonnet', 'opus', 'fable'];
+export const STATUSES = ['inbox', 'triaged', 'doing', 'done', 'dropped'];
+export const FILTERS = ['open', 'inbox', 'do', 'maybe', 'skip', 'doing', 'done', 'dropped', 'all'];
+
+const LOCK_STALE_MS = 5000;
+const LOCK_WAIT_MS = 8000;
+
+export function home() {
+  return process.env.IDEAMINE_HOME || path.join(os.homedir(), '.ideamine');
+}
+
+export function dbPath() {
+  return path.join(home(), 'ideas.json');
+}
+
+function sleepSync(ms) {
+  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+}
+
+const now = () => new Date().toISOString();
+
+function emptyDb() {
+  return { version: 1, next_id: 1, ideas: [] };
+}
+
+/** Read-only snapshot. Safe without the lock because writes are atomic renames. */
+export function load() {
+  const file = dbPath();
+  let raw;
+  try {
+    raw = fs.readFileSync(file, 'utf8').replace(/^/, '');
+  } catch (e) {
+    if (e.code === 'ENOENT') return emptyDb();
+    throw e;
+  }
+  if (!raw.trim()) return emptyDb();
+  let db;
+  try {
+    db = JSON.parse(raw);
+  } catch (e) {
+    throw new Error(`${file} is not valid JSON (${e.message}). Fix it, or restore ${file}.bak`);
+  }
+  if (!db || !Array.isArray(db.ideas)) throw new Error(`${file} has no "ideas" array`);
+  const maxId = db.ideas.reduce((m, i) => Math.max(m, Number(i.id) || 0), 0);
+  db.next_id = Math.max(Number(db.next_id) || 1, maxId + 1);
+  return db;
+}
+
+function acquireLock() {
+  fs.mkdirSync(home(), { recursive: true });
+  const lockDir = path.join(home(), '.lock');
+  const deadline = Date.now() + LOCK_WAIT_MS;
+  for (let attempt = 0; ; attempt++) {
+    try {
+      fs.mkdirSync(lockDir);
+      return () => fs.rmSync(lockDir, { recursive: true, force: true });
+    } catch (e) {
+      if (e.code !== 'EEXIST') throw e;
+    }
+    try {
+      // A writer holds the lock for milliseconds; an old lock means a crashed process.
+      if (Date.now() - fs.statSync(lockDir).mtimeMs > LOCK_STALE_MS) {
+        fs.rmSync(lockDir, { recursive: true, force: true });
+        continue;
+      }
+    } catch {
+      // The lock vanished between mkdir and stat; the next attempt will likely get it.
+    }
+    if (Date.now() > deadline) {
+      throw new Error(`idea store is busy (lock: ${lockDir}). Delete that folder if no ideamine process is running.`);
+    }
+    sleepSync(Math.min(10 + attempt * 5, 100));
+  }
+}
+
+function renameWithRetry(from, to) {
+  // Windows refuses to replace a file that another program (editor, antivirus, sync) has open.
+  for (let attempt = 0; ; attempt++) {
+    try {
+      fs.renameSync(from, to);
+      return;
+    } catch (e) {
+      if (attempt >= 40 || !['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) {
+        fs.rmSync(from, { force: true });
+        throw e;
+      }
+      sleepSync(25);
+    }
+  }
+}
+
+function save(db) {
+  const file = dbPath();
+  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
+  fs.writeFileSync(tmp, JSON.stringify(db, null, 2) + '\n');
+  try {
+    fs.copyFileSync(file, `${file}.bak`);
+  } catch (e) {
+    if (e.code !== 'ENOENT') throw e;
+  }
+  renameWithRetry(tmp, file);
+}
+
+/** Lock, read, apply `fn(db)`, write. Nothing is written if `fn` throws. */
+export function mutate(fn) {
+  const release = acquireLock();
+  try {
+    const db = load();
+    const result = fn(db);
+    save(db);
+    return result;
+  } finally {
+    release();
+  }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Queries
+
+export function samePath(a, b) {
+  if (!a || !b) return false;
+  const norm = (p) => {
+    const r = path.resolve(p).replace(/[\\/]+$/, '');
+    return process.platform === 'win32' ? r.toLowerCase() : r;
+  };
+  return norm(a) === norm(b);
+}
+
+export function findIdea(db, id) {
+  const n = Number(String(id).replace(/^#/, ''));
+  return db.ideas.find((i) => i.id === n) || null;
+}
+
+export function verdictOf(idea) {
+  return idea.status === 'triaged' ? idea.triage?.verdict || null : null;
+}
+
+/** Value per unit of effort; untriaged ideas sort last. */
+export function priority(idea) {
+  const t = idea.triage;
+  if (!t) return 0;
+  return (Number(t.impact) || 3) / (SIZE_COST[t.size] || 3);
+}
+
+/** Lane an idea shows up in on the board. */
+export function lane(idea) {
+  if (idea.status === 'triaged') return idea.triage?.verdict || 'maybe';
+  return idea.status;
+}
+
+const LANE_ORDER = ['doing', 'do', 'maybe', 'inbox', 'skip', 'done', 'dropped'];
+
+export function matchesFilter(idea, filter) {
+  const l = lane(idea);
+  switch (filter) {
+    case 'all':
+      return true;
+    case 'open':
+      return ['doing', 'do', 'maybe', 'inbox'].includes(l);
+    default:
+      return l === filter;
+  }
+}
+
+export function listIdeas(db, { filter = 'open', project = null, query = '', limit = 0 } = {}) {
+  if (!FILTERS.includes(filter)) throw new Error(`unknown filter "${filter}" (use: ${FILTERS.join(', ')})`);
+  const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
+  let ideas = db.ideas.filter((i) => matchesFilter(i, filter));
+  if (project) ideas = ideas.filter((i) => samePath(i.project, project));
+  if (words.length) {
+    ideas = ideas.filter((i) => {
+      const hay = `${i.title}\n${i.text}\n${(i.tags || []).join(' ')}`.toLowerCase();
+      return words.every((w) => hay.includes(w));
+    });
+  }
+  ideas.sort((a, b) => {
+    const la = LANE_ORDER.indexOf(lane(a));
+    const lb = LANE_ORDER.indexOf(lane(b));
+    if (la !== lb) return la - lb;
+    if (['done', 'dropped', 'inbox'].includes(lane(a))) return b.id - a.id; // newest first
+    return priority(b) - priority(a) || a.id - b.id;
+  });
+  return limit > 0 ? ideas.slice(0, limit) : ideas;
+}
+
+export function counts(db) {
+  const c = { inbox: 0, do: 0, maybe: 0, skip: 0, doing: 0, done: 0, dropped: 0 };
+  for (const i of db.ideas) c[lane(i)] = (c[lane(i)] || 0) + 1;
+  c.open = c.inbox + c.do + c.maybe + c.doing;
+  return c;
+}
+
+/** Best idea to build next: verdict "do", not started, current project first, then value per effort. */
+export function pickNext(db, { project = null, only = false } = {}) {
+  let ready = db.ideas.filter((i) => lane(i) === 'do');
+  if (only) ready = ready.filter((i) => samePath(i.project, project));
+  ready.sort((a, b) => {
+    const pa = project && samePath(a.project, project) ? 1 : 0;
+    const pb = project && samePath(b.project, project) ? 1 : 0;
+    return pb - pa || priority(b) - priority(a) || a.id - b.id;
+  });
+  return ready[0] || null;
+}
+
+function similarTo(db, text, excludeId) {
+  const words = wordSet(text);
+  if (words.size < 2) return [];
+  return db.ideas
+    .filter((i) => i.id !== excludeId && i.status !== 'dropped' && isSimilar(words, wordSet(`${i.title} ${i.text}`)))
+    .slice(-2)
+    .map((i) => ({ id: i.id, title: i.title, status: lane(i) }));
+}
+
+// ---------------------------------------------------------------------------------------------
+// Mutations
+
+export function addIdeas(texts, { source = 'mcp', project = null, session = null, tags = [] } = {}) {
+  const items = (Array.isArray(texts) ? texts : [texts]).map((t) => String(t ?? '').trim()).filter(Boolean);
+  if (!items.length) throw new Error('idea text is empty');
+  return mutate((db) =>
+    items.map((text) => {
+      const similar = similarTo(db, text, null);
+      const stamp = now();
+      const idea = {
+        id: db.next_id++,
+        title: deriveTitle(text),
+        text,
+        status: 'inbox',
+        tags: normalizeTags([...extractTags(text), ...normalizeTags(tags)]),
+        project: project ? path.resolve(project) : null,
+        source,
+        created: stamp,
+        updated: stamp,
+        triage: null,
+        notes: [],
+      };
+      if (session) idea.session = String(session);
+      db.ideas.push(idea);
+      return { idea, similar };
+    }),
+  );
+}
+
+export function normalizeModel(model) {
+  const m = String(model || '').toLowerCase();
+  return MODEL_ALIASES.find((alias) => m.includes(alias)) || null;
+}
+
+function cleanVerdict(v) {
+  const verdict = String(v.verdict || '').toLowerCase();
+  if (!VERDICTS.includes(verdict)) throw new Error(`verdict must be one of ${VERDICTS.join('/')}`);
+  const impact = Math.min(5, Math.max(1, Math.round(Number(v.impact) || 3)));
+  const size = SIZES.includes(String(v.size || '').toLowerCase()) ? String(v.size).toLowerCase() : 'm';
+  const model = normalizeModel(v.model) || 'sonnet';
+  return {
+    verdict,
+    impact,
+    size,
+    model,
+    why: clip(v.why || '', 300),
+    brief: verdict === 'skip' ? '' : String(v.brief || '').trim().slice(0, 2000),
+  };
+}
+
+/** Save triage verdicts. Bad items are reported individually instead of failing the batch. */
+export function applyTriage(verdicts, { by = null } = {}) {
+  if (!Array.isArray(verdicts) || !verdicts.length) throw new Error('no verdicts given');
+  return mutate((db) =>
+    verdicts.map((v) => {
+      const idea = findIdea(db, v?.id);
+      if (!idea) return { id: v?.id, error: 'no such idea' };
+      let t;
+      try {
+        t = cleanVerdict(v);
+      } catch (e) {
+        return { id: idea.id, error: e.message };
+      }
+      idea.triage = { ...t, at: now(), ...(by ? { by } : {}) };
+      if (v.title) idea.title = clip(v.title, 90);
+      if (Array.isArray(v.tags)) idea.tags = normalizeTags([...(idea.tags || []), ...v.tags]);
+      const dup = Number(v.dup_of);
+      if (dup && dup !== idea.id && findIdea(db, dup)) idea.dup_of = dup;
+      if (idea.status === 'inbox') idea.status = 'triaged';
+      idea.updated = now();
+      return { id: idea.id, verdict: t.verdict, model: t.model, size: t.size, title: idea.title };
+    }),
+  );
+}
+
+const STATUS_ALIASES = { start: 'doing', started: 'doing', finish: 'done', finished: 'done', drop: 'dropped' };
+
+export function updateIdea(id, patch = {}) {
+  return mutate((db) => {
+    const idea = findIdea(db, id);
+    if (!idea) throw new Error(`no idea #${id}`);
+    if (patch.status) {
+      let status = String(patch.status).toLowerCase();
+      status = STATUS_ALIASES[status] || status;
+      if (status === 'reopen' || status === 'open') status = idea.triage ? 'triaged' : 'inbox';
+      if (!STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}, or reopen`);
+      idea.status = status;
+      if (status === 'done' || status === 'dropped') idea.closed = now();
+      else delete idea.closed;
+    }
+    if (patch.text) idea.text = String(patch.text).trim();
+    if (patch.title) idea.title = clip(patch.title, 90);
+    else if (patch.text) idea.title = deriveTitle(idea.text);
+    if (Array.isArray(patch.tags)) idea.tags = normalizeTags(patch.tags);
+    if (patch.project !== undefined) idea.project = patch.project ? path.resolve(patch.project) : null;
+    if (patch.model) {
+      const model = normalizeModel(patch.model);
+      if (!model) throw new Error(`model must be one of ${MODEL_ALIASES.join(', ')}`);
+      idea.triage = { ...(idea.triage || { verdict: 'do', impact: 3, size: 'm', why: '', brief: '' }), model };
+      if (idea.status === 'inbox') idea.status = 'triaged';
+    }
+    if (patch.note) {
+      idea.notes ||= [];
+      idea.notes.push({ at: now(), text: String(patch.note).trim() });
+    }
+    idea.updated = now();
+    return idea;
+  });
+}
diff --git a/src/text.js b/src/text.js
new file mode 100644
index 0000000..500bd78
--- /dev/null
+++ b/src/text.js
@@ -0,0 +1,88 @@
+// Pure text helpers: titles, tags, bullet splitting, and cheap duplicate detection.
+
+const STOPWORDS = new Set(
+  ('a an and any are as at be but by can could do does for from get got has have how i if in into is it its ' +
+    'just like make me more my of on or our out so some than that the their them then there these they this ' +
+    'to too up use using via was way we what when where which while who why will with would you your')
+    .split(' '),
+);
+
+/** First non-empty line, whitespace-collapsed, cut at a word boundary. */
+export function deriveTitle(text, max = 72) {
+  const first = String(text).split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
+  const clean = first
+    .replace(/^(?:[-*•]|\d+[.)])\s+/, '')
+    .replace(/(?:^|\s)#[a-z][\w-]{0,31}(?=\s|$)/gi, ' ')
+    .replace(/\s+/g, ' ')
+    .trim() || first;
+  if (clean.length <= max) return clean;
+  const cut = clean.slice(0, max - 1);
+  const space = cut.lastIndexOf(' ');
+  return (space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,.;:-]+$/, '') + '…';
+}
+
+/** `#tag` words (must start with a letter, so `#12` and `C#` are not tags). */
+export function extractTags(text) {
+  const tags = new Set();
+  for (const m of String(text).matchAll(/(?:^|\s)#([a-z][\w-]{0,31})/gi)) tags.add(m[1].toLowerCase());
+  return [...tags];
+}
+
+export function normalizeTags(tags) {
+  if (!Array.isArray(tags)) return [];
+  return [...new Set(tags.map((t) => String(t).trim().replace(/^#/, '').toLowerCase()).filter(Boolean))];
+}
+
+const BULLET = /^(\s{0,3})(?:[-*•]|\d+[.)])\s+/;
+
+/**
+ * A pasted bulleted list becomes one idea per bullet; anything else is one idea.
+ * Indented lines under a bullet are kept with that bullet.
+ */
+export function splitIdeas(text) {
+  const lines = String(text).split(/\r?\n/);
+  const items = [];
+  let pureList = true;
+  for (const line of lines) {
+    if (!line.trim()) continue;
+    if (BULLET.test(line)) items.push(line.replace(BULLET, '').trim());
+    else if (items.length && /^\s+/.test(line)) items[items.length - 1] += '\n' + line.trim();
+    else {
+      pureList = false;
+      break;
+    }
+  }
+  if (pureList && items.length >= 2) return items.filter(Boolean);
+  const whole = String(text).trim();
+  return whole ? [whole] : [];
+}
+
+export function wordSet(text) {
+  const words = String(text)
+    .toLowerCase()
+    .normalize('NFKD')
+    .replace(/\p{M}/gu, '')
+    .replace(/[^\p{L}\p{N}\s]/gu, ' ')
+    .split(/\s+/)
+    .filter((w) => w.length > 2 && !STOPWORDS.has(w))
+    .map((w) => (w.length > 4 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w));
+  return new Set(words);
+}
+
+/** Jaccard similarity of two word sets, plus the shared-word count. */
+export function similarity(a, b) {
+  let shared = 0;
+  for (const w of a) if (b.has(w)) shared++;
+  const union = a.size + b.size - shared;
+  return { score: union ? shared / union : 0, shared };
+}
+
+export function isSimilar(a, b) {
+  const { score, shared } = similarity(a, b);
+  return (score >= 0.55 && shared >= 2) || score >= 0.8;
+}
+
+export function clip(text, max) {
+  const s = String(text ?? '').replace(/\s+/g, ' ').trim();
+  return s.length <= max ? s : s.slice(0, max - 1).trimEnd() + '…';
+}
diff --git a/tests/fixtures/fake-claude.js b/tests/fixtures/fake-claude.js
new file mode 100644
index 0000000..0ec4c49
--- /dev/null
+++ b/tests/fixtures/fake-claude.js
@@ -0,0 +1,34 @@
+// Stand-in for the Claude Code CLI: returns a verdict for every idea in the prompt and records how
+// it was called, so tests can check flags and environment without spending tokens.
+import fs from 'node:fs';
+
+let input = '';
+for await (const chunk of process.stdin) input += chunk;
+
+if (process.env.FAKE_CLAUDE_LOG) {
+  const env = {};
+  for (const key of ['CLAUDECODE', 'CLAUDE_EFFORT', 'CLAUDE_CODE_MESSAGING_SOCKET', 'ANTHROPIC_BASE_URL']) env[key] = process.env[key] ?? null;
+  fs.writeFileSync(process.env.FAKE_CLAUDE_LOG, JSON.stringify({ args: process.argv.slice(2), env, input }));
+}
+
+const ids = [...(input.split('Ideas to triage')[1] || '').matchAll(/^#(\d+)/gm)].map((m) => Number(m[1]));
+const verdicts = ids.map((id, i) => ({
+  id,
+  verdict: i === 2 ? 'skip' : 'do',
+  impact: 3,
+  size: 's',
+  model: i === 1 ? 'haiku' : 'sonnet',
+  title: `Idea ${id}`,
+  why: 'test',
+  brief: `Build ${id}.`,
+}));
+
+process.stdout.write(JSON.stringify({
+  type: 'result',
+  subtype: 'success',
+  is_error: false,
+  result: '',
+  structured_output: { verdicts },
+  total_cost_usd: 0.0012,
+  usage: { input_tokens: 321, output_tokens: 45 },
+}));
diff --git a/tests/hook.test.js b/tests/hook.test.js
new file mode 100644
index 0000000..8265a4c
--- /dev/null
+++ b/tests/hook.test.js
@@ -0,0 +1,78 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { beforeEach, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { handlePrompt } from '../src/hook.js';
+import * as store from '../src/store.js';
+
+const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
+
+beforeEach(() => {
+  process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-hook-'));
+});
+
+/** Run the real hook process the way Claude Code does: JSON on stdin, decision on stdout. */
+function runHook(prompt, cwd = '/work/app') {
+  const input = JSON.stringify({ session_id: 's1', hook_event_name: 'UserPromptSubmit', cwd, prompt });
+  const r = spawnSync(process.execPath, [BIN, 'hook'], { input, encoding: 'utf8', env: process.env });
+  assert.equal(r.status, 0, r.stderr);
+  return r.stdout ? JSON.parse(r.stdout) : null;
+}
+
+test('/idea saves the idea and blocks the prompt so no model call happens', () => {
+  const out = runHook('/idea add a read-aloud mode #a11y');
+  assert.equal(out.decision, 'block');
+  assert.match(out.reason, /Saved #1 · add a read-aloud mode/);
+  assert.deepEqual(out.hookSpecificOutput, { hookEventName: 'UserPromptSubmit', suppressOriginalPrompt: true });
+  const [idea] = store.load().ideas;
+  assert.equal(idea.source, 'hook');
+  assert.equal(idea.session, 's1');
+  assert.deepEqual(idea.tags, ['a11y']);
+  assert.equal(idea.project, path.resolve('/work/app'));
+});
+
+test('plugin-qualified and multi-line forms work', () => {
+  assert.match(runHook('/ideamine:idea first').reason, /Saved #1/);
+  assert.match(runHook('/idea - two\n- three').reason, /Saved 2 ideas: #2, #3/);
+});
+
+test('ordinary prompts, other commands, and questions pass straight through', () => {
+  for (const p of ['fix the tests', '/idea-triage', '/idea-go 3', '/ideasx', 'an /idea in the middle', '/ideas what should I build today?']) {
+    assert.equal(runHook(p), null, p);
+  }
+  assert.equal(store.load().ideas.length, 0);
+});
+
+test('/ideas shows the board, details, and applies quick edits', () => {
+  store.addIdeas(['alpha', 'beta'], { project: '/work/app' });
+  assert.match(runHook('/ideas').reason, /2 open[\s\S]*#2 {3}beta[\s\S]*#1 {3}alpha/);
+  assert.match(runHook('/ideas #1').reason, /^#1 alpha/);
+  assert.match(runHook('/ideas done 1 shipped it').reason, /#1 alpha → done/);
+  assert.match(runHook('/ideas done').reason, /DONE[\s\S]*#1/);
+  assert.match(runHook('/ideas 9').reason, /No idea #9/);
+  assert.match(runHook('/idea').reason, /^Usage/);
+});
+
+test('handlePrompt filters by project with "here"', () => {
+  store.addIdeas(['mine'], { project: '/a' });
+  store.addIdeas(['theirs'], { project: '/b' });
+  const board = handlePrompt('/ideas here', { cwd: '/a' });
+  assert.match(board, /mine/);
+  assert.doesNotMatch(board, /theirs/);
+});
+
+test('when the archive is unreadable the prompt goes through instead of being lost', () => {
+  fs.mkdirSync(store.home(), { recursive: true });
+  fs.writeFileSync(store.dbPath(), 'not json');
+  const r = spawnSync(process.execPath, [BIN, 'hook'], {
+    input: JSON.stringify({ prompt: '/idea keep me', cwd: '/x' }),
+    encoding: 'utf8',
+    env: process.env,
+  });
+  assert.equal(r.status, 0);
+  assert.equal(r.stdout, '');
+  assert.match(r.stderr, /not valid JSON/);
+});
diff --git a/tests/mcp.test.js b/tests/mcp.test.js
new file mode 100644
index 0000000..68197cf
--- /dev/null
+++ b/tests/mcp.test.js
@@ -0,0 +1,116 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import readline from 'node:readline';
+import { after, before, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
+const project = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-proj-'));
+let server;
+let nextId = 1;
+const pending = new Map();
+
+function request(method, params = {}) {
+  const id = nextId++;
+  server.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
+  return new Promise((resolve) => pending.set(id, resolve));
+}
+
+async function call(name, args = {}) {
+  const res = await request('tools/call', { name, arguments: args });
+  return { text: res.result.content[0].text, isError: !!res.result.isError };
+}
+
+before(async () => {
+  const env = {
+    ...process.env,
+    IDEAMINE_HOME: fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-mcp-')),
+    IDEAMINE_CLAUDE_BIN: fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)),
+  };
+  server = spawn(process.execPath, [BIN, 'mcp'], { cwd: project, env, stdio: ['pipe', 'pipe', 'inherit'] });
+  readline.createInterface({ input: server.stdout }).on('line', (line) => {
+    const msg = JSON.parse(line); // stdout must carry nothing but JSON-RPC
+    pending.get(msg.id)?.(msg);
+  });
+  const init = await request('initialize', {
+    protocolVersion: '2025-06-18',
+    capabilities: {},
+    clientInfo: { name: 'test', version: '0' },
+  });
+  assert.equal(init.result.protocolVersion, '2025-06-18');
+  assert.equal(init.result.serverInfo.name, 'ideamine');
+  assert.ok(init.result.capabilities.tools);
+  assert.ok(init.result.capabilities.prompts);
+  server.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
+});
+
+after(() => server.kill());
+
+test('lists the five tools with schemas', async () => {
+  const res = await request('tools/list');
+  assert.deepEqual(res.result.tools.map((t) => t.name), ['idea_add', 'idea_list', 'idea_triage', 'idea_update', 'idea_next']);
+  for (const t of res.result.tools) assert.equal(t.inputSchema.type, 'object');
+});
+
+test('full loop: add, triage, next, update', async () => {
+  const added = await call('idea_add', { text: '- cache the API responses\n- rename helpers.js to utils.js' });
+  assert.match(added.text, /Saved 2 ideas: #1, #2/);
+
+  const work = await call('idea_triage');
+  assert.match(work.text, /Ideas to triage \(2\)/);
+  assert.match(work.text, /haiku/);
+  assert.match(work.text, new RegExp(`project: ${path.basename(project)}`));
+
+  const saved = await call('idea_triage', {
+    by: 'test-model',
+    verdicts: [
+      { id: 1, verdict: 'do', impact: 4, size: 'm', model: 'sonnet', title: 'Cache API responses', why: 'slow pages', brief: 'Add an LRU cache.' },
+      { id: 2, verdict: 'do', impact: 2, size: 'xs', model: 'haiku', title: 'Rename helpers.js', why: 'trivial', brief: 'Rename and fix imports.' },
+    ],
+  });
+  assert.match(saved.text, /Saved 2 verdicts: 2 do · 0 maybe · 0 skip/);
+
+  const next = await call('idea_next');
+  assert.match(next.text, /^#2 Rename helpers\.js/); // 2/1 value per effort beats 4/3
+  assert.match(next.text, /recommended model: haiku/);
+
+  const upd = await call('idea_update', { id: 2, status: 'done', note: 'renamed' });
+  assert.match(upd.text, /Updated #2 Rename helpers\.js · done · note added/);
+
+  const board = await call('idea_list');
+  assert.match(board.text, /1 open \(1 do\) · 1 done/);
+  const one = await call('idea_list', { id: 1 });
+  assert.match(one.text, /brief {4}Add an LRU cache\./);
+  assert.match(one.text, /triaged .* by test-model/);
+});
+
+test('headless triage through the tool, while other requests stay responsive', async () => {
+  await call('idea_add', { text: '- idea x\n- idea y' });
+  const [triaged, pong] = await Promise.all([call('idea_triage', { headless: true }), request('ping')]);
+  assert.deepEqual(pong.result, {});
+  assert.match(triaged.text, /Saved 2 verdicts: 2 do · 0 maybe · 0 skip \(sonnet, 321 in \/ 45 out tokens\)/);
+  assert.match((await call('idea_triage', { headless: true })).text, /inbox is empty/);
+});
+
+test('tool errors come back as isError results, not protocol errors', async () => {
+  const res = await call('idea_update', { id: 999, status: 'done' });
+  assert.equal(res.isError, true);
+  assert.match(res.text, /no idea #999/);
+  const unknown = await request('tools/call', { name: 'nope', arguments: {} });
+  assert.equal(unknown.error.code, -32602);
+  const method = await request('does/not/exist');
+  assert.equal(method.error.code, -32601);
+});
+
+test('prompts mirror the skills', async () => {
+  const list = await request('prompts/list');
+  assert.deepEqual(list.result.prompts.map((p) => p.name), ['idea', 'ideas', 'idea-triage', 'idea-go']);
+  const got = await request('prompts/get', { name: 'idea', arguments: { text: 'teleport the cat' } });
+  const text = got.result.messages[0].content.text;
+  assert.match(text, /teleport the cat/);
+  assert.doesNotMatch(text, /^---/);
+  assert.match(text, /idea_add/);
+});
diff --git a/tests/store.test.js b/tests/store.test.js
new file mode 100644
index 0000000..e17631b
--- /dev/null
+++ b/tests/store.test.js
@@ -0,0 +1,85 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { beforeEach, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import * as store from '../src/store.js';
+
+const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url));
+
+beforeEach(() => {
+  process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-test-'));
+});
+
+test('add, triage, and list ideas by lane and priority', () => {
+  store.addIdeas(['small win', 'big but slow', 'meh', 'later'], { project: '/p' });
+  store.applyTriage([
+    { id: 1, verdict: 'do', impact: 3, size: 'xs', model: 'haiku', title: 'Small win', why: 'cheap', brief: 'b' },
+    { id: 2, verdict: 'do', impact: 5, size: 'xl', model: 'claude-opus-5', title: 'Big', why: 'w', brief: 'b' },
+    { id: 3, verdict: 'skip', impact: 1, size: 'm', model: 'sonnet', title: 'Meh', why: 'no', brief: 'ignored' },
+  ]);
+  const db = store.load();
+  assert.deepEqual(store.listIdeas(db).map((i) => i.id), [1, 2, 4]); // do by value/effort, then inbox
+  assert.deepEqual(store.listIdeas(db, { filter: 'skip' }).map((i) => i.id), [3]);
+  assert.equal(store.findIdea(db, 2).triage.model, 'opus'); // full model ids normalize to aliases
+  assert.equal(store.findIdea(db, 3).triage.brief, ''); // skip verdicts carry no brief
+  assert.equal(store.pickNext(db, { project: '/p' }).id, 1);
+  assert.deepEqual(store.counts(db), { inbox: 1, do: 2, maybe: 0, skip: 1, doing: 0, done: 0, dropped: 0, open: 3 });
+});
+
+test('bad verdicts are reported per item, not fatal', () => {
+  store.addIdeas(['one']);
+  const res = store.applyTriage([{ id: 1, verdict: 'yes' }, { id: 99, verdict: 'do' }]);
+  assert.match(res[0].error, /verdict/);
+  assert.equal(res[1].error, 'no such idea');
+  assert.equal(store.findIdea(store.load(), 1).status, 'inbox');
+});
+
+test('status changes, notes, model override, and reopen', () => {
+  store.addIdeas(['thing']);
+  store.updateIdea(1, { status: 'start', note: 'on it' });
+  let idea = store.updateIdea('#1', { status: 'done', note: 'shipped' });
+  assert.equal(idea.status, 'done');
+  assert.ok(idea.closed);
+  assert.deepEqual(idea.notes.map((n) => n.text), ['on it', 'shipped']);
+  idea = store.updateIdea(1, { status: 'reopen', model: 'opus' });
+  assert.equal(idea.status, 'triaged');
+  assert.equal(idea.triage.model, 'opus');
+  assert.equal(idea.closed, undefined);
+  assert.throws(() => store.updateIdea(1, { status: 'nope' }), /status must be/);
+  assert.throws(() => store.updateIdea(42, { note: 'x' }), /no idea #42/);
+});
+
+test('a corrupt archive is never overwritten', () => {
+  fs.mkdirSync(store.home(), { recursive: true });
+  fs.writeFileSync(store.dbPath(), '{"ideas": [oops');
+  assert.throws(() => store.addIdeas(['x']), /not valid JSON/);
+  assert.equal(fs.readFileSync(store.dbPath(), 'utf8'), '{"ideas": [oops');
+});
+
+test('a stale lock from a crashed writer is recovered', () => {
+  fs.mkdirSync(path.join(store.home(), '.lock'), { recursive: true });
+  const old = new Date(Date.now() - 60_000);
+  fs.utimesSync(path.join(store.home(), '.lock'), old, old);
+  assert.equal(store.addIdeas(['after crash'])[0].idea.id, 1);
+});
+
+test('concurrent writers from many processes never lose an idea', async () => {
+  const procs = 8;
+  const each = 6;
+  const run = (n) =>
+    new Promise((resolve, reject) => {
+      // Each process appends its ideas through the CLI, all racing for the same archive.
+      const script = `for (let i = 0; i < ${each}; i++) require('node:child_process').execFileSync(process.execPath, [${JSON.stringify(BIN)}, 'add', 'proc ${n} idea ' + i]);`;
+      const child = spawn(process.execPath, ['-e', script], { env: process.env, stdio: ['ignore', 'ignore', 'pipe'] });
+      let err = '';
+      child.stderr.on('data', (d) => (err += d));
+      child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(err))));
+    });
+  await Promise.all(Array.from({ length: procs }, (_, n) => run(n)));
+  const ids = store.load().ideas.map((i) => i.id).sort((a, b) => a - b);
+  assert.equal(ids.length, procs * each);
+  assert.deepEqual(ids, Array.from({ length: procs * each }, (_, i) => i + 1));
+});
diff --git a/tests/text.test.js b/tests/text.test.js
new file mode 100644
index 0000000..325038b
--- /dev/null
+++ b/tests/text.test.js
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { deriveTitle, extractTags, isSimilar, splitIdeas, wordSet } from '../src/text.js';
+
+test('deriveTitle uses the first line, drops bullets and tags, and cuts at a word', () => {
+  assert.equal(deriveTitle('  Add dark mode #ui\nmore detail'), 'Add dark mode');
+  assert.equal(deriveTitle('- a bullet idea'), 'a bullet idea');
+  const long = 'Build a thing that does many things '.repeat(5);
+  const t = deriveTitle(long, 40);
+  assert.ok(t.length <= 40, t);
+  assert.ok(t.endsWith('…'));
+  assert.ok(!t.includes('  '));
+});
+
+test('extractTags finds #words but not #numbers or C#', () => {
+  assert.deepEqual(extractTags('ship it #UI #perf-fix, see #12 and C# code'), ['ui', 'perf-fix']);
+});
+
+test('splitIdeas splits a bulleted list and keeps continuation lines', () => {
+  assert.deepEqual(splitIdeas('- one\n- two\n  more about two\n* three'), ['one', 'two\nmore about two', 'three']);
+  assert.deepEqual(splitIdeas('1. first\n2) second'), ['first', 'second']);
+});
+
+test('splitIdeas keeps prose (or a single bullet) as one idea', () => {
+  assert.deepEqual(splitIdeas('An idea\n- with a bullet inside'), ['An idea\n- with a bullet inside']);
+  assert.deepEqual(splitIdeas('- only one'), ['- only one']);
+  assert.deepEqual(splitIdeas('   '), []);
+});
+
+test('isSimilar flags near-duplicates and not mere topic overlap', () => {
+  const a = wordSet('Add dark mode toggle to the popup');
+  assert.ok(isSimilar(a, wordSet('add a dark mode toggle in settings')));
+  assert.ok(!isSimilar(wordSet('fix login bug'), wordSet('fix signup bug')));
+  assert.ok(!isSimilar(a, wordSet('export ideas to notion')));
+});
diff --git a/tests/triage.test.js b/tests/triage.test.js
new file mode 100644
index 0000000..50a5f3f
--- /dev/null
+++ b/tests/triage.test.js
@@ -0,0 +1,68 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { beforeEach, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { headlessTriage } from '../src/claude.js';
+import * as store from '../src/store.js';
+
+const FAKE = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url));
+let log;
+
+beforeEach(() => {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-triage-'));
+  process.env.IDEAMINE_HOME = dir;
+  process.env.IDEAMINE_CLAUDE_BIN = FAKE;
+  process.env.FAKE_CLAUDE_LOG = log = path.join(dir, 'call.json');
+});
+
+test('headless triage runs one minimal claude -p call and saves every verdict', async () => {
+  store.addIdeas(['cache responses', 'rename helpers', 'nft thing'], { project: '/p' });
+  // Pretend we are inside a desktop-app session at max effort.
+  process.env.CLAUDECODE = '1';
+  process.env.CLAUDE_EFFORT = 'max';
+  process.env.CLAUDE_CODE_MESSAGING_SOCKET = '\\\\.\\pipe\\x';
+  const out = await headlessTriage();
+  delete process.env.CLAUDECODE;
+  delete process.env.CLAUDE_EFFORT;
+  delete process.env.CLAUDE_CODE_MESSAGING_SOCKET;
+
+  assert.equal(out.results.length, 3);
+  assert.deepEqual(out.tokens, { input: 321, output: 45 });
+  const db = store.load();
+  assert.deepEqual(db.ideas.map((i) => store.lane(i)), ['do', 'do', 'skip']);
+  assert.equal(store.findIdea(db, 2).triage.model, 'haiku');
+  assert.equal(store.findIdea(db, 1).triage.by, 'sonnet (headless)');
+
+  const call = JSON.parse(fs.readFileSync(log, 'utf8'));
+  const flag = (name) => call.args[call.args.indexOf(name) + 1];
+  assert.equal(call.args[0], '-p');
+  assert.equal(flag('--model'), 'sonnet');
+  assert.equal(flag('--tools'), '');
+  assert.equal(flag('--setting-sources'), '');
+  assert.equal(flag('--effort'), 'low');
+  assert.ok(call.args.includes('--strict-mcp-config'));
+  assert.ok(call.args.includes('--no-session-persistence'));
+  assert.ok(JSON.parse(flag('--json-schema')).properties.verdicts);
+  // The child is an independent session: no nesting marker, no inherited effort or host socket.
+  assert.deepEqual(call.env, { CLAUDECODE: null, CLAUDE_EFFORT: null, CLAUDE_CODE_MESSAGING_SOCKET: null, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL ?? null });
+  assert.match(call.input, /Ideas to triage \(3\)/);
+});
+
+test('haiku gets no effort flag, and an empty inbox makes no call', async () => {
+  assert.equal((await headlessTriage()).message, 'Nothing to triage: the inbox is empty.');
+  assert.ok(!fs.existsSync(log));
+  store.addIdeas(['one']);
+  await headlessTriage({ model: 'claude-haiku-4-5' });
+  const call = JSON.parse(fs.readFileSync(log, 'utf8'));
+  assert.equal(call.args[call.args.indexOf('--model') + 1], 'haiku');
+  assert.ok(!call.args.includes('--effort'));
+});
+
+test('a missing CLI gives a clear error', async () => {
+  store.addIdeas(['one']);
+  process.env.IDEAMINE_CLAUDE_BIN = path.join(os.tmpdir(), 'definitely-not-claude-xyz');
+  await assert.rejects(headlessTriage(), /Claude Code CLI not found/);
+  assert.equal(store.load().ideas[0].status, 'inbox');
+});