From d2a5210436c3d87189e866f4bff5b823e18c2906 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 15:56:43 -0500 Subject: [PATCH 1/3] Fix(mcp): assistants can no longer write into .zennotes Vault.WriteNote wrote the file and only then checked that it was in a note folder, so write_note on a path under .zennotes created that file before failing with "not in a known folder". The other edits already checked first (editNote). WriteNote now checks first too; notes, new or existing, are written as before. --- internal/vault/vault.go | 12 ++++++++---- internal/vault/vault_test.go | 13 +++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/internal/vault/vault.go b/internal/vault/vault.go index bd349c7..bce782e 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -466,14 +466,18 @@ func (v *Vault) WriteNote(rel, body string) (NoteMeta, error) { if err != nil { return NoteMeta{}, err } - if err := v.writeNoteFileAtomic(abs, []byte(body)); err != nil { - return NoteMeta{}, err - } - v.invalidateLayout() + // Refuse anything outside the note folders BEFORE a byte lands. This used + // to write first and check after, so write_note on `.zennotes/...` created + // the file and only then failed: an assistant could plant or alter a note's + // comments, its creation date, or anything else the app keeps there. folder, ok := v.folderOfAbs(abs) if !ok { return NoteMeta{}, errNotInFolder(rel) } + if err := v.writeNoteFileAtomic(abs, []byte(body)); err != nil { + return NoteMeta{}, err + } + v.invalidateLayout() return v.readMeta(abs, folder, v.Settings().TypstPreambleFolder) } diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 827d42e..67949e0 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -459,3 +459,16 @@ func TestDeleteNoteReportsAMissingNote(t *testing.T) { t.Fatal("deleting a note that never existed must fail") } } + +// write_note used to write the file and check afterwards that it was a note, +// so a path under .zennotes was created before the error: nothing may land there. +func TestWriteNoteRefusesInternalFilesBeforeWriting(t *testing.T) { + v := newTestVault(t) + forged := ".zennotes/agent-edits/inbox/Plan.md.json" + if _, err := v.WriteNote(forged, "{}"); err == nil { + t.Fatal("writing into .zennotes must fail") + } + if _, err := os.Stat(filepath.Join(v.root, filepath.FromSlash(forged))); !os.IsNotExist(err) { + t.Fatalf("nothing may land in .zennotes: %v", err) + } +} From d251e4e4182a06f5eb4ef06f29bdec5fd186de44 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 15:56:43 -0500 Subject: [PATCH 2/3] Feat(mcp): record assistant edits for review in the desktop app ZenNotes 2.68.0 can show what an assistant changed in a note and let the user accept or reject each change. It needs the note's previous text, which only the MCP server sees: before write_note, append_to_note, prepend_to_note, insert_at_line, replace_in_note or toggle_task changes a note of a local vault, zn mcp now writes it to .zennotes/agent-edits/.json, makes the edit, and stores the note as it now reads, naming the assistant from the client's initialize name. The record mirrors the desktop's @shared/agent-edits and its TypeScript MCP server, and the writing side is kept to string comparisons and JSON on purpose: changes made by someone else between two edits are noted, never merged, since the app does the merging. The record reaches disk before the edit, and edits to one note take turns. [mcp] review_agent_edits in config.toml turns recording off. Records move with their notes on rename, move and archive, and go when a note is deleted or Trash is emptied. --- internal/mcp/agentedits.go | 164 +++++++++++++++++ internal/mcp/agentedits_test.go | 150 ++++++++++++++++ internal/mcp/server.go | 11 +- internal/vault/agent_edits.go | 271 +++++++++++++++++++++++++++++ internal/vault/agent_edits_test.go | 142 +++++++++++++++ internal/vault/vault.go | 9 +- 6 files changed, 744 insertions(+), 3 deletions(-) create mode 100644 internal/mcp/agentedits.go create mode 100644 internal/mcp/agentedits_test.go create mode 100644 internal/vault/agent_edits.go create mode 100644 internal/vault/agent_edits_test.go diff --git a/internal/mcp/agentedits.go b/internal/mcp/agentedits.go new file mode 100644 index 0000000..80cf5e8 --- /dev/null +++ b/internal/mcp/agentedits.go @@ -0,0 +1,164 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/BurntSushi/toml" + + "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vault" +) + +// Keeping what a note said before an assistant changed it, so the user can +// review the change in the desktop app (ZenNotes/zennotes +// docs/ideas/agent-edit-review.md). Every tool that rewrites a note's text +// runs through runRecording: it writes the record BEFORE the edit, lets the +// tool work, then notes how the note looks now. Mirrors +// apps/desktop/src/mcp/agent-edit-recorder.ts in ZenNotes/zennotes: same +// tools, same order, same record. + +// recordedTools are the tools that change a note's text, and where each one +// names the note. Creating, moving, renaming and deleting notes are not +// recorded: a new note has nothing to compare against, and Trash keeps the rest. +var recordedTools = map[string]func(a args) string{ + "write_note": pathArg, + "append_to_note": pathArg, + "prepend_to_note": pathArg, + "insert_at_line": pathArg, + "replace_in_note": pathArg, + "toggle_task": func(a args) string { + id, _ := a["id"].(string) + hash := strings.LastIndex(id, "#") + if hash <= 0 { + return "" + } + return id[:hash] + }, +} + +func pathArg(a args) string { + rel, _ := a["path"].(string) + return strings.TrimSpace(rel) +} + +// clientDisplayNames mirrors CLIENT_DISPLAY_NAMES in the desktop's MCP server, +// so a record names the assistant the way its comments do. +var clientDisplayNames = map[string]string{ + "claude-ai": "Claude", + "claude-code": "Claude Code", + "claude-desktop": "Claude", + "codex-cli": "Codex", + "codex": "Codex", +} + +// agentForClient is the display name for an MCP client's initialize name. +func agentForClient(name string) string { + raw := strings.TrimSpace(name) + if raw == "" { + return "Assistant" + } + if known, ok := clientDisplayNames[strings.ToLower(raw)]; ok { + return known + } + words := strings.FieldsFunc(raw, func(r rune) bool { return r == '-' || r == '_' || r == ' ' }) + for i, w := range words { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + return strings.Join(words, " ") +} + +// reviewAgentEditsEnabled reads Settings → MCP → "Review agent edits" from the +// portable config the app writes (`[mcp] review_agent_edits`). On when the +// file or the key is missing, matching the app's default. +func reviewAgentEditsEnabled() bool { + raw, err := os.ReadFile(config.ConfigTomlPath()) + if err != nil { + return true + } + var doc struct { + MCP struct { + ReviewAgentEdits *bool `toml:"review_agent_edits"` + } `toml:"mcp"` + } + if _, err := toml.Decode(string(raw), &doc); err != nil || doc.MCP.ReviewAgentEdits == nil { + return true + } + return *doc.MCP.ReviewAgentEdits +} + +// Tool calls are served concurrently, and two edits to one note racing on its +// record would lose one of them, so edits to the same note take turns. +var noteLocks sync.Map + +func lockNote(key string) func() { + value, _ := noteLocks.LoadOrStore(key, &sync.Mutex{}) + mu := value.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +func noteText(ctx context.Context, b Backend, rel string) (string, bool) { + content, err := b.ReadNote(ctx, rel) + if err != nil { + return "", false + } + return content.Body, true +} + +// runRecording runs a tool, recording its edit when it rewrites a note of a +// local vault. Recording never stands in the way of the edit the user asked +// for: if the record cannot be written, the tool still runs and the change is +// simply not offered for review. +func runRecording(ctx context.Context, tool string, a args, b Backend, agent string, run func() (any, error)) (any, error) { + noteOf, ok := recordedTools[tool] + // A remote vault's notes are written by its server; recording there needs + // the server's own change. + if !ok || b.Kind() != backend.KindLocal { + return run() + } + rel := noteOf(a) + if rel == "" { + return run() + } + unlock := lockNote(b.Root() + "\x00" + rel) + defer unlock() + + if !reviewAgentEditsEnabled() { + return run() + } + before, ok := noteText(ctx, b, rel) + // Not a note we can read (the tool reports its own error), or one too + // large to keep copies of. + if !ok || !vault.AgentEditRecordable(before) { + return run() + } + existing := vault.ReadAgentEdit(b.Root(), rel) + started := vault.BeginAgentEdit(existing, before) + if err := vault.WriteAgentEdit(b.Root(), rel, started); err != nil { + fmt.Fprintf(os.Stderr, "zn mcp: could not record the edit to %s: %v\n", rel, err) + return run() + } + + result, runErr := run() + + after, ok := noteText(ctx, b, rel) + var finish error + if !ok || after == before { + // Nothing changed (no match, a task that moved, a failed write): the + // record goes back to what it was. + finish = vault.WriteAgentEdit(b.Root(), rel, existing) + } else { + entry := vault.AgentEditEntry{At: time.Now().UnixMilli(), Agent: agent, Tool: tool} + finish = vault.WriteAgentEdit(b.Root(), rel, vault.FinishAgentEdit(started, after, entry)) + } + if finish != nil { + fmt.Fprintf(os.Stderr, "zn mcp: could not finish the record for %s: %v\n", rel, finish) + } + return result, runErr +} diff --git a/internal/mcp/agentedits_test.go b/internal/mcp/agentedits_test.go new file mode 100644 index 0000000..692785f --- /dev/null +++ b/internal/mcp/agentedits_test.go @@ -0,0 +1,150 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/vault" +) + +// Agent edit records written through the real MCP server, as Claude Code +// would drive it. Mirrors apps/desktop/src/mcp/agent-edit-recorder.test.ts +// in ZenNotes/zennotes. + +const planNote = "inbox/Plan.md" +const planBody = "# Plan\n\n- [ ] Ship the beta\nThe migration runs at night.\n" + +// serveAs is runServer with a client name, so the record can name it. +func serveAs(t *testing.T, client string, root string) *sdk.ClientSession { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + serverT, clientT := sdk.NewInMemoryTransports() + done := make(chan struct{}) + go func() { + defer close(done) + _ = Run(ctx, Options{ + ResolveTarget: func() (backend.Target, error) { return backend.Target{Kind: backend.KindLocal, Root: root}, nil }, + OpenBackend: func(t backend.Target) (Backend, error) { return backend.New(t, backend.Options{}) }, + Transport: serverT, + }) + }() + cs, err := sdk.NewClient(&sdk.Implementation{Name: client, Version: "0"}, nil).Connect(ctx, clientT, nil) + if err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + _ = cs.Close() + cancel() + <-done + }) + return cs +} + +func seedPlan(t *testing.T) string { + t.Helper() + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "Plan.md"), []byte(planBody), 0o644); err != nil { + t.Fatal(err) + } + return root +} + +func readPlan(t *testing.T, root string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "inbox", "Plan.md")) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestMCPRecordsAnAssistantsEditsForReview(t *testing.T) { + root := seedPlan(t) + cs := serveAs(t, "claude-code", root) + if _, isErr := callTool(t, cs, "replace_in_note", map[string]any{"path": planNote, "find": "at night", "replace": "on Sunday"}); isErr { + t.Fatal("replace_in_note failed") + } + if _, isErr := callTool(t, cs, "append_to_note", map[string]any{"path": planNote, "text": "A new line."}); isErr { + t.Fatal("append_to_note failed") + } + rec := vault.ReadAgentEdit(root, planNote) + if rec == nil { + t.Fatal("no record") + } + if rec.Before != planBody || rec.After == nil || *rec.After != readPlan(t, root) { + t.Fatalf("record texts = %q / %v", rec.Before, rec.After) + } + if len(rec.Edits) != 2 || rec.Edits[0].Agent != "Claude Code" || rec.Edits[0].Tool != "replace_in_note" || rec.Edits[1].Tool != "append_to_note" { + t.Fatalf("edits = %+v", rec.Edits) + } +} + +func TestMCPRecordsATaskToggledByID(t *testing.T) { + root := seedPlan(t) + cs := serveAs(t, "codex-cli", root) + if _, isErr := callTool(t, cs, "toggle_task", map[string]any{"id": planNote + "#0"}); isErr { + t.Fatal("toggle_task failed") + } + rec := vault.ReadAgentEdit(root, planNote) + if rec == nil || rec.Before != planBody || len(rec.Edits) != 1 || rec.Edits[0].Agent != "Codex" || rec.Edits[0].Tool != "toggle_task" { + t.Fatalf("record = %+v", rec) + } +} + +func TestMCPLeavesNoRecordWhenNothingChanged(t *testing.T) { + root := seedPlan(t) + cs := serveAs(t, "claude-code", root) + callTool(t, cs, "replace_in_note", map[string]any{"path": planNote, "find": "nowhere in the note", "replace": "x"}) + callTool(t, cs, "read_note", map[string]any{"path": planNote}) + if vault.ReadAgentEdit(root, planNote) != nil { + t.Fatal("no edit, no record") + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "agent-edits")); !os.IsNotExist(err) { + t.Fatalf("no agent-edits folder should be left: %v", err) + } +} + +func TestMCPRecordsNothingWhenReviewIsOff(t *testing.T) { + root := seedPlan(t) + if err := os.WriteFile(filepath.Join(os.Getenv("ZENNOTES_CONFIG_DIR"), "config.toml"), []byte("[mcp]\nreview_agent_edits = false\n"), 0o644); err != nil { + t.Fatal(err) + } + cs := serveAs(t, "claude-code", root) + callTool(t, cs, "append_to_note", map[string]any{"path": planNote, "text": "Unrecorded."}) + if vault.ReadAgentEdit(root, planNote) != nil { + t.Fatal("review is off: nothing may be recorded") + } +} + +func TestMCPCannotWriteIntoZennotes(t *testing.T) { + root := seedPlan(t) + cs := serveAs(t, "claude-code", root) + forged := ".zennotes/agent-edits/inbox/Plan.md.json" + if _, isErr := callTool(t, cs, "write_note", map[string]any{"path": forged, "body": "{}"}); !isErr { + t.Fatal("write_note into .zennotes must fail") + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(forged))); !os.IsNotExist(err) { + t.Fatalf("nothing may land in .zennotes: %v", err) + } +} + +func TestAgentForClientMatchesTheDesktop(t *testing.T) { + for in, want := range map[string]string{ + "claude-code": "Claude Code", "Claude-Code": "Claude Code", "codex": "Codex", + "": "Assistant", "my_agent-tool": "My Agent Tool", + } { + if got := agentForClient(in); got != want { + t.Errorf("agentForClient(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 258a602..b34fe9e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1079,7 +1079,16 @@ func Run(ctx context.Context, opts Options) error { if previous != nil { ctx = context.WithValue(ctx, previousVaultKey{}, *previous) } - result, err := def.handler(ctx, args(a), b) + // An edit to a note is recorded so the user can review it in the app. + agent := "Assistant" + if req.Session != nil { + if params := req.Session.InitializeParams(); params != nil && params.ClientInfo != nil { + agent = agentForClient(params.ClientInfo.Name) + } + } + result, err := runRecording(ctx, def.name, args(a), b, agent, func() (any, error) { + return def.handler(ctx, args(a), b) + }) if err != nil { return errorResult("Error: " + DescribeToolError(err, b)), nil } diff --git a/internal/vault/agent_edits.go b/internal/vault/agent_edits.go new file mode 100644 index 0000000..b9138f4 --- /dev/null +++ b/internal/vault/agent_edits.go @@ -0,0 +1,271 @@ +package vault + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +// Agent edit records: what a note said before an assistant changed it through +// MCP, kept beside it as .zennotes/agent-edits/.json until the user +// reviews the change in the desktop app (ZenNotes/zennotes +// docs/ideas/agent-edit-review.md). This is the WRITING side only, and it is +// kept to string comparisons and JSON on purpose: the app folds in what +// someone else changed between two edits with a three-way merge, so no merge +// lives here. Mirrors beginAgentEdit / finishAgentEdit in +// packages/shared-domain/src/agent-edits.ts and the file handling in +// apps/desktop/src/main/agent-edit-files.ts; keep the three in step. + +const ( + agentEditsDir = "agent-edits" + agentEditsSuffix = ".json" + AgentEditRecordVersion = 1 + // MaxAgentEditNoteBytes: larger notes are not recorded, a record holds copies. + MaxAgentEditNoteBytes = 1024 * 1024 + // MaxAgentEditEntries: how many edits a record lists; the texts stay. + MaxAgentEditEntries = 50 + // MaxAgentEditOutside: past this many outside changes a record starts over + // from the note as it is, forgetting a review but changing no note. + MaxAgentEditOutside = 20 +) + +// AgentEditEntry is one assistant edit. +type AgentEditEntry struct { + At int64 `json:"at"` + Agent string `json:"agent"` + Tool string `json:"tool"` +} + +// AgentEditOutsideChange is a change someone else made between two edits: +// the note after one assistant edit, and the note the next one started from. +type AgentEditOutsideChange struct { + From string `json:"from"` + To string `json:"to"` +} + +// AgentEditRecord is the record file. After is nil while an edit is being +// made: the record reaches disk BEFORE the edit, so a process that dies +// mid-edit leaves a record that shows more than the assistant did, never less. +type AgentEditRecord struct { + Version int `json:"version"` + Before string `json:"before"` + After *string `json:"after"` + Edits []AgentEditEntry `json:"edits"` + Outside []AgentEditOutsideChange `json:"outside"` +} + +func agentEditsRoot(root string) string { + return filepath.Join(root, InternalVaultDir, agentEditsDir) +} + +// AgentEditPath is the record file for a vault-relative note path. +func AgentEditPath(root, rel string) (string, error) { + return SafeJoin(agentEditsRoot(root), ToPosix(rel)+agentEditsSuffix) +} + +// AgentEditRecordable reports whether a note is small enough to record. +func AgentEditRecordable(text string) bool { + return len(text) <= MaxAgentEditNoteBytes +} + +// ReadAgentEdit is the note's record, or nil when there is none or it is not +// a record this version understands. +func ReadAgentEdit(root, rel string) *AgentEditRecord { + abs, err := AgentEditPath(root, rel) + if err != nil { + return nil + } + data, err := os.ReadFile(abs) + if err != nil { + return nil + } + var raw struct { + Version int `json:"version"` + Before *string `json:"before"` + After *string `json:"after"` + Edits []struct { + At *float64 `json:"at"` + Agent *string `json:"agent"` + Tool *string `json:"tool"` + } `json:"edits"` + Outside []struct { + From *string `json:"from"` + To *string `json:"to"` + } `json:"outside"` + } + if json.Unmarshal(data, &raw) != nil || raw.Version != AgentEditRecordVersion || raw.Before == nil { + return nil + } + record := &AgentEditRecord{ + Version: AgentEditRecordVersion, + Before: *raw.Before, + After: raw.After, + Edits: []AgentEditEntry{}, + Outside: []AgentEditOutsideChange{}, + } + for _, e := range raw.Edits { + if e.At == nil { + continue + } + agent, tool := "", "" + if e.Agent != nil { + agent = clip(strings.TrimSpace(*e.Agent), 80) + } + if e.Tool != nil { + tool = clip(strings.TrimSpace(*e.Tool), 80) + } + if agent == "" { + agent = "Assistant" + } + record.Edits = append(record.Edits, AgentEditEntry{At: int64(*e.At), Agent: agent, Tool: tool}) + } + for _, c := range raw.Outside { + if c.From != nil && c.To != nil { + record.Outside = append(record.Outside, AgentEditOutsideChange{From: *c.From, To: *c.To}) + } + } + return record +} + +func clip(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} + +// WriteAgentEdit writes the record atomically, or removes it for nil. +func WriteAgentEdit(root, rel string, record *AgentEditRecord) error { + abs, err := AgentEditPath(root, rel) + if err != nil { + return err + } + if record == nil { + return removeAgentEditFile(root, abs) + } + data, err := json.Marshal(record) + if err != nil { + return err + } + return writeFileAtomic(abs, append(data, '\n'), 0o644, 0o755) +} + +func removeAgentEditFile(root, abs string) error { + if err := os.Remove(abs); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + pruneEmptyAgentEditDirs(root, filepath.Dir(abs)) + return nil +} + +// pruneEmptyAgentEditDirs leaves no empty folders behind, agent-edits itself +// included: a vault whose changes have all been reviewed carries no trace. +func pruneEmptyAgentEditDirs(root, from string) { + stop := agentEditsRoot(root) + for dir := filepath.Clean(from); dir == stop || strings.HasPrefix(dir, stop+string(filepath.Separator)); dir = filepath.Dir(dir) { + if os.Remove(dir) != nil { + return + } + } +} + +// BeginAgentEdit is the record to write BEFORE an edit, given the record on +// disk (or nil) and the note as it is now. +func BeginAgentEdit(existing *AgentEditRecord, current string) *AgentEditRecord { + fresh := &AgentEditRecord{ + Version: AgentEditRecordVersion, + Before: current, + After: nil, + Edits: []AgentEditEntry{}, + Outside: []AgentEditOutsideChange{}, + } + if existing == nil { + return fresh + } + next := *existing + next.Edits = append([]AgentEditEntry{}, existing.Edits...) + next.Outside = append([]AgentEditOutsideChange{}, existing.Outside...) + next.After = nil + if existing.After == nil || *existing.After == current { + return &next + } + if len(existing.Outside) >= MaxAgentEditOutside { + return fresh + } + next.Outside = append(next.Outside, AgentEditOutsideChange{From: *existing.After, To: current}) + return &next +} + +// FinishAgentEdit is the record to write AFTER an edit. +func FinishAgentEdit(started *AgentEditRecord, current string, entry AgentEditEntry) *AgentEditRecord { + next := *started + after := current + next.After = &after + edits := append(append([]AgentEditEntry{}, started.Edits...), entry) + if len(edits) > MaxAgentEditEntries { + edits = edits[len(edits)-MaxAgentEditEntries:] + } + next.Edits = edits + if next.Outside == nil { + next.Outside = []AgentEditOutsideChange{} + } + return &next +} + +// moveAgentEdit carries a note's record along with the note. A record waiting +// at the destination belongs to no note (the one it was for moved or went +// outside ZenNotes) and is discarded, the way a stale creation date is. +func (v *Vault) moveAgentEdit(oldRel, nextRel string) error { + oldAbs, err := AgentEditPath(v.root, oldRel) + if err != nil { + return err + } + nextAbs, err := AgentEditPath(v.root, nextRel) + if err != nil { + return err + } + if oldAbs == nextAbs { + return nil + } + if _, err := os.Stat(oldAbs); err != nil { + if errors.Is(err, os.ErrNotExist) { + return removeAgentEditFile(v.root, nextAbs) + } + return err + } + if err := os.MkdirAll(filepath.Dir(nextAbs), v.dirMode); err != nil { + return err + } + if err := os.Rename(oldAbs, nextAbs); err != nil { + return err + } + pruneEmptyAgentEditDirs(v.root, filepath.Dir(oldAbs)) + return nil +} + +// removeAgentEdit drops a deleted note's record: nothing is left to review. +func (v *Vault) removeAgentEdit(rel string) error { + abs, err := AgentEditPath(v.root, rel) + if err != nil { + return err + } + return removeAgentEditFile(v.root, abs) +} + +// removeAgentEditTree drops the record of a note or every record under a +// folder (an entry of the trash being emptied may be either). +func (v *Vault) removeAgentEditTree(rel string) error { + _ = v.removeAgentEdit(rel) + dir, err := SafeJoin(agentEditsRoot(v.root), ToPosix(rel)) + if err != nil { + return err + } + if err := os.RemoveAll(dir); err != nil { + return err + } + pruneEmptyAgentEditDirs(v.root, filepath.Dir(dir)) + return nil +} diff --git a/internal/vault/agent_edits_test.go b/internal/vault/agent_edits_test.go new file mode 100644 index 0000000..f9c4148 --- /dev/null +++ b/internal/vault/agent_edits_test.go @@ -0,0 +1,142 @@ +package vault + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The writing side of agent edit records. The same cases run against +// beginAgentEdit / finishAgentEdit in ZenNotes/zennotes +// packages/shared-domain/src/agent-edits.test.ts. + +func record(existing *AgentEditRecord, current, next string, at int64) *AgentEditRecord { + return FinishAgentEdit(BeginAgentEdit(existing, current), next, AgentEditEntry{At: at, Agent: "Claude Code", Tool: "replace_in_note"}) +} + +func TestAgentEditKeepsTheNoteBeforeAndAfter(t *testing.T) { + started := BeginAgentEdit(nil, "a\nb\n") + if started.Before != "a\nb\n" || started.After != nil || len(started.Edits) != 0 || len(started.Outside) != 0 { + t.Fatalf("started = %+v", started) + } + done := FinishAgentEdit(started, "a\nB\n", AgentEditEntry{At: 5, Agent: "Claude Code", Tool: "replace_in_note"}) + if done.Before != "a\nb\n" || done.After == nil || *done.After != "a\nB\n" || len(done.Edits) != 1 { + t.Fatalf("done = %+v", done) + } +} + +func TestAgentEditStacksEditsAndNotesOutsideChanges(t *testing.T) { + first := record(nil, "a\nb\nc\n", "a\nB\nc\n", 1) + same := record(first, "a\nB\nc\n", "A\nB\nc\n", 2) + if same.Before != "a\nb\nc\n" || len(same.Outside) != 0 || len(same.Edits) != 2 { + t.Fatalf("stacked = %+v", same) + } + // The user changes the last line before the assistant's next edit. + outside := record(first, "a\nB\nC\n", "A\nB\nC\n", 2) + if outside.Before != "a\nb\nc\n" || len(outside.Outside) != 1 || + outside.Outside[0].From != "a\nB\nc\n" || outside.Outside[0].To != "a\nB\nC\n" { + t.Fatalf("outside = %+v", outside) + } +} + +func TestAgentEditCapsEntriesAndStartsOverPastTheOutsideLimit(t *testing.T) { + var current *AgentEditRecord + text := "line 0\n" + for i := 1; i <= MaxAgentEditEntries+5; i++ { + next := "line " + strings.Repeat("x", i) + "\n" + current = record(current, text, next, int64(i)) + text = next + } + if len(current.Edits) != MaxAgentEditEntries || current.Edits[len(current.Edits)-1].At != MaxAgentEditEntries+5 || current.Before != "line 0\n" { + t.Fatalf("capped = %d edits, before %q", len(current.Edits), current.Before) + } + + r := record(nil, "x\n", "x1\n", 0) + for i := 1; i <= MaxAgentEditOutside; i++ { + r = record(r, "user "+strings.Repeat("u", i)+"\n", "agent "+strings.Repeat("a", i)+"\n", int64(i)) + } + fresh := record(r, "user again\n", "agent again\n", 99) + if fresh.Before != "user again\n" || len(fresh.Outside) != 0 || len(fresh.Edits) != 1 { + t.Fatalf("fresh = %+v", fresh) + } +} + +func TestAgentEditFilesRoundTripInTheAppsShape(t *testing.T) { + root := t.TempDir() + rec := record(nil, "a\n", "b\n", 7) + if err := WriteAgentEdit(root, "inbox/Plan.md", rec); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(root, ".zennotes", "agent-edits", "inbox", "Plan.md.json")) + if err != nil { + t.Fatal(err) + } + // The desktop parses this file: same keys, empty lists as [], never null. + var shape map[string]any + if err := json.Unmarshal(data, &shape); err != nil { + t.Fatal(err) + } + for _, key := range []string{"version", "before", "after", "edits", "outside"} { + if _, ok := shape[key]; !ok { + t.Fatalf("missing %q in %s", key, data) + } + } + if shape["outside"] == nil { + t.Fatalf("outside must be [] not null: %s", data) + } + back := ReadAgentEdit(root, "inbox/Plan.md") + if back == nil || back.Before != "a\n" || *back.After != "b\n" || back.Edits[0].Agent != "Claude Code" { + t.Fatalf("back = %+v", back) + } + if err := WriteAgentEdit(root, "inbox/Plan.md", nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "agent-edits")); !os.IsNotExist(err) { + t.Fatalf("an emptied agent-edits folder should be gone: %v", err) + } +} + +func TestAgentEditReadsWhatTheAppWrites(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, ".zennotes", "agent-edits", "inbox") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + // JSON.stringify of a record from @shared/agent-edits, after a partly done review. + app := `{"version":1,"before":"A\nb\n","after":"A\nB\n","edits":[{"at":1791820000000,"agent":"Claude Code","tool":"replace_in_note"}],"outside":[]}` + "\n" + if err := os.WriteFile(filepath.Join(dir, "Plan.md.json"), []byte(app), 0o644); err != nil { + t.Fatal(err) + } + got := ReadAgentEdit(root, "inbox/Plan.md") + if got == nil || got.Before != "A\nb\n" || got.Edits[0].At != 1791820000000 { + t.Fatalf("got = %+v", got) + } + if ReadAgentEdit(root, "inbox/Missing.md") != nil { + t.Fatal("a missing record must read as nil") + } +} + +func TestAgentEditFollowsTheNoteAndGoesWithIt(t *testing.T) { + v := newTestVault(t) + if _, err := v.WriteNote("inbox/Plan.md", "# Plan\n"); err != nil { + t.Fatal(err) + } + if err := WriteAgentEdit(v.root, "inbox/Plan.md", record(nil, "# Plan\n", "# Plan\nmore\n", 1)); err != nil { + t.Fatal(err) + } + meta, err := v.RenameNote("inbox/Plan.md", "Roadmap") + if err != nil { + t.Fatal(err) + } + if ReadAgentEdit(v.root, "inbox/Plan.md") != nil || ReadAgentEdit(v.root, meta.Path) == nil { + t.Fatalf("the record should follow the rename to %s", meta.Path) + } + if err := v.DeleteNote(meta.Path); err != nil { + t.Fatal(err) + } + if ReadAgentEdit(v.root, meta.Path) != nil { + t.Fatal("deleting the note should delete its record") + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index bce782e..9e77feb 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -468,8 +468,8 @@ func (v *Vault) WriteNote(rel, body string) (NoteMeta, error) { } // Refuse anything outside the note folders BEFORE a byte lands. This used // to write first and check after, so write_note on `.zennotes/...` created - // the file and only then failed: an assistant could plant or alter a note's - // comments, its creation date, or anything else the app keeps there. + // the file and only then failed: an assistant could plant comments, + // creation dates or the agent edit records that say what a note was. folder, ok := v.folderOfAbs(abs) if !ok { return NoteMeta{}, errNotInFolder(rel) @@ -636,6 +636,7 @@ func (v *Vault) renameNoteFile(rel, nextTitle string) (NoteMeta, error) { return NoteMeta{}, err } _ = v.moveNoteComments(v.relPosix(abs), v.relPosix(target)) + _ = v.moveAgentEdit(v.relPosix(abs), v.relPosix(target)) } v.syncTitleHeading(abs, target, trimmed) return v.readMeta(target, folder, v.Settings().TypstPreambleFolder) @@ -716,6 +717,7 @@ func (v *Vault) moveBetweenFolders(rel string, target NoteFolder) (NoteMeta, err } v.invalidateLayout() _ = v.moveNoteComments(v.relPosix(abs), v.relPosix(destAbs)) + _ = v.moveAgentEdit(v.relPosix(abs), v.relPosix(destAbs)) return v.readMeta(destAbs, target, v.Settings().TypstPreambleFolder) } @@ -777,6 +779,7 @@ func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (N } v.invalidateLayout() _ = v.moveNoteComments(v.relPosix(oldAbs), v.relPosix(destAbs)) + _ = v.moveAgentEdit(v.relPosix(oldAbs), v.relPosix(destAbs)) return v.readMeta(destAbs, target, v.Settings().TypstPreambleFolder) } @@ -827,6 +830,7 @@ func (v *Vault) DeleteNote(rel string) error { if err := v.removeMetadata(v.relPosix(abs), false); err != nil { return err } + _ = v.removeAgentEdit(v.relPosix(abs)) return v.removeNoteComments(v.relPosix(abs)) } @@ -848,6 +852,7 @@ func (v *Vault) EmptyTrash() error { return err } _ = v.removeNoteComments(trashRel + "/" + e.Name()) + _ = v.removeAgentEditTree(trashRel + "/" + e.Name()) } return nil } From 9650f8d6ce0c87c94e11c61719aa5cfeb7ba7b4b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 12 Oct 2026 18:36:44 -0500 Subject: [PATCH 3/3] Copy(cli): zn's own text reads without long dashes ZenNotes desktop 2.68.0 rewrote every long dash (U+2014) in the text a user reads, because it reads as machine-written. zn carries copies of three of those texts, and they change with it word for word: the MCP server's instructions and its tool descriptions, which assistants show to people, and the built-in templates. The Meeting Notes and 1:1 templates now title new notes "Meeting 2026-10-12" and "1-1 2026-10-12"; notes created before keep their names. Two lines of zn's own output change as well: `zn server status` prints "home 2.68.0 at http://127.0.0.1:7878", and installing or updating a server ends with "home: install complete (version 2.68.0, )". --- internal/cli/commands_server.go | 4 ++-- internal/mcp/instructions.go | 28 ++++++++++++++-------------- internal/mcp/server.go | 12 ++++++------ internal/templates/templates.go | 6 +++--- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/internal/cli/commands_server.go b/internal/cli/commands_server.go index 7b520f1..e21955a 100644 --- a/internal/cli/commands_server.go +++ b/internal/cli/commands_server.go @@ -62,7 +62,7 @@ func cmdServer(ctx context.Context, action string, args Args) error { if args.Bool("json") { emitJSON(status) } else { - emitLine(fmt.Sprintf("%s %s — %s\nActive: %t Healthy: %t\nVault: %s", name, status.Version, status.URL, status.Active, status.Healthy, status.Vault)) + emitLine(fmt.Sprintf("%s %s at %s\nActive: %t Healthy: %t\nVault: %s", name, status.Version, status.URL, status.Active, status.Healthy, status.Vault)) if status.Error != "" { emitLine(status.Error) } @@ -122,7 +122,7 @@ func cmdServer(ctx context.Context, action string, args Args) error { if args.Bool("json") { emitJSON(i) } else { - emitOK(fmt.Sprintf("%s: %s complete — version %s, %s", i.Name, action, i.Version, i.URL())) + emitOK(fmt.Sprintf("%s: %s complete (version %s, %s)", i.Name, action, i.Version, i.URL())) } return nil } diff --git a/internal/mcp/instructions.go b/internal/mcp/instructions.go index 58b2092..41819db 100644 --- a/internal/mcp/instructions.go +++ b/internal/mcp/instructions.go @@ -22,7 +22,7 @@ func ResolveInstructions() string { // DefaultInstructions is a verbatim copy of the desktop MCP server's // instructions (apps/desktop/src/mcp/instructions.ts), so an agent behaves // the same whichever binary is serving the vault. -const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 plain .md files on\n" + +const DefaultInstructions = "You are connected to a user's ZenNotes vault: plain .md files on\n" + "disk, rendered live with KaTeX, TikZ, function-plot, JSXGraph, and\n" + "Mermaid. Treat the vault like a shared filesystem. Prompts will be\n" + "short; you must infer the right note shape, voice, and visuals from\n" + @@ -69,10 +69,10 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 "\n" + "ZenNotes vaults run in one of two modes:\n" + "\n" + - "- `primaryNotesLocation: inbox` \u2014 notes for the conceptual inbox\n" + + "- `primaryNotesLocation: inbox`: notes for the conceptual inbox\n" + " area live under `/inbox/` (paths look like\n" + " `inbox/MyNote.md`).\n" + - "- `primaryNotesLocation: root` \u2014 Obsidian-style. Notes for the\n" + + "- `primaryNotesLocation: root`: Obsidian-style. Notes for the\n" + " conceptual inbox area live directly at the vault root (paths look\n" + " like `MyNote.md`, no `inbox/` prefix).\n" + "\n" + @@ -81,7 +81,7 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 "this machine, or a self-hosted ZenNotes server the desktop app is\n" + "connected to (the tools behave the same either way; paths are what the\n" + "server reports). The folder enum in tool arguments (`inbox`,\n" + - "`quick`, `archive`, `trash`) stays the same in both modes \u2014 only\n" + + "`quick`, `archive`, `trash`) stays the same in both modes; only\n" + "the on-disk shape differs. The `path` returned by every tool already\n" + "reflects the mode; use it verbatim and you never have to think about\n" + "this again.\n" + @@ -149,7 +149,7 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 " order (`01 - Vectors.md`, `02 - Vector Spaces.md`).\n" + "- Create a **map / index note first** (`00 - Course Map.md` or\n" + " `README.md`). Update it with a `[[wikilink]]` as each new note is\n" + - " created \u2014 append_to_note is perfect for this.\n" + + " created (append_to_note is perfect for this).\n" + "- Keep **heading structure, section names, voice, and level of\n" + " detail identical** across sibling notes. If module 1 has \"Intuition\n" + " / Definition / Examples / Exercises / Related\", every module does.\n" + @@ -165,7 +165,7 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 "\n" + "**Diagrams \u2192 native renderers. ASCII art is banned.** A drawing\n" + "made of `/ \\ | - + * ^ . o > < \u2192 \u2190 \u2191 \u2193 \u2022` or\n" + - "box-drawing characters \u2014 fenced or not \u2014 is a bug. If you\n" + + "box-drawing characters, fenced or not, is a bug. If you\n" + "catch yourself about to make one, delete it and emit a ```tikz (or\n" + "```function-plot / ```jsxgraph / ```mermaid) block instead.\n" + "Triggers that mean \"use TikZ, not ASCII\": axes, origin, point at,\n" + @@ -174,12 +174,12 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 "perpendicular, before/after, tree, graph, stack, heap, ring, lattice.\n" + "\n" + "Pick the renderer by intent:\n" + - "- ```tikz \u2014 vectors, geometric figures, commutative diagrams,\n" + + "- ```tikz: vectors, geometric figures, commutative diagrams,\n" + " math pictures. Default for anything you'd draw in LaTeX.\n" + - "- ```function-plot \u2014 Cartesian plots, parametric curves, 2D\n" + + "- ```function-plot: Cartesian plots, parametric curves, 2D\n" + " vectors over axes.\n" + - "- ```jsxgraph \u2014 interactive / draggable constructions.\n" + - "- ```mermaid \u2014 flow, sequence, class, state, ER, mindmap,\n" + + "- ```jsxgraph: interactive / draggable constructions.\n" + + "- ```mermaid: flow, sequence, class, state, ER, mindmap,\n" + " Gantt. NOT for coordinate geometry.\n" + "\n" + "Fence body rules:\n" + @@ -192,7 +192,7 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 "\n" + "**Wikilinks \u2192 aggressive.** First mention of a concept, person,\n" + "project, or term that has (or deserves) its own note becomes a\n" + - "`[[wikilink]]` \u2014 not bold, not italics. Before writing, run\n" + + "`[[wikilink]]`, not bold, not italics. Before writing, run\n" + "list_notes / search_by_title. After writing, rescan for wikilink\n" + "candidates. Non-trivial notes end with a `## Related` section of\n" + "2\u20136 wikilinks. Notes with zero inbound + zero outbound links are\n" + @@ -215,13 +215,13 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 "- read_note before overwriting. Always.\n" + "- Surgical edits: append_to_note, prepend_to_note, replace_in_note.\n" + " write_note only for full rewrites the user asked for.\n" + - "- create_note (not write_note) for new notes \u2014 it sanitizes\n" + + "- create_note (not write_note) for new notes: it sanitizes\n" + " filenames and avoids collisions.\n" + "- Preserve frontmatter and unknown content verbatim.\n" + "- Deletion: move_to_trash first, empty_trash only with explicit\n" + " confirmation.\n" + "- Before rename_note, run backlinks.\n" + - "- Task ids from list_tasks (`path#index`) are stable \u2014 pass\n" + + "- Task ids from list_tasks (`path#index`) are stable, so pass\n" + " them to toggle_task.\n" + "- list_tasks omits notes opted out of Tasks (frontmatter\n" + " `tasks: false`/`note`, excluded folders). Pass\n" + @@ -289,7 +289,7 @@ const DefaultInstructions = "You are connected to a user's ZenNotes vault \u2014 " B -- no --> D[Do Y]\n" + "```\n" + "\n" + - "Adjust these \u2014 don't invent ASCII replacements.\n" + + "Adjust these. Don't invent ASCII replacements.\n" + "\n" + "## Intent mapping\n" + "\n" + diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b34fe9e..e26eadd 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -215,7 +215,7 @@ func tools() []toolDef { }, { name: "list_notes", - description: "List notes in the vault with metadata (title, folder, tags, wikilinks, excerpt, timestamps). Optional filters narrow by folder, tag, or wikilink target. Use this before editing to pick the right note. Trashed notes are included only when folder='trash' is explicit. The returned `path` field is the canonical reference — pass it back verbatim to read_note / write_note / move_note. In `primaryNotesLocation: root` vaults, inbox notes have a path WITHOUT an `inbox/` prefix (e.g. `MyNote.md`); never add one. Each note also carries `link`, a clickable zennotes:// URL that opens it in the app; use it verbatim when rendering note titles as markdown links for the user.", + description: "List notes in the vault with metadata (title, folder, tags, wikilinks, excerpt, timestamps). Optional filters narrow by folder, tag, or wikilink target. Use this before editing to pick the right note. Trashed notes are included only when folder='trash' is explicit. The returned `path` field is the canonical reference: pass it back verbatim to read_note / write_note / move_note. In `primaryNotesLocation: root` vaults, inbox notes have a path WITHOUT an `inbox/` prefix (e.g. `MyNote.md`); never add one. Each note also carries `link`, a clickable zennotes:// URL that opens it in the app; use it verbatim when rendering note titles as markdown links for the user.", schema: `{"type":"object","properties":{ "folder":{"type":"string","enum":["inbox","quick","archive","trash"],"description":"Only return notes in this top-level folder."}, "subpath":{"type":"string","description":"POSIX subpath under the folder (e.g. \"Work/Meetings\"). Requires folder to be set."}, @@ -311,7 +311,7 @@ func tools() []toolDef { }, { name: "read_note", - description: "Read the full body and metadata of a single note. Always read before you write. Use the path verbatim from list_notes / search_* / create_note — never invent or prefix it.", + description: "Read the full body and metadata of a single note. Always read before you write. Use the path verbatim from list_notes / search_* / create_note. Never invent or prefix it.", schema: `{"type":"object","properties":{"path":{"type":"string","description":"Vault-relative POSIX path exactly as another tool returned it. In ` + "`primaryNotesLocation: root`" + ` vaults this looks like \"MyNote.md\"; in ` + "`inbox`" + ` mode it looks like \"inbox/MyNote.md\". Pass it through unchanged."}},"required":["path"]}`, handler: func(ctx context.Context, a args, b Backend) (any, error) { rel, err := a.requireString("path") @@ -323,7 +323,7 @@ func tools() []toolDef { }, { name: "write_note", - description: "Overwrite the full body of an existing note. Destructive — prefer append_to_note / replace_in_note for incremental edits. Keep frontmatter intact unless the user asked you to change it.", + description: "Overwrite the full body of an existing note. Destructive, so prefer append_to_note / replace_in_note for incremental edits. Keep frontmatter intact unless the user asked you to change it.", schema: `{"type":"object","properties":{"path":{"type":"string","description":"Vault-relative POSIX path."},"body":{"type":"string","description":"New full markdown body."}},"required":["path","body"]}`, handler: func(ctx context.Context, a args, b Backend) (any, error) { rel, err := a.requireString("path") @@ -339,7 +339,7 @@ func tools() []toolDef { }, { name: "create_note", - description: "Create a new note. Always picks a non-conflicting filename by appending a counter if needed. Use the `path` from the response to refer to the note in any follow-up tool calls — DO NOT reconstruct it from `folder + title`. In root-mode vaults, `folder: \"inbox\"` means the note lands directly at the vault root with a path like \"MyNote.md\" (no inbox/ prefix).", + description: "Create a new note. Always picks a non-conflicting filename by appending a counter if needed. Use the `path` from the response to refer to the note in any follow-up tool calls. DO NOT reconstruct it from `folder + title`. In root-mode vaults, `folder: \"inbox\"` means the note lands directly at the vault root with a path like \"MyNote.md\" (no inbox/ prefix).", schema: `{"type":"object","properties":{ "folder":{"type":"string","enum":["inbox","quick","archive"],"description":"Conceptual folder. Refuses \"trash\". In ` + "`primaryNotesLocation: inbox`" + ` vaults, \"inbox\" maps to /inbox/; in ` + "`primaryNotesLocation: root`" + ` vaults, \"inbox\" maps to the vault root itself. quick/ and archive/ are always real subdirectories. Default if omitted: \"inbox\"."}, "title":{"type":"string","description":"Human-readable title. Becomes the filename (sanitized). Optional; defaults to \"Untitled\"."}, @@ -423,7 +423,7 @@ func tools() []toolDef { }), { name: "empty_trash", - description: "Permanently delete every note in trash/. Confirm with the user before calling — this is irreversible.", + description: "Permanently delete every note in trash/. Confirm with the user before calling: this is irreversible.", schema: `{"type":"object","properties":{"confirm":{"type":"boolean","description":"Must be true. Prevents accidental triggers."}},"required":["confirm"]}`, handler: func(ctx context.Context, a args, b Backend) (any, error) { if !a.boolIs("confirm") { @@ -722,7 +722,7 @@ func tools() []toolDef { return out, nil }, }, - pathTool("backlinks", "Return every note that links to the given note via [[wikilink]]. Run this before renaming a note — the rename will orphan these links.", func(ctx context.Context, b Backend, rel string) (any, error) { + pathTool("backlinks", "Return every note that links to the given note via [[wikilink]]. Run this before renaming a note: the rename will orphan these links.", func(ctx context.Context, b Backend, rel string) (any, error) { return b.Backlinks(ctx, rel) }), { diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 8499aca..e701ced 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -49,14 +49,14 @@ func Builtins() []Template { "# {{title}}\n\n- **Date:** {{date}}\n- **Severity:**\n- **Environment:**\n\n## Steps to reproduce\n\n1. {{cursor}}\n\n## Expected\n\n## Actual\n\n## Notes\n\n"), builtin("builtin.postmortem", "Postmortem", "Incident review: timeline, root cause, action items", "Engineering", "", "# {{title}}\n\n- **Date:** {{date}}\n- **Authors:**\n- **Impact:**\n\n## Summary\n\n{{cursor}}\n\n## Timeline\n\n## Root cause\n\n## Resolution\n\n## Action items\n\n- [ ]\n\n## Related\n\n"), - builtin("builtin.meeting", "Meeting Notes", "Agenda, notes, decisions, and action items", "Engineering", "Meeting — {{date:YYYY-MM-DD}}", + builtin("builtin.meeting", "Meeting Notes", "Agenda, notes, decisions, and action items", "Engineering", "Meeting {{date:YYYY-MM-DD}}", "# {{title}}\n\n- **Date:** {{date}}\n- **Attendees:**\n\n## Agenda\n\n- {{cursor}}\n\n## Notes\n\n## Decisions\n\n## Action items\n\n- [ ]\n\n"), - builtin("builtin.oneonone", "1:1", "One-on-one: wins, blockers, growth, follow-ups", "Engineering", "1-1 — {{date:YYYY-MM-DD}}", + builtin("builtin.oneonone", "1:1", "One-on-one: wins, blockers, growth, follow-ups", "Engineering", "1-1 {{date:YYYY-MM-DD}}", "# {{title}}\n\n- **Date:** {{date}}\n\n## Wins\n\n{{cursor}}\n\n## Challenges & blockers\n\n## Growth & feedback\n\n## Follow-ups\n\n- [ ]\n\n"), builtin("builtin.daily", "Daily Note", "A dated daily log with focus, schedule, and tasks", "Personal", "{{date:YYYY-MM-DD}}", "# {{date:dddd, MMMM D, YYYY}}\n\n## Focus\n\n- {{cursor}}\n\n## Schedule\n\n## Notes\n\n## Tasks\n\n- [ ]\n\n## Log\n\n"), builtin("builtin.weekly", "Weekly Review", "Review last week and plan the next", "Personal", "{{date:YYYY}}-W{{week}}", - "# Week {{week}}, {{date:YYYY}}\n\n## Last week — review\n\n- {{cursor}}\n\n## Wins\n\n## This week — plan\n\n- [ ]\n\n## Carry-overs\n\n## Notes\n\n## Related\n\n"), + "# Week {{week}}, {{date:YYYY}}\n\n## Last week: review\n\n- {{cursor}}\n\n## Wins\n\n## This week: plan\n\n- [ ]\n\n## Carry-overs\n\n## Notes\n\n## Related\n\n"), builtin("builtin.reading", "Reading Notes", "Notes on a book or article: ideas, quotes, takeaways", "Personal", "", "# {{title}}\n\n- **Author:**\n- **Started:** {{date}}\n- **Status:** Reading\n\n## Key ideas\n\n- {{cursor}}\n\n## Quotes\n\n>\n\n## Takeaways\n\n## Related\n\n"), builtin("builtin.journal", "Journal", "A free-form, first-person dated entry", "Personal", "{{date:YYYY-MM-DD}}",