Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/desktop/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,15 +671,15 @@ 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: {
status: {
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',
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/mcp/vault-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
21 changes: 19 additions & 2 deletions apps/desktop/src/mcp/vault-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')) {
Expand All @@ -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
Expand Down Expand Up @@ -1478,6 +1493,7 @@ function parseTasksFromBody(
due: due ?? defaults.due,
priority: priority ?? defaults.priority,
waiting,
status: inlineTaskField(tail, 'status') ?? defaults.status,
tags
})
taskIndex += 1
Expand Down Expand Up @@ -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)),
Expand Down
14 changes: 14 additions & 0 deletions packages/shared-domain/src/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `@<key>:<value>` 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
Expand Down
Loading