@toolbaux/guardian 0.1.22 → 0.1.23

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.
@@ -17,6 +17,21 @@
17
17
  import fs from "node:fs/promises";
18
18
  import path from "node:path";
19
19
  import readline from "node:readline";
20
+ import { spawn } from "node:child_process";
21
+ // ── CLI proxy ──
22
+ // Resolve the guardian CLI binary relative to this file (dist/cli.js).
23
+ const CLI_BIN = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../cli.js");
24
+ /** Run a guardian CLI subcommand and return stdout. */
25
+ function runCli(args) {
26
+ return new Promise((resolve) => {
27
+ const proc = spawn(process.execPath, [CLI_BIN, ...args], { stdio: ["ignore", "pipe", "pipe"] });
28
+ let out = "";
29
+ let err = "";
30
+ proc.stdout.on("data", (d) => { out += d.toString(); });
31
+ proc.stderr.on("data", (d) => { err += d.toString(); });
32
+ proc.on("close", () => resolve(out.trim() || err.trim() || "{}"));
33
+ });
34
+ }
20
35
  const metrics = {
21
36
  session_start: Date.now(),
22
37
  calls: [],
@@ -64,9 +79,11 @@ const metrics = {
64
79
  };
65
80
  },
66
81
  };
67
- // ── Response cache (dedup repeated queries) ──
82
+ // ── Session flag written on every guardian tool call so the PreToolUse hook knows guardian is active ──
83
+ const SESSION_FLAG = "/tmp/guardian-last-call";
84
+ // ── Response cache (dedup repeated queries within a session) ──
68
85
  const responseCache = new Map();
69
- const CACHE_TTL = 30_000; // 30s cache
86
+ const CACHE_TTL = 30_000;
70
87
  function getCached(key) {
71
88
  const entry = responseCache.get(key);
72
89
  if (entry && Date.now() - entry.time < CACHE_TTL)
@@ -76,319 +93,23 @@ function getCached(key) {
76
93
  function setCache(key, text) {
77
94
  responseCache.set(key, { text, time: Date.now() });
78
95
  }
79
- // ── Intelligence loader ──
80
- let intel = null;
81
- let intelPath = "";
82
- let lastLoadTime = 0;
83
- async function loadIntel() {
84
- // Reload if file changed (check every 5s max)
85
- const now = Date.now();
86
- if (intel && now - lastLoadTime < 5000)
87
- return intel;
88
- try {
89
- const raw = await fs.readFile(intelPath, "utf8");
90
- intel = JSON.parse(raw);
91
- lastLoadTime = now;
92
- }
93
- catch {
94
- // Return cached or empty
95
- if (!intel) {
96
- intel = { api_registry: {}, model_registry: {}, service_map: [], frontend_pages: [], meta: { project: "unknown", counts: {} } };
97
- }
98
- }
99
- return intel;
100
- }
101
- // ── Function intelligence loader ──
102
- let funcIntel = null;
103
- let funcIntelPath = "";
104
- let funcIntelLoadTime = 0;
105
- async function loadFuncIntel() {
106
- if (!funcIntelPath)
107
- return null;
108
- const now = Date.now();
109
- if (funcIntel && now - funcIntelLoadTime < 5000)
110
- return funcIntel;
111
- try {
112
- const raw = await fs.readFile(funcIntelPath, "utf8");
113
- funcIntel = JSON.parse(raw);
114
- funcIntelLoadTime = now;
115
- }
116
- catch {
117
- // File may not exist yet — not an error
118
- }
119
- return funcIntel;
120
- }
121
- // ── Helpers ──
122
- const SKIP_SERVICES = new Set(["str", "dict", "int", "len", "float", "max", "join", "getattr", "lower", "open", "params.append", "updates.append"]);
123
- function compact(obj) {
124
- return JSON.stringify(obj);
125
- }
126
- function normalize(p) {
127
- return p.replace(/^\.\//, "").replace(/\/\//g, "/");
128
- }
129
- function findModule(data, file) {
130
- const f = normalize(file);
131
- return data.service_map?.find((m) => {
132
- const mp = normalize(m.path || "");
133
- return mp && (f.startsWith(mp + "/") || f === mp);
134
- }) || data.service_map?.find((m) => {
135
- // Fallback: match by module ID (handles doubled paths)
136
- const mid = normalize(m.id || "");
137
- return mid && f.includes(mid);
138
- });
139
- }
140
- function findEndpointsInFile(data, file) {
141
- const f = normalize(file);
142
- const basename = path.basename(f);
143
- return Object.values(data.api_registry || {}).filter((ep) => {
144
- const ef = normalize(ep.file || "");
145
- return ef && (f.includes(ef) || ef.includes(f) || ef.endsWith(basename));
146
- });
147
- }
148
- function findModelsInFile(data, file) {
149
- const f = normalize(file);
150
- const basename = path.basename(f);
151
- return Object.values(data.model_registry || {}).filter((m) => {
152
- const mf = normalize(m.file || "");
153
- return mf && (f.includes(mf) || mf.includes(f) || mf.endsWith(basename));
154
- });
155
- }
156
- // ── Tool implementations (compact JSON, no redundancy) ──
96
+ // ── Tool implementations — thin CLI proxies ──
97
+ // All logic lives in `guardian search`. MCP tools are just structured wrappers.
98
+ let specsInputDir = "";
157
99
  async function orient() {
158
- // Read architecture-context.md first — it has the richest summary
159
- const contextPath = path.join(path.dirname(intelPath), "architecture-context.md");
160
- try {
161
- const raw = await fs.readFile(contextPath, "utf8");
162
- // Extract the content between guardian:context markers
163
- const match = raw.match(/<!-- guardian:context[^>]*-->([\s\S]*?)<!-- \/guardian:context -->/);
164
- if (match) {
165
- // Parse the markdown into compact structured data
166
- const content = match[1];
167
- const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
168
- // Extract key sections
169
- const desc = raw.match(/Description: (.+)/)?.[1] || "";
170
- const codeMap = lines.find((l) => l.startsWith("**Backend:**")) || "";
171
- // Module map with exports
172
- const moduleLines = lines.filter((l) => l.startsWith("- **backend/") || l.startsWith("- **frontend/"));
173
- const modules = moduleLines.map((l) => {
174
- const m = l.match(/\*\*([^*]+)\*\*\s*\(([^)]+)\)\s*[—–-]\s*(.*)/);
175
- return m ? [m[1], m[2], m[3].slice(0, 60)] : null;
176
- }).filter(Boolean);
177
- // Dependencies
178
- const deps = lines.filter((l) => l.includes("→")).map((l) => l.replace(/^- /, ""));
179
- // High-coupling files
180
- const coupling = lines.filter((l) => l.match(/score \d/)).map((l) => l.replace(/^- /, ""));
181
- // Structural intelligence
182
- const si = lines.filter((l) => l.includes("depth=")).map((l) => l.replace(/^- /, ""));
183
- // Model-endpoint map
184
- const modelEp = lines.filter((l) => l.includes("endpoints) ->")).map((l) => l.replace(/^- /, ""));
185
- return compact({
186
- desc: desc.slice(0, 120),
187
- map: codeMap,
188
- modules,
189
- deps,
190
- coupling: coupling.slice(0, 5),
191
- si: si.slice(0, 5),
192
- modelEp,
193
- });
194
- }
195
- }
196
- catch { }
197
- // Fallback: build from codebase-intelligence.json
198
- const d = await loadIntel();
199
- const c = d.meta?.counts || {};
200
- // Compute endpoint counts from api_registry (service_map counts are often 0)
201
- const epByMod = {};
202
- for (const ep of Object.values(d.api_registry || {})) {
203
- epByMod[ep.module] = (epByMod[ep.module] || 0) + 1;
204
- }
205
- const mods = (d.service_map || []).filter((m) => m.file_count > 0);
206
- const topMods = mods
207
- .map((m) => ({ ...m, ep_count: epByMod[m.id] || 0 }))
208
- .sort((a, b) => b.ep_count - a.ep_count)
209
- .slice(0, 6);
210
- return compact({
211
- p: d.meta?.project,
212
- ep: c.endpoints, mod: c.models, pg: c.pages, m: c.modules,
213
- top: topMods.map((m) => [m.id, m.ep_count, m.layer]),
214
- pages: (d.frontend_pages || []).map((p) => p.path),
215
- });
100
+ return runCli(["search", "--orient", "--input", specsInputDir]);
216
101
  }
217
102
  async function context(args) {
218
- const d = await loadIntel();
219
- const t = args.target;
220
- // Check if target is an endpoint (e.g. "POST /sessions/start")
221
- const epMatch = t.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(.+)$/i);
222
- if (epMatch) {
223
- const ep = d.api_registry?.[`${epMatch[1].toUpperCase()} ${epMatch[2]}`]
224
- || Object.values(d.api_registry || {}).find((e) => e.method === epMatch[1].toUpperCase() && e.path === epMatch[2]);
225
- if (!ep)
226
- return compact({ err: "not found" });
227
- const svcs = (ep.service_calls || []).filter((s) => !SKIP_SERVICES.has(s));
228
- return compact({
229
- ep: `${ep.method} ${ep.path}`, h: ep.handler, f: ep.file, m: ep.module,
230
- req: ep.request_schema, res: ep.response_schema,
231
- calls: svcs, ai: ep.ai_operations?.length || 0,
232
- });
233
- }
234
- // Otherwise treat as file path
235
- const file = t.replace(/^\.\//, "");
236
- const mod = findModule(d, file);
237
- const eps = findEndpointsInFile(d, file);
238
- const models = findModelsInFile(d, file);
239
- const fileName = path.basename(file, path.extname(file));
240
- const calledBy = [];
241
- for (const ep of Object.values(d.api_registry || {})) {
242
- if (ep.service_calls?.some((s) => s.toLowerCase().includes(fileName.toLowerCase()))) {
243
- calledBy.push(`${ep.method} ${ep.path}`);
244
- }
245
- }
246
- const calls = eps.flatMap((ep) => (ep.service_calls || []).filter((s) => !SKIP_SERVICES.has(s)));
247
- return compact({
248
- f: file,
249
- mod: mod ? [mod.id, mod.layer] : null,
250
- ep: eps.map((e) => `${e.method} ${e.path}`),
251
- models: models.map((m) => [m.name, m.fields?.length || 0]),
252
- calls: [...new Set(calls)],
253
- calledBy: calledBy.slice(0, 8),
254
- });
103
+ return runCli(["search", "--file", args.target, "--input", specsInputDir]);
255
104
  }
256
105
  async function impact(args) {
257
- const d = await loadIntel();
258
- const file = args.target.replace(/^\.\//, "");
259
- const eps = findEndpointsInFile(d, file);
260
- const models = findModelsInFile(d, file);
261
- const modelNames = new Set(models.map((m) => m.name));
262
- const affectedEps = Object.values(d.api_registry || {}).filter((ep) => (ep.request_schema && modelNames.has(ep.request_schema)) ||
263
- (ep.response_schema && modelNames.has(ep.response_schema)));
264
- const mod = findModule(d, file);
265
- const depMods = mod ? (d.service_map || []).filter((m) => m.imports?.includes(mod.id)) : [];
266
- const affectedPages = (d.frontend_pages || []).filter((p) => p.api_calls?.some((call) => eps.some((ep) => call.includes(ep.path?.split("{")[0]))));
267
- const total = eps.length + affectedEps.length + depMods.length + affectedPages.length;
268
- return compact({
269
- f: file,
270
- risk: total > 5 ? "HIGH" : total > 2 ? "MED" : "LOW",
271
- ep: eps.map((e) => `${e.method} ${e.path}`),
272
- models: models.map((m) => m.name),
273
- affectedEp: affectedEps.map((e) => `${e.method} ${e.path}`),
274
- depMods: depMods.map((m) => m.id),
275
- pages: affectedPages.map((p) => p.path),
276
- });
106
+ return runCli(["search", "--impact", args.target, "--input", specsInputDir]);
277
107
  }
278
108
  async function search(args) {
279
- const d = await loadIntel();
280
- const q = args.query.toLowerCase();
281
- // Endpoints: match path, handler, or service calls
282
- const eps = Object.values(d.api_registry || {}).filter((ep) => ep.path?.toLowerCase().includes(q) || ep.handler?.toLowerCase().includes(q) ||
283
- ep.service_calls?.some((s) => s.toLowerCase().includes(q))).slice(0, 8).map((ep) => `${ep.method} ${ep.path} [${ep.module}]`);
284
- // Models: match name or fields
285
- const models = Object.values(d.model_registry || {}).filter((m) => m.name?.toLowerCase().includes(q) || m.fields?.some((f) => f.toLowerCase().includes(q))).slice(0, 8).map((m) => `${m.name}:${m.fields?.length}f`);
286
- // Modules: match id, imports, or exports
287
- const mods = (d.service_map || []).filter((m) => m.id?.toLowerCase().includes(q) ||
288
- m.imports?.some((i) => i.toLowerCase().includes(q))).slice(0, 5).map((m) => `${m.id}:${m.file_count}files,${m.endpoint_count}ep [${m.layer}]`);
289
- // Exports: match exported symbol names across all modules
290
- const exports = [];
291
- for (const m of d.service_map || []) {
292
- for (const sym of m.exports || []) {
293
- if (sym.toLowerCase().includes(q)) {
294
- exports.push(`${sym} [${m.id}]`);
295
- }
296
- }
297
- }
298
- // Files: match file paths across all modules
299
- const files = [];
300
- for (const m of d.service_map || []) {
301
- for (const f of m.files || []) {
302
- if (f.toLowerCase().includes(q)) {
303
- files.push(`${f} [${m.id}]`);
304
- }
305
- }
306
- }
307
- // Enums: match name or values
308
- const enums = Object.values(d.enum_registry || {}).filter((e) => e.name?.toLowerCase().includes(q) || e.values?.some((v) => v.toLowerCase().includes(q))).slice(0, 5).map((e) => `${e.name}:${e.values?.length}vals [${e.file}]`);
309
- // Background tasks: match name or kind
310
- const tasks = (d.background_tasks || []).filter((t) => t.name?.toLowerCase().includes(q) || t.kind?.toLowerCase().includes(q)).slice(0, 5).map((t) => `${t.name} [${t.kind}] ${t.file}`);
311
- // Frontend pages: match path or component
312
- const pages = (d.frontend_pages || []).filter((p) => p.path?.toLowerCase().includes(q) || p.component?.toLowerCase().includes(q) ||
313
- p.api_calls?.some((c) => c.toLowerCase().includes(q))).slice(0, 5).map((p) => `${p.path} → ${p.component}`);
314
- // Functions: ranked search across names, literals, calls — capped at 10 to prevent flooding
315
- const fnHits = [];
316
- const fi = await loadFuncIntel();
317
- if (fi) {
318
- // Build a field map: file → field names (augments fn hits with model fields)
319
- const fileToFields = new Map();
320
- for (const m of Object.values(d.model_registry || {})) {
321
- if (!m.file)
322
- continue;
323
- const existing = fileToFields.get(m.file) ?? [];
324
- fileToFields.set(m.file, [...existing, ...(m.fields ?? [])]);
325
- }
326
- const scored = [];
327
- const seen = new Set();
328
- for (const fn of (fi.functions ?? [])) {
329
- const nameNorm = (fn.name ?? "").toLowerCase();
330
- const fileNorm = (fn.file ?? "").toLowerCase();
331
- const callsNorm = (fn.calls ?? []).map((c) => c.toLowerCase());
332
- const litsNorm = [...(fn.stringLiterals ?? []), ...(fn.regexPatterns ?? [])].map((l) => l.toLowerCase());
333
- let score = 0;
334
- if (nameNorm === q)
335
- score = 1.0;
336
- else if (nameNorm.includes(q))
337
- score = 0.7;
338
- else if (callsNorm.some((c) => c.includes(q)))
339
- score = 0.5;
340
- else if (litsNorm.some((l) => l.includes(q)))
341
- score = 0.3;
342
- else if (fileNorm.includes(q))
343
- score = 0.2;
344
- if (score > 0) {
345
- scored.push({ fn, score });
346
- seen.add(`${fn.file}:${fn.name}`);
347
- }
348
- }
349
- // Also surface literal_index hits not already captured
350
- const litIndex = fi.literal_index ?? {};
351
- for (const [key, hits] of Object.entries(litIndex)) {
352
- if (!key.includes(q))
353
- continue;
354
- for (const h of hits) {
355
- const uid = `${h.file}:${h.function}`;
356
- if (seen.has(uid))
357
- continue;
358
- seen.add(uid);
359
- const fn = fi.functions.find((f) => f.file === h.file && f.name === h.function);
360
- scored.push({ fn: fn ?? { name: h.function, file: h.file, lines: [h.line, h.line], calls: [], stringLiterals: [], regexPatterns: [] }, score: 0.25 });
361
- }
362
- }
363
- // Sort by score desc, take top 10
364
- scored.sort((a, b) => b.score - a.score);
365
- const projectRoot = process.cwd();
366
- for (const { fn } of scored.slice(0, 10)) {
367
- const relFile = fn.file?.startsWith("/") ? require("path").relative(projectRoot, fn.file) : fn.file;
368
- const fields = fileToFields.get(fn.file) ?? [];
369
- const fieldSuffix = fields.length > 0 ? ` fields:${fields.slice(0, 5).join(",")}` : "";
370
- fnHits.push(`${fn.name} [${relFile}:${fn.lines?.[0]}]${fieldSuffix}`);
371
- }
372
- }
373
- return compact({
374
- ep: eps, mod: models, m: mods,
375
- exports: exports.slice(0, 10),
376
- files: files.slice(0, 8),
377
- enums, tasks, pages,
378
- ...(fnHits.length > 0 ? { fns: fnHits } : {}),
379
- });
109
+ return runCli(["search", "--query", args.query, "--format", "json", "--backend", "auto", "--input", specsInputDir]);
380
110
  }
381
111
  async function model(args) {
382
- const d = await loadIntel();
383
- const m = d.model_registry?.[args.name];
384
- if (!m)
385
- return compact({ err: "not found" });
386
- const usedBy = Object.values(d.api_registry || {}).filter((ep) => ep.request_schema === args.name || ep.response_schema === args.name).map((ep) => `${ep.method} ${ep.path}`);
387
- return compact({
388
- name: m.name, fw: m.framework, f: m.file,
389
- fields: m.fields, rels: m.relationships,
390
- usedBy,
391
- });
112
+ return runCli(["search", "--model", args.name, "--input", specsInputDir]);
392
113
  }
393
114
  // ── MCP protocol ──
394
115
  const TOOLS = [
@@ -453,7 +174,7 @@ const TOOL_HANDLERS = {
453
174
  guardian_impact: impact,
454
175
  guardian_search: search,
455
176
  guardian_model: model,
456
- guardian_metrics: async () => compact(metrics.summary()),
177
+ guardian_metrics: async () => JSON.stringify(metrics.summary()),
457
178
  };
458
179
  function respond(id, result) {
459
180
  const msg = JSON.stringify({ jsonrpc: "2.0", id, result });
@@ -514,6 +235,8 @@ async function handleRequest(req) {
514
235
  const result = await handler(toolArgs);
515
236
  setCache(cacheKey, result);
516
237
  metrics.record(toolName, toolArgs, result, false);
238
+ // Write session flag so the PreToolUse hook knows guardian was called recently
239
+ fs.writeFile(SESSION_FLAG, Date.now().toString(), "utf8").catch(() => { });
517
240
  respond(req.id, {
518
241
  content: [{ type: "text", text: result }],
519
242
  });
@@ -537,30 +260,34 @@ async function handleRequest(req) {
537
260
  export async function runMcpServe(options) {
538
261
  const specsDir = path.resolve(options.specs);
539
262
  const quiet = options.quiet ?? false;
540
- intelPath = path.join(specsDir, "machine", "codebase-intelligence.json");
541
- funcIntelPath = path.join(specsDir, "machine", "function-intelligence.json");
542
- // Pre-load intelligence
543
- await loadIntel();
544
- await loadFuncIntel();
263
+ specsInputDir = specsDir;
545
264
  // Log to stderr (stdout is for MCP protocol)
546
265
  if (!quiet) {
547
- process.stderr.write(`Guardian MCP server started. Intelligence: ${intelPath}\n`);
266
+ process.stderr.write(`Guardian MCP server started. Specs: ${specsDir}\n`);
548
267
  process.stderr.write(`Tools: ${TOOLS.map((t) => t.name).join(", ")}\n`);
549
268
  }
550
269
  // Read JSON-RPC messages from stdin, line by line
551
270
  const rl = readline.createInterface({ input: process.stdin });
552
- rl.on("line", async (line) => {
271
+ // Track in-flight async handlers so we can drain before exit.
272
+ // Previously all handlers were instant (in-process); now they spawn subprocesses.
273
+ const pending = [];
274
+ rl.on("line", (line) => {
553
275
  if (!line.trim())
554
276
  return;
555
- try {
556
- const req = JSON.parse(line);
557
- await handleRequest(req);
558
- }
559
- catch (err) {
560
- respondError(null, -32700, `Parse error: ${err.message}`);
561
- }
277
+ const p = (async () => {
278
+ try {
279
+ const req = JSON.parse(line);
280
+ await handleRequest(req);
281
+ }
282
+ catch (err) {
283
+ respondError(null, -32700, `Parse error: ${err.message}`);
284
+ }
285
+ })();
286
+ pending.push(p);
562
287
  });
563
288
  rl.on("close", async () => {
289
+ // Drain all in-flight handlers before persisting metrics and exiting.
290
+ await Promise.allSettled(pending);
564
291
  // Persist session metrics to .specs/machine/mcp-metrics.jsonl
565
292
  const metricsPath = path.join(specsDir, "machine", "mcp-metrics.jsonl");
566
293
  try {