diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 990387f..41d52bb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -18,6 +18,13 @@ "description": "Save the last user question and Claude's answer into a chosen Hillnote workspace as a dated document.", "version": "0.1.0", "author": { "name": "Hillnote" } + }, + { + "name": "kanban-board", + "source": "./kanban-board", + "description": "Parse the last Claude answer for TODO items and turn them into a kanban board in a chosen Hillnote workspace.", + "version": "0.1.0", + "author": { "name": "Hillnote" } } ] } diff --git a/README.md b/README.md index c851b4f..6f26629 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ can pick one, and saves the session as a dated document under Claude's answer (skipping tool calls and thinking). Same workspace picker, saved under `documents/claude-qa/`. +**kanban-board** — run `/kanban-board` after Claude lays out a TODO list or +plan. It parses the last answer into tasks (title, status, priority), lets you +pick a workspace, and creates a Hillnote database with a kanban view — one +card per task, grouped into To Do / In Progress / Done. + ## Privacy These plugins send the captured content — your session transcript or the last @@ -44,6 +49,7 @@ comfortable storing on Hillnote. | --- | --- | | `session-capture` | Save the entire Claude Code session transcript into a chosen Hillnote workspace as a dated document. | | `response-capture` | Save the last user question and Claude's answer into a chosen Hillnote workspace as a dated document. | +| `kanban-board` | Parse the last Claude answer for TODO items and turn them into a kanban board in a chosen Hillnote workspace. | ## Roadmap diff --git a/kanban-board/.claude-plugin/plugin.json b/kanban-board/.claude-plugin/plugin.json new file mode 100644 index 0000000..2eb84ad --- /dev/null +++ b/kanban-board/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "kanban-board", + "description": "Parse the last Claude answer for TODO items and turn them into a kanban board in a chosen Hillnote workspace.", + "version": "0.1.0", + "author": { + "name": "Hillnote", + "email": "karthik@colloqi.com" + }, + "keywords": ["hillnote", "kanban", "board", "todo", "tasks", "database"] +} diff --git a/kanban-board/.mcp.json b/kanban-board/.mcp.json new file mode 100644 index 0000000..481f73c --- /dev/null +++ b/kanban-board/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "notes": { + "type": "http", + "url": "https://hillnote.com/mcp" + } + } +} diff --git a/kanban-board/commands/kanban-board.md b/kanban-board/commands/kanban-board.md new file mode 100644 index 0000000..786f993 --- /dev/null +++ b/kanban-board/commands/kanban-board.md @@ -0,0 +1,80 @@ +--- +description: Parse the last Claude answer for TODO items and create a kanban board in a chosen Hillnote workspace. +--- + +Turn the TODO items from the most recent exchange in this session into a Hillnote kanban board. A Hillnote kanban board is a **database** (a folder of markdown rows with frontmatter columns) with a kanban view grouped by status. Follow these steps exactly. + +1. **Render the last exchange.** Run: + + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/last_response_to_md.py" + ``` + + It reads `$CLAUDE_CODE_SESSION_ID`, finds this session's `.jsonl`, and prints the last user question and Claude's answer as Markdown. If the script exits non-zero, show its error and stop. + +2. **Extract the tasks.** Read the answer (and the question, if the TODOs were pasted there) and pull out every TODO / action item. For each task decide: + - **title** — short imperative phrase (≤ 60 chars), e.g. `Add Scribe v2 URL to elevenlabs.js` + - **description** — one sentence of the essential detail + - **status** — map completion markers: ✅ / "done" / "exists" → `Done`; ⚙️ / "partial" / "not wired" / "in progress" → `In Progress`; ❌ / everything else → `To Do`. Items explicitly parked/deferred also go to `To Do` with Low priority. + - **priority** — gating/blocking work → `High`; parked/optional/nice-to-have → `Low`; otherwise `Medium`. + + Preserve any stated ordering (e.g. "items 1–4 gate everything") in the descriptions. If the exchange contains no TODO-like items, tell the user and stop. + +3. **List Hillnote workspaces and let the user choose — interactively.** Call the Hillnote `list_workspaces` tool. Then present the choice with the `AskUserQuestion` tool (a clickable menu) instead of asking them to type. + + - Header: `Workspace`. Question: which workspace to create the board in. + - Options: the up-to-4 workspaces that best fit the content. Use the workspace **name** as the label and a short hint (`Owned` / `Shared`, plus why it fits) as the description. The automatic "Other" choice covers workspaces not shown. + - Map the chosen name back to its workspace **id** from `list_workspaces`. If ambiguous or not found, ask again. Do not guess the id. + +4. **Pick the board name.** Derive a short topic name from the content, ending in "Board" (e.g. `Scribe v2 Board`). Call `find_databases` for the chosen workspace; if that path is already taken, append the output of `date "+%Y-%m-%d %H%M"`. + +5. **Create one row per task.** For each task, call `add_document` with: + - `workspace`: the chosen workspace id + - `title`: the task title + - `folder`: the board name (this folder becomes the database) + - `content`: YAML frontmatter followed by a body — quote frontmatter values that contain colons: + + ``` + --- + status: To Do + priority: High + description: "One-sentence summary" + --- + + # Task title + + Fuller detail from the answer, if any. + ``` + + The rows **must exist before step 6** — the config is saved onto the folder they create. + +6. **Turn the folder into a kanban database.** Call `save_database_config` with `workspace`, `databasePath` = the board name, and this config (change only `name`): + + ```json + { + "name": "", + "emoji": "🗂️", + "columns": [ + {"id": "title", "name": "Title", "type": "title", "width": 300}, + {"id": "status", "name": "Status", "type": "status", "width": 150, + "options": ["To Do", "In Progress", "Done"], + "optionColors": {"To Do": "red", "In Progress": "amber", "Done": "emerald"}, + "optionStates": {"To Do": "normal", "In Progress": "normal", "Done": "done"}}, + {"id": "description", "name": "Description", "type": "text", "width": 250}, + {"id": "priority", "name": "Priority", "type": "select", "width": 120, + "options": ["High", "Medium", "Low"], + "optionColors": {"High": "red", "Medium": "amber", "Low": "gray"}} + ], + "views": [ + {"id": "default", "name": "All Tasks", "type": "table", "filters": [], "sorts": []}, + {"id": "kanban", "name": "Board", "type": "kanban", "groupBy": "status", "filters": [], "sorts": []} + ], + "defaultView": "kanban" + } + ``` + +7. **Confirm.** Tell the user the board name, the workspace, and the card count per column (e.g. `To Do 7 · In Progress 1 · Done 2`). + +Notes: +- The frontmatter keys (`status`, `priority`, `description`) are the column ids — they must match the config exactly. +- The Hillnote tools come from the bundled `notes` MCP server (`https://hillnote.com/mcp`). If they aren't available or return an auth error, the user needs to authenticate the server — Claude Code prompts on first use, or they can run `/mcp`. Tell them and stop. diff --git a/kanban-board/scripts/last_response_to_md.py b/kanban-board/scripts/last_response_to_md.py new file mode 100644 index 0000000..f6b993e --- /dev/null +++ b/kanban-board/scripts/last_response_to_md.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Render only the LAST user question and Claude's answer from the current +Claude Code session transcript (JSONL) to Markdown on stdout. + +Usage: + last_response_to_md.py [SESSION_ID_OR_PATH] + +If no arg is given, uses $CLAUDE_CODE_SESSION_ID. A session id is resolved by +globbing ~/.claude/projects/*/.jsonl; a path ending in .jsonl is used +directly. + +"Last question" = the last real user prose turn (tool results and the bare +slash-command trigger are ignored). "Answer" = every assistant text block that +follows it. Tool calls and thinking are omitted. + +Self-check: last_response_to_md.py --selfcheck +""" +import glob +import json +import os +import re +import sys + + +def find_transcript(arg): + """Return the JSONL path for a session id, an explicit path, or None.""" + if arg and arg.endswith(".jsonl"): + return arg if os.path.exists(arg) else None + session_id = arg or os.environ.get("CLAUDE_CODE_SESSION_ID") + if not session_id: + return None + matches = glob.glob(os.path.expanduser(f"~/.claude/projects/*/{session_id}.jsonl")) + return matches[0] if matches else None + + +def _user_text(content): + """Return the user's prose for an entry, or None if it carries no prose + (a tool_result or a bare slash-command / skill trigger).""" + if isinstance(content, str): + body = content.strip() + if not body or re.fullmatch(r"/[\w:.-]+", body): + return None # bare /command invocation, e.g. the trigger itself + return body + if isinstance(content, list): + parts = [b.get("text", "").strip() for b in content + if isinstance(b, dict) and b.get("type") == "text"] + joined = "\n".join(p for p in parts if p) + return joined or None + return None + + +def extract_last_qa(transcript_path): + """Return (question, answer) for the final Q&A pair, or (None, None).""" + question = None + answer = [] # assistant text blocks accumulated since the last question + with open(transcript_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + t = entry.get("type") + if t not in ("user", "assistant"): + continue + content = (entry.get("message") or {}).get("content") + + if t == "user": + prose = _user_text(content) + if prose is not None: # a real new question — start a fresh pair + question = prose + answer = [] + elif t == "assistant" and isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "text": + txt = b.get("text", "").strip() + if txt: + answer.append(txt) + return question, ("\n\n".join(answer) or None) + + +def render(transcript_path): + question, answer = extract_last_qa(transcript_path) + if question is None: + return None + out = ["## \U0001F464 Question\n", question] + out.append("\n## \U0001F916 Answer\n") + out.append(answer or "_(no answer captured)_") + return "\n".join(out).strip() + "\n" + + +def _selfcheck(): + import tempfile + + sample = [ + {"type": "user", "message": {"role": "user", "content": "First question."}}, + {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "text", "text": "First answer."}, + ]}}, + {"type": "user", "message": {"role": "user", "content": "Second question."}}, + {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "ponder"}, + {"type": "text", "text": "Part one."}, + {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}}, + ]}}, + {"type": "user", "message": {"role": "user", "content": [ + {"type": "tool_result", "content": [{"type": "text", "text": "file.py"}]}, + ]}}, + {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "text", "text": "Part two."}, + ]}}, + {"type": "user", "message": {"role": "user", "content": "/response-capture:response-capture"}}, + ] + with tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False) as f: + for row in sample: + f.write(json.dumps(row) + "\n") + path = f.name + md = render(path) + os.unlink(path) + + # Only the LAST question survives; the first pair is gone. + assert "Second question." in md, "missing last question" + assert "First question." not in md and "First answer." not in md, "earlier pair leaked" + # A tool_result between answer chunks must not reset the pair. + assert "Part one." in md and "Part two." in md, "tool_result split the answer" + # Tools / thinking omitted; the bare trigger is not treated as the question. + assert "Bash" not in md and "ponder" not in md, "noise leaked" + assert "/response-capture" not in md, "trigger treated as question" + print("selfcheck OK") + + +def main(argv): + if argv[1:2] == ["--selfcheck"]: + _selfcheck() + return 0 + path = find_transcript(argv[1] if len(argv) > 1 else None) + if not path: + sys.stderr.write( + "transcript not found: set $CLAUDE_CODE_SESSION_ID or pass a session id / .jsonl path\n" + ) + return 1 + md = render(path) + if md is None: + sys.stderr.write("no user question found in this session yet\n") + return 1 + sys.stdout.write(md) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))