@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/src/mcp.ts CHANGED
@@ -9,31 +9,54 @@ export interface McpServerOptions { tools: readonly McpTool[]; handlers: Record<
9
9
  const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"];
10
10
  const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
11
11
 
12
- /** Runtime validation for the small schema vocabulary the tools use. Unknown keywords never widen acceptance. */
13
- export function validArguments(value: unknown, raw: unknown): boolean {
12
+ /** First schema failure only; paths and schema limits are reported, never argument values. */
13
+ export function argumentError(value: unknown, raw: unknown, path = ""): string | undefined {
14
+ const at = path || "arguments", fail = (reason: string) => `${at}: ${reason}`;
15
+ if (!plain(raw)) return fail("invalid schema");
14
16
  const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
15
17
  const types: unknown[] = Array.isArray(s.type) ? s.type : [s.type];
16
- if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return false;
17
- if (type === "number" && !types.includes("number") && !Number.isSafeInteger(value)) return false;
18
- if (s.enum && !s.enum.includes(value)) return false;
19
- if (typeof value === "string" && (value.length < (s.minLength ?? 0) || value.length > (s.maxLength ?? FRAME_BYTES) || value.includes("\0"))) return false;
20
- if (typeof value === "number" && (!Number.isFinite(value) || value < (s.minimum ?? -Infinity) || value > (s.maximum ?? Infinity))) return false;
21
- if (Array.isArray(value)) {
22
- if (value.length < (s.minItems ?? 0) || value.length > (s.maxItems ?? 64) || !plain(s.items)) return false;
23
- return value.every(item => validArguments(item, s.items));
18
+ if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return fail(`expected ${types.join(" or ")}`);
19
+ if (type === "number" && !types.includes("number") && !Number.isSafeInteger(value)) return fail("expected safe integer");
20
+ if (s.enum && !s.enum.includes(value)) return fail("not an allowed value");
21
+ if (typeof value === "string") {
22
+ if (value.length < (s.minLength ?? 0)) return fail(`shorter than ${s.minLength}`);
23
+ if (value.length > (s.maxLength ?? FRAME_BYTES)) return fail(`longer than ${s.maxLength ?? FRAME_BYTES}`);
24
+ if (value.includes("\0")) return fail("NUL is not allowed");
25
+ }
26
+ if (typeof value === "number") {
27
+ if (!Number.isFinite(value)) return fail("expected finite number");
28
+ if (value < (s.minimum ?? -Infinity)) return fail(`less than ${s.minimum}`);
29
+ if (value > (s.maximum ?? Infinity)) return fail(`greater than ${s.maximum}`);
24
30
  }
25
- if (plain(value)) {
31
+ if (Array.isArray(value)) {
32
+ if (value.length < (s.minItems ?? 0)) return fail(`fewer than ${s.minItems} items`);
33
+ if (value.length > (s.maxItems ?? 64)) return fail(`more than ${s.maxItems ?? 64} items`);
34
+ if (!plain(s.items)) return fail("invalid item schema");
35
+ for (let i = 0; i < value.length; i++) {
36
+ const error = argumentError(value[i], s.items, `${path}[${i}]`);
37
+ if (error) return error;
38
+ }
39
+ } else if (plain(value)) {
26
40
  const keys = Object.keys(value);
27
- if (keys.length < (s.minProperties ?? 0) || keys.length > (s.maxProperties ?? 64)) return false;
28
- if ((s.required ?? []).some((key: string) => !Object.hasOwn(value, key))) return false;
41
+ if (keys.length < (s.minProperties ?? 0)) return fail(`fewer than ${s.minProperties} properties`);
42
+ if (keys.length > (s.maxProperties ?? 64)) return fail(`more than ${s.maxProperties ?? 64} properties`);
43
+ for (const key of s.required ?? []) if (!Object.hasOwn(value, key)) return `${path ? path + "." : ""}${key}: required`;
29
44
  for (const key of keys) {
30
- if (["__proto__", "prototype", "constructor"].includes(key) || key.length > 200) return false;
31
- if (Object.hasOwn(s.properties ?? {}, key)) { if (!validArguments(value[key], s.properties[key])) return false; }
32
- else if (s.additionalProperties === false || !plain(s.additionalProperties) || !validArguments(value[key], s.additionalProperties)) return false;
45
+ if (key.length > 200) return fail("property name longer than 200");
46
+ const child = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? (path ? `${path}.${key}` : key) : `${path || "$"}[${JSON.stringify(key)}]`;
47
+ if (["__proto__", "prototype", "constructor"].includes(key)) return `${child}: forbidden property name`;
48
+ let schema;
49
+ if (Object.hasOwn(s.properties ?? {}, key)) schema = s.properties[key];
50
+ else if (s.additionalProperties === false || !plain(s.additionalProperties)) return `${path ? path + ": " : ""}unknown argument: ${key}`;
51
+ else schema = s.additionalProperties;
52
+ const error = argumentError(value[key], schema, child);
53
+ if (error) return error;
33
54
  }
34
- } else if (type === "object") return false;
35
- return true;
55
+ } else if (type === "object") return fail("expected plain object");
56
+ return undefined;
36
57
  }
58
+ /** Boolean compatibility API for the same closed, bounded schema vocabulary. */
59
+ export function validArguments(value: unknown, raw: unknown): boolean { return argumentError(value, raw) === undefined; }
37
60
 
38
61
  /** Serve until input ends. stdout carries protocol messages only; nothing else is written to `output`. */
39
62
  export async function serveMcp(options: McpServerOptions): Promise<void> {
@@ -70,7 +93,9 @@ export async function serveMcp(options: McpServerOptions): Promise<void> {
70
93
  if (!plain(params) || Object.keys(params).some(k => !["name", "arguments", "_meta"].includes(k)) || typeof params.name !== "string") { await error(id, -32602, "Invalid tool call"); return; }
71
94
  const definition = options.tools.find(t => t.name === params.name), args = params.arguments ?? {};
72
95
  const handler = definition && options.handlers[definition.name];
73
- if (!definition || !handler || !validArguments(args, definition.inputSchema)) { await error(id, -32602, "Invalid tool name or arguments"); return; }
96
+ if (!definition || !handler) { await error(id, -32602, `unknown tool: ${params.name.slice(0, 200)}`); return; }
97
+ const invalid = argumentError(args, definition.inputSchema);
98
+ if (invalid) { await error(id, -32602, invalid); return; }
74
99
  let result: unknown;
75
100
  try {
76
101
  const text = JSON.stringify(await handler(args as Record<string, unknown>));
@@ -8,7 +8,7 @@ import { normalizeTarget, proseLines } from "./links.ts";
8
8
  import { loadRegistry } from "./registry.ts";
9
9
  import { VaultIndex } from "./vault-index.ts";
10
10
  import { applyWrite } from "./writer.ts";
11
- import { notePath } from "./write-operations.ts";
11
+ import { notePath, replaceTopicsSection } from "./write-operations.ts";
12
12
  import { screenContent } from "./write-validation.ts";
13
13
  import { appendLog, logBinding } from "./vault-log.ts";
14
14
  import type { WriteIssue, WriteReceipt } from "./write-types.ts";
@@ -97,19 +97,6 @@ function screenDroppedLinks(home: string, source: VaultSpec, all: VaultSpec[], d
97
97
  // Counts and categories only: the linked names are exactly what must not be echoed.
98
98
  return [...found].map(([category, count]) => issue("SCREEN", `${category}: ${count} body link${count === 1 ? "" : "s"} point at notes that are never promoted; remove those links from the source note first`));
99
99
  }
100
- /** Replace the last prose `## Topics` section (never one inside a fence) with the given topics; append one if absent. */
101
- export function replaceTopicsSection(body: string, topics: string[]): string {
102
- const prose = proseLines(body), raw = body.split("\n");
103
- let start = -1;
104
- for (let i = prose.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics[ \t]*#*[ \t]*$/.test(prose[i]!)) { start = i; break; }
105
- const section = ["## Topics", "", ...topics.map(topic => `- ${topic}`), ""];
106
- if (start < 0) return `${body.replace(/\n*$/, "")}\n\n${section.join("\n")}`;
107
- // The section ends at the next prose heading of level 1 or 2.
108
- let end = raw.length;
109
- for (let i = start + 1; i < prose.length; i++) if (/^ {0,3}#{1,2}[ \t]/.test(prose[i]!)) { end = i; break; }
110
- const tail = raw.slice(end);
111
- return [...raw.slice(0, start), ...section, ...(tail.length ? tail : [])].join("\n").replace(/\n*$/, "\n");
112
- }
113
100
  /** Replace visible wiki links whose target does not resolve in the target vault with their plain text. */
114
101
  export function dropUnresolvedLinks(body: string, resolves: (target: string) => boolean): { body: string; dropped: string[] } {
115
102
  const prose = proseLines(body), raw = body.split("\n"), dropped: string[] = [];
package/src/registry.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { closeSync, constants, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { realPath, memoryGitArgv, memoryGitEnv } from "./platform.ts";
2
4
  import { hostname } from "node:os";
3
5
  import { join } from "node:path";
4
6
  import { randomUUID } from "node:crypto";
5
- import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, piiExtensions, readConfig, type VaultSpec, type VmemConfig } from "./config.ts";
7
+ import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, parseSpec, piiExtensions, readConfig, type VaultSpec, type SpecSourceSpec, type VmemConfig } from "./config.ts";
6
8
  import { loosenedCells, piiPolicy, piiShorthand } from "./pii.ts";
7
9
  export const LOCK_FILE = "config.lock";
8
10
  const TEMP = /^\.config-[0-9a-f-]{36}\.tmp$/;
@@ -41,6 +43,7 @@ function mutate<T>(home: string, update: (config: VmemConfig) => T): T {
41
43
  const owner: LockOwner = { pid: process.pid, host: hostname(), created: new Date().toISOString() };
42
44
  writeFileSync(lockFd, JSON.stringify(owner) + "\n");
43
45
  const config = loadRegistry(home), result = update(config);
46
+ if (config.specs !== undefined) config.version = Math.max(config.version ?? 1, 2);
44
47
  parseConfig(config);
45
48
  const text = JSON.stringify(config, null, 2) + "\n";
46
49
  if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) throw new Error(`${join(home, CONFIG_FILE)}: serialized config exceeds 64 KiB`);
@@ -148,7 +151,7 @@ export function addVault(home: string, value: unknown): VaultSpec {
148
151
  if (!isDir) throw new Error(`vault ${vault.id}: ${vault.path} must contain notes directory`);
149
152
  if (realpathSync(notes) !== notes) throw new Error(`vault ${vault.id}: ${notes} must not be a symlink`);
150
153
  return mutate(home, config => {
151
- if (config.vaults.some(v => v.id === vault.id)) throw new Error(`vault ${vault.id}: id already registered`);
154
+ if ([...config.vaults, ...(config.specs ?? [])].some(v => v.id === vault.id)) throw new Error(`vault ${vault.id}: id already registered; use vmem vaults list and vmem specs list, then choose a unique id`);
152
155
  if (config.vaults.some(v => {
153
156
  if (v.path === vault.path) return true;
154
157
  try { return realpathSync(v.path) === vault.path; }
@@ -199,3 +202,61 @@ export function registryStatus(home: string) {
199
202
  } catch { return { ...base, healthy: false, issue: "vault path or notes directory unavailable or changed" }; }
200
203
  });
201
204
  }
205
+
206
+ /** Inspect only local git metadata, with inherited repository overrides removed and bounded output/time. */
207
+ function requireSpecRoot(path: string, env: NodeJS.ProcessEnv): void {
208
+ const argv = memoryGitArgv(["-C", path, "rev-parse", "--show-toplevel"]);
209
+ const result = spawnSync(argv[0]!, argv.slice(1), { env: memoryGitEnv({ ...env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, { repositoryIsolation: true }), encoding: "utf8", timeout: 5000, maxBuffer: 65536, stdio: ["ignore", "pipe", "pipe"] });
210
+ if (result.error) {
211
+ const code = (result.error as NodeJS.ErrnoException).code;
212
+ if (code === "ETIMEDOUT") throw new Error("spec source: git root check timed out after 5000 ms; retry and check for a locked repository or slow mount");
213
+ if (code === "ENOBUFS") throw new Error("spec source: git root check output exceeded 64 KiB; inspect git -C <path> rev-parse --show-toplevel and repair unexpected output before retrying");
214
+ throw new Error(`spec source: git could not run (${code ?? "spawn failure"}); install git or fix PATH, then retry the root check`);
215
+ }
216
+ if (result.status !== 0) {
217
+ const firstLine = result.stderr.split(/\r?\n/, 1)[0]?.split(path).join("<path>").slice(0, 240) || "no diagnostic";
218
+ throw new Error(`spec source: git refused the root check (exit ${result.status ?? "signal"}): ${firstLine}; inspect git -C <path> rev-parse --show-toplevel and repair the repository or ownership configuration`);
219
+ }
220
+ const root = result.stdout.replace(/\r?\n$/, "");
221
+ if (root !== path) throw new Error(`spec source: selected path is inside a work tree but not its root; register the reported root ${root} with vmem specs add <id> <absolute-path>`);
222
+ }
223
+ export function addSpec(home: string, value: unknown, env: NodeJS.ProcessEnv = process.env): SpecSourceSpec {
224
+ const spec = parseSpec(value);
225
+ try { spec.path = realPath(spec.path); }
226
+ catch { throw new Error(`spec source ${spec.id}: path is unavailable; restore the repository or pass its existing absolute root to vmem specs add`); }
227
+ requireSpecRoot(spec.path, env);
228
+ return mutate(home, config => {
229
+ if ([...config.vaults, ...(config.specs ?? [])].some(source => source.id === spec.id)) throw new Error(`spec source ${spec.id}: id already registered; use vmem specs list and vmem vaults list, then choose a unique id`);
230
+ if ((config.specs ?? []).some(source => {
231
+ if (source.path === spec.path) return true;
232
+ try { return realPath(source.path) === spec.path; }
233
+ catch (error) {
234
+ if (["ENOENT", "ENOTDIR", "EACCES", "ELOOP"].includes((error as NodeJS.ErrnoException).code ?? "")) return false;
235
+ throw error;
236
+ }
237
+ })) throw new Error(`spec source ${spec.id}: path already registered; use vmem specs list to select its existing id`);
238
+ (config.specs ??= []).push(spec);
239
+ return spec;
240
+ });
241
+ }
242
+ export function removeSpec(home: string, id: string, confirm = false): { applied: boolean; spec: SpecSourceSpec } {
243
+ const remove = (config: VmemConfig) => {
244
+ const spec = config.specs?.find(source => source.id === id);
245
+ if (!spec) throw new Error(`unknown spec source id ${id}; use vmem specs list to select a registered id`);
246
+ if (confirm) config.specs = config.specs!.filter(source => source.id !== id);
247
+ return { applied: confirm, spec };
248
+ };
249
+ return confirm ? mutate(home, remove) : remove(loadRegistry(home));
250
+ }
251
+ export function specStatus(home: string, env: NodeJS.ProcessEnv = process.env) {
252
+ return (loadRegistry(home).specs ?? []).map(spec => {
253
+ try {
254
+ if (realPath(spec.path) !== spec.path) throw new Error("spec source path changed; restore the canonical repository root or unregister it with vmem specs remove <id>");
255
+ requireSpecRoot(spec.path, env);
256
+ return { ...spec, healthy: true };
257
+ } catch (error) {
258
+ const cause = error instanceof Error ? error.message : "spec source path inspection failed";
259
+ return { ...spec, healthy: false, issue: `${cause}; restore a readable canonical root, or unregister it with vmem specs remove <id>` };
260
+ }
261
+ });
262
+ }
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ import { isAbsolute, posix, win32 } from "node:path";
2
+ import { specGit } from "./spec-git.ts";
3
+ import type { SpecCandidate, SpecDocument, SpecSnapshot } from "./spec-source.ts";
4
+ import type { SpecValidationIssue } from "./spec-validate.ts";
5
+
6
+ export interface SpecFreshnessEntry { path: string; id?: string; state: "fresh" | "stale"; commits: number; dirty: boolean }
7
+ export interface SpecFreshnessReport { items: SpecFreshnessEntry[]; issues: SpecValidationIssue[] }
8
+ export interface SpecFreshnessOptions { env?: NodeJS.ProcessEnv }
9
+ const PATH_OUTPUT_BYTES = 8 * 1024 * 1024;
10
+ const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
11
+ const ordered = <T extends { path: string }>(items: readonly T[]): T[] => [...items].sort((a, b) => compare(a.path.normalize("NFC"), b.path.normalize("NFC")) || compare(a.path, b.path));
12
+ const idOf = (candidate: SpecCandidate): { id?: string } => typeof candidate.frontmatter.id === "string" && candidate.frontmatter.id.trim().length > 0 ? { id: candidate.frontmatter.id } : {};
13
+ function issue(candidate: SpecCandidate, code: string, field: string, message: string): SpecValidationIssue {
14
+ return { code, severity: "error", path: candidate.path, ...idOf(candidate), field, message };
15
+ }
16
+ function pathsShape(value: unknown): value is string[] {
17
+ return Array.isArray(value) && value.length > 0 && value.every(item => typeof item === "string" && item.length > 0);
18
+ }
19
+ /** Strip only Git's magic header for the relative/no-parent check; Git interprets the original pathspec. */
20
+ function pathspecBody(value: string): string | undefined {
21
+ if (value.startsWith(":(")) {
22
+ const end = value.indexOf(")");
23
+ return end < 0 ? undefined : value.slice(end + 1);
24
+ }
25
+ return value.startsWith(":") ? value.replace(/^:[!^/]*:?/, "") : value;
26
+ }
27
+ function relativePathspec(value: string): boolean {
28
+ const body = pathspecBody(value);
29
+ return body !== undefined && !value.includes("\0") && !isAbsolute(body) && !win32.isAbsolute(body) && !body.split(/[\\/]/).includes("..");
30
+ }
31
+ function hasPaths(bytes: Buffer): boolean {
32
+ if (bytes.length && bytes[bytes.length - 1] !== 0) throw new Error("spec freshness: Git returned incomplete NUL-delimited paths; retry on a stable repository");
33
+ return bytes.length > 0;
34
+ }
35
+ function pathRecords(bytes: Buffer): Buffer[] {
36
+ hasPaths(bytes);
37
+ const paths: Buffer[] = [];
38
+ for (let start = 0; start < bytes.length;) {
39
+ const end = bytes.indexOf(0, start);
40
+ if (end > start) paths.push(bytes.subarray(start, end));
41
+ start = end + 1;
42
+ }
43
+ return paths;
44
+ }
45
+ type ScopeMatch = { files: Buffer } | { invalid: "syntax" | "unmatched"; diagnostic?: string };
46
+ function trackedScope(root: string, paths: string[], options: SpecFreshnessOptions): ScopeMatch {
47
+ // This conflict is known before Git quotes a possibly long pattern into a truncated diagnostic.
48
+ if (paths.some(path => {
49
+ if (!path.startsWith(":(")) return false;
50
+ const magic = path.slice(2, path.indexOf(")")).split(",");
51
+ return magic.includes("literal") && magic.includes("glob");
52
+ })) return { invalid: "syntax" };
53
+ const result = specGit(root, ["ls-files", "--cached", "-z", "--error-unmatch", "--", ...paths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES, allowedExitCodes: [0, 1, 128] });
54
+ if (result.status === 128) {
55
+ // A library caller may have a stale snapshot: distinguish a broken index from bad scope syntax.
56
+ specGit(root, ["ls-files", "--cached", "-z"], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES });
57
+ const diagnostic = result.stderr.toString("utf8").split(/\r?\n/, 1)[0]?.split(root).join("<path>").slice(0, 240) || "no diagnostic";
58
+ return { invalid: "syntax", diagnostic };
59
+ }
60
+ if (result.status !== 0 || !hasPaths(result.stdout)) return { invalid: "unmatched" };
61
+ return { files: result.stdout };
62
+ }
63
+ function fullHash(bytes: Buffer): string {
64
+ const value = bytes.toString("utf8").replace(/\n$/, "");
65
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) throw new Error("spec freshness: Git returned an invalid full commit identity; inspect git rev-parse HEAD and repair the repository before retrying");
66
+ return value;
67
+ }
68
+ function candidatePaths(candidate: SpecCandidate, issues: SpecValidationIssue[]): string[] | undefined {
69
+ const value = candidate.frontmatter.paths;
70
+ if (value === undefined) return undefined;
71
+ if (!pathsShape(value)) {
72
+ issues.push(issue(candidate, "wrong_shape", "paths", "paths must be a nonempty list of nonempty strings; write repository-relative Git pathspecs as a YAML list"));
73
+ return undefined;
74
+ }
75
+ if (!value.every(relativePathspec)) {
76
+ issues.push(issue(candidate, "invalid_paths", "paths", "paths must use well-formed relative Git pathspecs without parent traversal or NUL; correct magic syntax or replace absolute/parent-relative entries with in-repository scopes"));
77
+ return undefined;
78
+ }
79
+ return value;
80
+ }
81
+
82
+ /** Pure metadata checks remain available when callers intentionally disable native Git inspection. */
83
+ export function specMetadataIssues(candidate: SpecCandidate): SpecValidationIssue[] {
84
+ const issues: SpecValidationIssue[] = [];
85
+ candidatePaths(candidate, issues);
86
+ const stamp = candidate.frontmatter["verified-against"];
87
+ if (stamp !== undefined && (typeof stamp !== "string" || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(stamp))) issues.push(issue(candidate, "invalid_verified_against", "verified-against", "verified-against must be a full commit SHA; set it to the full git rev-parse HEAD result after reviewing the spec"));
88
+ return issues;
89
+ }
90
+
91
+ /** Computed freshness only: malformed metadata is reported, while Git operational failures propagate. */
92
+ export function specFreshness(snapshot: SpecSnapshot, options: SpecFreshnessOptions = {}): SpecFreshnessReport {
93
+ const items: SpecFreshnessEntry[] = [], issues: SpecValidationIssue[] = [];
94
+ let loadedHead = false, head: string | undefined;
95
+ const stampChecks = new Map<string, string | undefined>();
96
+ const headCommit = () => {
97
+ if (!loadedHead) {
98
+ const result = specGit(snapshot.root, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { env: options.env, allowedExitCodes: [0, 1] });
99
+ head = result.status === 0 ? fullHash(result.stdout) : undefined;
100
+ loadedHead = true;
101
+ }
102
+ return head;
103
+ };
104
+ const checkStamp = (stamp: string): string | undefined => {
105
+ if (stampChecks.has(stamp)) return stampChecks.get(stamp);
106
+ const current = headCommit();
107
+ let failure: string | undefined;
108
+ const commit = specGit(snapshot.root, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${stamp}^{commit}`], { env: options.env, allowedExitCodes: [0, 1] });
109
+ if (commit.status !== 0 || fullHash(commit.stdout) !== stamp.toLowerCase()) failure = "verified-against does not name a commit in this repository; choose a full commit SHA from git log after reviewing the spec";
110
+ else if (current === undefined) failure = "verified-against cannot be checked without a HEAD commit; commit the tracked source and review it before recording a verification stamp";
111
+ else if (specGit(snapshot.root, ["merge-base", "--is-ancestor", stamp, current], { env: options.env, allowedExitCodes: [0, 1] }).status !== 0) failure = "verified-against is not an ancestor of HEAD; review the spec against a reachable commit and update the full verification SHA";
112
+ stampChecks.set(stamp, failure);
113
+ return failure;
114
+ };
115
+ for (const candidate of ordered(snapshot.candidates ?? snapshot.specs)) {
116
+ const paths = candidatePaths(candidate, issues);
117
+ let validPaths: string[] | undefined;
118
+ if (paths !== undefined) {
119
+ const scope = trackedScope(snapshot.root, paths, options);
120
+ if ("invalid" in scope) issues.push(issue(candidate, "invalid_paths", "paths", scope.invalid === "syntax"
121
+ ? `paths contain invalid Git pathspec syntax${scope.diagnostic ? ` (${scope.diagnostic})` : ""}; correct the pathspec magic or use a repository-relative tracked path from git ls-files`
122
+ : "one or more paths match no tracked files, or exclusions leave the scope empty; choose matching pathspecs from git ls-files and correct the paths list"));
123
+ else validPaths = paths;
124
+ }
125
+ const stamp = candidate.frontmatter["verified-against"];
126
+ if (stamp === undefined) continue;
127
+ if (typeof stamp !== "string" || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(stamp)) {
128
+ issues.push(issue(candidate, "invalid_verified_against", "verified-against", "verified-against must be a full commit SHA; set it to the full git rev-parse HEAD result after reviewing the spec"));
129
+ continue;
130
+ }
131
+ const failure = checkStamp(stamp);
132
+ if (failure) { issues.push(issue(candidate, "invalid_verified_against", "verified-against", failure)); continue; }
133
+ if (validPaths === undefined) continue;
134
+ const count = specGit(snapshot.root, ["rev-list", "--count", "--full-history", `${stamp}..${head!}`, "--", ...validPaths], { env: options.env }).stdout.toString("utf8").trim();
135
+ if (!/^\d+$/.test(count) || !Number.isSafeInteger(Number(count))) throw new Error("spec freshness: Git returned an invalid source commit count; inspect the scoped history before retrying");
136
+ const trackedDirty = specGit(snapshot.root, ["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", head!, "--", ...validPaths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES }).stdout;
137
+ const stagedDirty = specGit(snapshot.root, ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", head!, "--", ...validPaths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES }).stdout;
138
+ const untrackedDirty = specGit(snapshot.root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...validPaths], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES }).stdout;
139
+ const changedTracked = hasPaths(trackedDirty), changedStaged = hasPaths(stagedDirty), changedUntracked = hasPaths(untrackedDirty);
140
+ const dirty = changedTracked || changedStaged || changedUntracked, commits = Number(count);
141
+ items.push({ path: candidate.path, ...idOf(candidate), state: commits > 0 || dirty ? "stale" : "fresh", commits, dirty });
142
+ }
143
+ return { items, issues };
144
+ }
145
+
146
+ /** Match tracked files through Git's own pathspec engine; invalid metadata has no invented ownership. */
147
+ export function specsCoveringPath(snapshot: SpecSnapshot, relativeFile: string, options: SpecFreshnessOptions = {}): SpecDocument[] {
148
+ if (typeof relativeFile !== "string" || !relativeFile || relativeFile.includes("\0") || isAbsolute(relativeFile) || win32.isAbsolute(relativeFile) || relativeFile.split(/[\\/]/).includes("..")) throw new Error("spec coversPath: expected a tracked repository-relative filename without parent traversal or NUL; pass a file listed by git ls-files");
149
+ const file = posix.normalize(relativeFile);
150
+ if (file === ".") return [];
151
+ const requested = specGit(snapshot.root, ["ls-files", "--cached", "-z", "--error-unmatch", "--", `:(literal)${file}`], { env: options.env, maxBuffer: PATH_OUTPUT_BYTES, allowedExitCodes: [0, 1] });
152
+ if (requested.status !== 0) return [];
153
+ // A literal directory pathspec also lists descendants; coversPath accepts a file, not a directory.
154
+ const spelling = file.normalize("NFC").toLowerCase();
155
+ const tracked = pathRecords(requested.stdout).filter(path => path.toString("utf8").normalize("NFC").toLowerCase() === spelling);
156
+ if (!tracked.length) return [];
157
+ return ordered(snapshot.specs).filter(document => {
158
+ const paths = document.frontmatter.paths;
159
+ if (!pathsShape(paths) || !paths.every(relativePathspec)) return false;
160
+ const scope = trackedScope(snapshot.root, paths, options);
161
+ return "files" in scope && pathRecords(scope.files).some(path => tracked.some(file => file.equals(path)));
162
+ });
163
+ }
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { memoryGitArgv, memoryGitEnv } from "./platform.ts";
3
+
4
+ export interface SpecGitOptions {
5
+ env?: NodeJS.ProcessEnv;
6
+ maxBuffer?: number;
7
+ allowedExitCodes?: readonly number[];
8
+ discovery?: boolean;
9
+ }
10
+ export interface SpecGitResult { status: number; stdout: Buffer; stderr: Buffer }
11
+ /** Internal bounded Git inspection, shared by discovery and validation; callers build readonly argv. */
12
+ export function specGit(root: string, args: string[], options: SpecGitOptions = {}): SpecGitResult {
13
+ const argv = memoryGitArgv(["-C", root, ...args]), maxBuffer = options.maxBuffer ?? 65536;
14
+ const env = memoryGitEnv({ ...(options.env ?? process.env), GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, { repositoryIsolation: true });
15
+ for (const key of ["GIT_LITERAL_PATHSPECS", "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS"]) delete env[key];
16
+ const result = spawnSync(argv[0]!, argv.slice(1), { env, timeout: 10000, maxBuffer, stdio: ["ignore", "pipe", "pipe"] });
17
+ if (result.error) {
18
+ const code = (result.error as NodeJS.ErrnoException).code;
19
+ if (code === "ETIMEDOUT") throw new Error("spec source: git inspection timed out after 10000 ms; retry and check for a locked repository or slow mount", { cause: result.error });
20
+ if (code === "ENOBUFS") {
21
+ const advice = options.discovery ? "ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before retrying" : "make SPEC.md paths more specific, or inspect the failed Git command if its output is unexpected, before retrying";
22
+ throw new Error(`spec source: git output exceeded ${maxBuffer} bytes; ${advice}`, { cause: result.error });
23
+ }
24
+ if (code === "E2BIG") throw new Error("spec source: git arguments exceed the platform limit; shorten the paths list before retrying", { cause: result.error });
25
+ throw new Error(`spec source: git could not run (${code ?? "spawn failure"}); install git or fix PATH before retrying`, { cause: result.error });
26
+ }
27
+ const status = result.status;
28
+ if (status === null || !(options.allowedExitCodes ?? [0]).includes(status)) {
29
+ const first = result.stderr.toString("utf8").split(/\r?\n/, 1)[0]?.split(root).join("<path>").slice(0, 240) || "no diagnostic";
30
+ throw new Error(`spec source: git refused inspection (exit ${status ?? "signal"}): ${first}; inspect git -C <path> rev-parse --show-toplevel and repair the repository or ownership configuration`);
31
+ }
32
+ return { status, stdout: result.stdout, stderr: result.stderr };
33
+ }