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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/integration/server-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/vault-mcp/mcp-core/tools/task-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
9 changes: 9 additions & 0 deletions src/vault-mcp/search/__tests__/search-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ const makeTaskRow = (overrides: Partial<TaskRow> = {}): 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,
Expand Down Expand Up @@ -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({
Expand Down
107 changes: 107 additions & 0 deletions src/vault-mcp/search/__tests__/task-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
])
})
})
4 changes: 4 additions & 0 deletions src/vault-mcp/search/search-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions src/vault-mcp/search/search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/vault-mcp/search/search-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
aliasunder marked this conversation as resolved.
${whereClause}
ORDER BY ${orderBy}, t.note_path ASC, t.line ASC
LIMIT ?
Expand Down
Loading