@systemdox/mcp-server 0.1.50 → 0.1.52

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.
@@ -82,6 +82,9 @@ var SEVERITY_SCORES = {
82
82
  medium: 0.5,
83
83
  low: 0.25
84
84
  };
85
+ var MIN_TITLE_SHARE = 1 / 3;
86
+ var RECORD_PATH = /(^|\/)changelog\//;
87
+ var RECORD_FACTOR = 0.5;
85
88
  var CHARS_PER_TOKEN = 4;
86
89
  function estimateTokens(text) {
87
90
  return Math.ceil(text.length / CHARS_PER_TOKEN);
@@ -134,6 +137,32 @@ function scoreSeverity(item) {
134
137
  if (!item.severity) return 0.5;
135
138
  return SEVERITY_SCORES[item.severity] ?? 0.5;
136
139
  }
140
+ function stem(word) {
141
+ return word.length > 3 ? word.replace(/s$/, "") : word;
142
+ }
143
+ function stems(text) {
144
+ return new Set(
145
+ text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(stem)
146
+ );
147
+ }
148
+ function titleShare(title, keywords) {
149
+ const wanted = stems(keywords.join(" "));
150
+ if (wanted.size === 0) return 0;
151
+ const held = stems(title);
152
+ let matches = 0;
153
+ for (const word of wanted) {
154
+ if (held.has(word)) matches++;
155
+ }
156
+ return matches / wanted.size;
157
+ }
158
+ function scoreReference(item, keywords) {
159
+ const share = titleShare(item.title, keywords);
160
+ const score = Math.max(
161
+ item.relevance ?? 0,
162
+ share >= MIN_TITLE_SHARE ? share : 0
163
+ );
164
+ return RECORD_PATH.test(item.path ?? "") ? score * RECORD_FACTOR : score;
165
+ }
137
166
  function extractKeywords(text) {
138
167
  const stopWords = /* @__PURE__ */ new Set([
139
168
  "the",
@@ -230,7 +259,8 @@ function scoreItems(items, taskDescription, filePaths) {
230
259
  const keywordScore = scoreKeywordMatch(item, keywords);
231
260
  const recencyScore = scoreRecency(item);
232
261
  const severityScore = scoreSeverity(item);
233
- const score = pathScore * WEIGHT_PATH + keywordScore * WEIGHT_KEYWORD + recencyScore * WEIGHT_RECENCY + severityScore * WEIGHT_SEVERITY;
262
+ const isReference = item.type === "spec" || item.type === "doc";
263
+ const score = isReference && keywords.length > 0 ? scoreReference(item, keywords) : pathScore * WEIGHT_PATH + keywordScore * WEIGHT_KEYWORD + recencyScore * WEIGHT_RECENCY + severityScore * WEIGHT_SEVERITY;
234
264
  return {
235
265
  type: item.type,
236
266
  id: item.id,
@@ -239,7 +269,7 @@ function scoreItems(items, taskDescription, filePaths) {
239
269
  score,
240
270
  tokenEstimate: estimateTokens(item.content)
241
271
  };
242
- }).sort((a, b) => b.score - a.score);
272
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score);
243
273
  }
244
274
  function selectWithinBudget(items, maxTokens) {
245
275
  const selected = [];
@@ -252,6 +282,15 @@ function selectWithinBudget(items, maxTokens) {
252
282
  return { selected, tokensUsed, totalItems: items.length };
253
283
  }
254
284
 
285
+ // src/lib/spec-label.ts
286
+ var DECISION_PREFIX = "ADR: ";
287
+ function isDecisionDraft(name) {
288
+ return name.startsWith(DECISION_PREFIX);
289
+ }
290
+ function specLabel(spec) {
291
+ return isDecisionDraft(spec.name) ? "decision record" : `${spec.requirementCount} requirements`;
292
+ }
293
+
255
294
  // src/lib/context-builder.ts
256
295
  var DEFAULT_BUDGET = 4e3;
257
296
  var RESERVE_TOKENS = 200;
@@ -261,6 +300,10 @@ var MAX_HYDRATED = 8;
261
300
  var MAX_TAIL = 10;
262
301
  var TAIL_SHARE = 0.15;
263
302
  var TRUNCATION_NOTICE_TOKENS = 30;
303
+ var STACK_SHARE = 0.1;
304
+ var MIN_REFERENCES = 3;
305
+ var SEARCH_RESULTS = 30;
306
+ var MAX_QUERY_CHARS = 300;
264
307
  function formatGuardrail(g) {
265
308
  let text = `## ${g.title} [${g.severity}]
266
309
 
@@ -279,7 +322,7 @@ ${g.fix}
279
322
  }
280
323
  function formatSpecHeadline(s) {
281
324
  return `### ${s.name}
282
- \`draft_id: ${s.draftId}\` \xB7 ${s.status} \xB7 ${s.requirementCount} requirements`;
325
+ \`draft_id: ${s.draftId}\` \xB7 ${s.status} \xB7 ${specLabel(s)}`;
283
326
  }
284
327
  function formatDocHeadline(d) {
285
328
  return `### ${d.title}
@@ -290,6 +333,21 @@ function formatStack(entries) {
290
333
  (e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
291
334
  ).join("\n");
292
335
  }
336
+ function selectStack(entries, words, maxTokens) {
337
+ const named = (e) => {
338
+ const entry = `${e.name} ${e.notes ?? ""}`.toLowerCase();
339
+ return words.some((w) => entry.includes(w));
340
+ };
341
+ const ordered = [...entries.filter(named), ...entries.filter((e) => !named(e))];
342
+ const shown = [];
343
+ for (const entry of ordered) {
344
+ if (shown.length > 0 && estimateTokens(formatStack([...shown, entry])) > maxTokens) {
345
+ break;
346
+ }
347
+ shown.push(entry);
348
+ }
349
+ return { text: formatStack(shown), hidden: entries.length - shown.length };
350
+ }
293
351
  function formatRules(rules) {
294
352
  const byCategory = /* @__PURE__ */ new Map();
295
353
  for (const r of rules) {
@@ -311,6 +369,21 @@ function formatRules(rules) {
311
369
 
312
370
  ${text.trim()}`;
313
371
  }
372
+ async function findDocs(api, taskDescription) {
373
+ const query = taskDescription.trim().slice(0, MAX_QUERY_CHARS);
374
+ if (query) {
375
+ try {
376
+ const found = await api.get("/v1/docs", {
377
+ q: query,
378
+ limit: String(SEARCH_RESULTS)
379
+ });
380
+ if (found.items.length > 0) return { ...found, searched: true };
381
+ } catch {
382
+ }
383
+ }
384
+ const listed = await api.get("/v1/docs");
385
+ return { ...listed, searched: false };
386
+ }
314
387
  async function fetchSpecBody(api, draftId) {
315
388
  const spec = await api.get(
316
389
  `/v1/specs/${encodeURIComponent(draftId)}`
@@ -411,7 +484,9 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
411
484
  const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
412
485
  api.get("/v1/guardrails").catch(orEmpty([])),
413
486
  api.get("/v1/specs").catch(orEmpty([])),
414
- api.get("/v1/docs").catch(orEmpty({ items: [] })),
487
+ findDocs(api, taskDescription).catch(
488
+ orEmpty({ items: [], searched: false })
489
+ ),
415
490
  api.get("/v1/ai/settings/stack").catch(orEmpty([])),
416
491
  api.get("/v1/standards/rules").catch(orEmpty([])),
417
492
  // Capability states — FAIL-OPEN: an unreadable config (older backend, a
@@ -424,6 +499,8 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
424
499
  const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
425
500
  const refs = /* @__PURE__ */ new Map();
426
501
  const key = (type, id) => `${type}:${id}`;
502
+ const docTitles = new Set(docs.items.map((d) => d.title));
503
+ const isCopyOfDoc = (s) => isDecisionDraft(s.name) && docTitles.has(s.name.slice("ADR: ".length));
427
504
  const scorable = [
428
505
  ...guardrails.map((g) => ({
429
506
  type: "guardrail",
@@ -434,12 +511,12 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
434
511
  severity: g.severity,
435
512
  updatedAt: g.updatedAt
436
513
  })),
437
- ...specs.filter((s) => s.status !== "archived").map((s) => {
514
+ ...specs.filter((s) => s.status !== "archived" && !isCopyOfDoc(s)).map((s) => {
438
515
  refs.set(key("spec", s.draftId), {
439
516
  type: "spec",
440
517
  id: s.draftId,
441
518
  title: s.name,
442
- listLine: `- **${s.name}** \u2014 \`draft_id: ${s.draftId}\` (${s.status}, ${s.requirementCount} requirements)`,
519
+ listLine: `- **${s.name}** \u2014 \`draft_id: ${s.draftId}\` (${s.status}, ${specLabel(s)})`,
443
520
  fetchHint: `get_specs(draft_id="${s.draftId}")`
444
521
  });
445
522
  return {
@@ -450,7 +527,7 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
450
527
  updatedAt: s.updatedAt
451
528
  };
452
529
  }),
453
- ...docs.items.map((d) => {
530
+ ...docs.items.map((d, position) => {
454
531
  const section = selectSection(d.outline, taskDescription);
455
532
  const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
456
533
  refs.set(key("doc", d.id), {
@@ -469,7 +546,9 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
469
546
  title: d.title,
470
547
  content: formatDocHeadline(d),
471
548
  category: d.doc_type === "adr" ? "architecture" : void 0,
472
- updatedAt: d.synced_at
549
+ updatedAt: d.synced_at,
550
+ path: d.path,
551
+ relevance: docs.searched ? 1 - position / (2 * docs.items.length) : void 0
473
552
  };
474
553
  })
475
554
  ];
@@ -477,9 +556,16 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
477
556
  const sections = [];
478
557
  let overhead = 0;
479
558
  if (stack.length > 0) {
559
+ const { text, hidden } = selectStack(
560
+ stack,
561
+ [...extractKeywords(taskDescription), ...detectLanguages(filePaths)],
562
+ Math.floor(budget * STACK_SHARE)
563
+ );
480
564
  const section = `## Tech Stack
481
565
 
482
- ${formatStack(stack)}`;
566
+ ${text}` + (hidden > 0 ? `
567
+
568
+ _${hidden} more with \`get_tech_stack\`._` : "");
483
569
  sections.push(section);
484
570
  overhead += estimateTokens(section);
485
571
  }
@@ -598,9 +684,10 @@ _Highest-ranked items \u2014 docs are scoped to their most relevant section. Eac
598
684
  const tailLines = [];
599
685
  for (const meta of tailCandidates) {
600
686
  const cost = estimateTokens(meta.listLine);
601
- if (cost > remaining) break;
687
+ const named = blocks.length + tailLines.length;
688
+ if (cost > remaining && named >= MIN_REFERENCES) break;
602
689
  tailLines.push(meta.listLine);
603
- remaining -= cost;
690
+ remaining = Math.max(0, remaining - cost);
604
691
  }
605
692
  if (tailLines.length > 0) {
606
693
  sections.push(
@@ -15,6 +15,10 @@ interface ScorableItem {
15
15
  category?: string;
16
16
  severity?: string;
17
17
  updatedAt?: string;
18
+ /** Where a document's repository file lives. */
19
+ path?: string;
20
+ /** 0 to 1, from the order the document search returned it in. */
21
+ relevance?: number;
18
22
  }
19
23
  export declare function estimateTokens(text: string): number;
20
24
  /**
@@ -26,6 +30,7 @@ export declare function truncateToTokens(text: string, maxTokens: number): {
26
30
  text: string;
27
31
  truncated: boolean;
28
32
  };
33
+ export declare function extractKeywords(text: string): string[];
29
34
  export declare function scoreItems(items: ScorableItem[], taskDescription: string, filePaths: string[]): ScoredItem[];
30
35
  export declare function selectWithinBudget(items: ScoredItem[], maxTokens: number): {
31
36
  selected: ScoredItem[];
@@ -103,5 +103,12 @@ export declare function installSkill(version: SkillVersionFiles, dest: string, {
103
103
  }): Promise<InstallResult>;
104
104
  /** A fence longer than any backtick run in `text`, so content cannot close it. */
105
105
  export declare function fenceFor(text: string): string;
106
+ /**
107
+ * What follows an install. Claude Code watches its skills folders, so a running
108
+ * session needs no restart, unless the top-level folder did not exist when the
109
+ * session started (code.claude.com/docs/en/skills, "Edit a skill during a
110
+ * session"): `/reload-skills` covers that, and reading the file works anywhere.
111
+ */
112
+ export declare function afterInstall(skillMd: string): string;
106
113
  /** Human summary of an install, shared by the tool and the CLI. */
107
114
  export declare function describeInstall(name: string, r: InstallResult): string;
@@ -182,6 +182,9 @@ function fenceFor(text) {
182
182
  const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((r) => r.length));
183
183
  return "`".repeat(Math.max(3, longest + 1));
184
184
  }
185
+ function afterInstall(skillMd) {
186
+ 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.`;
187
+ }
185
188
  function describeInstall(name, r) {
186
189
  const lines = [
187
190
  `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
@@ -191,7 +194,7 @@ function describeInstall(name, r) {
191
194
  lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
192
195
  }
193
196
  if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
194
- lines.push("Start a new Claude Code session to load it.");
197
+ lines.push(afterInstall(join(r.dest, "SKILL.md")));
195
198
  return lines.join("\n");
196
199
  }
197
200
  export {
@@ -201,6 +204,7 @@ export {
201
204
  MAX_FILE_BYTES,
202
205
  MAX_TOTAL_BYTES,
203
206
  SkillInstallRefused,
207
+ afterInstall,
204
208
  defaultSkillDir,
205
209
  describeInstall,
206
210
  fenceFor,
@@ -0,0 +1,5 @@
1
+ export declare function isDecisionDraft(name: string): boolean;
2
+ export declare function specLabel(spec: {
3
+ name: string;
4
+ requirementCount: number;
5
+ }): string;
package/dist/server.js CHANGED
@@ -188,6 +188,17 @@ ${text3}`
188
188
 
189
189
  // src/tools/get-specs.ts
190
190
  import { z as z2 } from "zod";
191
+
192
+ // src/lib/spec-label.ts
193
+ var DECISION_PREFIX = "ADR: ";
194
+ function isDecisionDraft(name) {
195
+ return name.startsWith(DECISION_PREFIX);
196
+ }
197
+ function specLabel(spec) {
198
+ return isDecisionDraft(spec.name) ? "decision record" : `${spec.requirementCount} requirements`;
199
+ }
200
+
201
+ // src/tools/get-specs.ts
191
202
  function registerGetSpecs(server, apiClient) {
192
203
  server.registerTool(
193
204
  "get_specs",
@@ -252,7 +263,7 @@ ${spec.taskContent}`);
252
263
  };
253
264
  }
254
265
  const text3 = filtered.map(
255
- (s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${s.requirementCount} requirements` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
266
+ (s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${specLabel(s)}` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
256
267
  ).join("\n");
257
268
  return {
258
269
  content: [
@@ -270,6 +281,11 @@ ${text3}`
270
281
 
271
282
  // src/tools/get-docs.ts
272
283
  import { z as z3 } from "zod";
284
+ var PATH_FILTER_LIMIT = "1000";
285
+ function literalPrefix(pattern) {
286
+ const wildcard = pattern.indexOf("*");
287
+ return wildcard < 0 ? pattern : pattern.slice(0, wildcard);
288
+ }
273
289
  function matchGlob(path, pattern) {
274
290
  const regex = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
275
291
  return new RegExp(`^${regex}$`).test(path);
@@ -330,11 +346,17 @@ ${doc.content}`;
330
346
  const params = {};
331
347
  if (repo) params.repo = repo;
332
348
  if (search) params.q = search;
349
+ if (path) {
350
+ params.limit = PATH_FILTER_LIMIT;
351
+ const prefix = literalPrefix(path);
352
+ if (prefix) params.pathPrefix = prefix;
353
+ }
333
354
  const response = await apiClient.get(
334
355
  "/v1/docs",
335
356
  params
336
357
  );
337
358
  let items = response.items;
359
+ const unread = path ? response.total - response.items.length : 0;
338
360
  if (path) {
339
361
  items = items.filter((item) => matchGlob(item.path, path));
340
362
  }
@@ -364,7 +386,9 @@ ${doc.content}`;
364
386
 
365
387
  ${text3}
366
388
 
367
- Use doc_id for full content, or doc_id + section to fetch one section.`
389
+ ` + (unread > 0 ? `${unread} more were not read. Start the path with a folder to narrow it.
390
+
391
+ ` : "") + "Use doc_id for full content, or doc_id + section to fetch one section."
368
392
  }
369
393
  ]
370
394
  };
@@ -465,7 +489,9 @@ ${deps}`);
465
489
  }
466
490
  if (ctx.adrs.length > 0) {
467
491
  const adrs = ctx.adrs.map((a) => `- ${a.title}${a.repo ? ` (${a.repo})` : ""}`).join("\n");
468
- sections.push(`## Architecture Decision Records (${ctx.adrs.length})
492
+ const total = ctx.adrTotal ?? ctx.adrs.length;
493
+ const count = total > ctx.adrs.length ? `${ctx.adrs.length} newest of ${total}` : `${ctx.adrs.length}`;
494
+ sections.push(`## Architecture Decision Records (${count})
469
495
 
470
496
  ${adrs}`);
471
497
  }
@@ -837,6 +863,9 @@ var SEVERITY_SCORES = {
837
863
  medium: 0.5,
838
864
  low: 0.25
839
865
  };
866
+ var MIN_TITLE_SHARE = 1 / 3;
867
+ var RECORD_PATH = /(^|\/)changelog\//;
868
+ var RECORD_FACTOR = 0.5;
840
869
  var CHARS_PER_TOKEN = 4;
841
870
  function estimateTokens(text3) {
842
871
  return Math.ceil(text3.length / CHARS_PER_TOKEN);
@@ -889,6 +918,32 @@ function scoreSeverity(item) {
889
918
  if (!item.severity) return 0.5;
890
919
  return SEVERITY_SCORES[item.severity] ?? 0.5;
891
920
  }
921
+ function stem(word) {
922
+ return word.length > 3 ? word.replace(/s$/, "") : word;
923
+ }
924
+ function stems(text3) {
925
+ return new Set(
926
+ text3.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(stem)
927
+ );
928
+ }
929
+ function titleShare(title, keywords) {
930
+ const wanted = stems(keywords.join(" "));
931
+ if (wanted.size === 0) return 0;
932
+ const held = stems(title);
933
+ let matches = 0;
934
+ for (const word of wanted) {
935
+ if (held.has(word)) matches++;
936
+ }
937
+ return matches / wanted.size;
938
+ }
939
+ function scoreReference(item, keywords) {
940
+ const share = titleShare(item.title, keywords);
941
+ const score = Math.max(
942
+ item.relevance ?? 0,
943
+ share >= MIN_TITLE_SHARE ? share : 0
944
+ );
945
+ return RECORD_PATH.test(item.path ?? "") ? score * RECORD_FACTOR : score;
946
+ }
892
947
  function extractKeywords(text3) {
893
948
  const stopWords = /* @__PURE__ */ new Set([
894
949
  "the",
@@ -985,7 +1040,8 @@ function scoreItems(items, taskDescription, filePaths) {
985
1040
  const keywordScore = scoreKeywordMatch(item, keywords);
986
1041
  const recencyScore = scoreRecency(item);
987
1042
  const severityScore = scoreSeverity(item);
988
- const score = pathScore * WEIGHT_PATH + keywordScore * WEIGHT_KEYWORD + recencyScore * WEIGHT_RECENCY + severityScore * WEIGHT_SEVERITY;
1043
+ const isReference = item.type === "spec" || item.type === "doc";
1044
+ const score = isReference && keywords.length > 0 ? scoreReference(item, keywords) : pathScore * WEIGHT_PATH + keywordScore * WEIGHT_KEYWORD + recencyScore * WEIGHT_RECENCY + severityScore * WEIGHT_SEVERITY;
989
1045
  return {
990
1046
  type: item.type,
991
1047
  id: item.id,
@@ -994,7 +1050,7 @@ function scoreItems(items, taskDescription, filePaths) {
994
1050
  score,
995
1051
  tokenEstimate: estimateTokens(item.content)
996
1052
  };
997
- }).sort((a, b) => b.score - a.score);
1053
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score);
998
1054
  }
999
1055
  function selectWithinBudget(items, maxTokens) {
1000
1056
  const selected = [];
@@ -1016,6 +1072,10 @@ var MAX_HYDRATED = 8;
1016
1072
  var MAX_TAIL = 10;
1017
1073
  var TAIL_SHARE = 0.15;
1018
1074
  var TRUNCATION_NOTICE_TOKENS = 30;
1075
+ var STACK_SHARE = 0.1;
1076
+ var MIN_REFERENCES = 3;
1077
+ var SEARCH_RESULTS = 30;
1078
+ var MAX_QUERY_CHARS = 300;
1019
1079
  function formatGuardrail(g) {
1020
1080
  let text3 = `## ${g.title} [${g.severity}]
1021
1081
 
@@ -1034,7 +1094,7 @@ ${g.fix}
1034
1094
  }
1035
1095
  function formatSpecHeadline(s) {
1036
1096
  return `### ${s.name}
1037
- \`draft_id: ${s.draftId}\` \xB7 ${s.status} \xB7 ${s.requirementCount} requirements`;
1097
+ \`draft_id: ${s.draftId}\` \xB7 ${s.status} \xB7 ${specLabel(s)}`;
1038
1098
  }
1039
1099
  function formatDocHeadline(d) {
1040
1100
  return `### ${d.title}
@@ -1045,6 +1105,21 @@ function formatStack(entries2) {
1045
1105
  (e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
1046
1106
  ).join("\n");
1047
1107
  }
1108
+ function selectStack(entries2, words, maxTokens) {
1109
+ const named = (e) => {
1110
+ const entry = `${e.name} ${e.notes ?? ""}`.toLowerCase();
1111
+ return words.some((w) => entry.includes(w));
1112
+ };
1113
+ const ordered = [...entries2.filter(named), ...entries2.filter((e) => !named(e))];
1114
+ const shown = [];
1115
+ for (const entry of ordered) {
1116
+ if (shown.length > 0 && estimateTokens(formatStack([...shown, entry])) > maxTokens) {
1117
+ break;
1118
+ }
1119
+ shown.push(entry);
1120
+ }
1121
+ return { text: formatStack(shown), hidden: entries2.length - shown.length };
1122
+ }
1048
1123
  function formatRules(rules) {
1049
1124
  const byCategory = /* @__PURE__ */ new Map();
1050
1125
  for (const r of rules) {
@@ -1066,6 +1141,21 @@ function formatRules(rules) {
1066
1141
 
1067
1142
  ${text3.trim()}`;
1068
1143
  }
1144
+ async function findDocs(api, taskDescription) {
1145
+ const query = taskDescription.trim().slice(0, MAX_QUERY_CHARS);
1146
+ if (query) {
1147
+ try {
1148
+ const found = await api.get("/v1/docs", {
1149
+ q: query,
1150
+ limit: String(SEARCH_RESULTS)
1151
+ });
1152
+ if (found.items.length > 0) return { ...found, searched: true };
1153
+ } catch {
1154
+ }
1155
+ }
1156
+ const listed = await api.get("/v1/docs");
1157
+ return { ...listed, searched: false };
1158
+ }
1069
1159
  async function fetchSpecBody(api, draftId) {
1070
1160
  const spec = await api.get(
1071
1161
  `/v1/specs/${encodeURIComponent(draftId)}`
@@ -1166,7 +1256,9 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
1166
1256
  const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
1167
1257
  api.get("/v1/guardrails").catch(orEmpty([])),
1168
1258
  api.get("/v1/specs").catch(orEmpty([])),
1169
- api.get("/v1/docs").catch(orEmpty({ items: [] })),
1259
+ findDocs(api, taskDescription).catch(
1260
+ orEmpty({ items: [], searched: false })
1261
+ ),
1170
1262
  api.get("/v1/ai/settings/stack").catch(orEmpty([])),
1171
1263
  api.get("/v1/standards/rules").catch(orEmpty([])),
1172
1264
  // Capability states — FAIL-OPEN: an unreadable config (older backend, a
@@ -1179,6 +1271,8 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
1179
1271
  const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
1180
1272
  const refs = /* @__PURE__ */ new Map();
1181
1273
  const key = (type, id) => `${type}:${id}`;
1274
+ const docTitles = new Set(docs.items.map((d) => d.title));
1275
+ const isCopyOfDoc = (s) => isDecisionDraft(s.name) && docTitles.has(s.name.slice("ADR: ".length));
1182
1276
  const scorable = [
1183
1277
  ...guardrails.map((g) => ({
1184
1278
  type: "guardrail",
@@ -1189,12 +1283,12 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
1189
1283
  severity: g.severity,
1190
1284
  updatedAt: g.updatedAt
1191
1285
  })),
1192
- ...specs.filter((s) => s.status !== "archived").map((s) => {
1286
+ ...specs.filter((s) => s.status !== "archived" && !isCopyOfDoc(s)).map((s) => {
1193
1287
  refs.set(key("spec", s.draftId), {
1194
1288
  type: "spec",
1195
1289
  id: s.draftId,
1196
1290
  title: s.name,
1197
- listLine: `- **${s.name}** \u2014 \`draft_id: ${s.draftId}\` (${s.status}, ${s.requirementCount} requirements)`,
1291
+ listLine: `- **${s.name}** \u2014 \`draft_id: ${s.draftId}\` (${s.status}, ${specLabel(s)})`,
1198
1292
  fetchHint: `get_specs(draft_id="${s.draftId}")`
1199
1293
  });
1200
1294
  return {
@@ -1205,7 +1299,7 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
1205
1299
  updatedAt: s.updatedAt
1206
1300
  };
1207
1301
  }),
1208
- ...docs.items.map((d) => {
1302
+ ...docs.items.map((d, position) => {
1209
1303
  const section = selectSection(d.outline, taskDescription);
1210
1304
  const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
1211
1305
  refs.set(key("doc", d.id), {
@@ -1224,7 +1318,9 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
1224
1318
  title: d.title,
1225
1319
  content: formatDocHeadline(d),
1226
1320
  category: d.doc_type === "adr" ? "architecture" : void 0,
1227
- updatedAt: d.synced_at
1321
+ updatedAt: d.synced_at,
1322
+ path: d.path,
1323
+ relevance: docs.searched ? 1 - position / (2 * docs.items.length) : void 0
1228
1324
  };
1229
1325
  })
1230
1326
  ];
@@ -1232,9 +1328,16 @@ async function buildContext(api, { taskDescription, filePaths = [], maxTokens })
1232
1328
  const sections = [];
1233
1329
  let overhead = 0;
1234
1330
  if (stack.length > 0) {
1331
+ const { text: text3, hidden } = selectStack(
1332
+ stack,
1333
+ [...extractKeywords(taskDescription), ...detectLanguages(filePaths)],
1334
+ Math.floor(budget * STACK_SHARE)
1335
+ );
1235
1336
  const section = `## Tech Stack
1236
1337
 
1237
- ${formatStack(stack)}`;
1338
+ ${text3}` + (hidden > 0 ? `
1339
+
1340
+ _${hidden} more with \`get_tech_stack\`._` : "");
1238
1341
  sections.push(section);
1239
1342
  overhead += estimateTokens(section);
1240
1343
  }
@@ -1353,9 +1456,10 @@ _Highest-ranked items \u2014 docs are scoped to their most relevant section. Eac
1353
1456
  const tailLines = [];
1354
1457
  for (const meta of tailCandidates) {
1355
1458
  const cost = estimateTokens(meta.listLine);
1356
- if (cost > remaining) break;
1459
+ const named = blocks.length + tailLines.length;
1460
+ if (cost > remaining && named >= MIN_REFERENCES) break;
1357
1461
  tailLines.push(meta.listLine);
1358
- remaining -= cost;
1462
+ remaining = Math.max(0, remaining - cost);
1359
1463
  }
1360
1464
  if (tailLines.length > 0) {
1361
1465
  sections.push(
@@ -1607,6 +1711,9 @@ function fenceFor(text3) {
1607
1711
  const longest = Math.max(0, ...(text3.match(/`+/g) ?? []).map((r) => r.length));
1608
1712
  return "`".repeat(Math.max(3, longest + 1));
1609
1713
  }
1714
+ function afterInstall(skillMd) {
1715
+ 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.`;
1716
+ }
1610
1717
  function describeInstall(name, r) {
1611
1718
  const lines = [
1612
1719
  `Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
@@ -1616,7 +1723,7 @@ function describeInstall(name, r) {
1616
1723
  lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
1617
1724
  }
1618
1725
  if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
1619
- lines.push("Start a new Claude Code session to load it.");
1726
+ lines.push(afterInstall(join(r.dest, "SKILL.md")));
1620
1727
  return lines.join("\n");
1621
1728
  }
1622
1729
 
@@ -2498,7 +2605,7 @@ ${fence}
2498
2605
  ${f.content}${f.content.endsWith("\n") ? "" : "\n"}${fence}`;
2499
2606
  }).join("\n\n");
2500
2607
  return text(
2501
- `Skill **${version.name}** version ${version.version}: write these ${version.files.length} file(s) into ${dest}/ exactly as shown (the content between the fences), creating folders as needed. If a file there differs from ${MANIFEST_FILE}'s hash for it, it was edited: ask before replacing it. Then write ${MANIFEST_FILE} with this content, and tell the user to start a new Claude Code session.
2608
+ `Skill **${version.name}** version ${version.version}: write these ${version.files.length} file(s) into ${dest}/ exactly as shown (the content between the fences), creating folders as needed. If a file there differs from ${MANIFEST_FILE}'s hash for it, it was edited: ask before replacing it. Then write ${MANIFEST_FILE} with this content. ${afterInstall(`${dest}/SKILL.md`)}
2502
2609
 
2503
2610
  ### ${MANIFEST_FILE}
2504
2611
 
@@ -2655,9 +2762,9 @@ function localDate(now) {
2655
2762
  return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
2656
2763
  }
2657
2764
  function conflictCopyName(path, date, taken) {
2658
- const stem = path.replace(/\.md$/, "");
2765
+ const stem2 = path.replace(/\.md$/, "");
2659
2766
  for (let n = 1; ; n++) {
2660
- const name = `${stem}.local-${date}${n === 1 ? "" : `-${n}`}.md`;
2767
+ const name = `${stem2}.local-${date}${n === 1 ? "" : `-${n}`}.md`;
2661
2768
  if (!taken.has(name.toLowerCase())) return name;
2662
2769
  }
2663
2770
  }
@@ -3544,7 +3651,7 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
3544
3651
  }
3545
3652
 
3546
3653
  // src/server.ts
3547
- var SERVER_VERSION = true ? "0.1.50" : "0.0.0-dev";
3654
+ var SERVER_VERSION = true ? "0.1.52" : "0.0.0-dev";
3548
3655
  function createServer(apiClient, { localFiles = false, instructions, keyProblem } = {}) {
3549
3656
  const server = new McpServer(
3550
3657
  {
package/dist/setup-mcp.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/setup-mcp.ts
4
4
  import { existsSync as existsSync3, readFileSync } from "fs";
5
+ import { mkdir as mkdir4 } from "fs/promises";
5
6
  import { join as join4 } from "path";
6
7
 
7
8
  // src/lib/cache.ts
@@ -591,6 +592,7 @@ async function setupMcpServer(opts) {
591
592
  }
592
593
  try {
593
594
  if ((await readCredentials())[workspace]?.apiKey !== apiKey) await writeCredentials(workspace, apiKey);
595
+ await mkdir4(join4(homeDir(), ".claude", "skills"), { recursive: true });
594
596
  const several = Object.keys(await readCredentials()).length > 1;
595
597
  for (const target of selected) {
596
598
  const removed = await writeServerEntry(target.path, apiUrl, several ? workspace : void 0);
@@ -2,6 +2,11 @@
2
2
 
3
3
  // src/tools/get-docs.ts
4
4
  import { z } from "zod";
5
+ var PATH_FILTER_LIMIT = "1000";
6
+ function literalPrefix(pattern) {
7
+ const wildcard = pattern.indexOf("*");
8
+ return wildcard < 0 ? pattern : pattern.slice(0, wildcard);
9
+ }
5
10
  function matchGlob(path, pattern) {
6
11
  const regex = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
7
12
  return new RegExp(`^${regex}$`).test(path);
@@ -62,11 +67,17 @@ ${doc.content}`;
62
67
  const params = {};
63
68
  if (repo) params.repo = repo;
64
69
  if (search) params.q = search;
70
+ if (path) {
71
+ params.limit = PATH_FILTER_LIMIT;
72
+ const prefix = literalPrefix(path);
73
+ if (prefix) params.pathPrefix = prefix;
74
+ }
65
75
  const response = await apiClient.get(
66
76
  "/v1/docs",
67
77
  params
68
78
  );
69
79
  let items = response.items;
80
+ const unread = path ? response.total - response.items.length : 0;
70
81
  if (path) {
71
82
  items = items.filter((item) => matchGlob(item.path, path));
72
83
  }
@@ -96,7 +107,9 @@ ${doc.content}`;
96
107
 
97
108
  ${text}
98
109
 
99
- Use doc_id for full content, or doc_id + section to fetch one section.`
110
+ ` + (unread > 0 ? `${unread} more were not read. Start the path with a folder to narrow it.
111
+
112
+ ` : "") + "Use doc_id for full content, or doc_id + section to fetch one section."
100
113
  }
101
114
  ]
102
115
  };