@systemdox/mcp-server 0.1.54 → 0.1.56

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
@@ -1,8 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/http.ts
4
- import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5
-
6
3
  // src/lib/cache.ts
7
4
  var ResponseCache = class {
8
5
  store = /* @__PURE__ */ new Map();
@@ -54,6 +51,17 @@ var SystemDoxApiError = class extends Error {
54
51
  code;
55
52
  body;
56
53
  };
54
+ function unescapedPath(path) {
55
+ let current = path.split(/[?#]/, 1)[0];
56
+ for (; ; ) {
57
+ const next = current.replace(/[\t\n\r]/g, "").replace(/%(?:2e|2f|5c|25|09|0a|0d)/gi, (escape) => decodeURIComponent(escape));
58
+ if (next === current) return current;
59
+ current = next;
60
+ }
61
+ }
62
+ function walksThePath(path) {
63
+ return unescapedPath(path).split(/[/\\]/).some((segment) => segment === "." || segment === "..");
64
+ }
57
65
  var TEAM_SETUP = "/v1/team-setup";
58
66
  var seg = (s) => encodeURIComponent(String(s));
59
67
  var SystemDoxApiClient = class {
@@ -89,7 +97,7 @@ var SystemDoxApiClient = class {
89
97
  }
90
98
  async mutate(method, path, body) {
91
99
  const url = this.buildUrl(path);
92
- const res = await fetch(url, {
100
+ const res = await this.call(url, {
93
101
  method,
94
102
  headers: {
95
103
  // The API Gateway authorizer only reads the Authorization header;
@@ -139,7 +147,7 @@ var SystemDoxApiClient = class {
139
147
  /** getPluginZip: the zip's bytes as served, and its X-Content-SHA256
140
148
  * header. The caller checks the BYTES against the hash it was given. */
141
149
  async getPluginZip(workspace, version) {
142
- const res = await fetch(
150
+ const res = await this.call(
143
151
  this.buildUrl(`${TEAM_SETUP}/${seg(workspace)}/plugin/versions/${seg(version)}/zip`),
144
152
  {
145
153
  headers: {
@@ -205,6 +213,10 @@ var SystemDoxApiClient = class {
205
213
  buildUrl(path, params) {
206
214
  const base = this.config.apiUrl.replace(/\/+$/, "");
207
215
  const url = new URL(`${base}${path.startsWith("/") ? "" : "/"}${path}`);
216
+ const { origin, pathname } = new URL(base);
217
+ if (walksThePath(path) || url.origin !== origin || !url.pathname.startsWith(`${pathname.replace(/\/+$/, "")}/`)) {
218
+ throw new Error("Refused: this id leaves the API's own path.");
219
+ }
208
220
  if (params) {
209
221
  for (const [key, value] of Object.entries(params)) {
210
222
  url.searchParams.set(key, value);
@@ -214,7 +226,7 @@ var SystemDoxApiClient = class {
214
226
  }
215
227
  async fetchRaw(path, params, signal) {
216
228
  const url = this.buildUrl(path, params);
217
- return fetch(url, {
229
+ return this.call(url, {
218
230
  headers: {
219
231
  Authorization: `Bearer ${this.config.apiKey}`,
220
232
  "X-API-Key": this.config.apiKey,
@@ -223,6 +235,16 @@ var SystemDoxApiClient = class {
223
235
  signal
224
236
  });
225
237
  }
238
+ /** Every call of this client, so `onAnswer` hears each answer. */
239
+ async call(url, init) {
240
+ const res = await fetch(url, init);
241
+ const { onAnswer } = this.config;
242
+ if (onAnswer) {
243
+ const refused2 = res.ok ? void 0 : new SystemDoxApiError(res.status, await res.clone().text());
244
+ onAnswer({ status: res.status, code: refused2?.code });
245
+ }
246
+ return res;
247
+ }
226
248
  };
227
249
 
228
250
  // src/lib/config.ts
@@ -244,714 +266,298 @@ function requireHttpsApiUrl(apiUrl) {
244
266
  return apiUrl;
245
267
  }
246
268
 
247
- // src/lib/metrics.ts
248
- var NAMESPACE = "SystemDox/Mcp";
249
- var METRIC_NAME = "McpToolCall";
250
- function buildMcpToolCallMetric(tenant, tool, timestamp = Date.now()) {
251
- return {
252
- _aws: {
253
- Timestamp: timestamp,
254
- CloudWatchMetrics: [
255
- {
256
- Namespace: NAMESPACE,
257
- Dimensions: [["tenant"]],
258
- Metrics: [{ Name: METRIC_NAME, Unit: "Count" }]
259
- }
260
- ]
261
- },
262
- tenant,
263
- tool,
264
- [METRIC_NAME]: 1
265
- };
266
- }
267
- function emitMcpToolCalls(tenant, rawBody, log = console.log) {
268
- try {
269
- const parsed = JSON.parse(rawBody);
270
- const messages = Array.isArray(parsed) ? parsed : [parsed];
271
- for (const message of messages) {
272
- if (typeof message === "object" && message !== null && message.method === "tools/call") {
273
- const params = message.params;
274
- const tool = typeof params?.name === "string" ? params.name : "unknown";
275
- log(JSON.stringify(buildMcpToolCallMetric(tenant || "unknown", tool)));
276
- }
277
- }
278
- } catch {
279
- }
280
- }
269
+ // src/lib/mcp-answer.ts
270
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
281
271
 
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";
272
+ // src/server.ts
273
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
286
274
 
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;
275
+ // src/tools/get-guardrails.ts
276
+ import { z } from "zod";
277
+
278
+ // src/lib/language.ts
279
+ var EXTENSION_MAP = {
280
+ ".py": { language: "python", categories: ["api", "backend", "data-access"] },
281
+ ".ts": { language: "typescript", categories: ["frontend", "api"] },
282
+ ".tsx": { language: "typescript", categories: ["frontend"] },
283
+ ".js": { language: "javascript", categories: ["frontend"] },
284
+ ".jsx": { language: "javascript", categories: ["frontend"] },
285
+ ".yaml": {
286
+ language: "cloudformation",
287
+ categories: ["infrastructure", "architecture"]
288
+ },
289
+ ".yml": {
290
+ language: "cloudformation",
291
+ categories: ["infrastructure", "architecture"]
292
+ },
293
+ ".json": { language: "json", categories: ["configuration"] },
294
+ ".css": { language: "css", categories: ["frontend"] },
295
+ ".html": { language: "html", categories: ["frontend"] },
296
+ ".sql": { language: "sql", categories: ["data-access"] },
297
+ ".sh": { language: "shell", categories: ["infrastructure"] },
298
+ ".bash": { language: "shell", categories: ["infrastructure"] },
299
+ ".tf": { language: "terraform", categories: ["infrastructure"] },
300
+ ".md": { language: "markdown", categories: ["documentation"] }
312
301
  };
313
- function sha256(content) {
314
- return createHash("sha256").update(content, "utf8").digest("hex");
315
- }
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;
302
+ var PATH_CATEGORY_RULES = [
303
+ { pattern: /backend\/functions\//, categories: ["api", "backend"] },
304
+ { pattern: /backend\/layers\//, categories: ["backend"] },
305
+ { pattern: /backend\//, categories: ["backend"] },
306
+ { pattern: /apps\/app\//, categories: ["frontend"] },
307
+ { pattern: /apps\/website\//, categories: ["frontend"] },
308
+ { pattern: /apps\/portal\//, categories: ["frontend"] },
309
+ { pattern: /packages\/ui\//, categories: ["frontend"] },
310
+ { pattern: /infra\//, categories: ["infrastructure"] },
311
+ { pattern: /\.github\//, categories: ["infrastructure"] },
312
+ { pattern: /tests?\//, categories: ["testing"] },
313
+ { pattern: /spec/, categories: ["testing"] },
314
+ { pattern: /docs\/adr/, categories: ["architecture"] },
315
+ { pattern: /template\.yaml$/, categories: ["infrastructure", "architecture"] }
316
+ ];
317
+ function detectLanguage(filePath) {
318
+ const ext = filePath.slice(filePath.lastIndexOf("."));
319
+ return EXTENSION_MAP[ext];
343
320
  }
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`);
354
- }
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}`);
321
+ function detectCategories(filePaths) {
322
+ const categories = /* @__PURE__ */ new Set();
323
+ for (const filePath of filePaths) {
324
+ const langInfo = detectLanguage(filePath);
325
+ if (langInfo) {
326
+ for (const cat of langInfo.categories) {
327
+ categories.add(cat);
328
+ }
363
329
  }
364
- let content;
365
- try {
366
- content = decoder.decode(raw);
367
- } catch {
368
- throw new Error(`${path} is not UTF-8 text`);
330
+ for (const rule of PATH_CATEGORY_RULES) {
331
+ if (rule.pattern.test(filePath)) {
332
+ for (const cat of rule.categories) {
333
+ categories.add(cat);
334
+ }
335
+ }
369
336
  }
370
- files.push({ path, content });
371
337
  }
372
- return files;
338
+ return Array.from(categories);
373
339
  }
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`);
378
- }
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`);
340
+ function detectLanguages(filePaths) {
341
+ const languages = /* @__PURE__ */ new Set();
342
+ for (const filePath of filePaths) {
343
+ const info = detectLanguage(filePath);
344
+ if (info) languages.add(info.language);
382
345
  }
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 };
346
+ return Array.from(languages);
388
347
  }
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;
348
+
349
+ // src/lib/deprecation.ts
350
+ var LEGACY_TOOL_REMOVAL_EARLIEST = "2027-01-31";
351
+ function legacyToolDescription(canonical, description) {
352
+ return `DEPRECATED \u2014 use \`${canonical}\` instead. This name still works and will not be removed before ${LEGACY_TOOL_REMOVAL_EARLIEST}. ${description}`;
394
353
  }
395
- async function readManifest(dest) {
354
+
355
+ // src/lib/config-links.ts
356
+ async function workspaceBase(apiClient) {
396
357
  try {
397
- return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
358
+ const access = await apiClient.get(
359
+ "/v1/docs/portal-access"
360
+ );
361
+ const slug = (access.host ?? "").split(".")[0];
362
+ return slug ? `https://${slug}.systemdox.com` : null;
398
363
  } catch {
399
- return void 0;
364
+ return null;
400
365
  }
401
366
  }
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);
367
+ function configLink(path, base) {
368
+ return base ? `${base}${path}` : `${path} in your SystemDox workspace`;
369
+ }
370
+
371
+ // src/tools/get-guardrails.ts
372
+ var DESCRIPTION = "Retrieve SystemDox checks (banned patterns, required patterns, coding rules). Filter by language, category, or file path to get only relevant checks.";
373
+ var inputSchema = () => ({
374
+ language: z.string().optional().describe(
375
+ "Filter by programming language (python, typescript, javascript, cloudformation)"
376
+ ),
377
+ category: z.string().optional().describe(
378
+ "Filter by category (imports, auth, error-handling, architecture, testing, security, conventions, data-access)"
379
+ ),
380
+ file_path: z.string().optional().describe(
381
+ "File path being edited \u2014 language is auto-detected from the extension"
382
+ )
383
+ });
384
+ function registerGetGuardrails(server, apiClient) {
385
+ const handler2 = async ({
386
+ language,
387
+ category,
388
+ file_path
389
+ }) => {
390
+ const guardrails = await apiClient.get("/v1/guardrails");
391
+ let filtered = guardrails;
392
+ const lang = language ?? (file_path ? detectLanguage(file_path)?.language : void 0);
393
+ if (lang) {
394
+ filtered = filtered.filter(
395
+ (g) => g.description.toLowerCase().includes(lang.toLowerCase()) || g.category.toLowerCase().includes(lang.toLowerCase()) || g.title.toLowerCase().includes(lang.toLowerCase())
396
+ );
427
397
  }
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;
398
+ if (category) {
399
+ filtered = filtered.filter(
400
+ (g) => g.category.toLowerCase() === category.toLowerCase()
401
+ );
445
402
  }
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
403
+ if (filtered.length === 0) {
404
+ 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}"` : ""}.`;
405
+ return {
406
+ content: [{ type: "text", text: text4 }]
407
+ };
408
+ }
409
+ const text3 = filtered.map(
410
+ (g) => `## ${g.title} [${g.severity}]
411
+
412
+ ${g.description}
413
+
414
+ ` + (g.pattern ? `**Bad pattern:**
415
+ \`\`\`
416
+ ${g.pattern}
417
+ \`\`\`
418
+
419
+ ` : "") + (g.fix ? `**Correct pattern:**
420
+ \`\`\`
421
+ ${g.fix}
422
+ \`\`\`
423
+
424
+ ` : "") + (g.fitnessTest ? `**Detection:** \`${g.fitnessTest}\`
425
+ ` : "")
426
+ ).join("\n---\n\n");
427
+ return {
428
+ content: [
429
+ {
430
+ type: "text",
431
+ text: `Found ${filtered.length} check(s):
432
+
433
+ ${text3}`
434
+ }
435
+ ]
436
+ };
463
437
  };
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 afterInstall(skillMd) {
470
- return `No restart is needed: a running Claude Code session picks it up. If one does not list it yet, read ${skillMd} and follow it, or run /reload-skills.`;
471
- }
472
- function describeInstall(name, r) {
473
- const lines = [
474
- `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
475
- ];
476
- if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
477
- if (r.keptEdited.length) {
478
- lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
479
- }
480
- if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
481
- lines.push(afterInstall(join(r.dest, "SKILL.md")));
482
- return lines.join("\n");
438
+ server.registerTool(
439
+ "get_checks",
440
+ {
441
+ title: "Get Checks",
442
+ description: DESCRIPTION,
443
+ inputSchema: inputSchema()
444
+ },
445
+ handler2
446
+ );
447
+ server.registerTool(
448
+ "get_guardrails",
449
+ {
450
+ title: "Get Checks (legacy name)",
451
+ description: legacyToolDescription("get_checks", DESCRIPTION),
452
+ inputSchema: inputSchema()
453
+ },
454
+ handler2
455
+ );
483
456
  }
484
457
 
485
- // src/lib/team-setup-files.ts
486
- import { execFile } from "child_process";
487
- import { chmod, mkdir as mkdir2, readFile as readFile2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
488
- import { homedir as homedir2, userInfo } from "os";
489
- import { dirname as dirname2, join as join2 } from "path";
490
- import { promisify } from "util";
491
- var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
492
- function isWorkspace(value) {
493
- return typeof value === "string" && value.length <= 64 && SLUG_RE.test(value);
458
+ // src/tools/get-specs.ts
459
+ import { z as z2 } from "zod";
460
+
461
+ // src/lib/spec-label.ts
462
+ var DECISION_PREFIX = "ADR: ";
463
+ function isDecisionDraft(name) {
464
+ return name.startsWith(DECISION_PREFIX);
494
465
  }
495
- function homeDir() {
496
- return homedir2();
466
+ function specLabel(spec) {
467
+ return isDecisionDraft(spec.name) ? "decision record" : `${spec.requirementCount} requirements`;
497
468
  }
498
- function systemdoxDir() {
499
- return join2(homeDir(), ".claude", "systemdox");
469
+
470
+ // src/tools/get-specs.ts
471
+ function registerGetSpecs(server, apiClient) {
472
+ server.registerTool(
473
+ "get_specs",
474
+ {
475
+ title: "Get Specs",
476
+ description: "Retrieve SystemDox specifications (proposal, requirements with RFC 2119 keywords, Given/When/Then scenarios). Get a specific spec by ID, filter by status, or search by keyword.",
477
+ inputSchema: {
478
+ draft_id: z2.string().optional().describe("Specific spec ID to retrieve with full content"),
479
+ status: z2.enum(["draft", "ready", "archived"]).optional().describe("Filter by status"),
480
+ search: z2.string().optional().describe("Keyword search across spec names")
481
+ }
482
+ },
483
+ async ({ draft_id, status, search }) => {
484
+ if (draft_id) {
485
+ const spec = await apiClient.get(
486
+ `/v1/specs/${encodeURIComponent(draft_id)}`
487
+ );
488
+ const sections = [`# ${spec.name}
489
+
490
+ **Status:** ${spec.status}`];
491
+ if (spec.proposalContent) {
492
+ sections.push(`## Proposal
493
+
494
+ ${spec.proposalContent}`);
495
+ }
496
+ if (spec.requirements.length > 0) {
497
+ const reqs = spec.requirements.map((r) => `### ${r.name}
498
+
499
+ ${r.content}`).join("\n\n");
500
+ sections.push(`## Requirements
501
+
502
+ ${reqs}`);
503
+ }
504
+ if (spec.taskContent) {
505
+ sections.push(`## Tasks
506
+
507
+ ${spec.taskContent}`);
508
+ }
509
+ return {
510
+ content: [{ type: "text", text: sections.join("\n\n") }]
511
+ };
512
+ }
513
+ const specs = await apiClient.get("/v1/specs");
514
+ let filtered = specs;
515
+ if (status) {
516
+ filtered = filtered.filter((s) => s.status === status);
517
+ }
518
+ if (search) {
519
+ const term = search.toLowerCase();
520
+ filtered = filtered.filter(
521
+ (s) => s.name.toLowerCase().includes(term)
522
+ );
523
+ }
524
+ if (filtered.length === 0) {
525
+ return {
526
+ content: [
527
+ {
528
+ type: "text",
529
+ text: `No specs found${status ? ` with status "${status}"` : ""}${search ? ` matching "${search}"` : ""}.`
530
+ }
531
+ ]
532
+ };
533
+ }
534
+ const text3 = filtered.map(
535
+ (s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${specLabel(s)}` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
536
+ ).join("\n");
537
+ return {
538
+ content: [
539
+ {
540
+ type: "text",
541
+ text: `Found ${filtered.length} spec(s):
542
+
543
+ ${text3}`
544
+ }
545
+ ]
546
+ };
547
+ }
548
+ );
500
549
  }
501
- function workspaceDir(workspace) {
502
- if (!isWorkspace(workspace)) throw new Error(`${JSON.stringify(workspace)} is not a workspace name`);
503
- return join2(systemdoxDir(), workspace);
550
+
551
+ // src/tools/get-docs.ts
552
+ import { z as z3 } from "zod";
553
+ var PATH_FILTER_LIMIT = "1000";
554
+ function literalPrefix(pattern) {
555
+ const wildcard = pattern.indexOf("*");
556
+ return wildcard < 0 ? pattern : pattern.slice(0, wildcard);
504
557
  }
505
- function statePath(workspace) {
506
- return join2(workspaceDir(workspace), "state.json");
507
- }
508
- async function readJson(path, empty) {
509
- let text3;
510
- try {
511
- text3 = await readFile2(path, "utf8");
512
- } catch (e) {
513
- if (e.code === "ENOENT") return empty;
514
- throw e;
515
- }
516
- try {
517
- return JSON.parse(text3);
518
- } catch {
519
- throw new Error(`${path} is not valid JSON: fix or remove it, then run this again`);
520
- }
521
- }
522
- async function writeJsonAtomically(path, value, mode) {
523
- await mkdir2(dirname2(path), { recursive: true });
524
- const tmp = `${path}.${process.pid}.tmp`;
525
- await writeFile2(tmp, JSON.stringify(value, null, 2) + "\n", mode === void 0 ? "utf8" : { encoding: "utf8", mode });
526
- await rename(tmp, path);
527
- }
528
- var run = promisify(execFile);
529
- async function readState(workspace) {
530
- return readJson(statePath(workspace), {});
531
- }
532
- async function writeState(workspace, state) {
533
- await writeJsonAtomically(statePath(workspace), state);
534
- }
535
- async function updateState(workspace, change) {
536
- const state = await readState(workspace);
537
- change(state);
538
- await writeState(workspace, state);
539
- return state;
540
- }
541
-
542
- // src/lib/session-brief.ts
543
- var INSTRUCTIONS_MAX = 2e3;
544
- var BRIEF_TIMEOUT_MS = 8e3;
545
- var SETUP_COMMAND = "npx -y @systemdox/mcp-server setup";
546
- 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.`;
547
- 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.";
548
- 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.";
549
- function noKey(reason) {
550
- 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.`;
551
- }
552
- function olderApi(setup) {
553
- 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.`;
554
- }
555
- async function readJsonQuietly(path) {
556
- try {
557
- return JSON.parse(await readFile3(path, "utf8"));
558
- } catch {
559
- return void 0;
560
- }
561
- }
562
- async function computerState(setup) {
563
- const id = `${setup.workspace}-setup@systemdox-${setup.workspace}`;
564
- const installed = await readJsonQuietly(join3(homeDir(), ".claude", "plugins", "installed_plugins.json"));
565
- const entries2 = installed?.plugins?.[id];
566
- const entry = Array.isArray(entries2) ? entries2.find((e) => (e?.scope ?? "user") === "user") : void 0;
567
- const state = {
568
- missing: [],
569
- behind: [],
570
- notFromSystemDox: [],
571
- pluginInstalled: !!entry,
572
- pluginVersion: entry?.version
573
- };
574
- const pluginCurrent = !!entry && !!setup.plugin && entry.version === `${setup.plugin.version}.0.0`;
575
- for (const skill of setup.sessionBrief?.skills ?? []) {
576
- const dir = defaultSkillDir(skill.name);
577
- if (!existsSync2(dir)) {
578
- if (!(pluginCurrent && setup.plugin?.skills?.[skill.name] !== void 0)) state.missing.push(skill.name);
579
- continue;
580
- }
581
- const manifest = await readJsonQuietly(join3(dir, MANIFEST_FILE));
582
- if (typeof manifest?.version !== "number" || !Number.isInteger(manifest.version)) {
583
- state.notFromSystemDox.push(skill.name);
584
- } else if (manifest.version !== skill.latestVersion) {
585
- state.behind.push({ name: skill.name, here: manifest.version, latest: skill.latestVersion });
586
- }
587
- }
588
- return state;
589
- }
590
- function hasPersonParts(setup) {
591
- return Object.keys(setup.parts ?? {}).length > 0 || (setup.memory ?? []).length > 0 || (setup.sessionBrief?.heldSkills ?? []).some((h) => h.reason === "not-plain");
592
- }
593
- var MORE = (n) => `${n} more (get_team_setup lists them)`;
594
- var LIST_MIN = `all ${MORE(100)}`.length;
595
- function listWithin(items, room) {
596
- const whole2 = items.join(", ");
597
- if (whole2.length <= room) return whole2;
598
- const kept = [];
599
- let used = 0;
600
- for (let i = 0; i < items.length; i++) {
601
- const rest = items.length - i - 1;
602
- const cost = items[i].length + (kept.length ? 2 : 0);
603
- if (used + cost + ` and ${MORE(rest)}`.length > room) break;
604
- kept.push(items[i]);
605
- used += cost;
606
- }
607
- return kept.length ? `${kept.join(", ")} and ${MORE(items.length - kept.length)}` : `all ${MORE(items.length)}`;
608
- }
609
- function localSection(setup, state, room) {
610
- const rest = hasPersonParts(setup) ? ` and that \`${SETUP_COMMAND}\` in their own terminal adds the rest of the team setup` : "";
611
- const latest = new Map((setup.sessionBrief?.skills ?? []).map((s) => [s.name, s.latestVersion]));
612
- const lines = [];
613
- if (state.missing.length) {
614
- lines.push({
615
- head: "On this computer, missing: ",
616
- items: state.missing.map((name) => `${name} version ${latest.get(name)}`),
617
- 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.`
618
- });
619
- }
620
- if (state.behind.length) {
621
- lines.push({
622
- head: "On this computer, behind: ",
623
- items: state.behind.map((b) => `${b.name} (version ${b.here} here, latest ${b.latest})`),
624
- tail: ". install_skill updates each to that latest version; if it refuses because files were edited, tell the person and leave them."
625
- });
626
- }
627
- if (state.notFromSystemDox.length) {
628
- lines.push({ head: "Not from SystemDox, left alone: ", items: state.notFromSystemDox, tail: "." });
629
- }
630
- const fixed = () => lines.reduce((n, l) => n + l.head.length + l.tail.length + LIST_MIN, 0) + Math.max(0, lines.length - 1);
631
- while (lines.length && fixed() > room) lines.pop();
632
- if (!lines.length) return "";
633
- const share = Math.floor((room - fixed()) / lines.length) + LIST_MIN;
634
- return lines.map((l) => `${l.head}${listWithin(l.items, share)}${l.tail}`).join("\n");
635
- }
636
- function fit(text3, section) {
637
- const whole2 = section ? `${text3}
638
-
639
- ${section}` : text3;
640
- if (whole2.length <= INSTRUCTIONS_MAX) return whole2;
641
- return text3.length <= INSTRUCTIONS_MAX ? text3 : `${text3.slice(0, INSTRUCTIONS_MAX - 1)}\u2026`;
642
- }
643
- async function readTeamSetup(apiClient, { keyProblem, timeoutMs = BRIEF_TIMEOUT_MS } = {}) {
644
- if (keyProblem) return { problem: noKey(keyProblem) };
645
- try {
646
- const setup = await apiClient.getOwnTeamSetup(AbortSignal.timeout(timeoutMs));
647
- return typeof setup?.workspace === "string" ? { setup } : { problem: NOT_CHECKED };
648
- } catch (err) {
649
- const status = err?.status;
650
- return { problem: status === 401 || status === 403 ? REFUSED : NOT_CHECKED };
651
- }
652
- }
653
- async function sessionInstructions(apiClient, options = {}) {
654
- const read = await readTeamSetup(apiClient, options);
655
- if ("problem" in read) return read.problem;
656
- const text3 = read.setup.sessionBrief?.text ?? olderApi(read.setup);
657
- try {
658
- if (!options.localFiles) return fit(text3, read.setup.sessionBrief?.skills?.length ? CANNOT_SEE : "");
659
- const room = INSTRUCTIONS_MAX - text3.length - 2;
660
- return fit(text3, localSection(read.setup, await computerState(read.setup), room));
661
- } catch {
662
- return fit(text3, "");
663
- }
664
- }
665
-
666
- // src/server.ts
667
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
668
-
669
- // src/tools/get-guardrails.ts
670
- import { z } from "zod";
671
-
672
- // src/lib/language.ts
673
- var EXTENSION_MAP = {
674
- ".py": { language: "python", categories: ["api", "backend", "data-access"] },
675
- ".ts": { language: "typescript", categories: ["frontend", "api"] },
676
- ".tsx": { language: "typescript", categories: ["frontend"] },
677
- ".js": { language: "javascript", categories: ["frontend"] },
678
- ".jsx": { language: "javascript", categories: ["frontend"] },
679
- ".yaml": {
680
- language: "cloudformation",
681
- categories: ["infrastructure", "architecture"]
682
- },
683
- ".yml": {
684
- language: "cloudformation",
685
- categories: ["infrastructure", "architecture"]
686
- },
687
- ".json": { language: "json", categories: ["configuration"] },
688
- ".css": { language: "css", categories: ["frontend"] },
689
- ".html": { language: "html", categories: ["frontend"] },
690
- ".sql": { language: "sql", categories: ["data-access"] },
691
- ".sh": { language: "shell", categories: ["infrastructure"] },
692
- ".bash": { language: "shell", categories: ["infrastructure"] },
693
- ".tf": { language: "terraform", categories: ["infrastructure"] },
694
- ".md": { language: "markdown", categories: ["documentation"] }
695
- };
696
- var PATH_CATEGORY_RULES = [
697
- { pattern: /backend\/functions\//, categories: ["api", "backend"] },
698
- { pattern: /backend\/layers\//, categories: ["backend"] },
699
- { pattern: /backend\//, categories: ["backend"] },
700
- { pattern: /apps\/app\//, categories: ["frontend"] },
701
- { pattern: /apps\/website\//, categories: ["frontend"] },
702
- { pattern: /apps\/portal\//, categories: ["frontend"] },
703
- { pattern: /packages\/ui\//, categories: ["frontend"] },
704
- { pattern: /infra\//, categories: ["infrastructure"] },
705
- { pattern: /\.github\//, categories: ["infrastructure"] },
706
- { pattern: /tests?\//, categories: ["testing"] },
707
- { pattern: /spec/, categories: ["testing"] },
708
- { pattern: /docs\/adr/, categories: ["architecture"] },
709
- { pattern: /template\.yaml$/, categories: ["infrastructure", "architecture"] }
710
- ];
711
- function detectLanguage(filePath) {
712
- const ext = filePath.slice(filePath.lastIndexOf("."));
713
- return EXTENSION_MAP[ext];
714
- }
715
- function detectCategories(filePaths) {
716
- const categories = /* @__PURE__ */ new Set();
717
- for (const filePath of filePaths) {
718
- const langInfo = detectLanguage(filePath);
719
- if (langInfo) {
720
- for (const cat of langInfo.categories) {
721
- categories.add(cat);
722
- }
723
- }
724
- for (const rule of PATH_CATEGORY_RULES) {
725
- if (rule.pattern.test(filePath)) {
726
- for (const cat of rule.categories) {
727
- categories.add(cat);
728
- }
729
- }
730
- }
731
- }
732
- return Array.from(categories);
733
- }
734
- function detectLanguages(filePaths) {
735
- const languages = /* @__PURE__ */ new Set();
736
- for (const filePath of filePaths) {
737
- const info = detectLanguage(filePath);
738
- if (info) languages.add(info.language);
739
- }
740
- return Array.from(languages);
741
- }
742
-
743
- // src/lib/deprecation.ts
744
- var LEGACY_TOOL_REMOVAL_EARLIEST = "2027-01-31";
745
- function legacyToolDescription(canonical, description) {
746
- return `DEPRECATED \u2014 use \`${canonical}\` instead. This name still works and will not be removed before ${LEGACY_TOOL_REMOVAL_EARLIEST}. ${description}`;
747
- }
748
-
749
- // src/lib/config-links.ts
750
- async function workspaceBase(apiClient) {
751
- try {
752
- const access = await apiClient.get(
753
- "/v1/docs/portal-access"
754
- );
755
- const slug = (access.host ?? "").split(".")[0];
756
- return slug ? `https://${slug}.systemdox.com` : null;
757
- } catch {
758
- return null;
759
- }
760
- }
761
- function configLink(path, base) {
762
- return base ? `${base}${path}` : `${path} in your SystemDox workspace`;
763
- }
764
-
765
- // src/tools/get-guardrails.ts
766
- var DESCRIPTION = "Retrieve SystemDox checks (banned patterns, required patterns, coding rules). Filter by language, category, or file path to get only relevant checks.";
767
- var inputSchema = () => ({
768
- language: z.string().optional().describe(
769
- "Filter by programming language (python, typescript, javascript, cloudformation)"
770
- ),
771
- category: z.string().optional().describe(
772
- "Filter by category (imports, auth, error-handling, architecture, testing, security, conventions, data-access)"
773
- ),
774
- file_path: z.string().optional().describe(
775
- "File path being edited \u2014 language is auto-detected from the extension"
776
- )
777
- });
778
- function registerGetGuardrails(server, apiClient) {
779
- const handler2 = async ({
780
- language,
781
- category,
782
- file_path
783
- }) => {
784
- const guardrails = await apiClient.get("/v1/guardrails");
785
- let filtered = guardrails;
786
- const lang = language ?? (file_path ? detectLanguage(file_path)?.language : void 0);
787
- if (lang) {
788
- filtered = filtered.filter(
789
- (g) => g.description.toLowerCase().includes(lang.toLowerCase()) || g.category.toLowerCase().includes(lang.toLowerCase()) || g.title.toLowerCase().includes(lang.toLowerCase())
790
- );
791
- }
792
- if (category) {
793
- filtered = filtered.filter(
794
- (g) => g.category.toLowerCase() === category.toLowerCase()
795
- );
796
- }
797
- if (filtered.length === 0) {
798
- 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}"` : ""}.`;
799
- return {
800
- content: [{ type: "text", text: text4 }]
801
- };
802
- }
803
- const text3 = filtered.map(
804
- (g) => `## ${g.title} [${g.severity}]
805
-
806
- ${g.description}
807
-
808
- ` + (g.pattern ? `**Bad pattern:**
809
- \`\`\`
810
- ${g.pattern}
811
- \`\`\`
812
-
813
- ` : "") + (g.fix ? `**Correct pattern:**
814
- \`\`\`
815
- ${g.fix}
816
- \`\`\`
817
-
818
- ` : "") + (g.fitnessTest ? `**Detection:** \`${g.fitnessTest}\`
819
- ` : "")
820
- ).join("\n---\n\n");
821
- return {
822
- content: [
823
- {
824
- type: "text",
825
- text: `Found ${filtered.length} check(s):
826
-
827
- ${text3}`
828
- }
829
- ]
830
- };
831
- };
832
- server.registerTool(
833
- "get_checks",
834
- {
835
- title: "Get Checks",
836
- description: DESCRIPTION,
837
- inputSchema: inputSchema()
838
- },
839
- handler2
840
- );
841
- server.registerTool(
842
- "get_guardrails",
843
- {
844
- title: "Get Checks (legacy name)",
845
- description: legacyToolDescription("get_checks", DESCRIPTION),
846
- inputSchema: inputSchema()
847
- },
848
- handler2
849
- );
850
- }
851
-
852
- // src/tools/get-specs.ts
853
- import { z as z2 } from "zod";
854
-
855
- // src/lib/spec-label.ts
856
- var DECISION_PREFIX = "ADR: ";
857
- function isDecisionDraft(name) {
858
- return name.startsWith(DECISION_PREFIX);
859
- }
860
- function specLabel(spec) {
861
- return isDecisionDraft(spec.name) ? "decision record" : `${spec.requirementCount} requirements`;
862
- }
863
-
864
- // src/tools/get-specs.ts
865
- function registerGetSpecs(server, apiClient) {
866
- server.registerTool(
867
- "get_specs",
868
- {
869
- title: "Get Specs",
870
- description: "Retrieve SystemDox specifications (proposal, requirements with RFC 2119 keywords, Given/When/Then scenarios). Get a specific spec by ID, filter by status, or search by keyword.",
871
- inputSchema: {
872
- draft_id: z2.string().optional().describe("Specific spec ID to retrieve with full content"),
873
- status: z2.enum(["draft", "ready", "archived"]).optional().describe("Filter by status"),
874
- search: z2.string().optional().describe("Keyword search across spec names")
875
- }
876
- },
877
- async ({ draft_id, status, search }) => {
878
- if (draft_id) {
879
- const spec = await apiClient.get(
880
- `/v1/specs/${draft_id}`
881
- );
882
- const sections = [`# ${spec.name}
883
-
884
- **Status:** ${spec.status}`];
885
- if (spec.proposalContent) {
886
- sections.push(`## Proposal
887
-
888
- ${spec.proposalContent}`);
889
- }
890
- if (spec.requirements.length > 0) {
891
- const reqs = spec.requirements.map((r) => `### ${r.name}
892
-
893
- ${r.content}`).join("\n\n");
894
- sections.push(`## Requirements
895
-
896
- ${reqs}`);
897
- }
898
- if (spec.taskContent) {
899
- sections.push(`## Tasks
900
-
901
- ${spec.taskContent}`);
902
- }
903
- return {
904
- content: [{ type: "text", text: sections.join("\n\n") }]
905
- };
906
- }
907
- const specs = await apiClient.get("/v1/specs");
908
- let filtered = specs;
909
- if (status) {
910
- filtered = filtered.filter((s) => s.status === status);
911
- }
912
- if (search) {
913
- const term = search.toLowerCase();
914
- filtered = filtered.filter(
915
- (s) => s.name.toLowerCase().includes(term)
916
- );
917
- }
918
- if (filtered.length === 0) {
919
- return {
920
- content: [
921
- {
922
- type: "text",
923
- text: `No specs found${status ? ` with status "${status}"` : ""}${search ? ` matching "${search}"` : ""}.`
924
- }
925
- ]
926
- };
927
- }
928
- const text3 = filtered.map(
929
- (s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${specLabel(s)}` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
930
- ).join("\n");
931
- return {
932
- content: [
933
- {
934
- type: "text",
935
- text: `Found ${filtered.length} spec(s):
936
-
937
- ${text3}`
938
- }
939
- ]
940
- };
941
- }
942
- );
943
- }
944
-
945
- // src/tools/get-docs.ts
946
- import { z as z3 } from "zod";
947
- var PATH_FILTER_LIMIT = "1000";
948
- function literalPrefix(pattern) {
949
- const wildcard = pattern.indexOf("*");
950
- return wildcard < 0 ? pattern : pattern.slice(0, wildcard);
951
- }
952
- function matchGlob(path, pattern) {
953
- const regex = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
954
- return new RegExp(`^${regex}$`).test(path);
558
+ function matchGlob(path, pattern) {
559
+ const regex = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
560
+ return new RegExp(`^${regex}$`).test(path);
955
561
  }
956
562
  function registerGetDocs(server, apiClient) {
957
563
  server.registerTool(
@@ -1071,36 +677,299 @@ function registerGetTechStack(server, apiClient) {
1071
677
  const stack = await apiClient.get(
1072
678
  "/v1/ai/settings/stack"
1073
679
  );
1074
- if (stack.length === 0) {
680
+ if (stack.length === 0) {
681
+ return {
682
+ content: [
683
+ {
684
+ type: "text",
685
+ text: `No tech stack entries configured. Add your stack at ${configLink("/docs/tech-stack", await workspaceBase(apiClient))}`
686
+ }
687
+ ]
688
+ };
689
+ }
690
+ const grouped = /* @__PURE__ */ new Map();
691
+ for (const entry of stack) {
692
+ const list = grouped.get(entry.category) ?? [];
693
+ list.push(entry);
694
+ grouped.set(entry.category, list);
695
+ }
696
+ const sections = Array.from(grouped.entries()).map(([category, entries2]) => {
697
+ const items = entries2.map(
698
+ (e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
699
+ ).join("\n");
700
+ return `### ${category}
701
+ ${items}`;
702
+ }).join("\n\n");
703
+ return {
704
+ content: [
705
+ {
706
+ type: "text",
707
+ text: `Tech stack (${stack.length} entries):
708
+
709
+ ${sections}`
710
+ }
711
+ ]
712
+ };
713
+ }
714
+ );
715
+ }
716
+
717
+ // src/tools/get-planning-context.ts
718
+ import { z as z4 } from "zod";
719
+ function registerGetPlanningContext(server, apiClient) {
720
+ server.registerTool(
721
+ "get_planning_context",
722
+ {
723
+ title: "Get Planning Context",
724
+ description: "Retrieve org-wide planning context: connected repos with summaries, cross-repo dependencies, ADRs, and active specs. Optionally focus on a target repo.",
725
+ inputSchema: {
726
+ target_repo: z4.string().optional().describe(
727
+ "Focus on a specific repo (owner/repo format) to get its CLAUDE.md and relevant context"
728
+ )
729
+ }
730
+ },
731
+ async ({ target_repo }) => {
732
+ const params = {};
733
+ if (target_repo) params.targetRepo = target_repo;
734
+ const ctx = await apiClient.get(
735
+ "/v1/org/planning-context",
736
+ params
737
+ );
738
+ const sections = [];
739
+ if (ctx.repoSummaries.length > 0) {
740
+ const repos = ctx.repoSummaries.map(
741
+ (r) => `#### ${r.repo}
742
+ ${r.purpose}
743
+
744
+ ${r.summary}` + (r.conventions.length > 0 ? `
745
+
746
+ **Conventions:**
747
+ ${r.conventions.map((c) => `- ${c}`).join("\n")}` : "")
748
+ ).join("\n\n");
749
+ sections.push(`## Repositories (${ctx.repoSummaries.length})
750
+
751
+ ${repos}`);
752
+ }
753
+ if (ctx.dependencies.length > 0) {
754
+ const deps = ctx.dependencies.map((d) => `- ${d.from} \u2192 ${d.to}${d.provenance === "inferred" ? " (inferred)" : ""}`).join("\n");
755
+ sections.push(`## Cross-Repo Dependencies
756
+
757
+ ${deps}`);
758
+ }
759
+ if (ctx.adrs.length > 0) {
760
+ const adrs = ctx.adrs.map((a) => `- ${a.title}${a.repo ? ` (${a.repo})` : ""}`).join("\n");
761
+ const total = ctx.adrTotal ?? ctx.adrs.length;
762
+ const count = total > ctx.adrs.length ? `${ctx.adrs.length} newest of ${total}` : `${ctx.adrs.length}`;
763
+ sections.push(`## Architecture Decision Records (${count})
764
+
765
+ ${adrs}`);
766
+ }
767
+ if (ctx.specs.length > 0) {
768
+ const specs = ctx.specs.map((s) => `- ${s.name} [${s.status}]`).join("\n");
769
+ sections.push(`## Specs (${ctx.specs.length})
770
+
771
+ ${specs}`);
772
+ }
773
+ if (sections.length === 0) {
774
+ return {
775
+ content: [
776
+ {
777
+ type: "text",
778
+ text: `No planning context available. Connect repositories at ${configLink("/settings/github", await workspaceBase(apiClient))}`
779
+ }
780
+ ]
781
+ };
782
+ }
783
+ return {
784
+ content: [
785
+ {
786
+ type: "text",
787
+ text: sections.join("\n\n")
788
+ }
789
+ ]
790
+ };
791
+ }
792
+ );
793
+ }
794
+
795
+ // src/tools/get-plan-board.ts
796
+ import { z as z5 } from "zod";
797
+ var API_ITEM_PAGE = 100;
798
+ var DEFAULT_LIMIT = 50;
799
+ var BUCKET_LABELS = {
800
+ inProgress: "In Progress",
801
+ todo: "Todo",
802
+ done: "Done"
803
+ };
804
+ var BUCKET_ORDER = ["inProgress", "todo", "done"];
805
+ var STATUS_ARG_TO_BUCKET = {
806
+ todo: "todo",
807
+ in_progress: "inProgress",
808
+ done: "done"
809
+ };
810
+ function bucketOf(item) {
811
+ const status = (item.fields?.Status ?? "").trim().toLowerCase();
812
+ if (status) {
813
+ if (status.includes("done") || status.includes("ship") || status.includes("complete")) {
814
+ return "done";
815
+ }
816
+ if (status.includes("progress") || status.includes("doing") || status.includes("review")) {
817
+ return "inProgress";
818
+ }
819
+ return "todo";
820
+ }
821
+ return item.state === "CLOSED" ? "done" : "todo";
822
+ }
823
+ function formatBoards(boards) {
824
+ const lines = boards.map((b) => {
825
+ const c = b.statusCounts ?? { todo: 0, inProgress: 0, done: 0 };
826
+ return `- **${b.title}** \u2014 board #${b.number} \xB7 ${b.itemCount} item(s) (todo ${c.todo} \xB7 in progress ${c.inProgress} \xB7 done ${c.done})` + (b.description ? `
827
+ ${b.description}` : "") + `
828
+ ${b.url}`;
829
+ });
830
+ return lines.join("\n");
831
+ }
832
+ function formatItem(item) {
833
+ const meta = [];
834
+ if (item.labels.length > 0) meta.push(`labels: ${item.labels.join(", ")}`);
835
+ if (item.assignees.length > 0)
836
+ meta.push(`assignees: ${item.assignees.join(", ")}`);
837
+ const epic = item.fields?.Epic;
838
+ if (epic) meta.push(`epic: ${epic}`);
839
+ return `- #${item.number} ${item.title}${item.repo ? ` \u2014 ${item.repo}` : ""}` + (meta.length > 0 ? `
840
+ ${meta.join(" \xB7 ")}` : "") + (item.url ? `
841
+ ${item.url}` : "");
842
+ }
843
+ function registerGetPlanBoard(server, apiClient) {
844
+ server.registerTool(
845
+ "get_plan_board",
846
+ {
847
+ title: "Get Plan Board",
848
+ description: "Read the GitHub Projects boards connected to this workspace. With no arguments, lists the open boards with their item counts by status. With project_number, lists that board's issues grouped by status (In Progress / Todo / Done), each with issue number, title, repo, labels, assignees and URL \u2014 so you can see what is planned or in flight and pick up the right issue. Read-only: this tool cannot create, move, or close board items.",
849
+ inputSchema: {
850
+ project_number: z5.number().int().positive().optional().describe(
851
+ "Board number (the N in github.com/orgs/{org}/projects/N). Omit to list the open boards."
852
+ ),
853
+ status: z5.enum(["todo", "in_progress", "done"]).optional().describe(
854
+ "With project_number, return only the items in this status group"
855
+ ),
856
+ limit: z5.number().int().positive().max(200).optional().describe(
857
+ `Max items to list (default ${DEFAULT_LIMIT}). Group headings always show the true totals.`
858
+ )
859
+ }
860
+ },
861
+ async ({ project_number, status, limit }) => {
862
+ if (project_number === void 0) {
863
+ const boards = await apiClient.get(
864
+ "/v1/planning/projects"
865
+ );
866
+ if (boards.length === 0) {
867
+ return {
868
+ content: [
869
+ {
870
+ type: "text",
871
+ text: `No project boards found. Connect a GitHub organisation and create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
872
+ }
873
+ ]
874
+ };
875
+ }
876
+ const open = boards.filter((b) => !b.closed);
877
+ if (open.length === 0) {
878
+ return {
879
+ content: [
880
+ {
881
+ type: "text",
882
+ text: `No open project boards (${boards.length} closed). Reopen one, or create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
883
+ }
884
+ ]
885
+ };
886
+ }
887
+ const closedNote = boards.length > open.length ? `
888
+
889
+ _${boards.length - open.length} closed board(s) not listed._` : "";
890
+ return {
891
+ content: [
892
+ {
893
+ type: "text",
894
+ text: `${open.length} open project board(s):
895
+
896
+ ${formatBoards(open)}` + closedNote + `
897
+
898
+ Call get_plan_board with project_number to list a board's issues.`
899
+ }
900
+ ]
901
+ };
902
+ }
903
+ const response = await apiClient.get(
904
+ `/v1/planning/projects/${project_number}/items`
905
+ );
906
+ const allItems = response.items ?? [];
907
+ const boardName = response.projectTitle ? `**${response.projectTitle}** \u2014 board #${project_number}` : `Board #${project_number}`;
908
+ if (allItems.length === 0) {
1075
909
  return {
1076
910
  content: [
1077
911
  {
1078
912
  type: "text",
1079
- text: `No tech stack entries configured. Add your stack at ${configLink("/docs/tech-stack", await workspaceBase(apiClient))}`
913
+ text: `${boardName} has no issues, or it is not visible to this workspace's GitHub connection. Check the board at ${configLink("/plan", await workspaceBase(apiClient))}`
1080
914
  }
1081
915
  ]
1082
916
  };
1083
917
  }
1084
- const grouped = /* @__PURE__ */ new Map();
1085
- for (const entry of stack) {
1086
- const list = grouped.get(entry.category) ?? [];
1087
- list.push(entry);
1088
- grouped.set(entry.category, list);
918
+ const grouped = {
919
+ inProgress: [],
920
+ todo: [],
921
+ done: []
922
+ };
923
+ for (const item of allItems) grouped[bucketOf(item)].push(item);
924
+ const wanted = status ? [STATUS_ARG_TO_BUCKET[status]] : BUCKET_ORDER;
925
+ const shown = wanted.reduce((n, b) => n + grouped[b].length, 0);
926
+ if (shown === 0) {
927
+ return {
928
+ content: [
929
+ {
930
+ type: "text",
931
+ text: `${boardName}: no items with status "${status}" (of ${allItems.length} item(s) on the board).`
932
+ }
933
+ ]
934
+ };
935
+ }
936
+ const cap = limit ?? DEFAULT_LIMIT;
937
+ let budget = cap;
938
+ const sections = [];
939
+ let rendered = 0;
940
+ for (const b of wanted) {
941
+ const items = grouped[b];
942
+ if (items.length === 0) continue;
943
+ const slice = items.slice(0, Math.max(budget, 0));
944
+ budget -= slice.length;
945
+ rendered += slice.length;
946
+ const omitted = items.length - slice.length;
947
+ sections.push(
948
+ `### ${BUCKET_LABELS[b]} (${items.length})
949
+ ` + (slice.length > 0 ? slice.map(formatItem).join("\n") : "_(not listed \u2014 item limit reached)_") + (omitted > 0 && slice.length > 0 ? `
950
+ _\u2026${omitted} more not listed._` : "")
951
+ );
952
+ }
953
+ const notes = [];
954
+ if (rendered < shown) {
955
+ notes.push(
956
+ `_Listing ${rendered} of ${shown} matching item(s) \u2014 raise limit (max 200) or filter by status to see the rest._`
957
+ );
958
+ }
959
+ if (allItems.length >= API_ITEM_PAGE) {
960
+ notes.push(
961
+ `_The API reads the board's first ${API_ITEM_PAGE} items; a larger board may have more._`
962
+ );
1089
963
  }
1090
- const sections = Array.from(grouped.entries()).map(([category, entries2]) => {
1091
- const items = entries2.map(
1092
- (e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
1093
- ).join("\n");
1094
- return `### ${category}
1095
- ${items}`;
1096
- }).join("\n\n");
1097
964
  return {
1098
965
  content: [
1099
966
  {
1100
967
  type: "text",
1101
- text: `Tech stack (${stack.length} entries):
968
+ text: `${boardName} \u2014 ${allItems.length} item(s)
1102
969
 
1103
- ${sections}`
970
+ ` + sections.join("\n\n") + (notes.length > 0 ? `
971
+
972
+ ${notes.join("\n")}` : "")
1104
973
  }
1105
974
  ]
1106
975
  };
@@ -1108,1083 +977,1204 @@ ${sections}`
1108
977
  );
1109
978
  }
1110
979
 
1111
- // src/tools/get-planning-context.ts
1112
- import { z as z4 } from "zod";
1113
- function registerGetPlanningContext(server, apiClient) {
1114
- server.registerTool(
1115
- "get_planning_context",
1116
- {
1117
- title: "Get Planning Context",
1118
- description: "Retrieve org-wide planning context: connected repos with summaries, cross-repo dependencies, ADRs, and active specs. Optionally focus on a target repo.",
1119
- inputSchema: {
1120
- target_repo: z4.string().optional().describe(
1121
- "Focus on a specific repo (owner/repo format) to get its CLAUDE.md and relevant context"
1122
- )
1123
- }
1124
- },
1125
- async ({ target_repo }) => {
1126
- const params = {};
1127
- if (target_repo) params.targetRepo = target_repo;
1128
- const ctx = await apiClient.get(
1129
- "/v1/org/planning-context",
1130
- params
1131
- );
1132
- const sections = [];
1133
- if (ctx.repoSummaries.length > 0) {
1134
- const repos = ctx.repoSummaries.map(
1135
- (r) => `#### ${r.repo}
1136
- ${r.purpose}
1137
-
1138
- ${r.summary}` + (r.conventions.length > 0 ? `
1139
-
1140
- **Conventions:**
1141
- ${r.conventions.map((c) => `- ${c}`).join("\n")}` : "")
1142
- ).join("\n\n");
1143
- sections.push(`## Repositories (${ctx.repoSummaries.length})
980
+ // src/tools/get-change.ts
981
+ import { z as z6 } from "zod";
982
+ var GITHUB_NAME = "[A-Za-z0-9][A-Za-z0-9._-]{0,99}";
983
+ var REPO_RE = new RegExp(`^${GITHUB_NAME}/${GITHUB_NAME}$`);
984
+ var SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
985
+ var SLUG_MAX = 64;
986
+ var PARTS = ["decision", "contract", "behaviour", "plan", "release"];
987
+ var BRIEF = ["decision", "contract", "behaviour", "plan"];
988
+ var PAST_REVIEW = /* @__PURE__ */ new Set(["passed", "being built", "done"]);
989
+ function briefVerdict(states) {
990
+ const waiting = BRIEF.filter(
991
+ (part) => !(part === "decision" && states.decision === null) && !PAST_REVIEW.has(String(states[part]))
992
+ ).map((part) => `${part} is ${states[part] ?? "unknown"}`);
993
+ return waiting.length === 0 ? "The brief is passed" : `The brief is not passed: ${waiting.join(", ")}`;
994
+ }
995
+ function prLink(pr) {
996
+ return `[${pr.repo}#${pr.number}](${pr.url}) ${pr.state}`;
997
+ }
998
+ function fileSource(file) {
999
+ if (file.pullRequest) return prLink(file.pullRequest);
1000
+ return file.direct ? "committed directly" : "\u2014";
1001
+ }
1002
+ function partPullRequests(change, part) {
1003
+ const files = part === "contract" ? change.contract.files : part === "behaviour" ? change.behaviour.files : part === "plan" ? [change.plan.file] : [];
1004
+ const prs = part === "decision" ? [change.decision?.pullRequest ?? null] : part === "release" ? change.release.prs : files.map((file) => file?.pullRequest ?? null);
1005
+ const unique = /* @__PURE__ */ new Map();
1006
+ for (const pr of prs) {
1007
+ if (pr && !unique.has(`${pr.repo}#${pr.number}`)) unique.set(`${pr.repo}#${pr.number}`, pr);
1008
+ }
1009
+ return [...unique.values()].sort((a, b) => Number(b.state === "open") - Number(a.state === "open"));
1010
+ }
1011
+ function countsLine(counts) {
1012
+ const tags = ["proposed", "built", "defect", "unbuilt", "open", "untagged"].filter((tag) => counts[tag] > 0).map((tag) => `${counts[tag]} ${tag}`);
1013
+ return `${counts.total} scenario(s)` + (tags.length > 0 ? `: ${tags.join(", ")}` : "");
1014
+ }
1015
+ function partsTable(change) {
1016
+ const rows = PARTS.filter((part) => !(part === "decision" && change.states.decision === null)).map((part) => {
1017
+ const prs = partPullRequests(change, part);
1018
+ return `| ${part} | ${change.states[part]} | ${prs.length > 0 ? prs.map(prLink).join(", ") : "\u2014"} |`;
1019
+ });
1020
+ return ["| Part | State | Pull request |", "|---|---|---|", ...rows].join("\n");
1021
+ }
1022
+ function decisionSection(change) {
1023
+ const decision = change.decision;
1024
+ if (decision === null) return "## Decision\n\nNone: the proposal names no ADR (a small change).";
1025
+ const status = decision.status || "no record in a connected repository";
1026
+ return `## Decision
1144
1027
 
1145
- ${repos}`);
1146
- }
1147
- if (ctx.dependencies.length > 0) {
1148
- const deps = ctx.dependencies.map((d) => `- ${d.from} \u2192 ${d.to}${d.provenance === "inferred" ? " (inferred)" : ""}`).join("\n");
1149
- sections.push(`## Cross-Repo Dependencies
1028
+ ${decision.adr} \xB7 ${status}${decision.title ? ` \u2014 ${decision.title}` : ""}`;
1029
+ }
1030
+ function contractSection(change) {
1031
+ const files = change.contract.files;
1032
+ 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).");
1033
+ }
1034
+ function behaviourSection(change) {
1035
+ const { files, counts } = change.behaviour;
1036
+ if (files.length === 0) {
1037
+ return `## Behaviour
1150
1038
 
1151
- ${deps}`);
1152
- }
1153
- if (ctx.adrs.length > 0) {
1154
- const adrs = ctx.adrs.map((a) => `- ${a.title}${a.repo ? ` (${a.repo})` : ""}`).join("\n");
1155
- const total = ctx.adrTotal ?? ctx.adrs.length;
1156
- const count = total > ctx.adrs.length ? `${ctx.adrs.length} newest of ${total}` : `${ctx.adrs.length}`;
1157
- sections.push(`## Architecture Decision Records (${count})
1039
+ 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}` : ""}.`;
1040
+ }
1041
+ const blocks = files.map(
1042
+ (file) => [
1043
+ `### ${file.path}`,
1044
+ "",
1045
+ [file.title, file.runner ? `runner ${file.runner}` : "", fileSource(file), countsLine(file.counts)].filter(Boolean).join(" \xB7 "),
1046
+ "",
1047
+ ...file.scenarios.map((s) => `- ${s.id || "(no id)"} \xB7 ${s.state || "untagged"} \xB7 ${s.title}`)
1048
+ ].join("\n")
1049
+ );
1050
+ return ["## Behaviour", countsLine(counts), ...blocks].join("\n\n");
1051
+ }
1052
+ function planSection(change) {
1053
+ const { file, draftId, board } = change.plan;
1054
+ 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";
1055
+ return [
1056
+ "## Plan",
1057
+ "",
1058
+ `- plan.md: ${file ? `${file.path} \u2014 ${fileSource(file)}` : "none"}`,
1059
+ `- Draft: ${draftId ?? "none"}`,
1060
+ `- Board: ${boardLine}`
1061
+ ].join("\n");
1062
+ }
1063
+ function releaseSection(change) {
1064
+ const lines = change.release.prs.map(
1065
+ (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")
1066
+ );
1067
+ return `## Release
1158
1068
 
1159
- ${adrs}`);
1160
- }
1161
- if (ctx.specs.length > 0) {
1162
- const specs = ctx.specs.map((s) => `- ${s.name} [${s.status}]`).join("\n");
1163
- sections.push(`## Specs (${ctx.specs.length})
1069
+ ${lines.length > 0 ? lines.join("\n") : "No release pull request is open."}`;
1070
+ }
1071
+ function formatChange(change) {
1072
+ const header = `# ${change.title}
1164
1073
 
1165
- ${specs}`);
1166
- }
1167
- if (sections.length === 0) {
1168
- return {
1169
- content: [
1170
- {
1171
- type: "text",
1172
- text: `No planning context available. Connect repositories at ${configLink("/settings/github", await workspaceBase(apiClient))}`
1173
- }
1174
- ]
1175
- };
1074
+ \`${change.key}\` \xB7 size: ${change.size}` + (change.archived ? " \xB7 archived: read-only, done" : "") + ` \xB7 synced ${change.syncedAt}`;
1075
+ return [
1076
+ header,
1077
+ partsTable(change),
1078
+ decisionSection(change),
1079
+ contractSection(change),
1080
+ behaviourSection(change),
1081
+ planSection(change),
1082
+ releaseSection(change),
1083
+ // Compact: a change's every scenario with its steps rides in this block.
1084
+ "```json\n" + JSON.stringify(change) + "\n```",
1085
+ briefVerdict(change.states)
1086
+ ].join("\n\n");
1087
+ }
1088
+ function registerGetChange(server, apiClient) {
1089
+ server.registerTool(
1090
+ "get_change",
1091
+ {
1092
+ title: "Get Change",
1093
+ 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.`,
1094
+ inputSchema: {
1095
+ 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"),
1096
+ slug: z6.string().max(SLUG_MAX).regex(SLUG_RE, "a change folder's name: lower-case words joined by -, e.g. customer-reminders").describe("The change's folder name: specs/changes/<slug>/")
1097
+ },
1098
+ annotations: { readOnlyHint: true }
1099
+ },
1100
+ async ({ repo, slug }) => {
1101
+ const [owner, name] = repo.split("/");
1102
+ let change;
1103
+ try {
1104
+ change = await apiClient.getFresh(
1105
+ `/v1/changes/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${encodeURIComponent(slug)}`
1106
+ );
1107
+ } catch (err) {
1108
+ if (err.status === 404) {
1109
+ return {
1110
+ content: [{ type: "text", text: `No such change ${repo}/${slug}` }],
1111
+ isError: true
1112
+ };
1113
+ }
1114
+ throw err;
1176
1115
  }
1177
- return {
1178
- content: [
1179
- {
1180
- type: "text",
1181
- text: sections.join("\n\n")
1182
- }
1183
- ]
1184
- };
1116
+ return { content: [{ type: "text", text: formatChange(change) }] };
1185
1117
  }
1186
1118
  );
1187
1119
  }
1188
1120
 
1189
- // src/tools/get-plan-board.ts
1190
- import { z as z5 } from "zod";
1191
- var API_ITEM_PAGE = 100;
1192
- var DEFAULT_LIMIT = 50;
1193
- var BUCKET_LABELS = {
1194
- inProgress: "In Progress",
1195
- todo: "Todo",
1196
- done: "Done"
1197
- };
1198
- var BUCKET_ORDER = ["inProgress", "todo", "done"];
1199
- var STATUS_ARG_TO_BUCKET = {
1200
- todo: "todo",
1201
- in_progress: "inProgress",
1202
- done: "done"
1121
+ // src/tools/get-context.ts
1122
+ import { z as z7 } from "zod";
1123
+
1124
+ // src/lib/scoring.ts
1125
+ var WEIGHT_PATH = 0.4;
1126
+ var WEIGHT_KEYWORD = 0.3;
1127
+ var WEIGHT_RECENCY = 0.15;
1128
+ var WEIGHT_SEVERITY = 0.15;
1129
+ var SEVERITY_SCORES = {
1130
+ critical: 1,
1131
+ high: 0.75,
1132
+ medium: 0.5,
1133
+ low: 0.25
1203
1134
  };
1204
- function bucketOf(item) {
1205
- const status = (item.fields?.Status ?? "").trim().toLowerCase();
1206
- if (status) {
1207
- if (status.includes("done") || status.includes("ship") || status.includes("complete")) {
1208
- return "done";
1209
- }
1210
- if (status.includes("progress") || status.includes("doing") || status.includes("review")) {
1211
- return "inProgress";
1212
- }
1213
- return "todo";
1135
+ var MIN_TITLE_SHARE = 1 / 3;
1136
+ var RECORD_PATH = /(^|\/)changelog\//;
1137
+ var RECORD_FACTOR = 0.5;
1138
+ var CHARS_PER_TOKEN = 4;
1139
+ function estimateTokens(text3) {
1140
+ return Math.ceil(text3.length / CHARS_PER_TOKEN);
1141
+ }
1142
+ function truncateToTokens(text3, maxTokens) {
1143
+ const maxChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN;
1144
+ if (text3.length <= maxChars) return { text: text3, truncated: false };
1145
+ const head = text3.slice(0, maxChars);
1146
+ const floor = Math.floor(maxChars * 0.75);
1147
+ const paragraph = head.lastIndexOf("\n\n");
1148
+ const line = head.lastIndexOf("\n");
1149
+ let cut = maxChars;
1150
+ if (paragraph >= floor) cut = paragraph;
1151
+ else if (line >= floor) cut = line;
1152
+ return { text: head.slice(0, cut).trimEnd(), truncated: true };
1153
+ }
1154
+ function scorePathMatch(item, languages, categories) {
1155
+ if (languages.length === 0 && categories.length === 0) return 0.5;
1156
+ let score = 0;
1157
+ const itemLang = item.language?.toLowerCase() ?? "";
1158
+ const itemCat = item.category?.toLowerCase() ?? "";
1159
+ if (itemLang && languages.some((l) => itemLang.includes(l))) {
1160
+ score += 0.6;
1214
1161
  }
1215
- return item.state === "CLOSED" ? "done" : "todo";
1162
+ if (itemCat && categories.some((c) => itemCat.includes(c))) {
1163
+ score += 0.4;
1164
+ }
1165
+ return Math.min(score, 1);
1216
1166
  }
1217
- function formatBoards(boards) {
1218
- const lines = boards.map((b) => {
1219
- const c = b.statusCounts ?? { todo: 0, inProgress: 0, done: 0 };
1220
- return `- **${b.title}** \u2014 board #${b.number} \xB7 ${b.itemCount} item(s) (todo ${c.todo} \xB7 in progress ${c.inProgress} \xB7 done ${c.done})` + (b.description ? `
1221
- ${b.description}` : "") + `
1222
- ${b.url}`;
1223
- });
1224
- return lines.join("\n");
1167
+ function scoreKeywordMatch(item, keywords) {
1168
+ if (keywords.length === 0) return 0;
1169
+ const searchable = `${item.title} ${item.content}`.toLowerCase();
1170
+ let matches = 0;
1171
+ for (const kw of keywords) {
1172
+ if (searchable.includes(kw)) matches++;
1173
+ }
1174
+ return Math.min(matches / keywords.length, 1);
1175
+ }
1176
+ function scoreRecency(item) {
1177
+ if (!item.updatedAt) return 0.5;
1178
+ const now = Date.now();
1179
+ const updated = new Date(item.updatedAt).getTime();
1180
+ const daysSince = (now - updated) / (1e3 * 60 * 60 * 24);
1181
+ if (daysSince <= 7) return 1;
1182
+ if (daysSince <= 30) return 0.75;
1183
+ if (daysSince <= 90) return 0.5;
1184
+ return 0.25;
1185
+ }
1186
+ function scoreSeverity(item) {
1187
+ if (!item.severity) return 0.5;
1188
+ return SEVERITY_SCORES[item.severity] ?? 0.5;
1189
+ }
1190
+ function stem(word) {
1191
+ return word.length > 3 ? word.replace(/s$/, "") : word;
1192
+ }
1193
+ function stems(text3) {
1194
+ return new Set(
1195
+ text3.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(stem)
1196
+ );
1197
+ }
1198
+ function titleShare(title, keywords) {
1199
+ const wanted = stems(keywords.join(" "));
1200
+ if (wanted.size === 0) return 0;
1201
+ const held = stems(title);
1202
+ let matches = 0;
1203
+ for (const word of wanted) {
1204
+ if (held.has(word)) matches++;
1205
+ }
1206
+ return matches / wanted.size;
1207
+ }
1208
+ function scoreReference(item, keywords) {
1209
+ const share = titleShare(item.title, keywords);
1210
+ const score = Math.max(
1211
+ item.relevance ?? 0,
1212
+ share >= MIN_TITLE_SHARE ? share : 0
1213
+ );
1214
+ return RECORD_PATH.test(item.path ?? "") ? score * RECORD_FACTOR : score;
1215
+ }
1216
+ function extractKeywords(text3) {
1217
+ const stopWords = /* @__PURE__ */ new Set([
1218
+ "the",
1219
+ "a",
1220
+ "an",
1221
+ "is",
1222
+ "are",
1223
+ "was",
1224
+ "were",
1225
+ "be",
1226
+ "been",
1227
+ "being",
1228
+ "have",
1229
+ "has",
1230
+ "had",
1231
+ "do",
1232
+ "does",
1233
+ "did",
1234
+ "will",
1235
+ "would",
1236
+ "could",
1237
+ "should",
1238
+ "may",
1239
+ "might",
1240
+ "can",
1241
+ "shall",
1242
+ "to",
1243
+ "of",
1244
+ "in",
1245
+ "for",
1246
+ "on",
1247
+ "with",
1248
+ "at",
1249
+ "by",
1250
+ "from",
1251
+ "as",
1252
+ "into",
1253
+ "about",
1254
+ "between",
1255
+ "through",
1256
+ "after",
1257
+ "before",
1258
+ "during",
1259
+ "without",
1260
+ "this",
1261
+ "that",
1262
+ "these",
1263
+ "those",
1264
+ "it",
1265
+ "its",
1266
+ "and",
1267
+ "or",
1268
+ "but",
1269
+ "not",
1270
+ "no",
1271
+ "all",
1272
+ "each",
1273
+ "every",
1274
+ "both",
1275
+ "few",
1276
+ "more",
1277
+ "most",
1278
+ "other",
1279
+ "some",
1280
+ "such",
1281
+ "than",
1282
+ "too",
1283
+ "very",
1284
+ "just",
1285
+ "also",
1286
+ "only",
1287
+ "new",
1288
+ "add",
1289
+ "update",
1290
+ "fix",
1291
+ "change",
1292
+ "make",
1293
+ "use",
1294
+ "get",
1295
+ "set",
1296
+ "need",
1297
+ "want",
1298
+ "implement",
1299
+ "create"
1300
+ ]);
1301
+ return text3.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
1302
+ }
1303
+ function scoreItems(items, taskDescription, filePaths) {
1304
+ const languages = detectLanguages(filePaths);
1305
+ const categories = detectCategories(filePaths);
1306
+ const keywords = extractKeywords(taskDescription);
1307
+ return items.map((item) => {
1308
+ const pathScore = scorePathMatch(item, languages, categories);
1309
+ const keywordScore = scoreKeywordMatch(item, keywords);
1310
+ const recencyScore = scoreRecency(item);
1311
+ const severityScore = scoreSeverity(item);
1312
+ const isReference = item.type === "spec" || item.type === "doc";
1313
+ const score = isReference && keywords.length > 0 ? scoreReference(item, keywords) : pathScore * WEIGHT_PATH + keywordScore * WEIGHT_KEYWORD + recencyScore * WEIGHT_RECENCY + severityScore * WEIGHT_SEVERITY;
1314
+ return {
1315
+ type: item.type,
1316
+ id: item.id,
1317
+ title: item.title,
1318
+ content: item.content,
1319
+ score,
1320
+ tokenEstimate: estimateTokens(item.content)
1321
+ };
1322
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score);
1225
1323
  }
1226
- function formatItem(item) {
1227
- const meta = [];
1228
- if (item.labels.length > 0) meta.push(`labels: ${item.labels.join(", ")}`);
1229
- if (item.assignees.length > 0)
1230
- meta.push(`assignees: ${item.assignees.join(", ")}`);
1231
- const epic = item.fields?.Epic;
1232
- if (epic) meta.push(`epic: ${epic}`);
1233
- return `- #${item.number} ${item.title}${item.repo ? ` \u2014 ${item.repo}` : ""}` + (meta.length > 0 ? `
1234
- ${meta.join(" \xB7 ")}` : "") + (item.url ? `
1235
- ${item.url}` : "");
1324
+ function selectWithinBudget(items, maxTokens) {
1325
+ const selected = [];
1326
+ let tokensUsed = 0;
1327
+ for (const item of items) {
1328
+ if (tokensUsed + item.tokenEstimate > maxTokens) continue;
1329
+ selected.push(item);
1330
+ tokensUsed += item.tokenEstimate;
1331
+ }
1332
+ return { selected, tokensUsed, totalItems: items.length };
1236
1333
  }
1237
- function registerGetPlanBoard(server, apiClient) {
1238
- server.registerTool(
1239
- "get_plan_board",
1240
- {
1241
- title: "Get Plan Board",
1242
- description: "Read the GitHub Projects boards connected to this workspace. With no arguments, lists the open boards with their item counts by status. With project_number, lists that board's issues grouped by status (In Progress / Todo / Done), each with issue number, title, repo, labels, assignees and URL \u2014 so you can see what is planned or in flight and pick up the right issue. Read-only: this tool cannot create, move, or close board items.",
1243
- inputSchema: {
1244
- project_number: z5.number().int().positive().optional().describe(
1245
- "Board number (the N in github.com/orgs/{org}/projects/N). Omit to list the open boards."
1246
- ),
1247
- status: z5.enum(["todo", "in_progress", "done"]).optional().describe(
1248
- "With project_number, return only the items in this status group"
1249
- ),
1250
- limit: z5.number().int().positive().max(200).optional().describe(
1251
- `Max items to list (default ${DEFAULT_LIMIT}). Group headings always show the true totals.`
1252
- )
1253
- }
1254
- },
1255
- async ({ project_number, status, limit }) => {
1256
- if (project_number === void 0) {
1257
- const boards = await apiClient.get(
1258
- "/v1/planning/projects"
1259
- );
1260
- if (boards.length === 0) {
1261
- return {
1262
- content: [
1263
- {
1264
- type: "text",
1265
- text: `No project boards found. Connect a GitHub organisation and create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
1266
- }
1267
- ]
1268
- };
1269
- }
1270
- const open = boards.filter((b) => !b.closed);
1271
- if (open.length === 0) {
1272
- return {
1273
- content: [
1274
- {
1275
- type: "text",
1276
- text: `No open project boards (${boards.length} closed). Reopen one, or create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
1277
- }
1278
- ]
1279
- };
1280
- }
1281
- const closedNote = boards.length > open.length ? `
1282
-
1283
- _${boards.length - open.length} closed board(s) not listed._` : "";
1284
- return {
1285
- content: [
1286
- {
1287
- type: "text",
1288
- text: `${open.length} open project board(s):
1289
1334
 
1290
- ${formatBoards(open)}` + closedNote + `
1335
+ // src/lib/context-builder.ts
1336
+ var DEFAULT_BUDGET = 4e3;
1337
+ var RESERVE_TOKENS = 200;
1338
+ var GUARDRAIL_SHARE = 0.35;
1339
+ var MIN_BODY_TOKENS = 150;
1340
+ var MAX_HYDRATED = 8;
1341
+ var MAX_TAIL = 10;
1342
+ var TAIL_SHARE = 0.15;
1343
+ var TRUNCATION_NOTICE_TOKENS = 30;
1344
+ var STACK_SHARE = 0.1;
1345
+ var MIN_REFERENCES = 3;
1346
+ var SEARCH_RESULTS = 30;
1347
+ var MAX_QUERY_CHARS = 300;
1348
+ function formatGuardrail(g) {
1349
+ let text3 = `## ${g.title} [${g.severity}]
1291
1350
 
1292
- Call get_plan_board with project_number to list a board's issues.`
1293
- }
1294
- ]
1295
- };
1296
- }
1297
- const response = await apiClient.get(
1298
- `/v1/planning/projects/${project_number}/items`
1299
- );
1300
- const allItems = response.items ?? [];
1301
- const boardName = response.projectTitle ? `**${response.projectTitle}** \u2014 board #${project_number}` : `Board #${project_number}`;
1302
- if (allItems.length === 0) {
1303
- return {
1304
- content: [
1305
- {
1306
- type: "text",
1307
- text: `${boardName} has no issues, or it is not visible to this workspace's GitHub connection. Check the board at ${configLink("/plan", await workspaceBase(apiClient))}`
1308
- }
1309
- ]
1310
- };
1311
- }
1312
- const grouped = {
1313
- inProgress: [],
1314
- todo: [],
1315
- done: []
1316
- };
1317
- for (const item of allItems) grouped[bucketOf(item)].push(item);
1318
- const wanted = status ? [STATUS_ARG_TO_BUCKET[status]] : BUCKET_ORDER;
1319
- const shown = wanted.reduce((n, b) => n + grouped[b].length, 0);
1320
- if (shown === 0) {
1321
- return {
1322
- content: [
1323
- {
1324
- type: "text",
1325
- text: `${boardName}: no items with status "${status}" (of ${allItems.length} item(s) on the board).`
1326
- }
1327
- ]
1328
- };
1329
- }
1330
- const cap = limit ?? DEFAULT_LIMIT;
1331
- let budget = cap;
1332
- const sections = [];
1333
- let rendered = 0;
1334
- for (const b of wanted) {
1335
- const items = grouped[b];
1336
- if (items.length === 0) continue;
1337
- const slice = items.slice(0, Math.max(budget, 0));
1338
- budget -= slice.length;
1339
- rendered += slice.length;
1340
- const omitted = items.length - slice.length;
1341
- sections.push(
1342
- `### ${BUCKET_LABELS[b]} (${items.length})
1343
- ` + (slice.length > 0 ? slice.map(formatItem).join("\n") : "_(not listed \u2014 item limit reached)_") + (omitted > 0 && slice.length > 0 ? `
1344
- _\u2026${omitted} more not listed._` : "")
1345
- );
1346
- }
1347
- const notes = [];
1348
- if (rendered < shown) {
1349
- notes.push(
1350
- `_Listing ${rendered} of ${shown} matching item(s) \u2014 raise limit (max 200) or filter by status to see the rest._`
1351
- );
1352
- }
1353
- if (allItems.length >= API_ITEM_PAGE) {
1354
- notes.push(
1355
- `_The API reads the board's first ${API_ITEM_PAGE} items; a larger board may have more._`
1356
- );
1357
- }
1358
- return {
1359
- content: [
1360
- {
1361
- type: "text",
1362
- text: `${boardName} \u2014 ${allItems.length} item(s)
1351
+ ${g.description}`;
1352
+ if (g.pattern) text3 += `
1363
1353
 
1364
- ` + sections.join("\n\n") + (notes.length > 0 ? `
1354
+ **Bad:** \`\`\`
1355
+ ${g.pattern}
1356
+ \`\`\``;
1357
+ if (g.fix) text3 += `
1365
1358
 
1366
- ${notes.join("\n")}` : "")
1367
- }
1368
- ]
1369
- };
1370
- }
1371
- );
1359
+ **Good:** \`\`\`
1360
+ ${g.fix}
1361
+ \`\`\``;
1362
+ return text3;
1372
1363
  }
1373
-
1374
- // src/tools/get-change.ts
1375
- import { z as z6 } from "zod";
1376
- var GITHUB_NAME = "[A-Za-z0-9][A-Za-z0-9._-]{0,99}";
1377
- var REPO_RE = new RegExp(`^${GITHUB_NAME}/${GITHUB_NAME}$`);
1378
- var SLUG_RE2 = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1379
- var SLUG_MAX = 64;
1380
- var PARTS = ["decision", "contract", "behaviour", "plan", "release"];
1381
- var BRIEF = ["decision", "contract", "behaviour", "plan"];
1382
- var PAST_REVIEW = /* @__PURE__ */ new Set(["passed", "being built", "done"]);
1383
- function briefVerdict(states) {
1384
- const waiting = BRIEF.filter(
1385
- (part) => !(part === "decision" && states.decision === null) && !PAST_REVIEW.has(String(states[part]))
1386
- ).map((part) => `${part} is ${states[part] ?? "unknown"}`);
1387
- return waiting.length === 0 ? "The brief is passed" : `The brief is not passed: ${waiting.join(", ")}`;
1364
+ function formatSpecHeadline(s) {
1365
+ return `### ${s.name}
1366
+ \`draft_id: ${s.draftId}\` \xB7 ${s.status} \xB7 ${specLabel(s)}`;
1388
1367
  }
1389
- function prLink(pr) {
1390
- return `[${pr.repo}#${pr.number}](${pr.url}) ${pr.state}`;
1368
+ function formatDocHeadline(d) {
1369
+ return `### ${d.title}
1370
+ \`doc_id: ${d.id}\` \xB7 \`${d.repo_full_name}/${d.path}\` \xB7 ${d.doc_type}`;
1391
1371
  }
1392
- function fileSource(file) {
1393
- if (file.pullRequest) return prLink(file.pullRequest);
1394
- return file.direct ? "committed directly" : "\u2014";
1372
+ function formatStack(entries2) {
1373
+ return entries2.map(
1374
+ (e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
1375
+ ).join("\n");
1395
1376
  }
1396
- function partPullRequests(change, part) {
1397
- const files = part === "contract" ? change.contract.files : part === "behaviour" ? change.behaviour.files : part === "plan" ? [change.plan.file] : [];
1398
- const prs = part === "decision" ? [change.decision?.pullRequest ?? null] : part === "release" ? change.release.prs : files.map((file) => file?.pullRequest ?? null);
1399
- const unique = /* @__PURE__ */ new Map();
1400
- for (const pr of prs) {
1401
- if (pr && !unique.has(`${pr.repo}#${pr.number}`)) unique.set(`${pr.repo}#${pr.number}`, pr);
1377
+ function selectStack(entries2, words, maxTokens) {
1378
+ const named = (e) => {
1379
+ const entry = `${e.name} ${e.notes ?? ""}`.toLowerCase();
1380
+ return words.some((w) => entry.includes(w));
1381
+ };
1382
+ const ordered = [...entries2.filter(named), ...entries2.filter((e) => !named(e))];
1383
+ const shown = [];
1384
+ for (const entry of ordered) {
1385
+ if (shown.length > 0 && estimateTokens(formatStack([...shown, entry])) > maxTokens) {
1386
+ break;
1387
+ }
1388
+ shown.push(entry);
1402
1389
  }
1403
- return [...unique.values()].sort((a, b) => Number(b.state === "open") - Number(a.state === "open"));
1404
- }
1405
- function countsLine(counts) {
1406
- const tags = ["proposed", "built", "defect", "unbuilt", "open", "untagged"].filter((tag) => counts[tag] > 0).map((tag) => `${counts[tag]} ${tag}`);
1407
- return `${counts.total} scenario(s)` + (tags.length > 0 ? `: ${tags.join(", ")}` : "");
1408
- }
1409
- function partsTable(change) {
1410
- const rows = PARTS.filter((part) => !(part === "decision" && change.states.decision === null)).map((part) => {
1411
- const prs = partPullRequests(change, part);
1412
- return `| ${part} | ${change.states[part]} | ${prs.length > 0 ? prs.map(prLink).join(", ") : "\u2014"} |`;
1413
- });
1414
- return ["| Part | State | Pull request |", "|---|---|---|", ...rows].join("\n");
1390
+ return { text: formatStack(shown), hidden: entries2.length - shown.length };
1415
1391
  }
1416
- function decisionSection(change) {
1417
- const decision = change.decision;
1418
- if (decision === null) return "## Decision\n\nNone: the proposal names no ADR (a small change).";
1419
- const status = decision.status || "no record in a connected repository";
1420
- return `## Decision
1392
+ function formatRules(rules) {
1393
+ const byCategory = /* @__PURE__ */ new Map();
1394
+ for (const r of rules) {
1395
+ const cat = r.category || "other";
1396
+ if (!byCategory.has(cat)) byCategory.set(cat, []);
1397
+ byCategory.get(cat).push(r);
1398
+ }
1399
+ let text3 = "";
1400
+ for (const [cat, catRules] of byCategory) {
1401
+ text3 += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
1402
+ `;
1403
+ for (const r of catRules) {
1404
+ text3 += `- ${r.text}
1405
+ `;
1406
+ }
1407
+ text3 += "\n";
1408
+ }
1409
+ return `## Project Rules (${rules.length})
1421
1410
 
1422
- ${decision.adr} \xB7 ${status}${decision.title ? ` \u2014 ${decision.title}` : ""}`;
1411
+ ${text3.trim()}`;
1423
1412
  }
1424
- function contractSection(change) {
1425
- const files = change.contract.files;
1426
- 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).");
1413
+ async function findDocs(api, taskDescription) {
1414
+ const query = taskDescription.trim().slice(0, MAX_QUERY_CHARS);
1415
+ if (query) {
1416
+ try {
1417
+ const found = await api.get("/v1/docs", {
1418
+ q: query,
1419
+ limit: String(SEARCH_RESULTS)
1420
+ });
1421
+ if (found.items.length > 0) return { ...found, searched: true };
1422
+ } catch {
1423
+ }
1424
+ }
1425
+ const listed = await api.get("/v1/docs");
1426
+ return { ...listed, searched: false };
1427
1427
  }
1428
- function behaviourSection(change) {
1429
- const { files, counts } = change.behaviour;
1430
- if (files.length === 0) {
1431
- return `## Behaviour
1428
+ async function fetchSpecBody(api, draftId) {
1429
+ const spec = await api.get(
1430
+ `/v1/specs/${encodeURIComponent(draftId)}`
1431
+ );
1432
+ const parts = [];
1433
+ if (spec.proposalContent) {
1434
+ parts.push(`**Proposal**
1432
1435
 
1433
- 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}` : ""}.`;
1436
+ ${spec.proposalContent}`);
1434
1437
  }
1435
- const blocks = files.map(
1436
- (file) => [
1437
- `### ${file.path}`,
1438
- "",
1439
- [file.title, file.runner ? `runner ${file.runner}` : "", fileSource(file), countsLine(file.counts)].filter(Boolean).join(" \xB7 "),
1440
- "",
1441
- ...file.scenarios.map((s) => `- ${s.id || "(no id)"} \xB7 ${s.state || "untagged"} \xB7 ${s.title}`)
1442
- ].join("\n")
1443
- );
1444
- return ["## Behaviour", countsLine(counts), ...blocks].join("\n\n");
1445
- }
1446
- function planSection(change) {
1447
- const { file, draftId, board } = change.plan;
1448
- 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";
1449
- return [
1450
- "## Plan",
1451
- "",
1452
- `- plan.md: ${file ? `${file.path} \u2014 ${fileSource(file)}` : "none"}`,
1453
- `- Draft: ${draftId ?? "none"}`,
1454
- `- Board: ${boardLine}`
1455
- ].join("\n");
1438
+ for (const r of spec.requirements ?? []) {
1439
+ parts.push(`**${r.name}**
1440
+
1441
+ ${r.content}`);
1442
+ }
1443
+ if (spec.taskContent) {
1444
+ parts.push(`**Tasks**
1445
+
1446
+ ${spec.taskContent}`);
1447
+ }
1448
+ const body = parts.join("\n\n").trim();
1449
+ if (!body) throw new Error("spec has no content");
1450
+ return body;
1456
1451
  }
1457
- function releaseSection(change) {
1458
- const lines = change.release.prs.map(
1459
- (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")
1452
+ async function fetchDocBody(api, docId, anchor) {
1453
+ const doc = await api.get(
1454
+ `/v1/docs/${encodeURIComponent(docId)}/content`,
1455
+ anchor ? { section: anchor } : void 0
1460
1456
  );
1461
- return `## Release
1462
-
1463
- ${lines.length > 0 ? lines.join("\n") : "No release pull request is open."}`;
1457
+ if (doc.error) throw new Error(doc.error);
1458
+ const body = (doc.content ?? "").trim();
1459
+ if (!body) throw new Error("document is empty");
1460
+ return body;
1464
1461
  }
1465
- function formatChange(change) {
1466
- const header = `# ${change.title}
1467
-
1468
- \`${change.key}\` \xB7 size: ${change.size}` + (change.archived ? " \xB7 archived: read-only, done" : "") + ` \xB7 synced ${change.syncedAt}`;
1469
- return [
1470
- header,
1471
- partsTable(change),
1472
- decisionSection(change),
1473
- contractSection(change),
1474
- behaviourSection(change),
1475
- planSection(change),
1476
- releaseSection(change),
1477
- // Compact: a change's every scenario with its steps rides in this block.
1478
- "```json\n" + JSON.stringify(change) + "\n```",
1479
- briefVerdict(change.states)
1480
- ].join("\n\n");
1462
+ function githubDeepLink(repoFullName, path, anchor) {
1463
+ return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
1481
1464
  }
1482
- function registerGetChange(server, apiClient) {
1483
- server.registerTool(
1484
- "get_change",
1485
- {
1486
- title: "Get Change",
1487
- 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.`,
1488
- inputSchema: {
1489
- 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"),
1490
- 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>/")
1491
- },
1492
- annotations: { readOnlyHint: true }
1493
- },
1494
- async ({ repo, slug }) => {
1495
- const [owner, name] = repo.split("/");
1496
- let change;
1497
- try {
1498
- change = await apiClient.getFresh(
1499
- `/v1/changes/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${encodeURIComponent(slug)}`
1500
- );
1501
- } catch (err) {
1502
- if (err.status === 404) {
1503
- return {
1504
- content: [{ type: "text", text: `No such change ${repo}/${slug}` }],
1505
- isError: true
1506
- };
1507
- }
1508
- throw err;
1509
- }
1510
- return { content: [{ type: "text", text: formatChange(change) }] };
1465
+ function formatSectionPointer(repoFullName, path, s) {
1466
+ return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
1467
+ }
1468
+ var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
1469
+ "the",
1470
+ "a",
1471
+ "an",
1472
+ "of",
1473
+ "to",
1474
+ "and",
1475
+ "or",
1476
+ "for",
1477
+ "in",
1478
+ "on",
1479
+ "with",
1480
+ "how",
1481
+ "why",
1482
+ "what",
1483
+ "add",
1484
+ "new",
1485
+ "use",
1486
+ "using",
1487
+ "update",
1488
+ "fix"
1489
+ ]);
1490
+ function selectSection(outline, taskDescription) {
1491
+ if (!outline || outline.length === 0) return void 0;
1492
+ const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
1493
+ let best = outline[0];
1494
+ let bestScore = -1;
1495
+ for (const s of outline) {
1496
+ const heading = s.heading.toLowerCase();
1497
+ let score = 0;
1498
+ for (const kw of keywords) if (heading.includes(kw)) score++;
1499
+ if (score > bestScore) {
1500
+ best = s;
1501
+ bestScore = score;
1511
1502
  }
1512
- );
1503
+ }
1504
+ return best;
1505
+ }
1506
+ async function hydrate(api, item, meta) {
1507
+ try {
1508
+ const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
1509
+ return { item, meta, body };
1510
+ } catch (err) {
1511
+ return {
1512
+ item,
1513
+ meta,
1514
+ error: err instanceof Error ? err.message : String(err)
1515
+ };
1516
+ }
1513
1517
  }
1518
+ async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
1519
+ const budget = maxTokens ?? DEFAULT_BUDGET;
1520
+ const failures = [];
1521
+ const orEmpty = (empty) => (err) => {
1522
+ failures.push(err);
1523
+ return empty;
1524
+ };
1525
+ const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
1526
+ api.get("/v1/guardrails").catch(orEmpty([])),
1527
+ api.get("/v1/specs").catch(orEmpty([])),
1528
+ findDocs(api, taskDescription).catch(
1529
+ orEmpty({ items: [], searched: false })
1530
+ ),
1531
+ api.get("/v1/ai/settings/stack").catch(orEmpty([])),
1532
+ api.get("/v1/standards/rules").catch(orEmpty([])),
1533
+ // Capability states — FAIL-OPEN: an unreadable config (older backend, a
1534
+ // blip) gates nothing, it can never hide working context.
1535
+ api.get("/v1/doc-repos").catch(() => null)
1536
+ ]);
1537
+ if (failures.length === 5) throw failures[0];
1538
+ const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
1539
+ const guardrails = disabled("fitness") ? [] : rawGuardrails;
1540
+ const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
1541
+ const refs = /* @__PURE__ */ new Map();
1542
+ const key = (type, id) => `${type}:${id}`;
1543
+ const docTitles = new Set(docs.items.map((d) => d.title));
1544
+ const isCopyOfDoc = (s) => isDecisionDraft(s.name) && docTitles.has(s.name.slice("ADR: ".length));
1545
+ const scorable = [
1546
+ ...guardrails.map((g) => ({
1547
+ type: "guardrail",
1548
+ id: g.id,
1549
+ title: g.title,
1550
+ content: formatGuardrail(g),
1551
+ category: g.category,
1552
+ severity: g.severity,
1553
+ updatedAt: g.updatedAt
1554
+ })),
1555
+ ...specs.filter((s) => s.status !== "archived" && !isCopyOfDoc(s)).map((s) => {
1556
+ refs.set(key("spec", s.draftId), {
1557
+ type: "spec",
1558
+ id: s.draftId,
1559
+ title: s.name,
1560
+ listLine: `- **${s.name}** \u2014 \`draft_id: ${s.draftId}\` (${s.status}, ${specLabel(s)})`,
1561
+ fetchHint: `get_specs(draft_id="${s.draftId}")`
1562
+ });
1563
+ return {
1564
+ type: "spec",
1565
+ id: s.draftId,
1566
+ title: s.name,
1567
+ content: formatSpecHeadline(s),
1568
+ updatedAt: s.updatedAt
1569
+ };
1570
+ }),
1571
+ ...docs.items.map((d, position) => {
1572
+ const section = selectSection(d.outline, taskDescription);
1573
+ const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
1574
+ refs.set(key("doc", d.id), {
1575
+ type: "doc",
1576
+ id: d.id,
1577
+ title: d.title,
1578
+ section,
1579
+ pointer,
1580
+ listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
1581
+ \u21B3 ${pointer}` : ""),
1582
+ fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
1583
+ });
1584
+ return {
1585
+ type: "doc",
1586
+ id: d.id,
1587
+ title: d.title,
1588
+ content: formatDocHeadline(d),
1589
+ category: d.doc_type === "adr" ? "architecture" : void 0,
1590
+ updatedAt: d.synced_at,
1591
+ path: d.path,
1592
+ relevance: docs.searched ? 1 - position / (2 * docs.items.length) : void 0
1593
+ };
1594
+ })
1595
+ ];
1596
+ const ranked = scoreItems(scorable, taskDescription, filePaths);
1597
+ const sections = [];
1598
+ let overhead = 0;
1599
+ if (stack.length > 0) {
1600
+ const { text: text3, hidden } = selectStack(
1601
+ stack,
1602
+ [...extractKeywords(taskDescription), ...detectLanguages(filePaths)],
1603
+ Math.floor(budget * STACK_SHARE)
1604
+ );
1605
+ const section = `## Tech Stack
1514
1606
 
1515
- // src/tools/get-context.ts
1516
- import { z as z7 } from "zod";
1607
+ ${text3}` + (hidden > 0 ? `
1517
1608
 
1518
- // src/lib/scoring.ts
1519
- var WEIGHT_PATH = 0.4;
1520
- var WEIGHT_KEYWORD = 0.3;
1521
- var WEIGHT_RECENCY = 0.15;
1522
- var WEIGHT_SEVERITY = 0.15;
1523
- var SEVERITY_SCORES = {
1524
- critical: 1,
1525
- high: 0.75,
1526
- medium: 0.5,
1527
- low: 0.25
1528
- };
1529
- var MIN_TITLE_SHARE = 1 / 3;
1530
- var RECORD_PATH = /(^|\/)changelog\//;
1531
- var RECORD_FACTOR = 0.5;
1532
- var CHARS_PER_TOKEN = 4;
1533
- function estimateTokens(text3) {
1534
- return Math.ceil(text3.length / CHARS_PER_TOKEN);
1535
- }
1536
- function truncateToTokens(text3, maxTokens) {
1537
- const maxChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN;
1538
- if (text3.length <= maxChars) return { text: text3, truncated: false };
1539
- const head = text3.slice(0, maxChars);
1540
- const floor = Math.floor(maxChars * 0.75);
1541
- const paragraph = head.lastIndexOf("\n\n");
1542
- const line = head.lastIndexOf("\n");
1543
- let cut = maxChars;
1544
- if (paragraph >= floor) cut = paragraph;
1545
- else if (line >= floor) cut = line;
1546
- return { text: head.slice(0, cut).trimEnd(), truncated: true };
1547
- }
1548
- function scorePathMatch(item, languages, categories) {
1549
- if (languages.length === 0 && categories.length === 0) return 0.5;
1550
- let score = 0;
1551
- const itemLang = item.language?.toLowerCase() ?? "";
1552
- const itemCat = item.category?.toLowerCase() ?? "";
1553
- if (itemLang && languages.some((l) => itemLang.includes(l))) {
1554
- score += 0.6;
1555
- }
1556
- if (itemCat && categories.some((c) => itemCat.includes(c))) {
1557
- score += 0.4;
1609
+ _${hidden} more with \`get_tech_stack\`._` : "");
1610
+ sections.push(section);
1611
+ overhead += estimateTokens(section);
1558
1612
  }
1559
- return Math.min(score, 1);
1560
- }
1561
- function scoreKeywordMatch(item, keywords) {
1562
- if (keywords.length === 0) return 0;
1563
- const searchable = `${item.title} ${item.content}`.toLowerCase();
1564
- let matches = 0;
1565
- for (const kw of keywords) {
1566
- if (searchable.includes(kw)) matches++;
1613
+ if (rules.length > 0) {
1614
+ const section = formatRules(rules);
1615
+ sections.push(section);
1616
+ overhead += estimateTokens(section);
1567
1617
  }
1568
- return Math.min(matches / keywords.length, 1);
1569
- }
1570
- function scoreRecency(item) {
1571
- if (!item.updatedAt) return 0.5;
1572
- const now = Date.now();
1573
- const updated = new Date(item.updatedAt).getTime();
1574
- const daysSince = (now - updated) / (1e3 * 60 * 60 * 24);
1575
- if (daysSince <= 7) return 1;
1576
- if (daysSince <= 30) return 0.75;
1577
- if (daysSince <= 90) return 0.5;
1578
- return 0.25;
1579
- }
1580
- function scoreSeverity(item) {
1581
- if (!item.severity) return 0.5;
1582
- return SEVERITY_SCORES[item.severity] ?? 0.5;
1583
- }
1584
- function stem(word) {
1585
- return word.length > 3 ? word.replace(/s$/, "") : word;
1586
- }
1587
- function stems(text3) {
1588
- return new Set(
1589
- text3.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(stem)
1618
+ let remaining = budget - RESERVE_TOKENS - overhead;
1619
+ const rankedGuardrails = ranked.filter((i) => i.type === "guardrail");
1620
+ const { selected: selectedGuardrails, tokensUsed: guardrailTokens } = selectWithinBudget(
1621
+ rankedGuardrails,
1622
+ Math.max(0, Math.floor(remaining * GUARDRAIL_SHARE))
1590
1623
  );
1591
- }
1592
- function titleShare(title, keywords) {
1593
- const wanted = stems(keywords.join(" "));
1594
- if (wanted.size === 0) return 0;
1595
- const held = stems(title);
1596
- let matches = 0;
1597
- for (const word of wanted) {
1598
- if (held.has(word)) matches++;
1624
+ remaining -= guardrailTokens;
1625
+ const rankedRefs = ranked.filter(
1626
+ (i) => i.type === "spec" || i.type === "doc"
1627
+ );
1628
+ const avgHeadline = rankedRefs.length ? Math.ceil(
1629
+ rankedRefs.slice(0, MAX_HYDRATED).reduce((sum, i) => sum + i.tokenEstimate, 0) / Math.min(rankedRefs.length, MAX_HYDRATED)
1630
+ ) : 0;
1631
+ const affordable = Math.floor(
1632
+ Math.max(0, remaining) / (MIN_BODY_TOKENS + avgHeadline)
1633
+ );
1634
+ const shortlist = rankedRefs.slice(
1635
+ 0,
1636
+ Math.max(0, Math.min(MAX_HYDRATED, affordable))
1637
+ );
1638
+ for (const item of shortlist) {
1639
+ remaining -= item.tokenEstimate;
1599
1640
  }
1600
- return matches / wanted.size;
1601
- }
1602
- function scoreReference(item, keywords) {
1603
- const share = titleShare(item.title, keywords);
1604
- const score = Math.max(
1605
- item.relevance ?? 0,
1606
- share >= MIN_TITLE_SHARE ? share : 0
1641
+ remaining = Math.max(0, remaining);
1642
+ const hydrated = await Promise.all(
1643
+ shortlist.map(
1644
+ (item) => hydrate(api, item, refs.get(key(item.type, item.id)))
1645
+ )
1607
1646
  );
1608
- return RECORD_PATH.test(item.path ?? "") ? score * RECORD_FACTOR : score;
1609
- }
1610
- function extractKeywords(text3) {
1611
- const stopWords = /* @__PURE__ */ new Set([
1612
- "the",
1613
- "a",
1614
- "an",
1615
- "is",
1616
- "are",
1617
- "was",
1618
- "were",
1619
- "be",
1620
- "been",
1621
- "being",
1622
- "have",
1623
- "has",
1624
- "had",
1625
- "do",
1626
- "does",
1627
- "did",
1628
- "will",
1629
- "would",
1630
- "could",
1631
- "should",
1632
- "may",
1633
- "might",
1634
- "can",
1635
- "shall",
1636
- "to",
1637
- "of",
1638
- "in",
1639
- "for",
1640
- "on",
1641
- "with",
1642
- "at",
1643
- "by",
1644
- "from",
1645
- "as",
1646
- "into",
1647
- "about",
1648
- "between",
1649
- "through",
1650
- "after",
1651
- "before",
1652
- "during",
1653
- "without",
1654
- "this",
1655
- "that",
1656
- "these",
1657
- "those",
1658
- "it",
1659
- "its",
1660
- "and",
1661
- "or",
1662
- "but",
1663
- "not",
1664
- "no",
1665
- "all",
1666
- "each",
1667
- "every",
1668
- "both",
1669
- "few",
1670
- "more",
1671
- "most",
1672
- "other",
1673
- "some",
1674
- "such",
1675
- "than",
1676
- "too",
1677
- "very",
1678
- "just",
1679
- "also",
1680
- "only",
1681
- "new",
1682
- "add",
1683
- "update",
1684
- "fix",
1685
- "change",
1686
- "make",
1687
- "use",
1688
- "get",
1689
- "set",
1690
- "need",
1691
- "want",
1692
- "implement",
1693
- "create"
1694
- ]);
1695
- return text3.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
1696
- }
1697
- function scoreItems(items, taskDescription, filePaths) {
1698
- const languages = detectLanguages(filePaths);
1699
- const categories = detectCategories(filePaths);
1700
- const keywords = extractKeywords(taskDescription);
1701
- return items.map((item) => {
1702
- const pathScore = scorePathMatch(item, languages, categories);
1703
- const keywordScore = scoreKeywordMatch(item, keywords);
1704
- const recencyScore = scoreRecency(item);
1705
- const severityScore = scoreSeverity(item);
1706
- const isReference = item.type === "spec" || item.type === "doc";
1707
- const score = isReference && keywords.length > 0 ? scoreReference(item, keywords) : pathScore * WEIGHT_PATH + keywordScore * WEIGHT_KEYWORD + recencyScore * WEIGHT_RECENCY + severityScore * WEIGHT_SEVERITY;
1708
- return {
1709
- type: item.type,
1710
- id: item.id,
1711
- title: item.title,
1712
- content: item.content,
1713
- score,
1714
- tokenEstimate: estimateTokens(item.content)
1647
+ const tailCandidates = rankedRefs.slice(shortlist.length, shortlist.length + MAX_TAIL).map((i) => refs.get(key(i.type, i.id)));
1648
+ const tailCost = tailCandidates.reduce(
1649
+ (sum, m) => sum + estimateTokens(m.listLine),
1650
+ 0
1651
+ );
1652
+ const tailReserve = Math.min(
1653
+ tailCost,
1654
+ Math.floor(remaining * TAIL_SHARE)
1655
+ );
1656
+ const bodyBudgetStart = Math.max(0, remaining - tailReserve);
1657
+ let bodyBudget = bodyBudgetStart;
1658
+ let itemsLeft = hydrated.length;
1659
+ let itemsWithBody = 0;
1660
+ let bodyFailures = 0;
1661
+ const blocks = [];
1662
+ for (const h of hydrated) {
1663
+ itemsLeft--;
1664
+ const headline = h.meta.pointer ? `${h.item.content}
1665
+ ${h.meta.pointer}` : h.item.content;
1666
+ const emit = (block) => {
1667
+ bodyBudget -= Math.max(
1668
+ 0,
1669
+ estimateTokens(block) - h.item.tokenEstimate
1670
+ );
1671
+ blocks.push(block);
1715
1672
  };
1716
- }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score);
1717
- }
1718
- function selectWithinBudget(items, maxTokens) {
1719
- const selected = [];
1720
- let tokensUsed = 0;
1721
- for (const item of items) {
1722
- if (tokensUsed + item.tokenEstimate > maxTokens) continue;
1723
- selected.push(item);
1724
- tokensUsed += item.tokenEstimate;
1673
+ if (h.error !== void 0) {
1674
+ bodyFailures++;
1675
+ emit(
1676
+ `${headline}
1677
+
1678
+ _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
1679
+ );
1680
+ continue;
1681
+ }
1682
+ const available = Math.max(0, bodyBudget);
1683
+ const allowance = Math.min(
1684
+ Math.max(MIN_BODY_TOKENS, Math.floor(available / (itemsLeft + 1))),
1685
+ available
1686
+ );
1687
+ if (allowance < MIN_BODY_TOKENS) {
1688
+ emit(
1689
+ `${headline}
1690
+
1691
+ _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
1692
+ );
1693
+ continue;
1694
+ }
1695
+ const { text: body2, truncated } = truncateToTokens(
1696
+ h.body,
1697
+ allowance - TRUNCATION_NOTICE_TOKENS
1698
+ );
1699
+ itemsWithBody++;
1700
+ emit(
1701
+ `${headline}
1702
+
1703
+ ${body2}` + (truncated ? `
1704
+
1705
+ _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`]_` : "")
1706
+ );
1725
1707
  }
1726
- return { selected, tokensUsed, totalItems: items.length };
1727
- }
1708
+ remaining = Math.max(0, remaining - (bodyBudgetStart - bodyBudget));
1709
+ if (selectedGuardrails.length > 0) {
1710
+ sections.push(
1711
+ `## Checks (${selectedGuardrails.length})
1728
1712
 
1729
- // src/lib/context-builder.ts
1730
- var DEFAULT_BUDGET = 4e3;
1731
- var RESERVE_TOKENS = 200;
1732
- var GUARDRAIL_SHARE = 0.35;
1733
- var MIN_BODY_TOKENS = 150;
1734
- var MAX_HYDRATED = 8;
1735
- var MAX_TAIL = 10;
1736
- var TAIL_SHARE = 0.15;
1737
- var TRUNCATION_NOTICE_TOKENS = 30;
1738
- var STACK_SHARE = 0.1;
1739
- var MIN_REFERENCES = 3;
1740
- var SEARCH_RESULTS = 30;
1741
- var MAX_QUERY_CHARS = 300;
1742
- function formatGuardrail(g) {
1743
- let text3 = `## ${g.title} [${g.severity}]
1713
+ ` + selectedGuardrails.map((i) => i.content).join("\n\n---\n\n")
1714
+ );
1715
+ }
1716
+ if (blocks.length > 0) {
1717
+ sections.push(
1718
+ `## Relevant Specs & Docs (${blocks.length})
1744
1719
 
1745
- ${g.description}`;
1746
- if (g.pattern) text3 += `
1720
+ _Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
1747
1721
 
1748
- **Bad:** \`\`\`
1749
- ${g.pattern}
1750
- \`\`\``;
1751
- if (g.fix) text3 += `
1722
+ ` + blocks.join("\n\n---\n\n")
1723
+ );
1724
+ }
1725
+ const tailLines = [];
1726
+ for (const meta of tailCandidates) {
1727
+ const cost = estimateTokens(meta.listLine);
1728
+ const named = blocks.length + tailLines.length;
1729
+ if (cost > remaining && named >= MIN_REFERENCES) break;
1730
+ tailLines.push(meta.listLine);
1731
+ remaining = Math.max(0, remaining - cost);
1732
+ }
1733
+ if (tailLines.length > 0) {
1734
+ sections.push(
1735
+ `## Also Indexed (${tailLines.length})
1752
1736
 
1753
- **Good:** \`\`\`
1754
- ${g.fix}
1755
- \`\`\``;
1756
- return text3;
1737
+ _Ranked lower \u2014 fetch with \`get_specs\` / \`get_docs\` if you need them._
1738
+
1739
+ ` + tailLines.join("\n")
1740
+ );
1741
+ }
1742
+ const itemsListed = blocks.length - itemsWithBody + tailLines.length;
1743
+ const itemsOmitted = ranked.length - selectedGuardrails.length - blocks.length - tailLines.length;
1744
+ const body = sections.join("\n\n");
1745
+ const tokensUsed = estimateTokens(body);
1746
+ const stats = {
1747
+ budget,
1748
+ tokensUsed,
1749
+ itemsWithBody,
1750
+ bodyFailures,
1751
+ itemsListed,
1752
+ itemsOmitted: Math.max(0, itemsOmitted),
1753
+ guardrails: selectedGuardrails.length
1754
+ };
1755
+ if (sections.length === 0) {
1756
+ 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.";
1757
+ return { text: text3, stats };
1758
+ }
1759
+ const footer = `
1760
+
1761
+ ---
1762
+ _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` : "") + `_`;
1763
+ return { text: body + footer, stats };
1757
1764
  }
1758
- function formatSpecHeadline(s) {
1759
- return `### ${s.name}
1760
- \`draft_id: ${s.draftId}\` \xB7 ${s.status} \xB7 ${specLabel(s)}`;
1765
+
1766
+ // src/tools/get-context.ts
1767
+ function registerGetContext(server, apiClient) {
1768
+ server.registerTool(
1769
+ "get_context",
1770
+ {
1771
+ title: "Get Context",
1772
+ 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.",
1773
+ inputSchema: {
1774
+ task_description: z7.string().describe(
1775
+ "What you're working on (e.g., 'add a new REST endpoint for user preferences')"
1776
+ ),
1777
+ file_paths: z7.array(z7.string()).optional().describe(
1778
+ "File paths being edited \u2014 used for language/category detection"
1779
+ ),
1780
+ max_tokens: z7.number().optional().describe("Maximum token budget for the response (default: 4000)")
1781
+ }
1782
+ },
1783
+ async ({ task_description, file_paths, max_tokens }) => {
1784
+ const { text: text3 } = await buildContext(apiClient, {
1785
+ taskDescription: task_description,
1786
+ filePaths: file_paths,
1787
+ maxTokens: max_tokens
1788
+ });
1789
+ return {
1790
+ content: [{ type: "text", text: text3 }]
1791
+ };
1792
+ }
1793
+ );
1761
1794
  }
1762
- function formatDocHeadline(d) {
1763
- return `### ${d.title}
1764
- \`doc_id: ${d.id}\` \xB7 \`${d.repo_full_name}/${d.path}\` \xB7 ${d.doc_type}`;
1795
+
1796
+ // src/lib/session-brief.ts
1797
+ import { existsSync as existsSync2 } from "fs";
1798
+ import { readFile as readFile3 } from "fs/promises";
1799
+ import { join as join3 } from "path";
1800
+
1801
+ // src/lib/skills.ts
1802
+ import { createHash } from "crypto";
1803
+ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
1804
+ import { existsSync } from "fs";
1805
+ import { homedir } from "os";
1806
+ import { dirname, join, relative, resolve, sep } from "path";
1807
+ var MANIFEST_FILE = ".systemdox-skill.json";
1808
+ var ENTRY_FILE = "SKILL.md";
1809
+ var PLUGIN_MANIFEST_FOLDER = ".claude-plugin";
1810
+ var MAX_FILES = 100;
1811
+ var MAX_FILE_BYTES = 256 * 1024;
1812
+ var MAX_TOTAL_BYTES = 1024 * 1024;
1813
+ var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1814
+ var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
1815
+ var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
1816
+ var SkillInstallRefused = class extends Error {
1817
+ constructor(dest, edited) {
1818
+ super(
1819
+ `${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
1820
+ );
1821
+ this.dest = dest;
1822
+ this.edited = edited;
1823
+ }
1824
+ dest;
1825
+ edited;
1826
+ };
1827
+ function sha256(content) {
1828
+ return createHash("sha256").update(content, "utf8").digest("hex");
1765
1829
  }
1766
- function formatStack(entries2) {
1767
- return entries2.map(
1768
- (e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
1769
- ).join("\n");
1830
+ function isValidName(name) {
1831
+ return name.length <= 64 && NAME_RE.test(name);
1770
1832
  }
1771
- function selectStack(entries2, words, maxTokens) {
1772
- const named = (e) => {
1773
- const entry = `${e.name} ${e.notes ?? ""}`.toLowerCase();
1774
- return words.some((w) => entry.includes(w));
1775
- };
1776
- const ordered = [...entries2.filter(named), ...entries2.filter((e) => !named(e))];
1777
- const shown = [];
1778
- for (const entry of ordered) {
1779
- if (shown.length > 0 && estimateTokens(formatStack([...shown, entry])) > maxTokens) {
1780
- break;
1781
- }
1782
- shown.push(entry);
1783
- }
1784
- return { text: formatStack(shown), hidden: entries2.length - shown.length };
1833
+ function isValidPath(path) {
1834
+ return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== ".." && s !== PLUGIN_MANIFEST_FOLDER);
1785
1835
  }
1786
- function formatRules(rules) {
1787
- const byCategory = /* @__PURE__ */ new Map();
1788
- for (const r of rules) {
1789
- const cat = r.category || "other";
1790
- if (!byCategory.has(cat)) byCategory.set(cat, []);
1791
- byCategory.get(cat).push(r);
1792
- }
1793
- let text3 = "";
1794
- for (const [cat, catRules] of byCategory) {
1795
- text3 += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
1796
- `;
1797
- for (const r of catRules) {
1798
- text3 += `- ${r.text}
1799
- `;
1800
- }
1801
- text3 += "\n";
1836
+ function frontmatterName(skillMd) {
1837
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
1838
+ if (!block) return void 0;
1839
+ const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
1840
+ return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
1841
+ }
1842
+ function defaultSkillDir(name) {
1843
+ return join(homedir(), ".claude", "skills", name);
1844
+ }
1845
+ async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
1846
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
1847
+ if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
1848
+ const full = join(dir, entry.name);
1849
+ const rel = relative(root, full).split(sep).join("/");
1850
+ if (entry.isSymbolicLink()) out.links.push(rel);
1851
+ else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
1852
+ else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
1853
+ else if (entry.isFile()) out.files.push(rel);
1802
1854
  }
1803
- return `## Project Rules (${rules.length})
1804
-
1805
- ${text3.trim()}`;
1855
+ out.files.sort();
1856
+ return out;
1806
1857
  }
1807
- async function findDocs(api, taskDescription) {
1808
- const query = taskDescription.trim().slice(0, MAX_QUERY_CHARS);
1809
- if (query) {
1858
+ async function readTextFiles(root, paths, what) {
1859
+ if (paths.length > MAX_FILES) {
1860
+ throw new Error(`${root} holds ${paths.length} files: ${what} holds at most ${MAX_FILES}`);
1861
+ }
1862
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1863
+ const files = [];
1864
+ let total = 0;
1865
+ for (const path of paths) {
1866
+ if (!isValidPath(path)) {
1867
+ throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
1868
+ }
1869
+ const raw = await readFile(join(root, ...path.split("/")));
1870
+ if (raw.includes(0)) throw new Error(`${path} is binary: ${what} holds text files only`);
1871
+ if (raw.length > MAX_FILE_BYTES) {
1872
+ throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
1873
+ }
1874
+ total += raw.length;
1875
+ if (total > MAX_TOTAL_BYTES) {
1876
+ throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
1877
+ }
1878
+ let content;
1810
1879
  try {
1811
- const found = await api.get("/v1/docs", {
1812
- q: query,
1813
- limit: String(SEARCH_RESULTS)
1814
- });
1815
- if (found.items.length > 0) return { ...found, searched: true };
1880
+ content = decoder.decode(raw);
1816
1881
  } catch {
1882
+ throw new Error(`${path} is not UTF-8 text`);
1817
1883
  }
1884
+ files.push({ path, content });
1818
1885
  }
1819
- const listed = await api.get("/v1/docs");
1820
- return { ...listed, searched: false };
1886
+ return files;
1821
1887
  }
1822
- async function fetchSpecBody(api, draftId) {
1823
- const spec = await api.get(
1824
- `/v1/specs/${encodeURIComponent(draftId)}`
1825
- );
1826
- const parts = [];
1827
- if (spec.proposalContent) {
1828
- parts.push(`**Proposal**
1829
-
1830
- ${spec.proposalContent}`);
1831
- }
1832
- for (const r of spec.requirements ?? []) {
1833
- parts.push(`**${r.name}**
1834
-
1835
- ${r.content}`);
1888
+ async function readSkillDirectory(directory) {
1889
+ const root = resolve(directory);
1890
+ if (!existsSync(join(root, ENTRY_FILE))) {
1891
+ throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
1836
1892
  }
1837
- if (spec.taskContent) {
1838
- parts.push(`**Tasks**
1839
-
1840
- ${spec.taskContent}`);
1893
+ const walked = await walk(root, { skipHidden: true });
1894
+ if (walked.links.length) {
1895
+ throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
1841
1896
  }
1842
- const body = parts.join("\n\n").trim();
1843
- if (!body) throw new Error("spec has no content");
1844
- return body;
1845
- }
1846
- async function fetchDocBody(api, docId, anchor) {
1847
- const doc = await api.get(
1848
- `/v1/docs/${encodeURIComponent(docId)}/content`,
1849
- anchor ? { section: anchor } : void 0
1850
- );
1851
- if (doc.error) throw new Error(doc.error);
1852
- const body = (doc.content ?? "").trim();
1853
- if (!body) throw new Error("document is empty");
1854
- return body;
1855
- }
1856
- function githubDeepLink(repoFullName, path, anchor) {
1857
- return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
1858
- }
1859
- function formatSectionPointer(repoFullName, path, s) {
1860
- return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
1897
+ const files = await readTextFiles(root, walked.files, "a skill");
1898
+ const skillMd = files.find((f) => f.path === ENTRY_FILE);
1899
+ const name = frontmatterName(skillMd.content);
1900
+ if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
1901
+ return { name, files, skipped: walked.hidden };
1861
1902
  }
1862
- var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
1863
- "the",
1864
- "a",
1865
- "an",
1866
- "of",
1867
- "to",
1868
- "and",
1869
- "or",
1870
- "for",
1871
- "in",
1872
- "on",
1873
- "with",
1874
- "how",
1875
- "why",
1876
- "what",
1877
- "add",
1878
- "new",
1879
- "use",
1880
- "using",
1881
- "update",
1882
- "fix"
1883
- ]);
1884
- function selectSection(outline, taskDescription) {
1885
- if (!outline || outline.length === 0) return void 0;
1886
- const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
1887
- let best = outline[0];
1888
- let bestScore = -1;
1889
- for (const s of outline) {
1890
- const heading = s.heading.toLowerCase();
1891
- let score = 0;
1892
- for (const kw of keywords) if (heading.includes(kw)) score++;
1893
- if (score > bestScore) {
1894
- best = s;
1895
- bestScore = score;
1896
- }
1897
- }
1898
- return best;
1903
+ function within(dest, path) {
1904
+ if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
1905
+ const full = resolve(dest, ...path.split("/"));
1906
+ if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
1907
+ return full;
1899
1908
  }
1900
- async function hydrate(api, item, meta) {
1909
+ async function readManifest(dest) {
1901
1910
  try {
1902
- const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
1903
- return { item, meta, body };
1904
- } catch (err) {
1905
- return {
1906
- item,
1907
- meta,
1908
- error: err instanceof Error ? err.message : String(err)
1909
- };
1911
+ return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
1912
+ } catch {
1913
+ return void 0;
1910
1914
  }
1911
1915
  }
1912
- async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
1913
- const budget = maxTokens ?? DEFAULT_BUDGET;
1914
- const failures = [];
1915
- const orEmpty = (empty) => (err) => {
1916
- failures.push(err);
1917
- return empty;
1918
- };
1919
- const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
1920
- api.get("/v1/guardrails").catch(orEmpty([])),
1921
- api.get("/v1/specs").catch(orEmpty([])),
1922
- findDocs(api, taskDescription).catch(
1923
- orEmpty({ items: [], searched: false })
1924
- ),
1925
- api.get("/v1/ai/settings/stack").catch(orEmpty([])),
1926
- api.get("/v1/standards/rules").catch(orEmpty([])),
1927
- // Capability states — FAIL-OPEN: an unreadable config (older backend, a
1928
- // blip) gates nothing, it can never hide working context.
1929
- api.get("/v1/doc-repos").catch(() => null)
1930
- ]);
1931
- if (failures.length === 5) throw failures[0];
1932
- const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
1933
- const guardrails = disabled("fitness") ? [] : rawGuardrails;
1934
- const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
1935
- const refs = /* @__PURE__ */ new Map();
1936
- const key = (type, id) => `${type}:${id}`;
1937
- const docTitles = new Set(docs.items.map((d) => d.title));
1938
- const isCopyOfDoc = (s) => isDecisionDraft(s.name) && docTitles.has(s.name.slice("ADR: ".length));
1939
- const scorable = [
1940
- ...guardrails.map((g) => ({
1941
- type: "guardrail",
1942
- id: g.id,
1943
- title: g.title,
1944
- content: formatGuardrail(g),
1945
- category: g.category,
1946
- severity: g.severity,
1947
- updatedAt: g.updatedAt
1948
- })),
1949
- ...specs.filter((s) => s.status !== "archived" && !isCopyOfDoc(s)).map((s) => {
1950
- refs.set(key("spec", s.draftId), {
1951
- type: "spec",
1952
- id: s.draftId,
1953
- title: s.name,
1954
- listLine: `- **${s.name}** \u2014 \`draft_id: ${s.draftId}\` (${s.status}, ${specLabel(s)})`,
1955
- fetchHint: `get_specs(draft_id="${s.draftId}")`
1956
- });
1957
- return {
1958
- type: "spec",
1959
- id: s.draftId,
1960
- title: s.name,
1961
- content: formatSpecHeadline(s),
1962
- updatedAt: s.updatedAt
1963
- };
1964
- }),
1965
- ...docs.items.map((d, position) => {
1966
- const section = selectSection(d.outline, taskDescription);
1967
- const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
1968
- refs.set(key("doc", d.id), {
1969
- type: "doc",
1970
- id: d.id,
1971
- title: d.title,
1972
- section,
1973
- pointer,
1974
- listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
1975
- \u21B3 ${pointer}` : ""),
1976
- fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
1977
- });
1978
- return {
1979
- type: "doc",
1980
- id: d.id,
1981
- title: d.title,
1982
- content: formatDocHeadline(d),
1983
- category: d.doc_type === "adr" ? "architecture" : void 0,
1984
- updatedAt: d.synced_at,
1985
- path: d.path,
1986
- relevance: docs.searched ? 1 - position / (2 * docs.items.length) : void 0
1987
- };
1988
- })
1916
+ async function installSkill(version, dest, { overwrite = false } = {}) {
1917
+ if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
1918
+ const incoming = new Map(version.files.map((f) => [f.path, f]));
1919
+ for (const f of version.files) within(dest, f.path);
1920
+ if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
1921
+ throw new Error(`${dest} is a link: install into a real folder`);
1922
+ }
1923
+ const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
1924
+ if (walked.links.length) {
1925
+ throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
1926
+ }
1927
+ const present = walked.files;
1928
+ const manifest = await readManifest(dest);
1929
+ const known2 = Object.fromEntries(
1930
+ Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
1931
+ );
1932
+ const edited = [];
1933
+ const untracked = [];
1934
+ for (const path of present) {
1935
+ const content = await readFile(join(dest, ...path.split("/")), "utf8");
1936
+ if (Object.hasOwn(known2, path)) {
1937
+ if (sha256(content) !== known2[path]) edited.push(path);
1938
+ } else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
1939
+ if (incoming.has(path) || !manifest) edited.push(path);
1940
+ else untracked.push(path);
1941
+ }
1942
+ }
1943
+ const blocking = edited.filter((p) => incoming.has(p) || !manifest);
1944
+ if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
1945
+ const written = [];
1946
+ for (const f of version.files) {
1947
+ const full = within(dest, f.path);
1948
+ await mkdir(dirname(full), { recursive: true });
1949
+ await writeFile(full, f.content, "utf8");
1950
+ written.push(f.path);
1951
+ }
1952
+ const removed = [];
1953
+ const keptEdited = [];
1954
+ for (const path of Object.keys(known2)) {
1955
+ if (incoming.has(path) || !present.includes(path)) continue;
1956
+ if (edited.includes(path)) {
1957
+ keptEdited.push(path);
1958
+ continue;
1959
+ }
1960
+ await rm(within(dest, path));
1961
+ removed.push(path);
1962
+ }
1963
+ const next = {
1964
+ name: version.name,
1965
+ version: version.version,
1966
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
1967
+ files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
1968
+ };
1969
+ await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
1970
+ return {
1971
+ dest,
1972
+ version: version.version,
1973
+ written,
1974
+ removed,
1975
+ keptUntracked: untracked.filter((p) => !incoming.has(p)),
1976
+ keptEdited
1977
+ };
1978
+ }
1979
+ function fenceFor(text3) {
1980
+ const longest = Math.max(0, ...(text3.match(/`+/g) ?? []).map((r) => r.length));
1981
+ return "`".repeat(Math.max(3, longest + 1));
1982
+ }
1983
+ function afterInstall(skillMd) {
1984
+ return `No restart is needed: a running Claude Code session picks it up. If one does not list it yet, read ${skillMd} and follow it, or run /reload-skills.`;
1985
+ }
1986
+ function describeInstall(name, r) {
1987
+ const lines = [
1988
+ `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
1989
1989
  ];
1990
- const ranked = scoreItems(scorable, taskDescription, filePaths);
1991
- const sections = [];
1992
- let overhead = 0;
1993
- if (stack.length > 0) {
1994
- const { text: text3, hidden } = selectStack(
1995
- stack,
1996
- [...extractKeywords(taskDescription), ...detectLanguages(filePaths)],
1997
- Math.floor(budget * STACK_SHARE)
1998
- );
1999
- const section = `## Tech Stack
2000
-
2001
- ${text3}` + (hidden > 0 ? `
2002
-
2003
- _${hidden} more with \`get_tech_stack\`._` : "");
2004
- sections.push(section);
2005
- overhead += estimateTokens(section);
1990
+ if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
1991
+ if (r.keptEdited.length) {
1992
+ lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
2006
1993
  }
2007
- if (rules.length > 0) {
2008
- const section = formatRules(rules);
2009
- sections.push(section);
2010
- overhead += estimateTokens(section);
1994
+ if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
1995
+ lines.push(afterInstall(join(r.dest, "SKILL.md")));
1996
+ return lines.join("\n");
1997
+ }
1998
+
1999
+ // src/lib/team-setup-files.ts
2000
+ import { execFile } from "child_process";
2001
+ import { chmod, mkdir as mkdir2, readFile as readFile2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
2002
+ import { homedir as homedir2, userInfo } from "os";
2003
+ import { dirname as dirname2, join as join2 } from "path";
2004
+ import { promisify } from "util";
2005
+ var SLUG_RE2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2006
+ function isWorkspace(value) {
2007
+ return typeof value === "string" && value.length <= 64 && SLUG_RE2.test(value);
2008
+ }
2009
+ function homeDir() {
2010
+ return homedir2();
2011
+ }
2012
+ function systemdoxDir() {
2013
+ return join2(homeDir(), ".claude", "systemdox");
2014
+ }
2015
+ function workspaceDir(workspace) {
2016
+ if (!isWorkspace(workspace)) throw new Error(`${JSON.stringify(workspace)} is not a workspace name`);
2017
+ return join2(systemdoxDir(), workspace);
2018
+ }
2019
+ function statePath(workspace) {
2020
+ return join2(workspaceDir(workspace), "state.json");
2021
+ }
2022
+ async function readJson(path, empty) {
2023
+ let text3;
2024
+ try {
2025
+ text3 = await readFile2(path, "utf8");
2026
+ } catch (e) {
2027
+ if (e.code === "ENOENT") return empty;
2028
+ throw e;
2011
2029
  }
2012
- let remaining = budget - RESERVE_TOKENS - overhead;
2013
- const rankedGuardrails = ranked.filter((i) => i.type === "guardrail");
2014
- const { selected: selectedGuardrails, tokensUsed: guardrailTokens } = selectWithinBudget(
2015
- rankedGuardrails,
2016
- Math.max(0, Math.floor(remaining * GUARDRAIL_SHARE))
2017
- );
2018
- remaining -= guardrailTokens;
2019
- const rankedRefs = ranked.filter(
2020
- (i) => i.type === "spec" || i.type === "doc"
2021
- );
2022
- const avgHeadline = rankedRefs.length ? Math.ceil(
2023
- rankedRefs.slice(0, MAX_HYDRATED).reduce((sum, i) => sum + i.tokenEstimate, 0) / Math.min(rankedRefs.length, MAX_HYDRATED)
2024
- ) : 0;
2025
- const affordable = Math.floor(
2026
- Math.max(0, remaining) / (MIN_BODY_TOKENS + avgHeadline)
2027
- );
2028
- const shortlist = rankedRefs.slice(
2029
- 0,
2030
- Math.max(0, Math.min(MAX_HYDRATED, affordable))
2031
- );
2032
- for (const item of shortlist) {
2033
- remaining -= item.tokenEstimate;
2030
+ try {
2031
+ return JSON.parse(text3);
2032
+ } catch {
2033
+ throw new Error(`${path} is not valid JSON: fix or remove it, then run this again`);
2034
2034
  }
2035
- remaining = Math.max(0, remaining);
2036
- const hydrated = await Promise.all(
2037
- shortlist.map(
2038
- (item) => hydrate(api, item, refs.get(key(item.type, item.id)))
2039
- )
2040
- );
2041
- const tailCandidates = rankedRefs.slice(shortlist.length, shortlist.length + MAX_TAIL).map((i) => refs.get(key(i.type, i.id)));
2042
- const tailCost = tailCandidates.reduce(
2043
- (sum, m) => sum + estimateTokens(m.listLine),
2044
- 0
2045
- );
2046
- const tailReserve = Math.min(
2047
- tailCost,
2048
- Math.floor(remaining * TAIL_SHARE)
2049
- );
2050
- const bodyBudgetStart = Math.max(0, remaining - tailReserve);
2051
- let bodyBudget = bodyBudgetStart;
2052
- let itemsLeft = hydrated.length;
2053
- let itemsWithBody = 0;
2054
- let bodyFailures = 0;
2055
- const blocks = [];
2056
- for (const h of hydrated) {
2057
- itemsLeft--;
2058
- const headline = h.meta.pointer ? `${h.item.content}
2059
- ${h.meta.pointer}` : h.item.content;
2060
- const emit = (block) => {
2061
- bodyBudget -= Math.max(
2062
- 0,
2063
- estimateTokens(block) - h.item.tokenEstimate
2064
- );
2065
- blocks.push(block);
2066
- };
2067
- if (h.error !== void 0) {
2068
- bodyFailures++;
2069
- emit(
2070
- `${headline}
2035
+ }
2036
+ async function writeJsonAtomically(path, value, mode) {
2037
+ await mkdir2(dirname2(path), { recursive: true });
2038
+ const tmp = `${path}.${process.pid}.tmp`;
2039
+ await writeFile2(tmp, JSON.stringify(value, null, 2) + "\n", mode === void 0 ? "utf8" : { encoding: "utf8", mode });
2040
+ await rename(tmp, path);
2041
+ }
2042
+ var run = promisify(execFile);
2043
+ async function readState(workspace) {
2044
+ return readJson(statePath(workspace), {});
2045
+ }
2046
+ async function writeState(workspace, state) {
2047
+ await writeJsonAtomically(statePath(workspace), state);
2048
+ }
2049
+ async function updateState(workspace, change) {
2050
+ const state = await readState(workspace);
2051
+ change(state);
2052
+ await writeState(workspace, state);
2053
+ return state;
2054
+ }
2071
2055
 
2072
- _Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
2073
- );
2056
+ // src/lib/session-brief.ts
2057
+ var INSTRUCTIONS_MAX = 2e3;
2058
+ var BRIEF_TIMEOUT_MS = 8e3;
2059
+ var SETUP_COMMAND = "npx -y @systemdox/mcp-server setup";
2060
+ 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.`;
2061
+ 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.";
2062
+ 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.";
2063
+ function noKey(reason) {
2064
+ 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.`;
2065
+ }
2066
+ function olderApi(setup) {
2067
+ 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.`;
2068
+ }
2069
+ async function readJsonQuietly(path) {
2070
+ try {
2071
+ return JSON.parse(await readFile3(path, "utf8"));
2072
+ } catch {
2073
+ return void 0;
2074
+ }
2075
+ }
2076
+ async function computerState(setup) {
2077
+ const id = `${setup.workspace}-setup@systemdox-${setup.workspace}`;
2078
+ const installed = await readJsonQuietly(join3(homeDir(), ".claude", "plugins", "installed_plugins.json"));
2079
+ const entries2 = installed?.plugins?.[id];
2080
+ const entry = Array.isArray(entries2) ? entries2.find((e) => (e?.scope ?? "user") === "user") : void 0;
2081
+ const state = {
2082
+ missing: [],
2083
+ behind: [],
2084
+ notFromSystemDox: [],
2085
+ pluginInstalled: !!entry,
2086
+ pluginVersion: entry?.version
2087
+ };
2088
+ const pluginCurrent = !!entry && !!setup.plugin && entry.version === `${setup.plugin.version}.0.0`;
2089
+ for (const skill of setup.sessionBrief?.skills ?? []) {
2090
+ const dir = defaultSkillDir(skill.name);
2091
+ if (!existsSync2(dir)) {
2092
+ if (!(pluginCurrent && setup.plugin?.skills?.[skill.name] !== void 0)) state.missing.push(skill.name);
2074
2093
  continue;
2075
2094
  }
2076
- const available = Math.max(0, bodyBudget);
2077
- const allowance = Math.min(
2078
- Math.max(MIN_BODY_TOKENS, Math.floor(available / (itemsLeft + 1))),
2079
- available
2080
- );
2081
- if (allowance < MIN_BODY_TOKENS) {
2082
- emit(
2083
- `${headline}
2084
-
2085
- _Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
2086
- );
2087
- continue;
2095
+ const manifest = await readJsonQuietly(join3(dir, MANIFEST_FILE));
2096
+ if (typeof manifest?.version !== "number" || !Number.isInteger(manifest.version)) {
2097
+ state.notFromSystemDox.push(skill.name);
2098
+ } else if (manifest.version !== skill.latestVersion) {
2099
+ state.behind.push({ name: skill.name, here: manifest.version, latest: skill.latestVersion });
2088
2100
  }
2089
- const { text: body2, truncated } = truncateToTokens(
2090
- h.body,
2091
- allowance - TRUNCATION_NOTICE_TOKENS
2092
- );
2093
- itemsWithBody++;
2094
- emit(
2095
- `${headline}
2096
-
2097
- ${body2}` + (truncated ? `
2098
-
2099
- _[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`]_` : "")
2100
- );
2101
2101
  }
2102
- remaining = Math.max(0, remaining - (bodyBudgetStart - bodyBudget));
2103
- if (selectedGuardrails.length > 0) {
2104
- sections.push(
2105
- `## Checks (${selectedGuardrails.length})
2106
-
2107
- ` + selectedGuardrails.map((i) => i.content).join("\n\n---\n\n")
2108
- );
2102
+ return state;
2103
+ }
2104
+ function hasPersonParts(setup) {
2105
+ return Object.keys(setup.parts ?? {}).length > 0 || (setup.memory ?? []).length > 0 || (setup.sessionBrief?.heldSkills ?? []).some((h) => h.reason === "not-plain");
2106
+ }
2107
+ var MORE = (n) => `${n} more (get_team_setup lists them)`;
2108
+ var LIST_MIN = `all ${MORE(100)}`.length;
2109
+ function listWithin(items, room) {
2110
+ const whole2 = items.join(", ");
2111
+ if (whole2.length <= room) return whole2;
2112
+ const kept = [];
2113
+ let used = 0;
2114
+ for (let i = 0; i < items.length; i++) {
2115
+ const rest = items.length - i - 1;
2116
+ const cost = items[i].length + (kept.length ? 2 : 0);
2117
+ if (used + cost + ` and ${MORE(rest)}`.length > room) break;
2118
+ kept.push(items[i]);
2119
+ used += cost;
2120
+ }
2121
+ return kept.length ? `${kept.join(", ")} and ${MORE(items.length - kept.length)}` : `all ${MORE(items.length)}`;
2122
+ }
2123
+ function localSection(setup, state, room) {
2124
+ const rest = hasPersonParts(setup) ? ` and that \`${SETUP_COMMAND}\` in their own terminal adds the rest of the team setup` : "";
2125
+ const latest = new Map((setup.sessionBrief?.skills ?? []).map((s) => [s.name, s.latestVersion]));
2126
+ const lines = [];
2127
+ if (state.missing.length) {
2128
+ lines.push({
2129
+ head: "On this computer, missing: ",
2130
+ items: state.missing.map((name) => `${name} version ${latest.get(name)}`),
2131
+ 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.`
2132
+ });
2109
2133
  }
2110
- if (blocks.length > 0) {
2111
- sections.push(
2112
- `## Relevant Specs & Docs (${blocks.length})
2113
-
2114
- _Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
2115
-
2116
- ` + blocks.join("\n\n---\n\n")
2117
- );
2134
+ if (state.behind.length) {
2135
+ lines.push({
2136
+ head: "On this computer, behind: ",
2137
+ items: state.behind.map((b) => `${b.name} (version ${b.here} here, latest ${b.latest})`),
2138
+ tail: ". install_skill updates each to that latest version; if it refuses because files were edited, tell the person and leave them."
2139
+ });
2118
2140
  }
2119
- const tailLines = [];
2120
- for (const meta of tailCandidates) {
2121
- const cost = estimateTokens(meta.listLine);
2122
- const named = blocks.length + tailLines.length;
2123
- if (cost > remaining && named >= MIN_REFERENCES) break;
2124
- tailLines.push(meta.listLine);
2125
- remaining = Math.max(0, remaining - cost);
2141
+ if (state.notFromSystemDox.length) {
2142
+ lines.push({ head: "Not from SystemDox, left alone: ", items: state.notFromSystemDox, tail: "." });
2126
2143
  }
2127
- if (tailLines.length > 0) {
2128
- sections.push(
2129
- `## Also Indexed (${tailLines.length})
2130
-
2131
- _Ranked lower \u2014 fetch with \`get_specs\` / \`get_docs\` if you need them._
2144
+ const fixed = () => lines.reduce((n, l) => n + l.head.length + l.tail.length + LIST_MIN, 0) + Math.max(0, lines.length - 1);
2145
+ while (lines.length && fixed() > room) lines.pop();
2146
+ if (!lines.length) return "";
2147
+ const share = Math.floor((room - fixed()) / lines.length) + LIST_MIN;
2148
+ return lines.map((l) => `${l.head}${listWithin(l.items, share)}${l.tail}`).join("\n");
2149
+ }
2150
+ function fit(text3, section) {
2151
+ const whole2 = section ? `${text3}
2132
2152
 
2133
- ` + tailLines.join("\n")
2134
- );
2135
- }
2136
- const itemsListed = blocks.length - itemsWithBody + tailLines.length;
2137
- const itemsOmitted = ranked.length - selectedGuardrails.length - blocks.length - tailLines.length;
2138
- const body = sections.join("\n\n");
2139
- const tokensUsed = estimateTokens(body);
2140
- const stats = {
2141
- budget,
2142
- tokensUsed,
2143
- itemsWithBody,
2144
- bodyFailures,
2145
- itemsListed,
2146
- itemsOmitted: Math.max(0, itemsOmitted),
2147
- guardrails: selectedGuardrails.length
2148
- };
2149
- if (sections.length === 0) {
2150
- 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.";
2151
- return { text: text3, stats };
2153
+ ${section}` : text3;
2154
+ if (whole2.length <= INSTRUCTIONS_MAX) return whole2;
2155
+ return text3.length <= INSTRUCTIONS_MAX ? text3 : `${text3.slice(0, INSTRUCTIONS_MAX - 1)}\u2026`;
2156
+ }
2157
+ async function readTeamSetup(apiClient, { keyProblem, timeoutMs = BRIEF_TIMEOUT_MS } = {}) {
2158
+ if (keyProblem) return { problem: noKey(keyProblem) };
2159
+ try {
2160
+ const setup = await apiClient.getOwnTeamSetup(AbortSignal.timeout(timeoutMs));
2161
+ return typeof setup?.workspace === "string" ? { setup } : { problem: NOT_CHECKED };
2162
+ } catch (err) {
2163
+ const status = err?.status;
2164
+ return { problem: status === 401 || status === 403 ? REFUSED : NOT_CHECKED };
2152
2165
  }
2153
- const footer = `
2154
-
2155
- ---
2156
- _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` : "") + `_`;
2157
- return { text: body + footer, stats };
2158
2166
  }
2159
-
2160
- // src/tools/get-context.ts
2161
- function registerGetContext(server, apiClient) {
2162
- server.registerTool(
2163
- "get_context",
2164
- {
2165
- title: "Get Context",
2166
- 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.",
2167
- inputSchema: {
2168
- task_description: z7.string().describe(
2169
- "What you're working on (e.g., 'add a new REST endpoint for user preferences')"
2170
- ),
2171
- file_paths: z7.array(z7.string()).optional().describe(
2172
- "File paths being edited \u2014 used for language/category detection"
2173
- ),
2174
- max_tokens: z7.number().optional().describe("Maximum token budget for the response (default: 4000)")
2175
- }
2176
- },
2177
- async ({ task_description, file_paths, max_tokens }) => {
2178
- const { text: text3 } = await buildContext(apiClient, {
2179
- taskDescription: task_description,
2180
- filePaths: file_paths,
2181
- maxTokens: max_tokens
2182
- });
2183
- return {
2184
- content: [{ type: "text", text: text3 }]
2185
- };
2186
- }
2187
- );
2167
+ async function sessionInstructions(apiClient, options = {}) {
2168
+ const read = await readTeamSetup(apiClient, options);
2169
+ if ("problem" in read) return read.problem;
2170
+ const text3 = read.setup.sessionBrief?.text ?? olderApi(read.setup);
2171
+ try {
2172
+ if (!options.localFiles) return fit(text3, read.setup.sessionBrief?.skills?.length ? CANNOT_SEE : "");
2173
+ const room = INSTRUCTIONS_MAX - text3.length - 2;
2174
+ return fit(text3, localSection(read.setup, await computerState(read.setup), room));
2175
+ } catch {
2176
+ return fit(text3, "");
2177
+ }
2188
2178
  }
2189
2179
 
2190
2180
  // src/tools/get-team-setup.ts
@@ -2527,7 +2517,8 @@ function registerUpdateSpec(server, apiClient) {
2527
2517
  }
2528
2518
  },
2529
2519
  async ({ draft_id, section, content, requirement_name }) => {
2530
- const spec = await apiClient.get(`/v1/specs/${draft_id}`);
2520
+ const specPath = `/v1/specs/${encodeURIComponent(draft_id)}`;
2521
+ const spec = await apiClient.get(specPath);
2531
2522
  if (spec.status !== "draft") {
2532
2523
  return {
2533
2524
  content: [
@@ -2552,8 +2543,8 @@ function registerUpdateSpec(server, apiClient) {
2552
2543
  };
2553
2544
  body.requirements = [...existing, newReq];
2554
2545
  }
2555
- await apiClient.put(`/v1/specs/${draft_id}`, body);
2556
- apiClient.invalidateCache(`/v1/specs/${draft_id}`);
2546
+ await apiClient.put(specPath, body);
2547
+ apiClient.invalidateCache(specPath);
2557
2548
  apiClient.invalidateCache("/v1/specs");
2558
2549
  return {
2559
2550
  content: [
@@ -2612,7 +2603,7 @@ ${decision}
2612
2603
  ## Consequences
2613
2604
 
2614
2605
  ${consequences}` + relatedSection;
2615
- await apiClient.put(`/v1/specs/${created.draftId}`, {
2606
+ await apiClient.put(`/v1/specs/${encodeURIComponent(created.draftId)}`, {
2616
2607
  proposalContent: adrContent
2617
2608
  });
2618
2609
  apiClient.invalidateCache("/v1/specs");
@@ -3994,15 +3985,56 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
3994
3985
  }
3995
3986
 
3996
3987
  // src/server.ts
3997
- var SERVER_VERSION = true ? "0.1.54" : "0.0.0-dev";
3998
- function createServer(apiClient, { localFiles = false, instructions, keyProblem } = {}) {
3999
- const server = new McpServer(
3988
+ var SERVER_VERSION = true ? "0.1.56" : "0.0.0-dev";
3989
+ var READ_TOOLS = [
3990
+ "get_context",
3991
+ "get_checks",
3992
+ "get_guardrails",
3993
+ "get_project_rules",
3994
+ "get_prompt_templates",
3995
+ "get_specs",
3996
+ "get_docs",
3997
+ "get_tech_stack",
3998
+ "get_planning_context",
3999
+ "get_plan_board",
4000
+ "get_change",
4001
+ "get_team_setup",
4002
+ "get_document_links",
4003
+ "trace_document_path",
4004
+ "get_graph_stats",
4005
+ "list_skills",
4006
+ "install_skill",
4007
+ "resolve_person"
4008
+ ];
4009
+ var WRITE_TOOLS = [
4010
+ "create_check",
4011
+ "create_guardrail",
4012
+ "create_project_rule",
4013
+ "create_prompt_template",
4014
+ "update_spec",
4015
+ "report_decision",
4016
+ "publish_skill",
4017
+ "publish_setup_part"
4018
+ ];
4019
+ function offering(server, access) {
4020
+ const offered = new Set(access === "write" ? [...READ_TOOLS, ...WRITE_TOOLS] : READ_TOOLS);
4021
+ return new Proxy(server, {
4022
+ get(target, property) {
4023
+ const value = Reflect.get(target, property, target);
4024
+ if (property !== "registerTool") return typeof value === "function" ? value.bind(target) : value;
4025
+ return (name, ...rest) => offered.has(name) ? target.registerTool(name, ...rest) : void 0;
4026
+ }
4027
+ });
4028
+ }
4029
+ function createServer(apiClient, { localFiles = false, instructions, keyProblem, access } = {}) {
4030
+ const mcpServer = new McpServer(
4000
4031
  {
4001
4032
  name: "systemdox-mcp-server",
4002
4033
  version: SERVER_VERSION
4003
4034
  },
4004
4035
  instructions ? { instructions } : void 0
4005
4036
  );
4037
+ const server = access ? offering(mcpServer, access) : mcpServer;
4006
4038
  registerGetGuardrails(server, apiClient);
4007
4039
  registerGetSpecs(server, apiClient);
4008
4040
  registerGetDocs(server, apiClient);
@@ -4022,7 +4054,87 @@ function createServer(apiClient, { localFiles = false, instructions, keyProblem
4022
4054
  registerCreatePromptTemplate(server, apiClient);
4023
4055
  registerSkillTools(server, apiClient, { localFiles });
4024
4056
  registerTeamSetupTools(server, apiClient, { localFiles });
4025
- return server;
4057
+ return mcpServer;
4058
+ }
4059
+
4060
+ // src/lib/mcp-answer.ts
4061
+ function opensSession(rawBody) {
4062
+ try {
4063
+ const parsed = JSON.parse(rawBody);
4064
+ return (Array.isArray(parsed) ? parsed : [parsed]).some((m) => m?.method === "initialize");
4065
+ } catch {
4066
+ return false;
4067
+ }
4068
+ }
4069
+ async function answerMcpRequest(apiClient, request, options = {}) {
4070
+ const { url, rawBody } = request;
4071
+ const headers = { ...request.headers };
4072
+ const instructions = opensSession(rawBody) ? await sessionInstructions(apiClient) : void 0;
4073
+ const server = createServer(apiClient, { instructions, ...options });
4074
+ const transport = new WebStandardStreamableHTTPServerTransport({
4075
+ sessionIdGenerator: void 0,
4076
+ // stateless — every POST is independent
4077
+ enableJsonResponse: true
4078
+ });
4079
+ headers["accept"] = "application/json, text/event-stream";
4080
+ if (!(headers["content-type"] ?? "").includes("application/json")) {
4081
+ headers["content-type"] = "application/json";
4082
+ }
4083
+ try {
4084
+ await server.connect(transport);
4085
+ const response = await transport.handleRequest(new Request(url, { method: "POST", headers, body: rawBody }));
4086
+ const FORWARD = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
4087
+ const responseHeaders = {};
4088
+ response.headers.forEach((value, key) => {
4089
+ if (FORWARD.has(key.toLowerCase())) responseHeaders[key] = value;
4090
+ });
4091
+ if (!("content-type" in responseHeaders)) {
4092
+ responseHeaders["content-type"] = "application/json";
4093
+ }
4094
+ return {
4095
+ statusCode: response.status,
4096
+ headers: responseHeaders,
4097
+ body: await response.text()
4098
+ };
4099
+ } finally {
4100
+ void transport.close().catch((err) => console.error("MCP transport close failed:", err));
4101
+ void server.close().catch((err) => console.error("MCP server close failed:", err));
4102
+ }
4103
+ }
4104
+
4105
+ // src/lib/metrics.ts
4106
+ var NAMESPACE = "SystemDox/Mcp";
4107
+ var METRIC_NAME = "McpToolCall";
4108
+ function buildMcpToolCallMetric(tenant, tool, timestamp = Date.now()) {
4109
+ return {
4110
+ _aws: {
4111
+ Timestamp: timestamp,
4112
+ CloudWatchMetrics: [
4113
+ {
4114
+ Namespace: NAMESPACE,
4115
+ Dimensions: [["tenant"]],
4116
+ Metrics: [{ Name: METRIC_NAME, Unit: "Count" }]
4117
+ }
4118
+ ]
4119
+ },
4120
+ tenant,
4121
+ tool,
4122
+ [METRIC_NAME]: 1
4123
+ };
4124
+ }
4125
+ function emitMcpToolCalls(tenant, rawBody, log = console.log) {
4126
+ try {
4127
+ const parsed = JSON.parse(rawBody);
4128
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
4129
+ for (const message of messages) {
4130
+ if (typeof message === "object" && message !== null && message.method === "tools/call") {
4131
+ const params = message.params;
4132
+ const tool = typeof params?.name === "string" ? params.name : "unknown";
4133
+ log(JSON.stringify(buildMcpToolCallMetric(tenant || "unknown", tool)));
4134
+ }
4135
+ }
4136
+ } catch {
4137
+ }
4026
4138
  }
4027
4139
 
4028
4140
  // src/http.ts
@@ -4034,14 +4146,6 @@ function lowercaseHeaders(headers) {
4034
4146
  }
4035
4147
  return out;
4036
4148
  }
4037
- function opensSession(rawBody) {
4038
- try {
4039
- const parsed = JSON.parse(rawBody);
4040
- return (Array.isArray(parsed) ? parsed : [parsed]).some((m) => m?.method === "initialize");
4041
- } catch {
4042
- return false;
4043
- }
4044
- }
4045
4149
  function jsonError(statusCode, message) {
4046
4150
  return {
4047
4151
  statusCode,
@@ -4095,42 +4199,11 @@ async function handler(event) {
4095
4199
  // per-request client; never cache across invocations
4096
4200
  defaultOrg: void 0
4097
4201
  });
4098
- const instructions = opensSession(rawBody) ? await sessionInstructions(apiClient) : void 0;
4099
- const server = createServer(apiClient, { instructions });
4100
- const transport = new WebStandardStreamableHTTPServerTransport({
4101
- sessionIdGenerator: void 0,
4102
- // stateless — every POST is independent
4103
- enableJsonResponse: true
4202
+ return answerMcpRequest(apiClient, {
4203
+ url: `https://${host || "localhost"}${event.path || "/mcp"}`,
4204
+ headers,
4205
+ rawBody
4104
4206
  });
4105
- headers["accept"] = "application/json, text/event-stream";
4106
- if (!(headers["content-type"] ?? "").includes("application/json")) {
4107
- headers["content-type"] = "application/json";
4108
- }
4109
- try {
4110
- await server.connect(transport);
4111
- const request = new Request(`https://${host || "localhost"}${event.path || "/mcp"}`, {
4112
- method: "POST",
4113
- headers,
4114
- body: rawBody
4115
- });
4116
- const response = await transport.handleRequest(request);
4117
- const FORWARD = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
4118
- const responseHeaders = {};
4119
- response.headers.forEach((value, key) => {
4120
- if (FORWARD.has(key.toLowerCase())) responseHeaders[key] = value;
4121
- });
4122
- if (!("content-type" in responseHeaders)) {
4123
- responseHeaders["content-type"] = "application/json";
4124
- }
4125
- return {
4126
- statusCode: response.status,
4127
- headers: responseHeaders,
4128
- body: await response.text()
4129
- };
4130
- } finally {
4131
- void transport.close().catch((err) => console.error("MCP transport close failed:", err));
4132
- void server.close().catch((err) => console.error("MCP server close failed:", err));
4133
- }
4134
4207
  }
4135
4208
  export {
4136
4209
  handler