@staix/vault-mem 0.3.2 → 0.3.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.3] - 2026-11-02
6
+
7
+ - Polish the spec read surface: scope fish parent completions to the first subcommand, preserve positional help tokens after `--`, document spec MCP tools and startup source registration, clarify shared field/error guidance, reuse body section helpers, and keep literal snippets on code-point boundaries (#103).
8
+
9
+ - Add read-only spec MCP tools and CLI get/graph/grep/validate commands from a shared bounded schema table, registry-free `spec validate --repo` CI mode, worktree-aware source selection, body/search pagination and metadata/path-scope filters. Spec snapshot filtering precedes FTS pagination; new read tools appear in the manifest. Spec content write tools remain deferred (#91 T5).
10
+
11
+ - Add spec validation reports, complete-tree introduced-error comparison, computed path-scoped freshness, tracked-file coversPath matching and registry-free validation library (#91 T3/T6), with a portable synthetic schema fixture. Candidate, link-list/path-list and supersession rules follow the confirmed schema. Discovery ignores inherited Git pathspec modes, skips oversized/non-UTF8 non-candidates with diagnostics, and performs Git inspection outside cache writer transactions. CLI/MCP wiring remains T5 and spec content writes remain deferred.
12
+
5
13
  ## [0.3.2] - 2026-11-02
6
14
 
7
15
  - Add library-only spec discovery, repository/worktree identity checks, typed id-based graphs and bounded directional slices (#91 T2). `SpecIndex` reuses the vault scan/metadata/hash/BM25 engine with a separate `spec-` cache per canonical worktree; vault cache names remain unchanged. Spec searches exclude shared-cache rows absent from the instance snapshot or replaced since its scan, and discovery errors name the relative file. Filesystem normalization/case aliases are accepted with root-identity and physical-containment checks, skipped listed paths have diagnostics, and error advice preserves the original cause and names `.gitignore` for discovery bounds. No CLI/MCP read surface or spec content write tools are added.
package/README.md CHANGED
@@ -148,10 +148,14 @@ Readers never create a home, log or lock, and work with `write: none`. They skip
148
148
 
149
149
  `vmem mcp` serves newline-delimited JSON-RPC 2.0 (MCP tools, protocol 2025-06-18 and earlier) on stdio. stdout carries protocol messages only; it needs no TTY and runs in agent sessions. Requests are limited to 64 KiB per frame and results to 256 KiB (a larger result becomes a structured `LIMIT` refusal). Arguments are validated against each tool's bounded schema (`additionalProperties: false`, string/array/integer caps); invalid arguments or unknown tools return JSON-RPC `-32602`, unknown methods `-32601`, malformed frames `-32700`/`-32600`. At startup it loads every module a tool can run, the `@staix/agent-mem` root included, so a server keeps its own code and tool list when its install is replaced or a newer release is installed beside it; restart the host session to pick up the new release.
150
150
 
151
- The vault set is read once at startup: every tool's `vault` property is an enum of the registered ids and defaults to the first `personal` vault. Restart the host session after `vaults add/remove`. Reads go through the same index as the CLI; writes go through `applyWrite`/`appendLog` with channel `mcp`, so they require `write: mcp` and pass the same V01-V10 gates. Every tool returns a JSON result: a receipt, or `{refused: true, issues}` for policy, validation or lookup failures.
151
+ The vault set is read once at startup: every tool's `vault` property is an enum of the registered ids and defaults to the first `personal` vault. Restart the host session after `vaults add/remove`. Registered spec sources are also read once when `vmem mcp` starts; restart the MCP server after `specs add` or `specs remove`. Reads go through the same index as the CLI; writes go through `applyWrite`/`appendLog` with channel `mcp`, so they require `write: mcp` and pass the same V01-V10 gates. Every tool returns a JSON result: a receipt, or `{refused: true, issues}` for policy, validation or lookup failures.
152
152
 
153
153
  | tool | arguments (caps) |
154
154
  |---|---|
155
+ | `vmem_spec_get` | `id`, optional `source`, absolute `worktree`, `body`, `maxBytes`<=65536, `section`, `offset`<=2097152; metadata, relations, hash, freshness and optional body pages |
156
+ | `vmem_spec_graph` | `id`, optional `source`, `worktree`, `direction` subtree/ancestors/neighbors, `edge`, `depth`<=3, `limit`<=500 |
157
+ | `vmem_spec_grep` | optional `source`, `worktree`, `query`<=1024 UTF-8 bytes, `literal`, `type`, `status`, `tag`, `parent`, `dependsOn`, `coversPath`, `offset`<=10000, `limit`<=500; continuation after filters |
158
+ | `vmem_spec_validate` | optional `source`, `worktree`, `requireFresh`, `limit`<=500; bounded report; stale is fatal only with `requireFresh` |
155
159
  | `vmem_status` | none; vaults with health, write policy, effective pii and `piiPolicy` (read-only) |
156
160
  | `vmem_search` | `query`<=1000, `vault`, `limit`<=50, `type`, `domain` |
157
161
  | `vmem_read` | `path` (stem, alias or `notes/` path), `vault`, `maxBytes`<=65536; returns `{path, frontmatter, body, sha256, truncated}` |
@@ -343,4 +347,33 @@ The config `specs` array holds `id`, canonical git working-tree root `path`, `wr
343
347
 
344
348
  Config writes containing `specs` use format 2; a config without `specs` keeps its existing serialization and version field. `status` lists spec entries under a separate `specs` key, and `doctor` checks that every registered path is still its canonical git working-tree root. An unavailable or changed root makes doctor exit 1 with a per-source issue. Existing vault entries and `vaults list` keep their output shape.
345
349
 
346
- The registry and a library-only read engine are implemented. `resolveSpecWorktree` checks the registered git common directory; `discoverSpecs` discovers nested Markdown specs in memory; `SpecIndex` reuses the vault scanner and BM25 with a separate `spec-` cache prefix per canonical worktree. Call `scan()` to refresh a `SpecIndex`; its `snapshot()` uses the last completed scan and `search()` admits only shared-cache rows matching that snapshot's paths and hashes. Call `scan()` again to see documents another index instance inserted or replaced. `buildSpecGraph` resolves typed links by frontmatter id, and `specSlice` returns bounded subtree/ancestor/neighbor slices (depth 0..3, at most 500 nodes). Discovery uses Git's tracked/untracked non-ignored Markdown list, reports skipped symlinks/missing/nonregular paths, accepts NFC/NFD and filesystem case aliases, and keeps the first duplicate id in NFC path order with a diagnostic. Root device/inode identity and per-file physical containment remain checked while accepting these spelling aliases. Unknown type/status strings remain readable. To exclude large or vendored untracked Markdown, add it to `.gitignore`; already-tracked ignored files must also be untracked with `git rm --cached`. Discovery/indexing is bounded to 10000 Markdown inputs, 2 MiB each and 64 MiB total; graphs allow 10000 inputs and 100000 unique edges. Graph results omit raw note sources. Spec validation, freshness, path filters, MCP tools and CLI graph/search commands are not yet implemented. Spec create/update/delete tools remain deferred; `write` stores a policy for later capabilities. Commands do not edit repository content or run git commit, branch or push.
350
+ The registry, read-only MCP tools and CLI commands are implemented. `resolveSpecWorktree` checks the registered git common directory; `discoverSpecs` discovers nested Markdown specs in memory; `SpecIndex` reuses the vault scanner and BM25 with a separate `spec-` cache prefix per canonical worktree. Call `scan()` to refresh a `SpecIndex`; its `snapshot()` uses the last completed scan and `search()` admits only shared-cache rows matching that snapshot's paths and hashes. Call `scan()` again to see documents another index instance inserted or replaced. A failed scan invalidates the instance snapshot; the next read refreshes it or propagates the original failure. `buildSpecGraph` resolves typed links by frontmatter id, and `specSlice` returns bounded subtree/ancestor/neighbor slices (depth 0..3, at most 500 nodes). Discovery uses Git's tracked/untracked non-ignored Markdown list, reports skipped symlinks/missing/nonregular paths, accepts NFC/NFD and filesystem case aliases, and keeps the first duplicate id in NFC path order with a diagnostic. Root device/inode identity and per-file physical containment remain checked while accepting these spelling aliases. Unknown type/status strings remain readable. To exclude large or vendored untracked Markdown, add it to `.gitignore`; already-tracked ignored files must also be untracked with `git rm --cached`. Discovery/indexing is bounded to 10000 Markdown inputs, 2 MiB each and 64 MiB total; graphs allow 10000 inputs and 100000 unique edges. Graph results omit raw note sources. Library validation (`validateSpecs`), introduced-error comparison (`introducedSpecErrors`), computed freshness (`specFreshness`), tracked-file ownership (`specsCoveringPath`) and registry-free CI logic (`validateSpecRepo`) are implemented. The read tools are `vmem_spec_get`, `vmem_spec_graph`, `vmem_spec_grep` and `vmem_spec_validate`. Spec content tools `vmem_spec_create`, `vmem_spec_update` and `vmem_spec_delete` are not implemented (T4, #97); `write` stores a policy for later capabilities. Commands do not edit repository content or run git commit, branch or push.
351
+
352
+ Read commands share one bounded schema table with their MCP tools. `source` (CLI `--source`) defaults to the first registered source; `worktree` (`--worktree`) defaults to its registered root and must be an absolute root with the same Git common directory. Reads are allowed with `write: none` and do not edit repository content. Derived search caches stay outside protected sources.
353
+
354
+ ```sh
355
+ vmem spec get goal --source project-specs --body --max-bytes 16384 --json
356
+ vmem spec graph goal --direction subtree --depth 3 --limit 100 --json
357
+ vmem spec grep implementation --type module --tag storage --limit 20 --offset 0 --json
358
+ vmem spec grep --covers-path src/index.ts --json
359
+ vmem spec validate --source project-specs --require-fresh --json
360
+ vmem spec validate --repo /absolute/checkout --require-fresh --json
361
+ ```
362
+
363
+ `get` returns frontmatter, resolved outgoing links, backlinks, children, path, SHA-256 and computed freshness. Body reads are optional (`--body`, or body paging/section options) and use byte offsets with bounded UTF-8 pages. `graph` accepts subtree, ancestors or neighbors and an optional edge kind. `grep` searches FTS terms by default or a literal substring with `--literal`; filters are type, status, tag, parent, depends-on and covers-path. Use `--` before a positional id/query beginning with a hyphen, for example `vmem spec grep --literal -- "- [ ]"`. Results carry a continuation `nextOffset` when more matching hits exist. Snapshot path/hash filtering occurs before search limit/offset, so another scanner's cache rows cannot shorten pages. Rerun a command to refresh its snapshot. Large responses refuse with guidance or mark bounded collections as truncated.
364
+
365
+ `validate` exits 1 on validation errors; stale scopes become errors only with `--require-fresh`. `--repo` validates a checkout without consulting a home/config file or creating a registry/cache, and cannot be combined with `--source` or `--worktree`. The four read tools are listed by `vmem manifest --json`; downstream hosts with frozen tool allow-lists need to add their names separately.
366
+
367
+ # Spec validation and freshness library
368
+
369
+ `validateSpecs(snapshot, { requireFresh?, limit?, env?, git? })` returns full error/warning counts and count/byte-bounded `items`, source `diagnostics` and computed `freshness`. Types are `goal`, `architecture`, `module`, `submodule`, with increasing parent/child ranks and skipped levels allowed; status is optional or `draft|active|deprecated`. A deprecated spec may name one replacement; dangling, self or non-deprecated replacement links error, while missing or already-deprecated replacements warn. Metadata link values accept plain ids or wiki links with labels/headings. List link fields and `paths` require lists; malformed scalars remain visible to validation. `covers` and `tags` require lists of nonempty strings; scalar, non-string or empty items error. Empty lists are allowed. Only valid `covers` lists contribute duplicate claims.
370
+
371
+ Validation candidates are any `SPEC.md`, plus Markdown with parsed `id` or `type` frontmatter. A bare `SPEC.md` reports missing required keys. If YAML cannot be parsed, only the filename asserts candidacy; ordinary horizontal-rule/title-only READMEs are ignored. Oversized/non-UTF8 non-candidates yield skipped-path diagnostics; candidates retain blocking file errors. A truncated oversized frontmatter header is ambiguous and fails closed; it is never assumed to be a non-candidate. Discovery strips inherited Git pathspec-mode variables. Git binding/discovery subprocesses run outside the cache's IMMEDIATE transaction, while descriptor/path stamp checks remain inside.
372
+
373
+ `introducedSpecErrors(current, prospective, options)` compares complete internal error identities before applying display limits. Owners are matched across both trees by ids that are unique in both (including file moves), with stable file paths as the fallback for missing/duplicate-id repairs. Old owners are matched one-to-one, so a new spec reusing a moved spec's path cannot inherit its errors. Error identity also includes the repository namespace, code, field and target. Existing errors remain reportable but do not block an unrelated change; new dangling targets, cycles and rank errors are returned. Results contain only introduced errors and are bounded samples; internal resource-budget failures throw instead of returning an empty success. This API is not yet wired to spec writes because T4 is deferred.
374
+
375
+ `specFreshness` checks full commit stamps against a pinned HEAD, counts scoped source commits and reports staged/unstaged/untracked changes touching native Git pathspecs. `paths` must be a nonempty relative pathspec list with no parent traversal and matching tracked files. Freshness is never authored into files. Stale scopes are report-only by default; `requireFresh` makes them errors. A spec without a stamp has no computed freshness entry. `git: false` skips native repository checks while retaining metadata shape/relative-path/full-SHA-format checks, and cannot be combined with `requireFresh`.
376
+
377
+ `specsCoveringPath` matches a tracked relative filename using Git's pathspec engine. `validateSpecRepo(repo, { requireFresh?, limit?, env? })` discovers and validates a checkout without reading or creating a registry, home or disk cache, returning `{ code, report }`; code is 1 for validation errors or required stale scopes. CLI `vmem spec validate --repo` implements this registry-free mode.
378
+
379
+ The portable schema contract is [test/fixtures/spec-graph/README.md](test/fixtures/spec-graph/README.md), with independent small valid/error/warning trees and expected codes. Consumers can copy the fixtures without a runtime dependency. Validation/CI reports omit raw sources; display limits are 1..500 records per collection and 128 KiB total, with errors shown before warnings, with `truncated` when records or oversized optional identifiers are omitted. Fixing errors and rerunning reveals later items.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/vault-mem",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Validated Obsidian-compatible knowledge vaults for agents and people",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { runSpecCommand } from "./mcp-spec.ts";
1
2
  import { tidyVault, TIDY_RULES } from "./tidy.ts";
2
3
  import { originCheck } from "./sync.ts";
3
4
  import { registryIds } from "./vault-log.ts";
@@ -29,6 +30,7 @@ function parse(args: string[], allowed: string[], values: string[]) {
29
30
  return { flags, positional };
30
31
  }
31
32
  function commandHelp(args: string[]): string | undefined {
33
+ if (args.includes("--")) return undefined;
32
34
  if (!["--help", "-h"].includes(args.at(-1) ?? "")) return undefined;
33
35
  const name = args.slice(0, -1).join(" ");
34
36
  if (!name) return undefined;
@@ -57,6 +59,7 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
57
59
  if (args.length !== 2) throw new Error("completion: requires bash, zsh or fish");
58
60
  return { text: completionScript(args[1]!).trimEnd(), code: 0 };
59
61
  }
62
+ if (args[0] === "spec") return runSpecCommand(args.slice(1), env);
60
63
  if (args[0] === "lint" || args[0] === "graph") return runAnalysisCommand(args[0],args.slice(1),resolveHome(env).home,env);
61
64
  if (args[0] === "promote" || args[0] === "sync") return runOrgCommand(args[0], args.slice(1), resolveHome(env).home, env);
62
65
  if (args[0] === "log") return runLogCommand(args.slice(1), resolveHome(env).home, env);
@@ -1,4 +1,6 @@
1
+ import { SPEC_COMMANDS } from "./spec-command-table.ts";
1
2
  export const COMMANDS = [
3
+ ...SPEC_COMMANDS.map(item => ({ name: `spec ${item.command}`, usage: item.usage, mutates: false })),
2
4
  { name: "lint", usage: "lint <vault> [--note PATH] [--write] [--dry-run] [--json]", mutates: "write" },
3
5
  { name: "graph build", usage: "graph build <vault> [--workspace] [--dry-run] [--json]", mutates: true },
4
6
  { name: "graph neighbors", usage: "graph neighbors <vault> <stem> [--hops 1..2] [--limit 1..500] [--json]", mutates: false },
@@ -69,10 +71,10 @@ export function completionScript(shell: string): string {
69
71
  if (shell === "fish") {
70
72
  const lines = [`complete -c vmem -f`, `complete -c vmem -n '__fish_use_subcommand' -a ${quote(top.join(" "))}`];
71
73
  for (const d of defs) {
72
- const cond = d.path.length === 1 ? `__fish_seen_subcommand_from ${d.path[0]}` : `__fish_seen_subcommand_from ${d.path[1]}`;
74
+ const cond = d.path.length === 1 ? `__fish_seen_subcommand_from ${d.path[0]}` : `__fish_seen_subcommand_from ${d.path[0]}; and __fish_seen_subcommand_from ${d.path[1]}`;
73
75
  for (const flag of Object.keys(d.options)) lines.push(`complete -c vmem -n ${quote(cond)} -l ${flag.slice(2)}`);
74
76
  }
75
- for (const parent of ["vaults", "specs", "log", "graph", "skills"]) lines.push(`complete -c vmem -n '__fish_seen_subcommand_from ${parent}' -a ${quote(defs.filter(d => d.path[0] === parent).map(d => d.path[1]).join(" "))}`);
77
+ for (const parent of ["vaults", "specs", "spec", "log", "graph", "skills"]) lines.push(`complete -c vmem -n '__fish_seen_subcommand_from ${parent}; and test (commandline -opc)[2] = ${parent}' -a ${quote(defs.filter(d => d.path[0] === parent).map(d => d.path[1]).join(" "))}`);
76
78
  return lines.join("\n") + "\n";
77
79
  }
78
80
  const cases = defs.map(d => ` ${quote(d.path.join(" "))}) words=${quote(Object.keys(d.options).join(" "))} ;;`);
@@ -81,7 +83,7 @@ export function completionScript(shell: string): string {
81
83
  cur="\${COMP_WORDS[COMP_CWORD]}"
82
84
  if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W ${quote(top.join(" "))} -- "$cur")); return; fi
83
85
  key="\${COMP_WORDS[1]}"
84
- case "$key" in graph) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'build neighbors' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; skills) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'mine' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; specs) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'list add remove' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; vaults|log) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W "$( [ "$key" = vaults ] && echo 'list add remove' || echo 'tail grep append')" -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; esac
86
+ case "$key" in spec) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W '${SPEC_COMMANDS.map(item => item.command).join(" ")}' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; graph) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'build neighbors' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; skills) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'mine' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; specs) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'list add remove' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; vaults|log) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W "$( [ "$key" = vaults ] && echo 'list add remove' || echo 'tail grep append')" -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; esac
85
87
  case "$key" in
86
88
  ${cases.join("\n")}
87
89
  *) words='' ;;
package/src/index.ts CHANGED
@@ -44,6 +44,11 @@ export { tidyVault, parseTidyMap, TIDY_RULES } from "./tidy.ts";
44
44
  export type { TidyRequest, TidyResult, TidyRule, TidyMap } from "./tidy.ts";
45
45
 
46
46
  export { resolveSpecWorktree, discoverSpecs, SpecIndex } from "./spec-source.ts";
47
- export type { SpecDocument, SpecDiagnostic, SpecSnapshot, SpecSourceOptions, SpecIndexOptions, SpecSearchResult } from "./spec-source.ts";
47
+ export type { SpecCandidate, SpecDocument, SpecDiagnostic, SpecSnapshot, SpecSourceOptions, SpecIndexOptions, SpecSearchResult } from "./spec-source.ts";
48
48
  export { buildSpecGraph, specSlice, SPEC_EDGE_KINDS, MAX_SPEC_INPUTS, MAX_SPEC_EDGES } from "./spec-graph.ts";
49
49
  export type { SpecNode, SpecEdge, SpecEdgeKind, SpecGraph, SpecSliceOptions, SpecSlice } from "./spec-graph.ts";
50
+
51
+ export { validateSpecs, introducedSpecErrors, validateSpecRepo } from "./spec-validate.ts";
52
+ export type { SpecValidationIssue, SpecValidationOptions, SpecValidationReport, SpecRepoValidationOptions, SpecRepoValidation } from "./spec-validate.ts";
53
+ export { specFreshness, specsCoveringPath } from "./spec-freshness.ts";
54
+ export type { SpecFreshnessEntry, SpecFreshnessReport, SpecFreshnessOptions } from "./spec-freshness.ts";
@@ -0,0 +1,65 @@
1
+ import { argumentError, type McpHandler, type McpTool } from "./mcp.ts";
2
+ import { SPEC_COMMANDS } from "./spec-command-table.ts";
3
+ import { executeSpecCommand } from "./spec-commands.ts";
4
+ import { validateSpecRepo } from "./spec-validate.ts";
5
+ import { resolveHome } from "./home.ts";
6
+ import { loadRegistry } from "./registry.ts";
7
+
8
+ export function createSpecMcp(home: string, env: NodeJS.ProcessEnv = process.env): { tools: McpTool[]; handlers: Record<string, McpHandler> } {
9
+ const registered = loadRegistry(home).specs ?? [];
10
+ const tools: McpTool[] = SPEC_COMMANDS.map(item => ({ name: item.name, description: item.description, inputSchema: item.inputSchema,
11
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }));
12
+ const handlers: Record<string, McpHandler> = {};
13
+ for (const item of SPEC_COMMANDS) handlers[item.name] = args => {
14
+ const invalid = argumentError(args, item.inputSchema);
15
+ if (invalid) throw new Error(invalid);
16
+ const selected = registered.find(source => source.id === (args.source ?? registered[0]?.id));
17
+ if (!selected) throw new Error("spec source: no matching source in the server's startup registry; pass the source argument with a registered id, or register with vmem specs add and restart the MCP server");
18
+ const current = loadRegistry(home).specs?.find(source => source.id === selected.id);
19
+ if (!current || JSON.stringify(current) !== JSON.stringify(selected)) throw new Error("spec source: registry changed since server startup; restart the MCP server before reading");
20
+ return executeSpecCommand(home, item.command, { ...args, source: selected.id }, env);
21
+ };
22
+ return { tools, handlers };
23
+ }
24
+
25
+ /** Parse with the same bounded field schemas as MCP. --repo deliberately bypasses home resolution. */
26
+ export function runSpecCommand(args: string[], env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
27
+ const item = SPEC_COMMANDS.find(entry => entry.command === args[0]);
28
+ if (!item) throw new Error("spec: unknown command; use vmem spec --help");
29
+ const values: Record<string, unknown> = {}, positional: string[] = [];
30
+ const properties = item.inputSchema.properties as Record<string, { type: string }>;
31
+ const flags = new Map(Object.keys(properties).filter(key => key !== "id" && key !== "query").map(key => ["--" + key.replace(/[A-Z]/g, value => "-" + value.toLowerCase()), key]));
32
+ const seen = new Set<string>(); let json = false, repo: string | undefined, positionalOnly = false;
33
+ for (let i = 1; i < args.length; i++) {
34
+ const flag = args[i]!;
35
+ if (positionalOnly || !flag.startsWith("-")) { positional.push(flag); continue; }
36
+ if (flag === "--") { positionalOnly = true; continue; }
37
+ if (seen.has(flag)) throw new Error(`spec ${item.command}: repeated option ${flag}; supply it once (vmem spec ${item.command} --help)`);
38
+ seen.add(flag);
39
+ if (flag === "--json") { json = true; continue; }
40
+ const key = flags.get(flag);
41
+ if (!key && !(flag === "--repo" && item.command === "validate")) throw new Error(`spec ${item.command}: unknown option ${flag}; see vmem spec ${item.command} --help`);
42
+ if (key && properties[key]!.type === "boolean") { values[key] = true; continue; }
43
+ const value = args[++i];
44
+ if (value === undefined || value.startsWith("--")) throw new Error(`${flag}: value required; see vmem spec ${item.command} --help`);
45
+ if (flag === "--repo") {
46
+ const invalidPath = argumentError(value, { type: "string", minLength: 1, maxLength: 4096 }, "repo");
47
+ if (invalidPath) throw new Error(`${invalidPath}; supply a checkout path with vmem spec validate --repo PATH`);
48
+ repo = value; continue;
49
+ }
50
+ if (properties[key!]!.type === "integer") {
51
+ if (!/^[0-9]+$/.test(value)) throw new Error(`${flag}: expected integer; see vmem spec ${item.command} --help for its bounds`);
52
+ values[key!] = Number(value);
53
+ } else values[key!] = value;
54
+ }
55
+ const takesId = item.command === "get" || item.command === "graph";
56
+ if (positional.length > (item.command === "validate" ? 0 : 1) || (takesId && positional.length !== 1)) throw new Error(`spec ${item.command}: unexpected targets; use vmem ${item.usage}`);
57
+ if (positional[0] !== undefined) values[takesId ? "id" : "query"] = positional[0];
58
+ const invalid = argumentError(values, item.inputSchema);
59
+ if (invalid) throw new Error(`${invalid}; see vmem spec ${item.command} --help to correct the arguments`);
60
+ if (repo !== undefined && (values.source !== undefined || values.worktree !== undefined)) throw new Error("--repo cannot be combined with --source or --worktree; choose registry-free or registered validation");
61
+ const result = repo !== undefined
62
+ ? validateSpecRepo(repo, { requireFresh: values.requireFresh as boolean | undefined, limit: values.limit as number | undefined, env }).report
63
+ : executeSpecCommand(resolveHome(env).home, item.command, values, env);
64
+ return { text: JSON.stringify(result, null, json ? undefined : 2), code: item.command === "validate" && !(result as { ok: boolean }).ok ? 1 : 0 };
65
+ }
package/src/mcp-vault.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createSpecMcp } from "./mcp-spec.ts";
2
+ import { SPEC_COMMANDS } from "./spec-command-table.ts";
1
3
  import { analysisInput } from "./analysis-input.ts";
2
4
  import { buildGraph, graphNeighbors } from "./vault-graph.ts";
3
5
  import { lintVault } from "./vault-lint.ts";
@@ -27,7 +29,7 @@ const scalar = { type: ["string", "number", "boolean", "null", "array"], maxLeng
27
29
 
28
30
  /** Tool names for the implemented read, write, log, analysis and promotion providers. */
29
31
  export const VMEM_TOOL_NAMES = ["vmem_status", "vmem_search", "vmem_read", "vmem_read_many", "vmem_list", "vmem_links", "vmem_mocs", "vmem_check", "vmem_log_tail",
30
- "vmem_create", "vmem_patch", "vmem_append", "vmem_set_frontmatter", "vmem_link", "vmem_supersede", "vmem_log_append", "vmem_move", "vmem_delete", "vmem_promote_from_amem", "vmem_lint", "vmem_graph_build", "vmem_graph_neighbors", "vmem_promote", "vmem_skills_mine"] as const;
32
+ "vmem_create", "vmem_patch", "vmem_append", "vmem_set_frontmatter", "vmem_link", "vmem_supersede", "vmem_log_append", "vmem_move", "vmem_delete", "vmem_promote_from_amem", "vmem_lint", "vmem_graph_build", "vmem_graph_neighbors", "vmem_promote", "vmem_skills_mine", ...SPEC_COMMANDS.map(item => item.name)] as const;
31
33
 
32
34
  /** Tool definitions for a fixed vault set: `vault` is an enum of the registered ids. */
33
35
  export function vaultTools(ids: readonly string[], orgIds: readonly string[] = []): McpTool[] {
@@ -169,11 +171,13 @@ export function createVaultMcp(home: string, env: NodeJS.ProcessEnv = process.en
169
171
  return { ...receipt, backlinks };
170
172
  },
171
173
  };
174
+ const specs = createSpecMcp(home, env);
175
+ Object.assign(handlers, specs.handlers);
172
176
  // Every handler returns a structured value; provider errors become refusals, never protocol errors.
173
177
  const guarded: Record<string, McpHandler> = {};
174
178
  for (const [name, handler] of Object.entries(handlers)) guarded[name] = async args => {
175
179
  try { return await handler(args); }
176
180
  catch (error) { return refusal((error as { code?: unknown }).code === "V04" ? "V04" : "REFUSED", error instanceof Error ? error.message.slice(0, 500) : "operation refused"); }
177
181
  };
178
- return { tools: vaultTools(ids, vaults.filter(v => v.kind === "org").map(v => v.id)), handlers: guarded, vaults, defaultVault };
182
+ return { tools: [...vaultTools(ids, vaults.filter(v => v.kind === "org").map(v => v.id)), ...specs.tools], handlers: guarded, vaults, defaultVault };
179
183
  }
@@ -0,0 +1,15 @@
1
+ import { SPEC_EDGE_KINDS } from "./spec-graph.ts";
2
+
3
+ const string = (maxLength: number, minLength = 1) => ({ type: "string", minLength, maxLength });
4
+ const integer = (maximum: number, minimum = 0) => ({ type: "integer", minimum, maximum });
5
+ const common = { source: string(64), worktree: string(4096) };
6
+ const schema = (properties: Record<string, unknown>, required: string[] = []) => ({ type: "object", properties: { ...common, ...properties }, required, additionalProperties: false, maxProperties: 20 });
7
+ /** Implemented read commands only; CLI flags and MCP definitions share these schemas. */
8
+ export const SPEC_COMMANDS = [
9
+ { command: "get", name: "vmem_spec_get", usage: "spec get <id> [--source ID] [--worktree PATH] [--body] [--section TEXT] [--offset N] [--max-bytes 1..65536] [--json]", description: "Read spec metadata, resolved links, backlinks, children and freshness; body reads are optional and paged.", inputSchema: schema({ id: string(1024), body: { type: "boolean" }, section: string(1024), offset: integer(2097152), maxBytes: integer(65536, 1) }, ["id"]) },
10
+ { command: "graph", name: "vmem_spec_graph", usage: "spec graph <id> [--source ID] [--worktree PATH] [--direction subtree|ancestors|neighbors] [--edge KIND] [--depth 0..3] [--limit 1..500] [--json]", description: "Read a bounded spec graph slice without raw source content.", inputSchema: schema({ id: string(1024), direction: { type: "string", enum: ["subtree", "ancestors", "neighbors"] }, edge: { type: "string", enum: [...SPEC_EDGE_KINDS] }, depth: integer(3), limit: integer(500, 1) }, ["id"]) },
11
+ { command: "grep", name: "vmem_spec_grep", usage: "spec grep [query] [--source ID] [--worktree PATH] [--literal] [--type TYPE] [--status STATUS] [--tag TAG] [--parent ID] [--depends-on ID] [--covers-path FILE] [--offset 0..10000] [--limit 1..500] [--json]", description: "Search specs with FTS terms or literal substring, metadata/path-scope filters and continuation offsets.", inputSchema: schema({ query: string(1024, 0), literal: { type: "boolean" }, type: string(256), status: string(256), tag: string(1024), parent: string(1024), dependsOn: string(1024), coversPath: string(4096), offset: integer(10000), limit: integer(500, 1) }) },
12
+ { command: "validate", name: "vmem_spec_validate", usage: "spec validate [--source ID] [--worktree PATH] [--require-fresh] [--limit 1..500] [--json] | spec validate --repo PATH [--require-fresh] [--limit 1..500] [--json]", description: "Validate spec candidates and report bounded errors, warnings and computed freshness; requireFresh makes stale scopes fatal.", inputSchema: schema({ requireFresh: { type: "boolean" }, limit: integer(500, 1) }) },
13
+ ] as const;
14
+ export type SpecCommand = typeof SPEC_COMMANDS[number]["command"];
15
+ export const specCommandSchema = (command: SpecCommand) => SPEC_COMMANDS.find(item => item.command === command)!.inputSchema;
@@ -0,0 +1,174 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { argumentError } from "./mcp.ts";
3
+ import { specCommandSchema, type SpecCommand } from "./spec-command-table.ts";
4
+ import { loadRegistry } from "./registry.ts";
5
+ import { h2Sections, headingMetadata } from "./vault-index.ts";
6
+ import { SpecIndex, discoverSpecs, type SpecDocument, type SpecSnapshot, type SpecSearchResult } from "./spec-source.ts";
7
+ import { buildSpecGraph, specReference, specSlice, type SpecEdgeKind } from "./spec-graph.ts";
8
+ import { specFreshness, specsCoveringPath } from "./spec-freshness.ts";
9
+ import { validateSpecs } from "./spec-validate.ts";
10
+
11
+ const MAX_RESPONSE_BYTES = 240 * 1024, MAX_RELATIONS = 500, BUDGET_RESERVE = 512;
12
+ const text = (value: unknown): string => typeof value === "string" ? value : "";
13
+ const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
14
+ function bytes(value: unknown): number {
15
+ try { return Buffer.byteLength(JSON.stringify(value)); }
16
+ catch (error) { throw new Error("spec result: metadata cannot be serialized safely; remove cyclic or unsupported frontmatter values before retrying", { cause: error }); }
17
+ }
18
+ function fits(result: unknown): boolean { return bytes(result) + BUDGET_RESERVE <= MAX_RESPONSE_BYTES; }
19
+ function checkBudget(result: unknown): void {
20
+ if (bytes(result) > MAX_RESPONSE_BYTES) throw new Error("spec result: required metadata/body exceeds 240 KiB; reduce maxBytes (--max-bytes) or oversized frontmatter before retrying");
21
+ }
22
+ function rows<T>(result: Record<string, unknown>, key: string, values: readonly T[], limit = MAX_RELATIONS): T[] {
23
+ const out: T[] = []; result[key] = out;
24
+ let used = bytes(result) + BUDGET_RESERVE;
25
+ for (const value of values) {
26
+ if (out.length >= limit) break;
27
+ const size = bytes(value) + (out.length ? 1 : 0);
28
+ if (used + size > MAX_RESPONSE_BYTES) break;
29
+ out.push(value); used += size;
30
+ }
31
+ if (out.length < values.length) result.truncated = true;
32
+ return out;
33
+ }
34
+ function clip(value: string, maximum: number): string {
35
+ const data = Buffer.from(value); if (data.length <= maximum) return value;
36
+ let end = maximum; while (end > 0 && (data[end]! & 0xc0) === 0x80) end--;
37
+ return data.subarray(0, end).toString("utf8");
38
+ }
39
+ function bodyPage(document: SpecDocument, args: Record<string, unknown>): Record<string, unknown> {
40
+ const section = typeof args.section === "string" ? args.section : undefined, offset = (args.offset as number | undefined) ?? 0, maximum = (args.maxBytes as number | undefined) ?? 16384;
41
+ if (section !== undefined && (!section.trim() || /[\r\n]/.test(section) || Buffer.byteLength(section) > 1024)) throw new Error("spec get: section must be one exact H2 name within 1024 UTF-8 bytes; choose a heading from the spec body");
42
+ let body = document.body;
43
+ const all = h2Sections(body);
44
+ if (section !== undefined) {
45
+ const selected = all.filter(item => item.name === section);
46
+ if (selected.length !== 1) throw new Error(selected.length ? "spec get: section is ambiguous; rename duplicate H2 headings before requesting this section" : "spec get: section was not found; select an exact H2 heading from the spec body");
47
+ body = body.slice(selected[0]!.start, selected[0]!.end);
48
+ }
49
+ const data = Buffer.from(body);
50
+ if (offset > data.length) throw new Error("spec get: offset exceeds the selected body/section; restart at offset 0 or use the last returned nextOffset");
51
+ if (offset < data.length && (data[offset]! & 0xc0) === 0x80) throw new Error("spec get: offset is not a UTF-8 boundary; use a nextOffset returned by the previous page");
52
+ let end = Math.min(offset + maximum, data.length);
53
+ while (end > offset && end < data.length && (data[end]! & 0xc0) === 0x80) end--;
54
+ if (end === offset && offset < data.length) throw new Error("spec get: maxBytes cannot fit the next UTF-8 character; increase maxBytes (--max-bytes) before retrying");
55
+ const truncated = end < data.length;
56
+ return { body: data.subarray(offset, end).toString("utf8"), bodyTruncated: truncated,
57
+ ...(truncated ? { nextOffset: end, ...headingMetadata(all) } : {}),
58
+ ...(section === undefined ? {} : { section }), ...(args.offset === undefined ? {} : { offset }) };
59
+ }
60
+ function metadataMatch(document: SpecDocument, args: Record<string, unknown>): boolean {
61
+ const data = document.frontmatter;
62
+ if (args.type !== undefined && data.type !== args.type || args.status !== undefined && data.status !== args.status) return false;
63
+ if (args.tag !== undefined && !(Array.isArray(data.tags) ? data.tags : [data.tags]).some(tag => tag === args.tag)) return false;
64
+ if (args.parent !== undefined && specReference(data.parent) !== specReference(args.parent)) return false;
65
+ if (args.dependsOn !== undefined && (!Array.isArray(data["depends-on"]) || !data["depends-on"].some(value => specReference(value) === specReference(args.dependsOn)))) return false;
66
+ return true;
67
+ }
68
+ function grep(index: SpecIndex, snapshot: SpecSnapshot, args: Record<string, unknown>, env: NodeJS.ProcessEnv, source: string): Record<string, unknown> {
69
+ const rawQuery = text(args.query), query = args.literal === true ? rawQuery : rawQuery.trim(), offset = (args.offset as number | undefined) ?? 0, limit = (args.limit as number | undefined) ?? 50;
70
+ if (Buffer.byteLength(rawQuery) > 1024) throw new Error("spec grep: query exceeds 1024 UTF-8 bytes; shorten the query before retrying");
71
+ let documents = snapshot.specs.filter(document => metadataMatch(document, args));
72
+ if (typeof args.coversPath === "string") {
73
+ const covered = new Set(specsCoveringPath({ ...snapshot, specs: documents }, args.coversPath, { env }).map(document => document.path));
74
+ documents = documents.filter(document => covered.has(document.path));
75
+ }
76
+ const byPath = new Map(documents.map(document => [document.path, document]));
77
+ const selected: Record<string, unknown>[] = [];
78
+ const hit = (document: SpecDocument, indexed?: SpecSearchResult): Record<string, unknown> => {
79
+ let start = query ? Math.max(0, document.source.indexOf(query) - 80) : 0;
80
+ // The context window is measured in UTF-16 units; do not start on an emoji's low surrogate.
81
+ if (start > 0 && (document.source.codePointAt(start - 1) ?? 0) > 0xffff) start--;
82
+ return { id: document.id, path: document.path, title: clip(text(document.frontmatter.title) || document.id, 512), type: text(document.frontmatter.type),
83
+ ...(typeof document.frontmatter.status === "string" ? { status: document.frontmatter.status } : {}),
84
+ snippet: indexed?.snippet ?? clip(document.source.slice(start), 1024), ...(indexed ? { score: indexed.score } : {}) };
85
+ };
86
+ let matched = 0;
87
+ if (!query || args.literal === true) {
88
+ for (const document of [...documents].sort((a, b) => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path))) {
89
+ if (query && !document.source.includes(query)) continue;
90
+ if (matched++ < offset) continue;
91
+ selected.push(hit(document)); if (selected.length > limit) break;
92
+ }
93
+ } else {
94
+ // A peer may rescan the cache while this request runs. Materialize one bounded
95
+ // statement result so metadata filtering and continuation use a stable hit set.
96
+ for (const item of index.searchAll(query)) {
97
+ const document = byPath.get(item.path); if (!document) continue;
98
+ if (matched++ < offset) continue;
99
+ selected.push(hit(document, item)); if (selected.length > limit) break;
100
+ }
101
+ }
102
+ const result: Record<string, unknown> = { source, worktree: snapshot.root, hits: [], offset, truncated: false };
103
+ const kept = rows(result, "hits", selected.slice(0, limit), limit);
104
+ if (selected.length > kept.length) {
105
+ if (!kept.length) throw new Error("spec grep: matching result metadata exceeds the response budget; narrow metadata filters or shorten oversized source metadata before retrying");
106
+ result.truncated = true; result.nextOffset = offset + kept.length;
107
+ }
108
+ return result;
109
+ }
110
+
111
+ /** Shared read-only provider for CLI and MCP; caches may be derived outside all registered sources. */
112
+ export function executeSpecCommand(home: string, command: SpecCommand, args: Record<string, unknown>, env: NodeJS.ProcessEnv = process.env): unknown {
113
+ if (!["get", "graph", "grep", "validate"].includes(command)) throw new Error("spec provider: unknown command; choose get, graph, grep or validate");
114
+ const invalid = argumentError(args, specCommandSchema(command));
115
+ if (invalid) throw new Error(`spec ${command}: ${invalid}; use vmem spec ${command} --help to correct the arguments`);
116
+ if (command === "grep") for (const field of ["parent", "dependsOn"] as const) {
117
+ if (args[field] !== undefined && specReference(args[field]) === undefined) throw new Error(`spec grep: ${field} filter resolves to no id; pass an id or [[id]]`);
118
+ }
119
+ const config = loadRegistry(home), sources = config.specs ?? [];
120
+ const source = args.source === undefined ? sources[0] : sources.find(item => item.id === args.source);
121
+ if (!source) throw new Error(sources.length ? "spec source is not registered; use vmem specs list and pass source (--source) with a registered id" : "no spec sources are registered; run vmem specs add <id> <absolute-repository-root> first");
122
+ const currentRegistry = () => {
123
+ let current;
124
+ try { current = loadRegistry(home); }
125
+ catch (error) { throw new Error("spec registry became unreadable during the request; run vmem doctor to diagnose the registry, repair it and retry the read", { cause: error }); }
126
+ if (!isDeepStrictEqual(current.specs?.find(item => item.id === source.id), source) || args.source === undefined && current.specs?.[0]?.id !== source.id) throw new Error("spec registry changed during the request; use vmem specs list to select the current source (--source) and retry the read");
127
+ };
128
+ let snapshot: SpecSnapshot, result: Record<string, unknown>;
129
+ if (command === "validate") {
130
+ snapshot = discoverSpecs(source, { env, ...(typeof args.worktree === "string" ? { worktree: args.worktree } : {}) });
131
+ result = { ...validateSpecs(snapshot, { env, requireFresh: args.requireFresh === true, ...(typeof args.limit === "number" ? { limit: args.limit } : {}) }) };
132
+ } else {
133
+ const index = new SpecIndex(home, source, { env, ...(typeof args.worktree === "string" ? { worktree: args.worktree } : {}), protectedRoots: [...config.vaults, ...sources].map(item => item.path) });
134
+ try {
135
+ index.scan(); snapshot = index.snapshot();
136
+ if (command === "grep") result = grep(index, snapshot, args, env, source.id);
137
+ else {
138
+ const graph = buildSpecGraph(snapshot), id = text(args.id), document = snapshot.specs.find(item => item.id === id);
139
+ if (!document) throw new Error("spec id was not found; pass an id from vmem spec grep with the matching source (--source)");
140
+ if (command === "graph") {
141
+ const slice = specSlice(graph, id, { ...(typeof args.direction === "string" ? { direction: args.direction as "subtree" | "ancestors" | "neighbors" } : {}), ...(typeof args.edge === "string" ? { edge: args.edge as SpecEdgeKind } : {}), ...(typeof args.depth === "number" ? { depth: args.depth } : {}), ...(typeof args.limit === "number" ? { limit: args.limit } : {}) });
142
+ result = { source: source.id, worktree: snapshot.root, nodes: [], edges: [], truncated: slice.truncated };
143
+ const root = slice.nodes.find(node => node.id === id)!;
144
+ if (!fits({ ...result, nodes: [root] })) throw new Error("spec graph: root metadata exceeds the response budget; shorten oversized spec metadata before requesting its graph");
145
+ const ordered = [root, ...slice.nodes.filter(node => node.id !== id)];
146
+ const nodes = rows(result, "nodes", ordered), ids = new Set(nodes.map(node => node.id));
147
+ rows(result, "edges", slice.edges.filter(edge => ids.has(edge.source) && ids.has(edge.target)));
148
+ } else {
149
+ const freshness = specFreshness({ ...snapshot, specs: [document], candidates: [document] }, { env });
150
+ const links = [], backlinks = [], childIds = new Set<string>();
151
+ for (const edge of graph.edges) {
152
+ if (edge.source === id) links.push(edge);
153
+ if (edge.target === id) {
154
+ backlinks.push(edge);
155
+ if (edge.kind === "parent") childIds.add(edge.source);
156
+ }
157
+ }
158
+ const children = graph.nodes.filter(node => childIds.has(node.id));
159
+ result = { source: source.id, worktree: snapshot.root, id, path: document.path, sha256: document.hash, frontmatter: document.frontmatter, links: [], backlinks: [], children: [], freshness: freshness.items[0] ?? null, freshnessIssues: freshness.issues,
160
+ truncated: false, totals: { links: links.length, backlinks: backlinks.length, children: children.length } };
161
+ if (args.body === true || args.maxBytes !== undefined || args.section !== undefined || args.offset !== undefined) {
162
+ Object.assign(result, bodyPage(document, args)); if (result.bodyTruncated === true) result.truncated = true;
163
+ }
164
+ if (!fits(result)) throw new Error("spec get: frontmatter/body metadata exceeds the response budget; reduce maxBytes (--max-bytes) or shorten oversized frontmatter before retrying");
165
+ rows(result, "links", links); rows(result, "backlinks", backlinks); rows(result, "children", children);
166
+ }
167
+ }
168
+ } finally { index.close(); }
169
+ }
170
+ currentRegistry();
171
+ result = { source: source.id, worktree: snapshot.root, ...result };
172
+ checkBudget(result);
173
+ return result;
174
+ }