commit cc361788adb57bbe50d42be08e63b22cdc9396cf map588 <map588@pitt.edu> 2026-09-22 02:32:35 -0400 Four dashboard views, and the agent pipeline On the page of ideamine 0.7.0, beside Board, Timeline, Groups, Prompts, and Memory: Table (sortable), Projects (a row per project, a column per lane), Flow (tiles and a cumulative flow chart from the new `flow` and `stats` of data.json), and Matrix (impact against size). A ticket in Doing shows its latest note. `/ideas-pipeline` and `ideamine go --pipeline` send an idea through the agent-pipeline plugin. The request tells the pipeline to record a note on the idea after each phase and to mark it done when tests and validation pass. `/ideas-go` points to it for size L and XL. The mcp tests pin CLAUDE_PROJECT_DIR to their own project folder, so they pass when a Claude Code hook runs them. A new test compiles the page script and checks the tabs against the views.
README.md | 29 +++- bin/ideamine.js | 11 +- dashboard/index.html | 300 ++++++++++++++++++++++++++++++++++++++++- skills/ideas-go/SKILL.md | 2 +- skills/ideas-pipeline/SKILL.md | 32 +++++ src/claude.js | 25 ++++ src/hook.js | 1 + src/mcp.js | 1 + src/publish.js | 74 +++++++++- src/store.js | 7 +- tests/dashboard.test.js | 21 +++ tests/mcp.test.js | 3 +- tests/pipeline.test.js | 21 +++ tests/publish.test.js | 97 +++++++++++++ 14 files changed, 610 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md index 722861e..5444743 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Marketplaces you add yourself do not auto-update. To upgrade, run `claude plugin | `/ideas-web` · `off` | Start the dashboard with a button for each command on this PC, and show its address. See [Buttons](#buttons). | **No** | | `/ideas-sync <url>` · `off` | Share one archive between all your machines through an ideamine server. See [One archive for every machine](#one-archive-for-every-machine). | **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-pipeline [12]` | The same, through the agent pipeline of the [agent-pipeline](#with-the-agent-pipeline) plugin: research, storyboard, plan, engineers in parallel, test, validate. For big ideas. | 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 | @@ -106,7 +107,17 @@ Claude can search by meaning with `idea_list` and `semantic: true`, and group wi ## Dashboard -`ideamine publish` makes a web page of your ideas: a board with a ticket for each idea, a timeline (a Gantt chart) of how long each idea waited and how long the work took, the groups, and search by meaning. A ticket opens a preview with the brief, the notes, and the related ideas. The page is one static file, `index.html`, and it reads a snapshot, `data.json`. It loads nothing from the internet. +`ideamine publish` makes a web page of your ideas. A ticket opens a preview with the brief, the notes, and the related ideas, and the search box finds ideas by meaning in every view. The page is one static file, `index.html`, and it reads a snapshot, `data.json`. It loads nothing from the internet. Skip and dropped ideas show only when you tick "Show parked". + +| View | What it shows | +|---|---| +| Board | A column for each lane (doing, do, maybe, inbox, done), a ticket for each idea, in queue order. A ticket in Doing shows its latest note. | +| Timeline | A Gantt chart: how long each idea waited in the inbox, waited in the queue, and was in work | +| Table | Every idea in one sortable table: lane, model, size, impact, project, tags, age | +| Projects | A row for each project and a column for each lane, so you see where the work piles up | +| Flow | Tiles (open, done in 7 days, median lead and cycle time, model mix) and a cumulative flow chart of the lanes over time | +| Matrix | Impact against size. Quick wins in the top left, ideas to avoid in the bottom right | +| Groups | The ideas grouped by meaning | ```bash ideamine publish http://10.66.0.1/ @@ -131,7 +142,7 @@ server { } ``` -`data.json` has `version` (1), `generated`, `embed` (model, query prefix, thresholds, and whether vectors are present), `counts`, `ideas`, and `groups`. Each idea has its ticket key (`IDEA-12`), lane, rank in the queue, triage, times (`created`, `triaged`, `started`, `closed`), timeline `phases`, notes, `group`, `related` ideas with their similarity, and `vec`, the vector as base64 of little-endian float32. The `data.json` of `ideamine serve` also has `live` (see below). +`data.json` has `version` (1), `generated`, `embed` (model, query prefix, thresholds, and whether vectors are present), `counts`, `ideas`, `groups`, `flow` (the times and the count of each lane at each time, for the cumulative flow chart), and `stats` (the tiles of the Flow view). Each idea has its ticket key (`IDEA-12`), lane, rank in the queue, triage, times (`created`, `triaged`, `started`, `closed`), timeline `phases`, notes, `group`, `related` ideas with their similarity, and `vec`, the vector as base64 of little-endian float32. The `data.json` of `ideamine serve` also has `live` (see below). ### Buttons @@ -193,6 +204,17 @@ The dashboard can also show what your Claudes did: - **Prompts**: each prompt that you typed, on every machine, as a timeline of sessions. `ideamine config prompt_log on` sends each prompt to the ideamine server in the background. The full text goes, without the notes that Claude Code puts into a prompt; a paste longer than 100,000 characters is cut. `ideamine prompts import` sends the prompts of your older chats from the Claude Code transcripts. - **Memory**: the memories of a [memstate](https://github.com/map588/memstate) daemon: a timeline of the writes of each project, the latest writes, and each memory with its versions. Set `memstate_url` on the server, for example `http://127.0.0.1:8765`. The dashboard only reads memstate. +## With the agent pipeline + +``` +> /ideas-pipeline 12 + #12 sync subtitles with the audiobook → /work/app, through the agent pipeline +``` + +`/ideas-go` gives an idea to one subagent. For a big idea, `/ideas-pipeline` gives it to the [agent-pipeline](https://github.com/map588/agents) plugin instead: a researcher maps the project, a story-writer turns the idea into stories, a project manager plans tasks, engineers build them in parallel worktrees, an integrator merges, and a tester and a validator check the result. The pipeline asks you to approve the stories and the plan. `/ideas-go` points to `/ideas-pipeline` when an idea is size L or XL. + +The idea is the record of the run. The request tells the pipeline to add a note to the idea after each phase (`pipeline: research done`, `pipeline: plan approved`, `pipeline: wave 1 integrated`, ...), to mark the idea done when its tests and validation pass, and to leave it in `doing` with a note when it stops at its iteration cap. The dashboard shows the latest note on the ticket in the Doing column, and the drawer shows them all. In a terminal, `ideamine go 12 --pipeline` opens Claude Code with the same request. + ## 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. @@ -230,6 +252,7 @@ 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 go 12 --pipeline # the same, with the agent pipeline as the request ideamine sort # headless triage (see above) ideamine watch [off] # the watcher (see above) ideamine find sync subtitles # search by meaning @@ -302,7 +325,7 @@ sync on: each change ──► outbox ──► ideamine server ──► the ar prompt log on: any prompt ──► hook ──► prompt outbox ──► background sync ──► the Prompts tab ``` -The plugin contains a Node MCP server with no dependencies, fifteen 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. +The plugin contains a Node MCP server with no dependencies, sixteen 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 diff --git a/bin/ideamine.js b/bin/ideamine.js index e2960ee..35c8b36 100644 --- a/bin/ideamine.js +++ b/bin/ideamine.js @@ -15,6 +15,7 @@ const HELP = `ideamine: an idea inbox for Claude Code 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 go [id] --pipeline the same, with the agent pipeline (/pipeline) as the request 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 @@ -141,17 +142,19 @@ async function main() { break; } case 'go': { - const { goPlan, launchSession } = await import('../src/claude.js'); + const { goPlan, launchSession, pipelinePrompt } = await import('../src/claude.js'); const db = await archive.fresh(); 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 sort'); - const { model, dir, prompt } = goPlan(idea, cwd); + const plan = goPlan(idea, cwd); + const { model, dir } = plan; + const prompt = flags.pipeline ? pipelinePrompt(idea, { dir }) : plan.prompt; if (flags.print) { console.log(`directory: ${dir}\nmodel: ${model}\n\n${prompt}`); break; } - queued(await archive.update(idea.id, { status: 'doing' })); - console.log(`Opening Claude Code (${model}) in ${dir} for #${idea.id}…`); + queued(await archive.update(idea.id, { status: 'doing', note: flags.pipeline ? 'pipeline: started' : undefined })); + console.log(`Opening Claude Code (${model}) in ${dir} for #${idea.id}${flags.pipeline ? ', through the agent pipeline' : ''}…`); process.exitCode = launchSession({ model, prompt, cwd: dir }); break; } diff --git a/dashboard/index.html b/dashboard/index.html index 13091e0..8e2ec57 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -31,6 +31,7 @@ --phase-inbox: #86b6ef; --phase-queued: #3987e5; --phase-doing: #184f95; + --flow-inbox: #7a6fc9; --now-line: #0b0b0b; } @media (prefers-color-scheme: dark) { @@ -56,6 +57,7 @@ --phase-inbox: #184f95; --phase-queued: #3987e5; --phase-doing: #86b6ef; + --flow-inbox: #8f7fe0; --now-line: #ffffff; } } @@ -116,6 +118,7 @@ main { padding: 12px; } .card-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; } .card-project { font-size: 11px; color: var(--text-muted); } .card-score { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--accent); } +.card-note { font-size: 11px; color: var(--text-dim); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* chips (lane / model / size / impact / tag) */ .chip { display: inline-flex; align-items: center; border: 1px solid var(--border-strong); border-radius: 999px; padding: 1px 8px; font-size: 11px; color: var(--text-dim); background: var(--surface-2); white-space: nowrap; } @@ -199,6 +202,63 @@ main { padding: 12px; } .tooltip-title { color: var(--text-dim); margin: 2px 0; } .tooltip-phase { color: var(--text-muted); } +/* table view */ +.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; } +.idea-table { border-collapse: collapse; width: 100%; font-size: 13px; } +.idea-table th, .idea-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; white-space: nowrap; } +.idea-table th { position: sticky; top: 0; background: var(--surface-2); font-weight: 600; font-size: 12px; } +.idea-table th button { background: none; border: none; padding: 0; cursor: pointer; font: inherit; font-weight: 600; color: var(--text-dim); } +.idea-table th[aria-sort] button { color: var(--text); } +.idea-table td.col-title { white-space: normal; min-width: 240px; } +.idea-table tbody tr { cursor: pointer; } +.idea-table tbody tr:hover, .idea-table tbody tr:focus-visible { background: var(--surface-2); outline: none; } +.sort-mark { font-size: 10px; margin-left: 3px; } + +/* projects view */ +.projects-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; } +.projects { display: grid; grid-auto-rows: minmax(48px, auto); width: max-content; } +.projects > div { border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); padding: 6px; min-width: 200px; } +.projects .proj-head { position: sticky; top: 0; z-index: 2; background: var(--surface-2); font-weight: 600; display: flex; align-items: center; gap: 6px; } +.projects .proj-name { position: sticky; left: 0; z-index: 1; background: var(--surface); min-width: 180px; max-width: 180px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; } +.projects .proj-name.proj-head { z-index: 3; } +.projects .proj-open { display: block; color: var(--text-muted); font-weight: 400; font-size: 12px; } +.projects .proj-cell { display: flex; flex-direction: column; gap: 6px; max-width: 260px; } + +/* flow view: tiles, then the cumulative flow chart */ +.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; } +.tile { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; } +.tile-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); } +.tile-value { font-size: 22px; font-weight: 700; line-height: 1.2; margin-top: 2px; } +.tile-note { font-size: 12px; color: var(--text-dim); margin-top: 2px; } +.mix-bar { display: flex; height: 10px; border-radius: 3px; overflow: hidden; gap: 2px; margin-top: 8px; background: var(--surface-2); } +.mix-seg { background: var(--chip-color); } +.mix-legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--text-dim); } +.mix-legend span::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 2px; background: var(--chip-color); margin-right: 4px; } +.flow-chart { width: 100%; height: auto; display: block; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } +.flow-area { stroke: var(--surface); stroke-width: 2; stroke-linejoin: round; } +.flow-grid { stroke: var(--border); stroke-width: 1; } +.flow-tick { fill: var(--text-muted); font-size: 10px; } +.flow-cross { stroke: var(--text); stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; } +.flow-hit { fill: transparent; cursor: crosshair; } +.tooltip-row { display: flex; justify-content: space-between; gap: 12px; } +.tooltip-swatch { display: inline-block; width: 8px; height: 8px; border-radius: 2px; background: var(--chip-color); margin-right: 5px; } + +/* matrix view */ +.matrix-wrap { overflow-x: auto; } +.matrix { display: grid; grid-template-columns: 64px repeat(5, minmax(150px, 1fr)); gap: 2px; min-width: 820px; } +.matrix > div { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 6px; min-height: 56px; position: relative; } +.matrix .mx-head { background: var(--surface-2); font-weight: 600; text-align: center; min-height: 0; display: flex; align-items: center; justify-content: center; } +.matrix .mx-count { position: absolute; top: 4px; right: 6px; font-size: 11px; color: var(--text-muted); } +.matrix .mx-corner { font-size: 11px; color: var(--text-muted); text-align: right; } +.matrix .mx-note { position: absolute; bottom: 4px; right: 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); } +.matrix .mx-win { border-color: var(--lane-do); } +.matrix .mx-avoid { border-color: var(--lane-dropped); } +.matrix-chip { display: flex; gap: 6px; align-items: baseline; width: 100%; text-align: left; background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; margin-bottom: 4px; cursor: pointer; font-size: 12px; } +.matrix-chip:hover { border-color: var(--border-strong); } +.matrix-chip .card-key { flex: none; } +.matrix-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.matrix-rest { color: var(--text-muted); font-size: 12px; margin-top: 8px; } + /* commands: only on the page of `ideamine serve` */ .header-actions { padding-top: 0; padding-bottom: 10px; } .readonly-note { color: var(--text-muted); font-size: 12px; padding-top: 0; padding-bottom: 8px; } @@ -265,6 +325,10 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus- <nav class="tabs" id="tabs" role="tablist" aria-label="Views"> <button type="button" role="tab" data-view="board" aria-controls="main">Board</button> <button type="button" role="tab" data-view="timeline" aria-controls="main">Timeline</button> + <button type="button" role="tab" data-view="table" aria-controls="main">Table</button> + <button type="button" role="tab" data-view="projects" aria-controls="main">Projects</button> + <button type="button" role="tab" data-view="flow" aria-controls="main">Flow</button> + <button type="button" role="tab" data-view="matrix" aria-controls="main">Matrix</button> <button type="button" role="tab" data-view="groups" aria-controls="main">Groups</button> <button type="button" role="tab" data-view="prompts" aria-controls="main" hidden>Prompts</button> <button type="button" role="tab" data-view="memory" aria-controls="main" hidden>Memory</button> @@ -333,7 +397,9 @@ const SEARCH_DEBOUNCE_MS = 300; const EMBED_TIMEOUT_MS = 4000; const MODELS = ['haiku', 'sonnet', 'opus', 'fable']; const OPEN_LANES = ['doing', 'do', 'maybe', 'inbox']; -const VIEWS = ['board', 'timeline', 'groups', 'prompts', 'memory']; +const VIEWS = ['board', 'timeline', 'table', 'projects', 'flow', 'matrix', 'groups', 'prompts', 'memory']; +const FLOW_STACK = ['dropped', 'skip', 'done', 'doing', 'do', 'maybe', 'inbox']; // bottom to top +const SIZE_ORDER = ['xs', 's', 'm', 'l', 'xl']; const PROMPT_RANGES = [[1, 'Last day'], [7, 'Last 7 days'], [30, 'Last 30 days'], [0, 'All']]; // Memory categories share the lane colors, so that the page needs no new colors. const CATEGORY_COLORS = { status: 'doing', decision: 'do', gotcha: 'dropped', config: 'maybe', note: 'inbox', reference: 'skip', learning: 'skip' }; @@ -348,6 +414,7 @@ const state = { query: '', searchResult: null, busy: false, + tableSort: { key: 'rank', dir: 1 }, prompts: { days: 1, list: null, error: null, filter: '' }, memory: { overview: null, error: null, project: null, memories: new Map(), histories: new Map() }, }; @@ -445,6 +512,20 @@ function fmtRelative(iso) { } } +function fmtDuration(ms) { + if (ms == null) return null; + if (ms < HOUR) return Math.max(1, Math.round(ms / MIN)) + ' min'; + if (ms < DAY) return Math.round(ms / HOUR) + ' h'; + const days = ms / DAY; + return (days < 10 ? days.toFixed(1) : Math.round(days)) + ' d'; +} + +function laneColor(lane) { return lane === 'inbox' ? 'var(--flow-inbox)' : 'var(--lane-' + lane + ')'; } + +function visibleIdeas() { + return state.data.ideas.filter((i) => state.showParked || !PARKED_LANES.includes(i.lane)); +} + function formatTick(t, step) { const d = new Date(t); if (step < HOUR) return d.toLocaleTimeString(LOCALE, { hour: '2-digit', minute: '2-digit' }); @@ -505,6 +586,8 @@ function ideaCard(idea, opts) { const top = el('div', { class: 'card-top' }, el('code', { class: 'card-key' }, idea.key), el('span', { class: 'card-chips' }, chips)); if (opts.scoreLabel) top.append(el('span', { class: 'card-score' }, opts.scoreLabel)); + // Work in progress shows its latest note, for example the phase of the agent pipeline. + const lastNote = idea.lane === 'doing' && idea.notes && idea.notes.length ? idea.notes[idea.notes.length - 1] : null; const meta = []; if (idea.project) meta.push(el('span', { class: 'card-project' }, idea.project)); for (const t of idea.tags || []) meta.push(el('span', { class: 'chip chip-tag' }, t)); @@ -512,6 +595,7 @@ function ideaCard(idea, opts) { return el('button', { class: 'card' + (opts.compact ? ' card-compact' : ''), type: 'button', onclick: () => openTicket(idea.key) }, top, el('div', { class: 'card-title' }, idea.title), + lastNote ? el('div', { class: 'card-note', title: lastNote.text }, lastNote.text) : null, meta.length ? el('div', { class: 'card-meta' }, meta) : null); } @@ -750,6 +834,216 @@ function renderTimeline() { $main.append(renderTimelineLegend(), scroll); } +/* ---- table view ---- */ + +const COLUMNS = [ + { key: 'rank', label: '#', value: (i) => i.rank, cell: (i) => el('code', { class: 'card-key' }, i.key) }, + { key: 'title', label: 'Title', value: (i) => i.title.toLowerCase(), cell: (i) => i.title, class: 'col-title' }, + { key: 'lane', label: 'Lane', value: (i) => ALL_LANES.indexOf(i.lane), cell: (i) => el('span', { class: 'chip chip-lane', style: '--chip-color:var(--lane-' + i.lane + ')' }, LANE_LABELS[i.lane] || i.lane) }, + { key: 'model', label: 'Model', value: (i) => (i.model ? MODELS.indexOf(i.model) : 9), cell: (i) => i.model || '' }, + { key: 'size', label: 'Size', value: (i) => (i.size ? SIZE_ORDER.indexOf(i.size) : 9), cell: (i) => (i.size ? i.size.toUpperCase() : '') }, + { key: 'impact', label: 'Impact', value: (i) => (i.impact == null ? -1 : i.impact), cell: (i) => (i.impact == null ? '' : '▲' + i.impact) }, + { key: 'project', label: 'Project', value: (i) => i.project || '', cell: (i) => i.project || '' }, + { key: 'tags', label: 'Tags', value: (i) => (i.tags || []).join(' '), cell: (i) => (i.tags || []).map((t) => el('span', { class: 'chip chip-tag' }, t)) }, + { key: 'created', label: 'Created', value: (i) => i.created, cell: (i) => el('span', { title: fmtDate(i.created) || '' }, fmtRelative(i.created)) }, + { key: 'updated', label: 'Updated', value: (i) => i.updated || '', cell: (i) => (i.updated ? el('span', { title: fmtDate(i.updated) || '' }, fmtRelative(i.updated)) : '') }, +]; + +function sortedForTable(ideas) { + const col = COLUMNS.find((c) => c.key === state.tableSort.key) || COLUMNS[0]; + const dir = state.tableSort.dir; + return ideas.slice().sort((a, b) => { + const va = col.value(a), vb = col.value(b); + const cmp = va < vb ? -1 : va > vb ? 1 : a.rank - b.rank; + return cmp * dir; + }); +} + +function renderTable() { + $main.textContent = ''; + const ideas = sortedForTable(visibleIdeas()); + if (!ideas.length) { $main.append(el('p', { class: 'empty-note' }, 'No ideas to show.')); return; } + const head = el('tr', {}, COLUMNS.map((c) => { + const active = state.tableSort.key === c.key; + const th = el('th', { scope: 'col', 'aria-sort': active ? (state.tableSort.dir > 0 ? 'ascending' : 'descending') : null }); + th.append(el('button', { type: 'button', onclick: () => { + state.tableSort = active ? { key: c.key, dir: -state.tableSort.dir } : { key: c.key, dir: 1 }; + render(); + } }, c.label, active ? el('span', { class: 'sort-mark' }, state.tableSort.dir > 0 ? '▲' : '▼') : null)); + return th; + })); + const body = el('tbody', {}, ideas.map((idea) => el('tr', { tabindex: '0', onclick: () => openTicket(idea.key), onkeydown: (e) => { if (e.key === 'Enter') openTicket(idea.key); } }, + COLUMNS.map((c) => el('td', { class: c.class || null }, c.cell(idea)))))); + $main.append(el('div', { class: 'table-wrap' }, el('table', { class: 'idea-table' }, el('thead', {}, head), body))); +} + +/* ---- projects view: one row per project, one column per lane ---- */ + +function renderProjects() { + $main.textContent = ''; + const lanes = state.showParked ? BOARD_LANES.concat(PARKED_LANES) : BOARD_LANES; + const rows = new Map(); + for (const idea of visibleIdeas()) { + const key = idea.project || ''; + if (!rows.has(key)) rows.set(key, []); + rows.get(key).push(idea); + } + if (!rows.size) { $main.append(el('p', { class: 'empty-note' }, 'No ideas to show.')); return; } + const openCount = (ideas) => ideas.filter((i) => OPEN_LANES.includes(i.lane)).length; + const names = [...rows.keys()].sort((a, b) => (a === '') - (b === '') || openCount(rows.get(b)) - openCount(rows.get(a)) || a.localeCompare(b)); + const grid = el('div', { class: 'projects', style: 'grid-template-columns: 180px repeat(' + lanes.length + ', minmax(200px, 260px))', role: 'table' }); + grid.append(el('div', { class: 'proj-name proj-head', role: 'columnheader' }, 'Project')); + for (const lane of lanes) { + grid.append(el('div', { class: 'proj-head', role: 'columnheader' }, el('span', { class: 'column-dot', style: '--chip-color:var(--lane-' + lane + ')' }), LANE_LABELS[lane])); + } + for (const name of names) { + const ideas = rows.get(name); + grid.append(el('div', { class: 'proj-name', role: 'rowheader', title: name || 'no project' }, name || el('span', { class: 'text-muted' }, 'no project'), el('span', { class: 'proj-open' }, openCount(ideas) + ' open'))); + for (const lane of lanes) { + const cell = ideas.filter((i) => i.lane === lane).sort((a, b) => a.rank - b.rank); + grid.append(el('div', { class: 'proj-cell', role: 'cell' }, cell.map((i) => ideaCard(i, { compact: true })))); + } + } + $main.append(el('div', { class: 'projects-wrap' }, grid)); +} + +/* ---- flow view: tiles, then the cumulative flow chart ---- */ + +function tile(label, value, note) { + return el('div', { class: 'tile' }, el('div', { class: 'tile-label' }, label), el('div', { class: 'tile-value' }, value), note ? el('div', { class: 'tile-note' }, note) : null); +} + +function modelMixTile(models) { + const total = MODELS.reduce((sum, m) => sum + (models[m] || 0), 0); + const node = el('div', { class: 'tile' }, el('div', { class: 'tile-label' }, 'Model mix, open ideas'), el('div', { class: 'tile-value' }, String(total))); + if (!total) { node.append(el('div', { class: 'tile-note' }, 'No triaged open ideas yet.')); return node; } + // One hue, a darker step for each more costly model; the legend names each one. + const steps = ['var(--phase-inbox)', 'var(--phase-queued)', 'var(--phase-doing)', 'var(--text)']; + const bar = el('div', { class: 'mix-bar', 'aria-hidden': 'true' }); + const legend = el('div', { class: 'mix-legend' }); + MODELS.forEach((m, n) => { + if (!models[m]) return; + bar.append(el('span', { class: 'mix-seg', style: '--chip-color:' + steps[n] + '; flex:' + models[m] })); + legend.append(el('span', { style: '--chip-color:' + steps[n] }, m + ' ' + models[m])); + }); + node.append(bar, legend); + return node; +} + +function renderTiles(stats) { + return el('div', { class: 'tiles' }, + tile('Open', String(stats.open), stats.oldest_open_ms != null ? 'oldest waits ' + fmtDuration(stats.oldest_open_ms) : null), + tile('Done, last 7 days', String(stats.done_7d), stats.dropped_7d ? stats.dropped_7d + ' dropped' : null), + tile('Median lead time', fmtDuration(stats.lead_median_ms) || '—', 'created → done'), + tile('Median cycle time', fmtDuration(stats.cycle_median_ms) || '—', stats.cycle_median_ms == null ? 'no recorded starts yet' : 'started → done'), + modelMixTile(stats.models || {})); +} + +function niceStep(max, ticks) { + const rough = max / ticks; + const mag = Math.pow(10, Math.floor(Math.log10(rough || 1))); + for (const m of [1, 2, 5, 10]) { if (m * mag >= rough) return m * mag; } + return 10 * mag; +} + +function renderFlowChart(flow) { + const lanes = FLOW_STACK.filter((l) => state.showParked || !PARKED_LANES.includes(l)); + const times = flow.times.map((t) => Date.parse(t)); + const n = times.length; + const W = 900, H = 260, left = 36, right = 12, top = 10, bottom = 24; + const innerW = W - left - right, innerH = H - top - bottom; + const totals = times.map((_, k) => lanes.reduce((sum, l) => sum + (flow.series[l][k] || 0), 0)); + const maxTotal = Math.max(1, ...totals); + const step = niceStep(maxTotal, 4); + const yMax = Math.ceil(maxTotal / step) * step; + const x = (k) => left + (n > 1 ? (times[k] - times[0]) / (times[n - 1] - times[0]) * innerW : innerW / 2); + const y = (v) => top + innerH - (v / yMax) * innerH; + + const svg = svgEl('svg', { class: 'flow-chart', viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': 'Cumulative flow: ' + lanes.map((l) => LANE_LABELS[l] + ' ' + flow.series[l][n - 1]).join(', ') + ' now' }); + const grid = svgEl('g'); + for (let v = 0; v <= yMax; v += step) { + grid.append(svgEl('line', { x1: left, x2: W - right, y1: y(v), y2: y(v), class: 'flow-grid' }), svgEl('text', { x: left - 6, y: y(v) + 3, 'text-anchor': 'end', class: 'flow-tick' }, String(v))); + } + const scale = buildTimeScale(times[0], times[n - 1]); + const span = Math.max(times[n - 1] - times[0], 1); + for (const t of scale.ticks) { + const px = left + ((t - times[0]) / span) * innerW; + grid.append(svgEl('line', { x1: px, x2: px, y1: top, y2: top + innerH, class: 'flow-grid' }), svgEl('text', { x: px, y: H - 8, 'text-anchor': 'middle', class: 'flow-tick' }, formatTick(t, scale.step))); + } + svg.append(grid); + + const base = new Array(n).fill(0); + for (const lane of lanes) { + const tops = base.map((b, k) => b + (flow.series[lane][k] || 0)); + let d = ''; + for (let k = 0; k < n; k++) d += (k ? 'L' : 'M') + x(k).toFixed(1) + ',' + y(tops[k]).toFixed(1); + for (let k = n - 1; k >= 0; k--) d += 'L' + x(k).toFixed(1) + ',' + y(base[k]).toFixed(1); + svg.append(svgEl('path', { d: d + 'Z', fill: laneColor(lane), class: 'flow-area' })); + for (let k = 0; k < n; k++) base[k] = tops[k]; + } + + const cross = svgEl('line', { x1: 0, x2: 0, y1: top, y2: top + innerH, class: 'flow-cross', visibility: 'hidden' }); + const hit = svgEl('rect', { x: left, y: top, width: innerW, height: innerH, class: 'flow-hit' }); + const tooltipAt = (k) => { + const box = el('div', {}, el('div', { class: 'tooltip-key' }, fmtDate(flow.times[k]))); + for (const lane of lanes.slice().reverse()) { + box.append(el('div', { class: 'tooltip-row' }, el('span', {}, el('span', { class: 'tooltip-swatch', style: '--chip-color:' + laneColor(lane) }), LANE_LABELS[lane]), el('span', {}, String(flow.series[lane][k] || 0)))); + } + box.append(el('div', { class: 'tooltip-row tooltip-phase' }, el('span', {}, 'Total'), el('span', {}, String(totals[k])))); + return box; + }; + hit.addEventListener('pointermove', (evt) => { + const rect = svg.getBoundingClientRect(); + const px = ((evt.clientX - rect.left) / rect.width) * W; + let best = 0; + for (let k = 1; k < n; k++) { if (Math.abs(x(k) - px) < Math.abs(x(best) - px)) best = k; } + cross.setAttribute('x1', x(best)); cross.setAttribute('x2', x(best)); cross.setAttribute('visibility', 'visible'); + $tooltip.textContent = ''; $tooltip.append(tooltipAt(best)); $tooltip.hidden = false; + positionTooltip(evt, svg); + }); + hit.addEventListener('pointerleave', () => { cross.setAttribute('visibility', 'hidden'); $tooltip.hidden = true; }); + svg.append(cross, hit); + + const legend = el('div', { class: 'tl-legend' }, lanes.slice().reverse().map((l) => el('span', { class: 'tl-legend-item' }, el('span', { class: 'tl-legend-swatch', style: '--chip-color:' + laneColor(l) }), LANE_LABELS[l]))); + return el('div', {}, legend, svg); +} + +function renderFlow() { + $main.textContent = ''; + const stats = state.data.stats; + const flow = state.data.flow; + if (!stats || !flow) { $main.append(el('p', { class: 'empty-note' }, 'This snapshot has no flow data. Publish it again with the current ideamine.')); return; } + $main.append(renderTiles(stats)); + if (!flow.times.length) { $main.append(el('p', { class: 'empty-note' }, 'No ideas yet.')); return; } + $main.append(renderFlowChart(flow)); +} + +/* ---- matrix view: impact against size ---- */ + +function renderMatrix() { + $main.textContent = ''; + const ideas = visibleIdeas(); + const placed = ideas.filter((i) => i.impact != null && SIZE_ORDER.includes(i.size)); + const grid = el('div', { class: 'matrix', role: 'table' }, el('div', { class: 'mx-head mx-corner' }, 'impact ↓ size →')); + for (const size of SIZE_ORDER) grid.append(el('div', { class: 'mx-head', role: 'columnheader' }, size.toUpperCase())); + for (let impact = 5; impact >= 1; impact--) { + grid.append(el('div', { class: 'mx-head', role: 'rowheader' }, '▲' + impact)); + for (const size of SIZE_ORDER) { + const cell = placed.filter((i) => i.impact === impact && i.size === size).sort((a, b) => a.rank - b.rank); + const win = impact === 5 && size === 'xs', avoid = impact === 1 && size === 'xl'; + const node = el('div', { class: (win ? 'mx-win' : '') + (avoid ? ' mx-avoid' : ''), role: 'cell' }, + cell.length ? el('span', { class: 'mx-count' }, String(cell.length)) : null, + cell.map((i) => el('button', { class: 'matrix-chip', type: 'button', onclick: () => openTicket(i.key) }, el('code', { class: 'card-key' }, i.key), el('span', {}, i.title)))); + if (win) node.append(el('span', { class: 'mx-note' }, 'quick wins')); + if (avoid) node.append(el('span', { class: 'mx-note' }, 'avoid')); + grid.append(node); + } + } + $main.append(el('div', { class: 'matrix-wrap' }, grid)); + const rest = ideas.length - placed.length; + if (rest) $main.append(el('p', { class: 'matrix-rest' }, rest + (rest === 1 ? ' idea has' : ' ideas have') + ' no triage yet, so no place here.')); +} + /* ---- search: semantic first, word fallback ---- */ function wordSearch(q) { @@ -1412,6 +1706,10 @@ function render() { renderToolbar(); if (state.query.trim() !== '') renderSearch(); else if (state.view === 'timeline') renderTimeline(); + else if (state.view === 'table') renderTable(); + else if (state.view === 'projects') renderProjects(); + else if (state.view === 'flow') renderFlow(); + else if (state.view === 'matrix') renderMatrix(); else if (state.view === 'groups') renderGroups(); else if (state.view === 'prompts') renderPrompts(); else if (state.view === 'memory') renderMemory(); diff --git a/skills/ideas-go/SKILL.md b/skills/ideas-go/SKILL.md index cb254f7..3af0acc 100644 --- a/skills/ideas-go/SKILL.md +++ b/skills/ideas-go/SKILL.md @@ -13,6 +13,6 @@ The user saves ideas from any session. The triage pairs each idea with its proje 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. 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. +4. Tell the user the title, the recommended model, and the directory in one line. If the size is L or XL, add one line: `/ideas-pipeline <id>` runs it through the agent pipeline instead. 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. diff --git a/skills/ideas-pipeline/SKILL.md b/skills/ideas-pipeline/SKILL.md new file mode 100644 index 0000000..713fe98 --- /dev/null +++ b/skills/ideas-pipeline/SKILL.md @@ -0,0 +1,32 @@ +--- +name: ideas-pipeline +description: Build the next ideamine idea, or a given id, through the agent pipeline (research, storyboard, plan, engineers, test, validate) of the agent-pipeline plugin. For big ideas. New ideas are triaged first. +argument-hint: "[id]" +disable-model-invocation: true +allowed-tools: mcp__plugin_ideamine_ideamine__idea_next, mcp__plugin_ideamine_ideamine__idea_update, mcp__plugin_ideamine_ideamine__idea_triage +--- + +Run one idea through the agent pipeline. Requested idea: $ARGUMENTS (empty means the next idea in the queue). + +The pipeline is the `pipeline` skill of the agent-pipeline plugin (`agent-pipeline:pipeline` in the skill list). If that skill is not available, say so in one line and stop, before you change any idea. + +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 take that idea. Else take the first idea. +3. Choose the project directory. 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 and the directory in one line. Then call `idea_update` with status "doing", `project` set to that directory, and the note "pipeline: started". +5. Invoke the `pipeline` skill with this request, in this shape: + + ``` + Build idea #<id> from my ideamine archive: <title> + <brief> + My original note: <text> + Project: <directory> + + Progress: ideamine idea #<id> is the record of this run. After each phase and each gate, add one note to it with the ideamine idea_update tool (id <id>, note), or run `ideamine note <id> "pipeline: <phase>"`. Use notes like "pipeline: research done", "pipeline: storyboard approved", "pipeline: plan approved (3 tasks, 2 waves)", "pipeline: wave 1 integrated", "pipeline: round 1 failed: <one line>". + When the tester and the validator both pass, call idea_update with status "done" and a one-line note. If the run stops at its iteration cap or escalates, leave the idea in "doing" and add a note that says what failed and where the reports are. + ``` + + The pipeline does not know ideamine; these lines tell it what to record. It has two gates where it asks the user for approval; that is expected. +6. When the pipeline ends, check the idea with `idea_next` (`id`) or the pipeline's final report. If the pipeline passed and the idea is not yet done, call `idea_update` with status "done" and 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/src/claude.js b/src/claude.js index f5cf049..0bb9900 100644 --- a/src/claude.js +++ b/src/claude.js @@ -208,6 +208,31 @@ export function buildPrompt(idea) { return lines.join('\n'); } +/** + * The request for the agent pipeline (the `pipeline` skill of the agent-pipeline plugin): the idea, + * its project, and how to record the progress on the idea. The pipeline does not know ideamine, + * so the request carries the instructions. + */ +export function pipelinePrompt(idea, { dir }) { + const t = idea.triage; + const lines = [`/pipeline Build idea #${idea.id} from my ideamine archive: ${idea.title}`]; + if (t?.brief) lines.push(t.brief); + lines.push(`My original note: ${idea.text}`, `Project: ${dir}`, '', ...progressInstructions(idea.id)); + return lines.join('\n'); +} + +/** What the pipeline orchestrator does with the idea after each phase. Shared with the /ideas-pipeline skill. */ +export function progressInstructions(id) { + return [ + `Progress: ideamine idea #${id} is the record of this run. After each phase and each gate, add one note to it: ` + + `run \`ideamine note ${id} "pipeline: <phase>"\`, or call the ideamine idea_update tool with id ${id} and the note. ` + + 'Use notes like "pipeline: research done", "pipeline: storyboard approved", "pipeline: plan approved (3 tasks, 2 waves)", ' + + '"pipeline: wave 1 integrated", "pipeline: round 1 failed: <one line>".', + `When the tester and the validator both pass, run \`ideamine done ${id} "<one-line outcome>"\` (or idea_update with status "done"). ` + + 'If the run stops at its iteration cap or escalates, leave the idea in "doing" and add a note that says what failed and where the reports are.', + ]; +} + /** * How `ideamine go` builds an idea: on its recommended model, in its project folder when that * folder still exists, else in `fallback`. diff --git a/src/hook.js b/src/hook.js index cc0cfe6..b80ba48 100644 --- a/src/hook.js +++ b/src/hook.js @@ -32,6 +32,7 @@ const USAGE = `Usage: /idea <text> add an idea (a bulleted list a /ideas-sync [url|off] share the archive of every machine through an ideamine server These call the model: /ideas-go [N] build the next idea, or #N, on its model. New ideas are triaged first. + /ideas-pipeline [N] the same, through the agent pipeline (agent-pipeline plugin), for big ideas /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-watch [off] Haiku triages new ideas and pairs them with projects, in the background`; diff --git a/src/mcp.js b/src/mcp.js index 2941830..ed48820 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -154,6 +154,7 @@ const PROMPTS = [ { name: 'ideas-done', description: 'Mark an idea done', arguments: [{ name: 'id', required: true }, { name: 'note', required: false }] }, { name: 'ideas-reopen', description: 'Put an idea back in the queue', arguments: [{ name: 'id', required: true }] }, { name: 'ideas-go', description: 'Build the next idea on its recommended model', arguments: [{ name: 'id', required: false }] }, + { name: 'ideas-pipeline', description: 'Build the next idea through the agent pipeline', 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 }] }, diff --git a/src/publish.js b/src/publish.js index 10c87dd..e220d55 100644 --- a/src/publish.js +++ b/src/publish.js @@ -10,7 +10,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as config from './config.js'; import * as embed from './embed.js'; -import { baseName, counts, dbPath, home, lane, listIdeas, load } from './store.js'; +import { baseName, counts, dbPath, home, lane, LANES, listIdeas, load, MODEL_ALIASES, SIZES } from './store.js'; const PAGE = new URL('../dashboard/index.html', import.meta.url); const WAIT_AFTER_ERROR_MS = 10 * 60 * 1000; // after a failed publish, before kick() tries again @@ -46,6 +46,76 @@ export function phases(idea) { return out; } +const DAY = 86400000; +const OPEN_LANES = ['inbox', 'do', 'maybe', 'doing']; + +/** + * The lane of an idea at time `t` (ms), with the same rules as phases(): null before the idea + * exists. A done idea without a recorded close time closed at its last update. + */ +export function laneAt(idea, t) { + const at = (time) => (time ? Date.parse(time) : NaN); + if (!(t >= at(idea.created))) return null; + const closedStatus = idea.status === 'done' || idea.status === 'dropped'; + if (closedStatus && t >= at(idea.closed || idea.updated)) return idea.status; + if (t >= at(idea.started)) return 'doing'; + const triaged = at(idea.triage?.at); + if (!idea.started && idea.status === 'doing' && t >= (triaged || at(idea.created))) return 'doing'; + if (t >= triaged) return idea.triage?.verdict || 'maybe'; + return 'inbox'; +} + +/** + * Cumulative flow: `times` (ISO, up to `samples` evenly spaced from the first idea to now) and + * `series`, a count for each lane at each time. At every time, the lanes add up to the ideas that + * existed then. + */ +export function flow(ideas, { samples = 60, now = Date.now() } = {}) { + const series = Object.fromEntries(LANES.map((l) => [l, []])); + const starts = ideas.map((i) => Date.parse(i.created)).filter(Number.isFinite); + if (!starts.length) return { times: [], series }; + const first = Math.min(...starts, now); + const span = Math.max(now - first, 1); + const n = Math.max(2, Math.min(samples, span + 1)); // a step of 1 ms at least, so the times differ + const times = []; + for (let k = 0; k < n; k++) { + const t = first + Math.round((span * k) / (n - 1)); + times.push(new Date(t).toISOString()); + for (const l of LANES) series[l].push(0); + for (const idea of ideas) { + const l = laneAt(idea, t); + if (l && series[l]) series[l][k]++; + } + } + return { times, series }; +} + +function median(values) { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** The tiles of the Flow view: what is open, what closed in the last 7 days, and how long ideas take. */ +export function stats(ideas, { now = Date.now() } = {}) { + const week = now - 7 * DAY; + const open = ideas.filter((i) => OPEN_LANES.includes(lane(i))); + const closedSince = (status) => ideas.filter((i) => i.status === status && Date.parse(i.closed) >= week).length; + const done = ideas.filter((i) => i.status === 'done' && i.closed); + const count = (keys, pick) => Object.fromEntries(keys.map((k) => [k, open.filter((i) => pick(i) === k).length])); + return { + open: open.length, + done_7d: closedSince('done'), + dropped_7d: closedSince('dropped'), + lead_median_ms: median(done.map((i) => Date.parse(i.closed) - Date.parse(i.created))), + cycle_median_ms: median(done.filter((i) => i.started).map((i) => Date.parse(i.closed) - Date.parse(i.started))), + oldest_open_ms: open.length ? Math.max(...open.map((i) => now - Date.parse(i.created))) : null, + models: count(MODEL_ALIASES, (i) => i.triage?.model), + sizes: count(SIZES, (i) => i.triage?.size), + }; +} + /** data.json (version 1). The dashboard page reads it; its format is in the README. */ export function snapshot(db, { vectors = null, groups = [], generated = new Date().toISOString() } = {}) { const model = config.get('embed_model'); @@ -99,6 +169,8 @@ export function snapshot(db, { vectors = null, groups = [], generated = new Date }; }), groups: groups.map((g, n) => ({ id: n, label: g.label, ids: g.ids })), + flow: flow(db.ideas, { now: Date.parse(generated) }), + stats: stats(db.ideas, { now: Date.parse(generated) }), }; } diff --git a/src/store.js b/src/store.js index 9dcc6ce..e6c4b08 100644 --- a/src/store.js +++ b/src/store.js @@ -187,7 +187,8 @@ export function lane(idea) { return idea.status; } -const LANE_ORDER = ['doing', 'do', 'maybe', 'inbox', 'skip', 'done', 'dropped']; +/** The lanes of the board, in the order they are shown. */ +export const LANES = ['doing', 'do', 'maybe', 'inbox', 'skip', 'done', 'dropped']; export function matchesFilter(idea, filter) { const l = lane(idea); @@ -213,8 +214,8 @@ export function listIdeas(db, { filter = 'open', project = null, query = '', lim }); } ideas.sort((a, b) => { - const la = LANE_ORDER.indexOf(lane(a)); - const lb = LANE_ORDER.indexOf(lane(b)); + const la = LANES.indexOf(lane(a)); + const lb = LANES.indexOf(lane(b)); if (la !== lb) return la - lb; if (['done', 'dropped', 'inbox'].includes(lane(a))) return b.id - a.id; // newest first return priority(b) - priority(a) || a.id - b.id; diff --git a/tests/dashboard.test.js b/tests/dashboard.test.js new file mode 100644 index 0000000..ba21f4f --- /dev/null +++ b/tests/dashboard.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const page = fs.readFileSync(new URL('../dashboard/index.html', import.meta.url), 'utf8'); +const script = /<script>([\s\S]*?)<\/script>/.exec(page)[1]; + +test('the page script compiles, and the tabs match the views the script knows', () => { + assert.doesNotThrow(() => new vm.Script(script, { filename: 'dashboard/index.html' })); + const tabs = [...page.matchAll(/data-view="([a-z]+)"/g)].map((m) => m[1]); + const views = JSON.parse(/const VIEWS = (\[[^\]]*\]);/.exec(script)[1].replace(/'/g, '"')); + assert.deepEqual(tabs, views); + assert.deepEqual(tabs, ['board', 'timeline', 'table', 'projects', 'flow', 'matrix', 'groups', 'prompts', 'memory']); + for (const view of views) assert.match(script, new RegExp(`state\\.view === '${view}'|else renderBoard`), `render() handles ${view}`); +}); + +test('the page loads nothing from the internet', () => { + assert.doesNotMatch(page, /https?:\/\/(?!www\.w3\.org\/2000\/svg)/); + assert.match(page, /Content-Security-Policy[^>]*default-src 'self'/); +}); diff --git a/tests/mcp.test.js b/tests/mcp.test.js index 216423e..995ef69 100644 --- a/tests/mcp.test.js +++ b/tests/mcp.test.js @@ -31,6 +31,7 @@ before(async () => { 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 + env.CLAUDE_PROJECT_DIR = project; // the server pairs ideas with this folder, also when a Claude Code hook runs 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 @@ -109,7 +110,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-find', 'ideas-groups', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-all', 'ideas-sort', 'ideas-watch', 'ideas-web', 'ideas-sync']); + assert.deepEqual(names, ['idea', 'ideas', 'ideas-ls', 'ideas-cat', 'ideas-rm', 'ideas-find', 'ideas-groups', 'ideas-done', 'ideas-reopen', 'ideas-go', 'ideas-pipeline', 'ideas-all', 'ideas-sort', 'ideas-watch', 'ideas-web', 'ideas-sync']); // 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/pipeline.test.js b/tests/pipeline.test.js new file mode 100644 index 0000000..4e3a173 --- /dev/null +++ b/tests/pipeline.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { buildPrompt, pipelinePrompt, progressInstructions } from '../src/claude.js'; + +const idea = { id: 12, title: 'Sync subtitles', text: 'sync subtitles with the audiobook', triage: { brief: 'Align the text with the audio track.' } }; + +test('the pipeline request names the idea, its project, and its id, so the pipeline can report on it', () => { + const prompt = pipelinePrompt(idea, { dir: '/work/app' }); + assert.equal(prompt, [ + '/pipeline Build idea #12 from my ideamine archive: Sync subtitles', + 'Align the text with the audio track.', + 'My original note: sync subtitles with the audiobook', + 'Project: /work/app', + '', + ...progressInstructions(12), + ].join('\n')); + assert.match(prompt, /ideamine note 12 "pipeline: <phase>"/); + assert.match(prompt, /ideamine done 12 /); + assert.match(pipelinePrompt({ ...idea, triage: null }, { dir: '/x' }), /^\/pipeline Build idea #12[^]*\nMy original note: /); + assert.doesNotMatch(buildPrompt(idea), /^\/pipeline/); // /ideas-go keeps its one-agent prompt +}); diff --git a/tests/publish.test.js b/tests/publish.test.js index 6465f68..40cd97b 100644 --- a/tests/publish.test.js +++ b/tests/publish.test.js @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, test } from 'node:test'; +import fc from 'fast-check'; import * as config from '../src/config.js'; import * as embed from '../src/embed.js'; import * as publish from '../src/publish.js'; @@ -139,3 +140,99 @@ test('config: the environment wins over the file, and an empty value restores th assert.throws(() => config.get('nope'), /unknown setting/); assert.match(config.describe(), /^embed_url\s+http:\/\/127\.0\.0\.1:\d+\/v1\s+IDEAMINE_EMBED_URL/); }); + +// ---- the Flow view: lane over time, cumulative flow, and the tiles ---- + +const iso = (ms) => new Date(ms).toISOString(); +const NOW = 4_000_000; + +/** An idea with ordered times: created ≤ triaged ≤ started ≤ closed, each stage optional. */ +const ideaArb = fc + .record({ + id: fc.integer({ min: 1, max: 1000 }), + created: fc.integer({ min: 0, max: 1_000_000 }), + gaps: fc.tuple(fc.integer({ min: 0, max: 500_000 }), fc.integer({ min: 0, max: 500_000 }), fc.integer({ min: 0, max: 500_000 })), + stage: fc.constantFrom('inbox', 'triaged', 'doing', 'done', 'dropped'), + triaged: fc.boolean(), + hasStart: fc.boolean(), + verdict: fc.constantFrom('do', 'maybe', 'skip'), + }) + .map(({ id, created, gaps, stage, triaged, hasStart, verdict }) => { + const idea = { id, created: iso(created), status: stage === 'triaged' ? 'triaged' : stage, updated: iso(created) }; + let t = created; + if (stage === 'triaged' || (triaged && stage !== 'inbox')) idea.triage = { at: iso((t += gaps[0])), verdict }; + if (hasStart && ['doing', 'done', 'dropped'].includes(stage)) idea.started = iso((t += gaps[1])); + if (stage === 'done' || stage === 'dropped') idea.closed = iso((t += gaps[2])); + idea.updated = iso(t); + return idea; + }); + +test('laneAt() agrees with lane() once every time is in the past, and is null before the idea exists', () => { + fc.assert( + fc.property(ideaArb, (idea) => { + assert.equal(publish.laneAt(idea, NOW), store.lane(idea)); + assert.equal(publish.laneAt(idea, Date.parse(idea.created) - 1), null); + assert.notEqual(publish.laneAt(idea, Date.parse(idea.created)), null); + }), + ); +}); + +test('laneAt() walks the stages in order', () => { + const idea = { id: 1, created: T(1), status: 'done', triage: { at: T(2), verdict: 'do' }, started: T(3), closed: T(5) }; + const at = (h) => publish.laneAt(idea, Date.parse(T(h))); + assert.deepEqual([at(0), at(1), at(2), at(3), at(4), at(5), at(9)], [null, 'inbox', 'do', 'doing', 'doing', 'done', 'done']); + // In work before ideamine recorded start times: the work is estimated from the triage, like phases(). + assert.equal(publish.laneAt({ id: 2, created: T(1), status: 'doing', triage: { at: T(2), verdict: 'do' } }, Date.parse(T(3))), 'doing'); + assert.equal(publish.laneAt({ id: 2, created: T(1), status: 'doing', triage: { at: T(2), verdict: 'do' } }, Date.parse(T(1))), 'inbox'); +}); + +test('flow(): at every sample, the lanes add up to the ideas that existed then', () => { + fc.assert( + fc.property(fc.array(ideaArb, { minLength: 0, maxLength: 30 }), fc.integer({ min: 2, max: 80 }), (ideas, samples) => { + const { times, series } = publish.flow(ideas, { samples, now: NOW }); + assert.deepEqual(Object.keys(series).sort(), [...store.LANES].sort()); + for (const lane of store.LANES) assert.equal(series[lane].length, times.length); + if (!ideas.length) return assert.equal(times.length, 0); + assert.ok(times.length >= 2 && times.length <= samples); + assert.equal(times[times.length - 1], iso(NOW)); + for (let k = 0; k < times.length; k++) { + const t = Date.parse(times[k]); + if (k > 0) assert.ok(t > Date.parse(times[k - 1]), 'times are strictly increasing'); + const total = store.LANES.reduce((sum, lane) => sum + series[lane][k], 0); + assert.equal(total, ideas.filter((i) => Date.parse(i.created) <= t).length); + } + }), + ); +}); + +test('stats(): open count, the last 7 days, medians, the oldest open idea, and the model mix', () => { + const now = Date.parse('2026-09-22T00:00:00.000Z'); + const day = (n, h = 0) => new Date(now - n * 86400000 + h * 3600000).toISOString(); + const ideas = [ + { id: 1, created: day(10), status: 'inbox' }, + { id: 2, created: day(9), status: 'triaged', triage: { at: day(8), verdict: 'do', model: 'haiku', size: 's' } }, + { id: 3, created: day(9), status: 'triaged', triage: { at: day(8), verdict: 'skip', model: 'opus', size: 'l' } }, + { id: 4, created: day(6), status: 'doing', triage: { at: day(5), verdict: 'do', model: 'sonnet', size: 'm' }, started: day(4) }, + { id: 5, created: day(8), status: 'done', started: day(3), closed: day(1) }, + { id: 6, created: day(30), status: 'done', started: day(20), closed: day(10) }, + { id: 7, created: day(2), status: 'dropped', closed: day(1) }, + ]; + const s = publish.stats(ideas, { now }); + assert.equal(s.open, 3); // #1 inbox, #2 do, #4 doing + assert.deepEqual([s.done_7d, s.dropped_7d], [1, 1]); + assert.equal(s.lead_median_ms, (7 + 20) / 2 * 86400000); // #5: 7 days, #6: 20 days + assert.equal(s.cycle_median_ms, (2 + 10) / 2 * 86400000); + assert.equal(s.oldest_open_ms, 10 * 86400000); + assert.deepEqual(s.models, { haiku: 1, sonnet: 1, opus: 0, fable: 0 }); + assert.deepEqual(s.sizes, { xs: 0, s: 1, m: 1, l: 0, xl: 0 }); + const empty = publish.stats([], { now }); + assert.deepEqual([empty.open, empty.lead_median_ms, empty.cycle_median_ms, empty.oldest_open_ms], [0, null, null, null]); +}); + +test('the snapshot carries the flow series and the stats', async () => { + store.addIdeas(['one idea']); + const { data } = await publish.build(store.load()); + assert.equal(data.flow.times.length, data.flow.series.inbox.length); + assert.equal(data.flow.series.inbox.at(-1), 1); + assert.equal(data.stats.open, 1); +});