Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
]
}
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions kanban-board/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"]
}
8 changes: 8 additions & 0 deletions kanban-board/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"notes": {
"type": "http",
"url": "https://hillnote.com/mcp"
}
}
}
80 changes: 80 additions & 0 deletions kanban-board/commands/kanban-board.md
Original file line number Diff line number Diff line change
@@ -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": "<board 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.
153 changes: 153 additions & 0 deletions kanban-board/scripts/last_response_to_md.py
Original file line number Diff line number Diff line change
@@ -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/*/<id>.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))
Loading