@systemdox/mcp-server 0.1.48 → 0.1.50

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/dist/http.js CHANGED
@@ -63,14 +63,6 @@ var SystemDoxApiClient = class {
63
63
  this.config = config;
64
64
  this.cache = new ResponseCache(config.cacheTtlMs);
65
65
  }
66
- async validate() {
67
- const res = await this.fetchRaw("/v1/health");
68
- if (!res.ok) {
69
- throw new Error(
70
- `SystemDox API authentication failed (${res.status}). Check your SYSTEMDOX_API_KEY.`
71
- );
72
- }
73
- }
74
66
  async get(path, params) {
75
67
  const url = this.buildUrl(path, params);
76
68
  const cacheKey = url.toString();
@@ -84,8 +76,8 @@ var SystemDoxApiClient = class {
84
76
  }
85
77
  /** A read that skips the cache: an install must take the version that is
86
78
  * latest now, not the one cached before someone published another. */
87
- async getFresh(path) {
88
- const res = await this.fetchRaw(path);
79
+ async getFresh(path, signal) {
80
+ const res = await this.fetchRaw(path, void 0, signal);
89
81
  if (!res.ok) throw new SystemDoxApiError(res.status, await res.text());
90
82
  return await res.json();
91
83
  }
@@ -115,8 +107,8 @@ var SystemDoxApiClient = class {
115
107
  // One method per operationId. Every read skips the cache: setup, sync and
116
108
  // plugin-path act on what the workspace holds now.
117
109
  /** getOwnTeamSetup: the key's own workspace, named in `workspace`. */
118
- getOwnTeamSetup() {
119
- return this.getFresh(TEAM_SETUP);
110
+ getOwnTeamSetup(signal) {
111
+ return this.getFresh(TEAM_SETUP, signal);
120
112
  }
121
113
  getTeamSetup(workspace) {
122
114
  return this.getFresh(`${TEAM_SETUP}/${seg(workspace)}`);
@@ -220,14 +212,15 @@ var SystemDoxApiClient = class {
220
212
  }
221
213
  return url;
222
214
  }
223
- async fetchRaw(path, params) {
215
+ async fetchRaw(path, params, signal) {
224
216
  const url = this.buildUrl(path, params);
225
217
  return fetch(url, {
226
218
  headers: {
227
219
  Authorization: `Bearer ${this.config.apiKey}`,
228
220
  "X-API-Key": this.config.apiKey,
229
221
  Accept: "application/json"
230
- }
222
+ },
223
+ signal
231
224
  });
232
225
  }
233
226
  };
@@ -286,149 +279,530 @@ function emitMcpToolCalls(tenant, rawBody, log = console.log) {
286
279
  }
287
280
  }
288
281
 
289
- // src/server.ts
290
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
291
-
292
- // src/tools/get-guardrails.ts
293
- import { z } from "zod";
282
+ // src/lib/session-brief.ts
283
+ import { existsSync as existsSync2 } from "fs";
284
+ import { readFile as readFile3 } from "fs/promises";
285
+ import { join as join3 } from "path";
294
286
 
295
- // src/lib/language.ts
296
- var EXTENSION_MAP = {
297
- ".py": { language: "python", categories: ["api", "backend", "data-access"] },
298
- ".ts": { language: "typescript", categories: ["frontend", "api"] },
299
- ".tsx": { language: "typescript", categories: ["frontend"] },
300
- ".js": { language: "javascript", categories: ["frontend"] },
301
- ".jsx": { language: "javascript", categories: ["frontend"] },
302
- ".yaml": {
303
- language: "cloudformation",
304
- categories: ["infrastructure", "architecture"]
305
- },
306
- ".yml": {
307
- language: "cloudformation",
308
- categories: ["infrastructure", "architecture"]
309
- },
310
- ".json": { language: "json", categories: ["configuration"] },
311
- ".css": { language: "css", categories: ["frontend"] },
312
- ".html": { language: "html", categories: ["frontend"] },
313
- ".sql": { language: "sql", categories: ["data-access"] },
314
- ".sh": { language: "shell", categories: ["infrastructure"] },
315
- ".bash": { language: "shell", categories: ["infrastructure"] },
316
- ".tf": { language: "terraform", categories: ["infrastructure"] },
317
- ".md": { language: "markdown", categories: ["documentation"] }
287
+ // src/lib/skills.ts
288
+ import { createHash } from "crypto";
289
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
290
+ import { existsSync } from "fs";
291
+ import { homedir } from "os";
292
+ import { dirname, join, relative, resolve, sep } from "path";
293
+ var MANIFEST_FILE = ".systemdox-skill.json";
294
+ var ENTRY_FILE = "SKILL.md";
295
+ var PLUGIN_MANIFEST_FOLDER = ".claude-plugin";
296
+ var MAX_FILES = 100;
297
+ var MAX_FILE_BYTES = 256 * 1024;
298
+ var MAX_TOTAL_BYTES = 1024 * 1024;
299
+ var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
300
+ var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
301
+ var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
302
+ var SkillInstallRefused = class extends Error {
303
+ constructor(dest, edited) {
304
+ super(
305
+ `${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
306
+ );
307
+ this.dest = dest;
308
+ this.edited = edited;
309
+ }
310
+ dest;
311
+ edited;
318
312
  };
319
- var PATH_CATEGORY_RULES = [
320
- { pattern: /backend\/functions\//, categories: ["api", "backend"] },
321
- { pattern: /backend\/layers\//, categories: ["backend"] },
322
- { pattern: /backend\//, categories: ["backend"] },
323
- { pattern: /apps\/app\//, categories: ["frontend"] },
324
- { pattern: /apps\/website\//, categories: ["frontend"] },
325
- { pattern: /apps\/portal\//, categories: ["frontend"] },
326
- { pattern: /packages\/ui\//, categories: ["frontend"] },
327
- { pattern: /infra\//, categories: ["infrastructure"] },
328
- { pattern: /\.github\//, categories: ["infrastructure"] },
329
- { pattern: /tests?\//, categories: ["testing"] },
330
- { pattern: /spec/, categories: ["testing"] },
331
- { pattern: /docs\/adr/, categories: ["architecture"] },
332
- { pattern: /template\.yaml$/, categories: ["infrastructure", "architecture"] }
333
- ];
334
- function detectLanguage(filePath) {
335
- const ext = filePath.slice(filePath.lastIndexOf("."));
336
- return EXTENSION_MAP[ext];
313
+ function sha256(content) {
314
+ return createHash("sha256").update(content, "utf8").digest("hex");
337
315
  }
338
- function detectCategories(filePaths) {
339
- const categories = /* @__PURE__ */ new Set();
340
- for (const filePath of filePaths) {
341
- const langInfo = detectLanguage(filePath);
342
- if (langInfo) {
343
- for (const cat of langInfo.categories) {
344
- categories.add(cat);
345
- }
316
+ function isValidName(name) {
317
+ return name.length <= 64 && NAME_RE.test(name);
318
+ }
319
+ function isValidPath(path) {
320
+ return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== ".." && s !== PLUGIN_MANIFEST_FOLDER);
321
+ }
322
+ function frontmatterName(skillMd) {
323
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
324
+ if (!block) return void 0;
325
+ const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
326
+ return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
327
+ }
328
+ function defaultSkillDir(name) {
329
+ return join(homedir(), ".claude", "skills", name);
330
+ }
331
+ async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
332
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
333
+ if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
334
+ const full = join(dir, entry.name);
335
+ const rel = relative(root, full).split(sep).join("/");
336
+ if (entry.isSymbolicLink()) out.links.push(rel);
337
+ else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
338
+ else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
339
+ else if (entry.isFile()) out.files.push(rel);
340
+ }
341
+ out.files.sort();
342
+ return out;
343
+ }
344
+ async function readTextFiles(root, paths, what) {
345
+ if (paths.length > MAX_FILES) {
346
+ throw new Error(`${root} holds ${paths.length} files: ${what} holds at most ${MAX_FILES}`);
347
+ }
348
+ const decoder = new TextDecoder("utf-8", { fatal: true });
349
+ const files = [];
350
+ let total = 0;
351
+ for (const path of paths) {
352
+ if (!isValidPath(path)) {
353
+ throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
346
354
  }
347
- for (const rule of PATH_CATEGORY_RULES) {
348
- if (rule.pattern.test(filePath)) {
349
- for (const cat of rule.categories) {
350
- categories.add(cat);
351
- }
352
- }
355
+ const raw = await readFile(join(root, ...path.split("/")));
356
+ if (raw.includes(0)) throw new Error(`${path} is binary: ${what} holds text files only`);
357
+ if (raw.length > MAX_FILE_BYTES) {
358
+ throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
359
+ }
360
+ total += raw.length;
361
+ if (total > MAX_TOTAL_BYTES) {
362
+ throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
363
+ }
364
+ let content;
365
+ try {
366
+ content = decoder.decode(raw);
367
+ } catch {
368
+ throw new Error(`${path} is not UTF-8 text`);
353
369
  }
370
+ files.push({ path, content });
354
371
  }
355
- return Array.from(categories);
372
+ return files;
356
373
  }
357
- function detectLanguages(filePaths) {
358
- const languages = /* @__PURE__ */ new Set();
359
- for (const filePath of filePaths) {
360
- const info = detectLanguage(filePath);
361
- if (info) languages.add(info.language);
374
+ async function readSkillDirectory(directory) {
375
+ const root = resolve(directory);
376
+ if (!existsSync(join(root, ENTRY_FILE))) {
377
+ throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
362
378
  }
363
- return Array.from(languages);
379
+ const walked = await walk(root, { skipHidden: true });
380
+ if (walked.links.length) {
381
+ throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
382
+ }
383
+ const files = await readTextFiles(root, walked.files, "a skill");
384
+ const skillMd = files.find((f) => f.path === ENTRY_FILE);
385
+ const name = frontmatterName(skillMd.content);
386
+ if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
387
+ return { name, files, skipped: walked.hidden };
364
388
  }
365
-
366
- // src/lib/deprecation.ts
367
- var LEGACY_TOOL_REMOVAL_EARLIEST = "2027-01-31";
368
- function legacyToolDescription(canonical, description) {
369
- return `DEPRECATED \u2014 use \`${canonical}\` instead. This name still works and will not be removed before ${LEGACY_TOOL_REMOVAL_EARLIEST}. ${description}`;
389
+ function within(dest, path) {
390
+ if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
391
+ const full = resolve(dest, ...path.split("/"));
392
+ if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
393
+ return full;
370
394
  }
371
-
372
- // src/lib/config-links.ts
373
- async function workspaceBase(apiClient) {
395
+ async function readManifest(dest) {
374
396
  try {
375
- const access = await apiClient.get(
376
- "/v1/docs/portal-access"
377
- );
378
- const slug = (access.host ?? "").split(".")[0];
379
- return slug ? `https://${slug}.systemdox.com` : null;
397
+ return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
380
398
  } catch {
381
- return null;
399
+ return void 0;
382
400
  }
383
401
  }
384
- function configLink(path, base) {
385
- return base ? `${base}${path}` : `${path} in your SystemDox workspace`;
386
- }
387
-
388
- // src/tools/get-guardrails.ts
389
- var DESCRIPTION = "Retrieve SystemDox checks (banned patterns, required patterns, coding rules). Filter by language, category, or file path to get only relevant checks.";
390
- var inputSchema = () => ({
391
- language: z.string().optional().describe(
392
- "Filter by programming language (python, typescript, javascript, cloudformation)"
393
- ),
394
- category: z.string().optional().describe(
395
- "Filter by category (imports, auth, error-handling, architecture, testing, security, conventions, data-access)"
396
- ),
397
- file_path: z.string().optional().describe(
398
- "File path being edited \u2014 language is auto-detected from the extension"
399
- )
400
- });
401
- function registerGetGuardrails(server, apiClient) {
402
- const handler2 = async ({
403
- language,
404
- category,
405
- file_path
406
- }) => {
407
- const guardrails = await apiClient.get("/v1/guardrails");
408
- let filtered = guardrails;
409
- const lang = language ?? (file_path ? detectLanguage(file_path)?.language : void 0);
410
- if (lang) {
411
- filtered = filtered.filter(
412
- (g) => g.description.toLowerCase().includes(lang.toLowerCase()) || g.category.toLowerCase().includes(lang.toLowerCase()) || g.title.toLowerCase().includes(lang.toLowerCase())
413
- );
414
- }
415
- if (category) {
416
- filtered = filtered.filter(
417
- (g) => g.category.toLowerCase() === category.toLowerCase()
418
- );
419
- }
420
- if (filtered.length === 0) {
421
- const text4 = guardrails.length === 0 ? `No checks found. Add checks at ${configLink("/docs/guardrails", await workspaceBase(apiClient))}` : `No checks found${lang ? ` for language "${lang}"` : ""}${category ? ` in category "${category}"` : ""}.`;
422
- return {
423
- content: [{ type: "text", text: text4 }]
424
- };
402
+ async function installSkill(version, dest, { overwrite = false } = {}) {
403
+ if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
404
+ const incoming = new Map(version.files.map((f) => [f.path, f]));
405
+ for (const f of version.files) within(dest, f.path);
406
+ if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
407
+ throw new Error(`${dest} is a link: install into a real folder`);
408
+ }
409
+ const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
410
+ if (walked.links.length) {
411
+ throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
412
+ }
413
+ const present = walked.files;
414
+ const manifest = await readManifest(dest);
415
+ const known2 = Object.fromEntries(
416
+ Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
417
+ );
418
+ const edited = [];
419
+ const untracked = [];
420
+ for (const path of present) {
421
+ const content = await readFile(join(dest, ...path.split("/")), "utf8");
422
+ if (Object.hasOwn(known2, path)) {
423
+ if (sha256(content) !== known2[path]) edited.push(path);
424
+ } else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
425
+ if (incoming.has(path) || !manifest) edited.push(path);
426
+ else untracked.push(path);
425
427
  }
426
- const text3 = filtered.map(
427
- (g) => `## ${g.title} [${g.severity}]
428
-
429
- ${g.description}
430
-
431
- ` + (g.pattern ? `**Bad pattern:**
428
+ }
429
+ const blocking = edited.filter((p) => incoming.has(p) || !manifest);
430
+ if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
431
+ const written = [];
432
+ for (const f of version.files) {
433
+ const full = within(dest, f.path);
434
+ await mkdir(dirname(full), { recursive: true });
435
+ await writeFile(full, f.content, "utf8");
436
+ written.push(f.path);
437
+ }
438
+ const removed = [];
439
+ const keptEdited = [];
440
+ for (const path of Object.keys(known2)) {
441
+ if (incoming.has(path) || !present.includes(path)) continue;
442
+ if (edited.includes(path)) {
443
+ keptEdited.push(path);
444
+ continue;
445
+ }
446
+ await rm(within(dest, path));
447
+ removed.push(path);
448
+ }
449
+ const next = {
450
+ name: version.name,
451
+ version: version.version,
452
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
453
+ files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
454
+ };
455
+ await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
456
+ return {
457
+ dest,
458
+ version: version.version,
459
+ written,
460
+ removed,
461
+ keptUntracked: untracked.filter((p) => !incoming.has(p)),
462
+ keptEdited
463
+ };
464
+ }
465
+ function fenceFor(text3) {
466
+ const longest = Math.max(0, ...(text3.match(/`+/g) ?? []).map((r) => r.length));
467
+ return "`".repeat(Math.max(3, longest + 1));
468
+ }
469
+ function describeInstall(name, r) {
470
+ const lines = [
471
+ `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
472
+ ];
473
+ if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
474
+ if (r.keptEdited.length) {
475
+ lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
476
+ }
477
+ if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
478
+ lines.push("Start a new Claude Code session to load it.");
479
+ return lines.join("\n");
480
+ }
481
+
482
+ // src/lib/team-setup-files.ts
483
+ import { execFile } from "child_process";
484
+ import { chmod, mkdir as mkdir2, readFile as readFile2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
485
+ import { homedir as homedir2, userInfo } from "os";
486
+ import { dirname as dirname2, join as join2 } from "path";
487
+ import { promisify } from "util";
488
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
489
+ function isWorkspace(value) {
490
+ return typeof value === "string" && value.length <= 64 && SLUG_RE.test(value);
491
+ }
492
+ function homeDir() {
493
+ return homedir2();
494
+ }
495
+ function systemdoxDir() {
496
+ return join2(homeDir(), ".claude", "systemdox");
497
+ }
498
+ function workspaceDir(workspace) {
499
+ if (!isWorkspace(workspace)) throw new Error(`${JSON.stringify(workspace)} is not a workspace name`);
500
+ return join2(systemdoxDir(), workspace);
501
+ }
502
+ function statePath(workspace) {
503
+ return join2(workspaceDir(workspace), "state.json");
504
+ }
505
+ async function readJson(path, empty) {
506
+ let text3;
507
+ try {
508
+ text3 = await readFile2(path, "utf8");
509
+ } catch (e) {
510
+ if (e.code === "ENOENT") return empty;
511
+ throw e;
512
+ }
513
+ try {
514
+ return JSON.parse(text3);
515
+ } catch {
516
+ throw new Error(`${path} is not valid JSON: fix or remove it, then run this again`);
517
+ }
518
+ }
519
+ async function writeJsonAtomically(path, value, mode) {
520
+ await mkdir2(dirname2(path), { recursive: true });
521
+ const tmp = `${path}.${process.pid}.tmp`;
522
+ await writeFile2(tmp, JSON.stringify(value, null, 2) + "\n", mode === void 0 ? "utf8" : { encoding: "utf8", mode });
523
+ await rename(tmp, path);
524
+ }
525
+ var run = promisify(execFile);
526
+ async function readState(workspace) {
527
+ return readJson(statePath(workspace), {});
528
+ }
529
+ async function writeState(workspace, state) {
530
+ await writeJsonAtomically(statePath(workspace), state);
531
+ }
532
+ async function updateState(workspace, change) {
533
+ const state = await readState(workspace);
534
+ change(state);
535
+ await writeState(workspace, state);
536
+ return state;
537
+ }
538
+
539
+ // src/lib/session-brief.ts
540
+ var INSTRUCTIONS_MAX = 2e3;
541
+ var BRIEF_TIMEOUT_MS = 8e3;
542
+ var SETUP_COMMAND = "npx -y @systemdox/mcp-server setup";
543
+ var REFUSED = `SystemDox refused this computer's key, so the team's rules and skills cannot be read and every SystemDox tool fails. Tell the person once: a new key comes from the SystemDox console, Settings \u203A API keys, and \`${SETUP_COMMAND}\` in their own terminal stores it.`;
544
+ var NOT_CHECKED = "SystemDox could not be read when this session started, so the team's skills were not checked. Call get_team_setup to check again before relying on them.";
545
+ var CANNOT_SEE = "This server cannot see this computer's disk. For each skill listed under Team skills: if ~/.claude/skills/<name>/ is absent, or its .systemdox-skill.json names another version than get_team_setup lists, call install_skill with that version and write what it returns, unless the latest team plugin carries it. Leave a folder with no such file alone. If a file there differs from the hash that file holds for it, it was edited: ask the person before replacing it.";
546
+ function noKey(reason) {
547
+ return `This computer has no SystemDox key it can use (${reason}), so the team's rules and skills cannot be read and every SystemDox tool fails. Tell the person once: \`${SETUP_COMMAND}\` in their own terminal asks for one.`;
548
+ }
549
+ function olderApi(setup) {
550
+ return `SystemDox workspace ${setup.workspace}. Before writing code in one of its repositories, call get_context with the task and the files you expect to touch, and obey what it returns. get_team_setup lists the team setup.`;
551
+ }
552
+ async function readJsonQuietly(path) {
553
+ try {
554
+ return JSON.parse(await readFile3(path, "utf8"));
555
+ } catch {
556
+ return void 0;
557
+ }
558
+ }
559
+ async function computerState(setup) {
560
+ const id = `${setup.workspace}-setup@systemdox-${setup.workspace}`;
561
+ const installed = await readJsonQuietly(join3(homeDir(), ".claude", "plugins", "installed_plugins.json"));
562
+ const entries2 = installed?.plugins?.[id];
563
+ const entry = Array.isArray(entries2) ? entries2.find((e) => (e?.scope ?? "user") === "user") : void 0;
564
+ const state = {
565
+ missing: [],
566
+ behind: [],
567
+ notFromSystemDox: [],
568
+ pluginInstalled: !!entry,
569
+ pluginVersion: entry?.version
570
+ };
571
+ const pluginCurrent = !!entry && !!setup.plugin && entry.version === `${setup.plugin.version}.0.0`;
572
+ for (const skill of setup.sessionBrief?.skills ?? []) {
573
+ const dir = defaultSkillDir(skill.name);
574
+ if (!existsSync2(dir)) {
575
+ if (!(pluginCurrent && setup.plugin?.skills?.[skill.name] !== void 0)) state.missing.push(skill.name);
576
+ continue;
577
+ }
578
+ const manifest = await readJsonQuietly(join3(dir, MANIFEST_FILE));
579
+ if (typeof manifest?.version !== "number" || !Number.isInteger(manifest.version)) {
580
+ state.notFromSystemDox.push(skill.name);
581
+ } else if (manifest.version !== skill.latestVersion) {
582
+ state.behind.push({ name: skill.name, here: manifest.version, latest: skill.latestVersion });
583
+ }
584
+ }
585
+ return state;
586
+ }
587
+ function hasPersonParts(setup) {
588
+ return Object.keys(setup.parts ?? {}).length > 0 || (setup.memory ?? []).length > 0 || (setup.sessionBrief?.heldSkills ?? []).some((h) => h.reason === "not-plain");
589
+ }
590
+ var MORE = (n) => `${n} more (get_team_setup lists them)`;
591
+ var LIST_MIN = `all ${MORE(100)}`.length;
592
+ function listWithin(items, room) {
593
+ const whole2 = items.join(", ");
594
+ if (whole2.length <= room) return whole2;
595
+ const kept = [];
596
+ let used = 0;
597
+ for (let i = 0; i < items.length; i++) {
598
+ const rest = items.length - i - 1;
599
+ const cost = items[i].length + (kept.length ? 2 : 0);
600
+ if (used + cost + ` and ${MORE(rest)}`.length > room) break;
601
+ kept.push(items[i]);
602
+ used += cost;
603
+ }
604
+ return kept.length ? `${kept.join(", ")} and ${MORE(items.length - kept.length)}` : `all ${MORE(items.length)}`;
605
+ }
606
+ function localSection(setup, state, room) {
607
+ const rest = hasPersonParts(setup) ? ` and that \`${SETUP_COMMAND}\` in their own terminal adds the rest of the team setup` : "";
608
+ const latest = new Map((setup.sessionBrief?.skills ?? []).map((s) => [s.name, s.latestVersion]));
609
+ const lines = [];
610
+ if (state.missing.length) {
611
+ lines.push({
612
+ head: "On this computer, missing: ",
613
+ items: state.missing.map((name) => `${name} version ${latest.get(name)}`),
614
+ tail: `. Install each now with install_skill at that version, before your first answer. Then tell the person what you installed${rest}. When a skill's line matches the request and it is not loaded yet, read ~/.claude/skills/<name>/SKILL.md and follow it.`
615
+ });
616
+ }
617
+ if (state.behind.length) {
618
+ lines.push({
619
+ head: "On this computer, behind: ",
620
+ items: state.behind.map((b) => `${b.name} (version ${b.here} here, latest ${b.latest})`),
621
+ tail: ". install_skill updates each to that latest version; if it refuses because files were edited, tell the person and leave them."
622
+ });
623
+ }
624
+ if (state.notFromSystemDox.length) {
625
+ lines.push({ head: "Not from SystemDox, left alone: ", items: state.notFromSystemDox, tail: "." });
626
+ }
627
+ const fixed = () => lines.reduce((n, l) => n + l.head.length + l.tail.length + LIST_MIN, 0) + Math.max(0, lines.length - 1);
628
+ while (lines.length && fixed() > room) lines.pop();
629
+ if (!lines.length) return "";
630
+ const share = Math.floor((room - fixed()) / lines.length) + LIST_MIN;
631
+ return lines.map((l) => `${l.head}${listWithin(l.items, share)}${l.tail}`).join("\n");
632
+ }
633
+ function fit(text3, section) {
634
+ const whole2 = section ? `${text3}
635
+
636
+ ${section}` : text3;
637
+ if (whole2.length <= INSTRUCTIONS_MAX) return whole2;
638
+ return text3.length <= INSTRUCTIONS_MAX ? text3 : `${text3.slice(0, INSTRUCTIONS_MAX - 1)}\u2026`;
639
+ }
640
+ async function readTeamSetup(apiClient, { keyProblem, timeoutMs = BRIEF_TIMEOUT_MS } = {}) {
641
+ if (keyProblem) return { problem: noKey(keyProblem) };
642
+ try {
643
+ const setup = await apiClient.getOwnTeamSetup(AbortSignal.timeout(timeoutMs));
644
+ return typeof setup?.workspace === "string" ? { setup } : { problem: NOT_CHECKED };
645
+ } catch (err) {
646
+ const status = err?.status;
647
+ return { problem: status === 401 || status === 403 ? REFUSED : NOT_CHECKED };
648
+ }
649
+ }
650
+ async function sessionInstructions(apiClient, options = {}) {
651
+ const read = await readTeamSetup(apiClient, options);
652
+ if ("problem" in read) return read.problem;
653
+ const text3 = read.setup.sessionBrief?.text ?? olderApi(read.setup);
654
+ try {
655
+ if (!options.localFiles) return fit(text3, read.setup.sessionBrief?.skills?.length ? CANNOT_SEE : "");
656
+ const room = INSTRUCTIONS_MAX - text3.length - 2;
657
+ return fit(text3, localSection(read.setup, await computerState(read.setup), room));
658
+ } catch {
659
+ return fit(text3, "");
660
+ }
661
+ }
662
+
663
+ // src/server.ts
664
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
665
+
666
+ // src/tools/get-guardrails.ts
667
+ import { z } from "zod";
668
+
669
+ // src/lib/language.ts
670
+ var EXTENSION_MAP = {
671
+ ".py": { language: "python", categories: ["api", "backend", "data-access"] },
672
+ ".ts": { language: "typescript", categories: ["frontend", "api"] },
673
+ ".tsx": { language: "typescript", categories: ["frontend"] },
674
+ ".js": { language: "javascript", categories: ["frontend"] },
675
+ ".jsx": { language: "javascript", categories: ["frontend"] },
676
+ ".yaml": {
677
+ language: "cloudformation",
678
+ categories: ["infrastructure", "architecture"]
679
+ },
680
+ ".yml": {
681
+ language: "cloudformation",
682
+ categories: ["infrastructure", "architecture"]
683
+ },
684
+ ".json": { language: "json", categories: ["configuration"] },
685
+ ".css": { language: "css", categories: ["frontend"] },
686
+ ".html": { language: "html", categories: ["frontend"] },
687
+ ".sql": { language: "sql", categories: ["data-access"] },
688
+ ".sh": { language: "shell", categories: ["infrastructure"] },
689
+ ".bash": { language: "shell", categories: ["infrastructure"] },
690
+ ".tf": { language: "terraform", categories: ["infrastructure"] },
691
+ ".md": { language: "markdown", categories: ["documentation"] }
692
+ };
693
+ var PATH_CATEGORY_RULES = [
694
+ { pattern: /backend\/functions\//, categories: ["api", "backend"] },
695
+ { pattern: /backend\/layers\//, categories: ["backend"] },
696
+ { pattern: /backend\//, categories: ["backend"] },
697
+ { pattern: /apps\/app\//, categories: ["frontend"] },
698
+ { pattern: /apps\/website\//, categories: ["frontend"] },
699
+ { pattern: /apps\/portal\//, categories: ["frontend"] },
700
+ { pattern: /packages\/ui\//, categories: ["frontend"] },
701
+ { pattern: /infra\//, categories: ["infrastructure"] },
702
+ { pattern: /\.github\//, categories: ["infrastructure"] },
703
+ { pattern: /tests?\//, categories: ["testing"] },
704
+ { pattern: /spec/, categories: ["testing"] },
705
+ { pattern: /docs\/adr/, categories: ["architecture"] },
706
+ { pattern: /template\.yaml$/, categories: ["infrastructure", "architecture"] }
707
+ ];
708
+ function detectLanguage(filePath) {
709
+ const ext = filePath.slice(filePath.lastIndexOf("."));
710
+ return EXTENSION_MAP[ext];
711
+ }
712
+ function detectCategories(filePaths) {
713
+ const categories = /* @__PURE__ */ new Set();
714
+ for (const filePath of filePaths) {
715
+ const langInfo = detectLanguage(filePath);
716
+ if (langInfo) {
717
+ for (const cat of langInfo.categories) {
718
+ categories.add(cat);
719
+ }
720
+ }
721
+ for (const rule of PATH_CATEGORY_RULES) {
722
+ if (rule.pattern.test(filePath)) {
723
+ for (const cat of rule.categories) {
724
+ categories.add(cat);
725
+ }
726
+ }
727
+ }
728
+ }
729
+ return Array.from(categories);
730
+ }
731
+ function detectLanguages(filePaths) {
732
+ const languages = /* @__PURE__ */ new Set();
733
+ for (const filePath of filePaths) {
734
+ const info = detectLanguage(filePath);
735
+ if (info) languages.add(info.language);
736
+ }
737
+ return Array.from(languages);
738
+ }
739
+
740
+ // src/lib/deprecation.ts
741
+ var LEGACY_TOOL_REMOVAL_EARLIEST = "2027-01-31";
742
+ function legacyToolDescription(canonical, description) {
743
+ return `DEPRECATED \u2014 use \`${canonical}\` instead. This name still works and will not be removed before ${LEGACY_TOOL_REMOVAL_EARLIEST}. ${description}`;
744
+ }
745
+
746
+ // src/lib/config-links.ts
747
+ async function workspaceBase(apiClient) {
748
+ try {
749
+ const access = await apiClient.get(
750
+ "/v1/docs/portal-access"
751
+ );
752
+ const slug = (access.host ?? "").split(".")[0];
753
+ return slug ? `https://${slug}.systemdox.com` : null;
754
+ } catch {
755
+ return null;
756
+ }
757
+ }
758
+ function configLink(path, base) {
759
+ return base ? `${base}${path}` : `${path} in your SystemDox workspace`;
760
+ }
761
+
762
+ // src/tools/get-guardrails.ts
763
+ var DESCRIPTION = "Retrieve SystemDox checks (banned patterns, required patterns, coding rules). Filter by language, category, or file path to get only relevant checks.";
764
+ var inputSchema = () => ({
765
+ language: z.string().optional().describe(
766
+ "Filter by programming language (python, typescript, javascript, cloudformation)"
767
+ ),
768
+ category: z.string().optional().describe(
769
+ "Filter by category (imports, auth, error-handling, architecture, testing, security, conventions, data-access)"
770
+ ),
771
+ file_path: z.string().optional().describe(
772
+ "File path being edited \u2014 language is auto-detected from the extension"
773
+ )
774
+ });
775
+ function registerGetGuardrails(server, apiClient) {
776
+ const handler2 = async ({
777
+ language,
778
+ category,
779
+ file_path
780
+ }) => {
781
+ const guardrails = await apiClient.get("/v1/guardrails");
782
+ let filtered = guardrails;
783
+ const lang = language ?? (file_path ? detectLanguage(file_path)?.language : void 0);
784
+ if (lang) {
785
+ filtered = filtered.filter(
786
+ (g) => g.description.toLowerCase().includes(lang.toLowerCase()) || g.category.toLowerCase().includes(lang.toLowerCase()) || g.title.toLowerCase().includes(lang.toLowerCase())
787
+ );
788
+ }
789
+ if (category) {
790
+ filtered = filtered.filter(
791
+ (g) => g.category.toLowerCase() === category.toLowerCase()
792
+ );
793
+ }
794
+ if (filtered.length === 0) {
795
+ const text4 = guardrails.length === 0 ? `No checks found. Add checks at ${configLink("/docs/guardrails", await workspaceBase(apiClient))}` : `No checks found${lang ? ` for language "${lang}"` : ""}${category ? ` in category "${category}"` : ""}.`;
796
+ return {
797
+ content: [{ type: "text", text: text4 }]
798
+ };
799
+ }
800
+ const text3 = filtered.map(
801
+ (g) => `## ${g.title} [${g.severity}]
802
+
803
+ ${g.description}
804
+
805
+ ` + (g.pattern ? `**Bad pattern:**
432
806
  \`\`\`
433
807
  ${g.pattern}
434
808
  \`\`\`
@@ -744,7 +1118,7 @@ ${r.conventions.map((c) => `- ${c}`).join("\n")}` : "")
744
1118
  ${repos}`);
745
1119
  }
746
1120
  if (ctx.dependencies.length > 0) {
747
- const deps = ctx.dependencies.map((d) => `- ${d.from} \u2192 ${d.to}`).join("\n");
1121
+ const deps = ctx.dependencies.map((d) => `- ${d.from} \u2192 ${d.to}${d.provenance === "inferred" ? " (inferred)" : ""}`).join("\n");
748
1122
  sections.push(`## Cross-Repo Dependencies
749
1123
 
750
1124
  ${deps}`);
@@ -968,8 +1342,149 @@ ${notes.join("\n")}` : "")
968
1342
  );
969
1343
  }
970
1344
 
971
- // src/tools/get-context.ts
1345
+ // src/tools/get-change.ts
972
1346
  import { z as z6 } from "zod";
1347
+ var GITHUB_NAME = "[A-Za-z0-9][A-Za-z0-9._-]{0,99}";
1348
+ var REPO_RE = new RegExp(`^${GITHUB_NAME}/${GITHUB_NAME}$`);
1349
+ var SLUG_RE2 = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1350
+ var SLUG_MAX = 64;
1351
+ var PARTS = ["decision", "contract", "behaviour", "plan", "release"];
1352
+ var BRIEF = ["decision", "contract", "behaviour", "plan"];
1353
+ var PAST_REVIEW = /* @__PURE__ */ new Set(["passed", "being built", "done"]);
1354
+ function briefVerdict(states) {
1355
+ const waiting = BRIEF.filter(
1356
+ (part) => !(part === "decision" && states.decision === null) && !PAST_REVIEW.has(String(states[part]))
1357
+ ).map((part) => `${part} is ${states[part] ?? "unknown"}`);
1358
+ return waiting.length === 0 ? "The brief is passed" : `The brief is not passed: ${waiting.join(", ")}`;
1359
+ }
1360
+ function prLink(pr) {
1361
+ return `[${pr.repo}#${pr.number}](${pr.url}) ${pr.state}`;
1362
+ }
1363
+ function fileSource(file) {
1364
+ if (file.pullRequest) return prLink(file.pullRequest);
1365
+ return file.direct ? "committed directly" : "\u2014";
1366
+ }
1367
+ function partPullRequests(change, part) {
1368
+ const files = part === "contract" ? change.contract.files : part === "behaviour" ? change.behaviour.files : part === "plan" ? [change.plan.file] : [];
1369
+ const prs = part === "decision" ? [change.decision?.pullRequest ?? null] : part === "release" ? change.release.prs : files.map((file) => file?.pullRequest ?? null);
1370
+ const unique = /* @__PURE__ */ new Map();
1371
+ for (const pr of prs) {
1372
+ if (pr && !unique.has(`${pr.repo}#${pr.number}`)) unique.set(`${pr.repo}#${pr.number}`, pr);
1373
+ }
1374
+ return [...unique.values()].sort((a, b) => Number(b.state === "open") - Number(a.state === "open"));
1375
+ }
1376
+ function countsLine(counts) {
1377
+ const tags = ["proposed", "built", "defect", "unbuilt", "open", "untagged"].filter((tag) => counts[tag] > 0).map((tag) => `${counts[tag]} ${tag}`);
1378
+ return `${counts.total} scenario(s)` + (tags.length > 0 ? `: ${tags.join(", ")}` : "");
1379
+ }
1380
+ function partsTable(change) {
1381
+ const rows = PARTS.filter((part) => !(part === "decision" && change.states.decision === null)).map((part) => {
1382
+ const prs = partPullRequests(change, part);
1383
+ return `| ${part} | ${change.states[part]} | ${prs.length > 0 ? prs.map(prLink).join(", ") : "\u2014"} |`;
1384
+ });
1385
+ return ["| Part | State | Pull request |", "|---|---|---|", ...rows].join("\n");
1386
+ }
1387
+ function decisionSection(change) {
1388
+ const decision = change.decision;
1389
+ if (decision === null) return "## Decision\n\nNone: the proposal names no ADR (a small change).";
1390
+ const status = decision.status || "no record in a connected repository";
1391
+ return `## Decision
1392
+
1393
+ ${decision.adr} \xB7 ${status}${decision.title ? ` \u2014 ${decision.title}` : ""}`;
1394
+ }
1395
+ function contractSection(change) {
1396
+ const files = change.contract.files;
1397
+ return "## Contract\n\n" + (files.length > 0 ? files.map((file) => `- ${file.path} \u2014 ${fileSource(file)}`).join("\n") : "No contract file yet (openapi.yaml, asyncapi.yaml or contract.md).");
1398
+ }
1399
+ function behaviourSection(change) {
1400
+ const { files, counts } = change.behaviour;
1401
+ if (files.length === 0) {
1402
+ return `## Behaviour
1403
+
1404
+ No feature file names this change. A feature file joins it when its path or its first ten lines name \`${change.slug}\`${change.decision ? ` or ${change.decision.adr}` : ""}.`;
1405
+ }
1406
+ const blocks = files.map(
1407
+ (file) => [
1408
+ `### ${file.path}`,
1409
+ "",
1410
+ [file.title, file.runner ? `runner ${file.runner}` : "", fileSource(file), countsLine(file.counts)].filter(Boolean).join(" \xB7 "),
1411
+ "",
1412
+ ...file.scenarios.map((s) => `- ${s.id || "(no id)"} \xB7 ${s.state || "untagged"} \xB7 ${s.title}`)
1413
+ ].join("\n")
1414
+ );
1415
+ return ["## Behaviour", countsLine(counts), ...blocks].join("\n\n");
1416
+ }
1417
+ function planSection(change) {
1418
+ const { file, draftId, board } = change.plan;
1419
+ const boardLine = board ? `[#${board.number} ${board.title}](${board.url}) \u2014 todo ${board.items.todo} \xB7 in progress ${board.items.inProgress} \xB7 done ${board.items.done}` + (board.items.truncated ? " (counted over its first 500 items)" : "") : "none";
1420
+ return [
1421
+ "## Plan",
1422
+ "",
1423
+ `- plan.md: ${file ? `${file.path} \u2014 ${fileSource(file)}` : "none"}`,
1424
+ `- Draft: ${draftId ?? "none"}`,
1425
+ `- Board: ${boardLine}`
1426
+ ].join("\n");
1427
+ }
1428
+ function releaseSection(change) {
1429
+ const lines = change.release.prs.map(
1430
+ (pr) => `- ${prLink(pr)} \xB7 ${pr.title} \xB7 ` + (pr.approvingReview ? `approved by ${pr.approvingReview.by} at ${pr.approvingReview.at}` : "no approving review on its head yet")
1431
+ );
1432
+ return `## Release
1433
+
1434
+ ${lines.length > 0 ? lines.join("\n") : "No release pull request is open."}`;
1435
+ }
1436
+ function formatChange(change) {
1437
+ const header = `# ${change.title}
1438
+
1439
+ \`${change.key}\` \xB7 size: ${change.size}` + (change.archived ? " \xB7 archived: read-only, done" : "") + ` \xB7 synced ${change.syncedAt}`;
1440
+ return [
1441
+ header,
1442
+ partsTable(change),
1443
+ decisionSection(change),
1444
+ contractSection(change),
1445
+ behaviourSection(change),
1446
+ planSection(change),
1447
+ releaseSection(change),
1448
+ // Compact: a change's every scenario with its steps rides in this block.
1449
+ "```json\n" + JSON.stringify(change) + "\n```",
1450
+ briefVerdict(change.states)
1451
+ ].join("\n\n");
1452
+ }
1453
+ function registerGetChange(server, apiClient) {
1454
+ server.registerTool(
1455
+ "get_change",
1456
+ {
1457
+ title: "Get Change",
1458
+ description: `Read one change (a specs/changes/<slug>/ folder of a connected repository) as SystemDox last synced it from GitHub: the state of each part of its brief (decision, contract, behaviour, plan) and of its release, each with its pull request; the decision's ADR and status; the contract files; every feature file that names the change, with its scenarios as id \xB7 state \xB7 title; the plan (plan.md, the draft, the board); the release pull requests; and the Change object as JSON. The last line says "The brief is passed" or "The brief is not passed: <part> is <state>". Call it before building a story, and stop if the brief is not passed or the story's scenarios are not proposed. Read-only.`,
1459
+ inputSchema: {
1460
+ repo: z6.string().regex(REPO_RE, "the repository as owner/name, e.g. acme/booking-api").describe("The repository that holds the change, as owner/name"),
1461
+ slug: z6.string().max(SLUG_MAX).regex(SLUG_RE2, "a change folder's name: lower-case words joined by -, e.g. customer-reminders").describe("The change's folder name: specs/changes/<slug>/")
1462
+ },
1463
+ annotations: { readOnlyHint: true }
1464
+ },
1465
+ async ({ repo, slug }) => {
1466
+ const [owner, name] = repo.split("/");
1467
+ let change;
1468
+ try {
1469
+ change = await apiClient.getFresh(
1470
+ `/v1/changes/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${encodeURIComponent(slug)}`
1471
+ );
1472
+ } catch (err) {
1473
+ if (err.status === 404) {
1474
+ return {
1475
+ content: [{ type: "text", text: `No such change ${repo}/${slug}` }],
1476
+ isError: true
1477
+ };
1478
+ }
1479
+ throw err;
1480
+ }
1481
+ return { content: [{ type: "text", text: formatChange(change) }] };
1482
+ }
1483
+ );
1484
+ }
1485
+
1486
+ // src/tools/get-context.ts
1487
+ import { z as z7 } from "zod";
973
1488
 
974
1489
  // src/lib/scoring.ts
975
1490
  var WEIGHT_PATH = 0.4;
@@ -1303,16 +1818,22 @@ async function hydrate(api, item, meta) {
1303
1818
  }
1304
1819
  async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
1305
1820
  const budget = maxTokens ?? DEFAULT_BUDGET;
1821
+ const failures = [];
1822
+ const orEmpty = (empty) => (err) => {
1823
+ failures.push(err);
1824
+ return empty;
1825
+ };
1306
1826
  const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
1307
- api.get("/v1/guardrails").catch(() => []),
1308
- api.get("/v1/specs").catch(() => []),
1309
- api.get("/v1/docs").catch(() => ({ items: [] })),
1310
- api.get("/v1/ai/settings/stack").catch(() => []),
1311
- api.get("/v1/standards/rules").catch(() => []),
1827
+ api.get("/v1/guardrails").catch(orEmpty([])),
1828
+ api.get("/v1/specs").catch(orEmpty([])),
1829
+ api.get("/v1/docs").catch(orEmpty({ items: [] })),
1830
+ api.get("/v1/ai/settings/stack").catch(orEmpty([])),
1831
+ api.get("/v1/standards/rules").catch(orEmpty([])),
1312
1832
  // Capability states — FAIL-OPEN: an unreadable config (older backend, a
1313
1833
  // blip) gates nothing, it can never hide working context.
1314
1834
  api.get("/v1/doc-repos").catch(() => null)
1315
1835
  ]);
1836
+ if (failures.length === 5) throw failures[0];
1316
1837
  const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
1317
1838
  const guardrails = disabled("fitness") ? [] : rawGuardrails;
1318
1839
  const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
@@ -1519,47 +2040,280 @@ _Ranked lower \u2014 fetch with \`get_specs\` / \`get_docs\` if you need them._
1519
2040
  guardrails: selectedGuardrails.length
1520
2041
  };
1521
2042
  if (sections.length === 0) {
1522
- return { text: "No relevant context found for this task.", stats };
2043
+ const text3 = failures.length ? `No context found for this task, but ${failures.length} of 5 reads failed, so this is not an all-clear: the team's rules may not have been read.` : "No relevant context found for this task.";
2044
+ return { text: text3, stats };
1523
2045
  }
1524
2046
  const footer = `
1525
2047
 
1526
2048
  ---
1527
- _Context: ${itemsWithBody} full text, ${itemsListed} listed, ${stats.itemsOmitted} omitted \xB7 ~${tokensUsed} of ${budget} tokens` + (bodyFailures > 0 ? ` \xB7 ${bodyFailures} body fetch(es) failed` : "") + `_`;
2049
+ _Context: ${itemsWithBody} full text, ${itemsListed} listed, ${stats.itemsOmitted} omitted \xB7 ~${tokensUsed} of ${budget} tokens` + (bodyFailures > 0 ? ` \xB7 ${bodyFailures} body fetch(es) failed` : "") + (failures.length > 0 ? ` \xB7 ${failures.length} of 5 reads failed` : "") + `_`;
1528
2050
  return { text: body + footer, stats };
1529
2051
  }
1530
-
1531
- // src/tools/get-context.ts
1532
- function registerGetContext(server, apiClient) {
2052
+
2053
+ // src/tools/get-context.ts
2054
+ function registerGetContext(server, apiClient) {
2055
+ server.registerTool(
2056
+ "get_context",
2057
+ {
2058
+ title: "Get Context",
2059
+ description: "Intelligent context aggregator: fetches checks, specs, docs, and tech stack, ranks them by relevance to your current task, and returns the full text of the highest-ranked items within a token budget \u2014 each with the ID needed to fetch the rest. Use this instead of calling individual tools when you want curated, relevant context.",
2060
+ inputSchema: {
2061
+ task_description: z7.string().describe(
2062
+ "What you're working on (e.g., 'add a new REST endpoint for user preferences')"
2063
+ ),
2064
+ file_paths: z7.array(z7.string()).optional().describe(
2065
+ "File paths being edited \u2014 used for language/category detection"
2066
+ ),
2067
+ max_tokens: z7.number().optional().describe("Maximum token budget for the response (default: 4000)")
2068
+ }
2069
+ },
2070
+ async ({ task_description, file_paths, max_tokens }) => {
2071
+ const { text: text3 } = await buildContext(apiClient, {
2072
+ taskDescription: task_description,
2073
+ filePaths: file_paths,
2074
+ maxTokens: max_tokens
2075
+ });
2076
+ return {
2077
+ content: [{ type: "text", text: text3 }]
2078
+ };
2079
+ }
2080
+ );
2081
+ }
2082
+
2083
+ // src/tools/get-team-setup.ts
2084
+ var HELD_BECAUSE = {
2085
+ "not-plain": `it is not a plain skill: it has frontmatter beyond a name and a description, files outside its own folders, or a command that runs as it loads. The team plugin carries it, and \`${SETUP_COMMAND}\` shows that before the person accepts`,
2086
+ "outside-default-space": "it is outside the default space, so the team plugin does not carry it either. The person can read it in the console, then install it with `npx -y @systemdox/mcp-server install-skill <name>`",
2087
+ "not-checked": "it could not be checked. Ask again later"
2088
+ };
2089
+ function describeSetup(setup) {
2090
+ const lines = [`# Team setup of ${setup.workspace}`, ""];
2091
+ const skills = setup.sessionBrief?.skills ?? [];
2092
+ const held = setup.sessionBrief?.heldSkills ?? [];
2093
+ lines.push(`## Skills a session installs by itself (${skills.length})`, "");
2094
+ if (skills.length) {
2095
+ for (const s of skills) lines.push(`- **${s.name}** version ${s.latestVersion}: ${s.description}`);
2096
+ } else if (!setup.sessionBrief && setup.skills.length) {
2097
+ lines.push(`- ${setup.skills.join(", ")}`);
2098
+ } else {
2099
+ lines.push("None.");
2100
+ }
2101
+ lines.push("");
2102
+ if (held.length) {
2103
+ lines.push(`## Skills that wait for the person (${held.length})`, "");
2104
+ for (const h of held) lines.push(`- **${h.name}**: ${HELD_BECAUSE[h.reason] ?? h.reason}`);
2105
+ lines.push("", "Do not install these yourself: each is the person's to decide.", "");
2106
+ }
2107
+ if (setup.plugin) {
2108
+ lines.push(`## Team plugin`, "", `\`${setup.workspace}-setup\` version ${setup.plugin.version}.`);
2109
+ for (const c of setup.plugin.hookCommands ?? []) lines.push(`- Hook: ${JSON.stringify(c)}`);
2110
+ for (const m of setup.plugin.mcpServers ?? []) {
2111
+ lines.push(`- MCP server ${JSON.stringify(m.name)}: ${JSON.stringify(m.url ?? [m.command, ...m.args ?? []].filter(Boolean).join(" "))}`);
2112
+ }
2113
+ lines.push("");
2114
+ }
2115
+ const parts = Object.entries(setup.parts ?? {});
2116
+ if (parts.length) {
2117
+ lines.push(`## Published parts`, "", ...parts.map(([p, v]) => `- ${p}: version ${v}`), "");
2118
+ }
2119
+ if (setup.memory?.length) {
2120
+ lines.push(`## Team memory`, "", ...setup.memory.map((m) => `- ${m.project}: ${m.fileCount} file(s)`), "");
2121
+ }
2122
+ return lines;
2123
+ }
2124
+ function describeComputer(setup, state) {
2125
+ const lines = ["## This computer", ""];
2126
+ if (!setup.sessionBrief) {
2127
+ return [...lines, "This SystemDox API is too old to compare this computer with the team setup."];
2128
+ }
2129
+ const skills = setup.sessionBrief.skills;
2130
+ const latest = new Map(skills.map((s) => [s.name, s.latestVersion]));
2131
+ lines.push(
2132
+ state.missing.length ? `- Missing: ${state.missing.map((name) => `${name} version ${latest.get(name)}`).join(", ")}` : `- Missing: none of ${skills.length}`
2133
+ );
2134
+ if (state.behind.length) {
2135
+ lines.push(`- Behind: ${state.behind.map((b) => `${b.name} (version ${b.here} here, latest ${b.latest})`).join(", ")}`);
2136
+ }
2137
+ if (state.notFromSystemDox.length) lines.push(`- Not from SystemDox, left alone: ${state.notFromSystemDox.join(", ")}`);
2138
+ if (setup.plugin) {
2139
+ lines.push(
2140
+ state.pluginInstalled ? `- Team plugin: installed${state.pluginVersion ? `, ${state.pluginVersion}` : ""}; the latest is version ${setup.plugin.version}` : "- Team plugin: not installed"
2141
+ );
2142
+ }
2143
+ lines.push("", "## What to do", "");
2144
+ const steps = [];
2145
+ if (state.missing.length) steps.push("Call install_skill for each missing skill, at the version named above. These are plain skills: installing writes files and runs nothing.");
2146
+ if (state.behind.length) {
2147
+ steps.push("Call install_skill for each skill behind, at the latest version named above. If it refuses because files were edited, tell the person and leave them.");
2148
+ }
2149
+ if (hasPersonParts(setup) && !state.pluginInstalled) {
2150
+ steps.push(
2151
+ `Hooks, MCP servers, team instructions and memory need the person: \`${SETUP_COMMAND}\` in their own terminal. Never answer its prompts for them.`
2152
+ );
2153
+ }
2154
+ lines.push(...steps.length ? steps.map((s, i) => `${i + 1}. ${s}`) : ["Nothing."]);
2155
+ return lines;
2156
+ }
2157
+ function registerGetTeamSetup(server, apiClient, { localFiles, keyProblem }) {
2158
+ server.registerTool(
2159
+ "get_team_setup",
2160
+ {
2161
+ title: "Get Team Setup",
2162
+ description: localFiles ? "What this workspace's team setup holds (skills, plugin, hooks, MCP servers, memory) and what this computer lacks, with what to do about it. Call it to check whether this computer is set up. Reads only." : "What this workspace's team setup holds (skills, plugin, hooks, MCP servers, memory), and how to check this computer against it. Reads only.",
2163
+ inputSchema: {},
2164
+ annotations: { readOnlyHint: true }
2165
+ },
2166
+ async () => {
2167
+ const read = await readTeamSetup(apiClient, { keyProblem, timeoutMs: 15e3 });
2168
+ if ("problem" in read) return { content: [{ type: "text", text: read.problem }] };
2169
+ const lines = describeSetup(read.setup);
2170
+ if (localFiles) lines.push(...describeComputer(read.setup, await computerState(read.setup)));
2171
+ else lines.push(CANNOT_SEE);
2172
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2173
+ }
2174
+ );
2175
+ }
2176
+
2177
+ // src/tools/document-graph.ts
2178
+ import { z as z8 } from "zod";
2179
+ var DEFAULT_LIMIT2 = 50;
2180
+ var MAX_LIMIT = 200;
2181
+ var DEFAULT_HOPS = 6;
2182
+ var MAX_HOPS = 8;
2183
+ function place(file, line) {
2184
+ return file ? `${file}:${line}` : "made by a person";
2185
+ }
2186
+ function known(provenance, confidence) {
2187
+ return `${provenance} ${confidence}`;
2188
+ }
2189
+ function formatNeighbours(answer) {
2190
+ const lines = [`# ${answer.doc.label}`, `\`${answer.doc.id}\``, ""];
2191
+ if (answer.links.length === 0) lines.push("No links.");
2192
+ for (const link of answer.links) {
2193
+ lines.push(
2194
+ `- ${link.direction} \xB7 ${link.relation} \xB7 ${known(link.provenance, link.confidence)} \xB7 ${link.other.label} \xB7 \`${link.other.id}\` \xB7 ${place(link.file, link.line)}`
2195
+ );
2196
+ }
2197
+ if (answer.truncated) {
2198
+ lines.push("", `Showing ${answer.links.length} of ${answer.total}. Raise limit to see more.`);
2199
+ }
2200
+ if (answer.ambiguous.length > 0) {
2201
+ lines.push("", "## Ambiguous: not drawn, because each could mean several documents");
2202
+ for (const entry of answer.ambiguous) {
2203
+ const candidates = entry.candidates.map((c) => `${c.label} (${c.repo}, \`${c.id}\`)`);
2204
+ lines.push(`- ${entry.reference} at ${place(entry.file, entry.line)}: ${candidates.join("; ")}`);
2205
+ }
2206
+ }
2207
+ return lines.join("\n");
2208
+ }
2209
+ function formatPath(answer, maxHops) {
2210
+ if (!answer.found) return `No path within ${maxHops} hops.`;
2211
+ const lines = answer.steps.map((step, index) => {
2212
+ const arrow = step.reversed ? "<-" : "->";
2213
+ return `${index + 1}. ${step.from.label} ${arrow} ${step.relation} ${arrow} ${step.to.label} \xB7 ${known(step.provenance, step.confidence)} \xB7 ${place(step.file, step.line)}`;
2214
+ });
2215
+ return [...lines, "", `Path confidence: ${answer.confidence}`].join("\n");
2216
+ }
2217
+ function formatStats(stats, repoLinks) {
2218
+ const { declared, extracted, inferred, ambiguous } = stats.links;
2219
+ const lines = [
2220
+ `# The graph: ${stats.nodeCount} documents, ${stats.edgeCount} links`,
2221
+ "",
2222
+ `Links: ${declared} declared \xB7 ${extracted} extracted \xB7 ${inferred} inferred \xB7 ${ambiguous} ambiguous (not drawn)`,
2223
+ "",
2224
+ "## Repositories"
2225
+ ];
2226
+ for (const repo of stats.repos) {
2227
+ const state = repo.behind ? `BEHIND: pushed ${repo.headCommit || "unknown"} at ${repo.pushedAt}, index at ${repo.indexedCommit || "unknown"}` : `index at ${repo.indexedCommit || "unknown"}`;
2228
+ lines.push(`- ${repo.repo}: ${repo.documents} documents, ${repo.links} links, ${state}`);
2229
+ }
2230
+ if (repoLinks.length > 0) {
2231
+ lines.push("", "## Repository links");
2232
+ for (const link of repoLinks) {
2233
+ lines.push(
2234
+ `- ${link.source} ${link.relation} ${link.target} \xB7 ${known(link.provenance, link.confidence)} \xB7 ${place(link.file, link.line)}`
2235
+ );
2236
+ }
2237
+ }
2238
+ return lines.join("\n");
2239
+ }
2240
+ function refused(err) {
2241
+ const status = err.status;
2242
+ if (status !== 404 && status !== 400) throw err;
2243
+ return {
2244
+ content: [{ type: "text", text: err.message }],
2245
+ isError: true
2246
+ };
2247
+ }
2248
+ function registerDocumentGraph(server, apiClient) {
1533
2249
  server.registerTool(
1534
- "get_context",
2250
+ "get_document_links",
1535
2251
  {
1536
- title: "Get Context",
1537
- description: "Intelligent context aggregator: fetches checks, specs, docs, and tech stack, ranks them by relevance to your current task, and returns the full text of the highest-ranked items within a token budget \u2014 each with the ID needed to fetch the rest. Use this instead of calling individual tools when you want curated, relevant context.",
2252
+ description: "What one document links to and from, strongest first. Each link says how it is known: declared (a person made it), extracted (the file says so), or inferred (a reading that could be wrong), with a confidence and the file and line it came from. A reference that could mean several documents is listed as ambiguous with its candidates, and is never drawn as a link. doc_id is the id get_docs returns. Read-only.",
1538
2253
  inputSchema: {
1539
- task_description: z6.string().describe(
1540
- "What you're working on (e.g., 'add a new REST endpoint for user preferences')"
1541
- ),
1542
- file_paths: z6.array(z6.string()).optional().describe(
1543
- "File paths being edited \u2014 used for language/category detection"
1544
- ),
1545
- max_tokens: z6.number().optional().describe("Maximum token budget for the response (default: 4000)")
2254
+ doc_id: z8.string().min(1).max(400).describe("A document id, as get_docs returns it"),
2255
+ direction: z8.enum(["out", "in", "both"]).optional().describe("Default both"),
2256
+ relation: z8.enum(["links_to", "cites", "mentions", "satisfies", "references"]).optional().describe("Only links of this kind"),
2257
+ limit: z8.number().int().min(1).max(MAX_LIMIT).optional().describe(`Most links to show (default ${DEFAULT_LIMIT2})`)
2258
+ },
2259
+ annotations: { readOnlyHint: true }
2260
+ },
2261
+ async ({ doc_id, direction, relation, limit }) => {
2262
+ const params = { doc: doc_id, limit: String(limit ?? DEFAULT_LIMIT2) };
2263
+ if (direction) params.direction = direction;
2264
+ if (relation) params.relation = relation;
2265
+ try {
2266
+ const answer = await apiClient.get("/v1/graph/neighbours", params);
2267
+ return { content: [{ type: "text", text: formatNeighbours(answer) }] };
2268
+ } catch (err) {
2269
+ return refused(err);
1546
2270
  }
2271
+ }
2272
+ );
2273
+ server.registerTool(
2274
+ "trace_document_path",
2275
+ {
2276
+ description: "The shortest way two documents connect, walking links in either direction. Each step says how it is known, and the path is only as strong as its weakest step. Read-only.",
2277
+ inputSchema: {
2278
+ from: z8.string().min(1).max(400).describe("A document id, as get_docs returns it"),
2279
+ to: z8.string().min(1).max(400).describe("A document id, as get_docs returns it"),
2280
+ max_hops: z8.number().int().min(1).max(MAX_HOPS).optional().describe(`Longest path to look for (default ${DEFAULT_HOPS})`)
2281
+ },
2282
+ annotations: { readOnlyHint: true }
1547
2283
  },
1548
- async ({ task_description, file_paths, max_tokens }) => {
1549
- const { text: text3 } = await buildContext(apiClient, {
1550
- taskDescription: task_description,
1551
- filePaths: file_paths,
1552
- maxTokens: max_tokens
1553
- });
1554
- return {
1555
- content: [{ type: "text", text: text3 }]
1556
- };
2284
+ async ({ from, to, max_hops }) => {
2285
+ const hops = max_hops ?? DEFAULT_HOPS;
2286
+ try {
2287
+ const answer = await apiClient.get("/v1/graph/path", {
2288
+ from,
2289
+ to,
2290
+ maxHops: String(hops)
2291
+ });
2292
+ return { content: [{ type: "text", text: formatPath(answer, hops) }] };
2293
+ } catch (err) {
2294
+ return refused(err);
2295
+ }
2296
+ }
2297
+ );
2298
+ server.registerTool(
2299
+ "get_graph_stats",
2300
+ {
2301
+ description: "The state of the graph: links counted by how they are known, each repository's freshness (marked BEHIND when a push has not finished indexing), and how repositories depend on each other, from the files that declare it. Read-only.",
2302
+ inputSchema: {},
2303
+ annotations: { readOnlyHint: true }
2304
+ },
2305
+ async () => {
2306
+ const [stats, repos] = await Promise.all([
2307
+ apiClient.getFresh("/v1/graph/stats"),
2308
+ apiClient.getFresh("/v1/graph/repos")
2309
+ ]);
2310
+ return { content: [{ type: "text", text: formatStats(stats, repos.links) }] };
1557
2311
  }
1558
2312
  );
1559
2313
  }
1560
2314
 
1561
2315
  // src/tools/create-guardrail.ts
1562
- import { z as z7 } from "zod";
2316
+ import { z as z9 } from "zod";
1563
2317
  var DESCRIPTION2 = "Create a new check in SystemDox. Use this when you discover a pattern that should be enforced (banned pattern, required pattern, coding rule).";
1564
2318
  var CATEGORIES = [
1565
2319
  "imports",
@@ -1572,13 +2326,13 @@ var CATEGORIES = [
1572
2326
  "data-access"
1573
2327
  ];
1574
2328
  var inputSchema2 = () => ({
1575
- title: z7.string().describe("Short title for the check"),
1576
- description: z7.string().describe("Detailed description of what the check enforces and why"),
1577
- severity: z7.enum(["critical", "high", "medium", "low"]).describe("Severity level"),
1578
- category: z7.enum(CATEGORIES).optional().describe("Category (defaults to conventions)"),
1579
- pattern: z7.string().optional().describe("Bad code pattern that should be avoided"),
1580
- fix: z7.string().optional().describe("Correct code pattern to use instead"),
1581
- fitness_test: z7.string().optional().describe("Shell command to detect violations in CI")
2329
+ title: z9.string().describe("Short title for the check"),
2330
+ description: z9.string().describe("Detailed description of what the check enforces and why"),
2331
+ severity: z9.enum(["critical", "high", "medium", "low"]).describe("Severity level"),
2332
+ category: z9.enum(CATEGORIES).optional().describe("Category (defaults to conventions)"),
2333
+ pattern: z9.string().optional().describe("Bad code pattern that should be avoided"),
2334
+ fix: z9.string().optional().describe("Correct code pattern to use instead"),
2335
+ fitness_test: z9.string().optional().describe("Shell command to detect violations in CI")
1582
2336
  });
1583
2337
  function registerCreateGuardrail(server, apiClient) {
1584
2338
  const handler2 = async ({
@@ -1644,7 +2398,7 @@ function registerCreateGuardrail(server, apiClient) {
1644
2398
  }
1645
2399
 
1646
2400
  // src/tools/update-spec.ts
1647
- import { z as z8 } from "zod";
2401
+ import { z as z10 } from "zod";
1648
2402
  function registerUpdateSpec(server, apiClient) {
1649
2403
  server.registerTool(
1650
2404
  "update_spec",
@@ -1652,12 +2406,12 @@ function registerUpdateSpec(server, apiClient) {
1652
2406
  title: "Update Spec",
1653
2407
  description: "Update an existing SystemDox spec's proposal, tasks, or requirements. Only draft specs can be modified \u2014 published specs must be versioned first.",
1654
2408
  inputSchema: {
1655
- draft_id: z8.string().describe("ID of the spec to update"),
1656
- section: z8.enum(["proposal", "tasks", "requirements"]).describe("Which section to update"),
1657
- content: z8.string().describe(
2409
+ draft_id: z10.string().describe("ID of the spec to update"),
2410
+ section: z10.enum(["proposal", "tasks", "requirements"]).describe("Which section to update"),
2411
+ content: z10.string().describe(
1658
2412
  "New markdown content for the section. For requirements, provide the full requirement content."
1659
2413
  ),
1660
- requirement_name: z8.string().optional().describe(
2414
+ requirement_name: z10.string().optional().describe(
1661
2415
  "Name of the requirement (required when section is 'requirements')"
1662
2416
  )
1663
2417
  },
@@ -1680,238 +2434,25 @@ function registerUpdateSpec(server, apiClient) {
1680
2434
  }
1681
2435
  const body = {};
1682
2436
  if (section === "proposal") {
1683
- body.proposalContent = content;
1684
- } else if (section === "tasks") {
1685
- body.taskContent = content;
1686
- } else if (section === "requirements") {
1687
- const existing = spec.requirements;
1688
- const newReq = {
1689
- name: requirement_name ?? "Untitled Requirement",
1690
- content
1691
- };
1692
- body.requirements = [...existing, newReq];
1693
- }
1694
- await apiClient.put(`/v1/specs/${draft_id}`, body);
1695
- apiClient.invalidateCache(`/v1/specs/${draft_id}`);
1696
- apiClient.invalidateCache("/v1/specs");
1697
- return {
1698
- content: [
1699
- {
1700
- type: "text",
1701
- text: `Updated spec "${spec.name}" \u2014 ${section} section modified.`
1702
- }
1703
- ]
1704
- };
1705
- }
1706
- );
1707
- }
1708
-
1709
- // src/tools/report-decision.ts
1710
- import { z as z9 } from "zod";
1711
- function registerReportDecision(server, apiClient) {
1712
- server.registerTool(
1713
- "report_decision",
1714
- {
1715
- title: "Report Decision",
1716
- description: "Record an architecture decision made during development. Creates a draft ADR in SystemDox for human review.",
1717
- inputSchema: {
1718
- title: z9.string().describe("Decision title (e.g., 'Use WebSocket for real-time updates')"),
1719
- context: z9.string().describe("What prompted this decision \u2014 the problem or constraint"),
1720
- decision: z9.string().describe("What was decided and why"),
1721
- consequences: z9.string().describe("What this decision means going forward \u2014 trade-offs, follow-up work"),
1722
- related_files: z9.array(z9.string()).optional().describe("File paths related to this decision")
1723
- },
1724
- annotations: {
1725
- readOnlyHint: false
1726
- }
1727
- },
1728
- async ({ title, context, decision, consequences, related_files }) => {
1729
- const adrTitle = `ADR: ${title}`;
1730
- const created = await apiClient.post("/v1/specs", {
1731
- name: adrTitle
1732
- });
1733
- const relatedSection = related_files && related_files.length > 0 ? `
1734
-
1735
- ## Related Files
1736
-
1737
- ${related_files.map((f) => `- \`${f}\``).join("\n")}` : "";
1738
- const adrContent = `# ${adrTitle}
1739
-
1740
- **Date:** ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}
1741
- **Status:** Draft (pending review)
1742
-
1743
- ## Context
1744
-
1745
- ${context}
1746
-
1747
- ## Decision
1748
-
1749
- ${decision}
1750
-
1751
- ## Consequences
1752
-
1753
- ${consequences}` + relatedSection;
1754
- await apiClient.put(`/v1/specs/${created.draftId}`, {
1755
- proposalContent: adrContent
1756
- });
1757
- apiClient.invalidateCache("/v1/specs");
1758
- return {
1759
- content: [
1760
- {
1761
- type: "text",
1762
- text: `Architecture decision recorded: **${adrTitle}** (spec id: ${created.draftId})
1763
-
1764
- Status: draft \u2014 review in SystemDox before publishing.`
1765
- }
1766
- ]
1767
- };
1768
- }
1769
- );
1770
- }
1771
-
1772
- // src/tools/get-project-rules.ts
1773
- import { z as z10 } from "zod";
1774
- var CATEGORY_LABELS = {
1775
- deployment: "Deployment",
1776
- architecture: "Architecture",
1777
- security: "Security",
1778
- testing: "Testing",
1779
- process: "Process",
1780
- other: "Other"
1781
- };
1782
- function registerGetProjectRules(server, apiClient) {
1783
- server.registerTool(
1784
- "get_project_rules",
1785
- {
1786
- title: "Get Project Rules",
1787
- description: "Retrieve project rules defined in SystemDox Standards. These are team-defined principles covering deployment, architecture, security, testing, and process. Filter by category to get only relevant rules.",
1788
- inputSchema: {
1789
- category: z10.enum(["deployment", "architecture", "security", "testing", "process", "other"]).optional().describe("Filter by rule category")
1790
- }
1791
- },
1792
- async ({ category }) => {
1793
- const rules = await apiClient.get(
1794
- "/v1/standards/rules"
1795
- );
1796
- let filtered = rules;
1797
- if (category) {
1798
- filtered = filtered.filter(
1799
- (r) => r.category.toLowerCase() === category.toLowerCase()
1800
- );
1801
- }
1802
- if (filtered.length === 0) {
1803
- const text4 = rules.length === 0 ? `No project rules found. Add rules at ${configLink("/docs/rules", await workspaceBase(apiClient))}` : `No project rules found${category ? ` in category "${category}"` : ""}.`;
1804
- return {
1805
- content: [{ type: "text", text: text4 }]
1806
- };
1807
- }
1808
- const grouped = /* @__PURE__ */ new Map();
1809
- for (const rule of filtered) {
1810
- const cat = rule.category || "other";
1811
- if (!grouped.has(cat)) grouped.set(cat, []);
1812
- grouped.get(cat).push(rule);
1813
- }
1814
- let text3 = `Found ${filtered.length} project rule(s):
1815
-
1816
- `;
1817
- for (const [cat, catRules] of grouped) {
1818
- const label = CATEGORY_LABELS[cat] ?? cat;
1819
- text3 += `## ${label}
1820
-
1821
- `;
1822
- for (const rule of catRules) {
1823
- text3 += `- ${rule.text}
1824
- `;
1825
- }
1826
- text3 += "\n";
1827
- }
1828
- return {
1829
- content: [
1830
- {
1831
- type: "text",
1832
- text: text3
1833
- }
1834
- ]
1835
- };
1836
- }
1837
- );
1838
- }
1839
-
1840
- // src/tools/create-project-rule.ts
1841
- import { z as z11 } from "zod";
1842
- function registerCreateProjectRule(server, apiClient) {
1843
- server.registerTool(
1844
- "create_project_rule",
1845
- {
1846
- title: "Create Project Rule",
1847
- description: "Create a new project rule in SystemDox Standards. Use this when you discover a team principle or convention that should be enforced across the project.",
1848
- inputSchema: {
1849
- text: z11.string().describe(
1850
- "The rule statement \u2014 one enforceable principle per rule (e.g. 'All deployments MUST go through GitHub Actions workflows')"
1851
- ),
1852
- category: z11.enum(["deployment", "architecture", "security", "testing", "process", "other"]).describe("Rule category")
1853
- },
1854
- annotations: {
1855
- readOnlyHint: false
1856
- }
1857
- },
1858
- async ({ text: text3, category }) => {
1859
- const saved = await apiClient.post(
1860
- "/v1/standards/rules",
1861
- { text: text3, category }
1862
- );
1863
- apiClient.invalidateCache("/v1/standards/rules");
1864
- return {
1865
- content: [
1866
- {
1867
- type: "text",
1868
- text: `Project rule created: **${saved.text}** (id: ${saved.id}, category: ${saved.category})`
1869
- }
1870
- ]
1871
- };
1872
- }
1873
- );
1874
- }
1875
-
1876
- // src/tools/get-prompt-templates.ts
1877
- function registerGetPromptTemplates(server, apiClient) {
1878
- server.registerTool(
1879
- "get_prompt_templates",
1880
- {
1881
- title: "Get Prompt Templates",
1882
- description: "Retrieve reusable prompt templates from SystemDox Standards. These are preconfigured prompts that Gen AI should use when generating content \u2014 e.g. code review checklists, documentation templates, or analysis frameworks.",
1883
- inputSchema: {}
1884
- },
1885
- async () => {
1886
- const templates = await apiClient.get(
1887
- "/v1/standards/prompt-templates"
1888
- );
1889
- if (templates.length === 0) {
1890
- return {
1891
- content: [
1892
- {
1893
- type: "text",
1894
- text: `No prompt templates found. Add templates at ${configLink("/docs/prompts", await workspaceBase(apiClient))}`
1895
- }
1896
- ]
2437
+ body.proposalContent = content;
2438
+ } else if (section === "tasks") {
2439
+ body.taskContent = content;
2440
+ } else if (section === "requirements") {
2441
+ const existing = spec.requirements;
2442
+ const newReq = {
2443
+ name: requirement_name ?? "Untitled Requirement",
2444
+ content
1897
2445
  };
2446
+ body.requirements = [...existing, newReq];
1898
2447
  }
1899
- const text3 = templates.map(
1900
- (t) => `## ${t.name}
1901
-
1902
- ` + (t.description ? `*${t.description}*
1903
-
1904
- ` : "") + `\`\`\`
1905
- ${t.prompt}
1906
- \`\`\``
1907
- ).join("\n\n---\n\n");
2448
+ await apiClient.put(`/v1/specs/${draft_id}`, body);
2449
+ apiClient.invalidateCache(`/v1/specs/${draft_id}`);
2450
+ apiClient.invalidateCache("/v1/specs");
1908
2451
  return {
1909
2452
  content: [
1910
2453
  {
1911
2454
  type: "text",
1912
- text: `Found ${templates.length} prompt template(s):
1913
-
1914
- ${text3}`
2455
+ text: `Updated spec "${spec.name}" \u2014 ${section} section modified.`
1915
2456
  }
1916
2457
  ]
1917
2458
  };
@@ -1919,38 +2460,62 @@ ${text3}`
1919
2460
  );
1920
2461
  }
1921
2462
 
1922
- // src/tools/create-prompt-template.ts
1923
- import { z as z12 } from "zod";
1924
- function registerCreatePromptTemplate(server, apiClient) {
2463
+ // src/tools/report-decision.ts
2464
+ import { z as z11 } from "zod";
2465
+ function registerReportDecision(server, apiClient) {
1925
2466
  server.registerTool(
1926
- "create_prompt_template",
2467
+ "report_decision",
1927
2468
  {
1928
- title: "Create Prompt Template",
1929
- description: "Create a new reusable prompt template in SystemDox Standards. Use this to store preconfigured prompts for common AI tasks like code reviews, documentation generation, or analysis frameworks.",
2469
+ title: "Report Decision",
2470
+ description: "Record an architecture decision made during development. Creates a draft ADR in SystemDox for human review.",
1930
2471
  inputSchema: {
1931
- name: z12.string().describe("Short name for the template"),
1932
- description: z12.string().optional().describe("Brief description of when to use this template"),
1933
- prompt: z12.string().describe("The full prompt text that Gen AI will use when generating content")
2472
+ title: z11.string().describe("Decision title (e.g., 'Use WebSocket for real-time updates')"),
2473
+ context: z11.string().describe("What prompted this decision \u2014 the problem or constraint"),
2474
+ decision: z11.string().describe("What was decided and why"),
2475
+ consequences: z11.string().describe("What this decision means going forward \u2014 trade-offs, follow-up work"),
2476
+ related_files: z11.array(z11.string()).optional().describe("File paths related to this decision")
1934
2477
  },
1935
2478
  annotations: {
1936
2479
  readOnlyHint: false
1937
2480
  }
1938
2481
  },
1939
- async ({ name, description, prompt }) => {
1940
- const saved = await apiClient.post(
1941
- "/v1/standards/prompt-templates",
1942
- {
1943
- name,
1944
- description: description ?? "",
1945
- prompt
1946
- }
1947
- );
1948
- apiClient.invalidateCache("/v1/standards/prompt-templates");
2482
+ async ({ title, context, decision, consequences, related_files }) => {
2483
+ const adrTitle = `ADR: ${title}`;
2484
+ const created = await apiClient.post("/v1/specs", {
2485
+ name: adrTitle
2486
+ });
2487
+ const relatedSection = related_files && related_files.length > 0 ? `
2488
+
2489
+ ## Related Files
2490
+
2491
+ ${related_files.map((f) => `- \`${f}\``).join("\n")}` : "";
2492
+ const adrContent = `# ${adrTitle}
2493
+
2494
+ **Date:** ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}
2495
+ **Status:** Draft (pending review)
2496
+
2497
+ ## Context
2498
+
2499
+ ${context}
2500
+
2501
+ ## Decision
2502
+
2503
+ ${decision}
2504
+
2505
+ ## Consequences
2506
+
2507
+ ${consequences}` + relatedSection;
2508
+ await apiClient.put(`/v1/specs/${created.draftId}`, {
2509
+ proposalContent: adrContent
2510
+ });
2511
+ apiClient.invalidateCache("/v1/specs");
1949
2512
  return {
1950
2513
  content: [
1951
2514
  {
1952
2515
  type: "text",
1953
- text: `Prompt template created: **${saved.name}** (id: ${saved.id})`
2516
+ text: `Architecture decision recorded: **${adrTitle}** (spec id: ${created.draftId})
2517
+
2518
+ Status: draft \u2014 review in SystemDox before publishing.`
1954
2519
  }
1955
2520
  ]
1956
2521
  };
@@ -1958,217 +2523,210 @@ function registerCreatePromptTemplate(server, apiClient) {
1958
2523
  );
1959
2524
  }
1960
2525
 
1961
- // src/tools/skills.ts
1962
- import { z as z13 } from "zod";
1963
- import { homedir as homedir2 } from "os";
1964
- import { basename, resolve as resolve2 } from "path";
1965
-
1966
- // src/lib/skills.ts
1967
- import { createHash } from "crypto";
1968
- import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
1969
- import { existsSync } from "fs";
1970
- import { homedir } from "os";
1971
- import { dirname, join, relative, resolve, sep } from "path";
1972
- var MANIFEST_FILE = ".systemdox-skill.json";
1973
- var ENTRY_FILE = "SKILL.md";
1974
- var MAX_FILES = 100;
1975
- var MAX_FILE_BYTES = 256 * 1024;
1976
- var MAX_TOTAL_BYTES = 1024 * 1024;
1977
- var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1978
- var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
1979
- var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
1980
- var SkillInstallRefused = class extends Error {
1981
- constructor(dest, edited) {
1982
- super(
1983
- `${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
1984
- );
1985
- this.dest = dest;
1986
- this.edited = edited;
1987
- }
1988
- dest;
1989
- edited;
2526
+ // src/tools/get-project-rules.ts
2527
+ import { z as z12 } from "zod";
2528
+ var CATEGORY_LABELS = {
2529
+ deployment: "Deployment",
2530
+ architecture: "Architecture",
2531
+ security: "Security",
2532
+ testing: "Testing",
2533
+ process: "Process",
2534
+ other: "Other"
1990
2535
  };
1991
- function sha256(content) {
1992
- return createHash("sha256").update(content, "utf8").digest("hex");
1993
- }
1994
- function isValidName(name) {
1995
- return name.length <= 64 && NAME_RE.test(name);
1996
- }
1997
- function isValidPath(path) {
1998
- return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..");
1999
- }
2000
- function frontmatterName(skillMd) {
2001
- const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
2002
- if (!block) return void 0;
2003
- const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
2004
- return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
2005
- }
2006
- function defaultSkillDir(name) {
2007
- return join(homedir(), ".claude", "skills", name);
2008
- }
2009
- async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
2010
- for (const entry of await readdir(dir, { withFileTypes: true })) {
2011
- if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
2012
- const full = join(dir, entry.name);
2013
- const rel = relative(root, full).split(sep).join("/");
2014
- if (entry.isSymbolicLink()) out.links.push(rel);
2015
- else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
2016
- else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
2017
- else if (entry.isFile()) out.files.push(rel);
2018
- }
2019
- out.files.sort();
2020
- return out;
2021
- }
2022
- async function readTextFiles(root, paths, what) {
2023
- if (paths.length > MAX_FILES) {
2024
- throw new Error(`${root} holds ${paths.length} files: ${what} holds at most ${MAX_FILES}`);
2025
- }
2026
- const decoder = new TextDecoder("utf-8", { fatal: true });
2027
- const files = [];
2028
- let total = 0;
2029
- for (const path of paths) {
2030
- if (!isValidPath(path)) {
2031
- throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
2032
- }
2033
- const raw = await readFile(join(root, ...path.split("/")));
2034
- if (raw.includes(0)) throw new Error(`${path} is binary: ${what} holds text files only`);
2035
- if (raw.length > MAX_FILE_BYTES) {
2036
- throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
2037
- }
2038
- total += raw.length;
2039
- if (total > MAX_TOTAL_BYTES) {
2040
- throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
2041
- }
2042
- let content;
2043
- try {
2044
- content = decoder.decode(raw);
2045
- } catch {
2046
- throw new Error(`${path} is not UTF-8 text`);
2047
- }
2048
- files.push({ path, content });
2049
- }
2050
- return files;
2051
- }
2052
- async function readSkillDirectory(directory) {
2053
- const root = resolve(directory);
2054
- if (!existsSync(join(root, ENTRY_FILE))) {
2055
- throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
2056
- }
2057
- const walked = await walk(root, { skipHidden: true });
2058
- if (walked.links.length) {
2059
- throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
2060
- }
2061
- const files = await readTextFiles(root, walked.files, "a skill");
2062
- const skillMd = files.find((f) => f.path === ENTRY_FILE);
2063
- const name = frontmatterName(skillMd.content);
2064
- if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
2065
- return { name, files, skipped: walked.hidden };
2066
- }
2067
- function within(dest, path) {
2068
- if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
2069
- const full = resolve(dest, ...path.split("/"));
2070
- if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
2071
- return full;
2072
- }
2073
- async function readManifest(dest) {
2074
- try {
2075
- return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
2076
- } catch {
2077
- return void 0;
2078
- }
2079
- }
2080
- async function installSkill(version, dest, { overwrite = false } = {}) {
2081
- if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
2082
- const incoming = new Map(version.files.map((f) => [f.path, f]));
2083
- for (const f of version.files) within(dest, f.path);
2084
- if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
2085
- throw new Error(`${dest} is a link: install into a real folder`);
2086
- }
2087
- const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
2088
- if (walked.links.length) {
2089
- throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
2090
- }
2091
- const present = walked.files;
2092
- const manifest = await readManifest(dest);
2093
- const known = Object.fromEntries(
2094
- Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
2095
- );
2096
- const edited = [];
2097
- const untracked = [];
2098
- for (const path of present) {
2099
- const content = await readFile(join(dest, ...path.split("/")), "utf8");
2100
- if (Object.hasOwn(known, path)) {
2101
- if (sha256(content) !== known[path]) edited.push(path);
2102
- } else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
2103
- if (incoming.has(path) || !manifest) edited.push(path);
2104
- else untracked.push(path);
2536
+ function registerGetProjectRules(server, apiClient) {
2537
+ server.registerTool(
2538
+ "get_project_rules",
2539
+ {
2540
+ title: "Get Project Rules",
2541
+ description: "Retrieve project rules defined in SystemDox Standards. These are team-defined principles covering deployment, architecture, security, testing, and process. Filter by category to get only relevant rules.",
2542
+ inputSchema: {
2543
+ category: z12.enum(["deployment", "architecture", "security", "testing", "process", "other"]).optional().describe("Filter by rule category")
2544
+ }
2545
+ },
2546
+ async ({ category }) => {
2547
+ const rules = await apiClient.get(
2548
+ "/v1/standards/rules"
2549
+ );
2550
+ let filtered = rules;
2551
+ if (category) {
2552
+ filtered = filtered.filter(
2553
+ (r) => r.category.toLowerCase() === category.toLowerCase()
2554
+ );
2555
+ }
2556
+ if (filtered.length === 0) {
2557
+ const text4 = rules.length === 0 ? `No project rules found. Add rules at ${configLink("/docs/rules", await workspaceBase(apiClient))}` : `No project rules found${category ? ` in category "${category}"` : ""}.`;
2558
+ return {
2559
+ content: [{ type: "text", text: text4 }]
2560
+ };
2561
+ }
2562
+ const grouped = /* @__PURE__ */ new Map();
2563
+ for (const rule of filtered) {
2564
+ const cat = rule.category || "other";
2565
+ if (!grouped.has(cat)) grouped.set(cat, []);
2566
+ grouped.get(cat).push(rule);
2567
+ }
2568
+ let text3 = `Found ${filtered.length} project rule(s):
2569
+
2570
+ `;
2571
+ for (const [cat, catRules] of grouped) {
2572
+ const label = CATEGORY_LABELS[cat] ?? cat;
2573
+ text3 += `## ${label}
2574
+
2575
+ `;
2576
+ for (const rule of catRules) {
2577
+ text3 += `- ${rule.text}
2578
+ `;
2579
+ }
2580
+ text3 += "\n";
2581
+ }
2582
+ return {
2583
+ content: [
2584
+ {
2585
+ type: "text",
2586
+ text: text3
2587
+ }
2588
+ ]
2589
+ };
2105
2590
  }
2106
- }
2107
- const blocking = edited.filter((p) => incoming.has(p) || !manifest);
2108
- if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
2109
- const written = [];
2110
- for (const f of version.files) {
2111
- const full = within(dest, f.path);
2112
- await mkdir(dirname(full), { recursive: true });
2113
- await writeFile(full, f.content, "utf8");
2114
- written.push(f.path);
2115
- }
2116
- const removed = [];
2117
- const keptEdited = [];
2118
- for (const path of Object.keys(known)) {
2119
- if (incoming.has(path) || !present.includes(path)) continue;
2120
- if (edited.includes(path)) {
2121
- keptEdited.push(path);
2122
- continue;
2591
+ );
2592
+ }
2593
+
2594
+ // src/tools/create-project-rule.ts
2595
+ import { z as z13 } from "zod";
2596
+ function registerCreateProjectRule(server, apiClient) {
2597
+ server.registerTool(
2598
+ "create_project_rule",
2599
+ {
2600
+ title: "Create Project Rule",
2601
+ description: "Create a new project rule in SystemDox Standards. Use this when you discover a team principle or convention that should be enforced across the project.",
2602
+ inputSchema: {
2603
+ text: z13.string().describe(
2604
+ "The rule statement \u2014 one enforceable principle per rule (e.g. 'All deployments MUST go through GitHub Actions workflows')"
2605
+ ),
2606
+ category: z13.enum(["deployment", "architecture", "security", "testing", "process", "other"]).describe("Rule category")
2607
+ },
2608
+ annotations: {
2609
+ readOnlyHint: false
2610
+ }
2611
+ },
2612
+ async ({ text: text3, category }) => {
2613
+ const saved = await apiClient.post(
2614
+ "/v1/standards/rules",
2615
+ { text: text3, category }
2616
+ );
2617
+ apiClient.invalidateCache("/v1/standards/rules");
2618
+ return {
2619
+ content: [
2620
+ {
2621
+ type: "text",
2622
+ text: `Project rule created: **${saved.text}** (id: ${saved.id}, category: ${saved.category})`
2623
+ }
2624
+ ]
2625
+ };
2123
2626
  }
2124
- await rm(within(dest, path));
2125
- removed.push(path);
2126
- }
2127
- const next = {
2128
- name: version.name,
2129
- version: version.version,
2130
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2131
- files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
2132
- };
2133
- await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
2134
- return {
2135
- dest,
2136
- version: version.version,
2137
- written,
2138
- removed,
2139
- keptUntracked: untracked.filter((p) => !incoming.has(p)),
2140
- keptEdited
2141
- };
2627
+ );
2142
2628
  }
2143
- function fenceFor(text3) {
2144
- const longest = Math.max(0, ...(text3.match(/`+/g) ?? []).map((r) => r.length));
2145
- return "`".repeat(Math.max(3, longest + 1));
2629
+
2630
+ // src/tools/get-prompt-templates.ts
2631
+ function registerGetPromptTemplates(server, apiClient) {
2632
+ server.registerTool(
2633
+ "get_prompt_templates",
2634
+ {
2635
+ title: "Get Prompt Templates",
2636
+ description: "Retrieve reusable prompt templates from SystemDox Standards. These are preconfigured prompts that Gen AI should use when generating content \u2014 e.g. code review checklists, documentation templates, or analysis frameworks.",
2637
+ inputSchema: {}
2638
+ },
2639
+ async () => {
2640
+ const templates = await apiClient.get(
2641
+ "/v1/standards/prompt-templates"
2642
+ );
2643
+ if (templates.length === 0) {
2644
+ return {
2645
+ content: [
2646
+ {
2647
+ type: "text",
2648
+ text: `No prompt templates found. Add templates at ${configLink("/docs/prompts", await workspaceBase(apiClient))}`
2649
+ }
2650
+ ]
2651
+ };
2652
+ }
2653
+ const text3 = templates.map(
2654
+ (t) => `## ${t.name}
2655
+
2656
+ ` + (t.description ? `*${t.description}*
2657
+
2658
+ ` : "") + `\`\`\`
2659
+ ${t.prompt}
2660
+ \`\`\``
2661
+ ).join("\n\n---\n\n");
2662
+ return {
2663
+ content: [
2664
+ {
2665
+ type: "text",
2666
+ text: `Found ${templates.length} prompt template(s):
2667
+
2668
+ ${text3}`
2669
+ }
2670
+ ]
2671
+ };
2672
+ }
2673
+ );
2146
2674
  }
2147
- function describeInstall(name, r) {
2148
- const lines = [
2149
- `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
2150
- ];
2151
- if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
2152
- if (r.keptEdited.length) {
2153
- lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
2154
- }
2155
- if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
2156
- lines.push("Start a new Claude Code session to load it.");
2157
- return lines.join("\n");
2675
+
2676
+ // src/tools/create-prompt-template.ts
2677
+ import { z as z14 } from "zod";
2678
+ function registerCreatePromptTemplate(server, apiClient) {
2679
+ server.registerTool(
2680
+ "create_prompt_template",
2681
+ {
2682
+ title: "Create Prompt Template",
2683
+ description: "Create a new reusable prompt template in SystemDox Standards. Use this to store preconfigured prompts for common AI tasks like code reviews, documentation generation, or analysis frameworks.",
2684
+ inputSchema: {
2685
+ name: z14.string().describe("Short name for the template"),
2686
+ description: z14.string().optional().describe("Brief description of when to use this template"),
2687
+ prompt: z14.string().describe("The full prompt text that Gen AI will use when generating content")
2688
+ },
2689
+ annotations: {
2690
+ readOnlyHint: false
2691
+ }
2692
+ },
2693
+ async ({ name, description, prompt }) => {
2694
+ const saved = await apiClient.post(
2695
+ "/v1/standards/prompt-templates",
2696
+ {
2697
+ name,
2698
+ description: description ?? "",
2699
+ prompt
2700
+ }
2701
+ );
2702
+ apiClient.invalidateCache("/v1/standards/prompt-templates");
2703
+ return {
2704
+ content: [
2705
+ {
2706
+ type: "text",
2707
+ text: `Prompt template created: **${saved.name}** (id: ${saved.id})`
2708
+ }
2709
+ ]
2710
+ };
2711
+ }
2712
+ );
2158
2713
  }
2159
2714
 
2160
2715
  // src/tools/skills.ts
2716
+ import { z as z15 } from "zod";
2717
+ import { homedir as homedir3 } from "os";
2718
+ import { basename, resolve as resolve2 } from "path";
2161
2719
  var SKILLS = "/v1/standards/skills";
2162
2720
  function expandHome(path) {
2163
- return path.replace(/^~(?=$|[\\/])/, homedir2());
2721
+ return path.replace(/^~(?=$|[\\/])/, homedir3());
2164
2722
  }
2165
2723
  function text(t) {
2166
2724
  return { content: [{ type: "text", text: t }] };
2167
2725
  }
2168
2726
  function registerSkillTools(server, apiClient, { localFiles }) {
2169
- const fileSchema = z13.object({
2170
- path: z13.string().describe('Path inside the skill folder, forward slashes, e.g. "references/guide.md"'),
2171
- content: z13.string().describe("The file's full text, exactly as it is")
2727
+ const fileSchema = z15.object({
2728
+ path: z15.string().describe('Path inside the skill folder, forward slashes, e.g. "references/guide.md"'),
2729
+ content: z15.string().describe("The file's full text, exactly as it is")
2172
2730
  });
2173
2731
  server.registerTool(
2174
2732
  "publish_skill",
@@ -2177,13 +2735,13 @@ function registerSkillTools(server, apiClient, { localFiles }) {
2177
2735
  description: localFiles ? "Publish a Claude Code skill folder (SKILL.md plus its references and scripts) to SystemDox so it can be installed on any computer. Pass `directory` (e.g. ~/.claude/skills/<name>): the server reads the files itself, byte for byte. Publishing a name again makes a new version. Never retype the files into `files` when the folder is on this computer." : "Publish a Claude Code skill (SKILL.md plus its references and scripts) to SystemDox so it can be installed on any computer. Pass every file of the folder in `files`, exactly as it is. Publishing a name again makes a new version.",
2178
2736
  inputSchema: {
2179
2737
  ...localFiles ? {
2180
- directory: z13.string().optional().describe("The skill folder on this computer, holding SKILL.md at its root")
2738
+ directory: z15.string().optional().describe("The skill folder on this computer, holding SKILL.md at its root")
2181
2739
  } : {},
2182
- files: z13.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the skill folder"),
2183
- name: z13.string().optional().describe("The skill's name; defaults to SKILL.md's frontmatter name and must match it"),
2184
- spaceId: z13.string().optional().describe("Space for a NEW skill (default: your default space)"),
2185
- note: z13.string().max(500).optional().describe(`A short change note for this version, shown in the console's history (e.g. "Fix the GA4 step")`),
2186
- baseVersion: z13.number().int().positive().optional().describe(
2740
+ files: z15.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the skill folder"),
2741
+ name: z15.string().optional().describe("The skill's name; defaults to SKILL.md's frontmatter name and must match it"),
2742
+ spaceId: z15.string().optional().describe("Space for a NEW skill (default: your default space)"),
2743
+ note: z15.string().max(500).optional().describe(`A short change note for this version, shown in the console's history (e.g. "Fix the GA4 step")`),
2744
+ baseVersion: z15.number().int().positive().optional().describe(
2187
2745
  "The version these files were edited from. When given, the publish is refused (SYD-K017) if someone published a newer version since, instead of replacing their changes"
2188
2746
  )
2189
2747
  },
@@ -2233,7 +2791,7 @@ Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
2233
2791
  const { skills } = await apiClient.get(SKILLS);
2234
2792
  if (!skills.length) {
2235
2793
  return text(
2236
- `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/skills", await workspaceBase(apiClient))}`
2794
+ `No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/team-setup/skills", await workspaceBase(apiClient))}`
2237
2795
  );
2238
2796
  }
2239
2797
  return text(
@@ -2249,10 +2807,10 @@ Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
2249
2807
  title: "Install Skill",
2250
2808
  description: localFiles ? "Install a skill published to SystemDox onto this computer (default ~/.claude/skills/<name>/). Refuses, naming the files, when a file there was edited since it was installed, unless overwrite is true. Never runs anything." : "Fetch a skill published to SystemDox so you can install it: returns every file and the folder to write them into (default ~/.claude/skills/<name>/). Write each file exactly, then the manifest it shows.",
2251
2809
  inputSchema: {
2252
- name: z13.string().describe("The skill's name"),
2253
- version: z13.number().int().positive().optional().describe("A version number (default: the latest)"),
2254
- target: z13.string().optional().describe("Folder to install into, e.g. a repo's .claude/skills/<name>"),
2255
- ...localFiles ? { overwrite: z13.boolean().optional().describe("Replace files edited since they were installed") } : {}
2810
+ name: z15.string().describe("The skill's name"),
2811
+ version: z15.number().int().positive().optional().describe("A version number (default: the latest)"),
2812
+ target: z15.string().optional().describe("Folder to install into, e.g. a repo's .claude/skills/<name>"),
2813
+ ...localFiles ? { overwrite: z15.boolean().optional().describe("Replace files edited since they were installed") } : {}
2256
2814
  },
2257
2815
  annotations: { readOnlyHint: !localFiles }
2258
2816
  },
@@ -2298,11 +2856,11 @@ ${body}`
2298
2856
  }
2299
2857
 
2300
2858
  // src/tools/team-setup.ts
2301
- import { z as z14 } from "zod";
2302
- import { existsSync as existsSync3 } from "fs";
2859
+ import { z as z16 } from "zod";
2860
+ import { existsSync as existsSync4 } from "fs";
2303
2861
  import { lstat as lstat2 } from "fs/promises";
2304
2862
  import { homedir as homedir4 } from "os";
2305
- import { join as join4, resolve as resolve4 } from "path";
2863
+ import { join as join5, resolve as resolve4 } from "path";
2306
2864
 
2307
2865
  // src/lib/person-values.ts
2308
2866
  var PHONE = "phone";
@@ -2379,13 +2937,13 @@ function findSecrets(text3) {
2379
2937
  }
2380
2938
 
2381
2939
  // src/lib/team-setup-types.ts
2382
- var PARTS = ["hooks", "mcp", "agents", "commands", "instructions"];
2940
+ var PARTS2 = ["hooks", "mcp", "agents", "commands", "instructions"];
2383
2941
  var PERSON_ID_RE = new RegExp(`^${PERSON_ID}$`);
2384
2942
 
2385
2943
  // src/sync-memory.ts
2386
- import { existsSync as existsSync2 } from "fs";
2387
- import { mkdir as mkdir3, readdir as readdir2, readFile as readFile3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
2388
- import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
2944
+ import { existsSync as existsSync3 } from "fs";
2945
+ import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
2946
+ import { dirname as dirname3, join as join4, resolve as resolve3 } from "path";
2389
2947
 
2390
2948
  // src/lib/memory-sync.ts
2391
2949
  import { createHash as createHash2 } from "crypto";
@@ -2656,63 +3214,6 @@ function lineDiff(before, after) {
2656
3214
  return out;
2657
3215
  }
2658
3216
 
2659
- // src/lib/team-setup-files.ts
2660
- import { execFile } from "child_process";
2661
- import { chmod, mkdir as mkdir2, readFile as readFile2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
2662
- import { homedir as homedir3, userInfo } from "os";
2663
- import { dirname as dirname2, join as join2 } from "path";
2664
- import { promisify } from "util";
2665
- var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2666
- function isWorkspace(value) {
2667
- return typeof value === "string" && value.length <= 64 && SLUG_RE.test(value);
2668
- }
2669
- function homeDir() {
2670
- return homedir3();
2671
- }
2672
- function systemdoxDir() {
2673
- return join2(homeDir(), ".claude", "systemdox");
2674
- }
2675
- function workspaceDir(workspace) {
2676
- if (!isWorkspace(workspace)) throw new Error(`${JSON.stringify(workspace)} is not a workspace name`);
2677
- return join2(systemdoxDir(), workspace);
2678
- }
2679
- function statePath(workspace) {
2680
- return join2(workspaceDir(workspace), "state.json");
2681
- }
2682
- async function readJson(path, empty) {
2683
- let text3;
2684
- try {
2685
- text3 = await readFile2(path, "utf8");
2686
- } catch (e) {
2687
- if (e.code === "ENOENT") return empty;
2688
- throw e;
2689
- }
2690
- try {
2691
- return JSON.parse(text3);
2692
- } catch {
2693
- throw new Error(`${path} is not valid JSON: fix or remove it, then run this again`);
2694
- }
2695
- }
2696
- async function writeJsonAtomically(path, value, mode) {
2697
- await mkdir2(dirname2(path), { recursive: true });
2698
- const tmp = `${path}.${process.pid}.tmp`;
2699
- await writeFile2(tmp, JSON.stringify(value, null, 2) + "\n", mode === void 0 ? "utf8" : { encoding: "utf8", mode });
2700
- await rename(tmp, path);
2701
- }
2702
- var run = promisify(execFile);
2703
- async function readState(workspace) {
2704
- return readJson(statePath(workspace), {});
2705
- }
2706
- async function writeState(workspace, state) {
2707
- await writeJsonAtomically(statePath(workspace), state);
2708
- }
2709
- async function updateState(workspace, change) {
2710
- const state = await readState(workspace);
2711
- change(state);
2712
- await writeState(workspace, state);
2713
- return state;
2714
- }
2715
-
2716
3217
  // src/sync-memory.ts
2717
3218
  var SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
2718
3219
  var SyncFailed = class extends Error {
@@ -2722,7 +3223,7 @@ function refusal(e) {
2722
3223
  const { status, code } = e;
2723
3224
  return { status: typeof status === "number" ? status : void 0, code: typeof code === "string" ? code : void 0 };
2724
3225
  }
2725
- var toPath = (root, rel) => join3(root, ...rel.split("/"));
3226
+ var toPath = (root, rel) => join4(root, ...rel.split("/"));
2726
3227
  async function readFolder(root) {
2727
3228
  const files = {};
2728
3229
  const names = /* @__PURE__ */ new Set();
@@ -2730,14 +3231,14 @@ async function readFolder(root) {
2730
3231
  for (const e of await readdir2(dir, { withFileTypes: true })) {
2731
3232
  if (e.name.startsWith(".")) continue;
2732
3233
  const rel = prefix ? `${prefix}/${e.name}` : e.name;
2733
- if (e.isDirectory()) await walk2(join3(dir, e.name), rel);
3234
+ if (e.isDirectory()) await walk2(join4(dir, e.name), rel);
2734
3235
  else if (e.isFile()) {
2735
3236
  names.add(rel.toLowerCase());
2736
- if (e.name.endsWith(".md")) files[rel] = await readFile3(join3(dir, e.name), "utf8");
3237
+ if (e.name.endsWith(".md")) files[rel] = await readFile4(join4(dir, e.name), "utf8");
2737
3238
  }
2738
3239
  }
2739
3240
  }
2740
- if (existsSync2(root)) await walk2(root, "");
3241
+ if (existsSync3(root)) await walk2(root, "");
2741
3242
  return { files, names };
2742
3243
  }
2743
3244
  async function pullTeam(client, ws, project) {
@@ -2946,11 +3447,11 @@ async function people(opts, ws, files, report, hint) {
2946
3447
  report.people.push({ kind: g.kind, answer: ANSWER_WORDS[result.stored[0].answer], label: result.stored[0].label, personId: result.stored[0].personId });
2947
3448
  break;
2948
3449
  }
2949
- const refused = result.refused[0];
2950
- if (refused?.code === "SYD-K021") {
2951
- out(` ${refused.detail}. Add them in Staff first, or answer p to file them.`);
3450
+ const refused2 = result.refused[0];
3451
+ if (refused2?.code === "SYD-K021") {
3452
+ out(` ${refused2.detail}. Add them in Staff first, or answer p to file them.`);
2952
3453
  choices = choices.filter((c) => c !== "m");
2953
- } else if (refused) out(` ${refused.detail}`);
3454
+ } else if (refused2) out(` ${refused2.detail}`);
2954
3455
  }
2955
3456
  }
2956
3457
  for (let i = 0; i < filed.length; i += 50) {
@@ -3094,7 +3595,7 @@ async function syncMemory(opts) {
3094
3595
  own.memory.length ? `${ws} has ${own.memory.length} team memories (${own.memory.map((m) => m.project).join(", ")}): name one with --project.` : `${ws} has no team memory yet: name the project to start with --project.`
3095
3596
  );
3096
3597
  }
3097
- const dir = resolve3(opts.dir ?? join3(workspaceDir(ws), "memory", project));
3598
+ const dir = resolve3(opts.dir ?? join4(workspaceDir(ws), "memory", project));
3098
3599
  const run2 = { ...opts, dir };
3099
3600
  const hint = [SYNC_MEMORY_COMMAND, `--project ${project}`, opts.dir ? `--dir "${opts.dir}"` : ""].filter(Boolean).join(" ");
3100
3601
  const report = new Report();
@@ -3244,21 +3745,21 @@ async function readPartDirectory(part, directory) {
3244
3745
  let present = true;
3245
3746
  for (let i = 1; i <= segments.length; i++) {
3246
3747
  const rel = segments.slice(0, i).join("/");
3247
- if (await isLink(join4(root, ...segments.slice(0, i)))) {
3748
+ if (await isLink(join5(root, ...segments.slice(0, i)))) {
3248
3749
  links.push(rel);
3249
3750
  present = false;
3250
3751
  break;
3251
3752
  }
3252
3753
  }
3253
- if (present && existsSync3(join4(root, ...segments))) paths.push(path);
3754
+ if (present && existsSync4(join5(root, ...segments))) paths.push(path);
3254
3755
  }
3255
3756
  for (const { dir, md } of layout.folders) {
3256
- const full = join4(root, dir);
3757
+ const full = join5(root, dir);
3257
3758
  if (await isLink(full)) {
3258
3759
  links.push(dir);
3259
3760
  continue;
3260
3761
  }
3261
- if (!existsSync3(full)) continue;
3762
+ if (!existsSync4(full)) continue;
3262
3763
  const walked = await walk(full, { skipHidden: true });
3263
3764
  links.push(...walked.links.map((p) => `${dir}/${p}`));
3264
3765
  leftOut.push(...walked.hidden.map((p) => `${dir}/${p}`));
@@ -3298,9 +3799,9 @@ function describePerson(id, person) {
3298
3799
  return lines.join("\n");
3299
3800
  }
3300
3801
  function registerTeamSetupTools(server, apiClient, { localFiles }) {
3301
- const fileSchema = z14.object({
3302
- path: z14.string().describe(`Path inside the part's folder, forward slashes, e.g. "hooks/hooks.json"`),
3303
- content: z14.string().describe("The file's full text, exactly as it is")
3802
+ const fileSchema = z16.object({
3803
+ path: z16.string().describe(`Path inside the part's folder, forward slashes, e.g. "hooks/hooks.json"`),
3804
+ content: z16.string().describe("The file's full text, exactly as it is")
3304
3805
  });
3305
3806
  server.registerTool(
3306
3807
  "publish_setup_part",
@@ -3308,13 +3809,13 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
3308
3809
  title: "Publish Setup Part",
3309
3810
  description: "Publish one part of your team's Claude Code setup to SystemDox: `hooks` (hooks/hooks.json and the scripts/ it calls), `mcp` (.mcp.json), `agents` (agents/*.md), `commands` (commands/*.md) or `instructions` (CLAUDE.md). Every part but instructions makes a new version of the team plugin, which each member accepts at their next update. Publishing hooks, mcp, agents or commands needs the admin role. " + (localFiles ? "Pass `directory`: the server reads only the files the part accepts, byte for byte, and refuses a link, a binary file or a file too large before anything is sent. Never retype the files into `files` when the folder is on this computer." : "Pass every file of the part in `files`, exactly as it is."),
3310
3811
  inputSchema: {
3311
- part: z14.enum(PARTS).describe("Which part of the setup"),
3812
+ part: z16.enum(PARTS2).describe("Which part of the setup"),
3312
3813
  ...localFiles ? {
3313
- directory: z14.string().optional().describe(
3814
+ directory: z16.string().optional().describe(
3314
3815
  "The folder on this computer: for hooks, the one holding hooks/hooks.json and scripts/; for mcp, the one holding .mcp.json; for instructions, the one holding CLAUDE.md; for agents or commands, the one holding agents/ or commands/"
3315
3816
  )
3316
3817
  } : {},
3317
- files: z14.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the part")
3818
+ files: z16.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the part")
3318
3819
  },
3319
3820
  annotations: { readOnlyHint: false }
3320
3821
  },
@@ -3350,7 +3851,7 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
3350
3851
  title: "Resolve Person",
3351
3852
  description: "Read back a person that a team memory names by identifier, written as `con_\u2026` (label), `ctc_\u2026` or `usr_\u2026`: their label, name, phone number and email address, from the vault that holds them. The details are for this session only: never write them into a memory file; the memory keeps the identifier. Writes nothing.",
3352
3853
  inputSchema: {
3353
- id: z14.string().regex(PERSON_ID_RE, "a con_, ctc_ or usr_ identifier, as written in the memory").describe("The identifier, e.g. con_01JC2Q8Y4M7T0X3K5N9P2R6S8V")
3854
+ id: z16.string().regex(PERSON_ID_RE, "a con_, ctc_ or usr_ identifier, as written in the memory").describe("The identifier, e.g. con_01JC2Q8Y4M7T0X3K5N9P2R6S8V")
3354
3855
  },
3355
3856
  annotations: { readOnlyHint: true }
3356
3857
  },
@@ -3367,8 +3868,8 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
3367
3868
  title: "Sync Memory",
3368
3869
  description: `Sync one project's team memory on this computer with SystemDox: pull the files the team changed, push the ones changed here, keep both texts of a file changed on both sides. It never answers for a person: a file that still holds a phone number, an email address or a home-folder path is skipped and listed, with the command for the member to run in their own terminal (\`${SYNC_MEMORY_COMMAND}\`). Pass dryRun to see what it would do without writing or sending anything.`,
3369
3870
  inputSchema: {
3370
- project: z14.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "a project name: lower case letters, digits and -").optional().describe("The project's team memory, e.g. puglieseweb-ltd; needed when the workspace has more than one"),
3371
- dryRun: z14.boolean().optional().describe("Show what would be pulled, pushed, deleted, conflicted and skipped, and change nothing")
3871
+ project: z16.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "a project name: lower case letters, digits and -").optional().describe("The project's team memory, e.g. puglieseweb-ltd; needed when the workspace has more than one"),
3872
+ dryRun: z16.boolean().optional().describe("Show what would be pulled, pushed, deleted, conflicted and skipped, and change nothing")
3372
3873
  },
3373
3874
  annotations: { readOnlyHint: false }
3374
3875
  },
@@ -3386,21 +3887,27 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
3386
3887
  }
3387
3888
 
3388
3889
  // src/server.ts
3389
- var SERVER_VERSION = true ? "0.1.48" : "0.0.0-dev";
3390
- function createServer(apiClient, { localFiles = false } = {}) {
3391
- const server = new McpServer({
3392
- name: "systemdox-mcp-server",
3393
- version: SERVER_VERSION
3394
- });
3890
+ var SERVER_VERSION = true ? "0.1.50" : "0.0.0-dev";
3891
+ function createServer(apiClient, { localFiles = false, instructions, keyProblem } = {}) {
3892
+ const server = new McpServer(
3893
+ {
3894
+ name: "systemdox-mcp-server",
3895
+ version: SERVER_VERSION
3896
+ },
3897
+ instructions ? { instructions } : void 0
3898
+ );
3395
3899
  registerGetGuardrails(server, apiClient);
3396
3900
  registerGetSpecs(server, apiClient);
3397
3901
  registerGetDocs(server, apiClient);
3398
3902
  registerGetTechStack(server, apiClient);
3399
3903
  registerGetPlanningContext(server, apiClient);
3400
3904
  registerGetPlanBoard(server, apiClient);
3905
+ registerGetChange(server, apiClient);
3401
3906
  registerGetContext(server, apiClient);
3402
3907
  registerGetProjectRules(server, apiClient);
3403
3908
  registerGetPromptTemplates(server, apiClient);
3909
+ registerGetTeamSetup(server, apiClient, { localFiles, keyProblem });
3910
+ registerDocumentGraph(server, apiClient);
3404
3911
  registerCreateGuardrail(server, apiClient);
3405
3912
  registerUpdateSpec(server, apiClient);
3406
3913
  registerReportDecision(server, apiClient);
@@ -3420,6 +3927,14 @@ function lowercaseHeaders(headers) {
3420
3927
  }
3421
3928
  return out;
3422
3929
  }
3930
+ function opensSession(rawBody) {
3931
+ try {
3932
+ const parsed = JSON.parse(rawBody);
3933
+ return (Array.isArray(parsed) ? parsed : [parsed]).some((m) => m?.method === "initialize");
3934
+ } catch {
3935
+ return false;
3936
+ }
3937
+ }
3423
3938
  function jsonError(statusCode, message) {
3424
3939
  return {
3425
3940
  statusCode,
@@ -3473,7 +3988,8 @@ async function handler(event) {
3473
3988
  // per-request client; never cache across invocations
3474
3989
  defaultOrg: void 0
3475
3990
  });
3476
- const server = createServer(apiClient);
3991
+ const instructions = opensSession(rawBody) ? await sessionInstructions(apiClient) : void 0;
3992
+ const server = createServer(apiClient, { instructions });
3477
3993
  const transport = new WebStandardStreamableHTTPServerTransport({
3478
3994
  sessionIdGenerator: void 0,
3479
3995
  // stateless — every POST is independent