@openparachute/vault 0.4.8 → 0.4.9-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/core/src/hooks.test.ts +320 -1
  2. package/core/src/hooks.ts +243 -38
  3. package/core/src/mcp.ts +35 -0
  4. package/core/src/portable-md.test.ts +252 -1
  5. package/core/src/portable-md.ts +370 -2
  6. package/core/src/schema.ts +51 -2
  7. package/core/src/store.ts +68 -2
  8. package/package.json +1 -1
  9. package/src/auth.ts +29 -1
  10. package/src/auto-transcribe.test.ts +7 -2
  11. package/src/auto-transcribe.ts +6 -2
  12. package/src/export-watch.test.ts +74 -0
  13. package/src/export-watch.ts +108 -7
  14. package/src/github-device-flow.test.ts +404 -0
  15. package/src/github-device-flow.ts +415 -0
  16. package/src/mcp-http.ts +24 -36
  17. package/src/mcp-tools.ts +286 -2
  18. package/src/mirror-config.test.ts +184 -14
  19. package/src/mirror-config.ts +220 -24
  20. package/src/mirror-credentials.test.ts +450 -0
  21. package/src/mirror-credentials.ts +577 -0
  22. package/src/mirror-deps.ts +42 -1
  23. package/src/mirror-import.test.ts +550 -0
  24. package/src/mirror-import.ts +484 -0
  25. package/src/mirror-manager.test.ts +423 -12
  26. package/src/mirror-manager.ts +579 -62
  27. package/src/mirror-routes.test.ts +966 -10
  28. package/src/mirror-routes.ts +1096 -5
  29. package/src/module-config.ts +11 -5
  30. package/src/routing.test.ts +92 -1
  31. package/src/routing.ts +165 -1
  32. package/src/server.ts +21 -8
  33. package/src/token-store.ts +158 -5
  34. package/src/transcription-worker.ts +9 -4
  35. package/src/triggers.ts +16 -3
  36. package/src/vault.test.ts +380 -1
  37. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  38. package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/core/src/hooks.ts CHANGED
@@ -47,7 +47,34 @@
47
47
 
48
48
  import type { Note, Store, Attachment } from "./types.js";
49
49
 
50
- export type HookEvent = "created" | "updated";
50
+ /**
51
+ * Note-mutation events. `"created"` and `"updated"` carry the full post-write
52
+ * `Note`; `"deleted"` carries only `{ id, path }` — the row is gone by
53
+ * dispatch time, so handlers can't re-read it. Down-stream consumers (e.g.
54
+ * the git-mirror) match by id/path and react (remove file, etc.). Predicate
55
+ * authors writing a `when(note)` for `"deleted"` should rely on `note.id` /
56
+ * `note.path` only; everything else is undefined for the deleted shape.
57
+ */
58
+ export type HookEvent = "created" | "updated" | "deleted";
59
+
60
+ /**
61
+ * Minimal identity payload for a deleted note. The row is gone by dispatch
62
+ * time, so handlers can't go back to the store for the rest. Path is
63
+ * optional because notes without a path are legal (e.g. fragments captured
64
+ * via API without a target slot).
65
+ */
66
+ export interface DeletedNoteRef {
67
+ id: string;
68
+ path?: string;
69
+ }
70
+
71
+ /**
72
+ * What a hook handler receives. For `"created"` / `"updated"` it's the full
73
+ * `Note` row; for `"deleted"` only the id/path remain. This union keeps the
74
+ * common predicates (path-prefix gates, tag checks for non-deleted shapes)
75
+ * working unchanged while making the "deleted has less" reality type-safe.
76
+ */
77
+ export type NoteHookPayload = Note | DeletedNoteRef;
51
78
 
52
79
  export interface NoteHook {
53
80
  /** Events this hook listens for. Defaults to ["created", "updated"]. */
@@ -57,10 +84,26 @@ export interface NoteHook {
57
84
  * Should be cheap and synchronous. Idempotency lives here: check
58
85
  * whether a marker (e.g. `metadata.audio_rendered_at`) is already set
59
86
  * and return false if so.
87
+ *
88
+ * For `"deleted"` events the payload is a `DeletedNoteRef` — only
89
+ * `id` / `path` are populated; tag/metadata/content fields are
90
+ * undefined. Predicates that read those fields effectively skip
91
+ * the deleted shape unless they handle the narrower payload.
92
+ */
93
+ when?: (note: NoteHookPayload) => boolean;
94
+ /**
95
+ * Handler — runs async, off the request path. Third arg is the event
96
+ * type. For `"deleted"` the payload is a `DeletedNoteRef`, not a full
97
+ * `Note` — the row is gone by dispatch time, so the store can't be
98
+ * queried for it. Handlers needing more context should stash it ahead
99
+ * of time on the note's `metadata` and read off the predicate / the
100
+ * tracking shape rather than re-querying.
60
101
  */
61
- when?: (note: Note) => boolean;
62
- /** Handler — runs async, off the request path. Third arg is the event type. */
63
- handler: (note: Note, store: Store, event?: HookEvent) => Promise<void> | void;
102
+ handler: (
103
+ note: NoteHookPayload,
104
+ store: Store,
105
+ event?: HookEvent,
106
+ ) => Promise<void> | void;
64
107
  /** Optional label for logs. */
65
108
  name?: string;
66
109
  }
@@ -70,22 +113,46 @@ interface RegisteredHook extends NoteHook {
70
113
  }
71
114
 
72
115
  /**
73
- * Attachment-mutation events. Today only `"created"` is dispatched the
74
- * transcription worker (and any future attachment-aware feature) registers
75
- * here to move off its poll-driven steady state and onto the same event bus
76
- * that note hooks use. Keeping attachments separate from notes means a
77
- * `NoteHook` predicate doesn't have to learn a second argument shape.
116
+ * Attachment-mutation events. `"created"` carries the full attachment;
117
+ * `"deleted"` carries only `{ id, note_id, path }` (the row is gone by
118
+ * dispatch time, same shape rule as deleted notes).
119
+ *
120
+ * Consumers (transcription worker, git-mirror) subscribe here to react to
121
+ * lifecycle changes without polling. Keeping attachments separate from
122
+ * notes means a `NoteHook` predicate doesn't have to learn a second
123
+ * argument shape.
124
+ */
125
+ export type AttachmentHookEvent = "created" | "deleted";
126
+
127
+ /**
128
+ * Identity payload for a deleted attachment. The DB row is gone by
129
+ * dispatch time, so handlers can only react to id/note_id/path; metadata
130
+ * and timestamps are not preserved.
78
131
  */
79
- export type AttachmentHookEvent = "created";
132
+ export interface DeletedAttachmentRef {
133
+ id: string;
134
+ noteId: string;
135
+ path: string;
136
+ }
137
+
138
+ /** Union over what an attachment hook handler may receive. */
139
+ export type AttachmentHookPayload = Attachment | DeletedAttachmentRef;
80
140
 
81
141
  export interface AttachmentHook {
82
142
  /** Events this hook listens for. Defaults to ["created"]. */
83
143
  event?: AttachmentHookEvent | AttachmentHookEvent[];
84
- /** Sync predicate. Same idempotency contract as `NoteHook.when`. */
85
- when?: (attachment: Attachment) => boolean;
86
- /** Handler runs async, off the request path. */
144
+ /**
145
+ * Sync predicate. Same idempotency contract as `NoteHook.when`. For
146
+ * `"deleted"` the payload is a `DeletedAttachmentRef` predicates
147
+ * relying on `metadata` / `createdAt` will see `undefined`.
148
+ */
149
+ when?: (attachment: AttachmentHookPayload) => boolean;
150
+ /**
151
+ * Handler — runs async, off the request path. For `"deleted"` the
152
+ * payload is a `DeletedAttachmentRef`, not a full `Attachment`.
153
+ */
87
154
  handler: (
88
- attachment: Attachment,
155
+ attachment: AttachmentHookPayload,
89
156
  store: Store,
90
157
  event?: AttachmentHookEvent,
91
158
  ) => Promise<void> | void;
@@ -97,6 +164,37 @@ interface RegisteredAttachmentHook extends AttachmentHook {
97
164
  events: Set<AttachmentHookEvent>;
98
165
  }
99
166
 
167
+ /**
168
+ * Tag-mutation events. `"upserted"` fires on tag-record create/update
169
+ * (description, fields, relationships, parent_names — any mutation that
170
+ * could change the schema sidecar that the git-mirror writes). `"deleted"`
171
+ * fires when the tag row is removed.
172
+ *
173
+ * Why this exists: the export sidecars at `.parachute/schemas/<tag>.yaml`
174
+ * are part of the mirror's output. Without a tag-mutation event the mirror
175
+ * has no signal that those files might need to change.
176
+ */
177
+ export type TagHookEvent = "upserted" | "deleted";
178
+
179
+ export interface TagHook {
180
+ /** Events this hook listens for. Defaults to ["upserted", "deleted"]. */
181
+ event?: TagHookEvent | TagHookEvent[];
182
+ /** Sync predicate keyed on the tag name. */
183
+ when?: (tag: string) => boolean;
184
+ /** Handler — runs async, off the request path. */
185
+ handler: (
186
+ tag: string,
187
+ store: Store,
188
+ event?: TagHookEvent,
189
+ ) => Promise<void> | void;
190
+ /** Optional label for logs. */
191
+ name?: string;
192
+ }
193
+
194
+ interface RegisteredTagHook extends TagHook {
195
+ events: Set<TagHookEvent>;
196
+ }
197
+
100
198
  /**
101
199
  * Tiny async semaphore — FIFO waiters, no dependencies.
102
200
  * Used to cap concurrent handler execution across all hooks.
@@ -139,6 +237,7 @@ export interface HookRegistryOptions {
139
237
  export class HookRegistry {
140
238
  private hooks: RegisteredHook[] = [];
141
239
  private attachmentHooks: RegisteredAttachmentHook[] = [];
240
+ private tagHooks: RegisteredTagHook[] = [];
142
241
  private semaphore: Semaphore;
143
242
  private inFlight = new Set<Promise<void>>();
144
243
  private logger: { error: (...args: unknown[]) => void };
@@ -150,7 +249,15 @@ export class HookRegistry {
150
249
  this.logger = opts.logger ?? console;
151
250
  }
152
251
 
153
- /** Register a hook. Returns an unregister function. */
252
+ /**
253
+ * Register a note-mutation hook. Returns an unregister function.
254
+ *
255
+ * The default event set is `["created", "updated"]` — explicit
256
+ * `event: "deleted"` (or include it in the array) is required to
257
+ * subscribe to deletions. This keeps existing hooks that pre-date the
258
+ * `"deleted"` event from suddenly receiving a payload shape
259
+ * (`DeletedNoteRef`) they weren't typed for.
260
+ */
154
261
  onNote(hook: NoteHook): () => void {
155
262
  const events = new Set<HookEvent>(
156
263
  Array.isArray(hook.event)
@@ -167,7 +274,13 @@ export class HookRegistry {
167
274
  };
168
275
  }
169
276
 
170
- /** Register an attachment-mutation hook. Returns an unregister function. */
277
+ /**
278
+ * Register an attachment-mutation hook. Returns an unregister function.
279
+ *
280
+ * The default event set is `["created"]` only (matches the historical
281
+ * shape pre-deletion-events). Subscribe to `"deleted"` explicitly when
282
+ * the handler needs to react to attachment removal.
283
+ */
171
284
  onAttachment(hook: AttachmentHook): () => void {
172
285
  const events = new Set<AttachmentHookEvent>(
173
286
  Array.isArray(hook.event)
@@ -184,15 +297,38 @@ export class HookRegistry {
184
297
  };
185
298
  }
186
299
 
300
+ /**
301
+ * Register a tag-mutation hook. Returns an unregister function. Default
302
+ * event set is both `"upserted"` and `"deleted"` — symmetric with the
303
+ * sole consumer's needs (the git-mirror reacts to either to refresh its
304
+ * schema sidecars).
305
+ */
306
+ onTag(hook: TagHook): () => void {
307
+ const events = new Set<TagHookEvent>(
308
+ Array.isArray(hook.event)
309
+ ? hook.event
310
+ : hook.event
311
+ ? [hook.event]
312
+ : (["upserted", "deleted"] as TagHookEvent[]),
313
+ );
314
+ const entry: RegisteredTagHook = { ...hook, events };
315
+ this.tagHooks.push(entry);
316
+ return () => {
317
+ const idx = this.tagHooks.indexOf(entry);
318
+ if (idx >= 0) this.tagHooks.splice(idx, 1);
319
+ };
320
+ }
321
+
187
322
  /** Remove all registered hooks. Mostly for tests. */
188
323
  clear(): void {
189
324
  this.hooks = [];
190
325
  this.attachmentHooks = [];
326
+ this.tagHooks = [];
191
327
  }
192
328
 
193
- /** Count of currently registered hooks (notes + attachments). */
329
+ /** Count of currently registered hooks (notes + attachments + tags). */
194
330
  get size(): number {
195
- return this.hooks.length + this.attachmentHooks.length;
331
+ return this.hooks.length + this.attachmentHooks.length + this.tagHooks.length;
196
332
  }
197
333
 
198
334
  /** Count of currently in-flight handler executions. */
@@ -201,13 +337,19 @@ export class HookRegistry {
201
337
  }
202
338
 
203
339
  /**
204
- * Dispatch a mutation event. Matches hooks, schedules their handlers
205
- * onto a microtask, and returns immediately. The caller is never
206
- * blocked on handler execution.
340
+ * Dispatch a note-mutation event. Matches hooks, schedules their
341
+ * handlers onto a microtask, and returns immediately. The caller is
342
+ * never blocked on handler execution.
207
343
  *
208
344
  * Must only be called after the triggering SQLite write has committed.
345
+ *
346
+ * For `"deleted"` the `note` argument is a `DeletedNoteRef` ({ id,
347
+ * path }) — the row is gone, so the runtime can't re-read it before
348
+ * dispatching to handlers. For `"created"` / `"updated"` the full
349
+ * `Note` is expected; `runHandler` will re-read from the store for
350
+ * non-deleted events to pick up the latest committed state.
209
351
  */
210
- dispatch(event: HookEvent, note: Note, store: Store): void {
352
+ dispatch(event: HookEvent, note: NoteHookPayload, store: Store): void {
211
353
  if (this.hooks.length === 0) return;
212
354
 
213
355
  // Snapshot matches synchronously so subsequent hook registration
@@ -241,13 +383,12 @@ export class HookRegistry {
241
383
 
242
384
  /**
243
385
  * Dispatch an attachment-mutation event. Same post-commit/microtask
244
- * contract as `dispatch()` for notes callers are never blocked on
245
- * handler execution, and the triggering SQLite write must already be
246
- * committed.
386
+ * contract as `dispatch()` for notes. For `"deleted"` the payload is
387
+ * a `DeletedAttachmentRef`; for `"created"` it's the full `Attachment`.
247
388
  */
248
389
  dispatchAttachment(
249
390
  event: AttachmentHookEvent,
250
- attachment: Attachment,
391
+ attachment: AttachmentHookPayload,
251
392
  store: Store,
252
393
  ): void {
253
394
  if (this.attachmentHooks.length === 0) return;
@@ -277,19 +418,61 @@ export class HookRegistry {
277
418
  });
278
419
  }
279
420
 
421
+ /**
422
+ * Dispatch a tag-mutation event. Same post-commit / microtask contract
423
+ * as the note + attachment dispatchers. `tag` is the bare tag name; the
424
+ * git-mirror handler matches on it to identify which `.parachute/schemas/<tag>.yaml`
425
+ * sidecar to rewrite or remove.
426
+ */
427
+ dispatchTag(event: TagHookEvent, tag: string, store: Store): void {
428
+ if (this.tagHooks.length === 0) return;
429
+
430
+ const matches: RegisteredTagHook[] = [];
431
+ for (const hook of this.tagHooks) {
432
+ if (!hook.events.has(event)) continue;
433
+ try {
434
+ if (hook.when && !hook.when(tag)) continue;
435
+ } catch (err) {
436
+ this.logger.error(
437
+ `[hooks] predicate threw for ${hook.name ?? "anonymous"} on tag ${tag}:`,
438
+ err,
439
+ );
440
+ continue;
441
+ }
442
+ matches.push(hook);
443
+ }
444
+ if (matches.length === 0) return;
445
+
446
+ queueMicrotask(() => {
447
+ for (const hook of matches) {
448
+ const task = this.runTagHandler(hook, event, tag, store);
449
+ this.inFlight.add(task);
450
+ task.finally(() => this.inFlight.delete(task));
451
+ }
452
+ });
453
+ }
454
+
280
455
  private async runHandler(
281
456
  hook: RegisteredHook,
282
457
  event: HookEvent,
283
- note: Note,
458
+ note: NoteHookPayload,
284
459
  store: Store,
285
460
  ): Promise<void> {
286
461
  const release = await this.semaphore.acquire();
287
462
  try {
288
- // Re-read the note so the handler sees the latest state (another
289
- // handler may have written back in between). If the note was
290
- // deleted, silently drop.
291
- const fresh = (await store.getNote(note.id)) ?? note;
292
- await hook.handler(fresh, store, event);
463
+ // For non-deleted events, re-read the note so the handler sees the
464
+ // latest committed state (another handler may have written back
465
+ // between dispatch and this acquisition). For "deleted" events the
466
+ // row is gone pass the DeletedNoteRef payload straight through.
467
+ let payload: NoteHookPayload = note;
468
+ if (event !== "deleted") {
469
+ const fresh = await store.getNote(note.id);
470
+ if (fresh) payload = fresh;
471
+ // If the note was deleted between dispatch and re-read, fall
472
+ // back to the dispatch-time payload — same shape as before; the
473
+ // handler can sense the disappearance via its own predicate.
474
+ }
475
+ await hook.handler(payload, store, event);
293
476
  } catch (err) {
294
477
  this.logger.error(
295
478
  `[hooks] handler ${hook.name ?? "anonymous"} threw on ${event} ${note.id}:`,
@@ -303,16 +486,19 @@ export class HookRegistry {
303
486
  private async runAttachmentHandler(
304
487
  hook: RegisteredAttachmentHook,
305
488
  event: AttachmentHookEvent,
306
- attachment: Attachment,
489
+ attachment: AttachmentHookPayload,
307
490
  store: Store,
308
491
  ): Promise<void> {
309
492
  const release = await this.semaphore.acquire();
310
493
  try {
311
- // Re-read the attachment so the handler sees the latest metadata
312
- // (another handler may have written back in between). If the
313
- // attachment was deleted, silently drop.
314
- const fresh = (await store.getAttachment(attachment.id)) ?? attachment;
315
- await hook.handler(fresh, store, event);
494
+ // Symmetric with runHandler: re-read for non-deleted events,
495
+ // pass straight through on delete.
496
+ let payload: AttachmentHookPayload = attachment;
497
+ if (event !== "deleted") {
498
+ const fresh = await store.getAttachment(attachment.id);
499
+ if (fresh) payload = fresh;
500
+ }
501
+ await hook.handler(payload, store, event);
316
502
  } catch (err) {
317
503
  this.logger.error(
318
504
  `[hooks] attachment handler ${hook.name ?? "anonymous"} threw on ${event} ${attachment.id}:`,
@@ -323,6 +509,25 @@ export class HookRegistry {
323
509
  }
324
510
  }
325
511
 
512
+ private async runTagHandler(
513
+ hook: RegisteredTagHook,
514
+ event: TagHookEvent,
515
+ tag: string,
516
+ store: Store,
517
+ ): Promise<void> {
518
+ const release = await this.semaphore.acquire();
519
+ try {
520
+ await hook.handler(tag, store, event);
521
+ } catch (err) {
522
+ this.logger.error(
523
+ `[hooks] tag handler ${hook.name ?? "anonymous"} threw on ${event} ${tag}:`,
524
+ err,
525
+ );
526
+ } finally {
527
+ release();
528
+ }
529
+ }
530
+
326
531
  /**
327
532
  * Wait for all currently in-flight handlers to settle. Best-effort
328
533
  * drain for graceful shutdown. New hooks dispatched during the drain
package/core/src/mcp.ts CHANGED
@@ -20,6 +20,18 @@ export interface McpToolDef {
20
20
  description: string;
21
21
  inputSchema: Record<string, unknown>;
22
22
  execute: (params: Record<string, unknown>) => unknown | Promise<unknown>;
23
+ /**
24
+ * Minimum scope verb the caller must hold for THIS vault to see + invoke
25
+ * the tool. `read` for pure queries, `write` for mutations, `admin` for
26
+ * token-management surfaces (only `manage-token` in the current set —
27
+ * core's nine tools cap at `write`). The MCP HTTP layer filters
28
+ * `tools/list` by this field and verb-gates `tools/call` against it; the
29
+ * filter is the primary defense, the inner gate is defense-in-depth.
30
+ *
31
+ * Pre-v19 unstamped tools default to `write` at the dispatch layer so a
32
+ * future addition that forgets to stamp this gets the safer treatment.
33
+ */
34
+ requiredVerb: "read" | "write" | "admin";
23
35
  }
24
36
 
25
37
  // ---------------------------------------------------------------------------
@@ -102,6 +114,7 @@ export function generateMcpTools(store: Store): McpToolDef[] {
102
114
  // =====================================================================
103
115
  {
104
116
  name: "query-notes",
117
+ requiredVerb: "read",
105
118
  description: `Query notes. Returns notes matching the given filters.
106
119
 
107
120
  - **Single note**: pass \`id\` (accepts note ID or path, e.g., "Projects/README")
@@ -403,6 +416,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
403
416
  // =====================================================================
404
417
  {
405
418
  name: "create-note",
419
+ requiredVerb: "write",
406
420
  description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.`,
407
421
  inputSchema: {
408
422
  type: "object",
@@ -518,6 +532,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
518
532
  // =====================================================================
519
533
  {
520
534
  name: "update-note",
535
+ requiredVerb: "write",
521
536
  description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
522
537
 
523
538
  - Three content-modification modes (mutually exclusive):
@@ -930,6 +945,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
930
945
  // =====================================================================
931
946
  {
932
947
  name: "delete-note",
948
+ // `write` — same destructive verb as update-note. Aaron's call
949
+ // 2026-05-27: "delete- in write; right now the only admin gated
950
+ // thing is tokens." Reserving `admin` for "operator-only
951
+ // capabilities" (token mgmt + future config writes). A future
952
+ // finer-grained model might split `vault:write:no-delete` for
953
+ // genuinely append-only callers — gating WITHIN write rather
954
+ // than promoting deletes out of it.
955
+ requiredVerb: "write",
933
956
  description: "Permanently delete a note and all its tags and links. Accepts ID or path.",
934
957
  inputSchema: {
935
958
  type: "object",
@@ -950,6 +973,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
950
973
  // =====================================================================
951
974
  {
952
975
  name: "list-tags",
976
+ requiredVerb: "read",
953
977
  description: `List tags with usage counts. Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps). Pass \`include_schema: true\` to include the full record for every tag.`,
954
978
  inputSchema: {
955
979
  type: "object",
@@ -1004,6 +1028,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1004
1028
  // =====================================================================
1005
1029
  {
1006
1030
  name: "update-tag",
1031
+ requiredVerb: "write",
1007
1032
  description: "Create or update a tag's identity row: description, indexed-field schemas, typed-link relationships, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
1008
1033
  inputSchema: {
1009
1034
  type: "object",
@@ -1159,6 +1184,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1159
1184
  // =====================================================================
1160
1185
  {
1161
1186
  name: "delete-tag",
1187
+ // `write` — Aaron's call 2026-05-27: admin reserved for token
1188
+ // mgmt + future config writes; deletes are write-tier mutations.
1189
+ // See delete-note rationale.
1190
+ requiredVerb: "write",
1162
1191
  description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
1163
1192
  inputSchema: {
1164
1193
  type: "object",
@@ -1191,6 +1220,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1191
1220
  // =====================================================================
1192
1221
  {
1193
1222
  name: "find-path",
1223
+ requiredVerb: "read",
1194
1224
  description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns the chain of note IDs and relationships, or null if no path exists.",
1195
1225
  inputSchema: {
1196
1226
  type: "object",
@@ -1215,6 +1245,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1215
1245
  // =====================================================================
1216
1246
  {
1217
1247
  name: "vault-info",
1248
+ // `read` so vault:read callers can fetch stats. The
1249
+ // description-update branch performs an inner write-check (see
1250
+ // overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
1251
+ // `write` or read-only callers lose the stats projection.
1252
+ requiredVerb: "read",
1218
1253
  description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and query hints. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution. Pass `description` to update the vault description (changes how AI agents behave in future sessions). Call this anytime mid-session to refresh schema context.",
1219
1254
  inputSchema: {
1220
1255
  type: "object",