From 7800a8df934a82ace0f79bbe773eade48777e865 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:06:59 -0400 Subject: [PATCH 01/10] fix(config): read the daily-notes and Tasks-format settings on every call Both readers kept their first successful read of the .obsidian file for the life of the process, so a renamed daily notes folder lost its delete and move protection and a Tasks format switch kept writing the old signifiers until a restart. - daily-notes.ts and task-format-config.ts read their file on every call and take the caller's logger; a missing file (or .obsidian being a file) takes the defaults, an unreadable one warns. - The protected-path guard fails closed: a daily-notes.json that exists but cannot be read refuses the delete or move until it is repaired or DAILY_NOTES_FOLDER / PROTECTED_PATHS is set. DAILY_NOTES_FOLDER alone now skips the file. - Task writes classify with the index's boot-time status registry (createSearchIndex exposes it; createTask/updateTask require it), so listings and writes always agree; format and date toggles go live. - The daily-review prompt reads the settings once per run. - trash-config.ts treats ENOTDIR as a missing file too. Co-Authored-By: Claude Fable 5.1 --- ARCHITECTURE.md | 4 +- .../server-error-contracts.test.ts | 112 +++ .../integration/server-integration.test.ts | 164 +++++ src/utils/fs.ts | 2 +- .../__snapshots__/tool-surface/default.json | 8 +- .../tool-surface/disabled-tools.json | 8 +- .../tool-surface/embedding-off.json | 8 +- .../file-tools-off+embedding-off.json | 8 +- .../tool-surface/file-tools-off.json | 8 +- .../memory-off+embedding-off.json | 8 +- ...mory-off+file-tools-off+embedding-off.json | 8 +- .../memory-off+file-tools-off.json | 8 +- .../tool-surface/memory-off.json | 8 +- .../__tests__/daily-review-prompt.test.ts | 29 + .../mcp-core/prompts/daily-review-prompt.ts | 19 +- .../prompts/vault-orientation-prompt.ts | 11 +- .../tools/__tests__/vault-crud-tools.test.ts | 69 +- src/vault-mcp/mcp-core/tools/search-tools.ts | 11 +- src/vault-mcp/mcp-core/tools/task-tools.ts | 6 +- .../mcp-core/tools/vault-crud-tools.ts | 24 +- src/vault-mcp/obsidian-markdown/tasks.ts | 11 + .../search/__tests__/search-index.test.ts | 25 + src/vault-mcp/search/search-index.ts | 11 +- src/vault-mcp/server.ts | 2 +- .../__tests__/daily-notes.test.ts | 554 ++++++++------ .../__tests__/task-format-config.test.ts | 136 ++-- .../__tests__/task-mutations.test.ts | 679 +++++++++++++----- .../__tests__/trash-config.test.ts | 13 + src/vault-mcp/vault-operations/daily-notes.ts | 114 ++- .../vault-operations/task-format-config.ts | 65 +- .../vault-operations/task-mutations.ts | 15 +- .../vault-operations/trash-config.ts | 5 +- 32 files changed, 1532 insertions(+), 621 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f09b095ab..566365027 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,7 +167,7 @@ The edit tools differ in how they locate the lines they change — by heading, b The three anchor tools share one resolution rule: a short, case-sensitive substring locates a full line, ambiguity is an error, and `first_match` takes the first match instead. -`vault_delete_note` refuses paths under protected folders as a server-side guardrail. The default protected set is the memory dir plus the daily notes folder, read at operation time from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`). `PROTECTED_PATHS` overrides the default entirely. Use `vault_delete_memory` for individual entries in memory files. `vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`. +`vault_delete_note` refuses paths under protected folders as a server-side guardrail. The default protected set is the memory dir plus the daily notes folder, read on every delete and move from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`). A `daily-notes.json` that exists but cannot be read or parsed refuses the delete or move, since the folder it protects is unknown; `DAILY_NOTES_FOLDER` or `PROTECTED_PATHS` bypasses the file. `PROTECTED_PATHS` overrides the default entirely. Use `vault_delete_memory` for individual entries in memory files. `vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`. `vault_move_note` moves or renames a note and rewrites every link across the vault that resolves to it, mirroring Obsidian's built-in rename: @@ -209,7 +209,7 @@ Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to **Promoted properties:** Five frontmatter keys — `title`, `tags`, `type`, `created`, `related` — get dedicated columns in the `notes` table for direct `WHERE`-clause filtering (no `json_extract` needed). In tool responses, these appear as top-level fields; remaining frontmatter keys are returned under `additional_properties` (via `formatNoteMetadata` in `tool-helpers.ts`). All other properties live in a JSON `properties` column — functional for any schema, but without dedicated columns. The property queries match a key as data through `json_each` over that column, never as a JSON path, so a property named `a.b` or `k[0]` is matched like any other. Array values are unpacked via `json_each`, so scalar and list properties both match. -**Daily notes:** `vault_get_daily_note` resolves the vault's folder and date format, each independently: `DAILY_NOTES_FOLDER`/`DAILY_NOTES_FORMAT` env setting → `.obsidian/daily-notes.json` → fallback (`Daily Notes/YYYY-MM-DD.md`). Only a successful config-file read is cached — a missing or malformed file is re-read on the next call, so a config file that arrives after boot is picked up without a restart. `task-format-config.ts` uses the same cache rule. +**Daily notes:** `vault_get_daily_note` resolves the vault's folder and date format, each independently: `DAILY_NOTES_FOLDER`/`DAILY_NOTES_FORMAT` env setting → `.obsidian/daily-notes.json` → fallback (`Daily Notes/YYYY-MM-DD.md`). The file is read on every call, so a folder or format change in Obsidian applies to the next call and a config file that arrives after boot needs no restart; a missing file takes the fallbacks, and a file that cannot be parsed takes them with a `warn`. `task-format-config.ts` reads the Tasks plugin's format and date toggles the same way on every task write; only its status registry is read once at boot, so listings and writes classify with one map. ### Memory diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index a2f20dc45..911aab822 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -1479,3 +1479,115 @@ describe("cannot read trash config", () => { }) }, 30_000) }) + +describe("cannot read daily notes config", () => { + it("an unreadable daily-notes.json refuses delete and move until it is repaired", async () => { + const server = await startServer(await freePort()) + onTestFinished(() => server.cleanup()) + const dailyNotesConfigPath = join(server.vaultPath, ".obsidian", "daily-notes.json") + await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Journal" }), "utf8") + const ownClient = await createTestClient(server.port) + onTestFinished(() => ownClient.close()) + for (const path of ["Journal/entry.md", "Scratch/keep.md"]) { + await callTool({ client: ownClient, name: "vault_write_note", args: { path, body: "kept" } }) + } + + // A refused delete while the file is valid: the folder has been read once. + const refusedWhileValid = await callTool({ + client: ownClient, + name: "vault_delete_note", + args: { path: "Journal/entry.md" }, + }) + expect(textContent(refusedWhileValid)).toBe( + '[Error]: cannot delete protected path "Journal/entry.md"', + ) + + await writeFile(dailyNotesConfigPath, "", "utf8") + + const deleteWhileUnreadable = await callTool({ + client: ownClient, + name: "vault_delete_note", + args: { path: "Scratch/keep.md" }, + }) + const moveWhileUnreadable = await callTool({ + client: ownClient, + name: "vault_move_note", + args: { old_path: "Scratch/keep.md", new_path: "Scratch/moved.md" }, + }) + + // Asserting the whole result keeps the cause out of every part of it. + expect(deleteWhileUnreadable).toEqual({ + content: [ + { + type: "text", + text: "[Error]: cannot read daily notes config from .obsidian/daily-notes.json", + }, + ], + isError: true, + }) + expect(moveWhileUnreadable).toEqual({ + content: [ + { + type: "text", + text: "[Error]: cannot read daily notes config from .obsidian/daily-notes.json", + }, + ], + isError: true, + }) + await expect(readFile(join(server.vaultPath, "Scratch", "keep.md"), "utf8")).resolves.toBe( + "kept\n", + ) + await expect(readFile(join(server.vaultPath, "Journal", "entry.md"), "utf8")).resolves.toBe( + "kept\n", + ) + await expect(readFile(join(server.vaultPath, "Scratch", "moved.md"), "utf8")).rejects.toThrow( + "ENOENT", + ) + + await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Journal" }), "utf8") + + const deleteAfterRepair = await callTool({ + client: ownClient, + name: "vault_delete_note", + args: { path: "Scratch/keep.md" }, + }) + + expect(deleteAfterRepair.isError).not.toBe(true) + expect(textContent(deleteAfterRepair)).toBe( + "Moved Scratch/keep.md to trash (.trash/Scratch/keep.md)", + ) + + // The warning carries the delete's request context, as the trash reader's does. + const { deleteCallLog, readFailureLog } = await vi.waitFor(() => { + const logEntries = parseLogEntries(server.stdout()) + const loggedReadFailure = logEntries.find( + (logEntry) => logEntry.message === "cannot read daily notes config", + ) + + if (!loggedReadFailure) { + throw new Error("the read failure is not logged yet") + } + + return { + deleteCallLog: logEntries.find( + (logEntry) => + logEntry.message === "tool_call" && + logEntry.tool === "vault_delete_note" && + logEntry.path === "Scratch/keep.md", + ), + readFailureLog: loggedReadFailure, + } + }) + + expect(typeof deleteCallLog?.requestId).toBe("number") + expect({ + error: readFailureLog.error, + tool: readFailureLog.tool, + requestId: readFailureLog.requestId, + }).toEqual({ + error: `[SyntaxError]: ${jsonParseFailureMessage("")}`, + tool: "vault_delete_note", + requestId: deleteCallLog?.requestId, + }) + }, 30_000) +}) diff --git a/src/__tests__/integration/server-integration.test.ts b/src/__tests__/integration/server-integration.test.ts index 010cce5df..50c199c6b 100644 --- a/src/__tests__/integration/server-integration.test.ts +++ b/src/__tests__/integration/server-integration.test.ts @@ -28,6 +28,7 @@ import { callTool, textContent, } from "./test-harness.js" +import type { ToolResult } from "./test-harness.js" vi.setConfig({ testTimeout: 15_000 }) @@ -1949,6 +1950,169 @@ describe("trash retention over real HTTP", () => { * structured stdout stream. */ const SWEEP_LOG_MARKER = '"message":"trash retention sweep complete"' +// ── Settings read on every call ─────────────────────────────── +// +// Each scenario boots its own server and rewrites a .obsidian settings file +// while it runs. The fixture vault has no daily-notes.json, and its Tasks +// plugin config sets only the status registry. + +describe("daily notes folder read on every call", () => { + it("delete, move and the daily-note tool follow a daily-notes.json folder change", async () => { + const server = await startServer(await freePort()) + onTestFinished(() => server.cleanup()) + const client = await createTestClient(server.port) + onTestFinished(() => client.close()) + const dailyNotesConfigPath = join(server.vaultPath, ".obsidian", "daily-notes.json") + await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Journal" }), "utf8") + for (const path of ["Journal/entry.md", "Diary/entry.md"]) { + await callTool({ client, name: "vault_write_note", args: { path, body: "kept" } }) + } + + const refusedWhileJournal = await callTool({ + client, + name: "vault_delete_note", + args: { path: "Journal/entry.md" }, + }) + const dailyNoteWhileJournal = await callTool({ + client, + name: "vault_get_daily_note", + args: { date: "2026-01-15" }, + }) + + expect(refusedWhileJournal.isError).toBe(true) + expect(textContent(refusedWhileJournal)).toBe( + '[Error]: cannot delete protected path "Journal/entry.md"', + ) + expect(JSON.parse(textContent(dailyNoteWhileJournal))).toEqual({ + path: "Journal/2026-01-15.md", + content: null, + exists: false, + }) + + await writeFile(dailyNotesConfigPath, JSON.stringify({ folder: "Diary" }), "utf8") + + const deletedAfterChange = await callTool({ + client, + name: "vault_delete_note", + args: { path: "Journal/entry.md" }, + }) + const refusedAfterChange = await callTool({ + client, + name: "vault_delete_note", + args: { path: "Diary/entry.md" }, + }) + const moveRefusedAfterChange = await callTool({ + client, + name: "vault_move_note", + args: { old_path: "Diary/entry.md", new_path: "Scratch/entry.md" }, + }) + const dailyNoteAfterChange = await callTool({ + client, + name: "vault_get_daily_note", + args: { date: "2026-01-15" }, + }) + + expect(deletedAfterChange.isError).not.toBe(true) + expect(textContent(deletedAfterChange)).toBe( + "Moved Journal/entry.md to trash (.trash/Journal/entry.md)", + ) + expect(refusedAfterChange.isError).toBe(true) + expect(textContent(refusedAfterChange)).toBe( + '[Error]: cannot delete protected path "Diary/entry.md"', + ) + expect(moveRefusedAfterChange.isError).toBe(true) + expect(textContent(moveRefusedAfterChange)).toBe( + '[Error]: cannot move protected path "Diary/entry.md"', + ) + expect(JSON.parse(textContent(dailyNoteAfterChange))).toEqual({ + path: "Diary/2026-01-15.md", + content: null, + exists: false, + }) + }, 30_000) +}) + +describe("Tasks plugin settings read on every task write", () => { + it("a format change reaches the next write while the boot-time status registry stays", async () => { + const server = await startServer(await freePort()) + onTestFinished(() => server.cleanup()) + const client = await createTestClient(server.port) + onTestFinished(() => client.close()) + const pluginConfigPath = join( + server.vaultPath, + ".obsidian", + "plugins", + "obsidian-tasks-plugin", + "data.json", + ) + const createTask = (blockId: string): Promise => { + return callTool({ + client, + name: "vault_create_task", + args: { + path: "Projects/alpha.md", + description: `Format probe ${blockId}`, + block_id: blockId, + heading: "Tasks", + due: "2026-08-01", + }, + }) + } + // The section also holds the fixture's own tasks, so each written line + // is checked as a fragment. + const readTasksSection = async (): Promise => { + const readback = await callTool({ + client, + name: "vault_read_note", + args: { path: "Projects/alpha.md", heading: "Tasks" }, + }) + return textContent(readback) + } + + const emojiCreate = await createTask("format-emoji") + + expect(emojiCreate.isError).not.toBe(true) + expect(await readTasksSection()).toContain( + `- [ ] Format probe format-emoji ➕ ${DateTime.now().toISODate()} 📅 2026-08-01 ^format-emoji`, + ) + + // The status registry the fixture ships is rewritten so ">" is a plain + // TODO, alongside the format switch. + await writeFile( + pluginConfigPath, + JSON.stringify({ + taskFormat: "dataview", + statusSettings: { + coreStatuses: [ + { symbol: " ", name: "Todo", nextStatusSymbol: "x", type: "TODO" }, + { symbol: "x", name: "Done", nextStatusSymbol: " ", type: "DONE" }, + ], + customStatuses: [{ symbol: ">", name: "Forwarded", nextStatusSymbol: " ", type: "TODO" }], + }, + }), + "utf8", + ) + + const dataviewCreate = await createTask("format-dataview") + const nonTaskUpdate = await callTool({ + client, + name: "vault_update_task", + args: { path: "Projects/status-registry.md", block_id: "forwarded-ref", status: "done" }, + }) + + expect(dataviewCreate.isError).not.toBe(true) + expect(await readTasksSection()).toContain( + `- [ ] Format probe format-dataview [created:: ${DateTime.now().toISODate()}] [due:: 2026-08-01] ^format-dataview`, + ) + // Writes classify with the registry the index was built with, so the + // retyped file changes nothing until a restart. + expect(nonTaskUpdate.isError).toBe(true) + expect(textContent(nonTaskUpdate)).toBe( + '[Error]: checkbox "[>]" is a NON_TASK status in the Tasks plugin registry', + ) + }, 30_000) +}) + describe("trash retention sweep gating", () => { it("the startup sweep runs under the default config", async () => { const server = await startServer(await freePort()) diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 11673505f..7481f5549 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -6,7 +6,7 @@ import { isErrnoException } from "./is-errno-exception.js" * missing (ENOENT), or a component on the way to it is a file rather than a * folder (ENOTDIR). Both read as "not there" to a caller that asked by path; * the raw message names the absolute path, so it never leaves as an error. */ -const isMissingPathError = (error: unknown): boolean => { +export const isMissingPathError = (error: unknown): boolean => { return isErrnoException(error, "ENOENT") || isErrnoException(error, "ENOTDIR") } diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json index dbe40d80e..8d65a6ee6 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json @@ -6,7 +6,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -194,7 +194,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -946,7 +946,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1638,7 +1638,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json index 66b1dac6d..0c710b409 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -196,7 +196,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -948,7 +948,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1580,7 +1580,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json index 4ca5a4117..cf5608408 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -196,7 +196,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -948,7 +948,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1640,7 +1640,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json index 17e72662b..ccffc3de5 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -197,7 +197,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -909,7 +909,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1557,7 +1557,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json index ccfa77f42..347ca839e 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -196,7 +196,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -908,7 +908,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1556,7 +1556,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json index 9f963af29..9ca61e2ab 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -152,7 +152,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -810,7 +810,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1442,7 +1442,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json index b175a7529..9a78875d5 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json @@ -10,7 +10,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -153,7 +153,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -771,7 +771,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1359,7 +1359,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json index 211f2f760..4383eeda8 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -152,7 +152,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -770,7 +770,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1358,7 +1358,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json index 7c08b84c0..3062f2a43 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot)\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -151,7 +151,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, honoring the vault's Obsidian \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) when the vault is served locally. \"Move to system trash\" — Obsidian's default, and what an absent setting means — moves the note to `.trash/` inside the vault (a container has no system trash, and `.trash/` is Obsidian's own fallback for that), where the server cleans up its copies after a retention window (TRASH_RETENTION_DAYS, default 30 days; `none` keeps them forever). \"Move to Obsidian trash (.trash folder)\" (`local`) also moves the note to `.trash/`, kept forever — matching Obsidian. \"Permanently delete\" (`none`) removes the note for good. Only notes this server moved to `.trash/` under the system setting are subject to the retention window — notes Obsidian itself trashed are never touched. When Obsidian Sync is configured, the trash setting is bypassed and notes are always permanently deleted — recovery is through Sync's version history, not .trash/. After deletion, links to it from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\n\nBehavior: With prune_empty_folders, pruning is best-effort and runs after the delete or trash move — it never fails the call, so the note is always gone from its original path even if a folder can't be removed. If your vault uses Obsidian Sync, deleted notes are recoverable from Sync's version history (1 month on Standard, 12 months on Plus).\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"absolute path blocked\" — the path starts at the filesystem root; use a vault-relative path\n- \"path traversal blocked\" — path escapes the vault root; use a vault-relative path\n- \"hidden path blocked\" — the path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; hidden paths are not deletable, matching Obsidian\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — the note's name already exists 100 times in .trash/; clear old trash files to free the name\n- \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path\n- \"cannot delete …\" — permanent delete failed (e.g. permissions); the note remains at its original path\n- \"cannot read trash config from .obsidian/app.json\" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set\n\nReturns: Confirmation message naming the outcome — \"Deleted\" for permanent removal, \"Moved to trash\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -809,7 +809,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set.\n- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" — old_path or new_path starts at the filesystem root; use vault-relative paths.\n- \"path traversal blocked\" — a path escapes the vault root; use vault-relative paths.\n- \"hidden path blocked\" — old_path or new_path targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; notes cannot be moved from or into hidden paths, matching Obsidian.\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — no failure loses data, and the error message names what failed and the resulting state. An ordinary move deletes the original only after the destination and all backlinks are written: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). A case-only rename commits by renaming in place instead: if the rename itself failed, nothing was written; if a later write failed, the note already lives at new_path — fix the remaining links in place (the error names them) rather than re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -1441,7 +1441,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot)\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- A recurring task completed with a rule that yields no next occurrence (unreadable rule text already on the line, or a finite rule with no dates left) still completes — the result carries an advisory instead of an error\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when the line round-trips clean and no recurrence notice applies) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above) or per recurrence event that did not produce a next occurrence; on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts b/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts index 9d4901044..7c42de04e 100644 --- a/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts +++ b/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { DateTime } from "luxon" import { registerPrompts } from "../../prompt-definitions.js" +import { readDailyNotesConfig } from "../../../vault-operations/daily-notes.js" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { type RegisterPromptCall, @@ -21,6 +22,9 @@ import { logger, } from "./prompt-test-harness.js" +// Real implementation, wrapped so a test can count the reads of daily-notes.json. +vi.mock("../../../vault-operations/daily-notes.js", { spy: true }) + afterEach(() => { vi.restoreAllMocks() }) @@ -28,6 +32,31 @@ afterEach(() => { // ── daily-review handler ───────────────────────────────────────── describe("daily-review handler", () => { + it("reads the daily-notes settings once per run, for the note path and the link flags alike", async () => { + const { vault, calls } = await setupDailyReviewVault({ + date: "2026-06-16", + dailyContent: "# 2026-06-16\n\n[[Daily Notes/2026-06-17]]\n", + }) + vi.mocked(readDailyNotesConfig).mockClear() + const handler = findCall(calls, PROMPT_NAMES.DAILY_REVIEW)[2] + + const text = textOf(await handler({ date: "2026-06-16" }, fakeExtra)) + + // A second read would answer the link classification from a settings + // version the note path never saw. The handler builds its request + // logger per call, so only the params are pinned. + expect(vi.mocked(readDailyNotesConfig)).toHaveBeenCalledTimes(1) + expect(vi.mocked(readDailyNotesConfig)).toHaveBeenCalledWith( + { vaultPath: vault, envSettings: { folder: undefined, format: undefined } }, + expect.anything(), + ) + expect(text).toContain('source="Daily Notes/2026-06-16.md"') + // The link into the resolved folder is a forward reference, not a broken + // link, so no broken-link summary follows the listing. + expect(text).toContain("- Daily Notes/2026-06-17 (**broken** — target does not exist)") + expect(text).not.toContain("broken link —") + }) + // Exact assembled output (note + recent notes) is asserted in "full prompt // output"; the cases below cover the missing-note and default-date branches. it("offers to create a missing note and shows date-specific activity", async () => { diff --git a/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts b/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts index 59059c80c..132516a72 100644 --- a/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts @@ -168,25 +168,24 @@ export const registerDailyReviewPrompt = ({ ) } - const dailyNote = await getDailyNote( + // Resolved once so the note path and the link classification below + // see the same settings; passing both fields makes getDailyNote skip + // its own read of daily-notes.json. + const dailyNotesConfig = await readDailyNotesConfig( { vaultPath, - date: dateArg, - envSettings: { - folder: config.dailyNotesFolder, - format: config.dailyNotesFormat, - }, + envSettings: { folder: config.dailyNotesFolder, format: config.dailyNotesFormat }, }, reqLogger, ) + const dailyNote = await getDailyNote( + { vaultPath, date: dateArg, envSettings: dailyNotesConfig }, + reqLogger, + ) const modifiedOnDate = search.modifiedOnDate( { date: dateArg, limit: DAILY_RECENT_LIMIT }, reqLogger, ) - const dailyNotesConfig = await readDailyNotesConfig(vaultPath, { - folder: config.dailyNotesFolder, - format: config.dailyNotesFormat, - }) const outgoingLinks = dailyNote.exists ? search.getOutgoingLinks( { diff --git a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts index afb6af89c..4b1986094 100644 --- a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts @@ -170,10 +170,13 @@ export const registerVaultOrientationPrompt = ({ ) const hasMoreOrphans = orphanResults.length > ORIENTATION_ORPHAN_LIMIT const orphans = orphanResults.slice(0, ORIENTATION_ORPHAN_LIMIT) - const dailyNotesConfig = await readDailyNotesConfig(vaultPath, { - folder: config.dailyNotesFolder, - format: config.dailyNotesFormat, - }) + const dailyNotesConfig = await readDailyNotesConfig( + { + vaultPath, + envSettings: { folder: config.dailyNotesFolder, format: config.dailyNotesFormat }, + }, + reqLogger, + ) const brokenLinkResult = search.brokenLinkCount( { dailyNotesFolder: dailyNotesConfig.folder }, reqLogger, diff --git a/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts b/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts index c02675145..2d3949df6 100644 --- a/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts +++ b/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import type { VaultConfig } from "../../../config.js" +import { logger } from "../../../../logger.js" vi.mock("../../../vault-operations/daily-notes.js", () => ({ - readDailyNotesConfig: vi.fn(), + readDailyNotesFileConfig: vi.fn(), })) import { resolveEffectiveProtectedPaths } from "../vault-crud-tools.js" -import { readDailyNotesConfig } from "../../../vault-operations/daily-notes.js" +import { readDailyNotesFileConfig } from "../../../vault-operations/daily-notes.js" -const mockedReadDailyNotesConfig = vi.mocked(readDailyNotesConfig) +const mockedReadDailyNotesFileConfig = vi.mocked(readDailyNotesFileConfig) const makeConfig = ( overrides: Partial< @@ -27,69 +28,81 @@ const makeConfig = ( describe("resolveEffectiveProtectedPaths", () => { beforeEach(() => { - mockedReadDailyNotesConfig.mockClear() + mockedReadDailyNotesFileConfig.mockReset() }) it("returns the user's list unchanged when PROTECTED_PATHS is set", async () => { const config = makeConfig({ protectedPathsOverride: ["Secrets", "Custom"] }) - const result = await resolveEffectiveProtectedPaths(config, "/vault") + const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) expect(result).toEqual(["Secrets", "Custom"]) - expect(mockedReadDailyNotesConfig).not.toHaveBeenCalled() + expect(mockedReadDailyNotesFileConfig).not.toHaveBeenCalled() }) it("protects the memory dir plus the file-configured daily folder by default", async () => { - mockedReadDailyNotesConfig.mockResolvedValue({ + mockedReadDailyNotesFileConfig.mockResolvedValue({ folder: "Journal", format: "YYYY-MM-DD", }) + const requestLogger = logger.child({ requestId: "request-1" }) const config = makeConfig() - const result = await resolveEffectiveProtectedPaths(config, "/vault") + const result = await resolveEffectiveProtectedPaths(config, "/vault", requestLogger) expect(result).toEqual(["About Me", "Journal"]) - expect(mockedReadDailyNotesConfig).toHaveBeenCalledWith("/vault", { - folder: undefined, - format: undefined, - }) + expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledTimes(1) + expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledWith("/vault", requestLogger) }) it("uses the configured memory dir in the default set", async () => { - mockedReadDailyNotesConfig.mockResolvedValue({ + mockedReadDailyNotesFileConfig.mockResolvedValue({ folder: "Daily Notes", format: "YYYY-MM-DD", }) const config = makeConfig({ memoryDir: "Profile" }) - const result = await resolveEffectiveProtectedPaths(config, "/vault") + const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) expect(result).toEqual(["Profile", "Daily Notes"]) }) it("protects only the memory dir when the resolved daily folder is blank", async () => { - mockedReadDailyNotesConfig.mockResolvedValue({ + mockedReadDailyNotesFileConfig.mockResolvedValue({ folder: " ", format: "YYYY-MM-DD", }) const config = makeConfig() - const result = await resolveEffectiveProtectedPaths(config, "/vault") + const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) expect(result).toEqual(["About Me"]) }) - it("passes env settings through to readDailyNotesConfig", async () => { - mockedReadDailyNotesConfig.mockResolvedValue({ - folder: "Journal", - format: "DD-MM-YYYY", - }) - const config = makeConfig({ - dailyNotesFolder: "Journal", - dailyNotesFormat: "DD-MM-YYYY", - }) - const result = await resolveEffectiveProtectedPaths(config, "/vault") + it("protects DAILY_NOTES_FOLDER without reading the file, even when the format is unset", async () => { + const config = makeConfig({ dailyNotesFolder: "Journal" }) + const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) expect(result).toEqual(["About Me", "Journal"]) - expect(mockedReadDailyNotesConfig).toHaveBeenCalledWith("/vault", { + expect(mockedReadDailyNotesFileConfig).not.toHaveBeenCalled() + }) + + it("reads the file for the folder when only DAILY_NOTES_FORMAT is set", async () => { + mockedReadDailyNotesFileConfig.mockResolvedValue({ folder: "Journal", - format: "DD-MM-YYYY", + format: "YYYY-MM-DD", }) + const config = makeConfig({ dailyNotesFormat: "DD-MM-YYYY" }) + const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) + + expect(result).toEqual(["About Me", "Journal"]) + expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledTimes(1) + }) + + it("rejects when the file exists but cannot be read, instead of protecting the default folder", async () => { + mockedReadDailyNotesFileConfig.mockRejectedValue( + new Error("cannot read daily notes config from .obsidian/daily-notes.json"), + ) + const config = makeConfig() + + await expect(resolveEffectiveProtectedPaths(config, "/vault", logger)).rejects.toThrow( + new Error("cannot read daily notes config from .obsidian/daily-notes.json"), + ) }) }) diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index b603325bf..d42260c61 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -587,10 +587,13 @@ Errors: Rejects paths that don't end in .md or .canvas. A path not in the index return safeHandler( reqLogger, async () => { - const dailyNotesConfig = await readDailyNotesConfig(vaultPath, { - folder: config.dailyNotesFolder, - format: config.dailyNotesFormat, - }) + const dailyNotesConfig = await readDailyNotesConfig( + { + vaultPath, + envSettings: { folder: config.dailyNotesFolder, format: config.dailyNotesFormat }, + }, + reqLogger, + ) return search.getOutgoingLinks( { path, dailyNotesFolder: dailyNotesConfig.folder }, reqLogger, diff --git a/src/vault-mcp/mcp-core/tools/task-tools.ts b/src/vault-mcp/mcp-core/tools/task-tools.ts index 527d069ca..003b0ca17 100644 --- a/src/vault-mcp/mcp-core/tools/task-tools.ts +++ b/src/vault-mcp/mcp-core/tools/task-tools.ts @@ -248,7 +248,7 @@ Errors: - "cannot place at position N under "X" — the heading appears N times" — integer position on a note with duplicate heading names; rename one section to make it unique - "parent task not found" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment - "checkbox '[c]' is a NON_TASK status" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system -- "no checkbox symbol for status ..." — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol +- "no checkbox symbol for status ..." — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server (the status registry is read once at boot) - "parentBlockId and parentLine are mutually exclusive" — both parent_block_id and parent_line were passed; drop one - "parent and heading are mutually exclusive" — a parent (parent_block_id or parent_line) and heading were both passed; drop one - "blockId ... already exists in this note" — pick a block_id not yet used in the note @@ -419,6 +419,7 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, changes, a { vaultPath, path, + statusRegistry: search.statusRegistry, description, blockId: block_id, heading, @@ -505,7 +506,7 @@ Errors: - "no task at line N" — line doesn't contain a task checkbox - "line N is inside a fenced code block or comment" — the line is inside a fenced code block or %% %% comment; target a line outside the fence - "checkbox '[c]' is a NON_TASK status" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated -- "no checkbox symbol for status ..." — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status +- "no checkbox symbol for status ..." — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server (the status registry is read once at boot) - "at least one mutation" — no change params provided - "cannot move a sub-task to a heading" — explicit heading on a task nested under another task (depth > 0${whenToolEnabledText("vault_list_tasks", " in vault_list_tasks")}) - "cannot reposition a sub-task" — explicit position on a sub-task (sub-tasks move with their parent) @@ -708,6 +709,7 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, next_occur { vaultPath, path, + statusRegistry: search.statusRegistry, blockId: block_id, line, status, diff --git a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts index 33c8614c7..6c966a191 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -2,9 +2,10 @@ import { z } from "zod" import type { VaultConfig } from "../../config.js" +import type { Logger } from "../../../logger.js" import { vaultFs, resolveVaultRelativePath } from "../../vault-operations/vault-filesystem.js" import { noteMover } from "../../vault-operations/note-mover.js" -import { readDailyNotesConfig } from "../../vault-operations/daily-notes.js" +import { readDailyNotesFileConfig } from "../../vault-operations/daily-notes.js" import { readTrashConfig } from "../../vault-operations/trash-config.js" import { vaultPatcher } from "../../vault-operations/vault-patcher.js" import type { DisplacedLeadingContent } from "../../vault-operations/vault-patcher.js" @@ -35,18 +36,21 @@ const describeDisplacedLeadingContent = ({ * - Otherwise the memory dir (protected even when MEMORY_ENABLED is false) * plus the daily notes folder, resolved on each call (DAILY_NOTES_FOLDER → * .obsidian/daily-notes.json → "Daily Notes") so a folder configured only - * in the vault is protected too. */ + * in the vault is protected too. + * Throws when daily-notes.json exists but cannot be read, since the folder + * it names is then unknown; DAILY_NOTES_FOLDER or PROTECTED_PATHS bypasses + * the file. */ export const resolveEffectiveProtectedPaths = async ( config: VaultConfig, vaultPath: string, + logger: Logger, ): Promise => { if (config.protectedPathsOverride) return config.protectedPathsOverride - // With both env fields set the reader skips the file; only the folder is used. - const dailyNotesConfig = await readDailyNotesConfig(vaultPath, { - folder: config.dailyNotesFolder, - format: config.dailyNotesFormat, - }) + // loadConfig trims DAILY_NOTES_FOLDER, so a set value is never blank. + if (config.dailyNotesFolder) return [config.memoryDir, config.dailyNotesFolder] + + const dailyNotesConfig = await readDailyNotesFileConfig(vaultPath, logger) // A whitespace-only folder in daily-notes.json protects nothing. const dailyFolder = dailyNotesConfig.folder.trim() @@ -957,6 +961,7 @@ Errors: - "cannot move to trash …" — the .trash/ move failed (e.g. a plain file blocks a needed directory); the note remains at its original path - "cannot delete …" — permanent delete failed (e.g. permissions); the note remains at its original path - "cannot read trash config from .obsidian/app.json" — the config file exists but is unreadable (permissions, corruption); the delete is blocked to prevent accidental permanent deletion when the user may have configured .trash/ retention +- "cannot read daily notes config from .obsidian/daily-notes.json" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the delete is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set Returns: Confirmation message naming the outcome — "Deleted" for permanent removal, "Moved to trash" when the note landed in .trash/. Notes how many empty folders were pruned when any were.`, inputSchema: { @@ -982,7 +987,7 @@ Returns: Confirmation message naming the outcome — "Deleted" for permanent rem return safeHandler( reqLogger, async () => { - const protectedPaths = await resolveEffectiveProtectedPaths(config, vaultPath) + const protectedPaths = await resolveEffectiveProtectedPaths(config, vaultPath, reqLogger) // Under Obsidian Sync the setting is skipped and the note is deleted // for good ("none", since "system" would land in .trash/): a @@ -1048,6 +1053,7 @@ Errors: - "destination exists: …" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first. - "note not found: …" — old_path does not exist; verify it with vault_list_notes. - "cannot move protected path …" / "cannot move into protected path …" — old_path or new_path sits under a protected folder. +- "cannot read daily notes config from .obsidian/daily-notes.json" — the file exists but cannot be read or parsed, so the protected daily notes folder is unknown; the move is refused until the file is repaired, or DAILY_NOTES_FOLDER or PROTECTED_PATHS is set. - "path must end in …" — old_path or new_path is missing the .md extension; both paths must end in .md. - "absolute path blocked" — old_path or new_path starts at the filesystem root; use vault-relative paths. - "path traversal blocked" — a path escapes the vault root; use vault-relative paths. @@ -1109,7 +1115,7 @@ Returns: JSON with moved_to (the new path), links_updated (count of link occurre const [allNotePaths, allAssetPaths, protectedPaths] = await Promise.all([ vaultFs.listNotes({ vaultPath }, reqLogger), vaultFs.listAssets({ vaultPath }, reqLogger), - resolveEffectiveProtectedPaths(config, vaultPath), + resolveEffectiveProtectedPaths(config, vaultPath, reqLogger), ]) return noteMover.moveNote( { diff --git a/src/vault-mcp/obsidian-markdown/tasks.ts b/src/vault-mcp/obsidian-markdown/tasks.ts index c194c5aa6..ea2856a7c 100644 --- a/src/vault-mcp/obsidian-markdown/tasks.ts +++ b/src/vault-mcp/obsidian-markdown/tasks.ts @@ -269,6 +269,17 @@ const PRIORITY_BY_WORD: Readonly> = { // ── Status mapping ────────────────────────────────────────────── +/** The Tasks plugin's built-in statuses — what the registry holds when the + * plugin config is absent. Classifies every char the way statusForChar + * does without a registry. */ +export const DEFAULT_STATUS_REGISTRY: ReadonlyMap = new Map([ + [" ", "todo"], + ["x", "done"], + ["X", "done"], + ["/", "in_progress"], + ["-", "cancelled"], +]) + /** Maps a checkbox character to a status classification. With a registry * (from the Tasks plugin config), the registry lookup wins and unknown * chars fall back to "todo". Without a registry, the four built-in chars diff --git a/src/vault-mcp/search/__tests__/search-index.test.ts b/src/vault-mcp/search/__tests__/search-index.test.ts index da8a45e95..a4087af15 100644 --- a/src/vault-mcp/search/__tests__/search-index.test.ts +++ b/src/vault-mcp/search/__tests__/search-index.test.ts @@ -986,6 +986,31 @@ describe("upsertNote", () => { expect(tags).toEqual([{ tag: "single-tag", count: 1 }]) }) + it("exposes the status registry it was built with", () => { + const statusRegistry: ReadonlyMap = new Map([ + [" ", "todo"], + ["x", "done"], + ["D", "done"], + ]) + const registryIndex = createSearchIndex(":memory:", undefined, undefined, { + statusRegistry, + }) + + expect(registryIndex.statusRegistry).toBe(statusRegistry) + }) + + it("exposes the built-in statuses when no registry is given", () => { + expect(index.statusRegistry).toEqual( + new Map([ + [" ", "todo"], + ["x", "done"], + ["X", "done"], + ["/", "in_progress"], + ["-", "cancelled"], + ]), + ) + }) + it("threads the status registry to classify custom task statuses", () => { const statusRegistry: ReadonlyMap = new Map([ [" ", "todo"], diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index d8e0f78d5..36e082dc4 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -14,7 +14,7 @@ import { links } from "../obsidian-markdown/links.js" import { splitIntoLines } from "../obsidian-markdown/lines.js" import { parseHeadings } from "../obsidian-markdown/headings.js" import { parseMemoryEntries, type MemoryEntry } from "../obsidian-markdown/memory-entries.js" -import { tasks } from "../obsidian-markdown/tasks.js" +import { DEFAULT_STATUS_REGISTRY, tasks } from "../obsidian-markdown/tasks.js" import type { StatusClassification, TaskPriority, TaskStatus } from "../obsidian-markdown/tasks.js" import { contentHash, type Embedder } from "./embedder.js" import type { Reranker } from "./reranker.js" @@ -343,8 +343,10 @@ export const createSearchIndex = ( /** When true, creates file_content + file_content_fts tables for * full-text search of non-markdown file content (e.g. canvas). */ fileToolsEnabled?: boolean | undefined - /** Checkbox char → classified type from the Tasks plugin config. - * Captured once at boot — a config change requires a server restart. */ + /** Checkbox char → classified type from the Tasks plugin config; the + * built-in statuses when omitted. Captured once at boot and exposed as + * `statusRegistry`, so task writes classify with the same map the index + * did — a status-type change in the plugin needs a server restart. */ statusRegistry?: ReadonlyMap | undefined /** Query-time overrides for hybridSearch (file-leg RRF weight, reranker * kind prefix — defaults in hybrid-search.ts) plus the index-time @@ -356,7 +358,7 @@ export const createSearchIndex = ( ) => { const memoryDir = options?.memoryDir const fileToolsEnabled = options?.fileToolsEnabled ?? false - const statusRegistry = options?.statusRegistry + const statusRegistry = options?.statusRegistry ?? DEFAULT_STATUS_REGISTRY const db = new Database(dbPath) db.pragma("journal_mode = WAL") db.pragma("synchronous = NORMAL") @@ -2468,6 +2470,7 @@ export const createSearchIndex = ( modifiedOnDate: bindQueryContext(queries.modifiedOnDate), vaultStats: bindQueryContext(queries.vaultStats), fileContentIndexingEnabled: fileToolsEnabled, + statusRegistry, } } diff --git a/src/vault-mcp/server.ts b/src/vault-mcp/server.ts index b25ee0dd8..26f108667 100644 --- a/src/vault-mcp/server.ts +++ b/src/vault-mcp/server.ts @@ -138,7 +138,7 @@ const startServer = async (): Promise => { const embedder = config.embeddingEnabled ? createEmbedder(logger) : undefined const reranker = config.embeddingEnabled && config.rerankMode === "blended" ? createReranker(logger) : undefined - const taskFormatConfig = await readTaskFormatConfig(vaultPath) + const taskFormatConfig = await readTaskFormatConfig(vaultPath, logger) const search = createSearchIndex(searchDbPath, embedder, reranker, { memoryDir: config.memoryEnabled ? config.memoryDir : undefined, fileToolsEnabled: config.fileToolsEnabled, diff --git a/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts b/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts index 5680a3bad..bd9beee0a 100644 --- a/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts @@ -1,257 +1,363 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { describe, it, expect, onTestFinished, vi } from "vitest" import { DateTime } from "luxon" import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import { logger } from "../../../logger.js" +import { + readDailyNotesConfig, + readDailyNotesFileConfig, + getDailyNotePath, + getDailyNote, +} from "../daily-notes.js" + +const createVault = async (): Promise => { + const vaultDir = await mkdtemp(join(tmpdir(), "daily-notes-test-")) + onTestFinished(async () => rm(vaultDir, { recursive: true })) + await mkdir(join(vaultDir, ".obsidian"), { recursive: true }) + return vaultDir +} + +const writeDailyNotesConfig = async (vaultDir: string, config: unknown): Promise => { + await writeFile(join(vaultDir, ".obsidian", "daily-notes.json"), JSON.stringify(config), "utf8") +} + +/** The message JSON.parse throws for `malformedJson`. Its wording varies by + * engine version, so tests read it from the engine. */ +const jsonParseFailureMessage = (malformedJson: string): string => { + try { + JSON.parse(malformedJson) + } catch (error) { + if (error instanceof SyntaxError) return error.message + } + throw new Error("expected JSON.parse to reject the malformed input") +} + +const FALLBACK_CONFIG = { folder: "Daily Notes", format: "YYYY-MM-DD" } // ── readDailyNotesConfig ───────────────────────────────────────── describe("readDailyNotesConfig", () => { - let vaultDir: string + it("reads folder and format from .obsidian/daily-notes.json", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "YYYY-MM-DD-dddd" }) - beforeEach(async () => { - vi.resetModules() - vaultDir = await mkdtemp(join(tmpdir(), "daily-notes-test-")) - await mkdir(join(vaultDir, ".obsidian"), { recursive: true }) - }) + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - afterEach(async () => { - await rm(vaultDir, { recursive: true }) - }) - - it("reads folder and format from .obsidian/daily-notes.json", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "YYYY-MM-DD-dddd" }), - "utf8", - ) - const config = await readDailyNotesConfig(vaultDir) expect(config).toEqual({ folder: "Journal", format: "YYYY-MM-DD-dddd" }) }) it("falls back to defaults when file is missing", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - const config = await readDailyNotesConfig(vaultDir) - expect(config).toEqual({ folder: "Daily Notes", format: "YYYY-MM-DD" }) + const vaultDir = await createVault() + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual(FALLBACK_CONFIG) }) - it("falls back to defaults when file is malformed JSON", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile(join(vaultDir, ".obsidian", "daily-notes.json"), "not valid json{{{", "utf8") - const config = await readDailyNotesConfig(vaultDir) - expect(config).toEqual({ folder: "Daily Notes", format: "YYYY-MM-DD" }) + it("falls back to defaults when .obsidian is a file rather than a folder", async () => { + const vaultDir = await mkdtemp(join(tmpdir(), "daily-notes-test-")) + onTestFinished(async () => rm(vaultDir, { recursive: true })) + await writeFile(join(vaultDir, ".obsidian"), "not a folder", "utf8") + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + // No settings exist, so nothing is wrong to warn about. + expect(config).toEqual(FALLBACK_CONFIG) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("falls back to defaults on malformed JSON and warns on the caller's logger, not the root logger", async () => { + const vaultDir = await createVault() + const malformedJson = "not valid json{{{" + await writeFile(join(vaultDir, ".obsidian", "daily-notes.json"), malformedJson, "utf8") + + const requestLogger = logger.child({ requestId: "request-1" }) + const requestWarnSpy = vi.spyOn(requestLogger, "warn") + const rootWarnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => rootWarnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, requestLogger) + + expect(config).toEqual(FALLBACK_CONFIG) + expect(requestWarnSpy).toHaveBeenCalledTimes(1) + expect(requestWarnSpy).toHaveBeenCalledWith("cannot read daily notes config, using defaults", { + error: `[SyntaxError]: ${jsonParseFailureMessage(malformedJson)}`, + }) + expect(rootWarnSpy).not.toHaveBeenCalled() }) it("uses default folder when config has empty folder string", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "", format: "YYYY-MM-DD" }), - "utf8", - ) - const config = await readDailyNotesConfig(vaultDir) - expect(config).toEqual({ folder: "Daily Notes", format: "YYYY-MM-DD" }) + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "", format: "YYYY-MM-DD" }) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual(FALLBACK_CONFIG) }) it("uses default format when config has empty format string", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal" }), - "utf8", - ) - const config = await readDailyNotesConfig(vaultDir) + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal" }) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + expect(config).toEqual({ folder: "Journal", format: "YYYY-MM-DD" }) }) - it("caches the config after first read", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "YYYY-MM-DD" }), - "utf8", - ) - const first = await readDailyNotesConfig(vaultDir) + it("follows a folder and format change between two reads", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "YYYY-MM-DD" }) + + const first = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) expect(first).toEqual({ folder: "Journal", format: "YYYY-MM-DD" }) - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Changed", format: "DD-MM-YYYY" }), - "utf8", - ) - const second = await readDailyNotesConfig(vaultDir) - expect(second).toEqual({ folder: "Journal", format: "YYYY-MM-DD" }) + await writeDailyNotesConfig(vaultDir, { folder: "Changed", format: "DD-MM-YYYY" }) + const second = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + expect(second).toEqual({ folder: "Changed", format: "DD-MM-YYYY" }) }) - it("retries after ENOENT — a config file appearing later is picked up without a restart", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - const beforeFileExists = await readDailyNotesConfig(vaultDir) - expect(beforeFileExists).toEqual({ - folder: "Daily Notes", - format: "YYYY-MM-DD", - }) + it("picks up a config file that appears after an earlier read", async () => { + const vaultDir = await createVault() - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "DD-MM-YYYY" }), - "utf8", - ) - const afterFileExists = await readDailyNotesConfig(vaultDir) - expect(afterFileExists).toEqual({ - folder: "Journal", - format: "DD-MM-YYYY", - }) + const beforeFileExists = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + expect(beforeFileExists).toEqual(FALLBACK_CONFIG) + + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + const afterFileExists = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + expect(afterFileExists).toEqual({ folder: "Journal", format: "DD-MM-YYYY" }) }) - it("retries after malformed JSON — a fixed config file is picked up without a restart", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") + it("picks up a repaired config file after a malformed read", async () => { + const vaultDir = await createVault() const configFilePath = join(vaultDir, ".obsidian", "daily-notes.json") await writeFile(configFilePath, "not valid json{{{", "utf8") - const whileMalformed = await readDailyNotesConfig(vaultDir) - expect(whileMalformed).toEqual({ - folder: "Daily Notes", - format: "YYYY-MM-DD", - }) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) - await writeFile( - configFilePath, - JSON.stringify({ folder: "Journal", format: "DD-MM-YYYY" }), - "utf8", - ) - const afterFix = await readDailyNotesConfig(vaultDir) + const whileMalformed = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + expect(whileMalformed).toEqual(FALLBACK_CONFIG) + + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + const afterFix = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) expect(afterFix).toEqual({ folder: "Journal", format: "DD-MM-YYYY" }) + expect(warnSpy).toHaveBeenCalledTimes(1) }) describe("overrides precedence", () => { it("folder-only override wins over the file's folder, file keeps format", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "DD-MM-YYYY" }), - "utf8", + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + + const config = await readDailyNotesConfig( + { vaultPath: vaultDir, envSettings: { folder: "Override Folder" } }, + logger, ) - const config = await readDailyNotesConfig(vaultDir, { - folder: "Override Folder", - }) - expect(config).toEqual({ - folder: "Override Folder", - format: "DD-MM-YYYY", - }) + + expect(config).toEqual({ folder: "Override Folder", format: "DD-MM-YYYY" }) }) it("format-only override wins over the file's format, file keeps folder", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "DD-MM-YYYY" }), - "utf8", + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + + const config = await readDailyNotesConfig( + { vaultPath: vaultDir, envSettings: { format: "YYYY_MM_DD" } }, + logger, ) - const config = await readDailyNotesConfig(vaultDir, { - format: "YYYY_MM_DD", - }) + expect(config).toEqual({ folder: "Journal", format: "YYYY_MM_DD" }) }) it("both overrides win over a conflicting file", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "DD-MM-YYYY" }), - "utf8", + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + + const config = await readDailyNotesConfig( + { vaultPath: vaultDir, envSettings: { folder: "Override Folder", format: "YYYY_MM_DD" } }, + logger, + ) + + expect(config).toEqual({ folder: "Override Folder", format: "YYYY_MM_DD" }) + }) + + it("both overrides skip the file, so a malformed one is never read", async () => { + const vaultDir = await createVault() + await writeFile(join(vaultDir, ".obsidian", "daily-notes.json"), "not valid json{{{", "utf8") + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig( + { vaultPath: vaultDir, envSettings: { folder: "Override Folder", format: "YYYY_MM_DD" } }, + logger, ) - const config = await readDailyNotesConfig(vaultDir, { - folder: "Override Folder", - format: "YYYY_MM_DD", - }) - expect(config).toEqual({ - folder: "Override Folder", - format: "YYYY_MM_DD", - }) + + expect(config).toEqual({ folder: "Override Folder", format: "YYYY_MM_DD" }) + expect(warnSpy).not.toHaveBeenCalled() }) it("both overrides apply without a config file", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - const config = await readDailyNotesConfig(vaultDir, { - folder: "Override Folder", - format: "YYYY_MM_DD", - }) - expect(config).toEqual({ - folder: "Override Folder", - format: "YYYY_MM_DD", - }) + const vaultDir = await createVault() + + const config = await readDailyNotesConfig( + { vaultPath: vaultDir, envSettings: { folder: "Override Folder", format: "YYYY_MM_DD" } }, + logger, + ) + + expect(config).toEqual({ folder: "Override Folder", format: "YYYY_MM_DD" }) }) it("folder-only override without a config file falls back to the default format", async () => { - const { readDailyNotesConfig } = await import("../daily-notes.js") - const config = await readDailyNotesConfig(vaultDir, { - folder: "Override Folder", - }) - expect(config).toEqual({ - folder: "Override Folder", - format: "YYYY-MM-DD", - }) + const vaultDir = await createVault() + + const config = await readDailyNotesConfig( + { vaultPath: vaultDir, envSettings: { folder: "Override Folder" } }, + logger, + ) + + expect(config).toEqual({ folder: "Override Folder", format: "YYYY-MM-DD" }) }) }) }) -// ── getDailyNotePath ───────────────────────────────────────────── +// ── readDailyNotesFileConfig ───────────────────────────────────── -describe("getDailyNotePath", () => { - let vaultDir: string +describe("readDailyNotesFileConfig", () => { + it("reads folder and format from .obsidian/daily-notes.json", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + + const config = await readDailyNotesFileConfig(vaultDir, logger) - beforeEach(async () => { - vi.resetModules() - vaultDir = await mkdtemp(join(tmpdir(), "daily-path-test-")) - await mkdir(join(vaultDir, ".obsidian"), { recursive: true }) + expect(config).toEqual({ folder: "Journal", format: "DD-MM-YYYY" }) }) - afterEach(async () => { - await rm(vaultDir, { recursive: true }) + it("falls back to defaults when the file is missing", async () => { + const vaultDir = await createVault() + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesFileConfig(vaultDir, logger) + + expect(config).toEqual(FALLBACK_CONFIG) + expect(warnSpy).not.toHaveBeenCalled() }) - it("resolves a specific date with default config", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - const path = await getDailyNotePath({ - vaultPath: vaultDir, - date: "2026-05-13", + it("falls back to defaults when .obsidian is a file rather than a folder", async () => { + const vaultDir = await mkdtemp(join(tmpdir(), "daily-notes-test-")) + onTestFinished(async () => rm(vaultDir, { recursive: true })) + await writeFile(join(vaultDir, ".obsidian"), "not a folder", "utf8") + + const config = await readDailyNotesFileConfig(vaultDir, logger) + + expect(config).toEqual(FALLBACK_CONFIG) + }) + + it("throws on malformed JSON and warns on the caller's logger, not the root logger", async () => { + const vaultDir = await createVault() + const malformedJson = "not valid json{{{" + await writeFile(join(vaultDir, ".obsidian", "daily-notes.json"), malformedJson, "utf8") + + const requestLogger = logger.child({ requestId: "request-1" }) + const requestWarnSpy = vi.spyOn(requestLogger, "warn") + const rootWarnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => rootWarnSpy.mockRestore()) + + await expect(readDailyNotesFileConfig(vaultDir, requestLogger)).rejects.toThrow( + new Error("cannot read daily notes config from .obsidian/daily-notes.json"), + ) + + expect(requestWarnSpy).toHaveBeenCalledTimes(1) + expect(requestWarnSpy).toHaveBeenCalledWith("cannot read daily notes config", { + error: `[SyntaxError]: ${jsonParseFailureMessage(malformedJson)}`, }) - expect(path).toBe("Daily Notes/2026-05-13.md") + expect(rootWarnSpy).not.toHaveBeenCalled() }) - it("resolves with custom folder and format", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "DD-MM-YYYY" }), - "utf8", + it("throws when daily-notes.json is a directory", async () => { + const vaultDir = await createVault() + await mkdir(join(vaultDir, ".obsidian", "daily-notes.json"), { recursive: true }) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + await expect(readDailyNotesFileConfig(vaultDir, logger)).rejects.toThrow( + new Error("cannot read daily notes config from .obsidian/daily-notes.json"), ) - const path = await getDailyNotePath({ - vaultPath: vaultDir, - date: "2026-05-13", + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("cannot read daily notes config", { + error: "[Error]: EISDIR: illegal operation on a directory, read", }) + }) + + it("follows a folder change between two reads", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal" }) + + const first = await readDailyNotesFileConfig(vaultDir, logger) + expect(first).toEqual({ folder: "Journal", format: "YYYY-MM-DD" }) + + await writeDailyNotesConfig(vaultDir, { folder: "Diary" }) + const second = await readDailyNotesFileConfig(vaultDir, logger) + expect(second).toEqual({ folder: "Diary", format: "YYYY-MM-DD" }) + }) +}) + +// ── getDailyNotePath ───────────────────────────────────────────── + +describe("getDailyNotePath", () => { + it("resolves a specific date with default config", async () => { + const vaultDir = await createVault() + + const path = await getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger) + + expect(path).toBe("Daily Notes/2026-05-13.md") + }) + + it("resolves with custom folder and format", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + + const path = await getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger) + expect(path).toBe("Journal/13-05-2026.md") }) it("resolves with env overrides winning over the config file", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "YYYY-MM-DD" }), - "utf8", + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "YYYY-MM-DD" }) + + const path = await getDailyNotePath( + { + vaultPath: vaultDir, + date: "2026-05-13", + envSettings: { folder: "Override Folder", format: "DD-MM-YYYY" }, + }, + logger, ) - const path = await getDailyNotePath({ - vaultPath: vaultDir, - date: "2026-05-13", - envSettings: { folder: "Override Folder", format: "DD-MM-YYYY" }, - }) + expect(path).toBe("Override Folder/13-05-2026.md") }) + it("follows a folder change between two calls", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal" }) + + const first = await getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger) + expect(first).toBe("Journal/2026-05-13.md") + + await writeDailyNotesConfig(vaultDir, { folder: "Diary" }) + const second = await getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger) + expect(second).toBe("Diary/2026-05-13.md") + }) + it("defaults to today when no date provided", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - const path = await getDailyNotePath({ vaultPath: vaultDir }) + const vaultDir = await createVault() + + const path = await getDailyNotePath({ vaultPath: vaultDir }, logger) + // Use Luxon's local-timezone today (same as the code under test) to // avoid UTC/local date mismatch near midnight const todayLocal = DateTime.now().toFormat("yyyy-MM-dd") @@ -259,102 +365,90 @@ describe("getDailyNotePath", () => { }) it("throws on invalid date format", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - await expect(getDailyNotePath({ vaultPath: vaultDir, date: "not-a-date" })).rejects.toThrow( - "invalid date", - ) + const vaultDir = await createVault() + + await expect( + getDailyNotePath({ vaultPath: vaultDir, date: "not-a-date" }, logger), + ).rejects.toThrow("invalid date") }) it("rejects partial ISO dates (year only)", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - await expect(getDailyNotePath({ vaultPath: vaultDir, date: "2026" })).rejects.toThrow( + const vaultDir = await createVault() + + await expect(getDailyNotePath({ vaultPath: vaultDir, date: "2026" }, logger)).rejects.toThrow( "invalid date", ) }) it("rejects partial ISO dates (year-month only)", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - await expect(getDailyNotePath({ vaultPath: vaultDir, date: "2026-05" })).rejects.toThrow( - "invalid date", - ) + const vaultDir = await createVault() + + await expect( + getDailyNotePath({ vaultPath: vaultDir, date: "2026-05" }, logger), + ).rejects.toThrow("invalid date") }) it("rejects full ISO timestamps", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") + const vaultDir = await createVault() + await expect( - getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13T14:30:00Z" }), + getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13T14:30:00Z" }, logger), ).rejects.toThrow("invalid date") }) it("rejects a format containing unsupported tokens (Do)", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "MMMM Do, YYYY" }), - "utf8", - ) - await expect(getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" })).rejects.toThrow( - "unsupported token(s): Do", - ) + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "MMMM Do, YYYY" }) + + await expect( + getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger), + ).rejects.toThrow("unsupported token(s): Do") }) it("rejects a format containing unsupported tokens (dd)", async () => { - const { getDailyNotePath } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "Journal", format: "YYYY-MM-DD dd" }), - "utf8", - ) - await expect(getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" })).rejects.toThrow( - "unsupported token(s): dd", - ) + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "YYYY-MM-DD dd" }) + + await expect( + getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger), + ).rejects.toThrow("unsupported token(s): dd") }) }) // ── getDailyNote ───────────────────────────────────────────────── describe("getDailyNote", () => { - let vaultDir: string - - beforeEach(async () => { - vi.resetModules() - vaultDir = await mkdtemp(join(tmpdir(), "daily-read-test-")) - await mkdir(join(vaultDir, ".obsidian"), { recursive: true }) - await mkdir(join(vaultDir, "Daily Notes"), { recursive: true }) - }) - - afterEach(async () => { - await rm(vaultDir, { recursive: true }) - }) - it("reads an existing daily note", async () => { - const { getDailyNote } = await import("../daily-notes.js") + const vaultDir = await createVault() + await mkdir(join(vaultDir, "Daily Notes"), { recursive: true }) await writeFile( join(vaultDir, "Daily Notes", "2026-05-13.md"), "---\ndate: 2026-05-13\n---\n\n# 2026-05-13\n\nToday's notes.\n", "utf8", ) + const result = await getDailyNote({ vaultPath: vaultDir, date: "2026-05-13" }, logger) - expect(result.exists).toBe(true) - expect(result.path).toBe("Daily Notes/2026-05-13.md") - expect(result.content).toContain("Today's notes.") + + expect(result).toEqual({ + path: "Daily Notes/2026-05-13.md", + content: "---\ndate: 2026-05-13\n---\n\n# 2026-05-13\n\nToday's notes.\n", + exists: true, + }) }) it("returns exists: false for missing daily note", async () => { - const { getDailyNote } = await import("../daily-notes.js") + const vaultDir = await createVault() + await mkdir(join(vaultDir, "Daily Notes"), { recursive: true }) + const result = await getDailyNote({ vaultPath: vaultDir, date: "2026-01-01" }, logger) - expect(result.exists).toBe(false) - expect(result.path).toBe("Daily Notes/2026-01-01.md") - expect(result.content).toBeNull() + + expect(result).toEqual({ path: "Daily Notes/2026-01-01.md", content: null, exists: false }) }) it("rethrows non-ENOENT errors (e.g. path traversal)", async () => { - const { getDailyNote } = await import("../daily-notes.js") - await writeFile( - join(vaultDir, ".obsidian", "daily-notes.json"), - JSON.stringify({ folder: "../escape", format: "YYYY-MM-DD" }), - "utf8", - ) + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "../escape", format: "YYYY-MM-DD" }) + await expect(getDailyNote({ vaultPath: vaultDir, date: "2026-05-13" }, logger)).rejects.toThrow( "path traversal blocked", ) diff --git a/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts b/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts index 5644ebdbb..bd82d567a 100644 --- a/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, onTestFinished } from "vitest" +import { describe, it, expect, onTestFinished, vi } from "vitest" import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" -import { readTaskFormatConfig, resetTaskFormatConfigCache } from "../task-format-config.js" +import { readTaskFormatConfig } from "../task-format-config.js" import type { StatusClassification } from "../task-format-config.js" +import { logger } from "../../../logger.js" const createVault = async (): Promise => { const vaultPath = await mkdtemp(join(tmpdir(), "task-format-config-test-")) @@ -20,6 +21,17 @@ const writePluginConfig = async ( await writeFile(join(pluginDir, "data.json"), JSON.stringify(config), "utf8") } +/** The message JSON.parse throws for `malformedJson`. Its wording varies by + * engine version, so tests read it from the engine. */ +const jsonParseFailureMessage = (malformedJson: string): string => { + try { + JSON.parse(malformedJson) + } catch (error) { + if (error instanceof SyntaxError) return error.message + } + throw new Error("expected JSON.parse to reject the malformed input") +} + const DEFAULT_STATUS_REGISTRY: ReadonlyMap = new Map([ [" ", "todo"], ["x", "done"], @@ -36,9 +48,16 @@ const DEFAULT_PLUGIN_FIELDS = { statusRegistry: DEFAULT_STATUS_REGISTRY, } as const +/** Every field at its plugin default — what an absent or unreadable file yields. */ +const DEFAULT_CONFIG = { + taskFormat: "emoji", + setDoneDate: true, + setCancelledDate: true, + ...DEFAULT_PLUGIN_FIELDS, +} as const + describe("readTaskFormatConfig", () => { it("reads emoji format from a valid config file", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { taskFormat: "tasksPluginEmoji", @@ -46,7 +65,7 @@ describe("readTaskFormatConfig", () => { setCancelledDate: false, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) expect(config).toEqual({ taskFormat: "emoji", @@ -57,7 +76,6 @@ describe("readTaskFormatConfig", () => { }) it("reads the recurrence-behavior settings from the config file", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { taskFormat: "tasksPluginEmoji", @@ -66,7 +84,7 @@ describe("readTaskFormatConfig", () => { removeScheduledDateOnRecurrence: true, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) expect(config).toEqual({ taskFormat: "emoji", @@ -80,7 +98,6 @@ describe("readTaskFormatConfig", () => { }) it("builds the full status registry from core + custom statuses", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { statusSettings: { @@ -100,7 +117,7 @@ describe("readTaskFormatConfig", () => { }, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) // Config wins on overlap (- is retyped from cancelled to todo), // defaults fill in what the config omits (X stays done). @@ -121,7 +138,6 @@ describe("readTaskFormatConfig", () => { }) it("resolves intra-config duplicate symbols with last-wins (custom over core)", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { statusSettings: { @@ -132,13 +148,12 @@ describe("readTaskFormatConfig", () => { }, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) expect(config.statusRegistry.get("!")).toBe("in_progress") }) it("ignores the legacy pre-type status format", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { statusSettings: { @@ -146,13 +161,12 @@ describe("readTaskFormatConfig", () => { }, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) expect(config.statusRegistry).toEqual(DEFAULT_STATUS_REGISTRY) }) it("ignores malformed status-registry entries", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { statusSettings: { @@ -161,13 +175,12 @@ describe("readTaskFormatConfig", () => { }, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) expect(config.statusRegistry).toEqual(DEFAULT_STATUS_REGISTRY) }) it("ignores entries with unrecognized plugin type strings", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { statusSettings: { @@ -182,7 +195,7 @@ describe("readTaskFormatConfig", () => { }, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) // The parsed entries merge on top of the defaults; the MYSTERY entry // is dropped, but the valid D→done entry survives alongside the defaults. @@ -199,7 +212,6 @@ describe("readTaskFormatConfig", () => { }) it("reads dataview format from a valid config file", async () => { - resetTaskFormatConfigCache() const vault = await createVault() await writePluginConfig(vault, { taskFormat: "dataview", @@ -207,7 +219,7 @@ describe("readTaskFormatConfig", () => { setCancelledDate: true, }) - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) expect(config).toEqual({ taskFormat: "dataview", @@ -218,54 +230,77 @@ describe("readTaskFormatConfig", () => { }) it("falls back to defaults when the config file is missing", async () => { - resetTaskFormatConfigCache() const vault = await createVault() - const config = await readTaskFormatConfig(vault) + const config = await readTaskFormatConfig(vault, logger) - expect(config).toEqual({ - taskFormat: "emoji", - setDoneDate: true, - setCancelledDate: true, - ...DEFAULT_PLUGIN_FIELDS, - }) + expect(config).toEqual(DEFAULT_CONFIG) }) - it("falls back to defaults on malformed JSON", async () => { - resetTaskFormatConfigCache() + it("falls back to defaults when .obsidian is a file rather than a folder", async () => { + const vault = await createVault() + await writeFile(join(vault, ".obsidian"), "not a folder", "utf8") + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readTaskFormatConfig(vault, logger) + + // No settings exist, so nothing is wrong to warn about. + expect(config).toEqual(DEFAULT_CONFIG) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("falls back to defaults on malformed JSON and warns on the caller's logger, not the root logger", async () => { const vault = await createVault() const pluginDir = join(vault, ".obsidian", "plugins", "obsidian-tasks-plugin") await mkdir(pluginDir, { recursive: true }) - await writeFile(join(pluginDir, "data.json"), "not valid json{{{", "utf8") + const malformedJson = "not valid json{{{" + await writeFile(join(pluginDir, "data.json"), malformedJson, "utf8") - const config = await readTaskFormatConfig(vault) + const requestLogger = logger.child({ requestId: "request-1" }) + const requestWarnSpy = vi.spyOn(requestLogger, "warn") + const rootWarnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => rootWarnSpy.mockRestore()) - expect(config).toEqual({ - taskFormat: "emoji", - setDoneDate: true, - setCancelledDate: true, - ...DEFAULT_PLUGIN_FIELDS, + const config = await readTaskFormatConfig(vault, requestLogger) + + expect(config).toEqual(DEFAULT_CONFIG) + expect(requestWarnSpy).toHaveBeenCalledTimes(1) + expect(requestWarnSpy).toHaveBeenCalledWith("cannot read Tasks plugin config, using defaults", { + error: `[SyntaxError]: ${jsonParseFailureMessage(malformedJson)}`, }) + expect(rootWarnSpy).not.toHaveBeenCalled() }) - it("retries after ENOENT — a plugin config appearing later is picked up without a restart", async () => { - resetTaskFormatConfigCache() + it("falls back to defaults when data.json is a directory", async () => { const vault = await createVault() + await mkdir(join(vault, ".obsidian", "plugins", "obsidian-tasks-plugin", "data.json"), { + recursive: true, + }) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) - const beforeFileExists = await readTaskFormatConfig(vault) - expect(beforeFileExists).toEqual({ - taskFormat: "emoji", - setDoneDate: true, - setCancelledDate: true, - ...DEFAULT_PLUGIN_FIELDS, + const config = await readTaskFormatConfig(vault, logger) + + expect(config).toEqual(DEFAULT_CONFIG) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("cannot read Tasks plugin config, using defaults", { + error: "[Error]: EISDIR: illegal operation on a directory, read", }) + }) + + it("picks up a plugin config that appears after an earlier read", async () => { + const vault = await createVault() + + const beforeFileExists = await readTaskFormatConfig(vault, logger) + expect(beforeFileExists).toEqual(DEFAULT_CONFIG) await writePluginConfig(vault, { taskFormat: "dataview", setDoneDate: false, setCancelledDate: false, }) - const afterFileExists = await readTaskFormatConfig(vault) + const afterFileExists = await readTaskFormatConfig(vault, logger) expect(afterFileExists).toEqual({ taskFormat: "dataview", setDoneDate: false, @@ -274,8 +309,7 @@ describe("readTaskFormatConfig", () => { }) }) - it("caches a successful read — later file changes are not re-read", async () => { - resetTaskFormatConfigCache() + it("follows a change to the config file between two reads", async () => { const vault = await createVault() await writePluginConfig(vault, { taskFormat: "dataview", @@ -283,7 +317,7 @@ describe("readTaskFormatConfig", () => { setCancelledDate: true, }) - const first = await readTaskFormatConfig(vault) + const first = await readTaskFormatConfig(vault, logger) expect(first).toEqual({ taskFormat: "dataview", setDoneDate: true, @@ -296,11 +330,11 @@ describe("readTaskFormatConfig", () => { setDoneDate: false, setCancelledDate: false, }) - const second = await readTaskFormatConfig(vault) + const second = await readTaskFormatConfig(vault, logger) expect(second).toEqual({ - taskFormat: "dataview", - setDoneDate: true, - setCancelledDate: true, + taskFormat: "emoji", + setDoneDate: false, + setCancelledDate: false, ...DEFAULT_PLUGIN_FIELDS, }) }) diff --git a/src/vault-mcp/vault-operations/__tests__/task-mutations.test.ts b/src/vault-mcp/vault-operations/__tests__/task-mutations.test.ts index c8016ac94..f8f1842a8 100644 --- a/src/vault-mcp/vault-operations/__tests__/task-mutations.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/task-mutations.test.ts @@ -4,7 +4,10 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { DateTime } from "luxon" import { taskMutations } from "../task-mutations.js" -import { resetTaskFormatConfigCache } from "../task-format-config.js" +import { + DEFAULT_STATUS_REGISTRY, + type StatusClassification, +} from "../../obsidian-markdown/tasks.js" import { logger } from "../../../logger.js" // ── Helpers ───────────────────────────────────────────────────── @@ -36,6 +39,22 @@ const today = (): string => { return date } +// Registries passed as `statusRegistry`, the way the tool layer passes the +// index's boot-time map. A write never takes its registry from the plugin +// file, so a test that needs a custom status supplies the map directly. + +/** The defaults plus a NON_TASK "Forwarded" checkbox. */ +const NON_TASK_REGISTRY: ReadonlyMap = new Map([ + ...DEFAULT_STATUS_REGISTRY, + [">", "non_task"], +]) + +/** The defaults plus a DONE-typed "Deployed" checkbox. */ +const DEPLOYED_DONE_REGISTRY: ReadonlyMap = new Map([ + ...DEFAULT_STATUS_REGISTRY, + ["D", "done"], +]) + // ── Fixtures ──────────────────────────────────────────────────── const SIMPLE_NOTE = `--- @@ -177,7 +196,13 @@ describe("task-mutations", () => { await writeTestNote(vault, "tasks.md", SIMPLE_NOTE) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 5, status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 5, + status: "done", + }, logger, ) @@ -198,7 +223,13 @@ describe("task-mutations", () => { await writeTestNote(vault, "tasks.md", SIMPLE_NOTE) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 5, status: "in_progress" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 5, + status: "in_progress", + }, logger, ) @@ -219,7 +250,13 @@ describe("task-mutations", () => { await writeTestNote(vault, "tasks.md", SIMPLE_NOTE) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 7, status: "todo" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 7, + status: "todo", + }, logger, ) @@ -240,7 +277,13 @@ describe("task-mutations", () => { await writeTestNote(vault, "tasks.md", SIMPLE_NOTE) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 5, status: "cancelled" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 5, + status: "cancelled", + }, logger, ) @@ -262,6 +305,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -288,7 +332,13 @@ describe("task-mutations", () => { await writeTestNote(vault, "tasks.md", SIMPLE_NOTE) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 6, status: "in_progress" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 6, + status: "in_progress", + }, logger, ) @@ -315,6 +365,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "no-pri", @@ -342,6 +393,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "has-pri", @@ -369,6 +421,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "has-pri", @@ -396,6 +449,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "plain-task", @@ -427,6 +481,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "planned-task", @@ -460,6 +515,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "walk", @@ -487,6 +543,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "parent", @@ -516,6 +573,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "task-a", @@ -544,6 +602,7 @@ describe("task-mutations", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "active-task", @@ -586,6 +645,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "move-me", @@ -619,6 +679,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "planned-task", @@ -649,6 +710,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -681,6 +743,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "missing.md", line: 1, @@ -698,6 +761,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md/child.md", line: 1, @@ -715,6 +779,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "nonexistent", @@ -731,7 +796,13 @@ title: Tasks await expect( taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 1, status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 1, + status: "done", + }, logger, ), ).rejects.toThrow("no task at line 1") @@ -742,7 +813,10 @@ title: Tasks await writeTestNote(vault, "tasks.md", SIMPLE_NOTE) await expect( - taskMutations.updateTask({ vaultPath: vault, path: "tasks.md", line: 5 }, logger), + taskMutations.updateTask( + { statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", line: 5 }, + logger, + ), ).rejects.toThrow("at least one mutation") }) @@ -753,6 +827,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "active-task", @@ -770,6 +845,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "task-a", @@ -787,6 +863,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", line: 5, @@ -803,7 +880,15 @@ title: Tasks await writeTestNote(vault, "tasks.md", SIMPLE_NOTE) await expect( - taskMutations.updateTask({ vaultPath: vault, path: "tasks.md", status: "done" }, logger), + taskMutations.updateTask( + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + status: "done", + }, + logger, + ), ).rejects.toThrow("exactly one of blockId or line is required") }) @@ -814,6 +899,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "task-a", @@ -835,6 +921,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "active-task", @@ -854,6 +941,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", line: 5, @@ -885,6 +973,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: ".trash/tasks.md", line: 5, @@ -906,6 +995,7 @@ title: Tasks const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "New task", @@ -934,6 +1024,7 @@ title: Tasks const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Appended", @@ -958,6 +1049,7 @@ title: Tasks const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Board task", @@ -1010,6 +1102,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Dated task", @@ -1034,6 +1127,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Child task", @@ -1055,6 +1149,7 @@ kanban-plugin: board const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Multi-stage task", @@ -1089,6 +1184,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Duplicate", @@ -1106,6 +1202,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Bad id", @@ -1123,6 +1220,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "No heading", @@ -1140,6 +1238,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Bad date", @@ -1158,6 +1257,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Orphan", @@ -1175,6 +1275,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Line child", @@ -1197,6 +1298,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Blocked task", @@ -1219,6 +1321,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Keyed task", @@ -1241,6 +1344,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Two locators", @@ -1262,6 +1366,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Conflicting", @@ -1281,6 +1386,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Conflicting", @@ -1302,6 +1408,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Ship", @@ -1322,6 +1429,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Line one\nLine two", @@ -1341,6 +1449,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Ship", @@ -1361,6 +1470,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "No deps", @@ -1381,6 +1491,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Bad line parent", @@ -1399,6 +1510,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: " ", @@ -1415,6 +1527,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Archived card", @@ -1444,6 +1557,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1471,6 +1585,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1499,6 +1614,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -1528,6 +1644,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -1557,6 +1674,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -1586,6 +1704,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -1617,6 +1736,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -1648,6 +1768,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -1679,6 +1800,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -1706,6 +1828,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1734,6 +1857,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1770,6 +1894,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "fix-bug", @@ -1798,6 +1923,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1822,6 +1948,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "parent", @@ -1856,6 +1983,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1884,6 +2012,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", line: 5, @@ -1911,6 +2040,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1935,6 +2065,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -1958,6 +2089,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "other", @@ -1976,6 +2108,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -2004,6 +2137,7 @@ kanban-plugin: board // Create a sub-task first so we have one to complete await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Sub-stage", @@ -2015,6 +2149,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "sub-stage", @@ -2038,6 +2173,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Sub for heading test", @@ -2050,6 +2186,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "sub-heading-test", @@ -2067,6 +2204,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -2084,6 +2222,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -2101,6 +2240,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -2118,6 +2258,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -2137,6 +2278,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -2155,6 +2297,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "agenda.md", blockId: "call-dentist", @@ -2181,6 +2324,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "quoted-parent", @@ -2210,6 +2354,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "quoted-parent", @@ -2249,6 +2394,7 @@ kanban-plugin: board const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Third", @@ -2272,6 +2418,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Prepended", @@ -2294,6 +2441,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "New card", @@ -2353,6 +2501,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Appended card", @@ -2402,6 +2551,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Prepended card", @@ -2449,6 +2599,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Top card", @@ -2483,6 +2634,7 @@ kanban-plugin: board const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Child task", @@ -2506,6 +2658,7 @@ kanban-plugin: board const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "End task", @@ -2529,6 +2682,7 @@ kanban-plugin: board const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "First card", @@ -2552,6 +2706,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "New done task", @@ -2599,6 +2754,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "New card", @@ -2635,6 +2791,7 @@ kanban-plugin: board const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Inserted", @@ -2659,6 +2816,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Clamped", @@ -2682,6 +2840,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Solo", @@ -2705,6 +2864,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Between", @@ -2728,6 +2888,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "After prose", @@ -2750,6 +2911,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Appended", @@ -2775,6 +2937,7 @@ kanban-plugin: board await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "planned-task", @@ -2816,6 +2979,7 @@ kanban-plugin: board await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "planned-task", @@ -2858,6 +3022,7 @@ kanban-plugin: board await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "active-task", @@ -2902,6 +3067,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "alpha", @@ -2925,6 +3091,7 @@ kanban-plugin: board const contentBefore = await readTestNote(vault, "board.md") const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "alpha", @@ -2945,6 +3112,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "alpha", @@ -2968,6 +3136,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "bravo", @@ -2990,6 +3159,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "bravo", @@ -3013,6 +3183,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", blockId: "child", @@ -3032,6 +3203,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "alpha", @@ -3048,28 +3220,7 @@ kanban-plugin: board }) it("same-lane reorder skips NON_TASK checkboxes in position count", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) - const vault = await createVault() - const pluginDir = join(vault, ".obsidian", "plugins", "obsidian-tasks-plugin") - await mkdir(pluginDir, { recursive: true }) - await writeFile( - join(pluginDir, "data.json"), - JSON.stringify({ - statusSettings: { - coreStatuses: [ - { symbol: " ", name: "Todo", nextStatusSymbol: "x", type: "TODO" }, - { symbol: "x", name: "Done", nextStatusSymbol: " ", type: "DONE" }, - ], - customStatuses: [ - { symbol: ">", name: "Forwarded", nextStatusSymbol: " ", type: "NON_TASK" }, - ], - }, - }), - "utf8", - ) - const board = [ "---", "title: Board", @@ -3090,6 +3241,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: NON_TASK_REGISTRY, vaultPath: vault, path: "board.md", blockId: "charlie", @@ -3128,6 +3280,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", blockId: "alpha", @@ -3148,6 +3301,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", blockId: "alpha", @@ -3168,6 +3322,7 @@ kanban-plugin: board await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", blockId: "alpha", @@ -3188,6 +3343,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Charlie", @@ -3212,6 +3368,7 @@ kanban-plugin: board await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Charlie", @@ -3233,6 +3390,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Charlie", @@ -3256,6 +3414,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", description: "Charlie", @@ -3279,6 +3438,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Archived item", @@ -3316,6 +3476,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", description: "Inserted", @@ -3348,28 +3509,7 @@ kanban-plugin: board }) it("integer position skips NON_TASK checkboxes", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) - const vault = await createVault() - const pluginDir = join(vault, ".obsidian", "plugins", "obsidian-tasks-plugin") - await mkdir(pluginDir, { recursive: true }) - await writeFile( - join(pluginDir, "data.json"), - JSON.stringify({ - statusSettings: { - coreStatuses: [ - { symbol: " ", name: "Todo", nextStatusSymbol: "x", type: "TODO" }, - { symbol: "x", name: "Done", nextStatusSymbol: " ", type: "DONE" }, - ], - customStatuses: [ - { symbol: ">", name: "Forwarded", nextStatusSymbol: " ", type: "NON_TASK" }, - ], - }, - }), - "utf8", - ) - const note = [ "---", "title: Board", @@ -3388,6 +3528,7 @@ kanban-plugin: board await taskMutations.createTask( { + statusRegistry: NON_TASK_REGISTRY, vaultPath: vault, path: "board.md", description: "Inserted", @@ -3425,6 +3566,7 @@ kanban-plugin: board const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "notes.md", blockId: "water", @@ -3455,6 +3597,7 @@ kanban-plugin: board await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "active-task", @@ -3496,15 +3639,13 @@ kanban-plugin: board // ── Recurring-task completion ─────────────────────────────────── -/** Writes the Tasks plugin's data.json and clears the process-wide config - * cache so this vault's settings are actually read; the cache is cleared - * again at test end so later tests see defaults. */ +/** Writes the Tasks plugin's data.json, which every task write reads for the + * format and date toggles. Its status settings never reach a write — the + * registry is a call param. */ const writeTasksPluginConfig = async ( vaultPath: string, config: Record, ): Promise => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) const pluginDir = join(vaultPath, ".obsidian", "plugins", "obsidian-tasks-plugin") await mkdir(pluginDir, { recursive: true }) await writeFile(join(pluginDir, "data.json"), JSON.stringify(config), "utf8") @@ -3524,6 +3665,7 @@ describe("recurring-task completion", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -3562,6 +3704,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -3595,6 +3738,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -3621,6 +3765,7 @@ title: Tasks await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "sync-task", @@ -3646,6 +3791,7 @@ title: Tasks await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "dup-task", @@ -3667,6 +3813,7 @@ title: Tasks await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -3693,7 +3840,13 @@ title: Tasks // Two trailing spaces once hid the block link — and with it every // metadata field — from the parser; the spawn depends on the fix. const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "habit", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "habit", + status: "done", + }, logger, ) @@ -3714,6 +3867,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "report", @@ -3744,6 +3898,7 @@ title: Tasks await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "stretch", @@ -3760,18 +3915,8 @@ title: Tasks it("does not spawn when the task's custom checkbox char is DONE-typed in the status registry", async () => { const vault = await createVault() - await writeTasksPluginConfig(vault, { - statusSettings: { - customStatuses: [ - { - symbol: "D", - name: "Deployed", - nextStatusSymbol: " ", - type: "DONE", - }, - ], - }, - }) + // The plugin file has no "D" typing; the supplied registry decides. + await writeTasksPluginConfig(vault, {}) await writeTestNote( vault, "tasks.md", @@ -3779,7 +3924,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "deploy", status: "done" }, + { + statusRegistry: DEPLOYED_DONE_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "deploy", + status: "done", + }, logger, ) @@ -3801,7 +3952,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "odd", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "odd", + status: "done", + }, logger, ) @@ -3817,7 +3974,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "board.md", blockId: "weekly", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "board.md", + blockId: "weekly", + status: "done", + }, logger, ) @@ -3840,7 +4003,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "board.md", blockId: "weekly", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "board.md", + blockId: "weekly", + status: "done", + }, logger, ) @@ -3863,6 +4032,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -3893,7 +4063,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "board.md", blockId: "weekly", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "board.md", + blockId: "weekly", + status: "done", + }, logger, ) @@ -3915,6 +4091,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "deploy", @@ -3947,7 +4124,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "board.md", blockId: "weekly", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "board.md", + blockId: "weekly", + status: "done", + }, logger, ) @@ -3973,6 +4156,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "board.md", blockId: "weekly", @@ -4001,7 +4185,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 6, status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 6, + status: "done", + }, logger, ) @@ -4021,13 +4211,25 @@ title: Tasks ) await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 5, status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 5, + status: "done", + }, logger, ) // The spawn now occupies line 5, so a blind retry completes the NEW // occurrence — the documented non-idempotence of line addressing. await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 5, status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 5, + status: "done", + }, logger, ) @@ -4046,7 +4248,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 6, status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 6, + status: "done", + }, logger, ) @@ -4066,7 +4274,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "fuzzy", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "fuzzy", + status: "done", + }, logger, ) @@ -4090,6 +4304,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "fuzzy", @@ -4117,6 +4332,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "one-shot", @@ -4141,6 +4357,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "done-habit", @@ -4160,6 +4377,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -4186,7 +4404,13 @@ title: Tasks ) await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "dv", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "dv", + status: "done", + }, logger, ) @@ -4209,6 +4433,7 @@ title: Tasks // rewrite the typed prose — the description lands byte-intact. const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "prose", @@ -4235,6 +4460,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "chore", @@ -4257,6 +4483,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -4279,6 +4506,7 @@ title: Tasks await expect( taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -4297,6 +4525,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water-plants", @@ -4325,6 +4554,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "chore", @@ -4351,6 +4581,7 @@ title: Tasks const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Weekly chore", @@ -4382,6 +4613,7 @@ title: Tasks const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Auto-clean task", @@ -4422,6 +4654,7 @@ title: Tasks await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Bad rule", @@ -4447,6 +4680,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "odd-dates", @@ -4471,6 +4705,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "check 🔁 every week with the team", @@ -4502,6 +4737,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Plain task with no signifiers", @@ -4525,6 +4761,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "check [repeat:: every week]", @@ -4546,6 +4783,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "🔁 every day", @@ -4570,6 +4808,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Water plants", @@ -4592,6 +4831,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Ship the feature", @@ -4618,6 +4858,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "my-task", @@ -4651,6 +4892,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "t1", @@ -4680,6 +4922,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "t2", @@ -4709,6 +4952,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water", @@ -4740,6 +4984,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "t1", @@ -4764,6 +5009,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "deploy", @@ -4793,6 +5039,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "t1", @@ -4819,7 +5066,13 @@ describe("round-trip advisories", () => { ) await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "fin", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "fin", + status: "done", + }, logger, ) const contentAfterFirstStamp = await readTestNote(vault, "tasks.md") @@ -4828,7 +5081,13 @@ describe("round-trip advisories", () => { ) const secondResult = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "fin", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "fin", + status: "done", + }, logger, ) const contentAfterSecondStamp = await readTestNote(vault, "tasks.md") @@ -4852,6 +5111,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "x", @@ -4877,6 +5137,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "x", @@ -4905,6 +5166,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "x", @@ -4932,6 +5194,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "x", @@ -4955,6 +5218,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "t", @@ -4986,6 +5250,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "t", @@ -5015,6 +5280,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water", @@ -5040,6 +5306,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water", @@ -5059,6 +5326,7 @@ describe("round-trip advisories", () => { const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "walk-dog", @@ -5091,6 +5359,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "plain-task", @@ -5125,6 +5394,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "has-delete", @@ -5159,6 +5429,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "has-delete", @@ -5193,6 +5464,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "plain-task", @@ -5232,6 +5504,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "has-delete", @@ -5267,6 +5540,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "has-delete", @@ -5344,6 +5618,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "delete-task", @@ -5385,6 +5660,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "keep-explicit", @@ -5426,6 +5702,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "no-field", @@ -5467,6 +5744,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "delete-task", @@ -5508,6 +5786,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "already-done", @@ -5552,6 +5831,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "parent-delete", @@ -5587,6 +5867,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "sub-delete", @@ -5623,6 +5904,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "delete-task", @@ -5674,6 +5956,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "one-child", @@ -5718,6 +6001,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water", @@ -5774,6 +6058,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "review", @@ -5829,6 +6114,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "water", @@ -5886,6 +6172,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "review", @@ -5929,18 +6216,8 @@ title: Tasks it("does not delete when the task's custom checkbox char is DONE-typed in the status registry", async () => { const vault = await createVault() - await writeTasksPluginConfig(vault, { - statusSettings: { - customStatuses: [ - { - symbol: "D", - name: "Deployed", - nextStatusSymbol: " ", - type: "DONE", - }, - ], - }, - }) + // The plugin file has no "D" typing; the supplied registry decides. + await writeTasksPluginConfig(vault, {}) await writeTestNote( vault, "tasks.md", @@ -5957,6 +6234,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEPLOYED_DONE_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "deploy-cleanup", @@ -5998,6 +6276,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "fuzzy", @@ -6027,32 +6306,8 @@ title: Tasks }) describe("NON_TASK guard", () => { - const writePluginConfig = async ( - vaultPath: string, - config: Record, - ): Promise => { - const pluginDir = join(vaultPath, ".obsidian", "plugins", "obsidian-tasks-plugin") - await mkdir(pluginDir, { recursive: true }) - await writeFile(join(pluginDir, "data.json"), JSON.stringify(config), "utf8") - } - - const NON_TASK_CONFIG = { - statusSettings: { - coreStatuses: [ - { symbol: " ", name: "Todo", nextStatusSymbol: "x", type: "TODO" }, - { symbol: "x", name: "Done", nextStatusSymbol: " ", type: "DONE" }, - ], - customStatuses: [ - { symbol: ">", name: "Forwarded", nextStatusSymbol: " ", type: "NON_TASK" }, - ], - }, - } - it("updateTask rejects a NON_TASK checkbox by block_id", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) const vault = await createVault() - await writePluginConfig(vault, NON_TASK_CONFIG) await writeTestNote( vault, "tasks.md", @@ -6061,17 +6316,20 @@ title: Tasks await expect( taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "fwd", heading: "Done" }, + { + statusRegistry: NON_TASK_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "fwd", + heading: "Done", + }, logger, ), ).rejects.toThrow('checkbox "[>]" is a NON_TASK status in the Tasks plugin registry') }) it("updateTask rejects a NON_TASK checkbox by line number", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) const vault = await createVault() - await writePluginConfig(vault, NON_TASK_CONFIG) await writeTestNote( vault, "tasks.md", @@ -6080,17 +6338,20 @@ title: Tasks await expect( taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 5, status: "done" }, + { + statusRegistry: NON_TASK_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 5, + status: "done", + }, logger, ), ).rejects.toThrow('checkbox "[>]" is a NON_TASK status in the Tasks plugin registry') }) it("createTask rejects a NON_TASK parent by block_id", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) const vault = await createVault() - await writePluginConfig(vault, NON_TASK_CONFIG) await writeTestNote( vault, "tasks.md", @@ -6100,6 +6361,7 @@ title: Tasks await expect( taskMutations.createTask( { + statusRegistry: NON_TASK_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Sub-task", @@ -6112,10 +6374,7 @@ title: Tasks }) it("createTask rejects a NON_TASK parent by line number", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) const vault = await createVault() - await writePluginConfig(vault, NON_TASK_CONFIG) await writeTestNote( vault, "tasks.md", @@ -6125,6 +6384,7 @@ title: Tasks await expect( taskMutations.createTask( { + statusRegistry: NON_TASK_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Sub-task", @@ -6137,10 +6397,7 @@ title: Tasks }) it("allows updating a normal task when NON_TASK statuses exist", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) const vault = await createVault() - await writePluginConfig(vault, NON_TASK_CONFIG) await writeTestNote( vault, "tasks.md", @@ -6148,7 +6405,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "normal", status: "done" }, + { + statusRegistry: NON_TASK_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "normal", + status: "done", + }, logger, ) @@ -6167,10 +6430,7 @@ title: Tasks }) it("appendSubtasks excludes NON_TASK children from the count", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) const vault = await createVault() - await writePluginConfig(vault, NON_TASK_CONFIG) await writeTestNote( vault, "tasks.md", @@ -6179,6 +6439,7 @@ title: Tasks const result = await taskMutations.updateTask( { + statusRegistry: NON_TASK_REGISTRY, vaultPath: vault, path: "tasks.md", blockId: "card", @@ -6197,29 +6458,14 @@ title: Tasks }) describe("charForStatus throw at mutation boundary", () => { - const writePluginConfig = async ( - vaultPath: string, - config: Record, - ): Promise => { - const pluginDir = join(vaultPath, ".obsidian", "plugins", "obsidian-tasks-plugin") - await mkdir(pluginDir, { recursive: true }) - await writeFile(join(pluginDir, "data.json"), JSON.stringify(config), "utf8") - } + /** The defaults with "-" retyped as a plain TODO, so no char is cancelled. */ + const PENDING_DASH_REGISTRY: ReadonlyMap = new Map([ + ...DEFAULT_STATUS_REGISTRY, + ["-", "todo"], + ]) it("updateTask rejects status change when the fallback char is retyped", async () => { - resetTaskFormatConfigCache() - onTestFinished(resetTaskFormatConfigCache) - const vault = await createVault() - await writePluginConfig(vault, { - statusSettings: { - coreStatuses: [ - { symbol: " ", name: "Todo", nextStatusSymbol: "x", type: "TODO" }, - { symbol: "x", name: "Done", nextStatusSymbol: " ", type: "DONE" }, - { symbol: "-", name: "Pending", nextStatusSymbol: " ", type: "TODO" }, - ], - customStatuses: [], - }, - }) + const vault = await createVault() await writeTestNote( vault, "tasks.md", @@ -6228,7 +6474,13 @@ title: Tasks await expect( taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "task", status: "cancelled" }, + { + statusRegistry: PENDING_DASH_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "task", + status: "cancelled", + }, logger, ), ).rejects.toThrow( @@ -6237,6 +6489,79 @@ title: Tasks }) }) + describe("supplied registry over the plugin file's", () => { + const PENDING_DASH_PLUGIN_CONFIG = { + statusSettings: { + coreStatuses: [ + { symbol: " ", name: "Todo", nextStatusSymbol: "x", type: "TODO" }, + { symbol: "x", name: "Done", nextStatusSymbol: " ", type: "DONE" }, + { symbol: "-", name: "Pending", nextStatusSymbol: " ", type: "TODO" }, + ], + customStatuses: [ + { symbol: ">", name: "Forwarded", nextStatusSymbol: " ", type: "NON_TASK" }, + ], + }, + } + + it("updateTask writes the cancelled char the supplied registry has, though the file retypes it", async () => { + const vault = await createVault() + await writeTasksPluginConfig(vault, PENDING_DASH_PLUGIN_CONFIG) + await writeTestNote( + vault, + "tasks.md", + `---\ntitle: Tasks\n---\n\n- [ ] Task ➕ 2026-07-01 ^task\n`, + ) + + const result = await taskMutations.updateTask( + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "task", + status: "cancelled", + }, + logger, + ) + + expect(result.changes).toEqual(["status: todo → cancelled"]) + const content = await readTestNote(vault, "tasks.md") + expect(content).toBe( + `---\ntitle: Tasks\n---\n\n- [-] Task ➕ 2026-07-01 ❌ ${today()} ^task\n`, + ) + }) + + it("createTask counts a checkbox the file types NON_TASK as a card when the supplied registry does not", async () => { + const vault = await createVault() + await writeTasksPluginConfig(vault, PENDING_DASH_PLUGIN_CONFIG) + await writeTestNote( + vault, + "board.md", + `---\ntitle: Board\nkanban-plugin: board\n---\n\n## Active\n\n- [ ] Card A ^card-a\n- [>] Forwarded ref\n- [ ] Card B ^card-b\n`, + ) + + const result = await taskMutations.createTask( + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "board.md", + description: "Inserted", + blockId: "inserted", + heading: "Active", + position: 2, + }, + logger, + ) + + // Position 2 lands above the [>] line, which the supplied registry + // reads as an ordinary todo card. + expect(result.line).toBe(9) + const content = await readTestNote(vault, "board.md") + expect(content).toBe( + `---\ntitle: Board\nkanban-plugin: board\n---\n\n## Active\n\n- [ ] Card A ^card-a\n- [ ] Inserted ➕ ${today()} ^inserted\n- [>] Forwarded ref\n- [ ] Card B ^card-b\n`, + ) + }) + }) + describe("fenced/comment task guard", () => { it("updateTask rejects a task inside a fenced code block", async () => { const vault = await createVault() @@ -6248,7 +6573,13 @@ title: Tasks await expect( taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "example", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "example", + status: "done", + }, logger, ), ).rejects.toThrow('blockId "example" is inside a fenced code block or comment in "tasks.md"') @@ -6265,6 +6596,7 @@ title: Tasks await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Child", @@ -6287,7 +6619,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "real", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "real", + status: "done", + }, logger, ) @@ -6304,7 +6642,13 @@ title: Tasks ) const result = await taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", blockId: "shared-id", status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + blockId: "shared-id", + status: "done", + }, logger, ) @@ -6322,6 +6666,7 @@ title: Tasks const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Child task", @@ -6344,6 +6689,7 @@ title: Tasks const result = await taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "New task", @@ -6366,7 +6712,13 @@ title: Tasks await expect( taskMutations.updateTask( - { vaultPath: vault, path: "tasks.md", line: 6, status: "done" }, + { + statusRegistry: DEFAULT_STATUS_REGISTRY, + vaultPath: vault, + path: "tasks.md", + line: 6, + status: "done", + }, logger, ), ).rejects.toThrow("line 6 is inside a fenced code block or comment") @@ -6383,6 +6735,7 @@ title: Tasks await expect( taskMutations.createTask( { + statusRegistry: DEFAULT_STATUS_REGISTRY, vaultPath: vault, path: "tasks.md", description: "Child", diff --git a/src/vault-mcp/vault-operations/__tests__/trash-config.test.ts b/src/vault-mcp/vault-operations/__tests__/trash-config.test.ts index 522d2a064..9b11848f6 100644 --- a/src/vault-mcp/vault-operations/__tests__/trash-config.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/trash-config.test.ts @@ -96,6 +96,19 @@ describe("readTrashConfig", () => { expect(result).toBe("system") }) + it('defaults to "system" when .obsidian is a file rather than a folder', async () => { + const vault = await createVault() + await writeFile(join(vault, ".obsidian"), "not a folder", "utf8") + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const result = await readTrashConfig(vault, logger) + + // No settings exist, so nothing is wrong to warn about. + expect(result).toBe("system") + expect(warnSpy).not.toHaveBeenCalled() + }) + it('throws on malformed JSON instead of falling back to "system"', async () => { const vault = await createVault() const obsidianDir = join(vault, ".obsidian") diff --git a/src/vault-mcp/vault-operations/daily-notes.ts b/src/vault-mcp/vault-operations/daily-notes.ts index c6693d898..067804ffc 100644 --- a/src/vault-mcp/vault-operations/daily-notes.ts +++ b/src/vault-mcp/vault-operations/daily-notes.ts @@ -1,11 +1,11 @@ import { readFile } from "node:fs/promises" import { join } from "node:path" import { DateTime } from "luxon" -import { logger, type Logger } from "../../logger.js" +import type { Logger } from "../../logger.js" import { vaultFs } from "./vault-filesystem.js" import { momentToLuxonFormat, findUnsupportedTokens } from "../obsidian-markdown/moment-format.js" import { describeError } from "../../utils/describe-error.js" -import { isErrnoException } from "../../utils/is-errno-exception.js" +import { isMissingPathError } from "../../utils/fs.js" // ── Config reading ────────────────────────────────────────────── @@ -29,26 +29,18 @@ const FALLBACK_CONFIG: DailyNotesConfig = { format: "YYYY-MM-DD", } -// TODO: Consider refactoring to factory/closure pattern (like createSearchIndex, -// createMemoryStore) so the cache lives in the closure instead of at module scope. -// Caches only SUCCESSFUL reads. Fallbacks are never cached: on a fresh -// remote deploy the config file can arrive after boot (initial sync still -// running), and retrying each call picks it up without a restart. -let cachedFileConfig: DailyNotesConfig | null = null - -/** Reads .obsidian/daily-notes.json, caching only successful reads. - * Returns the fallback config (uncached — see cache comment) when the - * file is missing or malformed. */ -const readDailyNotesFileConfig = async (vaultPath: string): Promise => { - if (cachedFileConfig) return cachedFileConfig - +/** Reads and parses .obsidian/daily-notes.json. Returns the fallbacks when + * no file exists there; any other failure propagates. */ +const readDailyNotesFile = async (vaultPath: string): Promise => { try { + // Read on every call, so a folder or format change in Obsidian applies + // to the next operation rather than after a restart. const configFileContent = await readFile( join(vaultPath, ".obsidian", "daily-notes.json"), "utf8", ) const parsedConfig: Record = JSON.parse(configFileContent) - const fileConfig = { + return { folder: typeof parsedConfig.folder === "string" && parsedConfig.folder.length > 0 ? parsedConfig.folder @@ -58,14 +50,48 @@ const readDailyNotesFileConfig = async (vaultPath: string): Promise => { + try { + return await readDailyNotesFile(vaultPath) + } catch (error) { + // A fallback here would protect "Daily Notes" while the user's folder is + // whatever the broken file says. The warn carries the raw cause; only the + // thrown message reaches the client. + logger.warn("cannot read daily notes config", { error: describeError(error) }) + throw new Error("cannot read daily notes config from .obsidian/daily-notes.json", { + cause: error, + }) + } +} + +/** The file config, or the fallbacks with a `warn` when the file exists but + * cannot be read or parsed — for reads and prompts, where a wrong folder + * costs a missed note, not a deleted one. */ +const readDailyNotesFileConfigOrFallback = async ( + vaultPath: string, + logger: Logger, +): Promise => { + try { + return await readDailyNotesFile(vaultPath) + } catch (error) { + logger.warn("cannot read daily notes config, using defaults", { + error: describeError(error), + }) return { ...FALLBACK_CONFIG } } } @@ -73,17 +99,23 @@ const readDailyNotesFileConfig = async (vaultPath: string): Promise => { + const { vaultPath, envSettings } = params + // Both fields set via env — the file can't contribute anything, skip I/O. if (envSettings?.folder && envSettings.format) { return { folder: envSettings.folder, format: envSettings.format } } - const fileConfig = await readDailyNotesFileConfig(vaultPath) + const fileConfig = await readDailyNotesFileConfigOrFallback(vaultPath, logger) return { folder: envSettings?.folder ?? fileConfig.folder, format: envSettings?.format ?? fileConfig.format, @@ -98,13 +130,16 @@ const STRICT_ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ /** Resolves a date to a vault-relative daily note path using the env * settings, the vault's .obsidian/daily-notes.json config, and * the fallbacks — in that per-field precedence order. */ -export const getDailyNotePath = async (params: { - vaultPath: string - date?: string | undefined - envSettings?: DailyNotesEnvSettings | undefined -}): Promise => { +export const getDailyNotePath = async ( + params: { + vaultPath: string + date?: string | undefined + envSettings?: DailyNotesEnvSettings | undefined + }, + logger: Logger, +): Promise => { const { vaultPath, date, envSettings } = params - const config = await readDailyNotesConfig(vaultPath, envSettings) + const config = await readDailyNotesConfig({ vaultPath, envSettings }, logger) const unsupportedTokens = findUnsupportedTokens(config.format) @@ -146,11 +181,14 @@ export const getDailyNote = async ( }, logger: Logger, ): Promise => { - const path = await getDailyNotePath({ - vaultPath: params.vaultPath, - date: params.date, - envSettings: params.envSettings, - }) + const path = await getDailyNotePath( + { + vaultPath: params.vaultPath, + date: params.date, + envSettings: params.envSettings, + }, + logger, + ) try { const content = await vaultFs.readNote({ vaultPath: params.vaultPath, path }, logger) diff --git a/src/vault-mcp/vault-operations/task-format-config.ts b/src/vault-mcp/vault-operations/task-format-config.ts index 517435c2f..f7aad4717 100644 --- a/src/vault-mcp/vault-operations/task-format-config.ts +++ b/src/vault-mcp/vault-operations/task-format-config.ts @@ -8,9 +8,10 @@ import { readFile } from "node:fs/promises" import { join } from "node:path" -import { logger } from "../../logger.js" +import type { Logger } from "../../logger.js" +import { DEFAULT_STATUS_REGISTRY } from "../obsidian-markdown/tasks.js" import { describeError } from "../../utils/describe-error.js" -import { isErrnoException } from "../../utils/is-errno-exception.js" +import { isMissingPathError } from "../../utils/fs.js" // ── Types ─────────────────────────────────────────────────────── @@ -40,14 +41,6 @@ export type TaskFormatConfig = { // ── Defaults ──────────────────────────────────────────────────── -const DEFAULT_STATUS_REGISTRY: ReadonlyMap = new Map([ - [" ", "todo"], - ["x", "done"], - ["X", "done"], - ["/", "in_progress"], - ["-", "cancelled"], -]) - const DEFAULTS: TaskFormatConfig = { taskFormat: "emoji", setDoneDate: true, @@ -120,11 +113,6 @@ const statusRegistryFrom = ( // ── Config reader ─────────────────────────────────────────────── -// Caches only SUCCESSFUL reads — same pattern and rationale as -// daily-notes.ts: uncached fallbacks are retried, so a plugin config -// arriving after boot is picked up without a restart. -let cachedConfig: TaskFormatConfig | null = null - /** The config keys that hold booleans — the settings file uses the same * key names. */ type BooleanSettingKey = @@ -141,22 +129,21 @@ const booleanSetting = (parsed: Record, key: BooleanSettingKey) return typeof value === "boolean" ? value : DEFAULTS[key] } -/** Reads the Tasks plugin's settings from - * `.obsidian/plugins/obsidian-tasks-plugin/data.json`. Falls back to the - * plugin's defaults (uncached — see cache comment) when the file is - * missing or malformed. */ -export const readTaskFormatConfig = async (vaultPath: string): Promise => { - if (cachedConfig) return cachedConfig +/** Reads and parses the plugin's data.json. Returns the defaults when no + * file exists there; any other failure propagates. */ +const readTaskFormatFileConfig = async (vaultPath: string): Promise => { + const configPath = join(vaultPath, ".obsidian", "plugins", "obsidian-tasks-plugin", "data.json") try { - const configPath = join(vaultPath, ".obsidian", "plugins", "obsidian-tasks-plugin", "data.json") + // Read on every call, so a format or date-toggle change in the plugin's + // settings applies to the next task write rather than after a restart. const fileContent = await readFile(configPath, "utf8") const parsed: Record = JSON.parse(fileContent) const rawFormat = parsed.taskFormat const taskFormat: "emoji" | "dataview" = rawFormat === "dataview" ? "dataview" : "emoji" - const fileConfig: TaskFormatConfig = { + return { taskFormat, setDoneDate: booleanSetting(parsed, "setDoneDate"), setCancelledDate: booleanSetting(parsed, "setCancelledDate"), @@ -165,19 +152,29 @@ export const readTaskFormatConfig = async (vaultPath: string): Promise { - cachedConfig = null +/** Reads the Tasks plugin's settings from + * `.obsidian/plugins/obsidian-tasks-plugin/data.json` on every call. + * Returns the plugin's defaults when the file is missing, and — with a + * `warn` on the caller's logger — when it exists but cannot be read or + * parsed. */ +export const readTaskFormatConfig = async ( + vaultPath: string, + logger: Logger, +): Promise => { + try { + return await readTaskFormatFileConfig(vaultPath) + } catch (error) { + // The settings only shape what a task write emits, so the defaults are + // a safe stand-in while the file is broken. + logger.warn("cannot read Tasks plugin config, using defaults", { + error: describeError(error), + }) + return { ...DEFAULTS } + } } diff --git a/src/vault-mcp/vault-operations/task-mutations.ts b/src/vault-mcp/vault-operations/task-mutations.ts index fc06dfb5f..b4bad201d 100644 --- a/src/vault-mcp/vault-operations/task-mutations.ts +++ b/src/vault-mcp/vault-operations/task-mutations.ts @@ -41,6 +41,11 @@ import type { Logger } from "../../logger.js" type CreateTaskParams = { vaultPath: string path: string + /** The status registry the task index classifies with, so a write never + * places or types a checkbox differently than a listing reads it. The + * plugin file's registry is ignored on writes; its format and date + * toggles are read live. */ + statusRegistry: ReadonlyMap description: string blockId: string heading?: string | undefined @@ -83,6 +88,8 @@ type CreateTaskResult = { type UpdateTaskParams = { vaultPath: string path: string + /** See CreateTaskParams.statusRegistry. */ + statusRegistry: ReadonlyMap blockId?: string | undefined line?: number | undefined status?: TaskStatus | undefined @@ -1367,6 +1374,7 @@ const createTask = async (params: CreateTaskParams, logger: Logger): Promise Date: Wed, 30 Sep 2026 18:27:18 -0400 Subject: [PATCH 02/10] style: reader-map comment in daily-notes, destructure boot registry in server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: code-quality · claude-opus-4-6 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/vault-mcp/server.ts | 6 ++++-- src/vault-mcp/vault-operations/daily-notes.ts | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vault-mcp/server.ts b/src/vault-mcp/server.ts index 26f108667..0e2216b2e 100644 --- a/src/vault-mcp/server.ts +++ b/src/vault-mcp/server.ts @@ -138,11 +138,13 @@ const startServer = async (): Promise => { const embedder = config.embeddingEnabled ? createEmbedder(logger) : undefined const reranker = config.embeddingEnabled && config.rerankMode === "blended" ? createReranker(logger) : undefined - const taskFormatConfig = await readTaskFormatConfig(vaultPath, logger) + // The status registry is captured at boot so listings and writes classify + // with one map; format and date toggles are read live per task write. + const { statusRegistry } = await readTaskFormatConfig(vaultPath, logger) const search = createSearchIndex(searchDbPath, embedder, reranker, { memoryDir: config.memoryEnabled ? config.memoryDir : undefined, fileToolsEnabled: config.fileToolsEnabled, - statusRegistry: taskFormatConfig.statusRegistry, + statusRegistry, }) const { count } = await search.rebuildFromVault({ vaultPath }, logger) logger.info("initial index built", { count }) diff --git a/src/vault-mcp/vault-operations/daily-notes.ts b/src/vault-mcp/vault-operations/daily-notes.ts index 067804ffc..ab45be1df 100644 --- a/src/vault-mcp/vault-operations/daily-notes.ts +++ b/src/vault-mcp/vault-operations/daily-notes.ts @@ -8,6 +8,11 @@ import { describeError } from "../../utils/describe-error.js" import { isMissingPathError } from "../../utils/fs.js" // ── Config reading ────────────────────────────────────────────── +// +// Three readers, each wrapping readDailyNotesFile: +// readDailyNotesFileConfig — strict: throws on an unreadable file (for the delete/move guard) +// readDailyNotesFileConfigOrFallback — tolerant: warns + falls back (for reads and prompts) +// readDailyNotesConfig — public: env → file → fallbacks, uses the tolerant reader type DailyNotesConfig = { folder: string From 5cf72585942f6c29658dc91498bb0fa96048344a Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:28:51 -0400 Subject: [PATCH 03/10] style: name the mid-walk directory check, list moveTaskBlock's phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two readability pauses from the stranger read: readdirOrNull's re-stat result gets a named boolean, and moveTaskBlock's docstring numbers the extract-reinsert steps. Ship-Check: triage · claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 --- src/utils/fs.ts | 3 ++- src/vault-mcp/vault-operations/task-mutations.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 7481f5549..2fbb724da 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -59,8 +59,9 @@ export const readdirOrNull = async (path: string): Promise => { // while it runs. Null would report that listing as empty, so it is // returned only when the listed path itself is not a folder. const pathStats = await statOrNull(path) + const listedPathIsDirectory = pathStats?.isDirectory() ?? false - if (pathStats?.isDirectory()) throw error + if (listedPathIsDirectory) throw error return null } } diff --git a/src/vault-mcp/vault-operations/task-mutations.ts b/src/vault-mcp/vault-operations/task-mutations.ts index b4bad201d..061cae7e0 100644 --- a/src/vault-mcp/vault-operations/task-mutations.ts +++ b/src/vault-mcp/vault-operations/task-mutations.ts @@ -837,7 +837,11 @@ const isInsideFenceOrComment = (bodyLines: readonly string[], lineIndex: number) /** No-op when the task already sits under the target heading and no * explicit position is requested. With a position, same-lane reorders - * go through the extract-reinsert cycle. */ + * go through the extract-reinsert cycle: + * 1. Splice the task and its sub-items out. + * 2. Re-parse the headings, since every line below the block moved up. + * 3. Splice the block in at the target slot. + * 4. Re-parse again to report the card's position in the result. */ const moveTaskBlock = ({ lines, taskLineIndex, From 8403d8dc941d90a981800fa05ebe3821b9b32b8c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:44:24 -0400 Subject: [PATCH 04/10] fix(config): read a parseable non-object settings file as no settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A daily-notes.json or Tasks data.json holding the JSON literal null parsed but threw on the first property access, so the guard refused every delete and move as "unreadable" while [] or a bare string took the fallbacks. Both readers now shape-check the parse result through a shared isRecord guard, matching the trash reader. Ship-Check: pr-monitor · claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 --- src/utils/is-record.ts | 5 ++++ .../__tests__/daily-notes.test.ts | 24 +++++++++++++++++++ .../__tests__/task-format-config.test.ts | 14 +++++++++++ src/vault-mcp/vault-operations/daily-notes.ts | 16 ++++++++----- .../vault-operations/task-format-config.ts | 22 ++++++++--------- 5 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 src/utils/is-record.ts diff --git a/src/utils/is-record.ts b/src/utils/is-record.ts new file mode 100644 index 000000000..05f2f4856 --- /dev/null +++ b/src/utils/is-record.ts @@ -0,0 +1,5 @@ +/** True for a non-null object, so parsed JSON can be read by key. Arrays pass + * too, since they are objects whose named keys read as undefined. */ +export const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null +} diff --git a/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts b/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts index bd9beee0a..a08e0c3cb 100644 --- a/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts @@ -98,6 +98,18 @@ describe("readDailyNotesConfig", () => { expect(config).toEqual(FALLBACK_CONFIG) }) + it("reads a file holding the JSON literal null as no settings, without a warn", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, null) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual(FALLBACK_CONFIG) + expect(warnSpy).not.toHaveBeenCalled() + }) + it("uses default format when config has empty format string", async () => { const vaultDir = await createVault() await writeDailyNotesConfig(vaultDir, { folder: "Journal" }) @@ -255,6 +267,18 @@ describe("readDailyNotesFileConfig", () => { expect(config).toEqual(FALLBACK_CONFIG) }) + it("reads a file holding the JSON literal null as no settings, not as unreadable", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, null) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesFileConfig(vaultDir, logger) + + expect(config).toEqual(FALLBACK_CONFIG) + expect(warnSpy).not.toHaveBeenCalled() + }) + it("throws on malformed JSON and warns on the caller's logger, not the root logger", async () => { const vaultDir = await createVault() const malformedJson = "not valid json{{{" diff --git a/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts b/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts index bd82d567a..7adef1ba8 100644 --- a/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/task-format-config.test.ts @@ -237,6 +237,20 @@ describe("readTaskFormatConfig", () => { expect(config).toEqual(DEFAULT_CONFIG) }) + it("reads a file holding the JSON literal null as no settings, without a warn", async () => { + const vault = await createVault() + const pluginDir = join(vault, ".obsidian", "plugins", "obsidian-tasks-plugin") + await mkdir(pluginDir, { recursive: true }) + await writeFile(join(pluginDir, "data.json"), "null", "utf8") + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readTaskFormatConfig(vault, logger) + + expect(config).toEqual(DEFAULT_CONFIG) + expect(warnSpy).not.toHaveBeenCalled() + }) + it("falls back to defaults when .obsidian is a file rather than a folder", async () => { const vault = await createVault() await writeFile(join(vault, ".obsidian"), "not a folder", "utf8") diff --git a/src/vault-mcp/vault-operations/daily-notes.ts b/src/vault-mcp/vault-operations/daily-notes.ts index ab45be1df..5c254a413 100644 --- a/src/vault-mcp/vault-operations/daily-notes.ts +++ b/src/vault-mcp/vault-operations/daily-notes.ts @@ -6,6 +6,7 @@ import { vaultFs } from "./vault-filesystem.js" import { momentToLuxonFormat, findUnsupportedTokens } from "../obsidian-markdown/moment-format.js" import { describeError } from "../../utils/describe-error.js" import { isMissingPathError } from "../../utils/fs.js" +import { isRecord } from "../../utils/is-record.js" // ── Config reading ────────────────────────────────────────────── // @@ -35,7 +36,8 @@ const FALLBACK_CONFIG: DailyNotesConfig = { } /** Reads and parses .obsidian/daily-notes.json. Returns the fallbacks when - * no file exists there; any other failure propagates. */ + * no file exists there, and for each field the file does not set; any + * other failure propagates. */ const readDailyNotesFile = async (vaultPath: string): Promise => { try { // Read on every call, so a folder or format change in Obsidian applies @@ -44,15 +46,17 @@ const readDailyNotesFile = async (vaultPath: string): Promise join(vaultPath, ".obsidian", "daily-notes.json"), "utf8", ) - const parsedConfig: Record = JSON.parse(configFileContent) + const parsedConfig: unknown = JSON.parse(configFileContent) + // Valid JSON can be `null` or a bare value, which has no keys to read. + const settings = isRecord(parsedConfig) ? parsedConfig : {} return { folder: - typeof parsedConfig.folder === "string" && parsedConfig.folder.length > 0 - ? parsedConfig.folder + typeof settings.folder === "string" && settings.folder.length > 0 + ? settings.folder : FALLBACK_CONFIG.folder, format: - typeof parsedConfig.format === "string" && parsedConfig.format.length > 0 - ? parsedConfig.format + typeof settings.format === "string" && settings.format.length > 0 + ? settings.format : FALLBACK_CONFIG.format, } } catch (error) { diff --git a/src/vault-mcp/vault-operations/task-format-config.ts b/src/vault-mcp/vault-operations/task-format-config.ts index f7aad4717..141c13079 100644 --- a/src/vault-mcp/vault-operations/task-format-config.ts +++ b/src/vault-mcp/vault-operations/task-format-config.ts @@ -12,6 +12,7 @@ import type { Logger } from "../../logger.js" import { DEFAULT_STATUS_REGISTRY } from "../obsidian-markdown/tasks.js" import { describeError } from "../../utils/describe-error.js" import { isMissingPathError } from "../../utils/fs.js" +import { isRecord } from "../../utils/is-record.js" // ── Types ─────────────────────────────────────────────────────── @@ -53,9 +54,6 @@ const DEFAULTS: TaskFormatConfig = { // ── Status-registry parsing ───────────────────────────────────── -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null - /** Maps the plugin's type strings to our status classification. */ const pluginTypeToClassification = (pluginType: string): StatusClassification | null => { switch (pluginType) { @@ -138,19 +136,21 @@ const readTaskFormatFileConfig = async (vaultPath: string): Promise = JSON.parse(fileContent) + const parsed: unknown = JSON.parse(fileContent) + // Valid JSON can be `null` or a bare value, which has no keys to read. + const settings = isRecord(parsed) ? parsed : {} - const rawFormat = parsed.taskFormat + const rawFormat = settings.taskFormat const taskFormat: "emoji" | "dataview" = rawFormat === "dataview" ? "dataview" : "emoji" return { taskFormat, - setDoneDate: booleanSetting(parsed, "setDoneDate"), - setCancelledDate: booleanSetting(parsed, "setCancelledDate"), - setCreatedDate: booleanSetting(parsed, "setCreatedDate"), - recurrenceOnNextLine: booleanSetting(parsed, "recurrenceOnNextLine"), - removeScheduledDateOnRecurrence: booleanSetting(parsed, "removeScheduledDateOnRecurrence"), - statusRegistry: statusRegistryFrom(parsed), + setDoneDate: booleanSetting(settings, "setDoneDate"), + setCancelledDate: booleanSetting(settings, "setCancelledDate"), + setCreatedDate: booleanSetting(settings, "setCreatedDate"), + recurrenceOnNextLine: booleanSetting(settings, "recurrenceOnNextLine"), + removeScheduledDateOnRecurrence: booleanSetting(settings, "removeScheduledDateOnRecurrence"), + statusRegistry: statusRegistryFrom(settings), } } catch (error) { if (isMissingPathError(error)) return { ...DEFAULTS } From 7dba637843cdcf87e209ce0ff01edf41f8b01705 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:45:47 -0400 Subject: [PATCH 05/10] refactor: share the isRecord guard between search-helpers and the config readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search-helpers.ts carried its own copy of the plain-object predicate; it now imports src/utils/is-record.ts, which excludes arrays as that copy did. canvas.ts keeps its own because obsidian-markdown/ is a leaf layer that may not import utils/ at runtime. Ship-Check: pr-monitor · claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 --- src/utils/is-record.ts | 6 +++--- src/vault-mcp/search/search-helpers.ts | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/utils/is-record.ts b/src/utils/is-record.ts index 05f2f4856..e47675457 100644 --- a/src/utils/is-record.ts +++ b/src/utils/is-record.ts @@ -1,5 +1,5 @@ -/** True for a non-null object, so parsed JSON can be read by key. Arrays pass - * too, since they are objects whose named keys read as undefined. */ +/** True for a plain object (not null, not an array), so parsed JSON can be + * read by key. */ export const isRecord = (value: unknown): value is Record => { - return typeof value === "object" && value !== null + return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/src/vault-mcp/search/search-helpers.ts b/src/vault-mcp/search/search-helpers.ts index 1e9ca79a6..c4cfad1ad 100644 --- a/src/vault-mcp/search/search-helpers.ts +++ b/src/vault-mcp/search/search-helpers.ts @@ -5,6 +5,7 @@ import { DateTime } from "luxon" import { mtimeToIso } from "../../utils/mtime-to-iso.js" import type { LeadingCallout } from "../obsidian-markdown/callouts.js" import { foldAsciiCase } from "../obsidian-markdown/links.js" +import { isRecord } from "../../utils/is-record.js" import type { NoteRow, NoteMetadata, @@ -29,9 +30,6 @@ export const coerceToArray = (value: unknown): string[] => { // ── JSON column parsers (private) ────────────────────────────── -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - /** Parses a JSON column that must contain a string array (tags, related, * depends_on). Throws on corruption — these columns are serialized by the * indexer, so a non-array value indicates index corruption. */ From a4ae3f599a41944cc84748cb4ebf58c92e1e2191 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:49:58 -0400 Subject: [PATCH 06/10] refactor(vault-crud): named params for resolveEffectiveProtectedPaths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard grew a third positional argument with the caller's logger; it now takes `{ config, vaultPath }` plus the logger, the data layer's (params, logger) shape. Ship-Check: pr-monitor · claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 --- .../tools/__tests__/vault-crud-tools.test.ts | 21 +++++++++++-------- .../mcp-core/tools/vault-crud-tools.ts | 10 +++++---- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts b/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts index 2d3949df6..edba05d50 100644 --- a/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts +++ b/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts @@ -33,7 +33,7 @@ describe("resolveEffectiveProtectedPaths", () => { it("returns the user's list unchanged when PROTECTED_PATHS is set", async () => { const config = makeConfig({ protectedPathsOverride: ["Secrets", "Custom"] }) - const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) + const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) expect(result).toEqual(["Secrets", "Custom"]) expect(mockedReadDailyNotesFileConfig).not.toHaveBeenCalled() @@ -46,7 +46,10 @@ describe("resolveEffectiveProtectedPaths", () => { }) const requestLogger = logger.child({ requestId: "request-1" }) const config = makeConfig() - const result = await resolveEffectiveProtectedPaths(config, "/vault", requestLogger) + const result = await resolveEffectiveProtectedPaths( + { config, vaultPath: "/vault" }, + requestLogger, + ) expect(result).toEqual(["About Me", "Journal"]) expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledTimes(1) @@ -59,7 +62,7 @@ describe("resolveEffectiveProtectedPaths", () => { format: "YYYY-MM-DD", }) const config = makeConfig({ memoryDir: "Profile" }) - const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) + const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) expect(result).toEqual(["Profile", "Daily Notes"]) }) @@ -70,14 +73,14 @@ describe("resolveEffectiveProtectedPaths", () => { format: "YYYY-MM-DD", }) const config = makeConfig() - const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) + const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) expect(result).toEqual(["About Me"]) }) it("protects DAILY_NOTES_FOLDER without reading the file, even when the format is unset", async () => { const config = makeConfig({ dailyNotesFolder: "Journal" }) - const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) + const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) expect(result).toEqual(["About Me", "Journal"]) expect(mockedReadDailyNotesFileConfig).not.toHaveBeenCalled() @@ -89,7 +92,7 @@ describe("resolveEffectiveProtectedPaths", () => { format: "YYYY-MM-DD", }) const config = makeConfig({ dailyNotesFormat: "DD-MM-YYYY" }) - const result = await resolveEffectiveProtectedPaths(config, "/vault", logger) + const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) expect(result).toEqual(["About Me", "Journal"]) expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledTimes(1) @@ -101,8 +104,8 @@ describe("resolveEffectiveProtectedPaths", () => { ) const config = makeConfig() - await expect(resolveEffectiveProtectedPaths(config, "/vault", logger)).rejects.toThrow( - new Error("cannot read daily notes config from .obsidian/daily-notes.json"), - ) + await expect( + resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger), + ).rejects.toThrow(new Error("cannot read daily notes config from .obsidian/daily-notes.json")) }) }) diff --git a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts index 6c966a191..d8d6c4397 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -41,8 +41,7 @@ const describeDisplacedLeadingContent = ({ * it names is then unknown; DAILY_NOTES_FOLDER or PROTECTED_PATHS bypasses * the file. */ export const resolveEffectiveProtectedPaths = async ( - config: VaultConfig, - vaultPath: string, + { config, vaultPath }: { config: VaultConfig; vaultPath: string }, logger: Logger, ): Promise => { if (config.protectedPathsOverride) return config.protectedPathsOverride @@ -987,7 +986,10 @@ Returns: Confirmation message naming the outcome — "Deleted" for permanent rem return safeHandler( reqLogger, async () => { - const protectedPaths = await resolveEffectiveProtectedPaths(config, vaultPath, reqLogger) + const protectedPaths = await resolveEffectiveProtectedPaths( + { config, vaultPath }, + reqLogger, + ) // Under Obsidian Sync the setting is skipped and the note is deleted // for good ("none", since "system" would land in .trash/): a @@ -1115,7 +1117,7 @@ Returns: JSON with moved_to (the new path), links_updated (count of link occurre const [allNotePaths, allAssetPaths, protectedPaths] = await Promise.all([ vaultFs.listNotes({ vaultPath }, reqLogger), vaultFs.listAssets({ vaultPath }, reqLogger), - resolveEffectiveProtectedPaths(config, vaultPath, reqLogger), + resolveEffectiveProtectedPaths({ config, vaultPath }, reqLogger), ]) return noteMover.moveNote( { From 1c60ec0945b4b96dc01fa01c7a5312e993e842ea Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:55:01 -0400 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20readTaskFormatFileConfig=20docstri?= =?UTF-8?q?ng=20=E2=80=94=20match=20the=20daily-notes=20per-field=20fallba?= =?UTF-8?q?ck=20clause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: bug-check · claude-opus-4-6 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/vault-mcp/vault-operations/task-format-config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vault-mcp/vault-operations/task-format-config.ts b/src/vault-mcp/vault-operations/task-format-config.ts index 141c13079..2597f0724 100644 --- a/src/vault-mcp/vault-operations/task-format-config.ts +++ b/src/vault-mcp/vault-operations/task-format-config.ts @@ -128,7 +128,8 @@ const booleanSetting = (parsed: Record, key: BooleanSettingKey) } /** Reads and parses the plugin's data.json. Returns the defaults when no - * file exists there; any other failure propagates. */ + * file exists there, and for each field the file does not set; any other + * failure propagates. */ const readTaskFormatFileConfig = async (vaultPath: string): Promise => { const configPath = join(vaultPath, ".obsidian", "plugins", "obsidian-tasks-plugin", "data.json") From ddf25d9e36b643ae3aa3a91551d2c40cc162f21b Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:00:40 -0400 Subject: [PATCH 08/10] docs: list the protected-folder and daily-notes rules in ARCHITECTURE.md The delete guard and daily-notes paragraphs each ran several rules together; each is now a lead line plus labelled bullets, like the vault_move_note block beside them. The Protected paths bullet under Data integrity names .obsidian/daily-notes.json as a source of the default folder, matching the guard. Co-Authored-By: Claude Fable 5.1 --- ARCHITECTURE.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 566365027..ca74072a7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,7 +167,15 @@ The edit tools differ in how they locate the lines they change — by heading, b The three anchor tools share one resolution rule: a short, case-sensitive substring locates a full line, ambiguity is an error, and `first_match` takes the first match instead. -`vault_delete_note` refuses paths under protected folders as a server-side guardrail. The default protected set is the memory dir plus the daily notes folder, read on every delete and move from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`). A `daily-notes.json` that exists but cannot be read or parsed refuses the delete or move, since the folder it protects is unknown; `DAILY_NOTES_FOLDER` or `PROTECTED_PATHS` bypasses the file. `PROTECTED_PATHS` overrides the default entirely. Use `vault_delete_memory` for individual entries in memory files. `vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`. +`vault_delete_note` refuses paths under protected folders as a server-side guardrail: + +- **Default set:** the memory dir plus the daily notes folder +- **Daily notes folder:** read on every delete and move, from `DAILY_NOTES_FOLDER`, then `.obsidian/daily-notes.json`, then the default `Daily Notes` +- **Unreadable `daily-notes.json`:** a file that exists but cannot be read or parsed refuses the delete or move, because the folder to protect is unknown; setting `DAILY_NOTES_FOLDER` skips the file +- **Override:** `PROTECTED_PATHS` replaces the default set entirely, and the file is not read +- **Memory entries:** use `vault_delete_memory` for individual entries in memory files + +`vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`. `vault_move_note` moves or renames a note and rewrites every link across the vault that resolves to it, mirroring Obsidian's built-in rename: @@ -209,7 +217,13 @@ Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to **Promoted properties:** Five frontmatter keys — `title`, `tags`, `type`, `created`, `related` — get dedicated columns in the `notes` table for direct `WHERE`-clause filtering (no `json_extract` needed). In tool responses, these appear as top-level fields; remaining frontmatter keys are returned under `additional_properties` (via `formatNoteMetadata` in `tool-helpers.ts`). All other properties live in a JSON `properties` column — functional for any schema, but without dedicated columns. The property queries match a key as data through `json_each` over that column, never as a JSON path, so a property named `a.b` or `k[0]` is matched like any other. Array values are unpacked via `json_each`, so scalar and list properties both match. -**Daily notes:** `vault_get_daily_note` resolves the vault's folder and date format, each independently: `DAILY_NOTES_FOLDER`/`DAILY_NOTES_FORMAT` env setting → `.obsidian/daily-notes.json` → fallback (`Daily Notes/YYYY-MM-DD.md`). The file is read on every call, so a folder or format change in Obsidian applies to the next call and a config file that arrives after boot needs no restart; a missing file takes the fallbacks, and a file that cannot be parsed takes them with a `warn`. `task-format-config.ts` reads the Tasks plugin's format and date toggles the same way on every task write; only its status registry is read once at boot, so listings and writes classify with one map. +**Daily notes:** `vault_get_daily_note` resolves the vault's folder and date format, each independently: `DAILY_NOTES_FOLDER`/`DAILY_NOTES_FORMAT` env setting → `.obsidian/daily-notes.json` → fallback (`Daily Notes/YYYY-MM-DD.md`). + +- **Read on every call:** a folder or format change in Obsidian applies to the next call, and a config file that arrives after boot needs no restart +- **Missing file:** takes the fallbacks +- **Unparseable file:** takes the fallbacks and logs a `warn` +- **Tasks plugin settings:** `task-format-config.ts` reads the format and date toggles the same way, on every task write +- **Status registry:** read once at boot, so listings and writes classify with one map ### Memory @@ -1205,8 +1219,9 @@ Docker hardening, and durability seatbelts above. like `../../outside` cannot escape the memory directory — and leading dots, which would create hidden files (memory paths are built via `join`, bypassing `resolveSafePath`'s hidden-path guard). -- **Protected paths**: `PROTECTED_PATHS` (default: `MEMORY_DIR` plus - `DAILY_NOTES_FOLDER`, falling back to `Daily Notes`) blocks deleting +- **Protected paths**: `PROTECTED_PATHS` (default: `MEMORY_DIR` plus the + daily notes folder, from `DAILY_NOTES_FOLDER`, then + `.obsidian/daily-notes.json`, then `Daily Notes`) blocks deleting notes in, moving notes out of, and moving notes into configured folders. The check (`isProtectedPath()`, shared by delete and move) runs on the canonical vault-relative path with a case-folded From 326cff3793bba87083a627b260a88699a073c4b6 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:15:32 -0400 Subject: [PATCH 09/10] fix(tasks): say a retyped NON_TASK checkbox needs a server restart The two NON_TASK error bullets gave no remedy. The status registry is read once at boot, so a checkbox retyped in the Tasks plugin stays rejected until the server restarts; both bullets now say so. Tool-surface snapshots regenerated. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/__snapshots__/tool-surface/default.json | 4 ++-- .../__tests__/__snapshots__/tool-surface/disabled-tools.json | 4 ++-- .../__tests__/__snapshots__/tool-surface/embedding-off.json | 4 ++-- .../tool-surface/file-tools-off+embedding-off.json | 4 ++-- .../__tests__/__snapshots__/tool-surface/file-tools-off.json | 4 ++-- .../__snapshots__/tool-surface/memory-off+embedding-off.json | 4 ++-- .../tool-surface/memory-off+file-tools-off+embedding-off.json | 4 ++-- .../__snapshots__/tool-surface/memory-off+file-tools-off.json | 4 ++-- .../__tests__/__snapshots__/tool-surface/memory-off.json | 4 ++-- src/vault-mcp/mcp-core/tools/task-tools.ts | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json index 53044b2dc..e5f7c6fbe 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json @@ -6,7 +6,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1642,7 +1642,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json index 1c77cf283..8f0f18cb4 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1584,7 +1584,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json index 8e76580cc..d7697d19f 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1644,7 +1644,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json index 12309b351..20e6bbd16 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1561,7 +1561,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json index 78638854e..c5961fd15 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1560,7 +1560,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json index 269733505..104a116fc 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1445,7 +1445,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json index 9105d221e..1a9e05601 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json @@ -10,7 +10,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1362,7 +1362,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json index 303ce0f25..60f6eaada 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1361,7 +1361,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json index 2c5f4f13d..c0ced0c8a 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -1444,7 +1444,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/tools/task-tools.ts b/src/vault-mcp/mcp-core/tools/task-tools.ts index 1736f0e5c..354223378 100644 --- a/src/vault-mcp/mcp-core/tools/task-tools.ts +++ b/src/vault-mcp/mcp-core/tools/task-tools.ts @@ -249,7 +249,7 @@ Errors: - "heading "X" not found; available: ..." — no heading matches; the error lists the note's headings - "cannot place at position N under "X" — the heading appears N times" — integer position on a note with duplicate heading names; rename one section to make it unique - "parent task not found" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment -- "checkbox '[c]' is a NON_TASK status" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system +- "checkbox '[c]' is a NON_TASK status" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server - "no checkbox symbol for status ..." — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server - "parentBlockId and parentLine are mutually exclusive" — both parent_block_id and parent_line were passed; drop one - "parent and heading are mutually exclusive" — a parent (parent_block_id or parent_line) and heading were both passed; drop one @@ -509,7 +509,7 @@ Errors: - "blockId ... is inside a fenced code block or comment" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence - "no task at line N" — line doesn't contain a task checkbox - "line N is inside a fenced code block or comment" — the line is inside a fenced code block or %% %% comment; target a line outside the fence -- "checkbox '[c]' is a NON_TASK status" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry; NON_TASK checkboxes are excluded from the task system and cannot be mutated +- "checkbox '[c]' is a NON_TASK status" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server - "no checkbox symbol for status ..." — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server - "at least one mutation" — no change params provided - "cannot move a sub-task to a heading" — explicit heading on a task nested under another task (depth > 0${whenToolEnabledText("vault_list_tasks", " in vault_list_tasks")}) From 2ce2943eaa68ea76cfc562a7933112a04171ed08 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:18:17 -0400 Subject: [PATCH 10/10] docs: group the ARCHITECTURE.md guard and config-reader rules by topic The delete and move guard block now covers only the guard: the daily notes lookup order points to the Daily notes section, the vault_update_properties sentence sits with the edit tools, and the vault_delete_memory pointer is its own line. The Tasks plugin settings move from the Daily notes block to the Tasks section. Co-Authored-By: Claude Fable 5.1 --- ARCHITECTURE.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca74072a7..a80039462 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,21 +167,21 @@ The edit tools differ in how they locate the lines they change — by heading, b The three anchor tools share one resolution rule: a short, case-sensitive substring locates a full line, ambiguity is an error, and `first_match` takes the first match instead. -`vault_delete_note` refuses paths under protected folders as a server-side guardrail: +`vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`. + +`vault_delete_note` and `vault_move_note` refuse paths under protected folders as a server-side guardrail: -- **Default set:** the memory dir plus the daily notes folder -- **Daily notes folder:** read on every delete and move, from `DAILY_NOTES_FOLDER`, then `.obsidian/daily-notes.json`, then the default `Daily Notes` +- **Default set:** the memory dir plus the daily notes folder, resolved on every delete and move as [Daily notes](#property-discovery--daily-notes) describes - **Unreadable `daily-notes.json`:** a file that exists but cannot be read or parsed refuses the delete or move, because the folder to protect is unknown; setting `DAILY_NOTES_FOLDER` skips the file -- **Override:** `PROTECTED_PATHS` replaces the default set entirely, and the file is not read -- **Memory entries:** use `vault_delete_memory` for individual entries in memory files +- **Override:** `PROTECTED_PATHS` replaces the default set entirely, and `daily-notes.json` is not read -`vault_update_properties` merges properties without touching the body — sets new keys, overwrites matching keys, deletes keys set to `null`. +To remove a single entry from a memory file, use `vault_delete_memory`. `vault_move_note` moves or renames a note and rewrites every link across the vault that resolves to it, mirroring Obsidian's built-in rename: - **Every link form:** wikilinks (including aliases, heading anchors, and embeds), markdown links, and frontmatter links, resolved with the same logic as the link-graph tools - **Minimal rewrites:** a link is rewritten only when leaving it unchanged would break it -- **Guardrails:** refuses to overwrite an existing destination, and blocks moves out of or into `PROTECTED_PATHS` +- **Guardrails:** refuses to overwrite an existing destination, and blocks moves out of or into protected folders Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to clean up parent directories left empty by the operation. @@ -222,8 +222,6 @@ Both `vault_delete_note` and `vault_move_note` support `prune_empty_folders` to - **Read on every call:** a folder or format change in Obsidian applies to the next call, and a config file that arrives after boot needs no restart - **Missing file:** takes the fallbacks - **Unparseable file:** takes the fallbacks and logs a `warn` -- **Tasks plugin settings:** `task-format-config.ts` reads the format and date toggles the same way, on every task write -- **Status registry:** read once at boot, so listings and writes classify with one map ### Memory @@ -367,6 +365,8 @@ Four design choices shape the query surface: - **Heading moves and position** — `heading` moves the task and its indented sub-items to another section; on a Kanban board that is a lane move, but any note with headings works. `position` (`"top"`, `"bottom"`, or a 1-based integer) selects where within the target heading the card lands; without a `heading`, it triggers a same-lane reorder (rejected when the task sits above the first heading or the lane's heading name is duplicated). A sub-task (depth > 0) never moves or reorders: an explicit `heading` or `position` is rejected, and `status: "done"` changes its checkbox in place. - **`add_subtasks`** — appends checklist items under the task's existing ones. +`vault_create_task` and `vault_update_task` read the Tasks plugin's format and date toggles on every write, so a change in Obsidian applies to the next write. The plugin's status registry is read once at boot, so `vault_list_tasks` and the two write tools always agree on which checkboxes are tasks; a status-type change needs a restart. + ## MCP Prompts Alongside tools, the server registers MCP **prompts** (`prompts/list` / `prompts/get`) — user-initiated workflows, distinct from the model-driven tools: