From 471cc6d913c1f8149465ee815f5217fa7f20bf82 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:39:32 -0400 Subject: [PATCH 1/3] feat(tasks): add subtask_progress to every vault_list_tasks entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtered or top_level_only board reads could not see whether a card has a checklist or how far along it is. Every entry now carries subtask_progress: { done, total } over its direct checklist children — { done: 0, total: 0 } means no checklist, done counts status done only, and the counts ignore the query's filters. Co-Authored-By: Claude Fable 5 --- ARCHITECTURE.md | 3 +- .../fixtures/vault/Projects/board.md | 2 +- .../integration/server-integration.test.ts | 33 ++++++ .../__tests__/tool-definitions.test.ts | 3 + src/vault-mcp/mcp-core/tools/task-tools.ts | 2 +- .../search/__tests__/search-helpers.test.ts | 4 + .../search/__tests__/search-index.test.ts | 1 + .../search/__tests__/task-queries.test.ts | 109 ++++++++++++++++++ src/vault-mcp/search/search-helpers.ts | 1 + src/vault-mcp/search/search-index.ts | 10 ++ src/vault-mcp/search/search-queries.ts | 16 +++ 11 files changed, 181 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b64eb705..a3002daa4 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 every entry** — `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. `{ done: 0, total: 0 }` means no 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..f185b4842 100644 --- a/src/__tests__/integration/server-integration.test.ts +++ b/src/__tests__/integration/server-integration.test.ts @@ -470,6 +470,39 @@ 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 none. + expect( + json.tasks.map( + (task: { block_id: string; subtask_progress: unknown }) => ({ + block_id: task.block_id, + subtask_progress: task.subtask_progress, + }), + ), + ).toEqual([ + { + block_id: "board-active-1", + subtask_progress: { done: 1, total: 2 }, + }, + { block_id: "board-next-1", subtask_progress: { done: 0, total: 0 } }, + { block_id: "board-done-1", subtask_progress: { done: 0, total: 0 } }, + ]) + }) + it("vault_update_task — description edit preserves metadata", async () => { const result = await callTool({ client, diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 2888debfd..85dce7d7a 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -1330,6 +1330,7 @@ describe("vault_list_tasks handler", () => { depends_on: [], tags: [], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }) }) @@ -1353,6 +1354,7 @@ describe("vault_list_tasks handler", () => { depends_on: [], tags: [], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }, ]) @@ -1381,6 +1383,7 @@ describe("vault_list_tasks handler", () => { depends_on: ["dep-1", "dep-2"], tags: ["errand"], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }, ]) diff --git a/src/vault-mcp/mcp-core/tools/task-tools.ts b/src/vault-mcp/mcp-core/tools/task-tools.ts index 28b6aa7fa..58e2dc3a5 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, tags (the arrays are [] when empty), and subtask_progress — { done, total } over the task's DIRECT checklist children, where { done: 0, total: 0 } means no checklist, 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. 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).`, 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..e068967a2 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, @@ -247,6 +249,7 @@ describe("rowToTaskEntry", () => { depends_on: ["def456"], tags: ["bug"], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }) }) @@ -273,6 +276,7 @@ describe("rowToTaskEntry", () => { depends_on: ["def456"], tags: ["bug"], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: true, done_lanes: ["Done"], }) diff --git a/src/vault-mcp/search/__tests__/search-index.test.ts b/src/vault-mcp/search/__tests__/search-index.test.ts index 64e739de3..ffae15ece 100644 --- a/src/vault-mcp/search/__tests__/search-index.test.ts +++ b/src/vault-mcp/search/__tests__/search-index.test.ts @@ -179,6 +179,7 @@ const versionATask = (): TaskEntry => ({ depends_on: [], tags: [], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }) diff --git a/src/vault-mcp/search/__tests__/task-queries.test.ts b/src/vault-mcp/search/__tests__/task-queries.test.ts index 6683869dd..d0ed7c753 100644 --- a/src/vault-mcp/search/__tests__/task-queries.test.ts +++ b/src/vault-mcp/search/__tests__/task-queries.test.ts @@ -102,6 +102,7 @@ describe("task indexing lifecycle", () => { tags: [], block_id: "fix-login", depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: true, } expect(fixLoginTask).toEqual(expectedEntry) @@ -131,6 +132,7 @@ describe("task indexing lifecycle", () => { depends_on: ["id-1", "id-2"], tags: ["home", "home/kitchen"], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, } expect(result.tasks).toEqual([expectedEntry]) @@ -173,6 +175,7 @@ describe("task indexing lifecycle", () => { depends_on: [], tags: [], depth: 0, + subtask_progress: { done: 0, total: 0 }, is_kanban_task: true, done_lanes: ["Done"], }) @@ -1422,3 +1425,109 @@ 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("reports { done: 0, total: 0 } 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?.subtask_progress).toEqual({ done: 0, total: 0 }) + }) + + 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 } }, + { subtask_progress: { done: 0, total: 0 } }, // the todo child "Test" + { block_id: "leaf-card", subtask_progress: { done: 0, total: 0 } }, + ]) + }) + + 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", subtask_progress: { done: 0, total: 0 } }, + { block_id: "child-b", subtask_progress: { done: 0, total: 0 } }, + ]) + }) + + 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", subtask_progress: { done: 0, total: 0 } }, + ]) + }) +}) diff --git a/src/vault-mcp/search/search-helpers.ts b/src/vault-mcp/search/search-helpers.ts index 6dc511f1f..3508929f7 100644 --- a/src/vault-mcp/search/search-helpers.ts +++ b/src/vault-mcp/search/search-helpers.ts @@ -184,6 +184,7 @@ 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: { done: row.subtask_done, total: row.subtask_total }, 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..4cb557bb9 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 is_kanban_task: boolean done_lanes?: string[] | undefined } +/** Direct-children checklist progress, present on every task entry — + * { done: 0, total: 0 } means the task has no checklist. 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. */ 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 ? From dc73bd0e4a72a66458c2b265848028d62de0bd5d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:58:40 -0400 Subject: [PATCH 2/3] perf(tasks): index child rows for the subtask_progress aggregate A partial index on tasks(note_path, parent_line) WHERE parent_line IS NOT NULL lets the grouped aggregate scan only child rows in GROUP BY order instead of hash-aggregating the whole tasks table on every listTasks call (EXPLAIN QUERY PLAN: temp B-tree eliminated). Co-Authored-By: Claude Fable 5 --- src/vault-mcp/search/search-index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 4cb557bb9..acc32ef2b 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -586,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. From 6da58dc414b69d14e625dfacb44fe1cf09ac38c5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:50:54 -0400 Subject: [PATCH 3/3] refactor(tasks): omit subtask_progress on tasks without a checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Present only when the task has direct checklist children — absence means no checklist, exactly as unambiguous as { done: 0, total: 0 } while saving the field on the majority of rows (most tasks are leaves), which matters for LLM clients reading large filtered pages. Matches the wire convention that metadata a task doesn't have is omitted. Co-Authored-By: Claude Fable 5 --- ARCHITECTURE.md | 2 +- .../integration/server-integration.test.ts | 11 +++++++---- .../__tests__/tool-definitions.test.ts | 3 --- src/vault-mcp/mcp-core/tools/task-tools.ts | 2 +- .../search/__tests__/search-helpers.test.ts | 9 +++++++-- .../search/__tests__/search-index.test.ts | 1 - .../search/__tests__/task-queries.test.ts | 18 ++++++++---------- src/vault-mcp/search/search-helpers.ts | 5 ++++- src/vault-mcp/search/search-index.ts | 6 +++--- 9 files changed, 31 insertions(+), 26 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3002daa4..f025fa237 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -327,7 +327,7 @@ Each row carries its attribution — note path, full parent folder, 1-based file 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 every entry** — `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. `{ done: 0, total: 0 }` means no checklist; `done` counts status done only, and the counts ignore the query's filters — progress belongs to the card, not the query. +- **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/server-integration.test.ts b/src/__tests__/integration/server-integration.test.ts index f185b4842..dd443a61a 100644 --- a/src/__tests__/integration/server-integration.test.ts +++ b/src/__tests__/integration/server-integration.test.ts @@ -485,12 +485,15 @@ describe("default config", () => { const json = JSON.parse(textContent(result)) // board.md fixture: the in-progress card has one done + one todo - // checklist item; the other cards have none. + // 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: task.subtask_progress, + ...("subtask_progress" in task + ? { subtask_progress: task.subtask_progress } + : {}), }), ), ).toEqual([ @@ -498,8 +501,8 @@ describe("default config", () => { block_id: "board-active-1", subtask_progress: { done: 1, total: 2 }, }, - { block_id: "board-next-1", subtask_progress: { done: 0, total: 0 } }, - { block_id: "board-done-1", subtask_progress: { done: 0, total: 0 } }, + { block_id: "board-next-1" }, + { block_id: "board-done-1" }, ]) }) diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 85dce7d7a..2888debfd 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -1330,7 +1330,6 @@ describe("vault_list_tasks handler", () => { depends_on: [], tags: [], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }) }) @@ -1354,7 +1353,6 @@ describe("vault_list_tasks handler", () => { depends_on: [], tags: [], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }, ]) @@ -1383,7 +1381,6 @@ describe("vault_list_tasks handler", () => { depends_on: ["dep-1", "dep-2"], tags: ["errand"], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }, ]) diff --git a/src/vault-mcp/mcp-core/tools/task-tools.ts b/src/vault-mcp/mcp-core/tools/task-tools.ts index 58e2dc3a5..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, tags (the arrays are [] when empty), and subtask_progress — { done, total } over the task's DIRECT checklist children, where { done: 0, total: 0 } means no checklist, 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. 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 e068967a2..30222cda8 100644 --- a/src/vault-mcp/search/__tests__/search-helpers.test.ts +++ b/src/vault-mcp/search/__tests__/search-helpers.test.ts @@ -249,11 +249,17 @@ describe("rowToTaskEntry", () => { depends_on: ["def456"], tags: ["bug"], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }) }) + 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({ @@ -276,7 +282,6 @@ describe("rowToTaskEntry", () => { depends_on: ["def456"], tags: ["bug"], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: true, done_lanes: ["Done"], }) diff --git a/src/vault-mcp/search/__tests__/search-index.test.ts b/src/vault-mcp/search/__tests__/search-index.test.ts index ffae15ece..64e739de3 100644 --- a/src/vault-mcp/search/__tests__/search-index.test.ts +++ b/src/vault-mcp/search/__tests__/search-index.test.ts @@ -179,7 +179,6 @@ const versionATask = (): TaskEntry => ({ depends_on: [], tags: [], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, }) diff --git a/src/vault-mcp/search/__tests__/task-queries.test.ts b/src/vault-mcp/search/__tests__/task-queries.test.ts index d0ed7c753..25803cac6 100644 --- a/src/vault-mcp/search/__tests__/task-queries.test.ts +++ b/src/vault-mcp/search/__tests__/task-queries.test.ts @@ -102,7 +102,6 @@ describe("task indexing lifecycle", () => { tags: [], block_id: "fix-login", depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: true, } expect(fixLoginTask).toEqual(expectedEntry) @@ -132,7 +131,6 @@ describe("task indexing lifecycle", () => { depends_on: ["id-1", "id-2"], tags: ["home", "home/kitchen"], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: false, } expect(result.tasks).toEqual([expectedEntry]) @@ -175,7 +173,6 @@ describe("task indexing lifecycle", () => { depends_on: [], tags: [], depth: 0, - subtask_progress: { done: 0, total: 0 }, is_kanban_task: true, done_lanes: ["Done"], }) @@ -1451,14 +1448,15 @@ describe("listTasks subtask_progress", () => { return index } - it("reports { done: 0, total: 0 } on a task with no checklist", () => { + 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?.subtask_progress).toEqual({ done: 0, total: 0 }) + expect(leafCard?.description).toBe("Leaf card") + expect(leafCard?.subtask_progress).toBeUndefined() }) it("counts done children only — cancelled counts toward total, not done", () => { @@ -1475,8 +1473,8 @@ describe("listTasks subtask_progress", () => { })), ).toEqual([ { block_id: "ship-feature", subtask_progress: { done: 2, total: 4 } }, - { subtask_progress: { done: 0, total: 0 } }, // the todo child "Test" - { block_id: "leaf-card", subtask_progress: { done: 0, total: 0 } }, + {}, // the todo child "Test" — no block_id, no checklist + { block_id: "leaf-card" }, ]) }) @@ -1508,8 +1506,8 @@ describe("listTasks 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", subtask_progress: { done: 0, total: 0 } }, - { block_id: "child-b", subtask_progress: { done: 0, total: 0 } }, + { block_id: "grandchild" }, + { block_id: "child-b" }, ]) }) @@ -1527,7 +1525,7 @@ describe("listTasks subtask_progress", () => { })), ).toEqual([ { block_id: "ship-feature", subtask_progress: { done: 2, total: 4 } }, - { block_id: "leaf-card", subtask_progress: { done: 0, total: 0 } }, + { block_id: "leaf-card" }, ]) }) }) diff --git a/src/vault-mcp/search/search-helpers.ts b/src/vault-mcp/search/search-helpers.ts index 3508929f7..01db8fb25 100644 --- a/src/vault-mcp/search/search-helpers.ts +++ b/src/vault-mcp/search/search-helpers.ts @@ -184,7 +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: { done: row.subtask_done, total: row.subtask_total }, + 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 acc32ef2b..71623f197 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -230,13 +230,13 @@ export type TaskEntry = { block_id?: string | undefined depth: number parent_block_id?: string | undefined - subtask_progress: SubtaskProgress + subtask_progress?: SubtaskProgress | undefined is_kanban_task: boolean done_lanes?: string[] | undefined } -/** Direct-children checklist progress, present on every task entry — - * { done: 0, total: 0 } means the task has no checklist. done counts +/** 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. */