@openparachute/vault 0.2.4 → 0.3.0-rc.1

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 (98) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/indexed-fields.test.ts +285 -0
  7. package/core/src/indexed-fields.ts +238 -0
  8. package/core/src/mcp.ts +127 -6
  9. package/core/src/notes.ts +153 -11
  10. package/core/src/query-operators.ts +174 -0
  11. package/core/src/schema.ts +69 -2
  12. package/core/src/store.ts +92 -0
  13. package/core/src/tag-schemas.ts +5 -0
  14. package/core/src/types.ts +28 -1
  15. package/docs/HTTP_API.md +105 -1
  16. package/package/package.json +32 -0
  17. package/package.json +2 -2
  18. package/src/auth.test.ts +83 -114
  19. package/src/auth.ts +68 -6
  20. package/src/backup-launchd.ts +1 -1
  21. package/src/backup.test.ts +1 -1
  22. package/src/backup.ts +18 -17
  23. package/src/cli.ts +179 -121
  24. package/src/config-triggers.test.ts +49 -0
  25. package/src/config.test.ts +317 -2
  26. package/src/config.ts +420 -40
  27. package/src/context.test.ts +136 -0
  28. package/src/context.ts +115 -0
  29. package/src/daemon.ts +17 -16
  30. package/src/doctor.test.ts +9 -7
  31. package/src/launchd.test.ts +1 -1
  32. package/src/launchd.ts +6 -6
  33. package/src/mcp-http.ts +75 -21
  34. package/src/mcp-install.test.ts +125 -0
  35. package/src/mcp-install.ts +60 -0
  36. package/src/mcp-tools.ts +34 -96
  37. package/src/module-config.ts +109 -0
  38. package/src/oauth.test.ts +345 -57
  39. package/src/oauth.ts +155 -35
  40. package/src/published.test.ts +2 -2
  41. package/src/routes.ts +209 -33
  42. package/src/routing.test.ts +817 -300
  43. package/src/routing.ts +204 -202
  44. package/src/scopes.test.ts +136 -0
  45. package/src/scopes.ts +105 -0
  46. package/src/scribe-env.test.ts +49 -0
  47. package/src/scribe-env.ts +33 -0
  48. package/src/server.ts +57 -5
  49. package/src/services-manifest.test.ts +140 -0
  50. package/src/services-manifest.ts +99 -0
  51. package/src/systemd.ts +3 -3
  52. package/src/token-store.ts +42 -9
  53. package/src/transcription-worker.test.ts +583 -0
  54. package/src/transcription-worker.ts +346 -0
  55. package/src/triggers.test.ts +191 -1
  56. package/src/triggers.ts +17 -2
  57. package/src/vault.test.ts +693 -77
  58. package/src/version.test.ts +1 -1
  59. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  60. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  61. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  62. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  63. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  64. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  65. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  66. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  67. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  68. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  69. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  70. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  71. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  72. package/religions-abrahamic-filter.png +0 -0
  73. package/religions-buddhism-v2.png +0 -0
  74. package/religions-buddhism.png +0 -0
  75. package/religions-final.png +0 -0
  76. package/religions-v1.png +0 -0
  77. package/religions-v2.png +0 -0
  78. package/religions-zen.png +0 -0
  79. package/web/README.md +0 -73
  80. package/web/bun.lock +0 -827
  81. package/web/eslint.config.js +0 -23
  82. package/web/index.html +0 -15
  83. package/web/package.json +0 -36
  84. package/web/public/favicon.svg +0 -1
  85. package/web/public/icons.svg +0 -24
  86. package/web/src/App.tsx +0 -149
  87. package/web/src/Graph.tsx +0 -200
  88. package/web/src/NoteView.tsx +0 -155
  89. package/web/src/Sidebar.tsx +0 -186
  90. package/web/src/api.ts +0 -21
  91. package/web/src/index.css +0 -50
  92. package/web/src/main.tsx +0 -10
  93. package/web/src/types.ts +0 -37
  94. package/web/src/utils.ts +0 -107
  95. package/web/tsconfig.app.json +0 -25
  96. package/web/tsconfig.json +0 -7
  97. package/web/tsconfig.node.json +0 -24
  98. package/web/vite.config.ts +0 -16
package/src/config.ts CHANGED
@@ -2,25 +2,41 @@
2
2
  * Configuration management for Parachute Vault.
3
3
  *
4
4
  * Directory layout:
5
- * ~/.parachute/
6
- * config.yaml global server config
7
- * vault.log / vault.err — daemon logs
8
- * vaults/
9
- * {name}/
10
- * vault.db SQLite database
11
- * vault.yaml — per-vault config (description, tool_hints, api_keys)
5
+ * ~/.parachute/ — ecosystem root (shared with sibling services)
6
+ * services.json CLI-owned manifest (services-manifest.ts)
7
+ * well-known/ CLI-owned (.well-known serving)
8
+ * vault/ — everything vault owns
9
+ * .env
10
+ * config.yaml global server config
11
+ * start.sh / server-path — daemon wrapper + pointer (daemon.ts)
12
+ * logs/
13
+ * vault.log / vault.err — daemon stdout/stderr (matches
14
+ * `~/.parachute/<svc>/logs/<svc>.log` — the
15
+ * CLI lifecycle convention from PR #83)
16
+ * data/ — per-vault SQLite data (Postgres-style:
17
+ * named `data/` rather than `vaults/` so it
18
+ * doesn't read as doubled)
19
+ * {name}/
20
+ * vault.db — SQLite database
21
+ * vault.yaml — per-vault config (description, api_keys, …)
22
+ * assets/ — per-vault attachments
23
+ *
24
+ * Pre-0.3 installs put vault state directly under `~/.parachute/`; on startup
25
+ * we auto-migrate those paths into `vault/` (see `migrateFromLegacyLayout`).
26
+ * Pre-filesystem-hygiene 0.3 installs put per-vault state under
27
+ * `vault/vaults/` and daemon logs flat in `vault/`; those are moved into
28
+ * `data/` and `logs/` by `migrateVaultInternalLayout` on startup.
12
29
  */
13
30
 
14
31
  import { homedir } from "os";
15
32
  import { join } from "path";
16
- import { mkdir, readFile, writeFile } from "fs/promises";
17
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
33
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "fs";
18
34
  import crypto from "node:crypto";
19
35
 
20
36
  // ---------------------------------------------------------------------------
21
37
  // Paths
22
38
  //
23
- // Historical note: the exported `CONFIG_DIR`, `VAULTS_DIR`, etc. used to be
39
+ // Historical note: the exported `CONFIG_DIR`, `DATA_DIR`, etc. used to be
24
40
  // `const` captured at module load. That made tests flaky: anything setting
25
41
  // `process.env.PARACHUTE_HOME` after import would be ignored, and when `bun
26
42
  // test` shares one process across files, whichever test loaded first froze
@@ -28,35 +44,50 @@ import crypto from "node:crypto";
28
44
  // getters so `PARACHUTE_HOME` is re-read per call. The top-level constants
29
45
  // are kept for backward-compat (other modules import them) and reflect the
30
46
  // value at load time.
47
+ //
48
+ // `configDirPath()` is the ecosystem root — shared with sibling services
49
+ // (channel, scribe, …) and with the CLI's `services.json` + `well-known/`.
50
+ // `vaultHomePath()` is the vault-scoped subdir; all vault-owned files live
51
+ // under it.
31
52
  // ---------------------------------------------------------------------------
32
53
 
33
54
  function configDirPath(): string {
34
55
  return process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
35
56
  }
36
57
 
37
- function vaultsDirPath(): string {
38
- return join(configDirPath(), "vaults");
58
+ function vaultHomePath(): string {
59
+ return join(configDirPath(), "vault");
60
+ }
61
+
62
+ function dataDirPath(): string {
63
+ return join(vaultHomePath(), "data");
64
+ }
65
+
66
+ function logsDirPath(): string {
67
+ return join(vaultHomePath(), "logs");
39
68
  }
40
69
 
41
70
  function globalConfigPath(): string {
42
- return join(configDirPath(), "config.yaml");
71
+ return join(vaultHomePath(), "config.yaml");
43
72
  }
44
73
 
45
74
  function envFilePath(): string {
46
- return join(configDirPath(), ".env");
75
+ return join(vaultHomePath(), ".env");
47
76
  }
48
77
 
49
78
  export const CONFIG_DIR = configDirPath();
50
- export const VAULTS_DIR = join(CONFIG_DIR, "vaults");
51
- export const GLOBAL_CONFIG_PATH = join(CONFIG_DIR, "config.yaml");
52
- export const ENV_PATH = join(CONFIG_DIR, ".env");
53
- export const LOG_PATH = join(CONFIG_DIR, "vault.log");
54
- export const ERR_PATH = join(CONFIG_DIR, "vault.err");
79
+ export const VAULT_HOME = join(CONFIG_DIR, "vault");
80
+ export const DATA_DIR = join(VAULT_HOME, "data");
81
+ export const LOGS_DIR = join(VAULT_HOME, "logs");
82
+ export const GLOBAL_CONFIG_PATH = join(VAULT_HOME, "config.yaml");
83
+ export const ENV_PATH = join(VAULT_HOME, ".env");
84
+ export const LOG_PATH = join(LOGS_DIR, "vault.log");
85
+ export const ERR_PATH = join(LOGS_DIR, "vault.err");
55
86
  export const DEFAULT_PORT = 1940;
56
- export const ASSETS_DIR = join(CONFIG_DIR, "assets");
87
+ export const ASSETS_DIR = join(VAULT_HOME, "assets");
57
88
 
58
89
  export function vaultDir(name: string): string {
59
- return join(vaultsDirPath(), name);
90
+ return join(dataDirPath(), name);
60
91
  }
61
92
 
62
93
  export function vaultDbPath(name: string): string {
@@ -101,6 +132,29 @@ export interface VaultConfig {
101
132
  tag_schemas?: Record<string, TagSchema>;
102
133
  /** Tag name that marks a note as publicly viewable. Default: "published". */
103
134
  published_tag?: string;
135
+ /**
136
+ * What to do with the audio file on disk once the worker is done with it.
137
+ * - `"keep"` (default): leave the file on disk.
138
+ * - `"until_transcribed"`: unlink once the transcript lands successfully;
139
+ * on failure the file is kept so the user can retry or re-upload.
140
+ * - `"never"`: unlink whenever the worker reaches a terminal state
141
+ * (`done` OR `failed`). Audio is discarded even if transcription
142
+ * failed — users who opt in accept that losing a bad transcription
143
+ * also loses the source audio.
144
+ *
145
+ * In every mode the attachment row (including any stored transcript) is
146
+ * preserved; only the file on disk is affected.
147
+ */
148
+ audio_retention?: "keep" | "until_transcribed" | "never";
149
+ /**
150
+ * Transcription worker settings for this vault. Today only `context` is
151
+ * honored — a list of context predicates the worker attaches to each
152
+ * transcription POST so scribe sees person/project context alongside the
153
+ * audio. Same shape as triggers' `action.include_context`.
154
+ */
155
+ transcription?: {
156
+ context?: TriggerIncludeContext[];
157
+ };
104
158
  }
105
159
 
106
160
  // ---------------------------------------------------------------------------
@@ -131,6 +185,20 @@ export interface TriggerWhen {
131
185
  */
132
186
  export type TriggerSendMode = "json" | "attachment" | "content";
133
187
 
188
+ /**
189
+ * A single `include_context` entry — a query over the vault whose matching
190
+ * notes are serialized as context entries and included alongside the primary
191
+ * webhook payload. See `src/context.ts` for the fetch + serialization rules.
192
+ */
193
+ export interface TriggerIncludeContext {
194
+ /** Tag the note must carry. Required. */
195
+ tag: string;
196
+ /** If set, notes also carrying this tag are excluded. */
197
+ exclude_tag?: string;
198
+ /** Metadata keys to surface on each resulting entry. */
199
+ include_metadata?: string[];
200
+ }
201
+
134
202
  export interface TriggerAction {
135
203
  /** URL to POST the webhook payload to. */
136
204
  webhook: string;
@@ -138,6 +206,12 @@ export interface TriggerAction {
138
206
  timeout?: number;
139
207
  /** How to send data to the webhook. Default "json". */
140
208
  send?: TriggerSendMode;
209
+ /**
210
+ * If present, the trigger pre-fetches the matching vault notes at fire
211
+ * time and attaches them as a `context` JSON part (send=attachment) or a
212
+ * top-level `context` field (send=json). send=content ignores this.
213
+ */
214
+ include_context?: TriggerIncludeContext[];
141
215
  }
142
216
 
143
217
  export interface TriggerConfig {
@@ -265,6 +339,23 @@ function serializeVaultConfig(config: VaultConfig): string {
265
339
  if (config.published_tag) {
266
340
  lines.push(`published_tag: ${config.published_tag}`);
267
341
  }
342
+ if (config.audio_retention) {
343
+ lines.push(`audio_retention: ${config.audio_retention}`);
344
+ }
345
+
346
+ if (config.transcription?.context?.length) {
347
+ lines.push("transcription:");
348
+ lines.push(" context:");
349
+ for (const entry of config.transcription.context) {
350
+ lines.push(` - tag: ${entry.tag}`);
351
+ if (entry.exclude_tag) {
352
+ lines.push(` exclude_tag: ${entry.exclude_tag}`);
353
+ }
354
+ if (entry.include_metadata?.length) {
355
+ lines.push(` include_metadata: [${entry.include_metadata.map((v) => `"${v}"`).join(", ")}]`);
356
+ }
357
+ }
358
+ }
268
359
 
269
360
  lines.push("api_keys:");
270
361
  for (const key of config.api_keys) {
@@ -320,6 +411,12 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
320
411
  const pubTagMatch = yaml.match(/^published_tag:\s*(\S+)/m);
321
412
  if (pubTagMatch) config.published_tag = pubTagMatch[1];
322
413
 
414
+ const retentionMatch = yaml.match(/^audio_retention:\s*(\S+)/m);
415
+ if (retentionMatch) {
416
+ const v = retentionMatch[1];
417
+ if (v === "keep" || v === "until_transcribed" || v === "never") config.audio_retention = v;
418
+ }
419
+
323
420
  // Parse description (block scalar)
324
421
  const descMatch = yaml.match(/^description:\s*\|\s*\n((?:\s{2}.+\n?)+)/m);
325
422
  if (descMatch) {
@@ -358,9 +455,64 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
358
455
  // Parse tag_schemas
359
456
  config.tag_schemas = parseTagSchemas(yaml);
360
457
 
458
+ // Parse transcription.context (same shape as triggers' include_context)
459
+ const transcriptionContext = parseTranscriptionContext(yaml);
460
+ if (transcriptionContext) {
461
+ config.transcription = { context: transcriptionContext };
462
+ }
463
+
361
464
  return config;
362
465
  }
363
466
 
467
+ /**
468
+ * Parse the `transcription: { context: [...] }` section from vault.yaml.
469
+ * Shape matches triggers' `action.include_context` so callers can reuse the
470
+ * same `ContextPredicate` helpers from src/context.ts.
471
+ */
472
+ function parseTranscriptionContext(yaml: string): TriggerIncludeContext[] | undefined {
473
+ const startMatch = yaml.match(/^transcription:\s*$/m);
474
+ if (!startMatch) return undefined;
475
+
476
+ const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
477
+ const lines = yaml.slice(startIdx).split("\n");
478
+
479
+ const entries: TriggerIncludeContext[] = [];
480
+ let inContext = false;
481
+ let current: TriggerIncludeContext | null = null;
482
+
483
+ const pushCurrent = () => {
484
+ if (current && current.tag) entries.push(current);
485
+ current = null;
486
+ };
487
+
488
+ for (const line of lines) {
489
+ // Stop at next top-level key.
490
+ if (line.match(/^\S/) && line.trim().length > 0) break;
491
+ if (line.trim().length === 0) continue;
492
+
493
+ const trimmed = line.trim();
494
+
495
+ if (trimmed === "context:") { inContext = true; continue; }
496
+ if (!inContext) continue;
497
+
498
+ const itemStart = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
499
+ if (itemStart) {
500
+ pushCurrent();
501
+ current = { tag: "" };
502
+ applyContextField(current, itemStart[1], itemStart[2]);
503
+ continue;
504
+ }
505
+ const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
506
+ if (fieldMatch && current) {
507
+ applyContextField(current, fieldMatch[1], fieldMatch[2]);
508
+ continue;
509
+ }
510
+ }
511
+
512
+ pushCurrent();
513
+ return entries.length > 0 ? entries : undefined;
514
+ }
515
+
364
516
  /**
365
517
  * Parse the tag_schemas section from vault.yaml.
366
518
  * Uses line-by-line indent tracking since the main parser is hand-rolled.
@@ -444,6 +596,25 @@ function parseYamlList(val: string): string[] {
444
596
  return inner.split(",").map((s) => s.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
445
597
  }
446
598
 
599
+ /**
600
+ * Apply a single "key: value" line to the include_context item being built.
601
+ * Shared between triggers and vault-config transcription.context (same shape).
602
+ */
603
+ function applyContextField(
604
+ item: TriggerIncludeContext,
605
+ key: string,
606
+ raw: string,
607
+ ): void {
608
+ const value = raw.trim();
609
+ if (key === "tag") { item.tag = value.replace(/^"(.*)"$/, "$1"); return; }
610
+ if (key === "exclude_tag") { item.exclude_tag = value.replace(/^"(.*)"$/, "$1"); return; }
611
+ if (key === "include_metadata") {
612
+ const listMatch = value.match(/^\[([^\]]*)\]/);
613
+ if (listMatch) item.include_metadata = parseYamlList(listMatch[1]);
614
+ return;
615
+ }
616
+ }
617
+
447
618
  function parseTriggers(yaml: string): TriggerConfig[] | undefined {
448
619
  const startMatch = yaml.match(/^triggers:\s*$/m);
449
620
  if (!startMatch) return undefined;
@@ -454,7 +625,17 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
454
625
  const triggers: TriggerConfig[] = [];
455
626
  let current: Partial<TriggerConfig> | null = null;
456
627
  // Track which section we're in by the last seen section header
457
- let section: "top" | "when" | "action" = "top";
628
+ let section: "top" | "when" | "action" | "include_context" = "top";
629
+ // When inside include_context, track the item currently being parsed.
630
+ let currentContext: TriggerIncludeContext | null = null;
631
+
632
+ const pushContextItem = () => {
633
+ if (currentContext && current?.action) {
634
+ current.action.include_context = current.action.include_context ?? [];
635
+ current.action.include_context.push(currentContext);
636
+ }
637
+ currentContext = null;
638
+ };
458
639
 
459
640
  for (const line of lines) {
460
641
  // Stop at next top-level key
@@ -466,6 +647,7 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
466
647
  // New trigger item: "- name: ..."
467
648
  const nameMatch = trimmed.match(/^-\s+name:\s*(.+)/);
468
649
  if (nameMatch) {
650
+ pushContextItem();
469
651
  if (current?.name) {
470
652
  if (current.action?.webhook) {
471
653
  triggers.push(current as TriggerConfig);
@@ -481,8 +663,16 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
481
663
  if (!current) continue;
482
664
 
483
665
  // Section headers — detect by key name regardless of indent
484
- if (trimmed === "when:") { section = "when"; continue; }
485
- if (trimmed === "action:") { section = "action"; continue; }
666
+ if (trimmed === "when:") { pushContextItem(); section = "when"; continue; }
667
+ if (trimmed === "action:") { pushContextItem(); section = "action"; continue; }
668
+ if (trimmed === "include_context:") {
669
+ // Entering the nested list under action:.
670
+ if (!current.action) current.action = { webhook: "" } as TriggerAction;
671
+ current.action.include_context = current.action.include_context ?? [];
672
+ section = "include_context";
673
+ currentContext = null;
674
+ continue;
675
+ }
486
676
 
487
677
  // Top-level trigger field
488
678
  const eventsMatch = trimmed.match(/^events:\s*\[([^\]]*)\]/);
@@ -521,8 +711,27 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
521
711
  continue;
522
712
  }
523
713
  }
714
+
715
+ // include_context list items: "- tag: X" starts a new item; subsequent
716
+ // indented lines set fields on it.
717
+ if (section === "include_context") {
718
+ const itemStart = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
719
+ if (itemStart) {
720
+ pushContextItem();
721
+ currentContext = { tag: "" };
722
+ applyContextField(currentContext, itemStart[1], itemStart[2]);
723
+ continue;
724
+ }
725
+ const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
726
+ if (fieldMatch && currentContext) {
727
+ applyContextField(currentContext, fieldMatch[1], fieldMatch[2]);
728
+ continue;
729
+ }
730
+ }
524
731
  }
525
732
 
733
+ pushContextItem();
734
+
526
735
  // Push the last trigger
527
736
  if (current?.name) {
528
737
  if (current.action?.webhook) {
@@ -713,14 +922,174 @@ function serializeBackup(backup: BackupConfig): string[] {
713
922
  // Directory management
714
923
  // ---------------------------------------------------------------------------
715
924
 
716
- export async function ensureConfigDir(): Promise<void> {
717
- await mkdir(configDirPath(), { recursive: true });
718
- await mkdir(vaultsDirPath(), { recursive: true });
719
- }
720
-
721
925
  export function ensureConfigDirSync(): void {
722
926
  mkdirSync(configDirPath(), { recursive: true });
723
- mkdirSync(vaultsDirPath(), { recursive: true });
927
+ migrateFromLegacyLayout();
928
+ mkdirSync(vaultHomePath(), { recursive: true });
929
+ migrateVaultInternalLayout();
930
+ mkdirSync(dataDirPath(), { recursive: true });
931
+ mkdirSync(logsDirPath(), { recursive: true });
932
+ }
933
+
934
+ /**
935
+ * Move vault-owned state from the legacy root layout (`~/.parachute/.env`,
936
+ * `~/.parachute/vaults/`, …) into `~/.parachute/vault/`. Fresh installs see
937
+ * nothing to migrate and exit quickly; double-calls are a no-op.
938
+ *
939
+ * Per-path move policy: if a legacy path exists AND the target under `vault/`
940
+ * does not, rename the legacy path into place. If both exist, the target
941
+ * wins — we don't overwrite a user's manually-migrated state — and the
942
+ * legacy path is left alone with a warning logged. Each moved path is
943
+ * announced on stdout so users notice when vault relocates their files.
944
+ */
945
+ export function migrateFromLegacyLayout(): void {
946
+ const root = configDirPath();
947
+ const dest = vaultHomePath();
948
+
949
+ // Pre-0.3 installs targeted flat names at root (`vaults`, `vault.log`);
950
+ // we now land those directly under their current canonical subdirs
951
+ // (`data/`, `logs/`) so upgrading users skip the intermediate shape that
952
+ // `migrateVaultInternalLayout` would otherwise correct on a second pass.
953
+ const candidates: Array<[string, string]> = [
954
+ [".env", ".env"],
955
+ ["config.yaml", "config.yaml"],
956
+ ["vault.log", "logs/vault.log"],
957
+ ["vault.err", "logs/vault.err"],
958
+ ["start.sh", "start.sh"],
959
+ ["server-path", "server-path"],
960
+ ["vaults", "data"],
961
+ ["assets", "assets"],
962
+ ];
963
+
964
+ const present: Array<[string, string]> = [];
965
+ for (const [from, to] of candidates) {
966
+ const src = join(root, from);
967
+ if (existsSync(src)) present.push([from, to]);
968
+ }
969
+ if (present.length === 0) return;
970
+
971
+ mkdirSync(dest, { recursive: true });
972
+
973
+ const moved: string[] = [];
974
+ const skipped: string[] = [];
975
+ for (const [from, to] of present) {
976
+ const src = join(root, from);
977
+ const dst = join(dest, to);
978
+ if (existsSync(dst)) {
979
+ skipped.push(from);
980
+ continue;
981
+ }
982
+ // Target may live in a subdir (logs/, data/); ensure parent exists
983
+ // before renameSync, which is strict about target parent existence.
984
+ const parent = join(dst, "..");
985
+ mkdirSync(parent, { recursive: true });
986
+ try {
987
+ renameSync(src, dst);
988
+ moved.push(from);
989
+ } catch (err) {
990
+ console.warn(formatMigrationFailure(src, dst, err));
991
+ }
992
+ }
993
+
994
+ // Log to stderr — migration is operational/audit output, and keeping
995
+ // stdout clean lets callers that pipe stdout (the CLI, spawned child
996
+ // processes, JSON-consuming shells) run without interference.
997
+ if (moved.length > 0) {
998
+ console.error(
999
+ `[parachute-vault] migrated to new layout: moved ${moved.map((p) => join(root, p)).join(", ")} → ${dest}/`,
1000
+ );
1001
+ }
1002
+ if (skipped.length > 0) {
1003
+ console.error(
1004
+ `[parachute-vault] left legacy paths in place (target already exists under vault/): ${skipped.map((p) => join(root, p)).join(", ")}. Remove the legacy copies once you've confirmed the vault/ copies are current.`,
1005
+ );
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * Format a migration-rename failure warning. If the underlying error is
1011
+ * EXDEV (cross-device rename — `PARACHUTE_HOME` straddles a mount, a common
1012
+ * shape in Docker with bind-mounts or multi-disk dev setups), the raw error
1013
+ * message is opaque. Surface the likely cause and note that vault continues
1014
+ * on the legacy layout rather than exiting.
1015
+ */
1016
+ export function formatMigrationFailure(src: string, dst: string, err: unknown): string {
1017
+ const msg = err instanceof Error ? err.message : String(err);
1018
+ const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
1019
+ if (code === "EXDEV") {
1020
+ return `[parachute-vault] migration failed for ${src} → ${dst}: likely because PARACHUTE_HOME crosses a mount boundary (EXDEV). Vault will continue on the legacy layout; move the file manually to complete the upgrade.`;
1021
+ }
1022
+ return `[parachute-vault] failed to migrate ${src} → ${dst}: ${msg}`;
1023
+ }
1024
+
1025
+ /**
1026
+ * Tidies the layout *inside* `vault/` for installs upgrading across the
1027
+ * filesystem-hygiene refactor:
1028
+ *
1029
+ * vault/vaults/ → vault/data/ (matches Postgres/Redis convention;
1030
+ * avoids the doubled "vault/vaults")
1031
+ * vault/vault.log → vault/logs/vault.log
1032
+ * vault/vault.err → vault/logs/vault.err
1033
+ *
1034
+ * Same target-wins, idempotent, rename-only policy as
1035
+ * `migrateFromLegacyLayout`. Runs every boot — once the moves have
1036
+ * happened, subsequent calls are pure existence checks that exit fast.
1037
+ *
1038
+ * If `vault/` doesn't exist yet (never booted before), this returns
1039
+ * immediately — `ensureConfigDirSync` creates the fresh layout right after.
1040
+ */
1041
+ export function migrateVaultInternalLayout(): void {
1042
+ const vaultHome = vaultHomePath();
1043
+ if (!existsSync(vaultHome)) return;
1044
+
1045
+ // vault/vaults/ → vault/data/
1046
+ const legacyData = join(vaultHome, "vaults");
1047
+ const newData = dataDirPath();
1048
+ if (existsSync(legacyData)) {
1049
+ if (existsSync(newData)) {
1050
+ console.error(
1051
+ `[parachute-vault] both ${legacyData}/ and ${newData}/ exist — using data/, leaving vaults/ in place. Remove the legacy copy once you've confirmed data/ is current.`,
1052
+ );
1053
+ } else {
1054
+ try {
1055
+ renameSync(legacyData, newData);
1056
+ console.error(`[parachute-vault] migrated ${legacyData}/ → ${newData}/`);
1057
+ } catch (err) {
1058
+ console.warn(formatMigrationFailure(`${legacyData}/`, `${newData}/`, err));
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ // vault/{vault.log,vault.err} → vault/logs/{vault.log,vault.err}
1064
+ const logsDir = logsDirPath();
1065
+ const logsMoved: string[] = [];
1066
+ const logsSkipped: string[] = [];
1067
+ for (const name of ["vault.log", "vault.err"]) {
1068
+ const src = join(vaultHome, name);
1069
+ if (!existsSync(src)) continue;
1070
+ const dst = join(logsDir, name);
1071
+ if (existsSync(dst)) {
1072
+ logsSkipped.push(name);
1073
+ continue;
1074
+ }
1075
+ mkdirSync(logsDir, { recursive: true });
1076
+ try {
1077
+ renameSync(src, dst);
1078
+ logsMoved.push(name);
1079
+ } catch (err) {
1080
+ console.warn(formatMigrationFailure(src, dst, err));
1081
+ }
1082
+ }
1083
+ if (logsMoved.length > 0) {
1084
+ console.error(
1085
+ `[parachute-vault] migrated ${logsMoved.map((n) => join(vaultHome, n)).join(", ")} → ${logsDir}/`,
1086
+ );
1087
+ }
1088
+ if (logsSkipped.length > 0) {
1089
+ console.error(
1090
+ `[parachute-vault] left legacy log files in place (target already exists under logs/): ${logsSkipped.map((n) => join(vaultHome, n)).join(", ")}.`,
1091
+ );
1092
+ }
724
1093
  }
725
1094
 
726
1095
  // ---------------------------------------------------------------------------
@@ -857,6 +1226,18 @@ export function writeGlobalConfig(config: GlobalConfig): void {
857
1226
  if (trigger.action.timeout) {
858
1227
  lines.push(` timeout: ${trigger.action.timeout}`);
859
1228
  }
1229
+ if (trigger.action.include_context?.length) {
1230
+ lines.push(" include_context:");
1231
+ for (const entry of trigger.action.include_context) {
1232
+ lines.push(` - tag: ${entry.tag}`);
1233
+ if (entry.exclude_tag) {
1234
+ lines.push(` exclude_tag: ${entry.exclude_tag}`);
1235
+ }
1236
+ if (entry.include_metadata?.length) {
1237
+ lines.push(` include_metadata: [${entry.include_metadata.map((v) => `"${v}"`).join(", ")}]`);
1238
+ }
1239
+ }
1240
+ }
860
1241
  }
861
1242
  }
862
1243
 
@@ -916,7 +1297,7 @@ export function generateApiKey(): { fullKey: string; keyId: string } {
916
1297
  }
917
1298
 
918
1299
  // ---------------------------------------------------------------------------
919
- // Environment file (~/.parachute/.env)
1300
+ // Environment file (~/.parachute/vault/.env)
920
1301
  // ---------------------------------------------------------------------------
921
1302
 
922
1303
  /**
@@ -952,7 +1333,7 @@ export function writeEnvFile(env: Record<string, string>): void {
952
1333
  ensureConfigDirSync();
953
1334
  const lines: string[] = [
954
1335
  "# Parachute Vault configuration",
955
- "# Managed by: parachute vault config",
1336
+ "# Managed by: parachute-vault config",
956
1337
  "",
957
1338
  ];
958
1339
  for (const [key, val] of Object.entries(env)) {
@@ -1001,7 +1382,7 @@ export function loadEnvFile(): void {
1001
1382
 
1002
1383
  export function listVaults(): string[] {
1003
1384
  try {
1004
- const dir = vaultsDirPath();
1385
+ const dir = dataDirPath();
1005
1386
  if (!existsSync(dir)) return [];
1006
1387
  const entries = Bun.spawnSync(["ls", dir]).stdout.toString().trim();
1007
1388
  if (!entries) return [];
@@ -1014,15 +1395,15 @@ export function listVaults(): string[] {
1014
1395
  }
1015
1396
 
1016
1397
  /**
1017
- * Resolve the vault that unscoped routes (`/mcp`, `/api/*`, `/oauth/*`,
1018
- * `/view/*`) should target.
1398
+ * Resolve the vault that tooling-level defaults (e.g. the `parachute-vault`
1399
+ * MCP entry the CLI writes into `~/.claude.json`) should target. HTTP routing
1400
+ * is vault-scoped — `/vault/<name>/...` is the only URL shape — so this helper
1401
+ * is no longer on the request path; it just picks the one vault the CLI wires
1402
+ * up by default.
1019
1403
  *
1020
1404
  * Resolution order:
1021
1405
  * 1. If `default_vault` is set in config.yaml AND that vault exists → use it.
1022
1406
  * 2. Else if exactly one vault exists → use that vault regardless of its name.
1023
- * This is the "single-vault auto-default": if you only have `journal`,
1024
- * `/mcp` transparently targets `journal` without requiring you to visit
1025
- * `/vaults/journal/mcp`.
1026
1407
  * 3. Otherwise → return `null` (multi-vault deployment with no/bad default;
1027
1408
  * the caller should surface an explicit error rather than guess).
1028
1409
  *
@@ -1030,8 +1411,7 @@ export function listVaults(): string[] {
1030
1411
  * - If `default_vault` points to a deleted vault, step 2 still kicks in so
1031
1412
  * operators aren't stranded after `vault remove`.
1032
1413
  * - The name "default" has no special meaning here; it's just whatever
1033
- * `vault init` happens to create on first run. A single vault named
1034
- * "journal" behaves identically.
1414
+ * `vault init` happens to create on first run.
1035
1415
  */
1036
1416
  export function resolveDefaultVault(): string | null {
1037
1417
  const globalConfig = readGlobalConfig();