fathom-mcp 0.5.21 → 0.6.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "0.5.21",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "src/",
12
12
  "scripts/*.sh",
13
13
  "scripts/*.py",
14
+ "scripts/*.js",
14
15
  "fathom-agents.md",
15
16
  "README.md",
16
17
  "CHANGELOG.md",
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Vault frontmatter lint hook for Claude Code PostToolUse (Write/Edit).
5
+ *
6
+ * Reads the tool result from stdin (JSON), checks if the file is inside a
7
+ * vault/ directory and ends in .md. If so, validates frontmatter.
8
+ * Exits 0 on pass, 1 on failure (with error message on stderr).
9
+ *
10
+ * Environment: CLAUDE_TOOL_NAME, CLAUDE_FILE_PATH are set by Claude Code hooks.
11
+ */
12
+
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import { parseFrontmatter, validateFrontmatter } from "../src/frontmatter.js";
16
+
17
+ const toolName = process.env.CLAUDE_TOOL_NAME || "";
18
+ const filePath = process.env.CLAUDE_FILE_PATH || "";
19
+
20
+ // Only check Write and Edit tools
21
+ if (toolName !== "Write" && toolName !== "Edit") {
22
+ process.exit(0);
23
+ }
24
+
25
+ // Only check .md files inside a vault/ directory
26
+ if (!filePath || !filePath.endsWith(".md")) {
27
+ process.exit(0);
28
+ }
29
+
30
+ // Check if the file is inside a vault/ directory
31
+ const parts = filePath.split(path.sep);
32
+ if (!parts.includes("vault")) {
33
+ process.exit(0);
34
+ }
35
+
36
+ // Read the file and validate
37
+ try {
38
+ const content = fs.readFileSync(filePath, "utf-8");
39
+
40
+ // Only validate if file has frontmatter
41
+ if (!content.startsWith("---")) {
42
+ process.exit(0);
43
+ }
44
+
45
+ const { fm } = parseFrontmatter(content);
46
+ if (Object.keys(fm).length === 0) {
47
+ process.exit(0);
48
+ }
49
+
50
+ const errors = validateFrontmatter(fm);
51
+ if (errors.length > 0) {
52
+ process.stderr.write(`Vault frontmatter validation failed for ${path.basename(filePath)}:\n`);
53
+ for (const err of errors) {
54
+ process.stderr.write(` - ${err}\n`);
55
+ }
56
+ process.exit(1);
57
+ }
58
+ } catch (e) {
59
+ // File read errors are non-fatal for the hook
60
+ if (e.code !== "ENOENT") {
61
+ process.stderr.write(`Frontmatter lint error: ${e.message}\n`);
62
+ }
63
+ }
64
+
65
+ process.exit(0);
package/src/cli.js CHANGED
@@ -8,13 +8,7 @@
8
8
  * npx fathom-mcp init — Interactive setup wizard
9
9
  * npx fathom-mcp status — Check server connection + workspace status
10
10
  * npx fathom-mcp update — Update hook scripts + version file
11
- * npx fathom-mcp list — List all agents + running status
12
- * npx fathom-mcp start [name] — Start agent by name or legacy cwd-walk
13
- * npx fathom-mcp stop <name> — Stop an agent
14
- * npx fathom-mcp restart <name> — Restart an agent
15
- * npx fathom-mcp add [name] — Add agent to registry (reads .fathom.json defaults)
16
- * npx fathom-mcp remove <name>— Remove agent from registry
17
- * npx fathom-mcp config <name>— Print agent config JSON
11
+ * npx fathom-mcp list — List workspaces + status (from server)
18
12
  */
19
13
 
20
14
  import fs from "fs";
@@ -25,17 +19,6 @@ import { fileURLToPath } from "url";
25
19
 
26
20
  import { findConfigFile, resolveConfig, writeConfig } from "./config.js";
27
21
  import { createClient } from "./server-client.js";
28
- import {
29
- listAgents,
30
- getAgent,
31
- addAgent as registryAddAgent,
32
- removeAgent as registryRemoveAgent,
33
- isAgentRunning,
34
- startAgent,
35
- stopAgent,
36
- defaultCommand,
37
- buildEntryFromConfig,
38
- } from "./agents.js";
39
22
 
40
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
24
  const SCRIPTS_DIR = path.join(__dirname, "..", "scripts");
@@ -463,7 +446,7 @@ async function runInit(flags = {}) {
463
446
 
464
447
  // 8. Vault mode selection
465
448
  let vaultMode;
466
- const validVaultModes = ["hosted", "synced", "local", "none"];
449
+ const validVaultModes = ["synced", "local", "none"];
467
450
  if (nonInteractive) {
468
451
  if (!serverReachable && flagServer) {
469
452
  console.error(`\n Error: Server at ${serverUrl} is not reachable.`);
@@ -475,26 +458,25 @@ async function runInit(flags = {}) {
475
458
  console.error(` Error: unknown vault mode "${flagVaultMode}". Valid modes: ${validVaultModes.join(", ")}`);
476
459
  process.exit(1);
477
460
  }
478
- if ((flagVaultMode === "hosted" || flagVaultMode === "synced") && !serverReachable) {
461
+ if (flagVaultMode === "synced" && !serverReachable) {
479
462
  console.error(` Error: vault mode "${flagVaultMode}" requires a reachable server.`);
480
463
  process.exit(1);
481
464
  }
482
465
  vaultMode = flagVaultMode;
483
466
  console.log(` Vault mode: ${vaultMode} (--vault-mode flag)`);
484
467
  } else {
485
- vaultMode = serverReachable ? "hosted" : "local";
468
+ vaultMode = serverReachable ? "synced" : "local";
486
469
  console.log(` Vault mode: ${vaultMode} (auto-selected)`);
487
470
  }
488
471
  } else {
489
472
  if (serverReachable) {
490
473
  console.log("\n Vault mode:");
491
- console.log(" 1. Hosted (default) — vault stored on server, accessible everywhere");
492
- console.log(" 2. Syncedlocal vault + server mirror (local is source of truth)");
493
- console.log(" 3. Local — vault on disk only, not visible to server");
494
- console.log(" 4. None — no vault, coordination features only");
474
+ console.log(" 1. Synced (default) — local vault + server indexing");
475
+ console.log(" 2. Local — vault on disk only, not visible to server");
476
+ console.log(" 3. Noneno vault, coordination features only");
495
477
  const modeChoice = await ask(rl, "\n Select mode", "1");
496
- const modeMap = { "1": "hosted", "2": "synced", "3": "local", "4": "none" };
497
- vaultMode = modeMap[modeChoice] || "hosted";
478
+ const modeMap = { "1": "synced", "2": "local", "3": "none" };
479
+ vaultMode = modeMap[modeChoice] || "synced";
498
480
  } else {
499
481
  console.log("\n Vault mode (server not available — hosted/synced require server):");
500
482
  console.log(" 1. Local (default) — vault on disk only");
@@ -594,6 +576,7 @@ async function runInit(flags = {}) {
594
576
  const precompactCmd = "bash ~/.config/fathom-mcp/scripts/fathom-precompact.sh";
595
577
 
596
578
  // Claude Code hooks
579
+ const lintCmd = "node ~/.config/fathom-mcp/scripts/vault-frontmatter-lint.js";
597
580
  if (hasClaude) {
598
581
  const settingsPath = path.join(cwd, ".claude", "settings.local.json");
599
582
  const settings = readJsonFile(settingsPath) || {};
@@ -601,6 +584,7 @@ async function runInit(flags = {}) {
601
584
  changed = ensureHook(settings, "SessionStart", sessionStartCmd, 10000) || changed;
602
585
  if (enableRecallHook) changed = ensureHook(settings, "UserPromptSubmit", recallCmd, 10000) || changed;
603
586
  if (enablePrecompactHook) changed = ensureHook(settings, "PreCompact", precompactCmd, 30000) || changed;
587
+ changed = ensureHook(settings, "PostToolUse", lintCmd, 5000) || changed;
604
588
  if (changed) {
605
589
  writeJsonFile(settingsPath, settings);
606
590
  console.log(" ✓ .claude/settings.local.json (permissions + hooks)");
@@ -657,11 +641,6 @@ async function runInit(flags = {}) {
657
641
  }
658
642
  }
659
643
 
660
- // Register in CLI agent registry (for ls/start/stop)
661
- const entry = buildEntryFromConfig(cwd, configData);
662
- registryAddAgent(workspace, entry);
663
- console.log(` ✓ Registered agent "${workspace}" in CLI registry`);
664
-
665
644
  // Context-aware next steps
666
645
  console.log(`\n Done! Fathom MCP is configured for workspace "${workspace}".`);
667
646
  console.log(` Vault mode: ${vaultMode}`);
@@ -677,11 +656,8 @@ async function runInit(flags = {}) {
677
656
 
678
657
  const stepNum = serverReachable ? 1 : 2;
679
658
  switch (vaultMode) {
680
- case "hosted":
681
- console.log(` ${stepNum}. Your vault is stored on the server. Start writing!`);
682
- break;
683
659
  case "synced":
684
- console.log(` ${stepNum}. Local vault syncs to server. Files in ./${vault}/ are the source of truth.`);
660
+ console.log(` ${stepNum}. Local vault indexed by server. Files in ./${vault}/ are the source of truth.`);
685
661
  break;
686
662
  case "local":
687
663
  console.log(` ${stepNum}. Local vault only. Server can't search or peek into it.`);
@@ -897,70 +873,56 @@ async function runUpdate() {
897
873
  console.log("\n Restart your agent session to pick up changes.\n");
898
874
  }
899
875
 
900
- // --- Start command -----------------------------------------------------------
901
-
902
- function runStart(argv) {
903
- // Check if first non-flag arg matches a registry entry
904
- const firstArg = argv.find((a) => !a.startsWith("-"));
905
- if (firstArg) {
906
- const entry = getAgent(firstArg);
907
- if (entry) {
908
- const result = startAgent(firstArg, entry);
909
- console.log(` ${result.message}`);
910
- process.exit(result.ok ? 0 : 1);
911
- return;
912
- }
913
- }
876
+ // --- List command (via server API) --------------------------------------------
914
877
 
915
- // Legacy fallback: delegate to fathom-start.sh
916
- const found = findConfigFile(process.cwd());
917
- const projectDir = found?.dir || process.cwd();
918
-
919
- const centralScript = path.join(process.env.HOME, ".config", "fathom-mcp", "scripts", "fathom-start.sh");
920
- const packageScript = path.join(SCRIPTS_DIR, "fathom-start.sh");
921
- const script = fs.existsSync(centralScript) ? centralScript : packageScript;
878
+ async function runList() {
879
+ const config = resolveConfig();
880
+ const client = createClient(config);
922
881
 
923
- if (!fs.existsSync(script)) {
924
- console.error(" Error: fathom-start.sh not found. Run `npx fathom-mcp update` first.");
882
+ const isUp = await client.healthCheck();
883
+ if (!isUp) {
884
+ console.error(`\n Error: Server not reachable at ${config.server}`);
885
+ console.error(" Start the server or check --server URL.\n");
925
886
  process.exit(1);
926
887
  }
927
888
 
928
- try {
929
- execFileSync("bash", [script, ...argv], {
930
- cwd: projectDir,
931
- stdio: "inherit",
932
- });
933
- } catch (e) {
934
- process.exit(e.status || 1);
889
+ const wsResult = await client.listWorkspaces();
890
+ if (wsResult.error) {
891
+ console.error(`\n Error: ${wsResult.error}\n`);
892
+ process.exit(1);
935
893
  }
936
- }
937
894
 
938
- // --- List command -------------------------------------------------------------
939
-
940
- function runList() {
941
- const agents = listAgents();
942
- const names = Object.keys(agents);
895
+ const profiles = wsResult.profiles || {};
896
+ const names = Object.keys(profiles);
943
897
 
944
898
  if (names.length === 0) {
945
- console.log("\n No agents registered. Run `fathom-mcp add` to register one.\n");
899
+ console.log("\n No workspaces registered on server.\n");
946
900
  return;
947
901
  }
948
902
 
949
- // Header
950
- const cols = { name: 16, type: 13, status: 10 };
903
+ const cols = { name: 20, type: 10, status: 12 };
951
904
  console.log(
952
905
  "\n " +
953
- "NAME".padEnd(cols.name) +
906
+ "WORKSPACE".padEnd(cols.name) +
954
907
  "TYPE".padEnd(cols.type) +
955
908
  "STATUS".padEnd(cols.status) +
956
- "DIR",
909
+ "PATH",
957
910
  );
958
911
 
959
912
  for (const name of names) {
960
- const entry = agents[name];
961
- const type = entry.agentType || "claude-code";
962
- const status = entry.ssh ? "[ssh]" : isAgentRunning(name, entry);
963
- const dir = entry.projectDir.replace(process.env.HOME, "~");
913
+ const p = profiles[name];
914
+ const type = p.type || "local";
915
+ let status;
916
+ if (type === "human") {
917
+ status = "human";
918
+ } else if (p.process && p.connected) {
919
+ status = "running";
920
+ } else if (p.process || p.connected) {
921
+ status = "partial";
922
+ } else {
923
+ status = "stopped";
924
+ }
925
+ const dir = (p.path || "").replace(process.env.HOME, "~");
964
926
 
965
927
  console.log(
966
928
  " " +
@@ -973,134 +935,6 @@ function runList() {
973
935
  console.log();
974
936
  }
975
937
 
976
- // --- Stop command ------------------------------------------------------------
977
-
978
- function runStop(name) {
979
- if (!name) {
980
- console.error(" Usage: fathom-mcp stop <name>");
981
- process.exit(1);
982
- }
983
- const entry = getAgent(name);
984
- if (!entry) {
985
- console.error(` Error: No agent "${name}" in registry. Run \`fathom-mcp list\` to see agents.`);
986
- process.exit(1);
987
- }
988
- const result = stopAgent(name, entry);
989
- console.log(` ${result.message}`);
990
- process.exit(result.ok ? 0 : 1);
991
- }
992
-
993
- // --- Restart command ---------------------------------------------------------
994
-
995
- function runRestart(name) {
996
- if (!name) {
997
- console.error(" Usage: fathom-mcp restart <name>");
998
- process.exit(1);
999
- }
1000
- const entry = getAgent(name);
1001
- if (!entry) {
1002
- console.error(` Error: No agent "${name}" in registry. Run \`fathom-mcp list\` to see agents.`);
1003
- process.exit(1);
1004
- }
1005
-
1006
- const stopResult = stopAgent(name, entry);
1007
- if (stopResult.ok) {
1008
- console.log(` ${stopResult.message}`);
1009
- }
1010
-
1011
- // Brief pause between stop and start
1012
- try { execFileSync("sleep", ["1"], { stdio: "pipe" }); } catch { /* */ }
1013
-
1014
- const startResult = startAgent(name, entry);
1015
- console.log(` ${startResult.message}`);
1016
- process.exit(startResult.ok ? 0 : 1);
1017
- }
1018
-
1019
- // --- Add command -------------------------------------------------------------
1020
-
1021
- async function runAdd(argv) {
1022
- const flags = parseFlags(argv);
1023
- const nameArg = argv.find((a) => !a.startsWith("-"));
1024
- const cwd = process.cwd();
1025
-
1026
- // Try to read .fathom.json from cwd for defaults
1027
- const found = findConfigFile(cwd);
1028
- const fathomConfig = found?.config || {};
1029
- const projectDir = found?.dir || cwd;
1030
-
1031
- const defaults = buildEntryFromConfig(projectDir, fathomConfig);
1032
- const defaultName = fathomConfig.workspace || path.basename(projectDir);
1033
-
1034
- if (flags.nonInteractive) {
1035
- const name = nameArg || defaultName;
1036
- registryAddAgent(name, defaults);
1037
- console.log(` ✓ Added agent "${name}" to registry.`);
1038
- return;
1039
- }
1040
-
1041
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1042
-
1043
- const name = await ask(rl, " Agent name", nameArg || defaultName);
1044
- const agentProjectDir = await ask(rl, " Project directory", defaults.projectDir);
1045
- const agentType = await ask(rl, " Agent type (claude-code|gemini|manual)", defaults.agentType);
1046
- const command = await ask(rl, " Command", defaultCommand(agentType));
1047
- const server = await ask(rl, " Server URL", defaults.server);
1048
- const apiKey = await ask(rl, " API key", defaults.apiKey);
1049
- const vault = await ask(rl, " Vault subdirectory", defaults.vault || "vault");
1050
- const vaultMode = await ask(rl, " Vault mode (hosted|synced|local|none)", defaults.vaultMode);
1051
- const description = await ask(rl, " Description", defaults.description);
1052
-
1053
- rl.close();
1054
-
1055
- const entry = {
1056
- projectDir: path.resolve(agentProjectDir),
1057
- agentType,
1058
- command,
1059
- server,
1060
- apiKey,
1061
- vault,
1062
- vaultMode,
1063
- description,
1064
- hooks: defaults.hooks || {},
1065
- ssh: null,
1066
- env: {},
1067
- };
1068
-
1069
- registryAddAgent(name, entry);
1070
- console.log(`\n ✓ Added agent "${name}" to registry.`);
1071
- }
1072
-
1073
- // --- Remove command ----------------------------------------------------------
1074
-
1075
- function runRemove(name) {
1076
- if (!name) {
1077
- console.error(" Usage: fathom-mcp remove <name>");
1078
- process.exit(1);
1079
- }
1080
- const removed = registryRemoveAgent(name);
1081
- if (removed) {
1082
- console.log(` ✓ Removed agent "${name}" from registry.`);
1083
- } else {
1084
- console.error(` Error: No agent "${name}" in registry.`);
1085
- process.exit(1);
1086
- }
1087
- }
1088
-
1089
- // --- Config command ----------------------------------------------------------
1090
-
1091
- function runConfigCmd(name) {
1092
- if (!name) {
1093
- console.error(" Usage: fathom-mcp config <name>");
1094
- process.exit(1);
1095
- }
1096
- const entry = getAgent(name);
1097
- if (!entry) {
1098
- console.error(` Error: No agent "${name}" in registry.`);
1099
- process.exit(1);
1100
- }
1101
- console.log(JSON.stringify({ [name]: entry }, null, 2));
1102
- }
1103
-
1104
938
  // --- Main --------------------------------------------------------------------
1105
939
 
1106
940
  // Guard: only run CLI when this module is the entry point (not when imported by tests)
@@ -1122,20 +956,8 @@ if (isMain) {
1122
956
  asyncHandler(runStatus);
1123
957
  } else if (command === "update") {
1124
958
  asyncHandler(runUpdate);
1125
- } else if (command === "start") {
1126
- runStart(process.argv.slice(3));
1127
959
  } else if (command === "list" || command === "ls") {
1128
- runList();
1129
- } else if (command === "stop") {
1130
- runStop(process.argv[3]);
1131
- } else if (command === "restart") {
1132
- runRestart(process.argv[3]);
1133
- } else if (command === "add") {
1134
- asyncHandler(() => runAdd(process.argv.slice(3)));
1135
- } else if (command === "remove" || command === "rm") {
1136
- runRemove(process.argv[3]);
1137
- } else if (command === "config") {
1138
- runConfigCmd(process.argv[3]);
960
+ asyncHandler(runList);
1139
961
  } else if (!command || command === "serve") {
1140
962
  import("./index.js");
1141
963
  } else {
@@ -1147,14 +969,7 @@ if (isMain) {
1147
969
  fathom-mcp init [-y --api-key KEY --vault-mode MODE --agent AGENT] Interactive/non-interactive setup
1148
970
  fathom-mcp status Check connection status
1149
971
  fathom-mcp update Update hooks + version
1150
-
1151
- fathom-mcp list List all agents + status
1152
- fathom-mcp start [name] Start agent (by name or legacy cwd)
1153
- fathom-mcp stop <name> Stop agent
1154
- fathom-mcp restart <name> Stop + start agent
1155
- fathom-mcp add [name] Add agent to registry
1156
- fathom-mcp remove <name> Remove from registry
1157
- fathom-mcp config <name> Print agent config JSON`);
972
+ fathom-mcp list List workspaces + status (from server)`);
1158
973
  process.exit(1);
1159
974
  }
1160
975
  }
package/src/config.js CHANGED
@@ -4,17 +4,15 @@
4
4
  * Precedence (highest wins):
5
5
  * 1. Environment variables (FATHOM_SERVER_URL, FATHOM_API_KEY, FATHOM_WORKSPACE, FATHOM_VAULT_DIR)
6
6
  * 2. .fathom.json (walked up from cwd to filesystem root)
7
- * 3. Central registry (~/.config/fathom-mcp/agents.json, matched by projectDir)
8
- * 4. Built-in defaults
7
+ * 3. Built-in defaults
9
8
  */
10
9
 
11
10
  import fs from "fs";
12
11
  import path from "path";
13
- import { findAgentByDir } from "./agents.js";
14
12
 
15
13
  const CONFIG_FILENAME = ".fathom.json";
16
14
 
17
- const VALID_VAULT_MODES = new Set(["hosted", "synced", "local", "none"]);
15
+ const VALID_VAULT_MODES = new Set(["synced", "local", "none"]);
18
16
 
19
17
  const DEFAULTS = {
20
18
  workspace: "",
@@ -76,31 +74,13 @@ function applyConfig(result, config) {
76
74
  }
77
75
 
78
76
  /**
79
- * Resolve final config by merging: defaults → registry → .fathom.json → env vars.
77
+ * Resolve final config by merging: defaults → .fathom.json → env vars.
80
78
  */
81
79
  export function resolveConfig(startDir = process.cwd()) {
82
80
  const result = { ...DEFAULTS, hooks: { ...DEFAULTS.hooks } };
83
81
  let projectDir = startDir;
84
82
 
85
- // Layer 3: Central registry (matched by projectDir walk-up)
86
- let registryMatch = null;
87
- try {
88
- registryMatch = findAgentByDir(startDir);
89
- } catch {
90
- // Registry not available — skip
91
- }
92
- if (registryMatch) {
93
- const { name, entry } = registryMatch;
94
- projectDir = entry.projectDir;
95
- result.workspace = name;
96
- applyConfig(result, entry);
97
- if (entry.agentType) {
98
- result.agents = [entry.agentType];
99
- }
100
- result._registryName = name;
101
- }
102
-
103
- // Layer 2: .fathom.json (overrides registry)
83
+ // Layer 2: .fathom.json
104
84
  const found = findConfigFile(startDir);
105
85
  if (found) {
106
86
  projectDir = found.dir;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared frontmatter parsing and validation for vault files.
3
+ *
4
+ * Used by the vault-frontmatter-lint hook script and any other
5
+ * code that needs to validate vault file frontmatter.
6
+ */
7
+
8
+ // --- Constants ---------------------------------------------------------------
9
+
10
+ export const VALID_STATUSES = new Set(["draft", "published", "archived"]);
11
+
12
+ export const VAULT_SCHEMA = {
13
+ title: { required: true, type: "string" },
14
+ date: { required: true, type: "string" },
15
+ tags: { required: false, type: "array" },
16
+ status: { required: false, type: "string" },
17
+ project: { required: false, type: "string" },
18
+ aliases: { required: false, type: "array" },
19
+ };
20
+
21
+ // --- Frontmatter -------------------------------------------------------------
22
+
23
+ export function parseFrontmatter(content) {
24
+ if (!content.startsWith("---")) return { fm: {}, body: content };
25
+ const lines = content.split("\n");
26
+ let endIdx = null;
27
+ for (let i = 1; i < lines.length; i++) {
28
+ if (lines[i].trim() === "---") { endIdx = i; break; }
29
+ }
30
+ if (endIdx === null) return { fm: {}, body: content };
31
+ try {
32
+ const fmLines = lines.slice(1, endIdx);
33
+ const fm = {};
34
+ let currentKey = null;
35
+ for (const line of fmLines) {
36
+ const listMatch = line.match(/^[ ]{2}- (.+)$/);
37
+ const kvMatch = line.match(/^(\w+):\s*(.*)$/);
38
+ if (listMatch && currentKey) {
39
+ fm[currentKey].push(listMatch[1].trim());
40
+ } else if (kvMatch) {
41
+ currentKey = kvMatch[1];
42
+ const val = kvMatch[2].trim();
43
+ if (val === "") {
44
+ fm[currentKey] = [];
45
+ } else {
46
+ fm[currentKey] = val;
47
+ }
48
+ }
49
+ }
50
+ const body = lines.slice(endIdx + 1).join("\n");
51
+ return { fm, body };
52
+ } catch {
53
+ return { fm: {}, body: content };
54
+ }
55
+ }
56
+
57
+ export function validateFrontmatter(fm) {
58
+ const errors = [];
59
+ for (const [field, spec] of Object.entries(VAULT_SCHEMA)) {
60
+ const val = fm[field];
61
+ if (spec.required && val == null) {
62
+ errors.push(`Missing required field: '${field}'`);
63
+ continue;
64
+ }
65
+ if (val != null) {
66
+ const actualType = Array.isArray(val) ? "array" : typeof val;
67
+ if (actualType !== spec.type) {
68
+ errors.push(`Field '${field}' must be ${spec.type}, got ${actualType}`);
69
+ }
70
+ }
71
+ }
72
+ const status = fm["status"];
73
+ if (status != null && !VALID_STATUSES.has(status)) {
74
+ errors.push(`Field 'status' must be one of [${[...VALID_STATUSES].join(", ")}], got '${status}'`);
75
+ }
76
+ return errors;
77
+ }