commit a82ce66038bb90f1720333eee03a88db83e84de8 equwal <13551856+equwal@users.noreply.github.com> 2026-09-21 04:08:00 -0700 ideamine 0.4.0: a watcher that triages and pairs ideas with Haiku /ideas-watch turns on the watcher. When you save an idea, a background pass lets Haiku triage the idea and pair it with its project folder. No process stays alive. The hook starts a pass on each prompt when there is work, so the watcher cannot stop while it is on, and it costs nothing while no new ideas come in. A failed call gets 2 more tries. After a failed pass, the watcher waits 10 minutes. All triage now pairs ideas with projects. The prompt lists the folders of the ideas and the projects in .claude.json, each with a name and the first line of its README. Haiku answered with the folder name, not the path, so the match takes either. The Haiku triage runs with MAX_THINKING_TOKENS=0. Its thinking was about 70% of its output tokens and did not change the verdicts. One idea cost $0.0043 instead of $0.0106. samePath moves to projects.js, so that store.js and projects.js do not import each other.
.claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 23 ++++-- bin/ideamine.js | 17 +++++ package.json | 2 +- skills/ideas-all/SKILL.md | 2 +- skills/ideas-go/SKILL.md | 2 +- skills/ideas-watch/SKILL.md | 8 +++ src/claude.js | 20 ++++-- src/hook.js | 44 ++++++++---- src/mcp.js | 15 ++-- src/projects.js | 83 +++++++++++++++++++++ src/render.js | 5 +- src/rubric.js | 18 ++++- src/store.js | 30 ++++---- src/watch.js | 156 ++++++++++++++++++++++++++++++++++++++++ tests/cli.test.js | 10 +++ tests/fixtures/fake-claude.js | 22 ++++-- tests/hook.test.js | 21 ++++++ tests/mcp.test.js | 3 +- tests/projects.test.js | 56 +++++++++++++++ tests/triage.test.js | 48 ++++++++++++- tests/watch.test.js | 81 +++++++++++++++++++++ 23 files changed, 611 insertions(+), 59 deletions(-)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4fe57ed..c72f57c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "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.3.0", + "version": "0.4.0", "author": { "name": "equwal" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ef674bf..5e4f1f7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.3.0", + "version": "0.4.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": "equwal", diff --git a/README.md b/README.md index 41085b3..39ad54a 100644 --- a/README.md +++ b/README.md @@ -45,16 +45,28 @@ Marketplaces you add yourself do not auto-update. To upgrade, run `claude plugin | `/ideas-go [12]` | Build the idea that fits this chat, else the first in the queue, or #12, on its recommended model. New ideas are triaged first. | Yes, this is the build | | `/ideas-all` | Claude reads every idea, takes the ones that fit this chat out of the queue, and does them. The others stay in the queue. | Yes, this is the build | | `/ideas-sort` | Triage the inbox now and show the queue. You do not have to: `/ideas-go` triages when it must. | Yes, briefly | +| `/ideas-watch [off]` | Turn on the watcher: Haiku triages each new idea and pairs it with its project, in the background. With no argument, it also shows what the watcher did. | Haiku, only for new ideas | | `/ideas <question>` | Ask about your ideas, e.g. "which ones fit in an hour?" | Yes, briefly | Each command has its own name, so the slash menu shows all of them when you type `/idea`. The plugin menu also shows them as `/ideamine:ideas-go` and so on. Both forms work. `/ideas-go` does not ask questions. The queue puts the best `do` ideas first, then the best `maybe` ideas. Ideas that the triage marks `skip` stay out of the queue until you reopen or delete them. -You save ideas from any session, so the project that ideamine records is only the folder you were in. That can be a scratch folder that is gone. For this reason, `/ideas-go` and `/ideas-all` let Claude judge by the text which ideas fit the current chat, and where to build each one. `/ideas-go` builds in the current project when the idea fits it. Else it uses the recorded folder if that folder exists, or finds the project that the idea is about. If it cannot find the project, it stops and tells you. +You save ideas from any session, so the folder that ideamine records is only the folder you were in. That can be the scratch folder of another chat. For this reason, the triage pairs each idea with the project that it is about (see [The watcher](#the-watcher)), and `/ideas-go` and `/ideas-all` let Claude judge by the text which ideas fit the current chat, and where to build each one. `/ideas-go` builds in the current project when the idea fits it. Else it uses the recorded folder if that folder exists, or finds the project that the idea is about. If it cannot find the project, it stops and tells you. 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. +## The watcher + +``` +> /ideas-watch + ideamine watch: on since 2026-09-21 14:02. haiku triages new ideas and pairs each one with its project. +``` + +Run `/ideas-watch` once. After that, a few seconds after you save an idea, Haiku (the cheapest model) triages it and pairs it with its project. The triage chooses from the folders of your ideas and the projects that Claude Code knows (`~/.claude.json`), and it reads the first line of each README. If no project fits, for example for an idea for a new product, the idea stays where you saved it. The first pass also triages again the open ideas from before 0.4.0, so that they get a project too. + +The watcher is not a process that stays alive, because a process like that can stop: a crash, a reboot, a full context, or a usage limit. Instead, the ideamine hook starts a short background pass when you send a prompt and there is work. Thus the watcher keeps going while you use Claude Code, and it costs nothing while no new ideas come in. A failed call gets 2 more tries in the same pass. After a failed pass, the watcher waits 10 minutes before it tries again. `/ideas-watch` shows the last passes, and `/ideas-watch off` turns the watcher off. In a terminal, `ideamine watch` does the same. The log is `~/.ideamine/watch.log`. + ## Model routing The 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. @@ -93,6 +105,7 @@ ideamine done 12 "shipped in v1.4" # also: drop, start, reopen, n ideamine next # what to build next ideamine go 12 # opens Claude Code on the right model, in the idea's project ideamine sort # headless triage (see above) +ideamine watch [off] # the watcher (see above) ideamine export IDEAS.md # Markdown copy of everything ``` @@ -112,7 +125,7 @@ Tools: `idea_add`, `idea_list`, `idea_triage`, `idea_update`, `idea_next`, `idea ## 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. +`~/.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. So that it can pair ideas with projects, the triage also sends the paths of your project folders and the first line of each README. | Variable | Default | Purpose | |---|---|---| @@ -131,9 +144,11 @@ Tools: `idea_add`, `idea_list`, `idea_triage`, `idea_update`, `idea_next`, `idea ──► verdict · impact · size · cheapest capable model · brief ──► subagent on that model ──► builds it ──► idea_update: done /ideas-all ──► Claude ──► MCP idea_list (full) ──► idea_remove for the ideas that fit this chat ──► builds them + +watcher on: any prompt ──► hook ──► background pass ──► claude -p (Haiku) ──► verdicts + a project for each idea ``` -The plugin contains a Node MCP server with no dependencies, ten skills (the slash commands), and one hook. The hook ignores every prompt except `/idea`, `/ideas`, and the local `/ideas-*` commands, 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. +The plugin contains a Node MCP server with no dependencies, eleven skills (the slash commands), and one hook. The hook answers `/idea`, `/ideas`, and the local `/ideas-*` commands before any API call, and it lets every other prompt through. 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 @@ -141,7 +156,7 @@ The plugin contains a Node MCP server with no dependencies, ten skills (the slas 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. +The tests use `node:test` only. They cover the store, including concurrent writers from several processes, the hook, the MCP protocol, headless triage with pairing, and the watcher. Triage runs against a stand-in `claude`, so the tests spend no tokens. ## License diff --git a/bin/ideamine.js b/bin/ideamine.js index 6fab023..2cef36f 100644 --- a/bin/ideamine.js +++ b/bin/ideamine.js @@ -16,6 +16,8 @@ const HELP = `ideamine: an idea inbox for Claude Code ideamine go [id] [--print] open Claude Code on the idea's recommended model, in its project ideamine sort [--model sonnet] [--limit 20] [--dry-run] triage the inbox with one headless \`claude -p\` call + ideamine watch [off] the watcher: Haiku triages new ideas and pairs them with projects + ideamine watch-pass one pass of the watcher, now, in the foreground 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 @@ -154,6 +156,21 @@ async function main() { for (const r of out.results.filter((x) => x.error)) console.log(` #${r.id} not saved: ${r.error}`); break; } + case 'watch': { + const watch = await import('../src/watch.js'); + if (words[0] === 'off') watch.turnOff(); + else { + watch.turnOn(); + watch.kick(); + } + console.log(watch.status()); + break; + } + case 'watch-pass': { + const watch = await import('../src/watch.js'); + if ((await watch.pass()) === 'error') process.exitCode = 1; + break; + } case 'export': { const md = render.renderMarkdown(store.load()); if (words[0]) { diff --git a/package.json b/package.json index 47b8b97..a5b0a54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.3.0", + "version": "0.4.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": { diff --git a/skills/ideas-all/SKILL.md b/skills/ideas-all/SKILL.md index 620330e..ee5649c 100644 --- a/skills/ideas-all/SKILL.md +++ b/skills/ideas-all/SKILL.md @@ -7,7 +7,7 @@ allowed-tools: mcp__plugin_ideamine_ideamine__idea_list, mcp__plugin_ideamine_id Do every idea from the queue that fits this chat. Do not ask the user anything. -The user saves ideas from any session. Thus the project that an idea records is only the folder the user was in, and that folder can be wrong or gone. Judge by the text of each idea whether it fits this chat. +The user saves ideas from any session. The triage pairs each idea with its project folder when one fits. Else the project of an idea is only the folder where the user saved it, and that folder can be wrong or gone. Judge by the text of each idea whether it fits this chat. 1. Call `idea_list` with `full: true`. It returns every idea in the queue, in full. 2. Choose the ideas that fit this chat: the current project or this conversation. If no idea fits, say so in one line and stop. diff --git a/skills/ideas-go/SKILL.md b/skills/ideas-go/SKILL.md index d9593a0..cb254f7 100644 --- a/skills/ideas-go/SKILL.md +++ b/skills/ideas-go/SKILL.md @@ -8,7 +8,7 @@ allowed-tools: mcp__plugin_ideamine_ideamine__idea_next, mcp__plugin_ideamine_id Build one idea with the model recommended for it. Requested idea: $ARGUMENTS (empty means the next idea in the queue). Do not ask the user anything. -The user saves ideas from any session. Thus the project that an idea records is only the folder the user was in, and that folder can be wrong or gone. Judge by the text of the idea which idea fits this chat, and where it belongs. +The user saves ideas from any session. The triage pairs each idea with its project folder when one fits. Else the project of an idea is only the folder where the user saved it, and that folder can be wrong or gone. Judge by the text of the idea which idea fits this chat, and where it belongs. 1. Call `idea_next` with `triage: true`. Pass `id` if an id is given. The tool triages new ideas first. If it says that the queue is empty, say so in one line and stop. If the triage failed and no idea came back, call `idea_triage` with no verdicts, judge those ideas, save all verdicts in ONE `idea_triage` call with your model name as `by`, and then call `idea_next` again. 2. If no id is given, the tool also returns the whole queue. If an idea in the queue clearly fits this chat (the current project or this conversation), call `idea_next` with its `id`, and build that idea. Else build the first idea. diff --git a/skills/ideas-watch/SKILL.md b/skills/ideas-watch/SKILL.md new file mode 100644 index 0000000..2bb34b2 --- /dev/null +++ b/skills/ideas-watch/SKILL.md @@ -0,0 +1,8 @@ +--- +name: ideas-watch +description: Turn on the ideamine watcher. Haiku triages each new idea and pairs it with its project, in the background, at almost no cost. "off" turns it off. The ideamine hook answers this with no model call. +argument-hint: "[off]" +disable-model-invocation: true +--- + +The ideamine hook did not answer this command, and only the hook or the ideamine CLI can run the watcher. Tell the user to run `ideamine watch` (or `ideamine watch off`) in a terminal. Then stop. diff --git a/src/claude.js b/src/claude.js index bcf30ce..6ed5dd5 100644 --- a/src/claude.js +++ b/src/claude.js @@ -1,6 +1,7 @@ // 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 { knownProjects } from './projects.js'; import { BATCH_SCHEMA, pendingIdeas, triagePrompt } from './rubric.js'; import { applyTriage, counts, findIdea, load, normalizeModel } from './store.js'; @@ -47,10 +48,10 @@ function parseLooseJson(text) { } } -function run(args, input, timeoutMs) { +function run(args, input, timeoutMs, env = {}) { const [bin, argv] = command(args); return new Promise((resolve, reject) => { - const child = spawn(bin, argv, { env: childEnv(), windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + const child = spawn(bin, argv, { env: { ...childEnv(), ...env }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => child.kill(), timeoutMs); @@ -72,7 +73,7 @@ function run(args, input, timeoutMs) { * 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. `ids` triages those - * ideas instead of the inbox. + * ideas instead of the inbox. The triage also pairs each idea with a project folder of this machine. */ export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL || 'sonnet', ids = null, limit = 20, dryRun = false, budget = 1 } = {}) { const alias = normalizeModel(model) || model; @@ -80,7 +81,8 @@ export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL const pending = pendingIdeas(db, { ids, 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.`; + const projects = knownProjects(db); + const prompt = `${triagePrompt(db, pending, projects)}\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 = [ @@ -96,12 +98,16 @@ export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL '--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'); + // Haiku takes no effort setting. Its thinking was about 70% of its output tokens and did not change + // the verdicts, so it gets no thinking. + const env = alias === 'haiku' ? { MAX_THINKING_TOKENS: '0' } : {}; if (dryRun) { const shown = args.map((a) => (/[\s"{]/.test(a) || !a ? JSON.stringify(a) : a)).join(' '); - return { message: `${claudeBin()} ${shown}\n\n${prompt}` }; + const vars = Object.entries(env).map(([k, v]) => `${k}=${v} `).join(''); + return { message: `${vars}${claudeBin()} ${shown}\n\n${prompt}` }; } - const res = await run(args, prompt, 5 * 60 * 1000); + const res = await run(args, prompt, 5 * 60 * 1000, env); let out; try { out = JSON.parse(res.stdout); @@ -112,7 +118,7 @@ export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL 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 results = applyTriage(data.verdicts, { by: `${alias} (headless)`, projects }); 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 } }; diff --git a/src/hook.js b/src/hook.js index 6fb1efc..ce9a813 100644 --- a/src/hook.js +++ b/src/hook.js @@ -1,11 +1,13 @@ // UserPromptSubmit hook: answers /idea, /ideas, and the local /ideas-* commands (ls, cat, rm, done, -// reopen) 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, -// including /ideas-go, /ideas-all, /ideas-sort, and questions, which their skills answer. +// reopen, watch) 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, including /ideas-go, /ideas-all, /ideas-sort, and questions, which their skills answer. +// After each prompt, the hook lets the watcher start a background pass when there is work. import { renderAdded, renderBoard, renderIdea } from './render.js'; import { addIdeas, FILTERS, findIdea, lane, load, removeIdeas, updateIdea } from './store.js'; import { clip, splitIdeas } from './text.js'; +import * as watch from './watch.js'; // `/idea ...`, `/ideas ...`, `/ideas-<verb> ...`, and the plugin-qualified `/ideamine:...` forms. // Each verb is a separate skill with a dash, so that the slash menu shows it. @@ -22,7 +24,8 @@ const USAGE = `Usage: /idea <text> add an idea (a bulleted list a These call the model: /ideas-go [N] build the next idea, or #N, on its model. New ideas are triaged first. /ideas-all do every idea that fits this chat. The others stay in the queue. - /ideas-sort triage the inbox now and show the queue`; + /ideas-sort triage the inbox now and show the queue + /ideas-watch [off] Haiku triages new ideas and pairs them with projects, in the background`; const noIdea = (ids) => `No idea ${ids.map((id) => `#${String(id).replace(/^#/, '')}`).join(', ')}.`; @@ -50,6 +53,13 @@ export function handlePrompt(prompt, { cwd = process.cwd(), session = null } = { return renderAdded(results, load()); } + if (command === 'ideas-watch') { + if (!arg) watch.turnOn(); + else if (/^off$/i.test(arg)) watch.turnOff(); + else return null; + return watch.status(); // runHook starts the first pass after this + } + const words = arg.split(/\s+/).filter(Boolean); const ids = words.length > 0 && words.every((w) => ID.test(w)) ? words : null; const db = load(); @@ -91,7 +101,7 @@ export async function runHook() { } catch { return; // not a hook payload; stay out of the way } - let message; + let message = null; try { message = handlePrompt(typeof input?.prompt === 'string' ? input.prompt : '', { cwd: input.cwd || process.cwd(), @@ -100,14 +110,20 @@ export async function runHook() { } 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 }, - })); + if (message != null) { + // 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 }, + })); + } + // Each prompt lets the watcher catch up, so it keeps going while it is on. + try { + watch.kick(); + } catch { + // The watcher must never stop a prompt. + } } diff --git a/src/mcp.js b/src/mcp.js index c93e969..da75325 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -2,9 +2,11 @@ // runs straight from a git checkout with nothing to install. import fs from 'node:fs'; +import path from 'node:path'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; import { headlessTriage, triageFirst } from './claude.js'; +import { knownProjects } from './projects.js'; import { renderAdded, renderBoard, renderIdea } from './render.js'; import { BATCH_SCHEMA, MODELS, pendingIdeas, triagePrompt } from './rubric.js'; import { @@ -159,6 +161,7 @@ const PROMPTS = [ { name: 'ideas-go', description: 'Build the next idea on its recommended model', arguments: [{ name: 'id', required: false }] }, { name: 'ideas-all', description: 'Do every idea that fits this chat', arguments: [] }, { name: 'ideas-sort', description: 'Triage the inbox now and show the queue', arguments: [] }, + { name: 'ideas-watch', description: 'Turn the background watcher on or off', arguments: [{ name: 'off', required: false }] }, ]; function skillBody(name, args) { @@ -171,7 +174,10 @@ 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 ok) { + const meta = r.verdict === 'skip' ? '' : ` · ${r.model} · ${r.size.toUpperCase()}`; + lines.push(` #${r.id} ${r.verdict}${meta} ${clip(r.title, 60)}${r.moved ? ` → ${path.basename(r.moved)}` : ''}`); + } 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.`); @@ -216,16 +222,17 @@ const handlers = { return withBoard(headlessSummary(await headlessTriage({ model, limit: limit || 20 }))); } if (Array.isArray(verdicts) && verdicts.length) { - return withBoard(summarizeTriage(applyTriage(verdicts, { by: by ? clip(by, 40) : 'claude' }))); + const projects = knownProjects(load()); + return withBoard(summarizeTriage(applyTriage(verdicts, { by: by ? clip(by, 40) : 'claude', projects }))); } 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) + + triagePrompt(db, pending, knownProjects(db)) + '\n\nSave every verdict in ONE idea_triage call: ' + - '{"verdicts":[{"id":1,"verdict":"do","impact":3,"size":"s","model":"sonnet","title":"...","why":"...","brief":"..."}]}' + '{"verdicts":[{"id":1,"verdict":"do","impact":3,"size":"s","model":"sonnet","title":"...","why":"...","brief":"...","project":"..."}]}' ); }, diff --git a/src/projects.js b/src/projects.js new file mode 100644 index 0000000..38d1fa9 --- /dev/null +++ b/src/projects.js @@ -0,0 +1,83 @@ +// The folders that an idea can belong to. The triage pairs each idea with one of them, because the +// folder where the user saved an idea is often the scratch folder of an unrelated chat. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const MAX_PROJECTS = 40; + +/** True when two paths name the same folder: case-insensitive on Windows, trailing slashes ignored. */ +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); +} + +/** A folder that can hold a project: it exists, and it is not a drive root, the home, or a scratch folder. */ +function isProjectDir(dir) { + let stat; + try { + stat = fs.statSync(dir); + } catch { + return false; + } + const full = path.resolve(dir); + // The Claude desktop app makes a scratch folder for each chat that has no project folder. + return stat.isDirectory() && path.dirname(full) !== full && !samePath(full, os.homedir()) && !/[\\/]scratch-workspaces[\\/]/i.test(full); +} + +/** The first line of the README, else the package.json description: enough for the triage to match. */ +function describe(dir) { + for (const name of ['README.md', 'readme.md', 'README']) { + try { + const lines = fs.readFileSync(path.join(dir, name), 'utf8').split(/\r?\n/); + const first = lines.map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean); + if (first) return first.slice(0, 100); + } catch { + // No README with this name. + } + } + try { + return String(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).description || '').slice(0, 100); + } catch { + return ''; + } +} + +/** + * Project folders on this machine: the folders of the ideas, then the projects that Claude Code + * knows (the keys of "projects" in .claude.json). Each has a unique `name` (the folder name, with a + * number when two folders have the same name), the `dir`, and a one-line `about`. + */ +export function knownProjects(db, { configDir = process.env.CLAUDE_CONFIG_DIR || os.homedir() } = {}) { + const dirs = []; + const add = (dir) => { + if (dir && isProjectDir(dir) && !dirs.some((d) => samePath(d, dir))) dirs.push(path.resolve(dir)); + }; + for (const idea of db.ideas) add(idea.project); + try { + const config = JSON.parse(fs.readFileSync(path.join(configDir, '.claude.json'), 'utf8')); + for (const dir of Object.keys(config.projects || {})) add(dir); + } catch { + // No Claude Code config: the folders of the ideas are the only candidates. + } + const taken = new Set(); + return dirs.slice(0, MAX_PROJECTS).map((dir) => { + const base = path.basename(dir); + let name = base; + for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base}-${n}`; + taken.add(name.toLowerCase()); + return { name, dir, about: describe(dir) }; + }); +} + +/** The project that a triage answer names: by its name (as the prompt shows it) or by its full path. */ +export function findProject(projects, answer) { + if (!answer) return null; + const key = String(answer).trim().toLowerCase(); + return projects.find((p) => p.name.toLowerCase() === key) || projects.find((p) => samePath(p.dir, answer)) || null; +} diff --git a/src/render.js b/src/render.js index 816a101..be694ed 100644 --- a/src/render.js +++ b/src/render.js @@ -1,7 +1,8 @@ // 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 { samePath } from './projects.js'; +import { counts, lane, listIdeas } from './store.js'; import { clip } from './text.js'; const LANE_TITLES = { @@ -18,7 +19,7 @@ function projectName(p) { return p ? path.basename(p) : ''; } -function stamp(iso) { +export function stamp(iso) { if (!iso) return ''; const d = new Date(iso); const pad = (n) => String(n).padStart(2, '0'); diff --git a/src/rubric.js b/src/rubric.js index d370a42..d6b3619 100644 --- a/src/rubric.js +++ b/src/rubric.js @@ -49,6 +49,12 @@ ${MODELS.map((m) => ` ${m.alias.padEnd(6)} ${m.price.padEnd(7)} ${m.use}`).jo - 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`; +// Only when there are projects to choose from. +const PAIRING = + '- project: the name (the word before the colon) of the project in "Projects on this machine" that the idea is ' + + 'about. The user saves ideas from any chat, so the folder where an idea was saved often has nothing to do with it. ' + + '"" if no project fits, for example an idea for a new product.'; + /** JSON Schema for a batch of verdicts; used for `claude -p --json-schema` and the MCP tool. */ export const VERDICT_SCHEMA = { type: 'object', @@ -62,6 +68,7 @@ export const VERDICT_SCHEMA = { why: { type: 'string' }, brief: { type: 'string' }, dup_of: { type: 'integer' }, + project: { type: 'string' }, }, required: ['id', 'verdict', 'impact', 'size', 'model', 'title', 'why', 'brief'], }; @@ -81,15 +88,20 @@ export function pendingIdeas(db, { ids = null, limit = 30 } = {}) { 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) { +/** The rubric plus the ideas to judge, as one prompt. `projects` (from knownProjects) turns on pairing. */ +export function triagePrompt(db, pending, projects = []) { 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, '']; + const lines = [RUBRIC]; + if (projects.length) lines.push(PAIRING); + lines.push(''); if (existing.length) lines.push('Existing ideas, for duplicate checks:', ...existing, ''); + if (projects.length) { + lines.push('Projects on this machine:', ...projects.map((p) => `${p.name}: ${p.dir}${p.about ? ` — ${p.about}` : ''}`), ''); + } 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(', ')}`] diff --git a/src/store.js b/src/store.js index c8116bc..c0efa83 100644 --- a/src/store.js +++ b/src/store.js @@ -5,6 +5,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { findProject, samePath } from './projects.js'; import { clip, deriveTitle, extractTags, isSimilar, normalizeTags, wordSet } from './text.js'; export const VERDICTS = ['do', 'maybe', 'skip']; @@ -129,15 +130,6 @@ export function mutate(fn) { // --------------------------------------------------------------------------------------------- // 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; @@ -279,8 +271,12 @@ function cleanVerdict(v) { }; } -/** Save triage verdicts. Bad items are reported individually instead of failing the batch. */ -export function applyTriage(verdicts, { by = null } = {}) { +/** + * Save triage verdicts. Bad items are reported individually instead of failing the batch. + * `projects` are the folders that the triage could pair ideas with (from knownProjects). A verdict + * that names one of them moves its idea there. + */ +export function applyTriage(verdicts, { by = null, projects = null } = {}) { if (!Array.isArray(verdicts) || !verdicts.length) throw new Error('no verdicts given'); return mutate((db) => verdicts.map((v) => { @@ -292,14 +288,22 @@ export function applyTriage(verdicts, { by = null } = {}) { } catch (e) { return { id: idea.id, error: e.message }; } - idea.triage = { ...t, at: now(), ...(by ? { by } : {}) }; + idea.triage = { ...t, at: now(), ...(by ? { by } : {}), ...(projects ? { paired: true } : {}) }; 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; + const target = projects && findProject(projects, v.project); + let moved = null; + if (target && !samePath(target.dir, idea.project)) { + idea.notes ||= []; + idea.notes.push({ at: now(), text: `paired with ${target.dir} (was ${idea.project || 'no folder'})` }); + idea.project = target.dir; + moved = target.dir; + } 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 }; + return { id: idea.id, verdict: t.verdict, model: t.model, size: t.size, title: idea.title, ...(moved ? { moved } : {}) }; }), ); } diff --git a/src/watch.js b/src/watch.js new file mode 100644 index 0000000..b3ca90a --- /dev/null +++ b/src/watch.js @@ -0,0 +1,156 @@ +// The watcher: the cheapest model triages new ideas and pairs each one with its project, in the +// background. No process stays alive between passes, because a process like that can stop (a +// crash, a reboot, a full context, a usage limit). Instead, the UserPromptSubmit hook calls kick() +// for each prompt, and kick() starts a pass when there is work. Thus the watcher goes on while it +// is on, and it costs nothing while no new ideas come in. + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stamp } from './render.js'; +import { home, lane, load } from './store.js'; + +const MODEL = 'haiku'; +const RETRY_WAITS_MS = [30 * 1000, 2 * 60 * 1000]; // after a failed call, in the same pass +const WAIT_AFTER_ERROR_MS = 10 * 60 * 1000; // after a failed pass, before kick() starts a new one +const LOCK_STALE_MS = 30 * 60 * 1000; // a pass is much shorter: an older lock is from a crashed pass +const MAX_ROUNDS = 5; // triage calls in one pass, 20 ideas each +const LOG_LINES = 200; + +const statePath = () => path.join(home(), 'watch.json'); +const logPath = () => path.join(home(), 'watch.log'); +const lockPath = () => path.join(home(), '.watch'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function readState() { + try { + return JSON.parse(fs.readFileSync(statePath(), 'utf8')); + } catch { + return { on: false }; + } +} + +function writeState(patch) { + fs.mkdirSync(home(), { recursive: true }); + const state = { ...readState(), ...patch }; + fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + '\n'); + return state; +} + +export function turnOn() { + const state = readState(); + return state.on ? state : writeState({ on: true, since: new Date().toISOString(), error: null, errorAt: null }); +} + +export function turnOff() { + return writeState({ on: false }); +} + +/** Ids that the watcher must triage: the inbox, and open ideas that were triaged before pairing. */ +export function pendingWork(db) { + return db.ideas.filter((i) => lane(i) === 'inbox' || (['do', 'maybe'].includes(lane(i)) && !i.triage?.paired)).map((i) => i.id); +} + +function isRunning(now) { + try { + return now - fs.statSync(lockPath()).mtimeMs < LOCK_STALE_MS; + } catch { + return false; + } +} + +/** Run a pass in a separate process, which goes on after the caller (the hook) exits. */ +function startPassProcess() { + const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); + spawn(process.execPath, [bin, 'watch-pass'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }).unref(); +} + +/** Start a pass if the watcher is on, there is work, no pass runs, and no pass failed a short time ago. */ +export function kick({ startPass = startPassProcess, now = Date.now() } = {}) { + const state = readState(); + if (!state.on) return false; + if (state.errorAt && now - Date.parse(state.errorAt) < WAIT_AFTER_ERROR_MS) return false; + if (isRunning(now) || !pendingWork(load()).length) return false; + startPass(); + return true; +} + +function readLog() { + try { + return fs.readFileSync(logPath(), 'utf8').split('\n').filter(Boolean); + } catch { + return []; + } +} + +function log(text) { + const lines = [...readLog(), `${stamp(new Date().toISOString())} ${text}`]; + fs.writeFileSync(logPath(), lines.slice(-LOG_LINES).join('\n') + '\n'); +} + +function describe(out) { + const ok = out.results.filter((r) => !r.error); + const moved = ok.filter((r) => r.moved).map((r) => `#${r.id} → ${path.basename(r.moved)}`); + const tokens = `${out.tokens.input} in / ${out.tokens.output} out tokens`; + return `triaged ${ok.map((r) => `#${r.id}`).join(' ')} (${out.model}, ${tokens})${moved.length ? ` · paired ${moved.join(', ')}` : ''}`; +} + +async function withRetries(fn, retries) { + for (let attempt = 0; ; attempt++) { + try { + return await fn(); + } catch (e) { + if (attempt >= retries) throw e; + await sleep(RETRY_WAITS_MS[Math.min(attempt, RETRY_WAITS_MS.length - 1)]); + } + } +} + +/** One pass: triage and pair all pending ideas with the cheapest model. Only one pass runs at a time. */ +export async function pass({ model = MODEL, retries = RETRY_WAITS_MS.length } = {}) { + fs.mkdirSync(home(), { recursive: true }); + try { + fs.mkdirSync(lockPath()); + } catch (e) { + if (e.code !== 'EEXIST' || isRunning(Date.now())) return 'busy'; + fs.utimesSync(lockPath(), new Date(), new Date()); // take over the lock of a crashed pass + } + try { + // Loaded here, not at the top: the hook imports this module for each prompt. + const { headlessTriage } = await import('./claude.js'); + for (let round = 0; round < MAX_ROUNDS; round++) { + const ids = pendingWork(load()).slice(0, 20); + if (!ids.length) break; + const out = await withRetries(() => headlessTriage({ model, ids }), retries); + if (out.message) break; // the ideas were deleted in the meantime + log(describe(out)); + // An idea that the triage cannot sort must not start a new pass for each prompt. + const stuck = pendingWork(load()).filter((id) => ids.includes(id)); + if (stuck.length) throw new Error(`the triage did not sort ${stuck.map((id) => `#${id}`).join(' ')}`); + } + writeState({ lastPass: new Date().toISOString(), error: null, errorAt: null }); + return 'done'; + } catch (e) { + log(`error: ${e.message}`); + writeState({ error: e.message, errorAt: new Date().toISOString() }); + return 'error'; + } finally { + fs.rmSync(lockPath(), { recursive: true, force: true }); + } +} + +/** On or off, what waits, and the last passes. */ +export function status() { + const state = readState(); + if (!state.on) return 'ideamine watch: off. /ideas-watch turns it on.'; + const waiting = pendingWork(load()).length; + const lines = [`ideamine watch: on since ${stamp(state.since)}. ${MODEL} triages new ideas and pairs each one with its project.`]; + lines.push(waiting ? `${waiting} idea${waiting === 1 ? ' waits' : 's wait'} for the watcher.` : 'No idea waits.'); + if (isRunning(Date.now())) lines.push('A pass runs now.'); + if (state.error) lines.push(`The last pass failed: ${state.error}. The watcher tries again 10 minutes after the failure.`); + const recent = readLog().slice(-5); + if (recent.length) lines.push('last passes:', ...recent.map((line) => ` ${line}`)); + lines.push('/ideas-watch off turns it off.'); + return lines.join('\n'); +} diff --git a/tests/cli.test.js b/tests/cli.test.js index 56b856f..cff87ee 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -10,6 +10,16 @@ const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); beforeEach(() => { process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-cli-')); + process.env.IDEAMINE_CLAUDE_BIN = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)); + process.env.CLAUDE_CONFIG_DIR = process.env.IDEAMINE_HOME; +}); + +test('ideamine watch turns the watcher on and off, and watch-pass runs one pass', () => { + assert.match(cli('watch').out, /^ideamine watch: on since /); + assert.equal(cli('add', 'one').code, 0); + assert.equal(cli('watch-pass').code, 0); + assert.match(cli('cat', '1').out, /triaged .* by haiku \(headless\)/); + assert.match(cli('watch', 'off').out, /^ideamine watch: off/); }); function cli(...args) { diff --git a/tests/fixtures/fake-claude.js b/tests/fixtures/fake-claude.js index 0ec4c49..52aa75f 100644 --- a/tests/fixtures/fake-claude.js +++ b/tests/fixtures/fake-claude.js @@ -1,5 +1,7 @@ // 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. +// it was called, so tests can check flags and environment without spending tokens. It pairs an +// idea with a listed project when the idea names that project. It answers with the folder path, +// or with the project name when FAKE_CLAUDE_ANSWER=name (as Haiku did). import fs from 'node:fs'; let input = ''; @@ -7,13 +9,22 @@ 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; + for (const key of ['CLAUDECODE', 'CLAUDE_EFFORT', 'CLAUDE_CODE_MESSAGING_SOCKET', 'ANTHROPIC_BASE_URL', 'MAX_THINKING_TOKENS']) { + 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, +// Each project line is "name: folder" or "name: folder — what the README says". +const projects = (input.split('Projects on this machine:\n')[1] || '') + .split('\n\n')[0] + .split('\n') + .filter(Boolean) + .map((line) => ({ name: line.slice(0, line.indexOf(': ')), dir: line.slice(line.indexOf(': ') + 2).split(' — ')[0] })); +const answer = (p) => (p ? (process.env.FAKE_CLAUDE_ANSWER === 'name' ? p.name : p.dir) : ''); +const ideas = [...(input.split('Ideas to triage')[1] || '').matchAll(/^#(\d+)(?: \[[^\]]*\])?: (.*)$/gm)]; +const verdicts = ideas.map(([, id, text], i) => ({ + id: Number(id), verdict: i === 2 ? 'skip' : 'do', impact: 3, size: 's', @@ -21,6 +32,7 @@ const verdicts = ids.map((id, i) => ({ title: `Idea ${id}`, why: 'test', brief: `Build ${id}.`, + project: answer(projects.find((p) => text.toLowerCase().includes(p.name.toLowerCase()))), })); process.stdout.write(JSON.stringify({ diff --git a/tests/hook.test.js b/tests/hook.test.js index a97af7b..afed0d5 100644 --- a/tests/hook.test.js +++ b/tests/hook.test.js @@ -12,6 +12,9 @@ const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); beforeEach(() => { process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-hook-')); + // A pass of the watcher must never reach the real claude or the projects of this machine. + process.env.IDEAMINE_CLAUDE_BIN = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)); + process.env.CLAUDE_CONFIG_DIR = process.env.IDEAMINE_HOME; }); /** Run the real hook process the way Claude Code does: JSON on stdin, decision on stdout. */ @@ -92,6 +95,24 @@ test('the board names the dashed commands, and does not send you to a triage', ( assert.doesNotMatch(board, /\/idea-triage|\/ideas-sort/); }); +test('/ideas-watch turns the watcher on and off, with no model call', () => { + const on = runHook('/ideas-watch'); + assert.equal(on.decision, 'block'); + assert.match(on.reason, /^ideamine watch: on since /); + assert.match(runHook('/ideas-watch').reason, /^ideamine watch: on since /); + assert.match(runHook('/ideamine:ideas-watch off').reason, /^ideamine watch: off/); + assert.equal(JSON.parse(fs.readFileSync(path.join(store.home(), 'watch.json'), 'utf8')).on, false); +}); + +test('while the watcher is on, a saved idea is triaged in the background', async () => { + runHook('/ideas-watch'); + runHook('/idea make the scroller faster'); + // The hook returns at once. A separate process triages the idea with the stand-in claude. + const deadline = Date.now() + 20000; + while (store.load().ideas[0].status === 'inbox' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200)); + assert.equal(store.findIdea(store.load(), 1).triage?.by, 'haiku (headless)'); +}); + test('handlePrompt filters by project with "here"', () => { store.addIdeas(['mine'], { project: '/a' }); store.addIdeas(['theirs'], { project: '/b' }); diff --git a/tests/mcp.test.js b/tests/mcp.test.js index abadfe8..b772897 100644 --- a/tests/mcp.test.js +++ b/tests/mcp.test.js @@ -30,6 +30,7 @@ before(async () => { IDEAMINE_HOME: fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-mcp-')), IDEAMINE_CLAUDE_BIN: fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)), }; + env.CLAUDE_CONFIG_DIR = env.IDEAMINE_HOME; // no .claude.json: the projects of this machine stay out of the tests 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 @@ -108,7 +109,7 @@ test('tool errors come back as isError results, not protocol errors', async () = test('prompts mirror the skills', async () => { const list = await request('prompts/list'); const names = list.result.prompts.map((p) => p.name); - assert.deepEqual(names, ['idea', 'ideas', 'ideas-ls', 'ideas-cat', 'ideas-rm', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-all', 'ideas-sort']); + assert.deepEqual(names, ['idea', 'ideas', 'ideas-ls', 'ideas-cat', 'ideas-rm', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-all', 'ideas-sort', 'ideas-watch']); // One prompt for each skill, so that other MCP clients get the same commands as the plugin. assert.deepEqual([...names].sort(), fs.readdirSync(new URL('../skills', import.meta.url)).sort()); const go = await request('prompts/get', { name: 'ideas-go', arguments: { id: '12' } }); diff --git a/tests/projects.test.js b/tests/projects.test.js new file mode 100644 index 0000000..9891f4e --- /dev/null +++ b/tests/projects.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { findProject, knownProjects } from '../src/projects.js'; + +test('knownProjects lists real project folders once, each with a short description', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-known-')); + const dir = (...parts) => { + const d = path.join(root, ...parts); + fs.mkdirSync(d, { recursive: true }); + return d; + }; + const alpha = dir('alpha'); + const beta = dir('beta'); + const bare = dir('bare'); + const otherAlpha = dir('work', 'alpha'); // the same folder name as the first alpha + const scratch = dir('Claude', 'scratch-workspaces', 'abc', 'scratch-2026-09-21-1a2b3c'); + fs.writeFileSync(path.join(alpha, 'README.md'), '\n# Alpha: a scroller extension\n\nMore text.\n'); + fs.writeFileSync(path.join(beta, 'package.json'), JSON.stringify({ description: 'Beta, a logo maker' })); + const config = dir('config'); + const claudeProjects = { + [alpha.replaceAll('\\', '/')]: {}, // Claude Code keeps some paths with forward slashes + [scratch]: {}, + [path.join(root, 'gone')]: {}, + [os.homedir()]: {}, + [bare]: {}, + [otherAlpha]: {}, + }; + fs.writeFileSync(path.join(config, '.claude.json'), JSON.stringify({ projects: claudeProjects })); + + const db = { ideas: [{ project: beta }, { project: alpha }, { project: scratch }, { project: null }] }; + const found = knownProjects(db, { configDir: config }); + // The folders of the ideas come first, then the other Claude Code projects. Each name is unique. + assert.deepEqual(found, [ + { name: 'beta', dir: beta, about: 'Beta, a logo maker' }, + { name: 'alpha', dir: alpha, about: 'Alpha: a scroller extension' }, + { name: 'bare', dir: bare, about: '' }, + { name: 'alpha-2', dir: otherAlpha, about: '' }, + ]); + assert.deepEqual(knownProjects(db, { configDir: path.join(root, 'no-config') }).map((p) => p.dir), [beta, alpha]); +}); + +test('findProject matches the name that the triage gives, or the full path', () => { + const projects = [ + { name: 'alpha', dir: path.resolve('/p/alpha'), about: '' }, + { name: 'alpha-2', dir: path.resolve('/q/alpha'), about: '' }, + ]; + assert.equal(findProject(projects, 'alpha').dir, path.resolve('/p/alpha')); + assert.equal(findProject(projects, 'ALPHA-2').dir, path.resolve('/q/alpha')); + assert.equal(findProject(projects, path.resolve('/q/alpha')).name, 'alpha-2'); + assert.equal(findProject(projects, ''), null); + assert.equal(findProject(projects, 'gamma'), null); + assert.equal(findProject(projects, undefined), null); +}); diff --git a/tests/triage.test.js b/tests/triage.test.js index 914a550..4bc3562 100644 --- a/tests/triage.test.js +++ b/tests/triage.test.js @@ -14,9 +14,47 @@ beforeEach(() => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-triage-')); process.env.IDEAMINE_HOME = dir; process.env.IDEAMINE_CLAUDE_BIN = FAKE; + process.env.CLAUDE_CONFIG_DIR = dir; // no .claude.json: the projects of this machine stay out of the tests process.env.FAKE_CLAUDE_LOG = log = path.join(dir, 'call.json'); }); +test('the triage pairs each idea with the project that it is about', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-pair-')); + const alpha = path.join(root, 'alpha'); + const beta = path.join(root, 'beta'); + const chat = path.join(root, 'scratch-workspaces', 'chat1'); // where the user saved the ideas + for (const d of [alpha, beta, chat]) fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(alpha, 'README.md'), '# Alpha: a scroller extension\n'); + fs.writeFileSync(path.join(root, '.claude.json'), JSON.stringify({ projects: { [alpha]: {}, [beta]: {} } })); + process.env.CLAUDE_CONFIG_DIR = root; + + store.addIdeas(['make alpha scroll faster', 'beta needs a logo', 'a brand new app'], { project: chat }); + await headlessTriage({ model: 'haiku' }); + const db = store.load(); + assert.equal(store.findIdea(db, 1).project, alpha); + assert.equal(store.findIdea(db, 2).project, beta); + assert.equal(store.findIdea(db, 3).project, chat); // no project fits: the idea stays where it was saved + assert.match(store.findIdea(db, 1).notes[0].text, /^paired with .*alpha \(was .*chat1\)$/); + assert.ok(db.ideas.every((i) => i.triage.paired)); + const { input } = JSON.parse(fs.readFileSync(log, 'utf8')); + assert.match(input, /- project: /); + assert.ok(input.includes(`Projects on this machine:\nalpha: ${alpha} — Alpha: a scroller extension\nbeta: ${beta}\n\n`)); +}); + +test('regression: the triage answers with the name of the folder, and the idea is still paired', async () => { + // Haiku answered "simple-autoscroll-free", not the path, and ideamine 0.4.0 before this fix did not pair. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-name-')); + const extension = path.join(root, 'simple-autoscroll-free'); + fs.mkdirSync(extension); + fs.writeFileSync(path.join(root, '.claude.json'), JSON.stringify({ projects: { [extension]: {} } })); + process.env.CLAUDE_CONFIG_DIR = root; + process.env.FAKE_CLAUDE_ANSWER = 'name'; + store.addIdeas(['add a speed slider to simple-autoscroll-free']); + await headlessTriage({ model: 'haiku' }); + delete process.env.FAKE_CLAUDE_ANSWER; + assert.equal(store.findIdea(store.load(), 1).project, extension); +}); + 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. @@ -46,7 +84,13 @@ test('headless triage runs one minimal claude -p call and saves every verdict', 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.deepEqual(call.env, { + CLAUDECODE: null, + CLAUDE_EFFORT: null, + CLAUDE_CODE_MESSAGING_SOCKET: null, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL ?? null, + MAX_THINKING_TOKENS: process.env.MAX_THINKING_TOKENS ?? null, // low effort already limits Sonnet + }); assert.match(call.input, /Ideas to triage \(3\)/); }); @@ -58,6 +102,8 @@ test('haiku gets no effort flag, and an empty inbox makes no call', async () => const call = JSON.parse(fs.readFileSync(log, 'utf8')); assert.equal(call.args[call.args.indexOf('--model') + 1], 'haiku'); assert.ok(!call.args.includes('--effort')); + // Haiku has no effort setting. Its thinking was 70% of the output tokens and did not change the verdicts. + assert.equal(call.env.MAX_THINKING_TOKENS, '0'); }); test('headless triage of given ids judges only those ideas', async () => { diff --git a/tests/watch.test.js b/tests/watch.test.js new file mode 100644 index 0000000..0865c46 --- /dev/null +++ b/tests/watch.test.js @@ -0,0 +1,81 @@ +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 * as store from '../src/store.js'; +import * as watch from '../src/watch.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-watch-')); + process.env.IDEAMINE_HOME = dir; + process.env.IDEAMINE_CLAUDE_BIN = FAKE; + process.env.CLAUDE_CONFIG_DIR = dir; + process.env.FAKE_CLAUDE_LOG = log = path.join(dir, 'call.json'); +}); + +const lock = () => path.join(store.home(), '.watch'); + +test('the watcher starts a pass only when it is on, there is work, and no pass runs', () => { + let started = 0; + const kick = () => watch.kick({ startPass: () => started++ }); + assert.equal(kick(), false); // off + watch.turnOn(); + assert.equal(kick(), false); // no ideas + store.addIdeas(['one']); + assert.equal(kick(), true); + fs.mkdirSync(lock()); // a pass runs + assert.equal(kick(), false); + fs.rmSync(lock(), { recursive: true }); + watch.turnOff(); + assert.equal(kick(), false); + assert.equal(started, 1); +}); + +test('a pass triages the inbox and the ideas from before pairing, with Haiku, and logs it', async () => { + store.addIdeas(['old idea', 'new idea']); + store.applyTriage([{ id: 1, verdict: 'do', impact: 3, size: 's', model: 'sonnet', title: 'Old', why: 'w', brief: 'b' }]); + watch.turnOn(); + assert.deepEqual(watch.pendingWork(store.load()), [1, 2]); + await watch.pass(); + const db = store.load(); + assert.deepEqual(watch.pendingWork(db), []); + assert.deepEqual(db.ideas.map((i) => i.triage.by), ['haiku (headless)', 'haiku (headless)']); + assert.match(JSON.parse(fs.readFileSync(log, 'utf8')).args.join(' '), /--model haiku/); + assert.match(fs.readFileSync(path.join(store.home(), 'watch.log'), 'utf8'), /triaged #1 #2 \(haiku, 321 in \/ 45 out tokens\)/); + assert.ok(watch.readState().lastPass); + assert.ok(!fs.existsSync(lock())); +}); + +test('a failed pass is logged, and the watcher waits before it tries again', async () => { + store.addIdeas(['one']); + watch.turnOn(); + process.env.IDEAMINE_CLAUDE_BIN = path.join(os.tmpdir(), 'definitely-not-claude-xyz'); + await watch.pass({ retries: 0 }); + assert.match(watch.readState().error, /Claude Code CLI not found/); + assert.match(fs.readFileSync(path.join(store.home(), 'watch.log'), 'utf8'), /error: Claude Code CLI not found/); + assert.equal(watch.kick({ startPass: () => assert.fail('it must wait after an error') }), false); + assert.equal(watch.kick({ startPass: () => {}, now: Date.now() + 11 * 60 * 1000 }), true); +}); + +test('only one pass runs at a time', async () => { + store.addIdeas(['one']); + watch.turnOn(); + fs.mkdirSync(lock()); + assert.equal(await watch.pass(), 'busy'); + assert.ok(!fs.existsSync(log)); // no call to claude + assert.equal(store.load().ideas[0].status, 'inbox'); +}); + +test('the status tells if the watcher is on, what waits, and what it did last', async () => { + assert.match(watch.status(), /^ideamine watch: off/); + store.addIdeas(['one']); + watch.turnOn(); + assert.match(watch.status(), /^ideamine watch: on since .*\n1 idea waits for the watcher/); + await watch.pass(); + assert.match(watch.status(), /\nlast passes:\n {2}\d{4}-\d\d-\d\d \d\d:\d\d {2}triaged #1 /); +});