@staix/vault-mem 0.3.1 → 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,26 @@
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
+
13
+ ## [0.3.2] - 2026-11-02
14
+
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.
16
+
17
+ - Register local git roots separately with `specs add|list|remove`; removal previews and unregisters only. Spec root diagnostics distinguish a missing git executable, timeout, git refusal and non-root path, and support repository names containing backslashes. Spec sources default to `write: none` and `pii: deny`, share IDs and the atomic registry lock with vaults, and get root diagnostics in `doctor`. Config writes use format 2 only when `specs` is present; vault-only settings and output stay compatible. Spec graph/read/validation tools and spec content writes are not part of this increment (#91 T1).
18
+
19
+ - MCP argument errors name the first failing path and reason without echoing values; unknown tools have a separate diagnostic. `vmem_create` accepts top-level `created` and checks a real YYYY-MM-DD calendar date. Writer/log CLI errors name rejected options, writer arity errors show the expected form, and per-command `--help`/`-h` prints usage (#94).
20
+
21
+ - V05 refuses newly introduced Topics placement or content problems while retaining existing problems as warnings. `frontmatter --set topics` updates only the body Topics wiki-list items in the same atomic write, preserving fenced blocks, H3/prose and line endings; an unchanged topics value leaves the body unchanged. All create paths, including org `promote` and `vmem_create` with a custom body, now refuse a note whose Topics is not the final H2 or does not match its frontmatter topics. Existing vaults must run `vmem tidy <vault> --all` once after upgrading to the validating writer, previewing and then confirming its fingerprint; `doctor` reports pending migration counts and unparsable paths without writing, using only the planning pass, and reports thrown or returned planning failures as per-vault diagnostics (#92).
22
+
23
+ - Releases publish a GitHub release next to the npm package, with the validated tarball and manifest attached and the CHANGELOG section as notes (#89).
24
+
5
25
  ## [0.3.1] - 2026-11-01
6
26
 
7
27
  - `sync` and `doctor` refuse a git URL rewrite (`insteadOf`/`pushInsteadOf`) only when its prefix matches the configured remote or one of origin's configured or effective URLs, explicit `pushurl` included, and name that rule (#86). An unrelated rule, such as a global `https://github.com/` to SSH rewrite with an SSH vault remote, no longer blocks sync.
@@ -65,7 +85,7 @@
65
85
 
66
86
  - Add vault registration, bounded configuration, home resolution, diagnostics and explicit unregister confirmation.
67
87
  - Add lossless frontmatter, local wiki-link resolution and incremental SQLite FTS indexing.
68
- - Add validated atomic note mutations, preview fingerprints and append-only attributed vault events.
88
+ - Add validated atomic note mutations, preview fingerprints and append-only attributed vault events. Existing vaults must run `vmem tidy <vault> --all` once after upgrading, previewing first and then confirming its fingerprint.
69
89
  - Add bounded CLI/MCP providers, reversible host setup and agent-mem promotion with provenance.
70
90
  - Add indexed lint, deterministic graph reports, bounded neighborhoods and synthetic reference-parity checks.
71
91
  - Reuse unchanged parsed notes and link analysis during write validation, refresh derived links only for affected sources, and recheck the staging snapshot by direct content comparison instead of rehashing.
package/README.md CHANGED
@@ -25,13 +25,13 @@ Home precedence: `VMEM_HOME` (the pointer is not consulted), else a `home` point
25
25
  {"vaults":[{"id":"personal","path":"/absolute/vault","kind":"personal","write":"mcp"}],"denyPaths":[],"piiPatterns":[]}
26
26
  ```
27
27
 
28
- Configuration is limited to 64 KiB both on read and after serialization (a change whose pretty-printed result exceeds the limit is refused and the file is unchanged), rejects unknown keys, and is updated under an exclusive lock using a flushed temporary file and atomic rename. An optional top-level `version` (positive integer, absent means 1) names the config format; this build writes format 1 and never adds `version` itself, so 0.2.4 and older keep reading what it writes. A config whose `version` is newer than this build's format is read tolerantly: unknown keys at the top level, in vaults and in PII policies are kept and written back unchanged by registry commands, while the keys this build knows are still validated. A PII policy with members this build does not know cannot be enforced, so it fails closed: every write on that vault (note writes and `check`, two-step previews and confirms, tidy, `promote-from-amem`, `promote` and log appends) is refused with `PII_POLICY`, naming the members, until the newer vmem that wrote the policy does the write. Reads and `vaults pii` show are unaffected. `vaults pii --set` refuses to replace a policy that carries such members, since the replacement would drop them; change it with the newer vmem. Versions that add a key bump the format. Errors name the config file or vault id and path but never echo config or note contents.
28
+ Configuration is limited to 64 KiB both on read and after serialization (a change whose pretty-printed result exceeds the limit is refused and the file is unchanged), rejects unknown keys, and is updated under an exclusive lock using a flushed temporary file and atomic rename. An optional top-level `version` (positive integer, absent means 1) names the config format; this build supports format 2 and adds `version: 2` when `specs` is present. Vault-only configurations retain their existing version field (or its absence), so 0.2.4 and older keep reading those configurations. A config whose `version` is newer than this build's format is read tolerantly: unknown keys at the top level, in vaults and in PII policies are kept and written back unchanged by registry commands, while the keys this build knows are still validated. A PII policy with members this build does not know cannot be enforced, so it fails closed: every write on that vault (note writes and `check`, two-step previews and confirms, tidy, `promote-from-amem`, `promote` and log appends) is refused with `PII_POLICY`, naming the members, until the newer vmem that wrote the policy does the write. Reads and `vaults pii` show are unaffected. `vaults pii --set` refuses to replace a policy that carries such members, since the replacement would drop them; change it with the newer vmem. Versions that add a key bump the format. Errors name the config file or vault id and path but never echo config or note contents.
29
29
 
30
30
  Durability: the temporary file is fsynced before the rename and the home directory is fsynced after it. On Linux this makes the new config durable once the command returns. On macOS directory fsync succeeds but does not flush the drive cache (no `F_FULLFSYNC`), so a power loss right after a change can revert to the previous complete config, never to a partial one. Windows is not a supported platform for durability claims; the rename stays atomic.
31
31
 
32
32
  Crash recovery: the lock file records the writer's pid, host and start time. Nothing is cleaned up automatically. `vmem recover` previews the state; `vmem recover --confirm` removes the lock only when it names this host and a pid that no longer runs, and then removes leftover `.config-<uuid>.tmp` files. A live lock, a lock from another host, or an unreadable (older-format) lock is never removed and the command exits 1; confirm no writer runs, then delete `config.lock` by hand. No other file in the home or any vault is touched. If cleanup fails after an operation (for example the lock cannot be removed), the original error is kept and the cleanup failure, including a retained lock, is appended to it.
33
33
 
34
- Vault fields: `id`, absolute `path` (must contain a real `notes/` directory), `kind` (`personal|org`), `write` (`mcp|cli|none`), optional `pii` (the `allow|deny` shorthand or a policy object, see PII policy below; default `allow` for personal and `deny` for org, and `status`/`vaults list` always report the effective shorthand and `piiPolicy` matrix, including for unhealthy entries), optional `remote` (any nonempty string such as an `https://` or `git@host:path` URL) and optional `registry` (absolute path). For healthy vaults, `doctor` checks locally that the vault is a git repository root with an origin whose fetch and push URLs all match `remote` (URL rewrites are refused), and that `registry` passes the same deny-path, size and project-id validation as log appends. Results appear under each vault's `checks.remote` and `checks.registry`; a failed check exits 1. These checks use no network and do not test reachability. `status` is unchanged. Policy fields are stored for later note-writing stages, not enforced by the read commands. Writer commands enforce write/channel policy and configured deny/PII screening.
34
+ Vault fields: `id`, absolute `path` (must contain a real `notes/` directory), `kind` (`personal|org`), `write` (`mcp|cli|none`), optional `pii` (the `allow|deny` shorthand or a policy object, see PII policy below; default `allow` for personal and `deny` for org, and `status`/`vaults list` always report the effective shorthand and `piiPolicy` matrix, including for unhealthy entries), optional `remote` (any nonempty string such as an `https://` or `git@host:path` URL) and optional `registry` (absolute path). For healthy vaults, `doctor` checks locally that the vault is a git repository root with an origin whose fetch and push URLs all match `remote` (URL rewrites are refused), and that `registry` passes the same deny-path, size and project-id validation as log appends. Results appear under each vault's `checks.remote` and `checks.registry`. Doctor also runs the tidy planning pass for writable vaults and reports pending counts and unparsable paths, without per-note writer validation or writes. Planning failures appear as `checks.tidy`; a failed check exits 1. Actual tidy previews validate planned notes and identify skipped notes. These checks use no network and do not test reachability. `status` is unchanged. Policy fields are stored for later note-writing stages, not enforced by the read commands. Writer commands enforce write/channel policy and configured deny/PII screening.
35
35
 
36
36
  The only runtime dependency is the exact first-party pin `@staix/agent-mem@0.17.0`; it is not loaded by registry commands. Public library exports cover configuration, home resolution, registry operations, frontmatter, link resolution and the derived index. No console bin or UI contract is advertised before its implementation.
37
37
 
@@ -117,11 +117,11 @@ vmem tidy personal --connections
117
117
  vmem tidy personal --all --json # migration sanitize preview (see Migration tidy)
118
118
  ```
119
119
 
120
- Syntax follows indexed reads: the vault id comes first. `new` supports `--title`, `--source`, `--confidence`, `--status`, `--extra JSON` and `--body file|-`; omitted body gets an H1/Relevant Notes/Topics skeleton. Patch accepts nonempty old/new strings up to 8192 bytes and requires exactly one occurrence (including overlapping occurrences). Append uses an existing section or inserts it before Topics; it refuses targeting or introducing a Topics H2. `frontmatter` supports `--set JSON`, `--unset key,key` and `--expected-sha`; append also supports `--expected-sha`. Link relations are `supports`, `contradicts`, `extends`, `applies-to`, `context-for`; links are bidirectional unless `--one-way`. Supersede sets `status`/`superseded_by` on the old note and adds a reverse link in the next note. Report permits plain paths below `reports/` or `ops/`, is schema-exempt and still screened. Inputs are bounded to 256 KiB, follow no file symlinks and accept stdin with `-`.
120
+ Syntax follows indexed reads: the vault id comes first. `new` supports `--title`, `--source`, `--confidence`, `--status`, `--extra JSON` and `--body file|-`; omitted body gets an H1/Relevant Notes/Topics skeleton. Patch accepts nonempty old/new strings up to 8192 bytes and requires exactly one occurrence (including overlapping occurrences). Append uses an existing section or inserts it before Topics; it refuses targeting or introducing a Topics H2. `frontmatter` supports `--set JSON`, `--unset key,key` and `--expected-sha`; changing `topics` also replaces only the body Topics wiki-list items in the same write, preserving other content and line endings; setting an equal topics value leaves the body unchanged; append also supports `--expected-sha`. Link relations are `supports`, `contradicts`, `extends`, `applies-to`, `context-for`; links are bidirectional unless `--one-way`. Supersede sets `status`/`superseded_by` on the old note and adds a reverse link in the next note. Report permits plain paths below `reports/` or `ops/`, is schema-exempt and still screened. Inputs are bounded to 256 KiB, follow no file symlinks and accept stdin with `-`.
121
121
 
122
122
  `--dry-run` plans and validates any writer without creating locks, temporary files, directories or caches. `check` always takes this path, even without `--dry-run`. Move, delete and tidy first return a preview with planned path/hash changes and a fingerprint; confirmation requires that fingerprint. It covers canonical operation input, the canonical vault path/identity and policy, expected SHAs, every scanned note and all touched files, so even a new inbound reference after preview forces another preview. Move rewrites visible wiki/heading/block/embed targets and wiki references in frontmatter across the vault, preserving explicit `|label` text, anchors and code. Alias-only links resolve to no note and are left as written. The move source may be an existing note with a legacy file name (lint L13, for example a dot in the stem); the destination must be a valid stem. Tidy folds Connections sections into Relevant Notes. Delete refuses when it would newly break inbound links or MOC topics; fix those references first.
123
123
 
124
- Validation returns named issues with severity and no matched secret text: V01 schema/enums/description; V02 existing MOC topics; V03 newly unresolved links; V04 flat ASCII note paths (maximum 200 bytes) and safe report paths; V05 final Topics placement/content (warning), with explicit append-to-Topics refusal; V06 pinned agent-mem secret regex parity and denied sources/input/snapshot paths; V07 email/phone/configured PII patterns when denied; V08 create duplicates (stem/NFKC H1); V09 256 KiB prospective note size / 16 MiB report size, exact patch and expected-SHA/drift; V10 supersession target integrity. Unchanged pre-existing broken links warn, including after a move. `possibleDuplicates` lists candidate paths for warnings from a disposable in-memory FTS5 query of title terms, with BM25 <= -0.000001, up to three paths; no disk cache is written for that check. `LOCKED`, `IO`, `ROLLBACK`, `INDEX` and `CLEANUP` identify operational failures. CLI returns exit 1 when `ok` is false.
124
+ Validation returns named issues with severity and no matched secret text: V01 schema/enums/description; V02 existing MOC topics; V03 newly unresolved links; V04 flat ASCII note paths (maximum 200 bytes) and safe report paths; V05 final Topics placement/content (error when the write introduces a placement or content problem the note did not already have, or on create; warning when every problem was already present), with explicit append-to-Topics refusal; V06 pinned agent-mem secret regex parity and denied sources/input/snapshot paths; V07 email/phone/configured PII patterns when denied; V08 create duplicates (stem/NFKC H1); V09 256 KiB prospective note size / 16 MiB report size, exact patch and expected-SHA/drift; V10 supersession target integrity. Unchanged pre-existing broken links warn, including after a move. `possibleDuplicates` lists candidate paths for warnings from a disposable in-memory FTS5 query of title terms, with BM25 <= -0.000001, up to three paths; no disk cache is written for that check. `LOCKED`, `IO`, `ROLLBACK`, `INDEX` and `CLEANUP` identify operational failures. CLI returns exit 1 when `ok` is false.
125
125
 
126
126
  A successful receipt reports `applied`, planned path/hash/byte changes, issues and `indexUpdated`. Unrelated malformed legacy YAML is skipped by duplicate hints and by writer index refresh, with a V01 warning naming the unchanged note; normal read/rebuild commands retain strict parsing. Move/tidy still require parseable inputs and name the offending path on refusal. Reads allocate each note's stat size plus one growth-probe byte, capped at the 2 MiB read limit plus one. A failed index refresh rolls back the file changes. Rollback first verifies each file still equals this operation's committed result; external edits are preserved. Failed/refused rollback retains the original backup and reports its relative recovery path. All files are staged before replacement and backups permit rollback of caught multi-file failures. Each individual rename is atomic; a machine/process crash between multiple renames can leave a partially completed batch. The retained lock and `.vmem-<uuid>.tmp` files require inspection and manual recovery after confirming the writer stopped. They are never automatically removed by registry recovery. Uncoordinated editors do not honor the lock; the pipeline rechecks SHAs before replacement, but this does not provide a filesystem transaction against concurrent external edits. Existing notes may be read up to the index's 2 MiB limit; prospective notes are limited to 256 KiB; report operations under `reports/` and `ops/` allow up to 16 MiB for both prospective content and existing-file reads (including staging and rollback rechecks), with at most 10000 flat notes per mutation snapshot. File/directory fsync has the platform guarantees described above. Successful changed mutations now append an event as described below; log failure is reported separately from note success.
127
127
 
@@ -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}` |
@@ -306,7 +310,7 @@ Runtime changes intentionally make `release:verify` report stale historical rele
306
310
 
307
311
  # Migration tidy
308
312
 
309
- `vmem tidy <vault> [--connections --escapes --enums --keys --sources --topics --secrets --log | --all] [--map <file>] [--json] [--confirm --fingerprint SHA]` sanitizes debt from older, unvalidated writers. It is meant for the one-time V2 cutover and later imports.
313
+ `vmem tidy <vault> [--connections --escapes --enums --keys --sources --topics --secrets --log | --all] [--map <file>] [--json] [--confirm --fingerprint SHA]` sanitizes debt from older, unvalidated writers. Existing vaults must run `vmem tidy <vault> --all` once after upgrading to the validating writer, previewing first and then confirming the returned fingerprint. `vmem doctor` reports pending migration planning counts and unparsable paths for writable vaults; it writes nothing. Doctor does not run the per-note writer validation, so its planning counts may include notes the actual tidy preview will skip. Tidy also handles later imports.
310
314
 
311
315
  - **Two steps.** Without `--confirm` it prints per-rule `counts`, a per-note change list (`key: old -> new`, dropped keys with their values, sections added) and `reported` items that were left unchanged; nothing is written. With `--confirm` the fingerprint must match: it covers the rules, the map file, and the before/after sha of every touched file (and of the log). Each note is then written through `applyWrite` (atomic per note, under the vault lock) and one `LINT` event is appended, for example `tidy: escapes 1, enums 13, keys 78, ...`.
312
316
  - **Never blocked by one note.** A sanitized note that the writer would still refuse (for example an unknown key) is left unchanged, listed under `skipped` and `reported`, and the rest of the run proceeds. Counts and the `LINT` event cover only notes that are written.
@@ -326,3 +330,50 @@ Runtime changes intentionally make `release:verify` report stale historical rele
326
330
  | `--log` | replace secret-looking substrings in existing log lines with `[redacted]`, and PII values that the vault policy denies on the `log` surface (allow-list entries excepted), under the log lock (refused with V06 when `denyPaths` covers the log). A line that fails the lint L09 structure check loses a trailing seconds field when that makes it valid, and is otherwise kept verbatim behind `# legacy: ` (readers skip `#` lines); no time, zone or TYPE is guessed. Other lines are kept byte for byte. Git history is not rewritten: rotate exposed credentials |
327
331
 
328
332
  `--map <file>` overrides defaults with strict JSON `{"enums": {"confidence|status|type": {"from": "to"}}, "keys": {"key": "drop" | "keep" | {"rename": "key"}}}` (at most 64 KiB and 1000 entries). For `tidy`, `--log` selects the log rule; tidy always writes its own `LINT` event.
333
+
334
+ # Spec source registry
335
+
336
+ Register a local repository as a separate spec source:
337
+
338
+ ```sh
339
+ vmem specs add project-specs /absolute/repository --write none --pii deny
340
+ vmem specs list --json
341
+ vmem doctor --json
342
+ vmem specs remove project-specs
343
+ vmem specs remove project-specs --confirm
344
+ ```
345
+
346
+ The config `specs` array holds `id`, canonical git working-tree root `path`, `write` (`mcp|cli|none`, default `none`) and `pii` (`allow|deny`, default `deny`). IDs are unique across vaults and spec sources. A spec source does not need a `notes/` directory. Registration and removal reuse the vault registry lock and atomic config write. Removal previews first, then `--confirm` unregisters only; repository files remain in place.
347
+
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.
349
+
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.1",
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,5 @@
1
+ import { runSpecCommand } from "./mcp-spec.ts";
2
+ import { tidyVault, TIDY_RULES } from "./tidy.ts";
1
3
  import { originCheck } from "./sync.ts";
2
4
  import { registryIds } from "./vault-log.ts";
3
5
  import { runAnalysisCommand } from "./analysis-commands.ts";
@@ -11,7 +13,7 @@ import { VMEM_TOOL_NAMES } from "./mcp-vault.ts";
11
13
  import { CONFIG_VERSION } from "./config.ts";
12
14
  import { runSetup, type SetupMode } from "./setup.ts";
13
15
  import { resolveHome } from "./home.ts";
14
- import { addVault, effectivePii, loadRegistry, recoverRegistry, registryStatus, removeVault, setVaultPii } from "./registry.ts";
16
+ import { addVault, addSpec, removeSpec, specStatus, effectivePii, loadRegistry, recoverRegistry, registryStatus, removeVault, setVaultPii } from "./registry.ts";
15
17
  import { piiPolicy } from "./pii.ts";
16
18
  function parse(args: string[], allowed: string[], values: string[]) {
17
19
  const flags: Record<string, string | boolean> = {}, positional: string[] = [];
@@ -27,8 +29,18 @@ function parse(args: string[], allowed: string[], values: string[]) {
27
29
  }
28
30
  return { flags, positional };
29
31
  }
32
+ function commandHelp(args: string[]): string | undefined {
33
+ if (args.includes("--")) return undefined;
34
+ if (!["--help", "-h"].includes(args.at(-1) ?? "")) return undefined;
35
+ const name = args.slice(0, -1).join(" ");
36
+ if (!name) return undefined;
37
+ const commands = COMMANDS.filter(command => command.name === name || command.name.startsWith(name + " "));
38
+ return commands.length ? commands.map(command => `Usage: vmem ${command.usage}`).join("\n") : undefined;
39
+ }
30
40
  export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
31
41
  if (!args.length || (args.length === 1 && ["--help", "-h", "help"].includes(args[0]!))) return { text: help(), code: 0 };
42
+ const usage = commandHelp(args);
43
+ if (usage) return { text: usage, code: 0 };
32
44
  if (args.length === 1 && args[0] === "--version") return { text: version, code: 0 };
33
45
  if (args[0] === "manifest") {
34
46
  const { flags, positional } = parse(args.slice(1), ["--json"], []);
@@ -47,23 +59,25 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
47
59
  if (args.length !== 2) throw new Error("completion: requires bash, zsh or fish");
48
60
  return { text: completionScript(args[1]!).trimEnd(), code: 0 };
49
61
  }
62
+ if (args[0] === "spec") return runSpecCommand(args.slice(1), env);
50
63
  if (args[0] === "lint" || args[0] === "graph") return runAnalysisCommand(args[0],args.slice(1),resolveHome(env).home,env);
51
64
  if (args[0] === "promote" || args[0] === "sync") return runOrgCommand(args[0], args.slice(1), resolveHome(env).home, env);
52
65
  if (args[0] === "log") return runLogCommand(args.slice(1), resolveHome(env).home, env);
53
66
  if (isWriteCommand(args[0]!)) return runWriteCommand(args[0]!, args.slice(1), resolveHome(env).home, env);
54
67
  if (isReadCommand(args[0]!)) return runReadCommand(args[0]!, args.slice(1), resolveHome(env).home);
55
- const command = args[0] === "vaults" ? `vaults ${args[1] ?? ""}` : args[0];
56
- const tail = args.slice(args[0] === "vaults" ? 2 : 1);
57
- const options = command === "vaults add" ? ["--kind", "--write", "--pii", "--remote", "--registry"] : command === "vaults pii" ? ["--set"] : [];
58
- const { flags, positional } = parse(tail, ["--json", ...options, ...(command === "vaults remove" || command === "vaults pii" || command === "recover" ? ["--confirm"] : [])], options);
68
+ const group = args[0] === "vaults" || args[0] === "specs";
69
+ const command = group ? `${args[0]} ${args[1] ?? ""}` : args[0];
70
+ const tail = args.slice(group ? 2 : 1);
71
+ const options = command === "vaults add" ? ["--kind", "--write", "--pii", "--remote", "--registry"] : command === "vaults pii" ? ["--set"] : command === "specs add" ? ["--write", "--pii"] : [];
72
+ const { flags, positional } = parse(tail, ["--json", ...options, ...(command === "vaults remove" || command === "specs remove" || command === "vaults pii" || command === "recover" ? ["--confirm"] : [])], options);
59
73
  const home = resolveHome(env);
60
74
  let result: unknown, code = 0;
61
75
  switch (command) {
62
76
  case "status": case "doctor": {
63
77
  if (positional.length) throw new Error(`${command}: unexpected arguments`);
64
- const config = command === "doctor" ? loadRegistry(home.home) : undefined;
78
+ const config = loadRegistry(home.home);
65
79
  const vaults = registryStatus(home.home).map(vault => {
66
- if (command !== "doctor" || !vault.healthy || (!vault.remote && !vault.registry)) return vault;
80
+ if (command !== "doctor" || !vault.healthy) return { ...vault, checks: undefined };
67
81
  const checks: Record<string, { ok: boolean; issue?: string }> = {};
68
82
  if (vault.remote) {
69
83
  try {
@@ -72,13 +86,26 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
72
86
  } catch (error) { checks.remote = { ok: false, issue: error instanceof Error ? error.message : "git failed" }; }
73
87
  }
74
88
  if (vault.registry) {
75
- try { registryIds(vault.registry, config!.denyPaths); checks.registry = { ok: true }; }
89
+ try { registryIds(vault.registry, config.denyPaths); checks.registry = { ok: true }; }
76
90
  catch (error) { checks.registry = { ok: false, issue: error instanceof Error ? error.message : "cannot read project registry" }; }
77
91
  }
78
- return { ...vault, checks };
92
+ let preview: ReturnType<typeof tidyVault> | undefined;
93
+ try {
94
+ if (vault.write !== "none") preview = tidyVault(home.home, { vault: vault.id, rules: [...TIDY_RULES] }, { env, validate: false });
95
+ } catch (error) { checks.tidy = { ok: false, issue: error instanceof Error ? error.message : "cannot preview migration tidy" }; }
96
+ if (preview && !preview.ok) {
97
+ const failures = preview.issues.filter(issue => issue.code !== "POLICY" && issue.code !== "PII_POLICY");
98
+ if (failures.length) checks.tidy = { ok: false, issue: failures.map(issue => `${issue.code}: ${issue.message}`).join("; ") };
99
+ }
100
+ const unparsable = preview?.reported.filter(item => item.message.startsWith("unparsable note skipped")).map(item => item.path) ?? [];
101
+ const tidy = preview?.ok && (preview.notes.length || unparsable.length || Object.values(preview.counts).some(count => count > 0))
102
+ ? { pending: preview.counts, notes: preview.notes.length, unparsable, hint: `Run vmem tidy ${vault.id} --all, then confirm its preview fingerprint to apply the migration.` }
103
+ : undefined;
104
+ return { ...vault, checks: Object.keys(checks).length ? checks : undefined, ...(tidy ? { tidy } : {}) };
79
105
  });
80
- const healthy = vaults.every(v => v.healthy && (!("checks" in v) || Object.values(v.checks).every(check => check.ok)));
81
- result = { ...home, version, stage: "foundation", healthy, vaults };
106
+ const sources = command === "doctor" && config.specs !== undefined ? specStatus(home.home, env) : undefined;
107
+ const healthy = vaults.every(v => v.healthy && (!v.checks || Object.values(v.checks).every(check => check.ok))) && (!sources || sources.every(source => source.healthy));
108
+ result = { ...home, version, stage: "foundation", healthy, vaults, ...(config.specs !== undefined ? { specs: sources ?? config.specs } : {}) };
82
109
  if (command === "doctor" && !healthy) code = 1;
83
110
  break;
84
111
  }
@@ -87,6 +114,21 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
87
114
  result = recoverRegistry(home.home, flags["--confirm"] === true);
88
115
  if ((result as { blocked?: string }).blocked) code = 1;
89
116
  break;
117
+ case "specs list":
118
+ if (positional.length) throw new Error("specs list: unexpected targets; use vmem specs list [--json]");
119
+ result = loadRegistry(home.home).specs ?? [];
120
+ break;
121
+ case "specs add": {
122
+ if (positional.length !== 2) throw new Error("specs add: requires id and absolute repository root; use vmem specs add <id> <absolute-path> [--write mcp|cli|none] [--pii allow|deny]");
123
+ const value: Record<string, unknown> = { id: positional[0], path: positional[1] };
124
+ for (const option of options) if (flags[option] !== undefined) value[option.slice(2)] = flags[option];
125
+ result = { applied: true, spec: addSpec(home.home, value, env) };
126
+ break;
127
+ }
128
+ case "specs remove":
129
+ if (positional.length !== 1) throw new Error("specs remove: requires one id; use vmem specs list, then vmem specs remove <id> [--confirm]");
130
+ result = removeSpec(home.home, positional[0]!, flags["--confirm"] === true);
131
+ break;
90
132
  case "vaults list":
91
133
  if (positional.length) throw new Error("vaults list: unexpected arguments");
92
134
  result = loadRegistry(home.home).vaults.map(v => ({ ...v, pii: effectivePii(v), piiPolicy: piiPolicy(v) }));
@@ -129,6 +171,8 @@ export async function runMcp(env: NodeJS.ProcessEnv = process.env): Promise<void
129
171
  }
130
172
  /** Async commands. Only `promote-from-amem` and `skills mine` load the agent-mem seam, and only when they run. */
131
173
  export async function runAsync(args: string[], env: NodeJS.ProcessEnv = process.env, io: { stdinIsTTY?: boolean } = {}): Promise<{ text: string; code: number }> {
174
+ const usage = commandHelp(args);
175
+ if (usage) return { text: usage, code: 0 };
132
176
  if (args[0] === "skills") {
133
177
  const { runSkillsCommand } = await import("./skills-mine.ts");
134
178
  return runSkillsCommand(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 },
@@ -14,6 +16,9 @@ export const COMMANDS = [
14
16
  { name: "vaults add", usage: "vaults add <id> <absolute-path> --kind personal|org --write mcp|cli|none [--pii allow|deny] [--remote URL] [--registry PATH] [--json]", mutates: true },
15
17
  { name: "vaults remove", usage: "vaults remove <id> [--confirm] [--json]", mutates: "confirm" },
16
18
  { name: "vaults pii", usage: "vaults pii <id> [--set allow|deny|JSON] [--confirm] [--json]", mutates: "confirm" },
19
+ { name: "specs list", usage: "specs list [--json]", mutates: false },
20
+ { name: "specs add", usage: "specs add <id> <absolute-path> [--write mcp|cli|none] [--pii allow|deny] [--json]", mutates: true },
21
+ { name: "specs remove", usage: "specs remove <id> [--confirm] [--json]", mutates: "confirm" },
17
22
  { name: "new", usage: "new <vault> <stem> --type TYPE --domain DOMAIN --topics a,b --description TEXT [--title TEXT] [--source TEXT|JSON] [--confidence TEXT] [--status TEXT] [--body file|-] [--extra JSON] [--dry-run] [--json]", mutates: true },
18
23
  { name: "patch", usage: "patch <vault> <note> --old TEXT --new TEXT [--expected-sha SHA] [--dry-run] [--json]", mutates: true },
19
24
  { name: "append", usage: "append <vault> <note> --section SECTION --markdown TEXT [--expected-sha SHA] [--dry-run] [--json]", mutates: true },
@@ -66,10 +71,10 @@ export function completionScript(shell: string): string {
66
71
  if (shell === "fish") {
67
72
  const lines = [`complete -c vmem -f`, `complete -c vmem -n '__fish_use_subcommand' -a ${quote(top.join(" "))}`];
68
73
  for (const d of defs) {
69
- 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]}`;
70
75
  for (const flag of Object.keys(d.options)) lines.push(`complete -c vmem -n ${quote(cond)} -l ${flag.slice(2)}`);
71
76
  }
72
- for (const parent of ["vaults", "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(" "))}`);
73
78
  return lines.join("\n") + "\n";
74
79
  }
75
80
  const cases = defs.map(d => ` ${quote(d.path.join(" "))}) words=${quote(Object.keys(d.options).join(" "))} ;;`);
@@ -78,7 +83,7 @@ export function completionScript(shell: string): string {
78
83
  cur="\${COMP_WORDS[COMP_CWORD]}"
79
84
  if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W ${quote(top.join(" "))} -- "$cur")); return; fi
80
85
  key="\${COMP_WORDS[1]}"
81
- 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]}" ;; 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
82
87
  case "$key" in
83
88
  ${cases.join("\n")}
84
89
  *) words='' ;;
package/src/config.ts CHANGED
@@ -23,10 +23,11 @@ export interface PiiSpec { email?: PiiCell; phone?: PiiCell; custom?: PiiCell; p
23
23
  * The config format this build writes. A build that adds a key bumps it. A config whose `version` is newer
24
24
  * than this build is read tolerantly: unknown keys are kept as they are and written back unchanged, so an
25
25
  * older vmem can share a config with a newer one. At or below this version unknown keys stay errors (typos).
26
- * An absent `version` means 1, and this build never adds one, so 0.2.4 and older keep reading what it writes.
26
+ * An absent `version` means 1. Registry writes add format 2 only when `specs` is present; vault-only configs keep their existing version field.
27
27
  */
28
- export const CONFIG_VERSION = 1;
29
- export interface VmemConfig { version?: number; home?: string; vaults: VaultSpec[]; denyPaths?: string[]; piiPatterns?: string[] }
28
+ export const CONFIG_VERSION = 2;
29
+ export interface SpecSourceSpec { id: string; path: string; write: "mcp" | "cli" | "none"; pii: "allow" | "deny" }
30
+ export interface VmemConfig { version?: number; home?: string; vaults: VaultSpec[]; specs?: SpecSourceSpec[]; denyPaths?: string[]; piiPatterns?: string[] }
30
31
  const controls = /[\x00-\x1f\x7f]/;
31
32
  function object(value: unknown, keys: string[], label: string, tolerant = false): Record<string, unknown> {
32
33
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected object`);
@@ -125,25 +126,44 @@ export function parseVault(value: unknown, tolerant = false): VaultSpec {
125
126
  if (v.registry !== undefined) result.registry = absolute(v.registry, "vault.registry");
126
127
  return result;
127
128
  }
129
+ /** Spec sources have their own registry shape; they are not vaults. */
130
+ export function parseSpec(value: unknown, tolerant = false): SpecSourceSpec {
131
+ try {
132
+ const keys = ["id", "path", "write", "pii"], raw = object(value, keys, "spec source", tolerant);
133
+ const id = string(raw.id, "spec source.id");
134
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(id)) throw new Error("spec source.id: expected a lowercase letter followed by at most 31 lowercase letters, digits or hyphens");
135
+ const write = raw.write === undefined ? "none" : raw.write, pii = raw.pii === undefined ? "deny" : raw.pii;
136
+ if (write !== "mcp" && write !== "cli" && write !== "none") throw new Error("spec source.write: expected mcp, cli or none");
137
+ if (pii !== "allow" && pii !== "deny") throw new Error("spec source.pii: expected allow or deny");
138
+ return { ...unknown(raw, keys, tolerant), id, path: absolute(raw.path, "spec source.path"), write, pii };
139
+ } catch (error) {
140
+ throw new Error(`${error instanceof Error ? error.message : "invalid spec source"}; use vmem specs add <id> <absolute-path> with --write mcp|cli|none and --pii allow|deny`);
141
+ }
142
+ }
128
143
  export function parseConfig(value: unknown): VmemConfig {
129
- const keys = ["version", "home", "vaults", "denyPaths", "piiPatterns"];
144
+ const keys = ["version", "home", "vaults", "specs", "denyPaths", "piiPatterns"];
130
145
  const version = (value as Record<string, unknown> | null)?.version;
131
146
  if (version !== undefined && (!Number.isSafeInteger(version) || (version as number) < 1)) throw new Error("config.version: expected a positive integer");
132
147
  const tolerant = newerFormat(value), raw = object(value, keys, "config", tolerant);
133
148
  const stamp = version === undefined ? {} : { version: version as number };
134
149
  if (raw.home !== undefined) {
135
- if (["vaults", "denyPaths", "piiPatterns"].some(key => raw[key] !== undefined)) throw new Error("config: home pointer must not contain vaults or policy");
150
+ if (["vaults", "specs", "denyPaths", "piiPatterns"].some(key => raw[key] !== undefined)) throw new Error("config: home pointer must not contain vaults, specs or policy; put source entries in the pointed-to home config");
136
151
  return { ...unknown(raw, keys, tolerant), ...stamp, home: absolute(raw.home, "config.home"), vaults: [] };
137
152
  }
138
- if (!Array.isArray(raw.vaults)) throw new Error("config.vaults: expected array");
139
- const vaults = raw.vaults.map(item => parseVault(item, tolerant));
153
+ const vaultInput = raw.vaults === undefined && raw.specs !== undefined ? [] : raw.vaults;
154
+ if (!Array.isArray(vaultInput)) throw new Error("config.vaults: expected array");
155
+ const vaults = vaultInput.map(item => parseVault(item, tolerant));
156
+ if (raw.specs !== undefined && !Array.isArray(raw.specs)) throw new Error("config.specs: expected an array; list spec source entries in specs");
157
+ const specs = (raw.specs as unknown[] | undefined)?.map(item => parseSpec(item, tolerant));
158
+ if (specs && new Set([...vaults, ...specs].map(source => source.id)).size !== vaults.length + specs.length) throw new Error("config: duplicate source id across vaults/specs; give each registered source a unique id");
159
+ if (specs && new Set(specs.map(source => source.path)).size !== specs.length) throw new Error("config: duplicate spec source path; keep only one entry for each repository root");
140
160
  if (new Set(vaults.map(v => v.id)).size !== vaults.length) throw new Error("config: duplicate vault id");
141
161
  if (new Set(vaults.map(v => v.path)).size !== vaults.length) throw new Error("config: duplicate vault path");
142
162
  const denyPaths = strings(raw.denyPaths, "denyPaths"), piiPatterns = strings(raw.piiPatterns, "piiPatterns");
143
163
  for (const pattern of piiPatterns ?? []) {
144
164
  try { new RegExp(pattern); } catch { throw new Error("piiPatterns: invalid regular expression"); }
145
165
  }
146
- return { ...unknown(raw, keys, tolerant), ...stamp, vaults, ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
166
+ return { ...unknown(raw, keys, tolerant), ...stamp, vaults, ...(specs !== undefined ? { specs } : {}), ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
147
167
  }
148
168
  /** Bounded descriptor read: reject links, devices and oversized/growing files. */
149
169
  export function readConfig(home: string): VmemConfig {
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { resolveHome } from "./home.ts";
2
- export { parseConfig, parseVault, readConfig, CONFIG_FILE, MAX_CONFIG_BYTES } from "./config.ts";
3
- export type { VaultSpec, VmemConfig } from "./config.ts";
4
- export { addVault, removeVault, loadRegistry, registryStatus, recoverRegistry, effectivePii, syncDirectory, LOCK_FILE } from "./registry.ts";
2
+ export { parseConfig, parseVault, parseSpec, readConfig, CONFIG_FILE, MAX_CONFIG_BYTES } from "./config.ts";
3
+ export type { VaultSpec, SpecSourceSpec, VmemConfig } from "./config.ts";
4
+ export { addVault, removeVault, addSpec, removeSpec, specStatus, loadRegistry, registryStatus, recoverRegistry, effectivePii, syncDirectory, LOCK_FILE } from "./registry.ts";
5
5
  export type { RecoveryReport, LockOwner } from "./registry.ts";
6
6
  export { parseFrontmatter, emitFrontmatter } from "./frontmatter.ts";
7
7
  export type { FrontmatterDocument } from "./frontmatter.ts";
@@ -42,3 +42,13 @@ export { normalizeSource, sourceReferences } from "./source.ts";
42
42
  export type { SourceValue, SourceReference } from "./source.ts";
43
43
  export { tidyVault, parseTidyMap, TIDY_RULES } from "./tidy.ts";
44
44
  export type { TidyRequest, TidyResult, TidyRule, TidyMap } from "./tidy.ts";
45
+
46
+ export { resolveSpecWorktree, discoverSpecs, SpecIndex } from "./spec-source.ts";
47
+ export type { SpecCandidate, SpecDocument, SpecDiagnostic, SpecSnapshot, SpecSourceOptions, SpecIndexOptions, SpecSearchResult } from "./spec-source.ts";
48
+ export { buildSpecGraph, specSlice, SPEC_EDGE_KINDS, MAX_SPEC_INPUTS, MAX_SPEC_EDGES } from "./spec-graph.ts";
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";
@@ -8,7 +8,7 @@ export function runLogCommand(args: string[], home: string, env: NodeJS.ProcessE
8
8
  for (let i = 1; i < args.length; i++) {
9
9
  const word = args[i]!;
10
10
  if (!word.startsWith("--")) { positional.push(word); continue; }
11
- if (Object.hasOwn(flags, word) || (word !== "--json" && !values.includes(word))) throw new Error("log: unknown or repeated option");
11
+ if (Object.hasOwn(flags, word) || (word !== "--json" && !values.includes(word))) throw new Error(`log ${command}: unknown or repeated option: ${word}`);
12
12
  if (word === "--json") flags[word] = true;
13
13
  else {
14
14
  const value = args[++i]; if (value === undefined || value.startsWith("--")) throw new Error("log: option value required");
@@ -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[] {
@@ -44,7 +46,7 @@ export function vaultTools(ids: readonly string[], orgIds: readonly string[] = [
44
46
  tool("vmem_mocs", "MOC notes with incoming link counts.", object({ vault })),
45
47
  tool("vmem_check", "Validate a prospective note text without writing.", object({ vault, path: string(200), text: string(262144, 0) }, ["text"])),
46
48
  tool("vmem_log_tail", "Read the newest vault log lines, optionally filtered.", object({ vault, n: integer(1, 200), type: { type: "string", enum: LOG_TYPES }, grep: string(256) })),
47
- tool("vmem_create", "Create a validated note. Requires a vault with write=mcp.", object({ vault, stem: string(200), type: string(64), domain: string(64), topics: { type: "array", maxItems: 32, items: string(200) }, description: string(1000), title: string(300), body: string(65536, 0), source: { type: ["string", "array"], minLength: 1, maxLength: 512, minItems: 1, maxItems: 32, items: string(512) }, confidence: string(32), status: string(32), extra: { type: "object", maxProperties: 32, additionalProperties: scalar }, log: logEvent }, ["stem", "type", "domain", "description"]), "write"),
49
+ tool("vmem_create", "Create a validated note. Requires a vault with write=mcp.", object({ vault, stem: string(200), type: string(64), domain: string(64), topics: { type: "array", maxItems: 32, items: string(200) }, description: string(1000), title: string(300), body: string(65536, 0), source: { type: ["string", "array"], minLength: 1, maxLength: 512, minItems: 1, maxItems: 32, items: string(512) }, confidence: string(32), status: string(32), created: string(32, 0), extra: { type: "object", maxProperties: 32, additionalProperties: scalar }, log: logEvent }, ["stem", "type", "domain", "description"]), "write"),
48
50
  tool("vmem_patch", "Replace one exact string in a note.", object({ vault, path: note, oldString: string(8192), newString: string(8192, 0), expectedSha: sha }, ["path", "oldString", "newString"]), "write"),
49
51
  tool("vmem_append", "Append markdown to a section of a note.", object({ vault, path: note, section: string(120), markdown: string(16384), expectedSha: sha }, ["path", "section", "markdown"]), "write"),
50
52
  tool("vmem_set_frontmatter", "Set or unset frontmatter keys of a note.", object({ vault, path: note, set: { type: "object", maxProperties: 32, additionalProperties: scalar }, unset: { type: "array", maxItems: 32, items: string(64) }, expectedSha: sha }, ["path"]), "write"),
@@ -140,8 +142,12 @@ export function createVaultMcp(home: string, env: NodeJS.ProcessEnv = process.en
140
142
  vmem_check: args => write(args, { op: "check", path: notePath(str(args.path) ?? "checked-note"), text: str(args.text)! }),
141
143
  vmem_log_tail: args => readLog(home, { vault: pick(args).id, limit: (args.n as number | undefined) ?? 50, ...(str(args.type) ? { type: str(args.type) } : {}), ...(str(args.grep) ? { grep: str(args.grep) } : {}) }),
142
144
  vmem_create: args => {
145
+ if (args.created !== undefined) {
146
+ const created = str(args.created), time = typeof created === "string" && /^\d{4}-\d{2}-\d{2}$/.test(created) ? Date.parse(created + "T00:00:00Z") : NaN;
147
+ if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== created) return refusal("V01", "created: expected YYYY-MM-DD");
148
+ }
143
149
  const frontmatter: Record<string, unknown> = { ...(args.extra as Record<string, unknown> | undefined ?? {}) };
144
- for (const key of ["type", "domain", "description", "title", "source", "confidence", "status"]) if (str(args[key]) !== undefined) frontmatter[key] = args[key];
150
+ for (const key of ["type", "domain", "description", "title", "source", "confidence", "status", "created"]) if (str(args[key]) !== undefined) frontmatter[key] = args[key];
145
151
  if (Array.isArray(args.source)) frontmatter.source = args.source as string[];
146
152
  if (Array.isArray(args.topics)) frontmatter.topics = (args.topics as string[]).map(v => v.startsWith("[[") ? v : `[[${v}]]`);
147
153
  return write(args, { op: "create", path: str(args.stem)!, frontmatter, ...(str(args.body) !== undefined ? { body: str(args.body) } : {}) });
@@ -165,11 +171,13 @@ export function createVaultMcp(home: string, env: NodeJS.ProcessEnv = process.en
165
171
  return { ...receipt, backlinks };
166
172
  },
167
173
  };
174
+ const specs = createSpecMcp(home, env);
175
+ Object.assign(handlers, specs.handlers);
168
176
  // Every handler returns a structured value; provider errors become refusals, never protocol errors.
169
177
  const guarded: Record<string, McpHandler> = {};
170
178
  for (const [name, handler] of Object.entries(handlers)) guarded[name] = async args => {
171
179
  try { return await handler(args); }
172
180
  catch (error) { return refusal((error as { code?: unknown }).code === "V04" ? "V04" : "REFUSED", error instanceof Error ? error.message.slice(0, 500) : "operation refused"); }
173
181
  };
174
- 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 };
175
183
  }