diff --git a/internal/vault/tasks.go b/internal/vault/tasks.go index 94f63e2..6c2cc63 100644 --- a/internal/vault/tasks.go +++ b/internal/vault/tasks.go @@ -15,12 +15,13 @@ import ( // `(?:>\s*)*` is a blockquote prefix, unrelated to the `>` state char. This // is the app's TASK_LINE_RE, so the CLI counts task indexes the way the // editor's toggles do and the two can never disagree about which line -// `inbox/Note.md#3` is. +// `inbox/Note.md#3` is. Both dialects read task lines with it: the desktop's +// MCP server matches these lines since 2.69.1, and `zn task` and `zn mcp` +// follow it so an assistant sees the same tasks through either server. var ( taskFenceRe = regexp.MustCompile("^(\\s*)(```|~~~)") taskLineRe = regexp.MustCompile(`^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[)( |x|X|>|-|/)(\].*)$`) cliFenceRe = regexp.MustCompile("^(\\s{0,3})(`{3,}|~{3,})") - cliTaskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+\[)( |x|X|>|-|/)(\].*)$`) inlineDueRe = regexp.MustCompile(`(?i)(?:^|\s)due:\s*(\S+)`) inlinePriorityRe = regexp.MustCompile(`(?i)(?:^|\s)!(high|med|medium|low|h|m|l)\b`) inlineWaitingRe = regexp.MustCompile(`(?i)(?:^|\s)@waiting\b`) @@ -44,9 +45,6 @@ func (d TaskDialect) fenceRe() *regexp.Regexp { } func (d TaskDialect) lineRe() *regexp.Regexp { - if d == DialectCLI { - return cliTaskLineRe - } return taskLineRe } diff --git a/internal/vault/types.go b/internal/vault/types.go index 9bbfbd8..f04be7b 100644 --- a/internal/vault/types.go +++ b/internal/vault/types.go @@ -149,11 +149,12 @@ type TaskGroups struct { OverdueCount int } -// TaskDialect picks which task-line grammar a scan uses. The desktop app -// and its CLI disagree on the edges (numbered-list and blockquoted checkboxes -// exist for the app only, and the CLI leaves `@key:value` fields in the -// content), and a task id is only stable within one grammar, so each surface -// keeps the grammar it always had. +// TaskDialect picks which task grammar a scan uses. Both read the same task +// lines, numbered-list and blockquoted checkboxes included (the CLI read only +// bullets until 0.7.2, when the desktop's MCP server began matching the app's +// lines). They still differ on the edges: the CLI leaves `@key:value` fields in +// the content and reads fences only up to three spaces in, and a task id is +// only stable within one grammar, so each surface keeps its own. type TaskDialect int const ( @@ -161,7 +162,8 @@ const ( // UI shares so a toggle there edits the line the app would. DialectApp TaskDialect = iota // DialectCLI is the grammar of the desktop's `zn task` commands and MCP - // tools, so scripts keep getting the ids they got before. + // tools, so `zn mcp` and the desktop's own MCP server list the same tasks + // under the same ids. DialectCLI ) diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 67949e0..3e9f761 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -425,18 +425,65 @@ func TestCLIDialect(t *testing.T) { body := "- [ ] one @project:x\n1. [ ] numbered\n> - [ ] quoted\n- [x] two\n" app := ParseTasks("a.md", "a", FolderInbox, body, ParseTasksOptions{}) cli := ParseTasks("a.md", "a", FolderInbox, body, ParseTasksOptions{Dialect: DialectCLI}) - if len(app) != 4 || len(cli) != 2 { + // Both read the same task lines, so a task has the same id in each; the + // CLI keeps `@key:value` fields in the content. + if len(app) != 4 || len(cli) != 4 { t.Fatalf("app=%d cli=%d", len(app), len(cli)) } + for i := range app { + if app[i].ID != cli[i].ID { + t.Fatalf("task %d: app id %q, cli id %q", i, app[i].ID, cli[i].ID) + } + } if app[0].Content != "one" || cli[0].Content != "one @project:x" || cli[0].Fields != nil { t.Fatalf("content app=%q cli=%q fields=%v", app[0].Content, cli[0].Content, cli[0].Fields) } - if cli[1].ID != "a.md#1" || !cli[1].Checked { - t.Fatalf("cli second = %+v", cli[1]) + if cli[1].Content != "numbered" || cli[2].Content != "quoted" { + t.Fatalf("cli numbered=%q quoted=%q", cli[1].Content, cli[2].Content) + } + if cli[3].ID != "a.md#3" || !cli[3].Checked { + t.Fatalf("cli last = %+v", cli[3]) } - next, ok := ToggleTaskInBody(body, 1, DialectCLI) + next, ok := ToggleTaskInBody(body, 3, DialectCLI) if !ok || !strings.Contains(next, "- [ ] two") { - t.Fatalf("cli toggle #1 should reopen 'two': %q", next) + t.Fatalf("cli toggle #3 should reopen 'two': %q", next) + } + next, ok = ToggleTaskInBody(body, 1, DialectCLI) + if !ok || !strings.Contains(next, "1. [x] numbered") { + t.Fatalf("cli toggle #1 should check off 'numbered': %q", next) + } +} + +// The desktop's own MCP server reads task lines the same way since 2.69.1; +// its test "sees the task lines the app sees" (apps/desktop/src/mcp/ +// vault-ops.test.ts) uses this note, so `zn mcp` and the desktop's server list +// the same tasks under the same ids. +func TestCLIDialectReadsEveryTaskShape(t *testing.T) { + body := strings.Join([]string{ + "- [ ] bullet", + "1. [ ] ordered", + "2) [x] ordered done", + "> - [ ] quoted", + "> > 3. [/] deeply quoted", + "-[ ] not a task", + "text [ ] not a task", + "", + }, "\n") + cli := ParseTasks("inbox/Shapes.md", "Shapes", FolderInbox, body, ParseTasksOptions{Dialect: DialectCLI}) + var got []string + for _, task := range cli { + got = append(got, task.Content) + } + want := []string{"bullet", "ordered", "ordered done", "quoted", "deeply quoted"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("cli tasks = %q, want %q", got, want) + } + if !cli[2].Checked || !cli[4].InProgress { + t.Fatalf("states: ordered done checked=%v, deeply quoted in progress=%v", cli[2].Checked, cli[4].InProgress) + } + next, ok := ToggleTaskInBody("1. [ ] first\n> - [ ] quoted\n", 1, DialectCLI) + if !ok || next != "1. [ ] first\n> - [x] quoted\n" { + t.Fatalf("toggle quoted = %q, %v", next, ok) } }