diff --git a/apps/desktop/src/mcp/server.ts b/apps/desktop/src/mcp/server.ts index 75cac527..3a81e651 100644 --- a/apps/desktop/src/mcp/server.ts +++ b/apps/desktop/src/mcp/server.ts @@ -671,7 +671,7 @@ const TOOLS: ToolDef[] = [ schema: { name: 'list_tasks', description: - 'Return every task (markdown checkbox) in live notes with parsed metadata: due date, priority, @waiting, tags. Filter by status, priority, date range, tag, or folder.', + 'Return every task (markdown checkbox) in live notes with parsed metadata: due date, priority, @waiting, tags, and status, the column a Kanban board grouped by status shows it in (a task note’s `status:`, a line’s `@status:`, or the frontmatter `status:` of its note). Filter by state, priority, date range, tag, or folder.', inputSchema: { type: 'object', properties: { @@ -679,7 +679,7 @@ const TOOLS: ToolDef[] = [ type: 'string', enum: ['open', 'done', 'waiting', 'all'], description: - 'open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything.' + 'The task’s state, not its status column. open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything.' }, priority: { type: 'string', diff --git a/apps/desktop/src/mcp/vault-ops.test.ts b/apps/desktop/src/mcp/vault-ops.test.ts index c168efca..a6e4f18f 100644 --- a/apps/desktop/src/mcp/vault-ops.test.ts +++ b/apps/desktop/src/mcp/vault-ops.test.ts @@ -15,6 +15,7 @@ import { toggleTaskInBody } from './vault-ops' import { TASK_LINE_RE as SHARED_TASK_LINE_RE } from '@shared/tasklists' +import { parseTasksFromBody as parseAppTasks } from '@shared/tasks' // Every note-shaped MCP result carries `link`, the zennotes:// deep link a // model renders as a markdown link so the user can click from chat straight @@ -113,6 +114,27 @@ describe('mcp task states', () => { expect(tasks.find((t) => t.content === 'deeply quoted')?.inProgress).toBe(true) }) + // An assistant sees which column of a status board a task is in, as the app + // and `zn mcp` report it, while this grammar keeps the token in the content + // (ZenNotes/zennotes#941). + it('reports each task’s status column the way the app reads it', async () => { + await writeFile(path.join(root, 'inbox', 'Ship it.md'), '---\ntags: [task]\nstatus: Ready\n---\nBuild it.\n') + await writeFile(path.join(root, 'inbox', 'Bare task.md'), '---\ntags: [task]\n---\nNo status yet.\n') + const sprint = '---\nstatus: backlog\n---\n- [ ] inherits\n- [ ] overrides @status:Review @status:later\n- [x] done here\n' + await writeFile(path.join(root, 'inbox', 'Sprint.md'), sprint) + const tasks = await scanAllTasks(root) + const statusOf = (content: string): string | undefined => tasks.find((t) => t.content === content)?.status + expect(statusOf('Ship it')).toBe('ready') + expect(statusOf('Bare task')).toBe('open') + expect(statusOf('inherits')).toBe('backlog') + expect(statusOf('overrides @status:Review @status:later')).toBe('review') + expect(statusOf('done here')).toBe('backlog') + expect(statusOf('actually do it')).toBeUndefined() + expect(JSON.parse(JSON.stringify(tasks.find((t) => t.content === 'actually do it')))).not.toHaveProperty('status') + const app = parseAppTasks(sprint, { path: 'inbox/Sprint.md', title: 'Sprint', folder: 'inbox' }) + expect(tasks.filter((t) => t.sourcePath === 'inbox/Sprint.md').map((t) => t.status)).toEqual(app.map((t) => t.status)) + }) + it('toggles an ordered or quoted task in place', () => { const body = '1. [ ] first\n> - [ ] quoted\n' expect(toggleTaskInBody(body, 0)).toBe('1. [x] first\n> - [ ] quoted\n') diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index 31373780..e7e3092f 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -21,7 +21,7 @@ import { retitleLeadingHeading } from '@shared/note-heading-sync' import { normalizeNoteComments } from '@shared/note-comments' import type { NoteComment, NoteCommentInput } from '@shared/ipc' export type { NoteComment, NoteCommentInput } -import { noteTasksMode, type NoteTasksMode } from '@shared/tasks' +import { inlineTaskField, noteTasksMode, type NoteTasksMode } from '@shared/tasks' import { isPathExcludedFromTasks, normalizeTasksExcludedFolders @@ -283,6 +283,13 @@ export interface VaultTask { due?: string priority?: 'high' | 'med' | 'low' waiting: boolean + /** The column the task sits in on a Kanban board grouped by status, read as + * the app reads it: a task note's frontmatter `status:` (`open` when it has + * none), a checkbox line's `@status:`, or else the frontmatter `status:` of + * the note the line is in. Lower-cased; absent when a line has none. The + * `@status:` token stays in `content`, as every field does in this grammar + * (ZenNotes/zennotes#941). */ + status?: string tags: string[] /** How this task is stored. `'file'` is a whole-note task (TaskNotes-style: a * `.md` file tagged `task`, metadata in frontmatter); `'inline'` (the default @@ -1356,11 +1363,17 @@ function normalizeDueDate(raw: string | undefined): string | undefined { function parseNoteDefaults(body: string): { due?: string priority?: 'high' | 'med' | 'low' + status?: string tasksMode: NoteTasksMode } { const m = body.match(FRONTMATTER_RE) if (!m) return { tasksMode: 'all' } - const out: { due?: string; priority?: 'high' | 'med' | 'low'; tasksMode: NoteTasksMode } = { + const out: { + due?: string + priority?: 'high' | 'med' | 'low' + status?: string + tasksMode: NoteTasksMode + } = { tasksMode: 'all' } for (const rawLine of m[1].split('\n')) { @@ -1377,6 +1390,8 @@ function parseNoteDefaults(body: string): { else if (key === 'priority') { const p = normalizePriority(value) if (p) out.priority = p + } else if (key === 'status') { + if (value) out.status = value.toLowerCase() } else if (key === 'tasks') out.tasksMode = noteTasksMode(value) } return out @@ -1478,6 +1493,7 @@ function parseTasksFromBody( due: due ?? defaults.due, priority: priority ?? defaults.priority, waiting, + status: inlineTaskField(tail, 'status') ?? defaults.status, tags }) taskIndex += 1 @@ -1603,6 +1619,7 @@ function parseTaskFile( due: normalizeDueDate(firstScalar(fm.due)), priority: normalizePriority(firstScalar(fm.priority)), waiting: status === 'waiting', + status, tags: tags.filter((t) => t !== TASK_FILE_TAG), kind: 'file', scheduled: normalizeDueDate(firstScalar(fm.scheduled)), diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index 2f17bb6e..5e83416e 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -135,6 +135,20 @@ const INLINE_WAITING_RE = /(?:^|\s)@waiting\b/i // Kanban group-by; `status` is simply the default one. Global so a line can // carry several fields. (#354) const INLINE_FIELD_RE = /(?:^|\s)@([a-z][a-z0-9_-]*):([\p{L}\d][\p{L}\d/_-]*)/giu + +/** The first `@:` that `text` carries for `key`, lower-cased: the + * value the parser below keeps in `fields[key]`. For the grammars that leave + * the fields in a task's text, as the MCP server and `zn` do, so they still + * report the status the app shows (ZenNotes/zennotes#941). */ +export function inlineTaskField(text: string, key: string): string | undefined { + const wanted = key.toLowerCase() + INLINE_FIELD_RE.lastIndex = 0 + for (const match of text.matchAll(INLINE_FIELD_RE)) { + if (match[1].toLowerCase() === wanted) return match[2].toLowerCase() + } + return undefined +} + // Match #tag-like tokens but only when preceded by start-of-string/whitespace. // Letters in any script (Cyrillic/CJK/…) plus digits, `_`, `-`, `/` (#205). const INLINE_TAG_RE = /(?:^|\s)#([\p{L}\d][\p{L}\d/_-]*)/gu