diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b64eb705..f025fa237 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -324,9 +324,10 @@ Each row carries its attribution — note path, full parent folder, 1-based file - **Filters** — status; six date fields (due, scheduled, start, created, done, cancelled), each with before/on/after bounds; priority; folder, tag, heading, and path scoping; `top_level_only`, which excludes sub-tasks from board reads. - **Sort keys** — `due`, `scheduled`, `start`, `created`, `done`, `priority`, `note_mtime`, `position`. -Three design choices shape the query surface: +Four design choices shape the query surface: - **Array params for status and heading** — both accept `string | string[]`, OR-combined. This collapses multi-lane Kanban queries (e.g. Active + Up Next + Waiting On) into a single call instead of N sequential reads. +- **Checklist progress on parent entries** — `subtask_progress: { done, total }` aggregates each task's direct children in the same query (a grouped self-join on the parent line), so filtered or `top_level_only` reads still show how far along a card's checklist is. The field appears only on tasks that have a checklist; `done` counts status done only, and the counts ignore the query's filters — progress belongs to the card, not the query. - **Date cascade sorting** — when the primary sort date is absent on a task, actionable date sorts fall back through the remaining fields in urgency order (due → scheduled → start → created), each using its own natural direction. (`done`, a terminal-state date, stands alone.) Tasks with sparse dates sort usably instead of clustering at the end. - **Kanban awareness** — each task carries an `is_kanban_task` flag, derived via `json_extract` on the parent note's `kanban-plugin` frontmatter (no schema changes). When true, `heading` carries the lane name, and `sort_by: "position"` (file path then line number) preserves the board's card arrangement as the sort order. A `done_lanes` field (populated at index time by scanning for the Kanban plugin's `**Complete**` marker between headings and list items) tells agents which lane(s) represent task completion. diff --git a/src/__tests__/integration/fixtures/vault/Projects/board.md b/src/__tests__/integration/fixtures/vault/Projects/board.md index 605831e70..b2bfeb387 100644 --- a/src/__tests__/integration/fixtures/vault/Projects/board.md +++ b/src/__tests__/integration/fixtures/vault/Projects/board.md @@ -9,7 +9,7 @@ created: 2026-01-15T10:00:00-05:00 ## Active - [/] In-progress feature ⏫ ➕ 2026-01-15 ^board-active-1 - - [ ] Stage 1 + - [x] Stage 1 - [ ] Stage 2 ## Up Next diff --git a/src/__tests__/integration/server-integration.test.ts b/src/__tests__/integration/server-integration.test.ts index 34fc72f95..dd443a61a 100644 --- a/src/__tests__/integration/server-integration.test.ts +++ b/src/__tests__/integration/server-integration.test.ts @@ -470,6 +470,42 @@ describe("default config", () => { ]) }) + it("vault_list_tasks — subtask_progress reports checklist progress on every entry", async () => { + const result = await callTool({ + client, + name: "vault_list_tasks", + args: { + path: "Projects/board.md", + status: "all", + sort_by: "position", + top_level_only: true, + }, + }) + expect(result.isError).not.toBe(true) + const json = JSON.parse(textContent(result)) + + // board.md fixture: the in-progress card has one done + one todo + // checklist item; the other cards have no checklist, so the + // serialized entries carry no subtask_progress key at all. + expect( + json.tasks.map( + (task: { block_id: string; subtask_progress: unknown }) => ({ + block_id: task.block_id, + ...("subtask_progress" in task + ? { subtask_progress: task.subtask_progress } + : {}), + }), + ), + ).toEqual([ + { + block_id: "board-active-1", + subtask_progress: { done: 1, total: 2 }, + }, + { block_id: "board-next-1" }, + { block_id: "board-done-1" }, + ]) + }) + it("vault_update_task — description edit preserves metadata", async () => { const result = await callTool({ client, diff --git a/src/vault-mcp/mcp-core/tools/task-tools.ts b/src/vault-mcp/mcp-core/tools/task-tools.ts index 28b6aa7fa..26e1c9e0a 100644 --- a/src/vault-mcp/mcp-core/tools/task-tools.ts +++ b/src/vault-mcp/mcp-core/tools/task-tools.ts @@ -44,7 +44,7 @@ Errors: - path without the ".md" extension is rejected - No matches returns { total: 0, tasks: [] }, not an error -Returns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only).`, +Returns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status "done" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.`, inputSchema: { status: z .union([ diff --git a/src/vault-mcp/search/__tests__/search-helpers.test.ts b/src/vault-mcp/search/__tests__/search-helpers.test.ts index edccd060a..30222cda8 100644 --- a/src/vault-mcp/search/__tests__/search-helpers.test.ts +++ b/src/vault-mcp/search/__tests__/search-helpers.test.ts @@ -169,6 +169,8 @@ const makeTaskRow = (overrides: Partial = {}): TaskRow => ({ depth: 0, parent_line: null, parent_block_id: null, + subtask_done: 0, + subtask_total: 0, is_kanban_task: 0, kanban_done_lanes: null, ...overrides, @@ -251,6 +253,13 @@ describe("rowToTaskEntry", () => { }) }) + it("maps subtask counts to subtask_progress when the task has children", () => { + const entry = rowToTaskEntry( + makeTaskRow({ subtask_done: 2, subtask_total: 4 }), + ) + expect(entry.subtask_progress).toEqual({ done: 2, total: 4 }) + }) + it("maps done_lanes for Kanban tasks", () => { const entry = rowToTaskEntry( makeTaskRow({ diff --git a/src/vault-mcp/search/__tests__/task-queries.test.ts b/src/vault-mcp/search/__tests__/task-queries.test.ts index 6683869dd..25803cac6 100644 --- a/src/vault-mcp/search/__tests__/task-queries.test.ts +++ b/src/vault-mcp/search/__tests__/task-queries.test.ts @@ -1422,3 +1422,110 @@ describe("comment block exclusion", () => { expect(result.total).toBe(2) }) }) + +describe("listTasks subtask_progress", () => { + /** A card with a mixed-status checklist (2 done, 1 todo, 1 cancelled) + * plus a leaf card with no checklist. */ + const CHECKLIST_NOTE = [ + "- [ ] Ship the feature ^ship-feature", + " - [x] Design", + " - [x] Implement", + " - [ ] Test", + " - [-] Abandoned stage", + "- [ ] Leaf card ^leaf-card", + ].join("\n") + + const indexWithChecklist = () => { + const index = createTestIndex() + index.upsertNote( + { + filePath: "tasks.md", + rawContent: CHECKLIST_NOTE, + fileStat: testStat(1000), + }, + logger, + ) + return index + } + + it("omits subtask_progress on a task with no checklist", () => { + const index = indexWithChecklist() + + const result = index.listTasks({ sortBy: "position" }, logger) + const leafCard = result.tasks.find( + (entry) => entry.block_id === "leaf-card", + ) + expect(leafCard?.description).toBe("Leaf card") + expect(leafCard?.subtask_progress).toBeUndefined() + }) + + it("counts done children only — cancelled counts toward total, not done", () => { + const index = indexWithChecklist() + + // The default not_done filter excludes the done and cancelled children + // from the result rows; the parent's counts must include them anyway — + // this fails if the aggregate ever inherits the query's filters. + const result = index.listTasks({ sortBy: "position" }, logger) + expect( + result.tasks.map((entry) => ({ + block_id: entry.block_id, + subtask_progress: entry.subtask_progress, + })), + ).toEqual([ + { block_id: "ship-feature", subtask_progress: { done: 2, total: 4 } }, + {}, // the todo child "Test" — no block_id, no checklist + { block_id: "leaf-card" }, + ]) + }) + + it("counts a grandchild toward its direct parent only", () => { + const index = createTestIndex() + index.upsertNote( + { + filePath: "tasks.md", + rawContent: [ + "- [ ] Parent ^parent", + " - [ ] Child A ^child-a", + " - [x] Grandchild ^grandchild", + " - [x] Child B ^child-b", + ].join("\n"), + fileStat: testStat(1000), + }, + logger, + ) + + const result = index.listTasks( + { status: "all", sortBy: "position" }, + logger, + ) + expect( + result.tasks.map((entry) => ({ + block_id: entry.block_id, + subtask_progress: entry.subtask_progress, + })), + ).toEqual([ + { block_id: "parent", subtask_progress: { done: 1, total: 2 } }, + { block_id: "child-a", subtask_progress: { done: 1, total: 1 } }, + { block_id: "grandchild" }, + { block_id: "child-b" }, + ]) + }) + + it("top_level_only rows still carry checklist progress", () => { + const index = indexWithChecklist() + + const result = index.listTasks( + { topLevelOnly: true, sortBy: "position" }, + logger, + ) + expect( + result.tasks.map((entry) => ({ + block_id: entry.block_id, + subtask_progress: entry.subtask_progress, + })), + ).toEqual([ + { block_id: "ship-feature", subtask_progress: { done: 2, total: 4 } }, + { block_id: "leaf-card" }, + ]) + }) +}) diff --git a/src/vault-mcp/search/search-helpers.ts b/src/vault-mcp/search/search-helpers.ts index 6dc511f1f..01db8fb25 100644 --- a/src/vault-mcp/search/search-helpers.ts +++ b/src/vault-mcp/search/search-helpers.ts @@ -184,6 +184,10 @@ export const rowToTaskEntry = (row: TaskRow): TaskEntry => ({ block_id: row.block_id ?? undefined, depth: row.depth, parent_block_id: row.parent_block_id ?? undefined, + subtask_progress: + row.subtask_total > 0 + ? { done: row.subtask_done, total: row.subtask_total } + : undefined, is_kanban_task: Boolean(row.is_kanban_task), done_lanes: row.kanban_done_lanes ? parseStringArray(row.kanban_done_lanes) diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 48b2cab21..71623f197 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -196,6 +196,8 @@ export type TaskRow = { depth: number parent_line: number | null parent_block_id: string | null + subtask_done: number + subtask_total: number is_kanban_task: number kanban_done_lanes: string | null } @@ -228,10 +230,18 @@ export type TaskEntry = { block_id?: string | undefined depth: number parent_block_id?: string | undefined + subtask_progress?: SubtaskProgress | undefined is_kanban_task: boolean done_lanes?: string[] | undefined } +/** Direct-children checklist progress, present only on tasks that have a + * checklist — an absent field means no checklist items. done counts + * status "done" only; cancelled children count toward total, not done. + * Counts are unaffected by the query's filters — progress is a property + * of the card, not of the query. */ +type SubtaskProgress = { done: number; total: number } + /** Status filter vocabulary for listTasks. "not_done" (the default) covers * todo + in_progress — the Tasks plugin's own `not done` semantics, which * exclude cancelled tasks. */ @@ -576,6 +586,15 @@ export const createSearchIndex = ( db.exec(`ALTER TABLE tasks ADD COLUMN parent_block_id TEXT`) } + // Created after the parent_line migration — the column may not exist when + // the base schema runs. Partial over child rows only, ordered to match the + // subtask_progress aggregate's GROUP BY, so listTasks scans just this small + // index instead of hash-aggregating the whole tasks table on every call. + db.exec( + `CREATE INDEX IF NOT EXISTS idx_tasks_parent_line + ON tasks(note_path, parent_line) WHERE parent_line IS NOT NULL`, + ) + // Same idempotent migration for non_md_files.bytes: a warm database from // before the column existed would fail the upsert. Nullable — NULL means // "not yet statted"; the startup rebuild backfills every row. diff --git a/src/vault-mcp/search/search-queries.ts b/src/vault-mcp/search/search-queries.ts index 92f8e954f..cffda4d27 100644 --- a/src/vault-mcp/search/search-queries.ts +++ b/src/vault-mcp/search/search-queries.ts @@ -1062,17 +1062,33 @@ export const listTasks = ( // Kanban detection: notes with kanban-plugin in frontmatter are Kanban boards, // so their tasks need lane moves (not checkbox toggles) to complete. + // The children join aggregates each task's DIRECT children over the whole + // tasks table — deliberately outside the WHERE, so a card's checklist + // progress is unaffected by the query's filters. The group key + // (note_path, parent_line) is unique per parent, so the join never + // multiplies rows. const sql = ` SELECT t.note_path, t.line, t.status_char, t.status, t.description, t.created, t.scheduled, t.start, t.due, t.done, t.cancelled, t.priority, t.recurrence, t.on_completion, t.task_id, t.depends_on, t.tags, t.block_id, t.heading, t.folder, t.depth, t.parent_line, t.parent_block_id, + COALESCE(children.subtask_done, 0) AS subtask_done, + COALESCE(children.subtask_total, 0) AS subtask_total, CASE WHEN json_extract(n.properties, '$.kanban-plugin') IS NOT NULL THEN 1 ELSE 0 END AS is_kanban_task, n.kanban_done_lanes FROM tasks t JOIN notes n ON n.path = t.note_path + LEFT JOIN ( + SELECT note_path, parent_line, + SUM(status = 'done') AS subtask_done, + COUNT(*) AS subtask_total + FROM tasks + WHERE parent_line IS NOT NULL + GROUP BY note_path, parent_line + ) children ON children.note_path = t.note_path + AND children.parent_line = t.line ${whereClause} ORDER BY ${orderBy}, t.note_path ASC, t.line ASC LIMIT ?