commit 2f026cce0259c72ccc730161c4a17f36bc339c43 equwal <13551856+equwal@users.noreply.github.com> 2026-09-21 01:13:39 -0700 ideamine 0.2.0: one /ideas command with shell verbs /ideas now takes a verb first, as in a shell: - ls [lane|-a] [here], cat N..., rm N..., done|start|reopen|drop N: the hook answers these with no model call. rm deletes ideas for good. - go [N]: triages new ideas first, then builds. It does not ask questions. It takes a "maybe" idea when no "do" idea is left. - all: Claude reads every idea, takes out the ideas that fit the current chat, and does them. The other ideas stay in the queue. - sort: triages the inbox now and shows the queue. /ideas go and /ideas sort replace /idea-go and /idea-triage. The CLI uses the same verbs: cat replaces show, sort replaces triage, and rm is new. go now runs its subagent in the foreground. Before, the subagent ran in the background, and its result came back in a later turn. The permission for idea_update had ended then, so the idea stayed "doing". Ideas come from any session, so the recorded folder can be wrong or gone. idea_next now tells if the folder exists and lists the whole queue. The skill tells Claude to judge which ideas fit the chat, and where to build each one. New MCP tool: idea_remove. idea_next takes triage=true, and idea_list takes full=true.
.claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 56 ++++++++++++--------- bin/ideamine.js | 35 +++++++------ package.json | 2 +- skills/idea-go/SKILL.md | 14 ------ skills/idea-triage/SKILL.md | 12 ----- skills/ideas/SKILL.md | 28 +++++++++-- src/claude.js | 22 +++++++-- src/hook.js | 70 ++++++++++++++++---------- src/mcp.js | 107 ++++++++++++++++++++++++++++++++-------- src/render.js | 7 ++- src/rubric.js | 2 +- src/store.js | 24 +++++++-- tests/cli.test.js | 33 +++++++++++++ tests/hook.test.js | 48 +++++++++++++++--- tests/mcp.test.js | 41 +++++++++++++-- tests/store.test.js | 45 +++++++++++++++++ tests/triage.test.js | 8 +++ 19 files changed, 418 insertions(+), 140 deletions(-)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c44bedd..9f41517 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.1.1", + "version": "0.2.0", "author": { "name": "equwal" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f66d7c7..82ae732 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.1.1", + "version": "0.2.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 b4e321e..09dbde7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ That line did not call the model. A hook answers `/idea` on your machine before - **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. +Every session writes to one archive, `~/.ideamine/ideas.json`, whatever project or model it uses. The archive is a queue. Claude triages new ideas to put the queue in order: 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. `/ideas go` takes the idea that fits your chat, else the first one, and builds it. ## Install @@ -35,22 +35,28 @@ Marketplaces you add yourself do not auto-update. To upgrade, run `claude plugin | 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** | +| `/idea <text>` | Add an idea. A pasted bulleted list adds one idea per bullet. `#tags` are recorded. | **No** | +| `/ideas` or `/ideas ls` | List the queue: doing, do (best first), maybe, inbox | **No** | +| `/ideas ls done` · `ls -a` · `ls here` | List one lane, every lane, or only this project | **No** | +| `/ideas cat 12` | Show idea #12 in full: brief, model, notes | **No** | +| `/ideas rm 12 14` | Delete ideas for good | **No** | +| `/ideas done 12 shipped it` | Mark an idea done, with a note. Also `start`, `reopen`, `drop` | **No** | +| `/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: `go` triages when it must. | Yes, briefly | | `/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. +The verb comes first, as in a shell. `/ideas all` is a command, not a view: to see every lane, use `/ideas ls -a`. `/ideas help` shows the usage. In the plugin menu the commands also appear as `/ideamine:idea` and `/ideamine:ideas`. Both forms work. + +`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 `rm` 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, `go` and `all` let Claude judge by the text which ideas fit the current chat, and where to build each one. `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. ## 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. +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. | Model | $ in / out per 1M tokens | Gets ideas like | |---|---|---| @@ -59,17 +65,17 @@ Claude can also save ideas by itself. If you write "idea: dark mode for the popu | `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. +The triage does not run on your session's model. `/ideas go` and `/ideas sort` make one tool call, and the MCP server hands the work to a separate, minimal `claude -p` run on Sonnet (see below). Only new ideas are triaged, once each. 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. +Recommendations are stored as aliases, so they stay valid when a newer model ships under the same name. `/ideas 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 +ideamine sort ``` -`/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. +`/ideas go` and `/ideas sort` use 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 @@ -79,12 +85,13 @@ 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 ls [inbox|do|maybe|skip|doing|done|-a] [here] +ideamine cat 12 +ideamine rm 12 # delete for good 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 sort # headless triage (see above) ideamine export IDEAS.md # Markdown copy of everything ``` @@ -100,7 +107,7 @@ The MCP server works without the plugin. For Claude Desktop, Cursor, or any stdi } ``` -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. +Tools: `idea_add`, `idea_list`, `idea_triage`, `idea_update`, `idea_next`, `idea_remove`. 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 @@ -109,22 +116,23 @@ Tools: `idea_add`, `idea_list`, `idea_triage`, `idea_update`, `idea_next`. Outsi | Variable | Default | Purpose | |---|---|---| | `IDEAMINE_HOME` | `~/.ideamine` | archive location | -| `IDEAMINE_TRIAGE_MODEL` | `sonnet` | model for `ideamine triage` | +| `IDEAMINE_TRIAGE_MODEL` | `sonnet` | model for the headless 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 … ──► UserPromptSubmit hook ──► ~/.ideamine/ideas.json ──► "💡 Saved #43" (model never called) +/ideas ls|cat|rm ──► same hook, answers locally -/idea-triage ──► Claude ──► MCP idea_triage ──► claude -p (Sonnet, minimal context) +/ideas go ──► Claude ──► MCP idea_next ──► claude -p (Sonnet, minimal context), for new ideas only ──► verdict · impact · size · cheapest capable model · brief -/idea-go ──► Claude ──► subagent on that model ──► builds it ──► idea_update: done + ──► 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 ``` -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. +The plugin contains a Node MCP server with no dependencies, two skills (the slash commands), and one hook. The hook ignores every prompt except `/idea` 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. ## Development diff --git a/bin/ideamine.js b/bin/ideamine.js index 5a38928..6fab023 100644 --- a/bin/ideamine.js +++ b/bin/ideamine.js @@ -6,15 +6,16 @@ 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 ls [lane|-a] [here] the queue. lanes: open inbox do maybe skip doing done dropped all + ideamine cat <id...> ideas in full + ideamine rm <id...> delete ideas for good 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 sort [--model sonnet] [--limit 20] [--dry-run] + triage 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 @@ -67,19 +68,23 @@ async function main() { console.log(render.renderAdded(results, store.load())); break; } - case 'ls': - case 'list': - case 'board': { - const lower = words.map((w) => w.toLowerCase()); + case 'ls': { + const lower = words.map((w) => (w === '-a' ? 'all' : 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)); + case 'cat': { + needId(); + const db = store.load(); + const ideas = words.map((id) => store.findIdea(db, id) || fail(`no idea #${id.replace(/^#/, '')}`)); + console.log(ideas.map(render.renderIdea).join('\n\n')); + break; + } + case 'rm': { + needId(); + for (const idea of store.removeIdeas(words)) console.log(`Removed #${idea.id} ${clip(idea.title, 60)}`); break; } case 'done': @@ -107,7 +112,7 @@ async function main() { 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'); + console.log('Nothing is ready to build. Triage the inbox first: ideamine sort'); break; } console.log(render.renderIdea(idea)); @@ -117,7 +122,7 @@ async function main() { 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'); + if (!idea) fail(words[0] ? `no idea #${words[0]}` : 'nothing is ready to build; run: ideamine sort'); const model = idea.triage?.model || 'sonnet'; const dir = idea.project && fs.existsSync(idea.project) ? idea.project : cwd; const prompt = buildPrompt(idea); @@ -130,7 +135,7 @@ async function main() { process.exitCode = launchSession({ model, prompt, cwd: dir }); break; } - case 'triage': { + case 'sort': { const { headlessTriage } = await import('../src/claude.js'); const out = await headlessTriage({ model: typeof flags.model === 'string' ? flags.model : undefined, diff --git a/package.json b/package.json index 16f02c8..ecb6cd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.1.1", + "version": "0.2.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/idea-go/SKILL.md b/skills/idea-go/SKILL.md deleted file mode 100644 index 2f928f6..0000000 --- a/skills/idea-go/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 93cc9de..0000000 --- a/skills/idea-triage/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -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/ideas/SKILL.md b/skills/ideas/SKILL.md index 7e0e539..dae9e9b 100644 --- a/skills/ideas/SKILL.md +++ b/skills/ideas/SKILL.md @@ -1,11 +1,31 @@ --- 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" +description: The ideamine queue. ls, cat, rm, and done run on your machine with no model call. go builds the next idea on the cheapest model that can do it, all does every idea that fits this chat, and sort triages the inbox. +argument-hint: "ls [lane|-a|here] | cat N | rm N | go [N] | all | sort | done|start|reopen N | a question" disable-model-invocation: true -allowed-tools: mcp__plugin_ideamine_ideamine__idea_list, mcp__plugin_ideamine_ideamine__idea_update +allowed-tools: mcp__plugin_ideamine_ideamine__idea_list, mcp__plugin_ideamine_ideamine__idea_update, mcp__plugin_ideamine_ideamine__idea_next, mcp__plugin_ideamine_ideamine__idea_triage, mcp__plugin_ideamine_ideamine__idea_remove --- 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. +The first word of the request is the command. Do not ask the user anything. Do the command. + +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 ideas fit this chat, and where each idea belongs. + +**go [N]**: build one idea with the model recommended for it. +1. Call `idea_next` with `triage: true`. Pass `id` if N 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 N is not 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. +3. Choose the directory for the build. Use the current project if the idea fits it. Else use the project directory of the idea if it exists and fits the idea (the tool tells if it exists). Else find the project that the idea is about. If you cannot find it, say so in one line and stop. +4. Tell the user the title, the recommended model, and the directory in one line. Then call `idea_update` with status "doing" and `project` set to that directory. +5. Give the build to ONE subagent (Agent tool, general-purpose). Set its `model` to the recommended model (haiku, sonnet, opus, or fable). This sends each idea to the cheapest model that can do it. Set `run_in_background` to false. Step 6 must run in this turn, because the permission to use the ideamine tools ends with the turn. In the subagent prompt, include the brief, the original note, and the directory. Tell the subagent to work only in that directory, and to report what it changed and what is left to do. +6. 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. + +**all**: do every idea that 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. +3. Call `idea_remove` with the `ids` of the ideas that fit. This takes them out of the queue. The other ideas stay in the queue for a chat that they fit. +4. Show the user the ideas that you took, one line each, and how many stay in the queue. Then do the ideas that you took, one at a time, as the user's request. + +**sort**: call `idea_triage` with `headless: true`. If that call fails, call `idea_triage` with no verdicts, judge every idea, and save all verdicts in ONE `idea_triage` call with your model name as `by`. Show the result exactly as returned: the verdicts, then the queue. + +**Anything else** (ls, cat, rm, done, start, reopen, drop, or a question): answer from `idea_list`. Pass `filter`, `id`, `query`, or `here` when the request asks for them. Show a board exactly as returned. To change a status, use `idea_update`. To delete ideas, use `idea_remove`. Keep the answer short. Do not start to build an idea. diff --git a/src/claude.js b/src/claude.js index cc2bb15..b662934 100644 --- a/src/claude.js +++ b/src/claude.js @@ -2,7 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { BATCH_SCHEMA, pendingIdeas, triagePrompt } from './rubric.js'; -import { applyTriage, load, normalizeModel } from './store.js'; +import { applyTriage, counts, findIdea, load, normalizeModel } from './store.js'; /** Claude Code executable: explicit override, else the one running us (desktop app), else PATH. */ export function claudeBin() { @@ -71,12 +71,13 @@ 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. + * costs about 400 tokens per idea, whatever model the calling session uses. `ids` triages those + * ideas instead of the inbox. */ -export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL || 'sonnet', limit = 20, dryRun = false, budget = 1 } = {}) { +export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL || 'sonnet', ids = null, limit = 20, dryRun = false, budget = 1 } = {}) { const alias = normalizeModel(model) || model; const db = load(); - const pending = pendingIdeas(db, { limit }); + 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.`; @@ -117,6 +118,19 @@ export async function headlessTriage({ model = process.env.IDEAMINE_TRIAGE_MODEL return { results, model: alias, cost: out.total_cost_usd, tokens: { input, output: u.output_tokens || 0 } }; } +/** + * The triage that `go` needs before it picks: the idea `id` when it has no verdict, else the whole + * inbox. Returns the headlessTriage result, or null when every candidate has a verdict already. + */ +export async function triageFirst({ id = null, model } = {}) { + const db = load(); + if (id != null) { + const idea = findIdea(db, id); + return idea && !idea.triage ? headlessTriage({ model, ids: [idea.id] }) : null; + } + return counts(db).inbox ? headlessTriage({ model }) : null; +} + /** The opening prompt for a fresh session that builds one idea. */ export function buildPrompt(idea) { const t = idea.triage; diff --git a/src/hook.js b/src/hook.js index c5b6025..bd8e5d2 100644 --- a/src/hook.js +++ b/src/hook.js @@ -1,18 +1,28 @@ -// 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. +// UserPromptSubmit hook: answers /idea and the local /ideas commands (ls, cat, rm, done, ...) 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, all, sort, and questions, which the /ideas skill answers. import { renderAdded, renderBoard, renderIdea } from './render.js'; -import { addIdeas, FILTERS, findIdea, lane, load, updateIdea } from './store.js'; +import { addIdeas, FILTERS, findIdea, lane, load, removeIdeas, 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 ID = /^#?\d+$/; -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]'; +const USAGE = `Usage: /idea <text> add an idea (a bulleted list adds one idea per bullet) + /ideas [ls [lane|-a] [here]] list the queue. Lanes: inbox do maybe skip doing done dropped + /ideas cat N... show ideas in full + /ideas rm N... delete ideas for good + /ideas done|start|reopen|drop N [note] +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`; + +const noIdea = (ids) => `No idea ${ids.map((id) => `#${String(id).replace(/^#/, '')}`).join(', ')}.`; function readStdin() { return new Promise((resolve) => { @@ -38,33 +48,43 @@ export function handlePrompt(prompt, { cwd = process.cwd(), session = null } = { return renderAdded(results, load()); } - // /ideas - const words = arg.split(/\s+/).filter(Boolean); + // /ideas <verb> [args]. The verb comes first, as in a shell, so "all" is never a view. + const [first = 'ls', ...rest] = arg.split(/\s+/).filter(Boolean); + const verb = first.toLowerCase(); + const ids = ID.test(first) ? [first, ...rest] : rest; + const allIds = ids.length > 0 && ids.every((w) => ID.test(w)); const db = load(); - if (!words.length) return renderBoard(db, { cwd, hints: true }); + if (verb === 'ls') { + const lower = rest.map((w) => (w === '-a' ? 'all' : 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 (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]}.`; + if ((verb === 'cat' || ID.test(first)) && allIds) { + const missing = ids.filter((id) => !findIdea(db, id)); + return missing.length ? noIdea(missing) : ids.map((id) => renderIdea(findIdea(db, id))).join('\n\n'); } - 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)' : ''}`; + if (verb === 'rm' && allIds) { + const missing = ids.filter((id) => !findIdea(db, id)); + if (missing.length) return noIdea(missing); + return removeIdeas(ids).map((i) => `✗ Removed #${i.id} · ${clip(i.title, 60)}`).join('\n'); } - 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 }); + const status = EDIT_VERBS[verb]; + if (status && rest[0] && ID.test(rest[0])) { + const note = rest.slice(1).join(' '); + const idea = updateIdea(rest[0], { status, note: note || undefined }); + return `✓ #${idea.id} ${clip(idea.title, 60)} → ${lane(idea)}${note ? ' (note added)' : ''}`; } - if (lower[0] === 'help') return USAGE; - return null; // a question about the ideas: let the model answer it (via the /ideas skill) + if (verb === 'help') return USAGE; + + return null; // go, all, sort, or a question: the /ideas skill answers it with the model } export async function runHook() { diff --git a/src/mcp.js b/src/mcp.js index d41e83f..5a5a4ee 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -4,10 +4,23 @@ import fs from 'node:fs'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; -import { headlessTriage } from './claude.js'; +import { headlessTriage, triageFirst } 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 { + addIdeas, + applyTriage, + counts, + FILTERS, + findIdea, + lane, + listIdeas, + load, + pickNext, + removeIdeas, + STATUSES, + updateIdea, +} from './store.js'; import { clip, splitIdeas } from './text.js'; const ROOT = new URL('..', import.meta.url); @@ -43,7 +56,7 @@ const TOOLS = [ }, { name: 'idea_list', - description: 'Show the idea board, or one idea in full when id is given.', + description: 'Show the idea board, or one idea in full when id is given. full=true shows every listed idea in full.', inputSchema: { type: 'object', properties: { @@ -52,6 +65,7 @@ const TOOLS = [ query: { type: 'string', description: 'Only ideas containing all of these words.' }, here: { type: 'boolean', description: 'Only ideas from the current project.' }, limit: { type: 'integer' }, + full: { type: 'boolean', description: 'Every listed idea in full (brief, text, notes), not one line each.' }, }, additionalProperties: false, }, @@ -100,17 +114,35 @@ const TOOLS = [ { 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.', + 'Pick the idea to build next (verdict "do" before "maybe", current project first, then best value per ' + + 'effort), or fetch one by id. Returns its brief, project, and the model recommended to build it. Without ' + + 'id, it also returns the whole queue. triage=true first triages the untriaged candidates (the inbox, or ' + + 'the given id) in one headless call.', inputSchema: { type: 'object', properties: { id: { type: 'integer' }, here: { type: 'boolean', description: 'Only consider ideas from the current project.' }, + triage: { type: 'boolean', description: 'Triage untriaged candidates first, so the pick and the model are current.' }, }, additionalProperties: false, }, - annotations: { title: 'Next idea', readOnlyHint: true, openWorldHint: false }, + annotations: { title: 'Next idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false }, + }, + { + name: 'idea_remove', + description: + 'Take ideas out of the queue: delete them from the archive for good, and return each one in full. ' + + 'An unknown id is an error, and then nothing is deleted.', + inputSchema: { + type: 'object', + properties: { + ids: { type: 'array', items: { type: 'integer' }, description: 'The ideas to delete.' }, + }, + required: ['ids'], + additionalProperties: false, + }, + annotations: { title: 'Remove ideas', readOnlyHint: false, destructiveHint: true, openWorldHint: false }, }, ]; @@ -118,10 +150,7 @@ const TOOLS = [ // 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 }] }, -]; + { name: 'ideas', skill: 'ideas', description: 'The idea queue: ls, cat, rm, go, all, sort', arguments: [{ name: 'request', required: false }] },]; function skillBody(name, args) { const file = new URL(`skills/${name}/SKILL.md`, ROOT); @@ -140,6 +169,15 @@ function summarizeTriage(results, how = '') { return lines.join('\n'); } +function headlessSummary(out) { + return out.message || summarizeTriage(out.results, `${out.model}, ${out.tokens.input} in / ${out.tokens.output} out tokens`); +} + +/** The board after a triage, so the user sees every idea in its new place. */ +function withBoard(text) { + return `${text}\n\n${renderBoard(load(), { cwd: currentProject(), hints: true })}`; +} + // --------------------------------------------------------------------------------------------- // Tool handlers return plain text for the model. @@ -149,23 +187,27 @@ const handlers = { return renderAdded(results, load()); }, - idea_list({ id, filter = 'open', query = '', here = false, limit = 0 }) { + idea_list({ id, filter = 'open', query = '', here = false, limit = 0, full = false }) { 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() }); + const project = here ? currentProject() : null; + if (full) { + const ideas = listIdeas(db, { filter, query, limit, project }); + return ideas.length ? ideas.map(renderIdea).join('\n\n') : 'No ideas match.'; + } + return renderBoard(db, { filter, query, limit, project, 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`); + return withBoard(headlessSummary(await headlessTriage({ model, limit: limit || 20 }))); } if (Array.isArray(verdicts) && verdicts.length) { - return summarizeTriage(applyTriage(verdicts, { by: by ? clip(by, 40) : 'claude' })); + return withBoard(summarizeTriage(applyTriage(verdicts, { by: by ? clip(by, 40) : 'claude' }))); } limit ||= 30; const db = load(); @@ -186,7 +228,16 @@ const handlers = { return `Updated ${bits.join(' · ')}`; }, - idea_next({ id, here = false }) { + async idea_next({ id, here = false, triage = false }) { + const out = []; + if (triage) { + try { + const res = await triageFirst({ id }); + if (res) out.push(headlessSummary(res)); + } catch (e) { + out.push(`Triage failed: ${e.message}`); + } + } const db = load(); let idea; if (id != null) { @@ -197,16 +248,30 @@ const handlers = { } 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.'; + out.push( + c.inbox + ? `No idea is ready: ${c.inbox} untriaged in the inbox.${triage ? '' : ' Pass triage: true to triage them first.'}` + : `The queue is empty${here ? ' for this project' : ''}.`, + ); + return out.join('\n\n'); } 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'); + // Ideas are saved from any session, so the recorded folder can be a scratch folder that is gone. + const dir = idea.project; + lines.push(`project dir: ${dir ? `${dir} (${fs.existsSync(dir) ? 'exists' : 'does not exist'})` : '(none recorded)'}`); + out.push(lines.join('\n')); + // The whole queue, so the caller can choose an idea that fits its chat better than the first one. + if (id == null) out.push(`The queue:\n${renderBoard(db, { cwd: currentProject() })}`); + return out.join('\n\n'); + }, + + idea_remove({ ids = [] }) { + if (!ids.length) throw new Error('pass the ids to delete'); + const gone = removeIdeas(ids); + return [`Removed ${gone.length} idea${gone.length === 1 ? '' : 's'}.`, ...gone.map(renderIdea)].join('\n\n'); }, }; diff --git a/src/render.js b/src/render.js index 32b121a..23279d6 100644 --- a/src/render.js +++ b/src/render.js @@ -62,10 +62,9 @@ export function renderBoard(db, { filter = 'open', project = null, query = '', c 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'); + const tips = ['/idea <text> add']; + if (counts(db).open) tips.push('/ideas go build next', '/ideas all do all that fit here'); + if (db.ideas.length) tips.push('/ideas cat N show', '/ideas rm N delete', '/ideas help'); out.push('', tips.join(' · ')); } return out.join('\n'); diff --git a/src/rubric.js b/src/rubric.js index c28fb33..1de0e18 100644 --- a/src/rubric.js +++ b/src/rubric.js @@ -1,5 +1,5 @@ // 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. +// /ideas go and /ideas sort, and the headless `ideamine sort`) read this file. import path from 'node:path'; import { lane, listIdeas } from './store.js'; diff --git a/src/store.js b/src/store.js index 56742c3..d863332 100644 --- a/src/store.js +++ b/src/store.js @@ -202,14 +202,19 @@ export function counts(db) { return c; } -/** Best idea to build next: verdict "do", not started, current project first, then value per effort. */ +const READY_LANES = ['do', 'maybe']; + +/** + * Best idea to build next: verdict "do" before "maybe", not started, current project first, then + * value per effort. A "maybe" idea is still in the queue, so /ideas go builds it when no "do" is left. + */ export function pickNext(db, { project = null, only = false } = {}) { - let ready = db.ideas.filter((i) => lane(i) === 'do'); + let ready = db.ideas.filter((i) => READY_LANES.includes(lane(i))); 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_LANES.indexOf(lane(a)) - READY_LANES.indexOf(lane(b)) || pb - pa || priority(b) - priority(a) || a.id - b.id; }); return ready[0] || null; } @@ -299,6 +304,19 @@ export function applyTriage(verdicts, { by = null } = {}) { ); } +/** Delete ideas for good and return them. An unknown id is an error, and then nothing is deleted. */ +export function removeIdeas(ids) { + return mutate((db) => { + const gone = ids.map((id) => { + const idea = findIdea(db, id); + if (!idea) throw new Error(`no idea #${String(id).replace(/^#/, '')}`); + return idea; + }); + db.ideas = db.ideas.filter((i) => !gone.includes(i)); + return [...new Set(gone)]; + }); +} + const STATUS_ALIASES = { start: 'doing', started: 'doing', finish: 'done', finished: 'done', drop: 'dropped' }; export function updateIdea(id, patch = {}) { diff --git a/tests/cli.test.js b/tests/cli.test.js new file mode 100644 index 0000000..56b856f --- /dev/null +++ b/tests/cli.test.js @@ -0,0 +1,33 @@ +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'; + +const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); + +beforeEach(() => { + process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-cli-')); +}); + +function cli(...args) { + const r = spawnSync(process.execPath, [BIN, ...args], { encoding: 'utf8', env: process.env }); + return { out: r.stdout, err: r.stderr, code: r.status }; +} + +test('the CLI uses the same unix verbs as /ideas: ls, cat, rm, sort', () => { + for (const idea of ['alpha', 'beta', 'gamma']) assert.equal(cli('add', idea).code, 0); + assert.match(cli('cat', '1', '#3').out, /^#1 alpha\n[\s\S]*\n\n#3 gamma\n/); + assert.equal(cli('rm', '2').out, 'Removed #2 beta\n'); + + const missing = cli('rm', '1', '9'); + assert.equal(missing.code, 1); + assert.match(missing.err, /no idea #9/); // and #1 is still there + + const all = cli('ls', '-a').out; + assert.match(all, /\[showing: all\][\s\S]*#3[\s\S]*#1/); + assert.doesNotMatch(all, /#2 /); + assert.match(cli('sort', '--dry-run').out, / -p --model sonnet [\s\S]*Ideas to triage \(2\)/); +}); diff --git a/tests/hook.test.js b/tests/hook.test.js index 8265a4c..df9690f 100644 --- a/tests/hook.test.js +++ b/tests/hook.test.js @@ -39,27 +39,61 @@ test('plugin-qualified and multi-line forms work', () => { 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); - } +test('ordinary prompts, model commands, and questions pass straight through', () => { + const prompts = [ + 'fix the tests', + '/ideasx', + 'an /idea in the middle', + '/ideas go', + '/ideamine:ideas go 3', + '/ideas all', // a command, never a view: a view is "ls all" + '/ideas sort', + '/ideas inbox', // the verb comes first, so a bare lane is a request for the model + '/ideas cat', + '/ideas ls ideas about scrolling', + '/ideas rm the vague one', + '/ideas what should I build today?', + ]; + for (const p of prompts) assert.equal(runHook(p), null, p); assert.equal(store.load().ideas.length, 0); }); -test('/ideas shows the board, details, and applies quick edits', () => { +test('/ideas lists the queue, shows ideas, 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.equal(runHook('/ideas ls').reason, runHook('/ideas').reason); assert.match(runHook('/ideas #1').reason, /^#1 alpha/); + assert.match(runHook('/ideas cat 1 2').reason, /^#1 alpha\n[\s\S]*\n\n#2 beta\n/); 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 ls done').reason, /DONE[\s\S]*#1/); + assert.match(runHook('/ideas ls -a').reason, /\[showing: all\][\s\S]*#2[\s\S]*#1/); assert.match(runHook('/ideas 9').reason, /No idea #9/); + assert.match(runHook('/ideas cat 1 8 9').reason, /^No idea #8, #9\.$/); + assert.match(runHook('/ideas help').reason, /^Usage[\s\S]*\/ideas rm N/); assert.match(runHook('/idea').reason, /^Usage/); }); +test('/ideas rm deletes ideas for good, without a model call', () => { + store.addIdeas(['alpha', 'beta', 'gamma', 'delta']); + const out = runHook('/ideas rm 1 #3'); + assert.equal(out.decision, 'block'); + assert.match(out.reason, /Removed #1 · alpha\n.*Removed #3 · gamma$/); + assert.equal(runHook('/ideas rm 2 9').reason, 'No idea #9.'); // an unknown id deletes nothing + assert.match(runHook('/ideamine:ideas rm 4').reason, /Removed #4 · delta/); + assert.deepEqual(store.load().ideas.map((i) => i.text), ['beta']); +}); + +test('the board tells how to build, take, and remove ideas, and does not send you to a triage', () => { + store.addIdeas(['alpha']); + const board = runHook('/ideas').reason; + for (const tip of [/\/ideas go/, /\/ideas all/, /\/ideas rm N/]) assert.match(board, tip); + assert.doesNotMatch(board, /\/idea-triage|\/ideas sort/); +}); + test('handlePrompt filters by project with "here"', () => { store.addIdeas(['mine'], { project: '/a' }); store.addIdeas(['theirs'], { project: '/b' }); - const board = handlePrompt('/ideas here', { cwd: '/a' }); + const board = handlePrompt('/ideas ls here', { cwd: '/a' }); assert.match(board, /mine/); assert.doesNotMatch(board, /theirs/); }); diff --git a/tests/mcp.test.js b/tests/mcp.test.js index 68197cf..147081d 100644 --- a/tests/mcp.test.js +++ b/tests/mcp.test.js @@ -49,9 +49,9 @@ before(async () => { after(() => server.kill()); -test('lists the five tools with schemas', async () => { +test('lists the six 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']); + assert.deepEqual(res.result.tools.map((t) => t.name), ['idea_add', 'idea_list', 'idea_triage', 'idea_update', 'idea_next', 'idea_remove']); for (const t of res.result.tools) assert.equal(t.inputSchema.type, 'object'); }); @@ -107,10 +107,45 @@ test('tool errors come back as isError results, not protocol errors', async () = 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']); + assert.deepEqual(list.result.prompts.map((p) => p.name), ['idea', 'ideas']); + const ideas = await request('prompts/get', { name: 'ideas', arguments: { request: 'go 12' } }); + assert.match(ideas.result.messages[0].content.text, /^Request: go 12\n[\s\S]*idea_next[\s\S]*idea_remove/); 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/); }); + +// The tests below continue from the archive that the tests above left: #1, #3, #4 do; #2 done. + +test('idea_list full shows the queue in full, so /ideas all can judge each idea; idea_remove deletes ideas', async () => { + assert.match((await call('idea_list', { full: true })).text, /^#3 Idea 3\n[^]*\n\n#4 Idea 4\n[^]*\n\n#1 Cache API responses\n/); + const none = await call('idea_remove', { ids: [] }); + assert.equal(none.isError, true); + assert.match((await call('idea_remove', { ids: [1, 3, 4] })).text, /^Removed 3 ideas\.\n\n#1 Cache API responses\n[^]*\n\n#3 Idea 3\n/); + assert.match((await call('idea_list')).text, /^ideamine: 0 open · 1 done/); + assert.equal((await call('idea_list', { full: true })).text, 'No ideas match.'); +}); + +test('idea_next with triage: true triages first, so /ideas go never has to stop', async () => { + await call('idea_add', { text: '- first\n- second\n- third' }); // #5 #6 #7 + assert.match((await call('idea_next')).text, /^No idea is ready: 3 untriaged in the inbox\. Pass triage: true/); + const next = await call('idea_next', { triage: true }); + assert.match(next.text, /^Saved 3 verdicts: 2 do · 0 maybe · 1 skip/); + assert.match(next.text, /\n\n#5 Idea 5\n[^]*recommended model: sonnet\nproject dir: [^\n]*ideamine-proj-\w+ \(exists\)\n/); + assert.match(next.text, /\n\nThe queue:\nideamine: 2 open \(2 do\)[^]*#5[^]*#6/); // to choose an idea that fits the chat + + // An explicit id triages only that idea. A saved folder that is gone is marked. + await call('idea_add', { text: '- eighth\n- ninth', project: path.join(project, 'gone') }); // #8 #9 + const nine = (await call('idea_next', { id: 9, triage: true })).text; + assert.match(nine, /^Saved 1 verdict: 1 do[^]*\n\n#9 Idea 9\n/); + assert.match(nine, /project dir: [^\n]*gone \(does not exist\)$/); + assert.match((await call('idea_list', { filter: 'inbox' })).text, /#8 +eighth/); +}); + +test('a manual triage shows the whole queue afterwards', async () => { + const out = await call('idea_triage', { headless: true }); // #8, the last one in the inbox + assert.match(out.text, /^Saved 1 verdict: 1 do[^]*\n\nideamine: 4 open \(4 do\) · 1 done\n\nDO \(best first\)\n[^]*#8 +sonnet/); + assert.match((await call('idea_triage', { headless: true })).text, /^Nothing to triage: the inbox is empty\.\n\nideamine: 4 open/); +}); diff --git a/tests/store.test.js b/tests/store.test.js index e17631b..f52abd1 100644 --- a/tests/store.test.js +++ b/tests/store.test.js @@ -29,6 +29,51 @@ test('add, triage, and list ideas by lane and priority', () => { assert.deepEqual(store.counts(db), { inbox: 1, do: 2, maybe: 0, skip: 1, doing: 0, done: 0, dropped: 0, open: 3 }); }); +test('pickNext takes the best "do" idea, else the best "maybe", and never an idea from another lane', () => { + // Every combination of lanes, so that no lane can get into or out of the queue unseen. + const LANES = ['do', 'maybe', 'skip', 'inbox', 'doing', 'done', 'dropped']; + const VERDICTS = ['do', 'maybe', 'skip']; + const make = (lane, id) => ({ + id, + title: lane, + status: VERDICTS.includes(lane) ? 'triaged' : lane, + triage: lane === 'inbox' ? null : { verdict: VERDICTS.includes(lane) ? lane : 'do', impact: 3, size: 'm', model: 'sonnet' }, + }); + for (let mask = 0; mask < 1 << LANES.length; mask++) { + const present = LANES.filter((_, bit) => mask & (1 << bit)); + const db = { ideas: present.map((lane, i) => make(lane, i + 1)) }; + const want = present.includes('do') ? 'do' : present.includes('maybe') ? 'maybe' : null; + assert.equal(store.pickNext(db)?.title ?? null, want, `lanes: ${present.join(', ')}`); + } + + // The verdict counts before the project: a "do" idea elsewhere beats a "maybe" idea here. + const db = { + ideas: [ + { id: 1, status: 'triaged', project: '/here', triage: { verdict: 'maybe', impact: 5, size: 'xs', model: 'haiku' } }, + { id: 2, status: 'triaged', project: '/there', triage: { verdict: 'do', impact: 1, size: 'xl', model: 'opus' } }, + ], + }; + assert.equal(store.pickNext(db, { project: '/here' }).id, 2); +}); + +test('regression: after a triage of "0 do · 1 maybe · 1 skip", /ideas go still has an idea to build', () => { + store.addIdeas(['tes', 'redesign the AssistKey UI for a premium e-ink feel']); + store.applyTriage([ + { id: 1, verdict: 'skip', impact: 1, size: 'xs', model: 'haiku', title: "Clarify vague 'tes' idea", why: 'w', brief: '' }, + { id: 2, verdict: 'maybe', impact: 3, size: 'l', model: 'opus', title: 'Redesign AssistKey UI', why: 'w', brief: 'b' }, + ]); + assert.equal(store.pickNext(store.load()).id, 2); +}); + +test('removeIdeas deletes ideas for good, all or nothing, and never reuses an id', () => { + store.addIdeas(['one', 'two', 'three']); + assert.throws(() => store.removeIdeas([1, 99]), /no idea #99/); + assert.equal(store.load().ideas.length, 3); // an unknown id deletes nothing + assert.deepEqual(store.removeIdeas(['#2', 3, '3']).map((i) => i.id), [2, 3]); + assert.deepEqual(store.load().ideas.map((i) => i.id), [1]); + assert.equal(store.addIdeas(['four'])[0].idea.id, 4); +}); + test('bad verdicts are reported per item, not fatal', () => { store.addIdeas(['one']); const res = store.applyTriage([{ id: 1, verdict: 'yes' }, { id: 99, verdict: 'do' }]); diff --git a/tests/triage.test.js b/tests/triage.test.js index 50a5f3f..914a550 100644 --- a/tests/triage.test.js +++ b/tests/triage.test.js @@ -60,6 +60,14 @@ test('haiku gets no effort flag, and an empty inbox makes no call', async () => assert.ok(!call.args.includes('--effort')); }); +test('headless triage of given ids judges only those ideas', async () => { + store.addIdeas(['one', 'two', 'three']); + const out = await headlessTriage({ ids: [3] }); + assert.deepEqual(out.results.map((r) => r.id), [3]); + assert.deepEqual(store.load().ideas.map((i) => i.status), ['inbox', 'inbox', 'triaged']); + assert.match(JSON.parse(fs.readFileSync(log, 'utf8')).input, /Ideas to triage \(1\):\n#3: three\n/); +}); + 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');