@systemdox/mcp-server 0.1.49 → 0.1.51
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/README.md +30 -1
- package/dist/cli.js +578 -169
- package/dist/http.js +1107 -730
- package/dist/index.d.ts +2 -2
- package/dist/index.js +751 -371
- package/dist/install-skill.js +13 -16
- package/dist/lib/api-client.d.ts +2 -3
- package/dist/lib/api-client.js +7 -14
- package/dist/lib/context-builder.js +14 -7
- package/dist/lib/session-brief.d.ts +59 -0
- package/dist/lib/session-brief.js +174 -0
- package/dist/lib/skills.d.ts +7 -0
- package/dist/lib/skills.js +7 -2
- package/dist/lib/team-setup-types.d.ts +18 -0
- package/dist/plugin-path.js +7 -14
- package/dist/server.d.ts +5 -1
- package/dist/server.js +3582 -0
- package/dist/setup-mcp.js +11 -15
- package/dist/setup-team.js +7 -14
- package/dist/sync-memory.js +7 -14
- package/dist/tools/document-graph.d.ts +87 -0
- package/dist/tools/get-team-setup.d.ts +6 -0
- package/dist/tools/skills.js +7 -3
- package/dist/tools/team-setup.js +2 -1
- package/package.json +1 -1
package/dist/server.js
ADDED
|
@@ -0,0 +1,3582 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
|
|
6
|
+
// src/tools/get-guardrails.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
// src/lib/language.ts
|
|
10
|
+
var EXTENSION_MAP = {
|
|
11
|
+
".py": { language: "python", categories: ["api", "backend", "data-access"] },
|
|
12
|
+
".ts": { language: "typescript", categories: ["frontend", "api"] },
|
|
13
|
+
".tsx": { language: "typescript", categories: ["frontend"] },
|
|
14
|
+
".js": { language: "javascript", categories: ["frontend"] },
|
|
15
|
+
".jsx": { language: "javascript", categories: ["frontend"] },
|
|
16
|
+
".yaml": {
|
|
17
|
+
language: "cloudformation",
|
|
18
|
+
categories: ["infrastructure", "architecture"]
|
|
19
|
+
},
|
|
20
|
+
".yml": {
|
|
21
|
+
language: "cloudformation",
|
|
22
|
+
categories: ["infrastructure", "architecture"]
|
|
23
|
+
},
|
|
24
|
+
".json": { language: "json", categories: ["configuration"] },
|
|
25
|
+
".css": { language: "css", categories: ["frontend"] },
|
|
26
|
+
".html": { language: "html", categories: ["frontend"] },
|
|
27
|
+
".sql": { language: "sql", categories: ["data-access"] },
|
|
28
|
+
".sh": { language: "shell", categories: ["infrastructure"] },
|
|
29
|
+
".bash": { language: "shell", categories: ["infrastructure"] },
|
|
30
|
+
".tf": { language: "terraform", categories: ["infrastructure"] },
|
|
31
|
+
".md": { language: "markdown", categories: ["documentation"] }
|
|
32
|
+
};
|
|
33
|
+
var PATH_CATEGORY_RULES = [
|
|
34
|
+
{ pattern: /backend\/functions\//, categories: ["api", "backend"] },
|
|
35
|
+
{ pattern: /backend\/layers\//, categories: ["backend"] },
|
|
36
|
+
{ pattern: /backend\//, categories: ["backend"] },
|
|
37
|
+
{ pattern: /apps\/app\//, categories: ["frontend"] },
|
|
38
|
+
{ pattern: /apps\/website\//, categories: ["frontend"] },
|
|
39
|
+
{ pattern: /apps\/portal\//, categories: ["frontend"] },
|
|
40
|
+
{ pattern: /packages\/ui\//, categories: ["frontend"] },
|
|
41
|
+
{ pattern: /infra\//, categories: ["infrastructure"] },
|
|
42
|
+
{ pattern: /\.github\//, categories: ["infrastructure"] },
|
|
43
|
+
{ pattern: /tests?\//, categories: ["testing"] },
|
|
44
|
+
{ pattern: /spec/, categories: ["testing"] },
|
|
45
|
+
{ pattern: /docs\/adr/, categories: ["architecture"] },
|
|
46
|
+
{ pattern: /template\.yaml$/, categories: ["infrastructure", "architecture"] }
|
|
47
|
+
];
|
|
48
|
+
function detectLanguage(filePath) {
|
|
49
|
+
const ext = filePath.slice(filePath.lastIndexOf("."));
|
|
50
|
+
return EXTENSION_MAP[ext];
|
|
51
|
+
}
|
|
52
|
+
function detectCategories(filePaths) {
|
|
53
|
+
const categories = /* @__PURE__ */ new Set();
|
|
54
|
+
for (const filePath of filePaths) {
|
|
55
|
+
const langInfo = detectLanguage(filePath);
|
|
56
|
+
if (langInfo) {
|
|
57
|
+
for (const cat of langInfo.categories) {
|
|
58
|
+
categories.add(cat);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const rule of PATH_CATEGORY_RULES) {
|
|
62
|
+
if (rule.pattern.test(filePath)) {
|
|
63
|
+
for (const cat of rule.categories) {
|
|
64
|
+
categories.add(cat);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return Array.from(categories);
|
|
70
|
+
}
|
|
71
|
+
function detectLanguages(filePaths) {
|
|
72
|
+
const languages = /* @__PURE__ */ new Set();
|
|
73
|
+
for (const filePath of filePaths) {
|
|
74
|
+
const info = detectLanguage(filePath);
|
|
75
|
+
if (info) languages.add(info.language);
|
|
76
|
+
}
|
|
77
|
+
return Array.from(languages);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/lib/deprecation.ts
|
|
81
|
+
var LEGACY_TOOL_REMOVAL_EARLIEST = "2027-01-31";
|
|
82
|
+
function legacyToolDescription(canonical, description) {
|
|
83
|
+
return `DEPRECATED \u2014 use \`${canonical}\` instead. This name still works and will not be removed before ${LEGACY_TOOL_REMOVAL_EARLIEST}. ${description}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/lib/config-links.ts
|
|
87
|
+
async function workspaceBase(apiClient) {
|
|
88
|
+
try {
|
|
89
|
+
const access = await apiClient.get(
|
|
90
|
+
"/v1/docs/portal-access"
|
|
91
|
+
);
|
|
92
|
+
const slug = (access.host ?? "").split(".")[0];
|
|
93
|
+
return slug ? `https://${slug}.systemdox.com` : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function configLink(path, base) {
|
|
99
|
+
return base ? `${base}${path}` : `${path} in your SystemDox workspace`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/tools/get-guardrails.ts
|
|
103
|
+
var DESCRIPTION = "Retrieve SystemDox checks (banned patterns, required patterns, coding rules). Filter by language, category, or file path to get only relevant checks.";
|
|
104
|
+
var inputSchema = () => ({
|
|
105
|
+
language: z.string().optional().describe(
|
|
106
|
+
"Filter by programming language (python, typescript, javascript, cloudformation)"
|
|
107
|
+
),
|
|
108
|
+
category: z.string().optional().describe(
|
|
109
|
+
"Filter by category (imports, auth, error-handling, architecture, testing, security, conventions, data-access)"
|
|
110
|
+
),
|
|
111
|
+
file_path: z.string().optional().describe(
|
|
112
|
+
"File path being edited \u2014 language is auto-detected from the extension"
|
|
113
|
+
)
|
|
114
|
+
});
|
|
115
|
+
function registerGetGuardrails(server, apiClient) {
|
|
116
|
+
const handler = async ({
|
|
117
|
+
language,
|
|
118
|
+
category,
|
|
119
|
+
file_path
|
|
120
|
+
}) => {
|
|
121
|
+
const guardrails = await apiClient.get("/v1/guardrails");
|
|
122
|
+
let filtered = guardrails;
|
|
123
|
+
const lang = language ?? (file_path ? detectLanguage(file_path)?.language : void 0);
|
|
124
|
+
if (lang) {
|
|
125
|
+
filtered = filtered.filter(
|
|
126
|
+
(g) => g.description.toLowerCase().includes(lang.toLowerCase()) || g.category.toLowerCase().includes(lang.toLowerCase()) || g.title.toLowerCase().includes(lang.toLowerCase())
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (category) {
|
|
130
|
+
filtered = filtered.filter(
|
|
131
|
+
(g) => g.category.toLowerCase() === category.toLowerCase()
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (filtered.length === 0) {
|
|
135
|
+
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}"` : ""}.`;
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text", text: text4 }]
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const text3 = filtered.map(
|
|
141
|
+
(g) => `## ${g.title} [${g.severity}]
|
|
142
|
+
|
|
143
|
+
${g.description}
|
|
144
|
+
|
|
145
|
+
` + (g.pattern ? `**Bad pattern:**
|
|
146
|
+
\`\`\`
|
|
147
|
+
${g.pattern}
|
|
148
|
+
\`\`\`
|
|
149
|
+
|
|
150
|
+
` : "") + (g.fix ? `**Correct pattern:**
|
|
151
|
+
\`\`\`
|
|
152
|
+
${g.fix}
|
|
153
|
+
\`\`\`
|
|
154
|
+
|
|
155
|
+
` : "") + (g.fitnessTest ? `**Detection:** \`${g.fitnessTest}\`
|
|
156
|
+
` : "")
|
|
157
|
+
).join("\n---\n\n");
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{
|
|
161
|
+
type: "text",
|
|
162
|
+
text: `Found ${filtered.length} check(s):
|
|
163
|
+
|
|
164
|
+
${text3}`
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
server.registerTool(
|
|
170
|
+
"get_checks",
|
|
171
|
+
{
|
|
172
|
+
title: "Get Checks",
|
|
173
|
+
description: DESCRIPTION,
|
|
174
|
+
inputSchema: inputSchema()
|
|
175
|
+
},
|
|
176
|
+
handler
|
|
177
|
+
);
|
|
178
|
+
server.registerTool(
|
|
179
|
+
"get_guardrails",
|
|
180
|
+
{
|
|
181
|
+
title: "Get Checks (legacy name)",
|
|
182
|
+
description: legacyToolDescription("get_checks", DESCRIPTION),
|
|
183
|
+
inputSchema: inputSchema()
|
|
184
|
+
},
|
|
185
|
+
handler
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/tools/get-specs.ts
|
|
190
|
+
import { z as z2 } from "zod";
|
|
191
|
+
function registerGetSpecs(server, apiClient) {
|
|
192
|
+
server.registerTool(
|
|
193
|
+
"get_specs",
|
|
194
|
+
{
|
|
195
|
+
title: "Get Specs",
|
|
196
|
+
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.",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
draft_id: z2.string().optional().describe("Specific spec ID to retrieve with full content"),
|
|
199
|
+
status: z2.enum(["draft", "ready", "archived"]).optional().describe("Filter by status"),
|
|
200
|
+
search: z2.string().optional().describe("Keyword search across spec names")
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
async ({ draft_id, status, search }) => {
|
|
204
|
+
if (draft_id) {
|
|
205
|
+
const spec = await apiClient.get(
|
|
206
|
+
`/v1/specs/${draft_id}`
|
|
207
|
+
);
|
|
208
|
+
const sections = [`# ${spec.name}
|
|
209
|
+
|
|
210
|
+
**Status:** ${spec.status}`];
|
|
211
|
+
if (spec.proposalContent) {
|
|
212
|
+
sections.push(`## Proposal
|
|
213
|
+
|
|
214
|
+
${spec.proposalContent}`);
|
|
215
|
+
}
|
|
216
|
+
if (spec.requirements.length > 0) {
|
|
217
|
+
const reqs = spec.requirements.map((r) => `### ${r.name}
|
|
218
|
+
|
|
219
|
+
${r.content}`).join("\n\n");
|
|
220
|
+
sections.push(`## Requirements
|
|
221
|
+
|
|
222
|
+
${reqs}`);
|
|
223
|
+
}
|
|
224
|
+
if (spec.taskContent) {
|
|
225
|
+
sections.push(`## Tasks
|
|
226
|
+
|
|
227
|
+
${spec.taskContent}`);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: "text", text: sections.join("\n\n") }]
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const specs = await apiClient.get("/v1/specs");
|
|
234
|
+
let filtered = specs;
|
|
235
|
+
if (status) {
|
|
236
|
+
filtered = filtered.filter((s) => s.status === status);
|
|
237
|
+
}
|
|
238
|
+
if (search) {
|
|
239
|
+
const term = search.toLowerCase();
|
|
240
|
+
filtered = filtered.filter(
|
|
241
|
+
(s) => s.name.toLowerCase().includes(term)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (filtered.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
content: [
|
|
247
|
+
{
|
|
248
|
+
type: "text",
|
|
249
|
+
text: `No specs found${status ? ` with status "${status}"` : ""}${search ? ` matching "${search}"` : ""}.`
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const text3 = filtered.map(
|
|
255
|
+
(s) => `- **${s.name}** (${s.draftId}) \u2014 ${s.status}, ${s.requirementCount} requirements` + (s.repoFullName ? `, repo: ${s.repoFullName}` : "")
|
|
256
|
+
).join("\n");
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "text",
|
|
261
|
+
text: `Found ${filtered.length} spec(s):
|
|
262
|
+
|
|
263
|
+
${text3}`
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/tools/get-docs.ts
|
|
272
|
+
import { z as z3 } from "zod";
|
|
273
|
+
function matchGlob(path, pattern) {
|
|
274
|
+
const regex = pattern.replace(/\./g, "\\.").replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^/]*").replace(/\{\{GLOBSTAR\}\}/g, ".*");
|
|
275
|
+
return new RegExp(`^${regex}$`).test(path);
|
|
276
|
+
}
|
|
277
|
+
function registerGetDocs(server, apiClient) {
|
|
278
|
+
server.registerTool(
|
|
279
|
+
"get_docs",
|
|
280
|
+
{
|
|
281
|
+
title: "Get Docs",
|
|
282
|
+
description: "Retrieve synced documentation from connected GitHub repositories. Filter by repo, file path pattern, or keyword search. Pass doc_id with section (a heading anchor) or chunk_id to retrieve just that section instead of the whole document.",
|
|
283
|
+
inputSchema: {
|
|
284
|
+
repo: z3.string().optional().describe("Filter by repo (owner/repo format)"),
|
|
285
|
+
path: z3.string().optional().describe("File path glob pattern (e.g., docs/adr/*.md)"),
|
|
286
|
+
search: z3.string().optional().describe("Keyword search across document content"),
|
|
287
|
+
doc_id: z3.string().optional().describe(
|
|
288
|
+
"Specific document ID to retrieve full content"
|
|
289
|
+
),
|
|
290
|
+
section: z3.string().optional().describe(
|
|
291
|
+
"Heading anchor (e.g. 'rollback-strategy') \u2014 with doc_id, returns just that section"
|
|
292
|
+
),
|
|
293
|
+
chunk_id: z3.string().optional().describe(
|
|
294
|
+
"Section chunk id ('{path_hash}#{anchor}') from get_context \u2014 with doc_id, returns just that section"
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
async ({ repo, path, search, doc_id, section, chunk_id }) => {
|
|
299
|
+
if (doc_id) {
|
|
300
|
+
const anchor = section ?? (chunk_id ? chunk_id.split("#").pop() : void 0);
|
|
301
|
+
const doc = await apiClient.get(
|
|
302
|
+
`/v1/docs/${encodeURIComponent(doc_id)}/content`,
|
|
303
|
+
anchor ? { section: anchor } : void 0
|
|
304
|
+
);
|
|
305
|
+
if (doc.error) {
|
|
306
|
+
return {
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: "text",
|
|
310
|
+
text: `Error retrieving document: ${doc.error}`
|
|
311
|
+
}
|
|
312
|
+
],
|
|
313
|
+
isError: true
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
let text4 = doc.content;
|
|
317
|
+
if (anchor && doc.section_resolved === false) {
|
|
318
|
+
text4 = `_Section "${anchor}" not found \u2014 returning the full document._
|
|
319
|
+
|
|
320
|
+
` + text4;
|
|
321
|
+
} else if (anchor && doc.heading) {
|
|
322
|
+
text4 = `## ${doc.heading}
|
|
323
|
+
|
|
324
|
+
${doc.content}`;
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
content: [{ type: "text", text: text4 }]
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const params = {};
|
|
331
|
+
if (repo) params.repo = repo;
|
|
332
|
+
if (search) params.q = search;
|
|
333
|
+
const response = await apiClient.get(
|
|
334
|
+
"/v1/docs",
|
|
335
|
+
params
|
|
336
|
+
);
|
|
337
|
+
let items = response.items;
|
|
338
|
+
if (path) {
|
|
339
|
+
items = items.filter((item) => matchGlob(item.path, path));
|
|
340
|
+
}
|
|
341
|
+
if (items.length === 0) {
|
|
342
|
+
return {
|
|
343
|
+
content: [
|
|
344
|
+
{
|
|
345
|
+
type: "text",
|
|
346
|
+
text: `No documents found${repo ? ` in ${repo}` : ""}${path ? ` matching "${path}"` : ""}${search ? ` for "${search}"` : ""}.`
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const text3 = items.map((d) => {
|
|
352
|
+
const sections = (d.outline ?? []).slice(0, 8);
|
|
353
|
+
const sectionHint = sections.length > 0 ? `
|
|
354
|
+
sections: ${sections.map((s) => s.anchor).join(", ")}` : "";
|
|
355
|
+
return `- **${d.title}** \u2014 \`${d.repo_full_name}/${d.path}\` (${d.doc_type}, ${d.size} bytes, synced ${d.synced_at})
|
|
356
|
+
doc_id: \`${d.id}\`` + (d.snippet ? `
|
|
357
|
+
> ${d.snippet}` : "") + sectionHint;
|
|
358
|
+
}).join("\n");
|
|
359
|
+
return {
|
|
360
|
+
content: [
|
|
361
|
+
{
|
|
362
|
+
type: "text",
|
|
363
|
+
text: `Found ${items.length} document(s) (${response.total} total):
|
|
364
|
+
|
|
365
|
+
${text3}
|
|
366
|
+
|
|
367
|
+
Use doc_id for full content, or doc_id + section to fetch one section.`
|
|
368
|
+
}
|
|
369
|
+
]
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/tools/get-tech-stack.ts
|
|
376
|
+
function registerGetTechStack(server, apiClient) {
|
|
377
|
+
server.registerTool(
|
|
378
|
+
"get_tech_stack",
|
|
379
|
+
{
|
|
380
|
+
title: "Get Tech Stack",
|
|
381
|
+
description: "Retrieve the configured technology stack (languages, frameworks, databases, infrastructure) with versions and notes."
|
|
382
|
+
},
|
|
383
|
+
async () => {
|
|
384
|
+
const stack = await apiClient.get(
|
|
385
|
+
"/v1/ai/settings/stack"
|
|
386
|
+
);
|
|
387
|
+
if (stack.length === 0) {
|
|
388
|
+
return {
|
|
389
|
+
content: [
|
|
390
|
+
{
|
|
391
|
+
type: "text",
|
|
392
|
+
text: `No tech stack entries configured. Add your stack at ${configLink("/docs/tech-stack", await workspaceBase(apiClient))}`
|
|
393
|
+
}
|
|
394
|
+
]
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
398
|
+
for (const entry of stack) {
|
|
399
|
+
const list = grouped.get(entry.category) ?? [];
|
|
400
|
+
list.push(entry);
|
|
401
|
+
grouped.set(entry.category, list);
|
|
402
|
+
}
|
|
403
|
+
const sections = Array.from(grouped.entries()).map(([category, entries2]) => {
|
|
404
|
+
const items = entries2.map(
|
|
405
|
+
(e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
|
|
406
|
+
).join("\n");
|
|
407
|
+
return `### ${category}
|
|
408
|
+
${items}`;
|
|
409
|
+
}).join("\n\n");
|
|
410
|
+
return {
|
|
411
|
+
content: [
|
|
412
|
+
{
|
|
413
|
+
type: "text",
|
|
414
|
+
text: `Tech stack (${stack.length} entries):
|
|
415
|
+
|
|
416
|
+
${sections}`
|
|
417
|
+
}
|
|
418
|
+
]
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/tools/get-planning-context.ts
|
|
425
|
+
import { z as z4 } from "zod";
|
|
426
|
+
function registerGetPlanningContext(server, apiClient) {
|
|
427
|
+
server.registerTool(
|
|
428
|
+
"get_planning_context",
|
|
429
|
+
{
|
|
430
|
+
title: "Get Planning Context",
|
|
431
|
+
description: "Retrieve org-wide planning context: connected repos with summaries, cross-repo dependencies, ADRs, and active specs. Optionally focus on a target repo.",
|
|
432
|
+
inputSchema: {
|
|
433
|
+
target_repo: z4.string().optional().describe(
|
|
434
|
+
"Focus on a specific repo (owner/repo format) to get its CLAUDE.md and relevant context"
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
async ({ target_repo }) => {
|
|
439
|
+
const params = {};
|
|
440
|
+
if (target_repo) params.targetRepo = target_repo;
|
|
441
|
+
const ctx = await apiClient.get(
|
|
442
|
+
"/v1/org/planning-context",
|
|
443
|
+
params
|
|
444
|
+
);
|
|
445
|
+
const sections = [];
|
|
446
|
+
if (ctx.repoSummaries.length > 0) {
|
|
447
|
+
const repos = ctx.repoSummaries.map(
|
|
448
|
+
(r) => `#### ${r.repo}
|
|
449
|
+
${r.purpose}
|
|
450
|
+
|
|
451
|
+
${r.summary}` + (r.conventions.length > 0 ? `
|
|
452
|
+
|
|
453
|
+
**Conventions:**
|
|
454
|
+
${r.conventions.map((c) => `- ${c}`).join("\n")}` : "")
|
|
455
|
+
).join("\n\n");
|
|
456
|
+
sections.push(`## Repositories (${ctx.repoSummaries.length})
|
|
457
|
+
|
|
458
|
+
${repos}`);
|
|
459
|
+
}
|
|
460
|
+
if (ctx.dependencies.length > 0) {
|
|
461
|
+
const deps = ctx.dependencies.map((d) => `- ${d.from} \u2192 ${d.to}${d.provenance === "inferred" ? " (inferred)" : ""}`).join("\n");
|
|
462
|
+
sections.push(`## Cross-Repo Dependencies
|
|
463
|
+
|
|
464
|
+
${deps}`);
|
|
465
|
+
}
|
|
466
|
+
if (ctx.adrs.length > 0) {
|
|
467
|
+
const adrs = ctx.adrs.map((a) => `- ${a.title}${a.repo ? ` (${a.repo})` : ""}`).join("\n");
|
|
468
|
+
sections.push(`## Architecture Decision Records (${ctx.adrs.length})
|
|
469
|
+
|
|
470
|
+
${adrs}`);
|
|
471
|
+
}
|
|
472
|
+
if (ctx.specs.length > 0) {
|
|
473
|
+
const specs = ctx.specs.map((s) => `- ${s.name} [${s.status}]`).join("\n");
|
|
474
|
+
sections.push(`## Specs (${ctx.specs.length})
|
|
475
|
+
|
|
476
|
+
${specs}`);
|
|
477
|
+
}
|
|
478
|
+
if (sections.length === 0) {
|
|
479
|
+
return {
|
|
480
|
+
content: [
|
|
481
|
+
{
|
|
482
|
+
type: "text",
|
|
483
|
+
text: `No planning context available. Connect repositories at ${configLink("/settings/github", await workspaceBase(apiClient))}`
|
|
484
|
+
}
|
|
485
|
+
]
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: "text",
|
|
492
|
+
text: sections.join("\n\n")
|
|
493
|
+
}
|
|
494
|
+
]
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/tools/get-plan-board.ts
|
|
501
|
+
import { z as z5 } from "zod";
|
|
502
|
+
var API_ITEM_PAGE = 100;
|
|
503
|
+
var DEFAULT_LIMIT = 50;
|
|
504
|
+
var BUCKET_LABELS = {
|
|
505
|
+
inProgress: "In Progress",
|
|
506
|
+
todo: "Todo",
|
|
507
|
+
done: "Done"
|
|
508
|
+
};
|
|
509
|
+
var BUCKET_ORDER = ["inProgress", "todo", "done"];
|
|
510
|
+
var STATUS_ARG_TO_BUCKET = {
|
|
511
|
+
todo: "todo",
|
|
512
|
+
in_progress: "inProgress",
|
|
513
|
+
done: "done"
|
|
514
|
+
};
|
|
515
|
+
function bucketOf(item) {
|
|
516
|
+
const status = (item.fields?.Status ?? "").trim().toLowerCase();
|
|
517
|
+
if (status) {
|
|
518
|
+
if (status.includes("done") || status.includes("ship") || status.includes("complete")) {
|
|
519
|
+
return "done";
|
|
520
|
+
}
|
|
521
|
+
if (status.includes("progress") || status.includes("doing") || status.includes("review")) {
|
|
522
|
+
return "inProgress";
|
|
523
|
+
}
|
|
524
|
+
return "todo";
|
|
525
|
+
}
|
|
526
|
+
return item.state === "CLOSED" ? "done" : "todo";
|
|
527
|
+
}
|
|
528
|
+
function formatBoards(boards) {
|
|
529
|
+
const lines = boards.map((b) => {
|
|
530
|
+
const c = b.statusCounts ?? { todo: 0, inProgress: 0, done: 0 };
|
|
531
|
+
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 ? `
|
|
532
|
+
${b.description}` : "") + `
|
|
533
|
+
${b.url}`;
|
|
534
|
+
});
|
|
535
|
+
return lines.join("\n");
|
|
536
|
+
}
|
|
537
|
+
function formatItem(item) {
|
|
538
|
+
const meta = [];
|
|
539
|
+
if (item.labels.length > 0) meta.push(`labels: ${item.labels.join(", ")}`);
|
|
540
|
+
if (item.assignees.length > 0)
|
|
541
|
+
meta.push(`assignees: ${item.assignees.join(", ")}`);
|
|
542
|
+
const epic = item.fields?.Epic;
|
|
543
|
+
if (epic) meta.push(`epic: ${epic}`);
|
|
544
|
+
return `- #${item.number} ${item.title}${item.repo ? ` \u2014 ${item.repo}` : ""}` + (meta.length > 0 ? `
|
|
545
|
+
${meta.join(" \xB7 ")}` : "") + (item.url ? `
|
|
546
|
+
${item.url}` : "");
|
|
547
|
+
}
|
|
548
|
+
function registerGetPlanBoard(server, apiClient) {
|
|
549
|
+
server.registerTool(
|
|
550
|
+
"get_plan_board",
|
|
551
|
+
{
|
|
552
|
+
title: "Get Plan Board",
|
|
553
|
+
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.",
|
|
554
|
+
inputSchema: {
|
|
555
|
+
project_number: z5.number().int().positive().optional().describe(
|
|
556
|
+
"Board number (the N in github.com/orgs/{org}/projects/N). Omit to list the open boards."
|
|
557
|
+
),
|
|
558
|
+
status: z5.enum(["todo", "in_progress", "done"]).optional().describe(
|
|
559
|
+
"With project_number, return only the items in this status group"
|
|
560
|
+
),
|
|
561
|
+
limit: z5.number().int().positive().max(200).optional().describe(
|
|
562
|
+
`Max items to list (default ${DEFAULT_LIMIT}). Group headings always show the true totals.`
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
async ({ project_number, status, limit }) => {
|
|
567
|
+
if (project_number === void 0) {
|
|
568
|
+
const boards = await apiClient.get(
|
|
569
|
+
"/v1/planning/projects"
|
|
570
|
+
);
|
|
571
|
+
if (boards.length === 0) {
|
|
572
|
+
return {
|
|
573
|
+
content: [
|
|
574
|
+
{
|
|
575
|
+
type: "text",
|
|
576
|
+
text: `No project boards found. Connect a GitHub organisation and create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
|
|
577
|
+
}
|
|
578
|
+
]
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const open = boards.filter((b) => !b.closed);
|
|
582
|
+
if (open.length === 0) {
|
|
583
|
+
return {
|
|
584
|
+
content: [
|
|
585
|
+
{
|
|
586
|
+
type: "text",
|
|
587
|
+
text: `No open project boards (${boards.length} closed). Reopen one, or create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
|
|
588
|
+
}
|
|
589
|
+
]
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const closedNote = boards.length > open.length ? `
|
|
593
|
+
|
|
594
|
+
_${boards.length - open.length} closed board(s) not listed._` : "";
|
|
595
|
+
return {
|
|
596
|
+
content: [
|
|
597
|
+
{
|
|
598
|
+
type: "text",
|
|
599
|
+
text: `${open.length} open project board(s):
|
|
600
|
+
|
|
601
|
+
${formatBoards(open)}` + closedNote + `
|
|
602
|
+
|
|
603
|
+
Call get_plan_board with project_number to list a board's issues.`
|
|
604
|
+
}
|
|
605
|
+
]
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const response = await apiClient.get(
|
|
609
|
+
`/v1/planning/projects/${project_number}/items`
|
|
610
|
+
);
|
|
611
|
+
const allItems = response.items ?? [];
|
|
612
|
+
const boardName = response.projectTitle ? `**${response.projectTitle}** \u2014 board #${project_number}` : `Board #${project_number}`;
|
|
613
|
+
if (allItems.length === 0) {
|
|
614
|
+
return {
|
|
615
|
+
content: [
|
|
616
|
+
{
|
|
617
|
+
type: "text",
|
|
618
|
+
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))}`
|
|
619
|
+
}
|
|
620
|
+
]
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const grouped = {
|
|
624
|
+
inProgress: [],
|
|
625
|
+
todo: [],
|
|
626
|
+
done: []
|
|
627
|
+
};
|
|
628
|
+
for (const item of allItems) grouped[bucketOf(item)].push(item);
|
|
629
|
+
const wanted = status ? [STATUS_ARG_TO_BUCKET[status]] : BUCKET_ORDER;
|
|
630
|
+
const shown = wanted.reduce((n, b) => n + grouped[b].length, 0);
|
|
631
|
+
if (shown === 0) {
|
|
632
|
+
return {
|
|
633
|
+
content: [
|
|
634
|
+
{
|
|
635
|
+
type: "text",
|
|
636
|
+
text: `${boardName}: no items with status "${status}" (of ${allItems.length} item(s) on the board).`
|
|
637
|
+
}
|
|
638
|
+
]
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const cap = limit ?? DEFAULT_LIMIT;
|
|
642
|
+
let budget = cap;
|
|
643
|
+
const sections = [];
|
|
644
|
+
let rendered = 0;
|
|
645
|
+
for (const b of wanted) {
|
|
646
|
+
const items = grouped[b];
|
|
647
|
+
if (items.length === 0) continue;
|
|
648
|
+
const slice = items.slice(0, Math.max(budget, 0));
|
|
649
|
+
budget -= slice.length;
|
|
650
|
+
rendered += slice.length;
|
|
651
|
+
const omitted = items.length - slice.length;
|
|
652
|
+
sections.push(
|
|
653
|
+
`### ${BUCKET_LABELS[b]} (${items.length})
|
|
654
|
+
` + (slice.length > 0 ? slice.map(formatItem).join("\n") : "_(not listed \u2014 item limit reached)_") + (omitted > 0 && slice.length > 0 ? `
|
|
655
|
+
_\u2026${omitted} more not listed._` : "")
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const notes = [];
|
|
659
|
+
if (rendered < shown) {
|
|
660
|
+
notes.push(
|
|
661
|
+
`_Listing ${rendered} of ${shown} matching item(s) \u2014 raise limit (max 200) or filter by status to see the rest._`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
if (allItems.length >= API_ITEM_PAGE) {
|
|
665
|
+
notes.push(
|
|
666
|
+
`_The API reads the board's first ${API_ITEM_PAGE} items; a larger board may have more._`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
content: [
|
|
671
|
+
{
|
|
672
|
+
type: "text",
|
|
673
|
+
text: `${boardName} \u2014 ${allItems.length} item(s)
|
|
674
|
+
|
|
675
|
+
` + sections.join("\n\n") + (notes.length > 0 ? `
|
|
676
|
+
|
|
677
|
+
${notes.join("\n")}` : "")
|
|
678
|
+
}
|
|
679
|
+
]
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/tools/get-change.ts
|
|
686
|
+
import { z as z6 } from "zod";
|
|
687
|
+
var GITHUB_NAME = "[A-Za-z0-9][A-Za-z0-9._-]{0,99}";
|
|
688
|
+
var REPO_RE = new RegExp(`^${GITHUB_NAME}/${GITHUB_NAME}$`);
|
|
689
|
+
var SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
690
|
+
var SLUG_MAX = 64;
|
|
691
|
+
var PARTS = ["decision", "contract", "behaviour", "plan", "release"];
|
|
692
|
+
var BRIEF = ["decision", "contract", "behaviour", "plan"];
|
|
693
|
+
var PAST_REVIEW = /* @__PURE__ */ new Set(["passed", "being built", "done"]);
|
|
694
|
+
function briefVerdict(states) {
|
|
695
|
+
const waiting = BRIEF.filter(
|
|
696
|
+
(part) => !(part === "decision" && states.decision === null) && !PAST_REVIEW.has(String(states[part]))
|
|
697
|
+
).map((part) => `${part} is ${states[part] ?? "unknown"}`);
|
|
698
|
+
return waiting.length === 0 ? "The brief is passed" : `The brief is not passed: ${waiting.join(", ")}`;
|
|
699
|
+
}
|
|
700
|
+
function prLink(pr) {
|
|
701
|
+
return `[${pr.repo}#${pr.number}](${pr.url}) ${pr.state}`;
|
|
702
|
+
}
|
|
703
|
+
function fileSource(file) {
|
|
704
|
+
if (file.pullRequest) return prLink(file.pullRequest);
|
|
705
|
+
return file.direct ? "committed directly" : "\u2014";
|
|
706
|
+
}
|
|
707
|
+
function partPullRequests(change, part) {
|
|
708
|
+
const files = part === "contract" ? change.contract.files : part === "behaviour" ? change.behaviour.files : part === "plan" ? [change.plan.file] : [];
|
|
709
|
+
const prs = part === "decision" ? [change.decision?.pullRequest ?? null] : part === "release" ? change.release.prs : files.map((file) => file?.pullRequest ?? null);
|
|
710
|
+
const unique = /* @__PURE__ */ new Map();
|
|
711
|
+
for (const pr of prs) {
|
|
712
|
+
if (pr && !unique.has(`${pr.repo}#${pr.number}`)) unique.set(`${pr.repo}#${pr.number}`, pr);
|
|
713
|
+
}
|
|
714
|
+
return [...unique.values()].sort((a, b) => Number(b.state === "open") - Number(a.state === "open"));
|
|
715
|
+
}
|
|
716
|
+
function countsLine(counts) {
|
|
717
|
+
const tags = ["proposed", "built", "defect", "unbuilt", "open", "untagged"].filter((tag) => counts[tag] > 0).map((tag) => `${counts[tag]} ${tag}`);
|
|
718
|
+
return `${counts.total} scenario(s)` + (tags.length > 0 ? `: ${tags.join(", ")}` : "");
|
|
719
|
+
}
|
|
720
|
+
function partsTable(change) {
|
|
721
|
+
const rows = PARTS.filter((part) => !(part === "decision" && change.states.decision === null)).map((part) => {
|
|
722
|
+
const prs = partPullRequests(change, part);
|
|
723
|
+
return `| ${part} | ${change.states[part]} | ${prs.length > 0 ? prs.map(prLink).join(", ") : "\u2014"} |`;
|
|
724
|
+
});
|
|
725
|
+
return ["| Part | State | Pull request |", "|---|---|---|", ...rows].join("\n");
|
|
726
|
+
}
|
|
727
|
+
function decisionSection(change) {
|
|
728
|
+
const decision = change.decision;
|
|
729
|
+
if (decision === null) return "## Decision\n\nNone: the proposal names no ADR (a small change).";
|
|
730
|
+
const status = decision.status || "no record in a connected repository";
|
|
731
|
+
return `## Decision
|
|
732
|
+
|
|
733
|
+
${decision.adr} \xB7 ${status}${decision.title ? ` \u2014 ${decision.title}` : ""}`;
|
|
734
|
+
}
|
|
735
|
+
function contractSection(change) {
|
|
736
|
+
const files = change.contract.files;
|
|
737
|
+
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).");
|
|
738
|
+
}
|
|
739
|
+
function behaviourSection(change) {
|
|
740
|
+
const { files, counts } = change.behaviour;
|
|
741
|
+
if (files.length === 0) {
|
|
742
|
+
return `## Behaviour
|
|
743
|
+
|
|
744
|
+
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}` : ""}.`;
|
|
745
|
+
}
|
|
746
|
+
const blocks = files.map(
|
|
747
|
+
(file) => [
|
|
748
|
+
`### ${file.path}`,
|
|
749
|
+
"",
|
|
750
|
+
[file.title, file.runner ? `runner ${file.runner}` : "", fileSource(file), countsLine(file.counts)].filter(Boolean).join(" \xB7 "),
|
|
751
|
+
"",
|
|
752
|
+
...file.scenarios.map((s) => `- ${s.id || "(no id)"} \xB7 ${s.state || "untagged"} \xB7 ${s.title}`)
|
|
753
|
+
].join("\n")
|
|
754
|
+
);
|
|
755
|
+
return ["## Behaviour", countsLine(counts), ...blocks].join("\n\n");
|
|
756
|
+
}
|
|
757
|
+
function planSection(change) {
|
|
758
|
+
const { file, draftId, board } = change.plan;
|
|
759
|
+
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";
|
|
760
|
+
return [
|
|
761
|
+
"## Plan",
|
|
762
|
+
"",
|
|
763
|
+
`- plan.md: ${file ? `${file.path} \u2014 ${fileSource(file)}` : "none"}`,
|
|
764
|
+
`- Draft: ${draftId ?? "none"}`,
|
|
765
|
+
`- Board: ${boardLine}`
|
|
766
|
+
].join("\n");
|
|
767
|
+
}
|
|
768
|
+
function releaseSection(change) {
|
|
769
|
+
const lines = change.release.prs.map(
|
|
770
|
+
(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")
|
|
771
|
+
);
|
|
772
|
+
return `## Release
|
|
773
|
+
|
|
774
|
+
${lines.length > 0 ? lines.join("\n") : "No release pull request is open."}`;
|
|
775
|
+
}
|
|
776
|
+
function formatChange(change) {
|
|
777
|
+
const header = `# ${change.title}
|
|
778
|
+
|
|
779
|
+
\`${change.key}\` \xB7 size: ${change.size}` + (change.archived ? " \xB7 archived: read-only, done" : "") + ` \xB7 synced ${change.syncedAt}`;
|
|
780
|
+
return [
|
|
781
|
+
header,
|
|
782
|
+
partsTable(change),
|
|
783
|
+
decisionSection(change),
|
|
784
|
+
contractSection(change),
|
|
785
|
+
behaviourSection(change),
|
|
786
|
+
planSection(change),
|
|
787
|
+
releaseSection(change),
|
|
788
|
+
// Compact: a change's every scenario with its steps rides in this block.
|
|
789
|
+
"```json\n" + JSON.stringify(change) + "\n```",
|
|
790
|
+
briefVerdict(change.states)
|
|
791
|
+
].join("\n\n");
|
|
792
|
+
}
|
|
793
|
+
function registerGetChange(server, apiClient) {
|
|
794
|
+
server.registerTool(
|
|
795
|
+
"get_change",
|
|
796
|
+
{
|
|
797
|
+
title: "Get Change",
|
|
798
|
+
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.`,
|
|
799
|
+
inputSchema: {
|
|
800
|
+
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"),
|
|
801
|
+
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>/")
|
|
802
|
+
},
|
|
803
|
+
annotations: { readOnlyHint: true }
|
|
804
|
+
},
|
|
805
|
+
async ({ repo, slug }) => {
|
|
806
|
+
const [owner, name] = repo.split("/");
|
|
807
|
+
let change;
|
|
808
|
+
try {
|
|
809
|
+
change = await apiClient.getFresh(
|
|
810
|
+
`/v1/changes/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${encodeURIComponent(slug)}`
|
|
811
|
+
);
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (err.status === 404) {
|
|
814
|
+
return {
|
|
815
|
+
content: [{ type: "text", text: `No such change ${repo}/${slug}` }],
|
|
816
|
+
isError: true
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
throw err;
|
|
820
|
+
}
|
|
821
|
+
return { content: [{ type: "text", text: formatChange(change) }] };
|
|
822
|
+
}
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/tools/get-context.ts
|
|
827
|
+
import { z as z7 } from "zod";
|
|
828
|
+
|
|
829
|
+
// src/lib/scoring.ts
|
|
830
|
+
var WEIGHT_PATH = 0.4;
|
|
831
|
+
var WEIGHT_KEYWORD = 0.3;
|
|
832
|
+
var WEIGHT_RECENCY = 0.15;
|
|
833
|
+
var WEIGHT_SEVERITY = 0.15;
|
|
834
|
+
var SEVERITY_SCORES = {
|
|
835
|
+
critical: 1,
|
|
836
|
+
high: 0.75,
|
|
837
|
+
medium: 0.5,
|
|
838
|
+
low: 0.25
|
|
839
|
+
};
|
|
840
|
+
var CHARS_PER_TOKEN = 4;
|
|
841
|
+
function estimateTokens(text3) {
|
|
842
|
+
return Math.ceil(text3.length / CHARS_PER_TOKEN);
|
|
843
|
+
}
|
|
844
|
+
function truncateToTokens(text3, maxTokens) {
|
|
845
|
+
const maxChars = Math.max(0, maxTokens) * CHARS_PER_TOKEN;
|
|
846
|
+
if (text3.length <= maxChars) return { text: text3, truncated: false };
|
|
847
|
+
const head = text3.slice(0, maxChars);
|
|
848
|
+
const floor = Math.floor(maxChars * 0.75);
|
|
849
|
+
const paragraph = head.lastIndexOf("\n\n");
|
|
850
|
+
const line = head.lastIndexOf("\n");
|
|
851
|
+
let cut = maxChars;
|
|
852
|
+
if (paragraph >= floor) cut = paragraph;
|
|
853
|
+
else if (line >= floor) cut = line;
|
|
854
|
+
return { text: head.slice(0, cut).trimEnd(), truncated: true };
|
|
855
|
+
}
|
|
856
|
+
function scorePathMatch(item, languages, categories) {
|
|
857
|
+
if (languages.length === 0 && categories.length === 0) return 0.5;
|
|
858
|
+
let score = 0;
|
|
859
|
+
const itemLang = item.language?.toLowerCase() ?? "";
|
|
860
|
+
const itemCat = item.category?.toLowerCase() ?? "";
|
|
861
|
+
if (itemLang && languages.some((l) => itemLang.includes(l))) {
|
|
862
|
+
score += 0.6;
|
|
863
|
+
}
|
|
864
|
+
if (itemCat && categories.some((c) => itemCat.includes(c))) {
|
|
865
|
+
score += 0.4;
|
|
866
|
+
}
|
|
867
|
+
return Math.min(score, 1);
|
|
868
|
+
}
|
|
869
|
+
function scoreKeywordMatch(item, keywords) {
|
|
870
|
+
if (keywords.length === 0) return 0;
|
|
871
|
+
const searchable = `${item.title} ${item.content}`.toLowerCase();
|
|
872
|
+
let matches = 0;
|
|
873
|
+
for (const kw of keywords) {
|
|
874
|
+
if (searchable.includes(kw)) matches++;
|
|
875
|
+
}
|
|
876
|
+
return Math.min(matches / keywords.length, 1);
|
|
877
|
+
}
|
|
878
|
+
function scoreRecency(item) {
|
|
879
|
+
if (!item.updatedAt) return 0.5;
|
|
880
|
+
const now = Date.now();
|
|
881
|
+
const updated = new Date(item.updatedAt).getTime();
|
|
882
|
+
const daysSince = (now - updated) / (1e3 * 60 * 60 * 24);
|
|
883
|
+
if (daysSince <= 7) return 1;
|
|
884
|
+
if (daysSince <= 30) return 0.75;
|
|
885
|
+
if (daysSince <= 90) return 0.5;
|
|
886
|
+
return 0.25;
|
|
887
|
+
}
|
|
888
|
+
function scoreSeverity(item) {
|
|
889
|
+
if (!item.severity) return 0.5;
|
|
890
|
+
return SEVERITY_SCORES[item.severity] ?? 0.5;
|
|
891
|
+
}
|
|
892
|
+
function extractKeywords(text3) {
|
|
893
|
+
const stopWords = /* @__PURE__ */ new Set([
|
|
894
|
+
"the",
|
|
895
|
+
"a",
|
|
896
|
+
"an",
|
|
897
|
+
"is",
|
|
898
|
+
"are",
|
|
899
|
+
"was",
|
|
900
|
+
"were",
|
|
901
|
+
"be",
|
|
902
|
+
"been",
|
|
903
|
+
"being",
|
|
904
|
+
"have",
|
|
905
|
+
"has",
|
|
906
|
+
"had",
|
|
907
|
+
"do",
|
|
908
|
+
"does",
|
|
909
|
+
"did",
|
|
910
|
+
"will",
|
|
911
|
+
"would",
|
|
912
|
+
"could",
|
|
913
|
+
"should",
|
|
914
|
+
"may",
|
|
915
|
+
"might",
|
|
916
|
+
"can",
|
|
917
|
+
"shall",
|
|
918
|
+
"to",
|
|
919
|
+
"of",
|
|
920
|
+
"in",
|
|
921
|
+
"for",
|
|
922
|
+
"on",
|
|
923
|
+
"with",
|
|
924
|
+
"at",
|
|
925
|
+
"by",
|
|
926
|
+
"from",
|
|
927
|
+
"as",
|
|
928
|
+
"into",
|
|
929
|
+
"about",
|
|
930
|
+
"between",
|
|
931
|
+
"through",
|
|
932
|
+
"after",
|
|
933
|
+
"before",
|
|
934
|
+
"during",
|
|
935
|
+
"without",
|
|
936
|
+
"this",
|
|
937
|
+
"that",
|
|
938
|
+
"these",
|
|
939
|
+
"those",
|
|
940
|
+
"it",
|
|
941
|
+
"its",
|
|
942
|
+
"and",
|
|
943
|
+
"or",
|
|
944
|
+
"but",
|
|
945
|
+
"not",
|
|
946
|
+
"no",
|
|
947
|
+
"all",
|
|
948
|
+
"each",
|
|
949
|
+
"every",
|
|
950
|
+
"both",
|
|
951
|
+
"few",
|
|
952
|
+
"more",
|
|
953
|
+
"most",
|
|
954
|
+
"other",
|
|
955
|
+
"some",
|
|
956
|
+
"such",
|
|
957
|
+
"than",
|
|
958
|
+
"too",
|
|
959
|
+
"very",
|
|
960
|
+
"just",
|
|
961
|
+
"also",
|
|
962
|
+
"only",
|
|
963
|
+
"new",
|
|
964
|
+
"add",
|
|
965
|
+
"update",
|
|
966
|
+
"fix",
|
|
967
|
+
"change",
|
|
968
|
+
"make",
|
|
969
|
+
"use",
|
|
970
|
+
"get",
|
|
971
|
+
"set",
|
|
972
|
+
"need",
|
|
973
|
+
"want",
|
|
974
|
+
"implement",
|
|
975
|
+
"create"
|
|
976
|
+
]);
|
|
977
|
+
return text3.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
|
|
978
|
+
}
|
|
979
|
+
function scoreItems(items, taskDescription, filePaths) {
|
|
980
|
+
const languages = detectLanguages(filePaths);
|
|
981
|
+
const categories = detectCategories(filePaths);
|
|
982
|
+
const keywords = extractKeywords(taskDescription);
|
|
983
|
+
return items.map((item) => {
|
|
984
|
+
const pathScore = scorePathMatch(item, languages, categories);
|
|
985
|
+
const keywordScore = scoreKeywordMatch(item, keywords);
|
|
986
|
+
const recencyScore = scoreRecency(item);
|
|
987
|
+
const severityScore = scoreSeverity(item);
|
|
988
|
+
const score = pathScore * WEIGHT_PATH + keywordScore * WEIGHT_KEYWORD + recencyScore * WEIGHT_RECENCY + severityScore * WEIGHT_SEVERITY;
|
|
989
|
+
return {
|
|
990
|
+
type: item.type,
|
|
991
|
+
id: item.id,
|
|
992
|
+
title: item.title,
|
|
993
|
+
content: item.content,
|
|
994
|
+
score,
|
|
995
|
+
tokenEstimate: estimateTokens(item.content)
|
|
996
|
+
};
|
|
997
|
+
}).sort((a, b) => b.score - a.score);
|
|
998
|
+
}
|
|
999
|
+
function selectWithinBudget(items, maxTokens) {
|
|
1000
|
+
const selected = [];
|
|
1001
|
+
let tokensUsed = 0;
|
|
1002
|
+
for (const item of items) {
|
|
1003
|
+
if (tokensUsed + item.tokenEstimate > maxTokens) continue;
|
|
1004
|
+
selected.push(item);
|
|
1005
|
+
tokensUsed += item.tokenEstimate;
|
|
1006
|
+
}
|
|
1007
|
+
return { selected, tokensUsed, totalItems: items.length };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/lib/context-builder.ts
|
|
1011
|
+
var DEFAULT_BUDGET = 4e3;
|
|
1012
|
+
var RESERVE_TOKENS = 200;
|
|
1013
|
+
var GUARDRAIL_SHARE = 0.35;
|
|
1014
|
+
var MIN_BODY_TOKENS = 150;
|
|
1015
|
+
var MAX_HYDRATED = 8;
|
|
1016
|
+
var MAX_TAIL = 10;
|
|
1017
|
+
var TAIL_SHARE = 0.15;
|
|
1018
|
+
var TRUNCATION_NOTICE_TOKENS = 30;
|
|
1019
|
+
function formatGuardrail(g) {
|
|
1020
|
+
let text3 = `## ${g.title} [${g.severity}]
|
|
1021
|
+
|
|
1022
|
+
${g.description}`;
|
|
1023
|
+
if (g.pattern) text3 += `
|
|
1024
|
+
|
|
1025
|
+
**Bad:** \`\`\`
|
|
1026
|
+
${g.pattern}
|
|
1027
|
+
\`\`\``;
|
|
1028
|
+
if (g.fix) text3 += `
|
|
1029
|
+
|
|
1030
|
+
**Good:** \`\`\`
|
|
1031
|
+
${g.fix}
|
|
1032
|
+
\`\`\``;
|
|
1033
|
+
return text3;
|
|
1034
|
+
}
|
|
1035
|
+
function formatSpecHeadline(s) {
|
|
1036
|
+
return `### ${s.name}
|
|
1037
|
+
\`draft_id: ${s.draftId}\` \xB7 ${s.status} \xB7 ${s.requirementCount} requirements`;
|
|
1038
|
+
}
|
|
1039
|
+
function formatDocHeadline(d) {
|
|
1040
|
+
return `### ${d.title}
|
|
1041
|
+
\`doc_id: ${d.id}\` \xB7 \`${d.repo_full_name}/${d.path}\` \xB7 ${d.doc_type}`;
|
|
1042
|
+
}
|
|
1043
|
+
function formatStack(entries2) {
|
|
1044
|
+
return entries2.map(
|
|
1045
|
+
(e) => `- ${e.name}${e.version ? ` v${e.version}` : ""}${e.notes ? ` \u2014 ${e.notes}` : ""}`
|
|
1046
|
+
).join("\n");
|
|
1047
|
+
}
|
|
1048
|
+
function formatRules(rules) {
|
|
1049
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
1050
|
+
for (const r of rules) {
|
|
1051
|
+
const cat = r.category || "other";
|
|
1052
|
+
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
|
1053
|
+
byCategory.get(cat).push(r);
|
|
1054
|
+
}
|
|
1055
|
+
let text3 = "";
|
|
1056
|
+
for (const [cat, catRules] of byCategory) {
|
|
1057
|
+
text3 += `### ${cat.charAt(0).toUpperCase() + cat.slice(1)}
|
|
1058
|
+
`;
|
|
1059
|
+
for (const r of catRules) {
|
|
1060
|
+
text3 += `- ${r.text}
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
text3 += "\n";
|
|
1064
|
+
}
|
|
1065
|
+
return `## Project Rules (${rules.length})
|
|
1066
|
+
|
|
1067
|
+
${text3.trim()}`;
|
|
1068
|
+
}
|
|
1069
|
+
async function fetchSpecBody(api, draftId) {
|
|
1070
|
+
const spec = await api.get(
|
|
1071
|
+
`/v1/specs/${encodeURIComponent(draftId)}`
|
|
1072
|
+
);
|
|
1073
|
+
const parts = [];
|
|
1074
|
+
if (spec.proposalContent) {
|
|
1075
|
+
parts.push(`**Proposal**
|
|
1076
|
+
|
|
1077
|
+
${spec.proposalContent}`);
|
|
1078
|
+
}
|
|
1079
|
+
for (const r of spec.requirements ?? []) {
|
|
1080
|
+
parts.push(`**${r.name}**
|
|
1081
|
+
|
|
1082
|
+
${r.content}`);
|
|
1083
|
+
}
|
|
1084
|
+
if (spec.taskContent) {
|
|
1085
|
+
parts.push(`**Tasks**
|
|
1086
|
+
|
|
1087
|
+
${spec.taskContent}`);
|
|
1088
|
+
}
|
|
1089
|
+
const body = parts.join("\n\n").trim();
|
|
1090
|
+
if (!body) throw new Error("spec has no content");
|
|
1091
|
+
return body;
|
|
1092
|
+
}
|
|
1093
|
+
async function fetchDocBody(api, docId, anchor) {
|
|
1094
|
+
const doc = await api.get(
|
|
1095
|
+
`/v1/docs/${encodeURIComponent(docId)}/content`,
|
|
1096
|
+
anchor ? { section: anchor } : void 0
|
|
1097
|
+
);
|
|
1098
|
+
if (doc.error) throw new Error(doc.error);
|
|
1099
|
+
const body = (doc.content ?? "").trim();
|
|
1100
|
+
if (!body) throw new Error("document is empty");
|
|
1101
|
+
return body;
|
|
1102
|
+
}
|
|
1103
|
+
function githubDeepLink(repoFullName, path, anchor) {
|
|
1104
|
+
return `https://github.com/${repoFullName}/blob/HEAD/${path}#${anchor}`;
|
|
1105
|
+
}
|
|
1106
|
+
function formatSectionPointer(repoFullName, path, s) {
|
|
1107
|
+
return `\`${path}#${s.anchor}\` \xB7 \`chunk_id: ${s.chunkId}\` \xB7 [${s.heading}](${githubDeepLink(repoFullName, path, s.anchor)})`;
|
|
1108
|
+
}
|
|
1109
|
+
var SECTION_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
1110
|
+
"the",
|
|
1111
|
+
"a",
|
|
1112
|
+
"an",
|
|
1113
|
+
"of",
|
|
1114
|
+
"to",
|
|
1115
|
+
"and",
|
|
1116
|
+
"or",
|
|
1117
|
+
"for",
|
|
1118
|
+
"in",
|
|
1119
|
+
"on",
|
|
1120
|
+
"with",
|
|
1121
|
+
"how",
|
|
1122
|
+
"why",
|
|
1123
|
+
"what",
|
|
1124
|
+
"add",
|
|
1125
|
+
"new",
|
|
1126
|
+
"use",
|
|
1127
|
+
"using",
|
|
1128
|
+
"update",
|
|
1129
|
+
"fix"
|
|
1130
|
+
]);
|
|
1131
|
+
function selectSection(outline, taskDescription) {
|
|
1132
|
+
if (!outline || outline.length === 0) return void 0;
|
|
1133
|
+
const keywords = taskDescription.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !SECTION_STOP_WORDS.has(w));
|
|
1134
|
+
let best = outline[0];
|
|
1135
|
+
let bestScore = -1;
|
|
1136
|
+
for (const s of outline) {
|
|
1137
|
+
const heading = s.heading.toLowerCase();
|
|
1138
|
+
let score = 0;
|
|
1139
|
+
for (const kw of keywords) if (heading.includes(kw)) score++;
|
|
1140
|
+
if (score > bestScore) {
|
|
1141
|
+
best = s;
|
|
1142
|
+
bestScore = score;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return best;
|
|
1146
|
+
}
|
|
1147
|
+
async function hydrate(api, item, meta) {
|
|
1148
|
+
try {
|
|
1149
|
+
const body = meta.type === "spec" ? await fetchSpecBody(api, meta.id) : await fetchDocBody(api, meta.id, meta.section?.anchor);
|
|
1150
|
+
return { item, meta, body };
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
return {
|
|
1153
|
+
item,
|
|
1154
|
+
meta,
|
|
1155
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async function buildContext(api, { taskDescription, filePaths = [], maxTokens }) {
|
|
1160
|
+
const budget = maxTokens ?? DEFAULT_BUDGET;
|
|
1161
|
+
const failures = [];
|
|
1162
|
+
const orEmpty = (empty) => (err) => {
|
|
1163
|
+
failures.push(err);
|
|
1164
|
+
return empty;
|
|
1165
|
+
};
|
|
1166
|
+
const [rawGuardrails, rawSpecs, docs, stack, rules, docRepos] = await Promise.all([
|
|
1167
|
+
api.get("/v1/guardrails").catch(orEmpty([])),
|
|
1168
|
+
api.get("/v1/specs").catch(orEmpty([])),
|
|
1169
|
+
api.get("/v1/docs").catch(orEmpty({ items: [] })),
|
|
1170
|
+
api.get("/v1/ai/settings/stack").catch(orEmpty([])),
|
|
1171
|
+
api.get("/v1/standards/rules").catch(orEmpty([])),
|
|
1172
|
+
// Capability states — FAIL-OPEN: an unreadable config (older backend, a
|
|
1173
|
+
// blip) gates nothing, it can never hide working context.
|
|
1174
|
+
api.get("/v1/doc-repos").catch(() => null)
|
|
1175
|
+
]);
|
|
1176
|
+
if (failures.length === 5) throw failures[0];
|
|
1177
|
+
const disabled = (key2) => docRepos?.capabilities?.[key2]?.status === "disabled";
|
|
1178
|
+
const guardrails = disabled("fitness") ? [] : rawGuardrails;
|
|
1179
|
+
const specs = disabled("requirements") && disabled("design") && disabled("specs") ? [] : rawSpecs;
|
|
1180
|
+
const refs = /* @__PURE__ */ new Map();
|
|
1181
|
+
const key = (type, id) => `${type}:${id}`;
|
|
1182
|
+
const scorable = [
|
|
1183
|
+
...guardrails.map((g) => ({
|
|
1184
|
+
type: "guardrail",
|
|
1185
|
+
id: g.id,
|
|
1186
|
+
title: g.title,
|
|
1187
|
+
content: formatGuardrail(g),
|
|
1188
|
+
category: g.category,
|
|
1189
|
+
severity: g.severity,
|
|
1190
|
+
updatedAt: g.updatedAt
|
|
1191
|
+
})),
|
|
1192
|
+
...specs.filter((s) => s.status !== "archived").map((s) => {
|
|
1193
|
+
refs.set(key("spec", s.draftId), {
|
|
1194
|
+
type: "spec",
|
|
1195
|
+
id: s.draftId,
|
|
1196
|
+
title: s.name,
|
|
1197
|
+
listLine: `- **${s.name}** \u2014 \`draft_id: ${s.draftId}\` (${s.status}, ${s.requirementCount} requirements)`,
|
|
1198
|
+
fetchHint: `get_specs(draft_id="${s.draftId}")`
|
|
1199
|
+
});
|
|
1200
|
+
return {
|
|
1201
|
+
type: "spec",
|
|
1202
|
+
id: s.draftId,
|
|
1203
|
+
title: s.name,
|
|
1204
|
+
content: formatSpecHeadline(s),
|
|
1205
|
+
updatedAt: s.updatedAt
|
|
1206
|
+
};
|
|
1207
|
+
}),
|
|
1208
|
+
...docs.items.map((d) => {
|
|
1209
|
+
const section = selectSection(d.outline, taskDescription);
|
|
1210
|
+
const pointer = section ? formatSectionPointer(d.repo_full_name, d.path, section) : void 0;
|
|
1211
|
+
refs.set(key("doc", d.id), {
|
|
1212
|
+
type: "doc",
|
|
1213
|
+
id: d.id,
|
|
1214
|
+
title: d.title,
|
|
1215
|
+
section,
|
|
1216
|
+
pointer,
|
|
1217
|
+
listLine: `- **${d.title}** \u2014 \`doc_id: ${d.id}\` (\`${d.repo_full_name}/${d.path}\`)` + (pointer ? `
|
|
1218
|
+
\u21B3 ${pointer}` : ""),
|
|
1219
|
+
fetchHint: section ? `get_docs(doc_id="${d.id}", section="${section.anchor}")` : `get_docs(doc_id="${d.id}")`
|
|
1220
|
+
});
|
|
1221
|
+
return {
|
|
1222
|
+
type: "doc",
|
|
1223
|
+
id: d.id,
|
|
1224
|
+
title: d.title,
|
|
1225
|
+
content: formatDocHeadline(d),
|
|
1226
|
+
category: d.doc_type === "adr" ? "architecture" : void 0,
|
|
1227
|
+
updatedAt: d.synced_at
|
|
1228
|
+
};
|
|
1229
|
+
})
|
|
1230
|
+
];
|
|
1231
|
+
const ranked = scoreItems(scorable, taskDescription, filePaths);
|
|
1232
|
+
const sections = [];
|
|
1233
|
+
let overhead = 0;
|
|
1234
|
+
if (stack.length > 0) {
|
|
1235
|
+
const section = `## Tech Stack
|
|
1236
|
+
|
|
1237
|
+
${formatStack(stack)}`;
|
|
1238
|
+
sections.push(section);
|
|
1239
|
+
overhead += estimateTokens(section);
|
|
1240
|
+
}
|
|
1241
|
+
if (rules.length > 0) {
|
|
1242
|
+
const section = formatRules(rules);
|
|
1243
|
+
sections.push(section);
|
|
1244
|
+
overhead += estimateTokens(section);
|
|
1245
|
+
}
|
|
1246
|
+
let remaining = budget - RESERVE_TOKENS - overhead;
|
|
1247
|
+
const rankedGuardrails = ranked.filter((i) => i.type === "guardrail");
|
|
1248
|
+
const { selected: selectedGuardrails, tokensUsed: guardrailTokens } = selectWithinBudget(
|
|
1249
|
+
rankedGuardrails,
|
|
1250
|
+
Math.max(0, Math.floor(remaining * GUARDRAIL_SHARE))
|
|
1251
|
+
);
|
|
1252
|
+
remaining -= guardrailTokens;
|
|
1253
|
+
const rankedRefs = ranked.filter(
|
|
1254
|
+
(i) => i.type === "spec" || i.type === "doc"
|
|
1255
|
+
);
|
|
1256
|
+
const avgHeadline = rankedRefs.length ? Math.ceil(
|
|
1257
|
+
rankedRefs.slice(0, MAX_HYDRATED).reduce((sum, i) => sum + i.tokenEstimate, 0) / Math.min(rankedRefs.length, MAX_HYDRATED)
|
|
1258
|
+
) : 0;
|
|
1259
|
+
const affordable = Math.floor(
|
|
1260
|
+
Math.max(0, remaining) / (MIN_BODY_TOKENS + avgHeadline)
|
|
1261
|
+
);
|
|
1262
|
+
const shortlist = rankedRefs.slice(
|
|
1263
|
+
0,
|
|
1264
|
+
Math.max(0, Math.min(MAX_HYDRATED, affordable))
|
|
1265
|
+
);
|
|
1266
|
+
for (const item of shortlist) {
|
|
1267
|
+
remaining -= item.tokenEstimate;
|
|
1268
|
+
}
|
|
1269
|
+
remaining = Math.max(0, remaining);
|
|
1270
|
+
const hydrated = await Promise.all(
|
|
1271
|
+
shortlist.map(
|
|
1272
|
+
(item) => hydrate(api, item, refs.get(key(item.type, item.id)))
|
|
1273
|
+
)
|
|
1274
|
+
);
|
|
1275
|
+
const tailCandidates = rankedRefs.slice(shortlist.length, shortlist.length + MAX_TAIL).map((i) => refs.get(key(i.type, i.id)));
|
|
1276
|
+
const tailCost = tailCandidates.reduce(
|
|
1277
|
+
(sum, m) => sum + estimateTokens(m.listLine),
|
|
1278
|
+
0
|
|
1279
|
+
);
|
|
1280
|
+
const tailReserve = Math.min(
|
|
1281
|
+
tailCost,
|
|
1282
|
+
Math.floor(remaining * TAIL_SHARE)
|
|
1283
|
+
);
|
|
1284
|
+
const bodyBudgetStart = Math.max(0, remaining - tailReserve);
|
|
1285
|
+
let bodyBudget = bodyBudgetStart;
|
|
1286
|
+
let itemsLeft = hydrated.length;
|
|
1287
|
+
let itemsWithBody = 0;
|
|
1288
|
+
let bodyFailures = 0;
|
|
1289
|
+
const blocks = [];
|
|
1290
|
+
for (const h of hydrated) {
|
|
1291
|
+
itemsLeft--;
|
|
1292
|
+
const headline = h.meta.pointer ? `${h.item.content}
|
|
1293
|
+
${h.meta.pointer}` : h.item.content;
|
|
1294
|
+
const emit = (block) => {
|
|
1295
|
+
bodyBudget -= Math.max(
|
|
1296
|
+
0,
|
|
1297
|
+
estimateTokens(block) - h.item.tokenEstimate
|
|
1298
|
+
);
|
|
1299
|
+
blocks.push(block);
|
|
1300
|
+
};
|
|
1301
|
+
if (h.error !== void 0) {
|
|
1302
|
+
bodyFailures++;
|
|
1303
|
+
emit(
|
|
1304
|
+
`${headline}
|
|
1305
|
+
|
|
1306
|
+
_Body unavailable (${h.error}) \u2014 retry with \`${h.meta.fetchHint}\`._`
|
|
1307
|
+
);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
const available = Math.max(0, bodyBudget);
|
|
1311
|
+
const allowance = Math.min(
|
|
1312
|
+
Math.max(MIN_BODY_TOKENS, Math.floor(available / (itemsLeft + 1))),
|
|
1313
|
+
available
|
|
1314
|
+
);
|
|
1315
|
+
if (allowance < MIN_BODY_TOKENS) {
|
|
1316
|
+
emit(
|
|
1317
|
+
`${headline}
|
|
1318
|
+
|
|
1319
|
+
_Body omitted (token budget exhausted) \u2014 fetch with \`${h.meta.fetchHint}\`._`
|
|
1320
|
+
);
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
const { text: body2, truncated } = truncateToTokens(
|
|
1324
|
+
h.body,
|
|
1325
|
+
allowance - TRUNCATION_NOTICE_TOKENS
|
|
1326
|
+
);
|
|
1327
|
+
itemsWithBody++;
|
|
1328
|
+
emit(
|
|
1329
|
+
`${headline}
|
|
1330
|
+
|
|
1331
|
+
${body2}` + (truncated ? `
|
|
1332
|
+
|
|
1333
|
+
_[truncated to fit the token budget \u2014 full text via \`${h.meta.fetchHint}\`]_` : "")
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
remaining = Math.max(0, remaining - (bodyBudgetStart - bodyBudget));
|
|
1337
|
+
if (selectedGuardrails.length > 0) {
|
|
1338
|
+
sections.push(
|
|
1339
|
+
`## Checks (${selectedGuardrails.length})
|
|
1340
|
+
|
|
1341
|
+
` + selectedGuardrails.map((i) => i.content).join("\n\n---\n\n")
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
if (blocks.length > 0) {
|
|
1345
|
+
sections.push(
|
|
1346
|
+
`## Relevant Specs & Docs (${blocks.length})
|
|
1347
|
+
|
|
1348
|
+
_Highest-ranked items \u2014 docs are scoped to their most relevant section. Each carries the ID / \`chunk_id\` to fetch more._
|
|
1349
|
+
|
|
1350
|
+
` + blocks.join("\n\n---\n\n")
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
const tailLines = [];
|
|
1354
|
+
for (const meta of tailCandidates) {
|
|
1355
|
+
const cost = estimateTokens(meta.listLine);
|
|
1356
|
+
if (cost > remaining) break;
|
|
1357
|
+
tailLines.push(meta.listLine);
|
|
1358
|
+
remaining -= cost;
|
|
1359
|
+
}
|
|
1360
|
+
if (tailLines.length > 0) {
|
|
1361
|
+
sections.push(
|
|
1362
|
+
`## Also Indexed (${tailLines.length})
|
|
1363
|
+
|
|
1364
|
+
_Ranked lower \u2014 fetch with \`get_specs\` / \`get_docs\` if you need them._
|
|
1365
|
+
|
|
1366
|
+
` + tailLines.join("\n")
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
const itemsListed = blocks.length - itemsWithBody + tailLines.length;
|
|
1370
|
+
const itemsOmitted = ranked.length - selectedGuardrails.length - blocks.length - tailLines.length;
|
|
1371
|
+
const body = sections.join("\n\n");
|
|
1372
|
+
const tokensUsed = estimateTokens(body);
|
|
1373
|
+
const stats = {
|
|
1374
|
+
budget,
|
|
1375
|
+
tokensUsed,
|
|
1376
|
+
itemsWithBody,
|
|
1377
|
+
bodyFailures,
|
|
1378
|
+
itemsListed,
|
|
1379
|
+
itemsOmitted: Math.max(0, itemsOmitted),
|
|
1380
|
+
guardrails: selectedGuardrails.length
|
|
1381
|
+
};
|
|
1382
|
+
if (sections.length === 0) {
|
|
1383
|
+
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.";
|
|
1384
|
+
return { text: text3, stats };
|
|
1385
|
+
}
|
|
1386
|
+
const footer = `
|
|
1387
|
+
|
|
1388
|
+
---
|
|
1389
|
+
_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` : "") + `_`;
|
|
1390
|
+
return { text: body + footer, stats };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/tools/get-context.ts
|
|
1394
|
+
function registerGetContext(server, apiClient) {
|
|
1395
|
+
server.registerTool(
|
|
1396
|
+
"get_context",
|
|
1397
|
+
{
|
|
1398
|
+
title: "Get Context",
|
|
1399
|
+
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.",
|
|
1400
|
+
inputSchema: {
|
|
1401
|
+
task_description: z7.string().describe(
|
|
1402
|
+
"What you're working on (e.g., 'add a new REST endpoint for user preferences')"
|
|
1403
|
+
),
|
|
1404
|
+
file_paths: z7.array(z7.string()).optional().describe(
|
|
1405
|
+
"File paths being edited \u2014 used for language/category detection"
|
|
1406
|
+
),
|
|
1407
|
+
max_tokens: z7.number().optional().describe("Maximum token budget for the response (default: 4000)")
|
|
1408
|
+
}
|
|
1409
|
+
},
|
|
1410
|
+
async ({ task_description, file_paths, max_tokens }) => {
|
|
1411
|
+
const { text: text3 } = await buildContext(apiClient, {
|
|
1412
|
+
taskDescription: task_description,
|
|
1413
|
+
filePaths: file_paths,
|
|
1414
|
+
maxTokens: max_tokens
|
|
1415
|
+
});
|
|
1416
|
+
return {
|
|
1417
|
+
content: [{ type: "text", text: text3 }]
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/lib/session-brief.ts
|
|
1424
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1425
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1426
|
+
import { join as join3 } from "path";
|
|
1427
|
+
|
|
1428
|
+
// src/lib/skills.ts
|
|
1429
|
+
import { createHash } from "crypto";
|
|
1430
|
+
import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
1431
|
+
import { existsSync } from "fs";
|
|
1432
|
+
import { homedir } from "os";
|
|
1433
|
+
import { dirname, join, relative, resolve, sep } from "path";
|
|
1434
|
+
var MANIFEST_FILE = ".systemdox-skill.json";
|
|
1435
|
+
var ENTRY_FILE = "SKILL.md";
|
|
1436
|
+
var PLUGIN_MANIFEST_FOLDER = ".claude-plugin";
|
|
1437
|
+
var MAX_FILES = 100;
|
|
1438
|
+
var MAX_FILE_BYTES = 256 * 1024;
|
|
1439
|
+
var MAX_TOTAL_BYTES = 1024 * 1024;
|
|
1440
|
+
var NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1441
|
+
var SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
|
|
1442
|
+
var SKIPPED = /* @__PURE__ */ new Set([MANIFEST_FILE, ".git", "node_modules", "__pycache__", ".DS_Store"]);
|
|
1443
|
+
var SkillInstallRefused = class extends Error {
|
|
1444
|
+
constructor(dest, edited) {
|
|
1445
|
+
super(
|
|
1446
|
+
`${dest} has local changes in ${edited.length} file(s): ${edited.join(", ")}. Nothing was written. Move your changes aside, or install again with overwrite.`
|
|
1447
|
+
);
|
|
1448
|
+
this.dest = dest;
|
|
1449
|
+
this.edited = edited;
|
|
1450
|
+
}
|
|
1451
|
+
dest;
|
|
1452
|
+
edited;
|
|
1453
|
+
};
|
|
1454
|
+
function sha256(content) {
|
|
1455
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
1456
|
+
}
|
|
1457
|
+
function isValidName(name) {
|
|
1458
|
+
return name.length <= 64 && NAME_RE.test(name);
|
|
1459
|
+
}
|
|
1460
|
+
function isValidPath(path) {
|
|
1461
|
+
return path.length > 0 && path.length <= 512 && !path.startsWith("/") && path.split("/").every((s) => SEGMENT_RE.test(s) && s !== "." && s !== ".." && s !== PLUGIN_MANIFEST_FOLDER);
|
|
1462
|
+
}
|
|
1463
|
+
function frontmatterName(skillMd) {
|
|
1464
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
|
|
1465
|
+
if (!block) return void 0;
|
|
1466
|
+
const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
|
|
1467
|
+
return line ? line[1].replace(/^(['"])(.*)\1$/, "$2") : void 0;
|
|
1468
|
+
}
|
|
1469
|
+
function defaultSkillDir(name) {
|
|
1470
|
+
return join(homedir(), ".claude", "skills", name);
|
|
1471
|
+
}
|
|
1472
|
+
async function walk(root, { skipHidden }, dir = root, out = { files: [], links: [], hidden: [] }) {
|
|
1473
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
1474
|
+
if (SKIPPED.has(entry.name) || entry.name.endsWith(".pyc")) continue;
|
|
1475
|
+
const full = join(dir, entry.name);
|
|
1476
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
1477
|
+
if (entry.isSymbolicLink()) out.links.push(rel);
|
|
1478
|
+
else if (skipHidden && entry.name.startsWith(".")) out.hidden.push(rel);
|
|
1479
|
+
else if (entry.isDirectory()) await walk(root, { skipHidden }, full, out);
|
|
1480
|
+
else if (entry.isFile()) out.files.push(rel);
|
|
1481
|
+
}
|
|
1482
|
+
out.files.sort();
|
|
1483
|
+
return out;
|
|
1484
|
+
}
|
|
1485
|
+
async function readTextFiles(root, paths, what) {
|
|
1486
|
+
if (paths.length > MAX_FILES) {
|
|
1487
|
+
throw new Error(`${root} holds ${paths.length} files: ${what} holds at most ${MAX_FILES}`);
|
|
1488
|
+
}
|
|
1489
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1490
|
+
const files = [];
|
|
1491
|
+
let total = 0;
|
|
1492
|
+
for (const path of paths) {
|
|
1493
|
+
if (!isValidPath(path)) {
|
|
1494
|
+
throw new Error(`${path}: file names may use letters, digits, ".", "_" and "-" only`);
|
|
1495
|
+
}
|
|
1496
|
+
const raw = await readFile(join(root, ...path.split("/")));
|
|
1497
|
+
if (raw.includes(0)) throw new Error(`${path} is binary: ${what} holds text files only`);
|
|
1498
|
+
if (raw.length > MAX_FILE_BYTES) {
|
|
1499
|
+
throw new Error(`${path} is ${raw.length} bytes: a file holds at most ${MAX_FILE_BYTES}`);
|
|
1500
|
+
}
|
|
1501
|
+
total += raw.length;
|
|
1502
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
1503
|
+
throw new Error(`the files pass ${MAX_TOTAL_BYTES} bytes at ${path}`);
|
|
1504
|
+
}
|
|
1505
|
+
let content;
|
|
1506
|
+
try {
|
|
1507
|
+
content = decoder.decode(raw);
|
|
1508
|
+
} catch {
|
|
1509
|
+
throw new Error(`${path} is not UTF-8 text`);
|
|
1510
|
+
}
|
|
1511
|
+
files.push({ path, content });
|
|
1512
|
+
}
|
|
1513
|
+
return files;
|
|
1514
|
+
}
|
|
1515
|
+
async function readSkillDirectory(directory) {
|
|
1516
|
+
const root = resolve(directory);
|
|
1517
|
+
if (!existsSync(join(root, ENTRY_FILE))) {
|
|
1518
|
+
throw new Error(`${root} has no ${ENTRY_FILE}: a skill folder holds SKILL.md at its root`);
|
|
1519
|
+
}
|
|
1520
|
+
const walked = await walk(root, { skipHidden: true });
|
|
1521
|
+
if (walked.links.length) {
|
|
1522
|
+
throw new Error(`${walked.links.join(", ")}: links are not followed; copy the file into the skill folder`);
|
|
1523
|
+
}
|
|
1524
|
+
const files = await readTextFiles(root, walked.files, "a skill");
|
|
1525
|
+
const skillMd = files.find((f) => f.path === ENTRY_FILE);
|
|
1526
|
+
const name = frontmatterName(skillMd.content);
|
|
1527
|
+
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name`);
|
|
1528
|
+
return { name, files, skipped: walked.hidden };
|
|
1529
|
+
}
|
|
1530
|
+
function within(dest, path) {
|
|
1531
|
+
if (!isValidPath(path)) throw new Error(`refusing to write ${JSON.stringify(path)}: not a skill path`);
|
|
1532
|
+
const full = resolve(dest, ...path.split("/"));
|
|
1533
|
+
if (!full.startsWith(resolve(dest) + sep)) throw new Error(`refusing to write outside ${dest}: ${path}`);
|
|
1534
|
+
return full;
|
|
1535
|
+
}
|
|
1536
|
+
async function readManifest(dest) {
|
|
1537
|
+
try {
|
|
1538
|
+
return JSON.parse(await readFile(join(dest, MANIFEST_FILE), "utf8"));
|
|
1539
|
+
} catch {
|
|
1540
|
+
return void 0;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
async function installSkill(version, dest, { overwrite = false } = {}) {
|
|
1544
|
+
if (!isValidName(version.name)) throw new Error(`refusing to install ${JSON.stringify(version.name)}: not a skill name`);
|
|
1545
|
+
const incoming = new Map(version.files.map((f) => [f.path, f]));
|
|
1546
|
+
for (const f of version.files) within(dest, f.path);
|
|
1547
|
+
if (existsSync(dest) && (await lstat(dest)).isSymbolicLink()) {
|
|
1548
|
+
throw new Error(`${dest} is a link: install into a real folder`);
|
|
1549
|
+
}
|
|
1550
|
+
const walked = existsSync(dest) ? await walk(dest, { skipHidden: false }) : { files: [], links: [], hidden: [] };
|
|
1551
|
+
if (walked.links.length) {
|
|
1552
|
+
throw new Error(`${dest} holds links (${walked.links.join(", ")}): remove them, nothing was written`);
|
|
1553
|
+
}
|
|
1554
|
+
const present = walked.files;
|
|
1555
|
+
const manifest = await readManifest(dest);
|
|
1556
|
+
const known2 = Object.fromEntries(
|
|
1557
|
+
Object.entries(manifest?.files ?? {}).filter(([p, h]) => isValidPath(p) && typeof h === "string")
|
|
1558
|
+
);
|
|
1559
|
+
const edited = [];
|
|
1560
|
+
const untracked = [];
|
|
1561
|
+
for (const path of present) {
|
|
1562
|
+
const content = await readFile(join(dest, ...path.split("/")), "utf8");
|
|
1563
|
+
if (Object.hasOwn(known2, path)) {
|
|
1564
|
+
if (sha256(content) !== known2[path]) edited.push(path);
|
|
1565
|
+
} else if (!incoming.has(path) || sha256(content) !== incoming.get(path).sha256) {
|
|
1566
|
+
if (incoming.has(path) || !manifest) edited.push(path);
|
|
1567
|
+
else untracked.push(path);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const blocking = edited.filter((p) => incoming.has(p) || !manifest);
|
|
1571
|
+
if (blocking.length && !overwrite) throw new SkillInstallRefused(dest, blocking);
|
|
1572
|
+
const written = [];
|
|
1573
|
+
for (const f of version.files) {
|
|
1574
|
+
const full = within(dest, f.path);
|
|
1575
|
+
await mkdir(dirname(full), { recursive: true });
|
|
1576
|
+
await writeFile(full, f.content, "utf8");
|
|
1577
|
+
written.push(f.path);
|
|
1578
|
+
}
|
|
1579
|
+
const removed = [];
|
|
1580
|
+
const keptEdited = [];
|
|
1581
|
+
for (const path of Object.keys(known2)) {
|
|
1582
|
+
if (incoming.has(path) || !present.includes(path)) continue;
|
|
1583
|
+
if (edited.includes(path)) {
|
|
1584
|
+
keptEdited.push(path);
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
await rm(within(dest, path));
|
|
1588
|
+
removed.push(path);
|
|
1589
|
+
}
|
|
1590
|
+
const next = {
|
|
1591
|
+
name: version.name,
|
|
1592
|
+
version: version.version,
|
|
1593
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1594
|
+
files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
|
|
1595
|
+
};
|
|
1596
|
+
await writeFile(join(dest, MANIFEST_FILE), JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
1597
|
+
return {
|
|
1598
|
+
dest,
|
|
1599
|
+
version: version.version,
|
|
1600
|
+
written,
|
|
1601
|
+
removed,
|
|
1602
|
+
keptUntracked: untracked.filter((p) => !incoming.has(p)),
|
|
1603
|
+
keptEdited
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
function fenceFor(text3) {
|
|
1607
|
+
const longest = Math.max(0, ...(text3.match(/`+/g) ?? []).map((r) => r.length));
|
|
1608
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
1609
|
+
}
|
|
1610
|
+
function afterInstall(skillMd) {
|
|
1611
|
+
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.`;
|
|
1612
|
+
}
|
|
1613
|
+
function describeInstall(name, r) {
|
|
1614
|
+
const lines = [
|
|
1615
|
+
`Installed ${name} version ${r.version} into ${r.dest} (${r.written.length} file(s)).`
|
|
1616
|
+
];
|
|
1617
|
+
if (r.removed.length) lines.push(`Removed (no longer in the skill): ${r.removed.join(", ")}`);
|
|
1618
|
+
if (r.keptEdited.length) {
|
|
1619
|
+
lines.push(`Kept, edited here and no longer in the skill: ${r.keptEdited.join(", ")}`);
|
|
1620
|
+
}
|
|
1621
|
+
if (r.keptUntracked.length) lines.push(`Kept, added here: ${r.keptUntracked.join(", ")}`);
|
|
1622
|
+
lines.push(afterInstall(join(r.dest, "SKILL.md")));
|
|
1623
|
+
return lines.join("\n");
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// src/lib/team-setup-files.ts
|
|
1627
|
+
import { execFile } from "child_process";
|
|
1628
|
+
import { chmod, mkdir as mkdir2, readFile as readFile2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
1629
|
+
import { homedir as homedir2, userInfo } from "os";
|
|
1630
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1631
|
+
import { promisify } from "util";
|
|
1632
|
+
var SLUG_RE2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1633
|
+
function isWorkspace(value) {
|
|
1634
|
+
return typeof value === "string" && value.length <= 64 && SLUG_RE2.test(value);
|
|
1635
|
+
}
|
|
1636
|
+
function homeDir() {
|
|
1637
|
+
return homedir2();
|
|
1638
|
+
}
|
|
1639
|
+
function systemdoxDir() {
|
|
1640
|
+
return join2(homeDir(), ".claude", "systemdox");
|
|
1641
|
+
}
|
|
1642
|
+
function workspaceDir(workspace) {
|
|
1643
|
+
if (!isWorkspace(workspace)) throw new Error(`${JSON.stringify(workspace)} is not a workspace name`);
|
|
1644
|
+
return join2(systemdoxDir(), workspace);
|
|
1645
|
+
}
|
|
1646
|
+
function statePath(workspace) {
|
|
1647
|
+
return join2(workspaceDir(workspace), "state.json");
|
|
1648
|
+
}
|
|
1649
|
+
async function readJson(path, empty) {
|
|
1650
|
+
let text3;
|
|
1651
|
+
try {
|
|
1652
|
+
text3 = await readFile2(path, "utf8");
|
|
1653
|
+
} catch (e) {
|
|
1654
|
+
if (e.code === "ENOENT") return empty;
|
|
1655
|
+
throw e;
|
|
1656
|
+
}
|
|
1657
|
+
try {
|
|
1658
|
+
return JSON.parse(text3);
|
|
1659
|
+
} catch {
|
|
1660
|
+
throw new Error(`${path} is not valid JSON: fix or remove it, then run this again`);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
async function writeJsonAtomically(path, value, mode) {
|
|
1664
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1665
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1666
|
+
await writeFile2(tmp, JSON.stringify(value, null, 2) + "\n", mode === void 0 ? "utf8" : { encoding: "utf8", mode });
|
|
1667
|
+
await rename(tmp, path);
|
|
1668
|
+
}
|
|
1669
|
+
var run = promisify(execFile);
|
|
1670
|
+
async function readState(workspace) {
|
|
1671
|
+
return readJson(statePath(workspace), {});
|
|
1672
|
+
}
|
|
1673
|
+
async function writeState(workspace, state) {
|
|
1674
|
+
await writeJsonAtomically(statePath(workspace), state);
|
|
1675
|
+
}
|
|
1676
|
+
async function updateState(workspace, change) {
|
|
1677
|
+
const state = await readState(workspace);
|
|
1678
|
+
change(state);
|
|
1679
|
+
await writeState(workspace, state);
|
|
1680
|
+
return state;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// src/lib/session-brief.ts
|
|
1684
|
+
var BRIEF_TIMEOUT_MS = 8e3;
|
|
1685
|
+
var SETUP_COMMAND = "npx -y @systemdox/mcp-server setup";
|
|
1686
|
+
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.`;
|
|
1687
|
+
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.";
|
|
1688
|
+
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.";
|
|
1689
|
+
function noKey(reason) {
|
|
1690
|
+
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.`;
|
|
1691
|
+
}
|
|
1692
|
+
async function readJsonQuietly(path) {
|
|
1693
|
+
try {
|
|
1694
|
+
return JSON.parse(await readFile3(path, "utf8"));
|
|
1695
|
+
} catch {
|
|
1696
|
+
return void 0;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
async function computerState(setup) {
|
|
1700
|
+
const id = `${setup.workspace}-setup@systemdox-${setup.workspace}`;
|
|
1701
|
+
const installed = await readJsonQuietly(join3(homeDir(), ".claude", "plugins", "installed_plugins.json"));
|
|
1702
|
+
const entries2 = installed?.plugins?.[id];
|
|
1703
|
+
const entry = Array.isArray(entries2) ? entries2.find((e) => (e?.scope ?? "user") === "user") : void 0;
|
|
1704
|
+
const state = {
|
|
1705
|
+
missing: [],
|
|
1706
|
+
behind: [],
|
|
1707
|
+
notFromSystemDox: [],
|
|
1708
|
+
pluginInstalled: !!entry,
|
|
1709
|
+
pluginVersion: entry?.version
|
|
1710
|
+
};
|
|
1711
|
+
const pluginCurrent = !!entry && !!setup.plugin && entry.version === `${setup.plugin.version}.0.0`;
|
|
1712
|
+
for (const skill of setup.sessionBrief?.skills ?? []) {
|
|
1713
|
+
const dir = defaultSkillDir(skill.name);
|
|
1714
|
+
if (!existsSync2(dir)) {
|
|
1715
|
+
if (!(pluginCurrent && setup.plugin?.skills?.[skill.name] !== void 0)) state.missing.push(skill.name);
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const manifest = await readJsonQuietly(join3(dir, MANIFEST_FILE));
|
|
1719
|
+
if (typeof manifest?.version !== "number" || !Number.isInteger(manifest.version)) {
|
|
1720
|
+
state.notFromSystemDox.push(skill.name);
|
|
1721
|
+
} else if (manifest.version !== skill.latestVersion) {
|
|
1722
|
+
state.behind.push({ name: skill.name, here: manifest.version, latest: skill.latestVersion });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return state;
|
|
1726
|
+
}
|
|
1727
|
+
function hasPersonParts(setup) {
|
|
1728
|
+
return Object.keys(setup.parts ?? {}).length > 0 || (setup.memory ?? []).length > 0 || (setup.sessionBrief?.heldSkills ?? []).some((h) => h.reason === "not-plain");
|
|
1729
|
+
}
|
|
1730
|
+
var MORE = (n) => `${n} more (get_team_setup lists them)`;
|
|
1731
|
+
var LIST_MIN = `all ${MORE(100)}`.length;
|
|
1732
|
+
async function readTeamSetup(apiClient, { keyProblem, timeoutMs = BRIEF_TIMEOUT_MS } = {}) {
|
|
1733
|
+
if (keyProblem) return { problem: noKey(keyProblem) };
|
|
1734
|
+
try {
|
|
1735
|
+
const setup = await apiClient.getOwnTeamSetup(AbortSignal.timeout(timeoutMs));
|
|
1736
|
+
return typeof setup?.workspace === "string" ? { setup } : { problem: NOT_CHECKED };
|
|
1737
|
+
} catch (err) {
|
|
1738
|
+
const status = err?.status;
|
|
1739
|
+
return { problem: status === 401 || status === 403 ? REFUSED : NOT_CHECKED };
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// src/tools/get-team-setup.ts
|
|
1744
|
+
var HELD_BECAUSE = {
|
|
1745
|
+
"not-plain": `it is not a plain skill: it has frontmatter beyond a name and a description, files outside its own folders, or a command that runs as it loads. The team plugin carries it, and \`${SETUP_COMMAND}\` shows that before the person accepts`,
|
|
1746
|
+
"outside-default-space": "it is outside the default space, so the team plugin does not carry it either. The person can read it in the console, then install it with `npx -y @systemdox/mcp-server install-skill <name>`",
|
|
1747
|
+
"not-checked": "it could not be checked. Ask again later"
|
|
1748
|
+
};
|
|
1749
|
+
function describeSetup(setup) {
|
|
1750
|
+
const lines = [`# Team setup of ${setup.workspace}`, ""];
|
|
1751
|
+
const skills = setup.sessionBrief?.skills ?? [];
|
|
1752
|
+
const held = setup.sessionBrief?.heldSkills ?? [];
|
|
1753
|
+
lines.push(`## Skills a session installs by itself (${skills.length})`, "");
|
|
1754
|
+
if (skills.length) {
|
|
1755
|
+
for (const s of skills) lines.push(`- **${s.name}** version ${s.latestVersion}: ${s.description}`);
|
|
1756
|
+
} else if (!setup.sessionBrief && setup.skills.length) {
|
|
1757
|
+
lines.push(`- ${setup.skills.join(", ")}`);
|
|
1758
|
+
} else {
|
|
1759
|
+
lines.push("None.");
|
|
1760
|
+
}
|
|
1761
|
+
lines.push("");
|
|
1762
|
+
if (held.length) {
|
|
1763
|
+
lines.push(`## Skills that wait for the person (${held.length})`, "");
|
|
1764
|
+
for (const h of held) lines.push(`- **${h.name}**: ${HELD_BECAUSE[h.reason] ?? h.reason}`);
|
|
1765
|
+
lines.push("", "Do not install these yourself: each is the person's to decide.", "");
|
|
1766
|
+
}
|
|
1767
|
+
if (setup.plugin) {
|
|
1768
|
+
lines.push(`## Team plugin`, "", `\`${setup.workspace}-setup\` version ${setup.plugin.version}.`);
|
|
1769
|
+
for (const c of setup.plugin.hookCommands ?? []) lines.push(`- Hook: ${JSON.stringify(c)}`);
|
|
1770
|
+
for (const m of setup.plugin.mcpServers ?? []) {
|
|
1771
|
+
lines.push(`- MCP server ${JSON.stringify(m.name)}: ${JSON.stringify(m.url ?? [m.command, ...m.args ?? []].filter(Boolean).join(" "))}`);
|
|
1772
|
+
}
|
|
1773
|
+
lines.push("");
|
|
1774
|
+
}
|
|
1775
|
+
const parts = Object.entries(setup.parts ?? {});
|
|
1776
|
+
if (parts.length) {
|
|
1777
|
+
lines.push(`## Published parts`, "", ...parts.map(([p, v]) => `- ${p}: version ${v}`), "");
|
|
1778
|
+
}
|
|
1779
|
+
if (setup.memory?.length) {
|
|
1780
|
+
lines.push(`## Team memory`, "", ...setup.memory.map((m) => `- ${m.project}: ${m.fileCount} file(s)`), "");
|
|
1781
|
+
}
|
|
1782
|
+
return lines;
|
|
1783
|
+
}
|
|
1784
|
+
function describeComputer(setup, state) {
|
|
1785
|
+
const lines = ["## This computer", ""];
|
|
1786
|
+
if (!setup.sessionBrief) {
|
|
1787
|
+
return [...lines, "This SystemDox API is too old to compare this computer with the team setup."];
|
|
1788
|
+
}
|
|
1789
|
+
const skills = setup.sessionBrief.skills;
|
|
1790
|
+
const latest = new Map(skills.map((s) => [s.name, s.latestVersion]));
|
|
1791
|
+
lines.push(
|
|
1792
|
+
state.missing.length ? `- Missing: ${state.missing.map((name) => `${name} version ${latest.get(name)}`).join(", ")}` : `- Missing: none of ${skills.length}`
|
|
1793
|
+
);
|
|
1794
|
+
if (state.behind.length) {
|
|
1795
|
+
lines.push(`- Behind: ${state.behind.map((b) => `${b.name} (version ${b.here} here, latest ${b.latest})`).join(", ")}`);
|
|
1796
|
+
}
|
|
1797
|
+
if (state.notFromSystemDox.length) lines.push(`- Not from SystemDox, left alone: ${state.notFromSystemDox.join(", ")}`);
|
|
1798
|
+
if (setup.plugin) {
|
|
1799
|
+
lines.push(
|
|
1800
|
+
state.pluginInstalled ? `- Team plugin: installed${state.pluginVersion ? `, ${state.pluginVersion}` : ""}; the latest is version ${setup.plugin.version}` : "- Team plugin: not installed"
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
lines.push("", "## What to do", "");
|
|
1804
|
+
const steps = [];
|
|
1805
|
+
if (state.missing.length) steps.push("Call install_skill for each missing skill, at the version named above. These are plain skills: installing writes files and runs nothing.");
|
|
1806
|
+
if (state.behind.length) {
|
|
1807
|
+
steps.push("Call install_skill for each skill behind, at the latest version named above. If it refuses because files were edited, tell the person and leave them.");
|
|
1808
|
+
}
|
|
1809
|
+
if (hasPersonParts(setup) && !state.pluginInstalled) {
|
|
1810
|
+
steps.push(
|
|
1811
|
+
`Hooks, MCP servers, team instructions and memory need the person: \`${SETUP_COMMAND}\` in their own terminal. Never answer its prompts for them.`
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
lines.push(...steps.length ? steps.map((s, i) => `${i + 1}. ${s}`) : ["Nothing."]);
|
|
1815
|
+
return lines;
|
|
1816
|
+
}
|
|
1817
|
+
function registerGetTeamSetup(server, apiClient, { localFiles, keyProblem }) {
|
|
1818
|
+
server.registerTool(
|
|
1819
|
+
"get_team_setup",
|
|
1820
|
+
{
|
|
1821
|
+
title: "Get Team Setup",
|
|
1822
|
+
description: localFiles ? "What this workspace's team setup holds (skills, plugin, hooks, MCP servers, memory) and what this computer lacks, with what to do about it. Call it to check whether this computer is set up. Reads only." : "What this workspace's team setup holds (skills, plugin, hooks, MCP servers, memory), and how to check this computer against it. Reads only.",
|
|
1823
|
+
inputSchema: {},
|
|
1824
|
+
annotations: { readOnlyHint: true }
|
|
1825
|
+
},
|
|
1826
|
+
async () => {
|
|
1827
|
+
const read = await readTeamSetup(apiClient, { keyProblem, timeoutMs: 15e3 });
|
|
1828
|
+
if ("problem" in read) return { content: [{ type: "text", text: read.problem }] };
|
|
1829
|
+
const lines = describeSetup(read.setup);
|
|
1830
|
+
if (localFiles) lines.push(...describeComputer(read.setup, await computerState(read.setup)));
|
|
1831
|
+
else lines.push(CANNOT_SEE);
|
|
1832
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1833
|
+
}
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// src/tools/document-graph.ts
|
|
1838
|
+
import { z as z8 } from "zod";
|
|
1839
|
+
var DEFAULT_LIMIT2 = 50;
|
|
1840
|
+
var MAX_LIMIT = 200;
|
|
1841
|
+
var DEFAULT_HOPS = 6;
|
|
1842
|
+
var MAX_HOPS = 8;
|
|
1843
|
+
function place(file, line) {
|
|
1844
|
+
return file ? `${file}:${line}` : "made by a person";
|
|
1845
|
+
}
|
|
1846
|
+
function known(provenance, confidence) {
|
|
1847
|
+
return `${provenance} ${confidence}`;
|
|
1848
|
+
}
|
|
1849
|
+
function formatNeighbours(answer) {
|
|
1850
|
+
const lines = [`# ${answer.doc.label}`, `\`${answer.doc.id}\``, ""];
|
|
1851
|
+
if (answer.links.length === 0) lines.push("No links.");
|
|
1852
|
+
for (const link of answer.links) {
|
|
1853
|
+
lines.push(
|
|
1854
|
+
`- ${link.direction} \xB7 ${link.relation} \xB7 ${known(link.provenance, link.confidence)} \xB7 ${link.other.label} \xB7 \`${link.other.id}\` \xB7 ${place(link.file, link.line)}`
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
if (answer.truncated) {
|
|
1858
|
+
lines.push("", `Showing ${answer.links.length} of ${answer.total}. Raise limit to see more.`);
|
|
1859
|
+
}
|
|
1860
|
+
if (answer.ambiguous.length > 0) {
|
|
1861
|
+
lines.push("", "## Ambiguous: not drawn, because each could mean several documents");
|
|
1862
|
+
for (const entry of answer.ambiguous) {
|
|
1863
|
+
const candidates = entry.candidates.map((c) => `${c.label} (${c.repo}, \`${c.id}\`)`);
|
|
1864
|
+
lines.push(`- ${entry.reference} at ${place(entry.file, entry.line)}: ${candidates.join("; ")}`);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return lines.join("\n");
|
|
1868
|
+
}
|
|
1869
|
+
function formatPath(answer, maxHops) {
|
|
1870
|
+
if (!answer.found) return `No path within ${maxHops} hops.`;
|
|
1871
|
+
const lines = answer.steps.map((step, index) => {
|
|
1872
|
+
const arrow = step.reversed ? "<-" : "->";
|
|
1873
|
+
return `${index + 1}. ${step.from.label} ${arrow} ${step.relation} ${arrow} ${step.to.label} \xB7 ${known(step.provenance, step.confidence)} \xB7 ${place(step.file, step.line)}`;
|
|
1874
|
+
});
|
|
1875
|
+
return [...lines, "", `Path confidence: ${answer.confidence}`].join("\n");
|
|
1876
|
+
}
|
|
1877
|
+
function formatStats(stats, repoLinks) {
|
|
1878
|
+
const { declared, extracted, inferred, ambiguous } = stats.links;
|
|
1879
|
+
const lines = [
|
|
1880
|
+
`# The graph: ${stats.nodeCount} documents, ${stats.edgeCount} links`,
|
|
1881
|
+
"",
|
|
1882
|
+
`Links: ${declared} declared \xB7 ${extracted} extracted \xB7 ${inferred} inferred \xB7 ${ambiguous} ambiguous (not drawn)`,
|
|
1883
|
+
"",
|
|
1884
|
+
"## Repositories"
|
|
1885
|
+
];
|
|
1886
|
+
for (const repo of stats.repos) {
|
|
1887
|
+
const state = repo.behind ? `BEHIND: pushed ${repo.headCommit || "unknown"} at ${repo.pushedAt}, index at ${repo.indexedCommit || "unknown"}` : `index at ${repo.indexedCommit || "unknown"}`;
|
|
1888
|
+
lines.push(`- ${repo.repo}: ${repo.documents} documents, ${repo.links} links, ${state}`);
|
|
1889
|
+
}
|
|
1890
|
+
if (repoLinks.length > 0) {
|
|
1891
|
+
lines.push("", "## Repository links");
|
|
1892
|
+
for (const link of repoLinks) {
|
|
1893
|
+
lines.push(
|
|
1894
|
+
`- ${link.source} ${link.relation} ${link.target} \xB7 ${known(link.provenance, link.confidence)} \xB7 ${place(link.file, link.line)}`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
return lines.join("\n");
|
|
1899
|
+
}
|
|
1900
|
+
function refused(err) {
|
|
1901
|
+
const status = err.status;
|
|
1902
|
+
if (status !== 404 && status !== 400) throw err;
|
|
1903
|
+
return {
|
|
1904
|
+
content: [{ type: "text", text: err.message }],
|
|
1905
|
+
isError: true
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
function registerDocumentGraph(server, apiClient) {
|
|
1909
|
+
server.registerTool(
|
|
1910
|
+
"get_document_links",
|
|
1911
|
+
{
|
|
1912
|
+
description: "What one document links to and from, strongest first. Each link says how it is known: declared (a person made it), extracted (the file says so), or inferred (a reading that could be wrong), with a confidence and the file and line it came from. A reference that could mean several documents is listed as ambiguous with its candidates, and is never drawn as a link. doc_id is the id get_docs returns. Read-only.",
|
|
1913
|
+
inputSchema: {
|
|
1914
|
+
doc_id: z8.string().min(1).max(400).describe("A document id, as get_docs returns it"),
|
|
1915
|
+
direction: z8.enum(["out", "in", "both"]).optional().describe("Default both"),
|
|
1916
|
+
relation: z8.enum(["links_to", "cites", "mentions", "satisfies", "references"]).optional().describe("Only links of this kind"),
|
|
1917
|
+
limit: z8.number().int().min(1).max(MAX_LIMIT).optional().describe(`Most links to show (default ${DEFAULT_LIMIT2})`)
|
|
1918
|
+
},
|
|
1919
|
+
annotations: { readOnlyHint: true }
|
|
1920
|
+
},
|
|
1921
|
+
async ({ doc_id, direction, relation, limit }) => {
|
|
1922
|
+
const params = { doc: doc_id, limit: String(limit ?? DEFAULT_LIMIT2) };
|
|
1923
|
+
if (direction) params.direction = direction;
|
|
1924
|
+
if (relation) params.relation = relation;
|
|
1925
|
+
try {
|
|
1926
|
+
const answer = await apiClient.get("/v1/graph/neighbours", params);
|
|
1927
|
+
return { content: [{ type: "text", text: formatNeighbours(answer) }] };
|
|
1928
|
+
} catch (err) {
|
|
1929
|
+
return refused(err);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
);
|
|
1933
|
+
server.registerTool(
|
|
1934
|
+
"trace_document_path",
|
|
1935
|
+
{
|
|
1936
|
+
description: "The shortest way two documents connect, walking links in either direction. Each step says how it is known, and the path is only as strong as its weakest step. Read-only.",
|
|
1937
|
+
inputSchema: {
|
|
1938
|
+
from: z8.string().min(1).max(400).describe("A document id, as get_docs returns it"),
|
|
1939
|
+
to: z8.string().min(1).max(400).describe("A document id, as get_docs returns it"),
|
|
1940
|
+
max_hops: z8.number().int().min(1).max(MAX_HOPS).optional().describe(`Longest path to look for (default ${DEFAULT_HOPS})`)
|
|
1941
|
+
},
|
|
1942
|
+
annotations: { readOnlyHint: true }
|
|
1943
|
+
},
|
|
1944
|
+
async ({ from, to, max_hops }) => {
|
|
1945
|
+
const hops = max_hops ?? DEFAULT_HOPS;
|
|
1946
|
+
try {
|
|
1947
|
+
const answer = await apiClient.get("/v1/graph/path", {
|
|
1948
|
+
from,
|
|
1949
|
+
to,
|
|
1950
|
+
maxHops: String(hops)
|
|
1951
|
+
});
|
|
1952
|
+
return { content: [{ type: "text", text: formatPath(answer, hops) }] };
|
|
1953
|
+
} catch (err) {
|
|
1954
|
+
return refused(err);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
);
|
|
1958
|
+
server.registerTool(
|
|
1959
|
+
"get_graph_stats",
|
|
1960
|
+
{
|
|
1961
|
+
description: "The state of the graph: links counted by how they are known, each repository's freshness (marked BEHIND when a push has not finished indexing), and how repositories depend on each other, from the files that declare it. Read-only.",
|
|
1962
|
+
inputSchema: {},
|
|
1963
|
+
annotations: { readOnlyHint: true }
|
|
1964
|
+
},
|
|
1965
|
+
async () => {
|
|
1966
|
+
const [stats, repos] = await Promise.all([
|
|
1967
|
+
apiClient.getFresh("/v1/graph/stats"),
|
|
1968
|
+
apiClient.getFresh("/v1/graph/repos")
|
|
1969
|
+
]);
|
|
1970
|
+
return { content: [{ type: "text", text: formatStats(stats, repos.links) }] };
|
|
1971
|
+
}
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// src/tools/create-guardrail.ts
|
|
1976
|
+
import { z as z9 } from "zod";
|
|
1977
|
+
var DESCRIPTION2 = "Create a new check in SystemDox. Use this when you discover a pattern that should be enforced (banned pattern, required pattern, coding rule).";
|
|
1978
|
+
var CATEGORIES = [
|
|
1979
|
+
"imports",
|
|
1980
|
+
"auth",
|
|
1981
|
+
"error-handling",
|
|
1982
|
+
"architecture",
|
|
1983
|
+
"testing",
|
|
1984
|
+
"security",
|
|
1985
|
+
"conventions",
|
|
1986
|
+
"data-access"
|
|
1987
|
+
];
|
|
1988
|
+
var inputSchema2 = () => ({
|
|
1989
|
+
title: z9.string().describe("Short title for the check"),
|
|
1990
|
+
description: z9.string().describe("Detailed description of what the check enforces and why"),
|
|
1991
|
+
severity: z9.enum(["critical", "high", "medium", "low"]).describe("Severity level"),
|
|
1992
|
+
category: z9.enum(CATEGORIES).optional().describe("Category (defaults to conventions)"),
|
|
1993
|
+
pattern: z9.string().optional().describe("Bad code pattern that should be avoided"),
|
|
1994
|
+
fix: z9.string().optional().describe("Correct code pattern to use instead"),
|
|
1995
|
+
fitness_test: z9.string().optional().describe("Shell command to detect violations in CI")
|
|
1996
|
+
});
|
|
1997
|
+
function registerCreateGuardrail(server, apiClient) {
|
|
1998
|
+
const handler = async ({
|
|
1999
|
+
title,
|
|
2000
|
+
description,
|
|
2001
|
+
severity,
|
|
2002
|
+
category,
|
|
2003
|
+
pattern,
|
|
2004
|
+
fix,
|
|
2005
|
+
fitness_test
|
|
2006
|
+
}) => {
|
|
2007
|
+
const result = await apiClient.post(
|
|
2008
|
+
"/v1/guardrails",
|
|
2009
|
+
{
|
|
2010
|
+
entries: [
|
|
2011
|
+
{
|
|
2012
|
+
title,
|
|
2013
|
+
description,
|
|
2014
|
+
severity,
|
|
2015
|
+
category: category ?? "conventions",
|
|
2016
|
+
pattern: pattern ?? "",
|
|
2017
|
+
fix: fix ?? "",
|
|
2018
|
+
fitnessTest: fitness_test ?? ""
|
|
2019
|
+
}
|
|
2020
|
+
]
|
|
2021
|
+
}
|
|
2022
|
+
);
|
|
2023
|
+
apiClient.invalidateCache("/v1/guardrails");
|
|
2024
|
+
const saved = result[0];
|
|
2025
|
+
return {
|
|
2026
|
+
content: [
|
|
2027
|
+
{
|
|
2028
|
+
type: "text",
|
|
2029
|
+
text: `Check created: **${saved.title}** (id: ${saved.id}, severity: ${saved.severity}, category: ${saved.category})`
|
|
2030
|
+
}
|
|
2031
|
+
]
|
|
2032
|
+
};
|
|
2033
|
+
};
|
|
2034
|
+
server.registerTool(
|
|
2035
|
+
"create_check",
|
|
2036
|
+
{
|
|
2037
|
+
title: "Create Check",
|
|
2038
|
+
description: DESCRIPTION2,
|
|
2039
|
+
inputSchema: inputSchema2(),
|
|
2040
|
+
annotations: {
|
|
2041
|
+
readOnlyHint: false
|
|
2042
|
+
}
|
|
2043
|
+
},
|
|
2044
|
+
handler
|
|
2045
|
+
);
|
|
2046
|
+
server.registerTool(
|
|
2047
|
+
"create_guardrail",
|
|
2048
|
+
{
|
|
2049
|
+
title: "Create Check (legacy name)",
|
|
2050
|
+
description: legacyToolDescription("create_check", DESCRIPTION2),
|
|
2051
|
+
inputSchema: inputSchema2(),
|
|
2052
|
+
annotations: {
|
|
2053
|
+
readOnlyHint: false
|
|
2054
|
+
}
|
|
2055
|
+
},
|
|
2056
|
+
handler
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// src/tools/update-spec.ts
|
|
2061
|
+
import { z as z10 } from "zod";
|
|
2062
|
+
function registerUpdateSpec(server, apiClient) {
|
|
2063
|
+
server.registerTool(
|
|
2064
|
+
"update_spec",
|
|
2065
|
+
{
|
|
2066
|
+
title: "Update Spec",
|
|
2067
|
+
description: "Update an existing SystemDox spec's proposal, tasks, or requirements. Only draft specs can be modified \u2014 published specs must be versioned first.",
|
|
2068
|
+
inputSchema: {
|
|
2069
|
+
draft_id: z10.string().describe("ID of the spec to update"),
|
|
2070
|
+
section: z10.enum(["proposal", "tasks", "requirements"]).describe("Which section to update"),
|
|
2071
|
+
content: z10.string().describe(
|
|
2072
|
+
"New markdown content for the section. For requirements, provide the full requirement content."
|
|
2073
|
+
),
|
|
2074
|
+
requirement_name: z10.string().optional().describe(
|
|
2075
|
+
"Name of the requirement (required when section is 'requirements')"
|
|
2076
|
+
)
|
|
2077
|
+
},
|
|
2078
|
+
annotations: {
|
|
2079
|
+
readOnlyHint: false
|
|
2080
|
+
}
|
|
2081
|
+
},
|
|
2082
|
+
async ({ draft_id, section, content, requirement_name }) => {
|
|
2083
|
+
const spec = await apiClient.get(`/v1/specs/${draft_id}`);
|
|
2084
|
+
if (spec.status !== "draft") {
|
|
2085
|
+
return {
|
|
2086
|
+
content: [
|
|
2087
|
+
{
|
|
2088
|
+
type: "text",
|
|
2089
|
+
text: `Cannot modify spec "${spec.name}" \u2014 status is "${spec.status}". Only draft specs can be updated.`
|
|
2090
|
+
}
|
|
2091
|
+
],
|
|
2092
|
+
isError: true
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
const body = {};
|
|
2096
|
+
if (section === "proposal") {
|
|
2097
|
+
body.proposalContent = content;
|
|
2098
|
+
} else if (section === "tasks") {
|
|
2099
|
+
body.taskContent = content;
|
|
2100
|
+
} else if (section === "requirements") {
|
|
2101
|
+
const existing = spec.requirements;
|
|
2102
|
+
const newReq = {
|
|
2103
|
+
name: requirement_name ?? "Untitled Requirement",
|
|
2104
|
+
content
|
|
2105
|
+
};
|
|
2106
|
+
body.requirements = [...existing, newReq];
|
|
2107
|
+
}
|
|
2108
|
+
await apiClient.put(`/v1/specs/${draft_id}`, body);
|
|
2109
|
+
apiClient.invalidateCache(`/v1/specs/${draft_id}`);
|
|
2110
|
+
apiClient.invalidateCache("/v1/specs");
|
|
2111
|
+
return {
|
|
2112
|
+
content: [
|
|
2113
|
+
{
|
|
2114
|
+
type: "text",
|
|
2115
|
+
text: `Updated spec "${spec.name}" \u2014 ${section} section modified.`
|
|
2116
|
+
}
|
|
2117
|
+
]
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// src/tools/report-decision.ts
|
|
2124
|
+
import { z as z11 } from "zod";
|
|
2125
|
+
function registerReportDecision(server, apiClient) {
|
|
2126
|
+
server.registerTool(
|
|
2127
|
+
"report_decision",
|
|
2128
|
+
{
|
|
2129
|
+
title: "Report Decision",
|
|
2130
|
+
description: "Record an architecture decision made during development. Creates a draft ADR in SystemDox for human review.",
|
|
2131
|
+
inputSchema: {
|
|
2132
|
+
title: z11.string().describe("Decision title (e.g., 'Use WebSocket for real-time updates')"),
|
|
2133
|
+
context: z11.string().describe("What prompted this decision \u2014 the problem or constraint"),
|
|
2134
|
+
decision: z11.string().describe("What was decided and why"),
|
|
2135
|
+
consequences: z11.string().describe("What this decision means going forward \u2014 trade-offs, follow-up work"),
|
|
2136
|
+
related_files: z11.array(z11.string()).optional().describe("File paths related to this decision")
|
|
2137
|
+
},
|
|
2138
|
+
annotations: {
|
|
2139
|
+
readOnlyHint: false
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2142
|
+
async ({ title, context, decision, consequences, related_files }) => {
|
|
2143
|
+
const adrTitle = `ADR: ${title}`;
|
|
2144
|
+
const created = await apiClient.post("/v1/specs", {
|
|
2145
|
+
name: adrTitle
|
|
2146
|
+
});
|
|
2147
|
+
const relatedSection = related_files && related_files.length > 0 ? `
|
|
2148
|
+
|
|
2149
|
+
## Related Files
|
|
2150
|
+
|
|
2151
|
+
${related_files.map((f) => `- \`${f}\``).join("\n")}` : "";
|
|
2152
|
+
const adrContent = `# ${adrTitle}
|
|
2153
|
+
|
|
2154
|
+
**Date:** ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}
|
|
2155
|
+
**Status:** Draft (pending review)
|
|
2156
|
+
|
|
2157
|
+
## Context
|
|
2158
|
+
|
|
2159
|
+
${context}
|
|
2160
|
+
|
|
2161
|
+
## Decision
|
|
2162
|
+
|
|
2163
|
+
${decision}
|
|
2164
|
+
|
|
2165
|
+
## Consequences
|
|
2166
|
+
|
|
2167
|
+
${consequences}` + relatedSection;
|
|
2168
|
+
await apiClient.put(`/v1/specs/${created.draftId}`, {
|
|
2169
|
+
proposalContent: adrContent
|
|
2170
|
+
});
|
|
2171
|
+
apiClient.invalidateCache("/v1/specs");
|
|
2172
|
+
return {
|
|
2173
|
+
content: [
|
|
2174
|
+
{
|
|
2175
|
+
type: "text",
|
|
2176
|
+
text: `Architecture decision recorded: **${adrTitle}** (spec id: ${created.draftId})
|
|
2177
|
+
|
|
2178
|
+
Status: draft \u2014 review in SystemDox before publishing.`
|
|
2179
|
+
}
|
|
2180
|
+
]
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// src/tools/get-project-rules.ts
|
|
2187
|
+
import { z as z12 } from "zod";
|
|
2188
|
+
var CATEGORY_LABELS = {
|
|
2189
|
+
deployment: "Deployment",
|
|
2190
|
+
architecture: "Architecture",
|
|
2191
|
+
security: "Security",
|
|
2192
|
+
testing: "Testing",
|
|
2193
|
+
process: "Process",
|
|
2194
|
+
other: "Other"
|
|
2195
|
+
};
|
|
2196
|
+
function registerGetProjectRules(server, apiClient) {
|
|
2197
|
+
server.registerTool(
|
|
2198
|
+
"get_project_rules",
|
|
2199
|
+
{
|
|
2200
|
+
title: "Get Project Rules",
|
|
2201
|
+
description: "Retrieve project rules defined in SystemDox Standards. These are team-defined principles covering deployment, architecture, security, testing, and process. Filter by category to get only relevant rules.",
|
|
2202
|
+
inputSchema: {
|
|
2203
|
+
category: z12.enum(["deployment", "architecture", "security", "testing", "process", "other"]).optional().describe("Filter by rule category")
|
|
2204
|
+
}
|
|
2205
|
+
},
|
|
2206
|
+
async ({ category }) => {
|
|
2207
|
+
const rules = await apiClient.get(
|
|
2208
|
+
"/v1/standards/rules"
|
|
2209
|
+
);
|
|
2210
|
+
let filtered = rules;
|
|
2211
|
+
if (category) {
|
|
2212
|
+
filtered = filtered.filter(
|
|
2213
|
+
(r) => r.category.toLowerCase() === category.toLowerCase()
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
if (filtered.length === 0) {
|
|
2217
|
+
const text4 = rules.length === 0 ? `No project rules found. Add rules at ${configLink("/docs/rules", await workspaceBase(apiClient))}` : `No project rules found${category ? ` in category "${category}"` : ""}.`;
|
|
2218
|
+
return {
|
|
2219
|
+
content: [{ type: "text", text: text4 }]
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2223
|
+
for (const rule of filtered) {
|
|
2224
|
+
const cat = rule.category || "other";
|
|
2225
|
+
if (!grouped.has(cat)) grouped.set(cat, []);
|
|
2226
|
+
grouped.get(cat).push(rule);
|
|
2227
|
+
}
|
|
2228
|
+
let text3 = `Found ${filtered.length} project rule(s):
|
|
2229
|
+
|
|
2230
|
+
`;
|
|
2231
|
+
for (const [cat, catRules] of grouped) {
|
|
2232
|
+
const label = CATEGORY_LABELS[cat] ?? cat;
|
|
2233
|
+
text3 += `## ${label}
|
|
2234
|
+
|
|
2235
|
+
`;
|
|
2236
|
+
for (const rule of catRules) {
|
|
2237
|
+
text3 += `- ${rule.text}
|
|
2238
|
+
`;
|
|
2239
|
+
}
|
|
2240
|
+
text3 += "\n";
|
|
2241
|
+
}
|
|
2242
|
+
return {
|
|
2243
|
+
content: [
|
|
2244
|
+
{
|
|
2245
|
+
type: "text",
|
|
2246
|
+
text: text3
|
|
2247
|
+
}
|
|
2248
|
+
]
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// src/tools/create-project-rule.ts
|
|
2255
|
+
import { z as z13 } from "zod";
|
|
2256
|
+
function registerCreateProjectRule(server, apiClient) {
|
|
2257
|
+
server.registerTool(
|
|
2258
|
+
"create_project_rule",
|
|
2259
|
+
{
|
|
2260
|
+
title: "Create Project Rule",
|
|
2261
|
+
description: "Create a new project rule in SystemDox Standards. Use this when you discover a team principle or convention that should be enforced across the project.",
|
|
2262
|
+
inputSchema: {
|
|
2263
|
+
text: z13.string().describe(
|
|
2264
|
+
"The rule statement \u2014 one enforceable principle per rule (e.g. 'All deployments MUST go through GitHub Actions workflows')"
|
|
2265
|
+
),
|
|
2266
|
+
category: z13.enum(["deployment", "architecture", "security", "testing", "process", "other"]).describe("Rule category")
|
|
2267
|
+
},
|
|
2268
|
+
annotations: {
|
|
2269
|
+
readOnlyHint: false
|
|
2270
|
+
}
|
|
2271
|
+
},
|
|
2272
|
+
async ({ text: text3, category }) => {
|
|
2273
|
+
const saved = await apiClient.post(
|
|
2274
|
+
"/v1/standards/rules",
|
|
2275
|
+
{ text: text3, category }
|
|
2276
|
+
);
|
|
2277
|
+
apiClient.invalidateCache("/v1/standards/rules");
|
|
2278
|
+
return {
|
|
2279
|
+
content: [
|
|
2280
|
+
{
|
|
2281
|
+
type: "text",
|
|
2282
|
+
text: `Project rule created: **${saved.text}** (id: ${saved.id}, category: ${saved.category})`
|
|
2283
|
+
}
|
|
2284
|
+
]
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// src/tools/get-prompt-templates.ts
|
|
2291
|
+
function registerGetPromptTemplates(server, apiClient) {
|
|
2292
|
+
server.registerTool(
|
|
2293
|
+
"get_prompt_templates",
|
|
2294
|
+
{
|
|
2295
|
+
title: "Get Prompt Templates",
|
|
2296
|
+
description: "Retrieve reusable prompt templates from SystemDox Standards. These are preconfigured prompts that Gen AI should use when generating content \u2014 e.g. code review checklists, documentation templates, or analysis frameworks.",
|
|
2297
|
+
inputSchema: {}
|
|
2298
|
+
},
|
|
2299
|
+
async () => {
|
|
2300
|
+
const templates = await apiClient.get(
|
|
2301
|
+
"/v1/standards/prompt-templates"
|
|
2302
|
+
);
|
|
2303
|
+
if (templates.length === 0) {
|
|
2304
|
+
return {
|
|
2305
|
+
content: [
|
|
2306
|
+
{
|
|
2307
|
+
type: "text",
|
|
2308
|
+
text: `No prompt templates found. Add templates at ${configLink("/docs/prompts", await workspaceBase(apiClient))}`
|
|
2309
|
+
}
|
|
2310
|
+
]
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
const text3 = templates.map(
|
|
2314
|
+
(t) => `## ${t.name}
|
|
2315
|
+
|
|
2316
|
+
` + (t.description ? `*${t.description}*
|
|
2317
|
+
|
|
2318
|
+
` : "") + `\`\`\`
|
|
2319
|
+
${t.prompt}
|
|
2320
|
+
\`\`\``
|
|
2321
|
+
).join("\n\n---\n\n");
|
|
2322
|
+
return {
|
|
2323
|
+
content: [
|
|
2324
|
+
{
|
|
2325
|
+
type: "text",
|
|
2326
|
+
text: `Found ${templates.length} prompt template(s):
|
|
2327
|
+
|
|
2328
|
+
${text3}`
|
|
2329
|
+
}
|
|
2330
|
+
]
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// src/tools/create-prompt-template.ts
|
|
2337
|
+
import { z as z14 } from "zod";
|
|
2338
|
+
function registerCreatePromptTemplate(server, apiClient) {
|
|
2339
|
+
server.registerTool(
|
|
2340
|
+
"create_prompt_template",
|
|
2341
|
+
{
|
|
2342
|
+
title: "Create Prompt Template",
|
|
2343
|
+
description: "Create a new reusable prompt template in SystemDox Standards. Use this to store preconfigured prompts for common AI tasks like code reviews, documentation generation, or analysis frameworks.",
|
|
2344
|
+
inputSchema: {
|
|
2345
|
+
name: z14.string().describe("Short name for the template"),
|
|
2346
|
+
description: z14.string().optional().describe("Brief description of when to use this template"),
|
|
2347
|
+
prompt: z14.string().describe("The full prompt text that Gen AI will use when generating content")
|
|
2348
|
+
},
|
|
2349
|
+
annotations: {
|
|
2350
|
+
readOnlyHint: false
|
|
2351
|
+
}
|
|
2352
|
+
},
|
|
2353
|
+
async ({ name, description, prompt }) => {
|
|
2354
|
+
const saved = await apiClient.post(
|
|
2355
|
+
"/v1/standards/prompt-templates",
|
|
2356
|
+
{
|
|
2357
|
+
name,
|
|
2358
|
+
description: description ?? "",
|
|
2359
|
+
prompt
|
|
2360
|
+
}
|
|
2361
|
+
);
|
|
2362
|
+
apiClient.invalidateCache("/v1/standards/prompt-templates");
|
|
2363
|
+
return {
|
|
2364
|
+
content: [
|
|
2365
|
+
{
|
|
2366
|
+
type: "text",
|
|
2367
|
+
text: `Prompt template created: **${saved.name}** (id: ${saved.id})`
|
|
2368
|
+
}
|
|
2369
|
+
]
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// src/tools/skills.ts
|
|
2376
|
+
import { z as z15 } from "zod";
|
|
2377
|
+
import { homedir as homedir3 } from "os";
|
|
2378
|
+
import { basename, resolve as resolve2 } from "path";
|
|
2379
|
+
var SKILLS = "/v1/standards/skills";
|
|
2380
|
+
function expandHome(path) {
|
|
2381
|
+
return path.replace(/^~(?=$|[\\/])/, homedir3());
|
|
2382
|
+
}
|
|
2383
|
+
function text(t) {
|
|
2384
|
+
return { content: [{ type: "text", text: t }] };
|
|
2385
|
+
}
|
|
2386
|
+
function registerSkillTools(server, apiClient, { localFiles }) {
|
|
2387
|
+
const fileSchema = z15.object({
|
|
2388
|
+
path: z15.string().describe('Path inside the skill folder, forward slashes, e.g. "references/guide.md"'),
|
|
2389
|
+
content: z15.string().describe("The file's full text, exactly as it is")
|
|
2390
|
+
});
|
|
2391
|
+
server.registerTool(
|
|
2392
|
+
"publish_skill",
|
|
2393
|
+
{
|
|
2394
|
+
title: "Publish Skill",
|
|
2395
|
+
description: localFiles ? "Publish a Claude Code skill folder (SKILL.md plus its references and scripts) to SystemDox so it can be installed on any computer. Pass `directory` (e.g. ~/.claude/skills/<name>): the server reads the files itself, byte for byte. Publishing a name again makes a new version. Never retype the files into `files` when the folder is on this computer." : "Publish a Claude Code skill (SKILL.md plus its references and scripts) to SystemDox so it can be installed on any computer. Pass every file of the folder in `files`, exactly as it is. Publishing a name again makes a new version.",
|
|
2396
|
+
inputSchema: {
|
|
2397
|
+
...localFiles ? {
|
|
2398
|
+
directory: z15.string().optional().describe("The skill folder on this computer, holding SKILL.md at its root")
|
|
2399
|
+
} : {},
|
|
2400
|
+
files: z15.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the skill folder"),
|
|
2401
|
+
name: z15.string().optional().describe("The skill's name; defaults to SKILL.md's frontmatter name and must match it"),
|
|
2402
|
+
spaceId: z15.string().optional().describe("Space for a NEW skill (default: your default space)"),
|
|
2403
|
+
note: z15.string().max(500).optional().describe(`A short change note for this version, shown in the console's history (e.g. "Fix the GA4 step")`),
|
|
2404
|
+
baseVersion: z15.number().int().positive().optional().describe(
|
|
2405
|
+
"The version these files were edited from. When given, the publish is refused (SYD-K017) if someone published a newer version since, instead of replacing their changes"
|
|
2406
|
+
)
|
|
2407
|
+
},
|
|
2408
|
+
annotations: { readOnlyHint: false }
|
|
2409
|
+
},
|
|
2410
|
+
async (args) => {
|
|
2411
|
+
let files;
|
|
2412
|
+
let name;
|
|
2413
|
+
let skipped = [];
|
|
2414
|
+
if (localFiles && args.directory) {
|
|
2415
|
+
({ name, files, skipped } = await readSkillDirectory(expandHome(args.directory)));
|
|
2416
|
+
} else if (args.files?.length) {
|
|
2417
|
+
files = args.files;
|
|
2418
|
+
const entry = files.find((f) => f.path === ENTRY_FILE);
|
|
2419
|
+
name = entry ? frontmatterName(entry.content) : void 0;
|
|
2420
|
+
} else {
|
|
2421
|
+
throw new Error(localFiles ? "Pass `directory` (or `files`)." : "Pass `files`.");
|
|
2422
|
+
}
|
|
2423
|
+
name = args.name ?? name;
|
|
2424
|
+
if (!name) throw new Error(`${ENTRY_FILE} has no frontmatter name; pass \`name\`.`);
|
|
2425
|
+
const saved = await apiClient.post(
|
|
2426
|
+
`${SKILLS}/${encodeURIComponent(name)}/versions`,
|
|
2427
|
+
{
|
|
2428
|
+
files,
|
|
2429
|
+
...args.spaceId ? { spaceId: args.spaceId } : {},
|
|
2430
|
+
...args.note ? { note: args.note } : {},
|
|
2431
|
+
...args.baseVersion ? { baseVersion: args.baseVersion } : {}
|
|
2432
|
+
}
|
|
2433
|
+
);
|
|
2434
|
+
apiClient.invalidateCache(SKILLS);
|
|
2435
|
+
return text(
|
|
2436
|
+
`Published **${saved.name}** version ${saved.version} (${saved.fileCount} file(s), ${saved.totalBytes} bytes).
|
|
2437
|
+
Install it anywhere with: npx -y @systemdox/mcp-server install-skill ${saved.name}` + (skipped.length ? `
|
|
2438
|
+
Left out (hidden files are never part of a skill): ${skipped.join(", ")}` : "")
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
);
|
|
2442
|
+
server.registerTool(
|
|
2443
|
+
"list_skills",
|
|
2444
|
+
{
|
|
2445
|
+
title: "List Skills",
|
|
2446
|
+
description: "List the Claude Code skills published to SystemDox that you can install: name, description, latest version. Use install_skill to install one.",
|
|
2447
|
+
inputSchema: {},
|
|
2448
|
+
annotations: { readOnlyHint: true }
|
|
2449
|
+
},
|
|
2450
|
+
async () => {
|
|
2451
|
+
const { skills } = await apiClient.get(SKILLS);
|
|
2452
|
+
if (!skills.length) {
|
|
2453
|
+
return text(
|
|
2454
|
+
`No skills published yet. Publish one with publish_skill; they appear at ${configLink("/docs/team-setup/skills", await workspaceBase(apiClient))}`
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
return text(
|
|
2458
|
+
`${skills.length} skill(s):
|
|
2459
|
+
|
|
2460
|
+
` + skills.map((s) => `- **${s.name}** (version ${s.latestVersion}, updated ${s.updatedAt.slice(0, 10)}): ${s.description}`).join("\n")
|
|
2461
|
+
);
|
|
2462
|
+
}
|
|
2463
|
+
);
|
|
2464
|
+
server.registerTool(
|
|
2465
|
+
"install_skill",
|
|
2466
|
+
{
|
|
2467
|
+
title: "Install Skill",
|
|
2468
|
+
description: localFiles ? "Install a skill published to SystemDox onto this computer (default ~/.claude/skills/<name>/). Refuses, naming the files, when a file there was edited since it was installed, unless overwrite is true. Never runs anything." : "Fetch a skill published to SystemDox so you can install it: returns every file and the folder to write them into (default ~/.claude/skills/<name>/). Write each file exactly, then the manifest it shows.",
|
|
2469
|
+
inputSchema: {
|
|
2470
|
+
name: z15.string().describe("The skill's name"),
|
|
2471
|
+
version: z15.number().int().positive().optional().describe("A version number (default: the latest)"),
|
|
2472
|
+
target: z15.string().optional().describe("Folder to install into, e.g. a repo's .claude/skills/<name>"),
|
|
2473
|
+
...localFiles ? { overwrite: z15.boolean().optional().describe("Replace files edited since they were installed") } : {}
|
|
2474
|
+
},
|
|
2475
|
+
annotations: { readOnlyHint: !localFiles }
|
|
2476
|
+
},
|
|
2477
|
+
async (args) => {
|
|
2478
|
+
const version = await apiClient.getFresh(
|
|
2479
|
+
`${SKILLS}/${encodeURIComponent(args.name)}/versions/${args.version ?? "latest"}`
|
|
2480
|
+
);
|
|
2481
|
+
if (localFiles) {
|
|
2482
|
+
const dest2 = args.target ? expandHome(args.target) : defaultSkillDir(version.name);
|
|
2483
|
+
if (basename(resolve2(dest2)) !== version.name) {
|
|
2484
|
+
throw new Error(`target must be a folder named ${version.name} (e.g. .claude/skills/${version.name}); got ${dest2}`);
|
|
2485
|
+
}
|
|
2486
|
+
const result = await installSkill(version, dest2, { overwrite: args.overwrite });
|
|
2487
|
+
return text(describeInstall(version.name, result));
|
|
2488
|
+
}
|
|
2489
|
+
const dest = args.target ?? `~/.claude/skills/${version.name}`;
|
|
2490
|
+
const manifest = {
|
|
2491
|
+
name: version.name,
|
|
2492
|
+
version: version.version,
|
|
2493
|
+
installedAt: "<now, ISO 8601>",
|
|
2494
|
+
files: Object.fromEntries(version.files.map((f) => [f.path, f.sha256]))
|
|
2495
|
+
};
|
|
2496
|
+
const body = version.files.map((f) => {
|
|
2497
|
+
const fence = fenceFor(f.content);
|
|
2498
|
+
return `### ${f.path}
|
|
2499
|
+
|
|
2500
|
+
${fence}
|
|
2501
|
+
${f.content}${f.content.endsWith("\n") ? "" : "\n"}${fence}`;
|
|
2502
|
+
}).join("\n\n");
|
|
2503
|
+
return text(
|
|
2504
|
+
`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`)}
|
|
2505
|
+
|
|
2506
|
+
### ${MANIFEST_FILE}
|
|
2507
|
+
|
|
2508
|
+
\`\`\`json
|
|
2509
|
+
${JSON.stringify(manifest, null, 2)}
|
|
2510
|
+
\`\`\`
|
|
2511
|
+
|
|
2512
|
+
${body}`
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// src/tools/team-setup.ts
|
|
2519
|
+
import { z as z16 } from "zod";
|
|
2520
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2521
|
+
import { lstat as lstat2 } from "fs/promises";
|
|
2522
|
+
import { homedir as homedir4 } from "os";
|
|
2523
|
+
import { join as join5, resolve as resolve4 } from "path";
|
|
2524
|
+
|
|
2525
|
+
// src/lib/person-values.ts
|
|
2526
|
+
var PHONE = "phone";
|
|
2527
|
+
var EMAIL = "email";
|
|
2528
|
+
var HOME_PATH = "home-path";
|
|
2529
|
+
var NATIONAL_PREFIX = "+44";
|
|
2530
|
+
var W = "[\\p{L}\\p{N}_]";
|
|
2531
|
+
var D = "\\p{Nd}";
|
|
2532
|
+
var WORD_OR = (extra) => `[\\p{L}\\p{N}_${extra}]`;
|
|
2533
|
+
var PERSON_ID = "(?:(?:con|ctc)_[0-9A-Za-z]{10,40}|usr_[0-9A-Za-z-]{10,40})";
|
|
2534
|
+
var PERSON_TOKEN = `\`${PERSON_ID}\`(?:[ \\t]*\\([^()\\n]*\\))?`;
|
|
2535
|
+
var BEFORE = `(?<!${WORD_OR("+.:/\\\\\\-")})(?<!${D}[ -])`;
|
|
2536
|
+
var AFTER = `(?!${WORD_OR(":/\\\\")}|\\.${D}|[ -]?${D}|-${W})`;
|
|
2537
|
+
var E164 = `${BEFORE}\\+${D}+(?:[ -]${D}+)*${AFTER}`;
|
|
2538
|
+
var NATIONAL = `${BEFORE}0${D}*(?:[ -]${D}+)*${AFTER}`;
|
|
2539
|
+
var GROUP_SIZES = /* @__PURE__ */ new Set([2, 3, 4, 5, 6]);
|
|
2540
|
+
var ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_{|}~\\-]";
|
|
2541
|
+
var EMAIL_RE = "(?<![A-Za-z0-9!#$%&*+/=?^_{|}~.@\\-])[A-Za-z0-9_]" + ATEXT + "*(?:\\." + ATEXT + "+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}(?![A-Za-z0-9\\-]|\\.[A-Za-z0-9])";
|
|
2542
|
+
var HOME_PREFIX = "(?:[A-Za-z]:\\\\+Users\\\\+|[A-Za-z]:/+Users/+|/Users/|/home/)";
|
|
2543
|
+
var HOME = `(?<!${WORD_OR(".~:/\\\\$}\\-")})${HOME_PREFIX}[^\\\\/\\s"'\`<>|:*?]+`;
|
|
2544
|
+
var KEY_PREFIX = `(?<!${W})(?:pweb_[a-z]+_key_|sk-ant-|sk_live_|rk_live_|github_pat_|gh[pousr]_|sntrys_|xox[baprs]-)[A-Za-z0-9_\\-]{4,}`;
|
|
2545
|
+
var global = (source, flags = "") => new RegExp(source, `gu${flags}`);
|
|
2546
|
+
var whole = (source, flags = "") => new RegExp(`^(?:${source})$`, `u${flags}`);
|
|
2547
|
+
function lineOf(text3, start) {
|
|
2548
|
+
let line = 1;
|
|
2549
|
+
for (let i = text3.indexOf("\n"); i !== -1 && i < start; i = text3.indexOf("\n", i + 1)) line++;
|
|
2550
|
+
return line;
|
|
2551
|
+
}
|
|
2552
|
+
function phoneShape(value) {
|
|
2553
|
+
const groups = value.replace(/^\++/, "").split(/[ -]/);
|
|
2554
|
+
const digits = [...groups.join("")];
|
|
2555
|
+
if (value.startsWith("+")) return digits.length >= 8 && digits.length <= 15 && digits[0] !== "0";
|
|
2556
|
+
if (digits.length < 10 || digits.length > 11) return false;
|
|
2557
|
+
return groups.length === 1 || groups.slice(1).every((g) => GROUP_SIZES.has([...g].length));
|
|
2558
|
+
}
|
|
2559
|
+
function normalise(kind, value) {
|
|
2560
|
+
const v = typeof value === "string" ? value.trim() : "";
|
|
2561
|
+
if (kind === PHONE) {
|
|
2562
|
+
if (!(whole(E164).test(v) || whole(NATIONAL).test(v)) || !phoneShape(v)) {
|
|
2563
|
+
throw new Error("not a phone number");
|
|
2564
|
+
}
|
|
2565
|
+
const digits = v.replace(/[ -]/g, "");
|
|
2566
|
+
return digits.startsWith("+") ? digits : NATIONAL_PREFIX + digits.slice(1);
|
|
2567
|
+
}
|
|
2568
|
+
if (kind === EMAIL) {
|
|
2569
|
+
if (!whole(EMAIL_RE).test(v)) throw new Error("not an email address");
|
|
2570
|
+
return v.toLowerCase();
|
|
2571
|
+
}
|
|
2572
|
+
throw new Error(`not a value kind: ${JSON.stringify(kind)}`);
|
|
2573
|
+
}
|
|
2574
|
+
function findPersonValues(text3) {
|
|
2575
|
+
if (typeof text3 !== "string" || !text3) return [];
|
|
2576
|
+
const skipped = [...text3.matchAll(global(PERSON_TOKEN))].map((m) => [m.index, m.index + m[0].length]);
|
|
2577
|
+
const found = [];
|
|
2578
|
+
const add = (kind, m, normalised) => {
|
|
2579
|
+
const start = m.index;
|
|
2580
|
+
const end = start + m[0].length;
|
|
2581
|
+
if (skipped.some(([s, e]) => s <= start && end <= e)) return;
|
|
2582
|
+
if (found.some((f) => f.start < end && start < f.end)) return;
|
|
2583
|
+
found.push({ kind, value: m[0], normalised, line: lineOf(text3, start), start, end });
|
|
2584
|
+
};
|
|
2585
|
+
for (const m of text3.matchAll(global(EMAIL_RE))) add(EMAIL, m, m[0].toLowerCase());
|
|
2586
|
+
for (const source of [E164, NATIONAL]) {
|
|
2587
|
+
for (const m of text3.matchAll(global(source))) {
|
|
2588
|
+
if (phoneShape(m[0])) add(PHONE, m, normalise(PHONE, m[0]));
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
for (const m of text3.matchAll(global(HOME, "i"))) add(HOME_PATH, m, m[0]);
|
|
2592
|
+
return found.sort((a, b) => a.start - b.start);
|
|
2593
|
+
}
|
|
2594
|
+
function findSecrets(text3) {
|
|
2595
|
+
if (typeof text3 !== "string" || !text3) return [];
|
|
2596
|
+
return [...text3.matchAll(global(KEY_PREFIX))].map((m) => ({ kind: "key-prefix", line: lineOf(text3, m.index) }));
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// src/lib/team-setup-types.ts
|
|
2600
|
+
var PARTS2 = ["hooks", "mcp", "agents", "commands", "instructions"];
|
|
2601
|
+
var PERSON_ID_RE = new RegExp(`^${PERSON_ID}$`);
|
|
2602
|
+
|
|
2603
|
+
// src/sync-memory.ts
|
|
2604
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2605
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
2606
|
+
import { dirname as dirname3, join as join4, resolve as resolve3 } from "path";
|
|
2607
|
+
|
|
2608
|
+
// src/lib/memory-sync.ts
|
|
2609
|
+
import { createHash as createHash2 } from "crypto";
|
|
2610
|
+
var INDEX_FILE = "MEMORY.md";
|
|
2611
|
+
var SHAREABLE_TYPES = ["feedback", "project", "reference"];
|
|
2612
|
+
var FILE_PATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
2613
|
+
var CONFLICT_COPY_RE = /\.local-\d{4}-\d{2}-\d{2}(?:-\d+)?\.md$/;
|
|
2614
|
+
function sha2562(text3) {
|
|
2615
|
+
return createHash2("sha256").update(text3, "utf8").digest("hex");
|
|
2616
|
+
}
|
|
2617
|
+
function isFilePath(path) {
|
|
2618
|
+
return path.length <= 512 && FILE_PATH_RE.test(path) && !path.split("/").some((s) => s === "." || s === "..");
|
|
2619
|
+
}
|
|
2620
|
+
function isConflictCopy(path) {
|
|
2621
|
+
return CONFLICT_COPY_RE.test(path.split("/").pop() ?? path);
|
|
2622
|
+
}
|
|
2623
|
+
var BLOCK_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
2624
|
+
var YAML_FALSE = /* @__PURE__ */ new Set(["false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF"]);
|
|
2625
|
+
function readFrontmatter(text3) {
|
|
2626
|
+
const m = BLOCK_RE.exec(text3);
|
|
2627
|
+
if (!m) return void 0;
|
|
2628
|
+
const front = { shareFalse: false };
|
|
2629
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
2630
|
+
const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
2631
|
+
if (!kv) continue;
|
|
2632
|
+
const raw = kv[2].replace(/[ \t]+#.*$/, "");
|
|
2633
|
+
const quoted = /^(["'])(.*)\1$/.exec(raw);
|
|
2634
|
+
const value = quoted ? quoted[2] : raw;
|
|
2635
|
+
if (kv[1] === "type") front.type = value.trim().toLowerCase();
|
|
2636
|
+
if (kv[1] === "share") front.shareFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
|
|
2637
|
+
}
|
|
2638
|
+
return front;
|
|
2639
|
+
}
|
|
2640
|
+
function isShareable(path, text3) {
|
|
2641
|
+
if (isConflictCopy(path) || !path.endsWith(".md")) return false;
|
|
2642
|
+
const front = readFrontmatter(text3);
|
|
2643
|
+
if (front?.shareFalse) return false;
|
|
2644
|
+
if (path === INDEX_FILE) return true;
|
|
2645
|
+
return !!front && SHAREABLE_TYPES.includes(front.type ?? "");
|
|
2646
|
+
}
|
|
2647
|
+
function addShareFalse(text3) {
|
|
2648
|
+
const eol = text3.includes("\r\n") ? "\r\n" : "\n";
|
|
2649
|
+
const m = BLOCK_RE.exec(text3);
|
|
2650
|
+
if (!m) return `---${eol}share: false${eol}---${eol}${text3}`;
|
|
2651
|
+
const lines = m[1].split(/\r?\n/).filter((l) => !/^share[ \t]*:/.test(l));
|
|
2652
|
+
lines.push("share: false");
|
|
2653
|
+
const closing = m[0].slice(m[0].lastIndexOf("---"));
|
|
2654
|
+
return `---${eol}${lines.join(eol)}${eol}${closing}${text3.slice(m[0].length)}`;
|
|
2655
|
+
}
|
|
2656
|
+
function localDate(now) {
|
|
2657
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2658
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
|
|
2659
|
+
}
|
|
2660
|
+
function conflictCopyName(path, date, taken) {
|
|
2661
|
+
const stem = path.replace(/\.md$/, "");
|
|
2662
|
+
for (let n = 1; ; n++) {
|
|
2663
|
+
const name = `${stem}.local-${date}${n === 1 ? "" : `-${n}`}.md`;
|
|
2664
|
+
if (!taken.has(name.toLowerCase())) return name;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
function decide(path, base, team, local) {
|
|
2668
|
+
if (team === local) return "unchanged";
|
|
2669
|
+
if (team === base) return local === void 0 ? "delete-team" : "local";
|
|
2670
|
+
if (local === base) return team === void 0 ? "delete-local" : "team";
|
|
2671
|
+
if (path === INDEX_FILE && team !== void 0 && local !== void 0) return "index";
|
|
2672
|
+
if (team === void 0) return "local";
|
|
2673
|
+
if (local === void 0) return "team";
|
|
2674
|
+
return "conflict";
|
|
2675
|
+
}
|
|
2676
|
+
function decideAll(input) {
|
|
2677
|
+
const localHashes = Object.fromEntries(Object.entries(input.local).map(([p, t]) => [p, sha2562(t)]));
|
|
2678
|
+
const paths = /* @__PURE__ */ new Set([...Object.keys(input.base), ...Object.keys(input.team), ...Object.keys(localHashes)]);
|
|
2679
|
+
const rows = {};
|
|
2680
|
+
const needed = /* @__PURE__ */ new Set();
|
|
2681
|
+
for (const path of [...paths].sort()) {
|
|
2682
|
+
if (input.excluded.has(path)) continue;
|
|
2683
|
+
const row = decide(path, input.base[path], input.team[path], localHashes[path]);
|
|
2684
|
+
rows[path] = row;
|
|
2685
|
+
if (row === "team" || row === "conflict" || row === "index") needed.add(input.team[path]);
|
|
2686
|
+
if (row === "index" && input.base[path]) needed.add(input.base[path]);
|
|
2687
|
+
}
|
|
2688
|
+
return { rows, needed: [...needed] };
|
|
2689
|
+
}
|
|
2690
|
+
function planMerge(input, rows, texts) {
|
|
2691
|
+
const plan = {
|
|
2692
|
+
manifest: {},
|
|
2693
|
+
upload: {},
|
|
2694
|
+
writes: {},
|
|
2695
|
+
deletes: [],
|
|
2696
|
+
pulled: [],
|
|
2697
|
+
pushed: [],
|
|
2698
|
+
deletedHere: [],
|
|
2699
|
+
deletedFromTeam: [],
|
|
2700
|
+
conflicts: [],
|
|
2701
|
+
indexTeamLines: []
|
|
2702
|
+
};
|
|
2703
|
+
const taken = new Set(input.taken);
|
|
2704
|
+
const teamText = (path) => {
|
|
2705
|
+
const text3 = texts[input.team[path]];
|
|
2706
|
+
if (text3 === void 0) throw new Error(`the team's ${path} could not be read`);
|
|
2707
|
+
return text3;
|
|
2708
|
+
};
|
|
2709
|
+
for (const path of input.excluded) if (input.team[path]) plan.manifest[path] = input.team[path];
|
|
2710
|
+
for (const [path, row] of Object.entries(rows)) {
|
|
2711
|
+
const team = input.team[path];
|
|
2712
|
+
const local = input.local[path];
|
|
2713
|
+
switch (row) {
|
|
2714
|
+
case "unchanged":
|
|
2715
|
+
if (team) plan.manifest[path] = team;
|
|
2716
|
+
break;
|
|
2717
|
+
case "local":
|
|
2718
|
+
plan.manifest[path] = sha2562(local);
|
|
2719
|
+
plan.upload[path] = local;
|
|
2720
|
+
plan.pushed.push(path);
|
|
2721
|
+
break;
|
|
2722
|
+
case "delete-team":
|
|
2723
|
+
plan.deletedFromTeam.push(path);
|
|
2724
|
+
break;
|
|
2725
|
+
case "team":
|
|
2726
|
+
plan.manifest[path] = team;
|
|
2727
|
+
plan.writes[path] = teamText(path);
|
|
2728
|
+
plan.pulled.push(path);
|
|
2729
|
+
break;
|
|
2730
|
+
case "delete-local":
|
|
2731
|
+
plan.deletes.push(path);
|
|
2732
|
+
plan.deletedHere.push(path);
|
|
2733
|
+
break;
|
|
2734
|
+
case "conflict": {
|
|
2735
|
+
const copy = conflictCopyName(path, input.date, taken);
|
|
2736
|
+
taken.add(copy.toLowerCase());
|
|
2737
|
+
plan.manifest[path] = team;
|
|
2738
|
+
plan.writes[path] = teamText(path);
|
|
2739
|
+
plan.writes[copy] = local;
|
|
2740
|
+
plan.conflicts.push({ path, copy });
|
|
2741
|
+
break;
|
|
2742
|
+
}
|
|
2743
|
+
case "index": {
|
|
2744
|
+
const base = input.base[path] ? texts[input.base[path]] : void 0;
|
|
2745
|
+
const merged = mergeIndex(base, teamText(path), local);
|
|
2746
|
+
const hash = sha2562(merged.text);
|
|
2747
|
+
plan.manifest[path] = hash;
|
|
2748
|
+
if (merged.text !== local) {
|
|
2749
|
+
plan.writes[path] = merged.text;
|
|
2750
|
+
plan.pulled.push(path);
|
|
2751
|
+
}
|
|
2752
|
+
if (hash !== team) {
|
|
2753
|
+
plan.upload[path] = merged.text;
|
|
2754
|
+
plan.pushed.push(path);
|
|
2755
|
+
}
|
|
2756
|
+
plan.indexTeamLines.push(...merged.teamLines);
|
|
2757
|
+
break;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return plan;
|
|
2762
|
+
}
|
|
2763
|
+
var LINK_RE = /\]\(\s*<?([^)\s>]+)>?(?:\s+["'(][^)]*)?\)/;
|
|
2764
|
+
function entries(lines) {
|
|
2765
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2766
|
+
return lines.map((line) => {
|
|
2767
|
+
const link = LINK_RE.exec(line);
|
|
2768
|
+
const id = link ? `link:${link[1]}` : `text:${line}`;
|
|
2769
|
+
const n = seen.get(id) ?? 0;
|
|
2770
|
+
seen.set(id, n + 1);
|
|
2771
|
+
return { key: n ? `${id}#${n}` : id, line };
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
var splitLines = (text3) => text3.split(/\r?\n/);
|
|
2775
|
+
function mergeIndex(base, team, local) {
|
|
2776
|
+
const eol = local.includes("\r\n") ? "\r\n" : "\n";
|
|
2777
|
+
const b = new Map(entries(base === void 0 ? [] : splitLines(base)).map((e) => [e.key, e.line]));
|
|
2778
|
+
const teamEntries = entries(splitLines(team));
|
|
2779
|
+
const t = new Map(teamEntries.map((e) => [e.key, e.line]));
|
|
2780
|
+
const localEntries = entries(splitLines(local));
|
|
2781
|
+
const result = [];
|
|
2782
|
+
const teamLines = [];
|
|
2783
|
+
for (const e of localEntries) {
|
|
2784
|
+
const was = b.get(e.key);
|
|
2785
|
+
const theirs = t.get(e.key);
|
|
2786
|
+
if (theirs === e.line || theirs === was) result.push(e);
|
|
2787
|
+
else if (e.line === was) {
|
|
2788
|
+
if (theirs !== void 0) result.push({ key: e.key, line: theirs });
|
|
2789
|
+
} else {
|
|
2790
|
+
result.push(e);
|
|
2791
|
+
if (theirs !== void 0) teamLines.push(theirs);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
const baseEntries = entries(base === void 0 ? [] : splitLines(base));
|
|
2795
|
+
const localKeys = new Set(localEntries.map((e) => e.key));
|
|
2796
|
+
const replacedHere = /* @__PURE__ */ new Map();
|
|
2797
|
+
baseEntries.forEach((e, i) => {
|
|
2798
|
+
if (e.key.startsWith("text:") && !t.has(e.key) && !localKeys.has(e.key)) {
|
|
2799
|
+
replacedHere.set(e.key, i ? baseEntries[i - 1].key : "");
|
|
2800
|
+
}
|
|
2801
|
+
});
|
|
2802
|
+
const inResult = (key) => result.findIndex((e) => e.key === key);
|
|
2803
|
+
teamEntries.forEach((e, i) => {
|
|
2804
|
+
if (inResult(e.key) !== -1) return;
|
|
2805
|
+
const was = b.get(e.key);
|
|
2806
|
+
const before = i ? teamEntries[i - 1].key : "";
|
|
2807
|
+
const both = e.key.startsWith("text:") && [...replacedHere].find(([, prev]) => prev === before);
|
|
2808
|
+
if (was === void 0 && both) {
|
|
2809
|
+
replacedHere.delete(both[0]);
|
|
2810
|
+
teamLines.push(e.line);
|
|
2811
|
+
} else if (was === void 0) {
|
|
2812
|
+
let at = -1;
|
|
2813
|
+
for (let j = i - 1; j >= 0 && at === -1; j--) at = inResult(teamEntries[j].key);
|
|
2814
|
+
result.splice(at + 1, 0, e);
|
|
2815
|
+
} else if (e.line !== was) {
|
|
2816
|
+
teamLines.push(e.line);
|
|
2817
|
+
}
|
|
2818
|
+
});
|
|
2819
|
+
return { text: result.map((e) => e.line).join(eol), teamLines };
|
|
2820
|
+
}
|
|
2821
|
+
var groupKey = (kind, normalised) => `${kind}:${normalised}`;
|
|
2822
|
+
function collectValues(files) {
|
|
2823
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2824
|
+
const homePaths = [];
|
|
2825
|
+
const findings = {};
|
|
2826
|
+
for (const path of Object.keys(files).sort()) {
|
|
2827
|
+
const found = findPersonValues(files[path]);
|
|
2828
|
+
findings[path] = found;
|
|
2829
|
+
for (const f of found) {
|
|
2830
|
+
if (f.kind === HOME_PATH) {
|
|
2831
|
+
if (!homePaths.includes(path)) homePaths.push(path);
|
|
2832
|
+
continue;
|
|
2833
|
+
}
|
|
2834
|
+
const key = groupKey(f.kind, f.normalised);
|
|
2835
|
+
const group = groups.get(key);
|
|
2836
|
+
if (group) {
|
|
2837
|
+
if (!group.files.includes(path)) group.files.push(path);
|
|
2838
|
+
} else {
|
|
2839
|
+
groups.set(key, {
|
|
2840
|
+
kind: f.kind,
|
|
2841
|
+
value: f.value,
|
|
2842
|
+
normalised: f.normalised,
|
|
2843
|
+
files: [path],
|
|
2844
|
+
first: { path, line: f.line, text: splitLines(files[path])[f.line - 1].trim() }
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
return { groups: [...groups.values()], homePaths, findings };
|
|
2850
|
+
}
|
|
2851
|
+
function personToken(personId, label) {
|
|
2852
|
+
return label ? `\`${personId}\` (${label})` : `\`${personId}\``;
|
|
2853
|
+
}
|
|
2854
|
+
function rewriteValues(text3, findings, replacement) {
|
|
2855
|
+
let out = "";
|
|
2856
|
+
let last = 0;
|
|
2857
|
+
for (const f of [...findings].sort((a, b) => a.start - b.start)) {
|
|
2858
|
+
const to = replacement(f);
|
|
2859
|
+
if (to === void 0) continue;
|
|
2860
|
+
out += text3.slice(last, f.start) + to;
|
|
2861
|
+
last = f.end;
|
|
2862
|
+
}
|
|
2863
|
+
return out + text3.slice(last);
|
|
2864
|
+
}
|
|
2865
|
+
function lineDiff(before, after) {
|
|
2866
|
+
const a = splitLines(before);
|
|
2867
|
+
const b = splitLines(after);
|
|
2868
|
+
const out = [];
|
|
2869
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
2870
|
+
if (a[i] === b[i]) continue;
|
|
2871
|
+
if (a[i] !== void 0) out.push(`- ${a[i]}`);
|
|
2872
|
+
if (b[i] !== void 0) out.push(`+ ${b[i]}`);
|
|
2873
|
+
}
|
|
2874
|
+
return out;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// src/sync-memory.ts
|
|
2878
|
+
var SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
|
|
2879
|
+
var SyncFailed = class extends Error {
|
|
2880
|
+
};
|
|
2881
|
+
function refusal(e) {
|
|
2882
|
+
if (!(e instanceof Error)) return {};
|
|
2883
|
+
const { status, code } = e;
|
|
2884
|
+
return { status: typeof status === "number" ? status : void 0, code: typeof code === "string" ? code : void 0 };
|
|
2885
|
+
}
|
|
2886
|
+
var toPath = (root, rel) => join4(root, ...rel.split("/"));
|
|
2887
|
+
async function readFolder(root) {
|
|
2888
|
+
const files = {};
|
|
2889
|
+
const names = /* @__PURE__ */ new Set();
|
|
2890
|
+
async function walk2(dir, prefix) {
|
|
2891
|
+
for (const e of await readdir2(dir, { withFileTypes: true })) {
|
|
2892
|
+
if (e.name.startsWith(".")) continue;
|
|
2893
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
2894
|
+
if (e.isDirectory()) await walk2(join4(dir, e.name), rel);
|
|
2895
|
+
else if (e.isFile()) {
|
|
2896
|
+
names.add(rel.toLowerCase());
|
|
2897
|
+
if (e.name.endsWith(".md")) files[rel] = await readFile4(join4(dir, e.name), "utf8");
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
if (existsSync3(root)) await walk2(root, "");
|
|
2902
|
+
return { files, names };
|
|
2903
|
+
}
|
|
2904
|
+
async function pullTeam(client, ws, project) {
|
|
2905
|
+
try {
|
|
2906
|
+
const latest = await client.getMemory(ws, project);
|
|
2907
|
+
return {
|
|
2908
|
+
version: latest.version,
|
|
2909
|
+
manifest: Object.fromEntries(Object.entries(latest.files).map(([p, f]) => [p, f.sha256])),
|
|
2910
|
+
exists: true
|
|
2911
|
+
};
|
|
2912
|
+
} catch (e) {
|
|
2913
|
+
if (refusal(e).status === 404) return { version: 0, manifest: {}, exists: false };
|
|
2914
|
+
throw e;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
async function readTexts(client, ws, project, hashes) {
|
|
2918
|
+
const texts = {};
|
|
2919
|
+
const one = async (batch) => {
|
|
2920
|
+
let left = batch;
|
|
2921
|
+
while (left.length) {
|
|
2922
|
+
const got = await client.readBlobs(ws, project, left);
|
|
2923
|
+
for (const b of got.blobs) texts[b.sha256] = b.content;
|
|
2924
|
+
if (!got.blobs.length) break;
|
|
2925
|
+
left = got.remaining;
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
for (let i = 0; i < hashes.length; i += 1e3) {
|
|
2929
|
+
const batch = hashes.slice(i, i + 1e3);
|
|
2930
|
+
try {
|
|
2931
|
+
await one(batch);
|
|
2932
|
+
} catch (e) {
|
|
2933
|
+
if (!(refusal(e).status === 404)) throw e;
|
|
2934
|
+
for (const h of batch) {
|
|
2935
|
+
try {
|
|
2936
|
+
await one([h]);
|
|
2937
|
+
} catch (err) {
|
|
2938
|
+
if (!(refusal(err).status === 404)) throw err;
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
return texts;
|
|
2944
|
+
}
|
|
2945
|
+
var Report = class {
|
|
2946
|
+
pulled = /* @__PURE__ */ new Set();
|
|
2947
|
+
pushed = /* @__PURE__ */ new Set();
|
|
2948
|
+
deletedHere = /* @__PURE__ */ new Set();
|
|
2949
|
+
deletedFromTeam = /* @__PURE__ */ new Set();
|
|
2950
|
+
conflicts = [];
|
|
2951
|
+
skipped = /* @__PURE__ */ new Map();
|
|
2952
|
+
people = [];
|
|
2953
|
+
toAnswer = /* @__PURE__ */ new Map();
|
|
2954
|
+
add(plan) {
|
|
2955
|
+
plan.pulled.forEach((p) => this.pulled.add(p));
|
|
2956
|
+
plan.pushed.forEach((p) => this.pushed.add(p));
|
|
2957
|
+
plan.deletedHere.forEach((p) => this.deletedHere.add(p));
|
|
2958
|
+
plan.deletedFromTeam.forEach((p) => this.deletedFromTeam.add(p));
|
|
2959
|
+
for (const c of plan.conflicts) this.conflicts.push(`${c.path}: the team's text; yours is beside it as ${c.copy}`);
|
|
2960
|
+
for (const line of plan.indexTeamLines) this.conflicts.push(`${INDEX_FILE}: your line was kept; the team's was: ${line}`);
|
|
2961
|
+
}
|
|
2962
|
+
skip(path, reason) {
|
|
2963
|
+
if (!this.skipped.has(path)) this.skipped.set(path, reason);
|
|
2964
|
+
}
|
|
2965
|
+
lines(summary, dryRun) {
|
|
2966
|
+
const out = [summary];
|
|
2967
|
+
const section = (title, items) => {
|
|
2968
|
+
if (items.length) out.push("", `${title}:`, ...items.map((i) => `- ${i}`));
|
|
2969
|
+
};
|
|
2970
|
+
const sorted = (s) => [...s].sort();
|
|
2971
|
+
section(dryRun ? "Would pull" : "Pulled", sorted(this.pulled));
|
|
2972
|
+
section(dryRun ? "Would push" : "Pushed", sorted(this.pushed));
|
|
2973
|
+
section(dryRun ? "Would delete" : "Deleted", [
|
|
2974
|
+
...sorted(this.deletedHere).map((p) => `${p} (here: the team deleted it)`),
|
|
2975
|
+
...sorted(this.deletedFromTeam).map((p) => `${p} (from the team: you deleted it)`)
|
|
2976
|
+
]);
|
|
2977
|
+
section("Conflicts", this.conflicts);
|
|
2978
|
+
section(
|
|
2979
|
+
"Skipped",
|
|
2980
|
+
[...this.skipped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([p, why]) => `${p}: ${why}`)
|
|
2981
|
+
);
|
|
2982
|
+
section(
|
|
2983
|
+
"People",
|
|
2984
|
+
this.people.map(
|
|
2985
|
+
(a) => [a.kind, a.answer, a.label, a.personId && `\`${a.personId}\``, a.files !== void 0 && `${a.files} files`].filter(Boolean).join(", ")
|
|
2986
|
+
)
|
|
2987
|
+
);
|
|
2988
|
+
if (this.toAnswer.size) {
|
|
2989
|
+
out.push(
|
|
2990
|
+
"",
|
|
2991
|
+
`Values still to answer: ${[...this.toAnswer.entries()].map(([kind, n]) => `${n} ${kind}`).join(", ")}.`
|
|
2992
|
+
);
|
|
2993
|
+
}
|
|
2994
|
+
return out;
|
|
2995
|
+
}
|
|
2996
|
+
};
|
|
2997
|
+
var ANSWER_WORDS = {
|
|
2998
|
+
member: "a member's contact",
|
|
2999
|
+
person: "someone else",
|
|
3000
|
+
ours: "ours, not a person"
|
|
3001
|
+
};
|
|
3002
|
+
function labelProblem(label) {
|
|
3003
|
+
if (!label) return "a label is needed";
|
|
3004
|
+
if (label.length > 60) return "a label is at most 60 characters";
|
|
3005
|
+
if (findPersonValues(label).length) return "a label is a role, never a phone number, an email address or a path";
|
|
3006
|
+
return void 0;
|
|
3007
|
+
}
|
|
3008
|
+
async function askLabel(ask, out, question, optional) {
|
|
3009
|
+
for (; ; ) {
|
|
3010
|
+
const label = (await ask(question)).trim();
|
|
3011
|
+
if (optional && !label) return void 0;
|
|
3012
|
+
const problem = labelProblem(label);
|
|
3013
|
+
if (!problem) return label;
|
|
3014
|
+
out(` ${problem}.`);
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
async function people(opts, ws, files, report, hint) {
|
|
3018
|
+
const { client, out, ask, dryRun } = opts;
|
|
3019
|
+
const terminal = !!ask && !dryRun;
|
|
3020
|
+
const held = /* @__PURE__ */ new Set();
|
|
3021
|
+
const leftOut = /* @__PURE__ */ new Set();
|
|
3022
|
+
const current = { ...files };
|
|
3023
|
+
for (const [path, text3] of Object.entries(current)) {
|
|
3024
|
+
const secret = findSecrets(text3)[0];
|
|
3025
|
+
if (secret) {
|
|
3026
|
+
held.add(path);
|
|
3027
|
+
report.skip(path, `a secret (${secret.kind}) on line ${secret.line}: remove it, then sync again`);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
const { groups, findings } = collectValues(current);
|
|
3031
|
+
const answers = /* @__PURE__ */ new Map();
|
|
3032
|
+
for (let i = 0; i < groups.length; i += 500) {
|
|
3033
|
+
const batch = groups.slice(i, i + 500);
|
|
3034
|
+
const got = await client.lookupValues(ws, batch.map((g) => ({ kind: g.kind, value: g.value })));
|
|
3035
|
+
batch.forEach((g, j) => {
|
|
3036
|
+
const a = got.answers[j];
|
|
3037
|
+
if (a && a.answer !== "unanswered") answers.set(groupKey(g.kind, g.normalised), a);
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
const vaultDown = /* @__PURE__ */ new Set();
|
|
3041
|
+
if (terminal) {
|
|
3042
|
+
const filed = [];
|
|
3043
|
+
for (const g of groups) {
|
|
3044
|
+
const key = groupKey(g.kind, g.normalised);
|
|
3045
|
+
if (answers.has(key)) continue;
|
|
3046
|
+
let choices = ["m", "p", "o", "x", "s"];
|
|
3047
|
+
for (; ; ) {
|
|
3048
|
+
const question = [
|
|
3049
|
+
`${g.value} (${g.kind}) is in ${g.files.length} file${g.files.length === 1 ? "" : "s"}. First line it appears on, in ${g.first.path}:`,
|
|
3050
|
+
` ${g.first.text}`,
|
|
3051
|
+
[
|
|
3052
|
+
choices.includes("m") && "[m] a member's contact",
|
|
3053
|
+
"[p] someone else",
|
|
3054
|
+
"[o] ours, not a person",
|
|
3055
|
+
"[x] leave these files out",
|
|
3056
|
+
"[s] skip for now"
|
|
3057
|
+
].filter(Boolean).join(" "),
|
|
3058
|
+
`Answer (${choices.join("/")}): `
|
|
3059
|
+
].join("\n");
|
|
3060
|
+
const answer = (await ask(question)).trim().toLowerCase();
|
|
3061
|
+
if (!choices.includes(answer)) continue;
|
|
3062
|
+
if (answer === "s") break;
|
|
3063
|
+
if (answer === "x") {
|
|
3064
|
+
for (const path of g.files) {
|
|
3065
|
+
if (leftOut.has(path)) continue;
|
|
3066
|
+
current[path] = addShareFalse(current[path]);
|
|
3067
|
+
await writeFile3(toPath(opts.dir, path), current[path], "utf8");
|
|
3068
|
+
leftOut.add(path);
|
|
3069
|
+
}
|
|
3070
|
+
report.people.push({ kind: g.kind, answer: "left out (share: false)", files: g.files.length });
|
|
3071
|
+
break;
|
|
3072
|
+
}
|
|
3073
|
+
if (answer === "p") {
|
|
3074
|
+
const label2 = await askLabel(ask, out, "Their role, as a label (e.g. reseller, sales; never a name): ", false);
|
|
3075
|
+
let entry = filed.length ? await (async () => {
|
|
3076
|
+
const list = filed.map((f, i) => ` ${i + 1}. ${f.label}: ${f.groups.map((x) => x.value).join(", ")}`);
|
|
3077
|
+
for (; ; ) {
|
|
3078
|
+
const same = (await ask(["The same person as one filed above?", ...list, "Number, or Enter for a new person: "].join("\n"))).trim();
|
|
3079
|
+
if (!same) return void 0;
|
|
3080
|
+
const n = Number(same);
|
|
3081
|
+
if (Number.isInteger(n) && n >= 1 && n <= filed.length && filed[n - 1].groups.length < 4) return filed[n - 1];
|
|
3082
|
+
}
|
|
3083
|
+
})() : void 0;
|
|
3084
|
+
if (!entry) {
|
|
3085
|
+
entry = { groups: [], label: label2 };
|
|
3086
|
+
filed.push(entry);
|
|
3087
|
+
}
|
|
3088
|
+
entry.groups.push(g);
|
|
3089
|
+
break;
|
|
3090
|
+
}
|
|
3091
|
+
const label = answer === "m" ? await askLabel(ask, out, "A role label for it (e.g. owner, business mobile), or Enter for none: ", true) : void 0;
|
|
3092
|
+
let result;
|
|
3093
|
+
try {
|
|
3094
|
+
result = await client.answerValues(ws, [
|
|
3095
|
+
{ kind: g.kind, value: g.value, answer: answer === "m" ? "member" : "ours", ...label ? { label } : {} }
|
|
3096
|
+
]);
|
|
3097
|
+
} catch (e) {
|
|
3098
|
+
if (refusal(e).code === "SYD-K019") {
|
|
3099
|
+
vaultDown.add(key);
|
|
3100
|
+
out(" The vault did not answer (SYD-K019): the files holding it wait for the next sync.");
|
|
3101
|
+
break;
|
|
3102
|
+
}
|
|
3103
|
+
throw e;
|
|
3104
|
+
}
|
|
3105
|
+
if (result.stored[0]) {
|
|
3106
|
+
answers.set(key, result.stored[0]);
|
|
3107
|
+
report.people.push({ kind: g.kind, answer: ANSWER_WORDS[result.stored[0].answer], label: result.stored[0].label, personId: result.stored[0].personId });
|
|
3108
|
+
break;
|
|
3109
|
+
}
|
|
3110
|
+
const refused2 = result.refused[0];
|
|
3111
|
+
if (refused2?.code === "SYD-K021") {
|
|
3112
|
+
out(` ${refused2.detail}. Add them in Staff first, or answer p to file them.`);
|
|
3113
|
+
choices = choices.filter((c) => c !== "m");
|
|
3114
|
+
} else if (refused2) out(` ${refused2.detail}`);
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
for (let i = 0; i < filed.length; i += 50) {
|
|
3118
|
+
const batch = filed.slice(i, i + 50);
|
|
3119
|
+
let result;
|
|
3120
|
+
try {
|
|
3121
|
+
result = await client.fileMemoryPeople(
|
|
3122
|
+
ws,
|
|
3123
|
+
batch.map((f) => ({ values: f.groups.map((g) => ({ kind: g.kind, value: g.value })), label: f.label }))
|
|
3124
|
+
);
|
|
3125
|
+
} catch (e) {
|
|
3126
|
+
if (refusal(e).code === "SYD-K019") {
|
|
3127
|
+
out("Contacts did not answer (SYD-K019): the files holding those values wait for the next sync.");
|
|
3128
|
+
batch.forEach((f) => f.groups.forEach((g) => vaultDown.add(groupKey(g.kind, g.normalised))));
|
|
3129
|
+
continue;
|
|
3130
|
+
}
|
|
3131
|
+
throw e;
|
|
3132
|
+
}
|
|
3133
|
+
for (const stored of result.stored) {
|
|
3134
|
+
let key;
|
|
3135
|
+
try {
|
|
3136
|
+
key = groupKey(stored.kind, normalise(stored.kind, stored.value));
|
|
3137
|
+
} catch {
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
answers.set(key, stored);
|
|
3141
|
+
}
|
|
3142
|
+
for (const r of result.refused) {
|
|
3143
|
+
out(` ${batch[r.index]?.label ?? ""}: ${r.detail}`);
|
|
3144
|
+
}
|
|
3145
|
+
for (const f of batch) {
|
|
3146
|
+
const a = answers.get(groupKey(f.groups[0].kind, f.groups[0].normalised));
|
|
3147
|
+
if (a?.answer === "person") {
|
|
3148
|
+
for (const g of f.groups) report.people.push({ kind: g.kind, answer: ANSWER_WORDS.person, label: a.label, personId: a.personId });
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
const rewritten = {};
|
|
3154
|
+
for (const [path, text3] of Object.entries(current)) {
|
|
3155
|
+
if (leftOut.has(path) || held.has(path)) continue;
|
|
3156
|
+
const next = rewriteValues(text3, findings[path] ?? [], (f) => {
|
|
3157
|
+
if (f.kind === HOME_PATH) return "~";
|
|
3158
|
+
const a = answers.get(groupKey(f.kind, f.normalised));
|
|
3159
|
+
return a && (a.answer === "member" || a.answer === "person") && a.personId ? personToken(a.personId, a.label) : void 0;
|
|
3160
|
+
});
|
|
3161
|
+
if (next !== text3) rewritten[path] = next;
|
|
3162
|
+
}
|
|
3163
|
+
const paths = Object.keys(rewritten).sort();
|
|
3164
|
+
if (paths.length && terminal) {
|
|
3165
|
+
out("", "The rewrite:");
|
|
3166
|
+
for (const path of paths) out(path, ...lineDiff(current[path], rewritten[path]).map((l) => ` ${l}`));
|
|
3167
|
+
const yes = /^y(es)?$/i.test((await ask(`Rewrite ${paths.length} file${paths.length === 1 ? "" : "s"} as shown? [y/N] `)).trim());
|
|
3168
|
+
if (yes) {
|
|
3169
|
+
for (const path of paths) {
|
|
3170
|
+
await writeFile3(toPath(opts.dir, path), rewritten[path], "utf8");
|
|
3171
|
+
current[path] = rewritten[path];
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
for (const [path, text3] of Object.entries(current)) {
|
|
3176
|
+
if (leftOut.has(path) || held.has(path)) continue;
|
|
3177
|
+
const pending = findPersonValues(text3).filter((f) => {
|
|
3178
|
+
if (f.kind === HOME_PATH) return true;
|
|
3179
|
+
return answers.get(groupKey(f.kind, f.normalised))?.answer !== "ours";
|
|
3180
|
+
});
|
|
3181
|
+
if (!pending.length) continue;
|
|
3182
|
+
held.add(path);
|
|
3183
|
+
const keys = pending.filter((f) => f.kind !== HOME_PATH).map((f) => groupKey(f.kind, f.normalised));
|
|
3184
|
+
if (keys.some((k) => vaultDown.has(k))) {
|
|
3185
|
+
report.skip(path, "the vault did not answer (SYD-K019) for a value in it; it syncs once the value is filed");
|
|
3186
|
+
} else if (keys.some((k) => !answers.has(k))) {
|
|
3187
|
+
report.skip(path, `it holds a value nobody has answered for: run \`${hint}\` in a terminal`);
|
|
3188
|
+
} else {
|
|
3189
|
+
report.skip(path, `it holds a value to rewrite as its identifier or ~: run \`${hint}\` in a terminal`);
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
for (const g of groups) {
|
|
3193
|
+
const key = groupKey(g.kind, g.normalised);
|
|
3194
|
+
if (!answers.has(key) && g.files.some((p) => !leftOut.has(p))) {
|
|
3195
|
+
report.toAnswer.set(g.kind, (report.toAnswer.get(g.kind) ?? 0) + 1);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
return { files: current, held, leftOut };
|
|
3199
|
+
}
|
|
3200
|
+
async function applyPlan(dir, plan) {
|
|
3201
|
+
for (const [path, text3] of Object.entries(plan.writes)) {
|
|
3202
|
+
const full = toPath(dir, path);
|
|
3203
|
+
await mkdir3(dirname3(full), { recursive: true });
|
|
3204
|
+
await writeFile3(full, text3, "utf8");
|
|
3205
|
+
}
|
|
3206
|
+
for (const path of plan.deletes) await rm3(toPath(dir, path), { force: true });
|
|
3207
|
+
}
|
|
3208
|
+
var sameManifest = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([p, h]) => b[p] === h);
|
|
3209
|
+
async function upload(client, ws, project, plan, team, report) {
|
|
3210
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
3211
|
+
for (const path of Object.keys(plan.upload)) {
|
|
3212
|
+
const h = plan.manifest[path];
|
|
3213
|
+
byHash.set(h, [...byHash.get(h) ?? [], path]);
|
|
3214
|
+
}
|
|
3215
|
+
const hashes = [...byHash.keys()];
|
|
3216
|
+
const missing = [];
|
|
3217
|
+
for (let i = 0; i < hashes.length; i += 1e4) {
|
|
3218
|
+
missing.push(...(await client.findMissingBlobs(ws, project, hashes.slice(i, i + 1e4))).missing);
|
|
3219
|
+
}
|
|
3220
|
+
const blobs = missing.map((h) => ({ sha256: h, content: plan.upload[byHash.get(h)[0]] }));
|
|
3221
|
+
let batch = [];
|
|
3222
|
+
let bytes = 0;
|
|
3223
|
+
const send = async () => {
|
|
3224
|
+
if (!batch.length) return;
|
|
3225
|
+
const result = await client.uploadBlobs(ws, project, batch);
|
|
3226
|
+
for (const r of result.refused) {
|
|
3227
|
+
for (const path of byHash.get(r.sha256) ?? []) {
|
|
3228
|
+
if (team[path]) plan.manifest[path] = team[path];
|
|
3229
|
+
else delete plan.manifest[path];
|
|
3230
|
+
plan.pushed = plan.pushed.filter((p) => p !== path);
|
|
3231
|
+
report.pushed.delete(path);
|
|
3232
|
+
report.skip(path, `refused by SystemDox, ${r.detail.startsWith(r.code) ? r.detail : `${r.code}: ${r.detail}`}`);
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
batch = [];
|
|
3236
|
+
bytes = 0;
|
|
3237
|
+
};
|
|
3238
|
+
for (const blob of blobs) {
|
|
3239
|
+
const size = Buffer.byteLength(blob.content, "utf8");
|
|
3240
|
+
if (batch.length === 100 || batch.length && bytes + size > 5 * 1024 * 1024 - 64 * 1024) await send();
|
|
3241
|
+
batch.push(blob);
|
|
3242
|
+
bytes += size;
|
|
3243
|
+
}
|
|
3244
|
+
await send();
|
|
3245
|
+
}
|
|
3246
|
+
async function syncMemory(opts) {
|
|
3247
|
+
const { client, out } = opts;
|
|
3248
|
+
const now = opts.now?.() ?? /* @__PURE__ */ new Date();
|
|
3249
|
+
try {
|
|
3250
|
+
const own = await client.getOwnTeamSetup();
|
|
3251
|
+
const ws = own.workspace;
|
|
3252
|
+
const project = opts.project ?? (own.memory.length === 1 ? own.memory[0].project : void 0);
|
|
3253
|
+
if (!project) {
|
|
3254
|
+
throw new SyncFailed(
|
|
3255
|
+
own.memory.length ? `${ws} has ${own.memory.length} team memories (${own.memory.map((m) => m.project).join(", ")}): name one with --project.` : `${ws} has no team memory yet: name the project to start with --project.`
|
|
3256
|
+
);
|
|
3257
|
+
}
|
|
3258
|
+
const dir = resolve3(opts.dir ?? join4(workspaceDir(ws), "memory", project));
|
|
3259
|
+
const run2 = { ...opts, dir };
|
|
3260
|
+
const hint = [SYNC_MEMORY_COMMAND, `--project ${project}`, opts.dir ? `--dir "${opts.dir}"` : ""].filter(Boolean).join(" ");
|
|
3261
|
+
const report = new Report();
|
|
3262
|
+
const date = localDate(now);
|
|
3263
|
+
const stored = (await readState(ws)).memory?.[project];
|
|
3264
|
+
let team = await pullTeam(client, ws, project);
|
|
3265
|
+
let base = stored ? { version: stored.version, manifest: stored.manifest, exists: true } : { version: 0, manifest: {}, exists: false };
|
|
3266
|
+
const folder = await readFolder(dir);
|
|
3267
|
+
const shared = {};
|
|
3268
|
+
const notShared = /* @__PURE__ */ new Set();
|
|
3269
|
+
for (const [path, text3] of Object.entries(folder.files)) {
|
|
3270
|
+
if (!isShareable(path, text3)) notShared.add(path);
|
|
3271
|
+
else if (!isFilePath(path)) {
|
|
3272
|
+
notShared.add(path);
|
|
3273
|
+
report.skip(path, "its name has characters a team memory path cannot hold (letters, digits, '.', '_', '-')");
|
|
3274
|
+
} else shared[path] = text3;
|
|
3275
|
+
}
|
|
3276
|
+
const importWrites = {};
|
|
3277
|
+
if (opts.importDir) {
|
|
3278
|
+
const other = await readFolder(resolve3(opts.importDir));
|
|
3279
|
+
const taken = new Set(folder.names);
|
|
3280
|
+
for (const [path, text3] of Object.entries(other.files).sort(([a], [b]) => a.localeCompare(b))) {
|
|
3281
|
+
if (!isShareable(path, text3) || !isFilePath(path)) continue;
|
|
3282
|
+
const here = folder.files[path];
|
|
3283
|
+
if (here === void 0) {
|
|
3284
|
+
shared[path] = text3;
|
|
3285
|
+
importWrites[path] = text3;
|
|
3286
|
+
taken.add(path.toLowerCase());
|
|
3287
|
+
} else if (here !== text3) {
|
|
3288
|
+
const copy = conflictCopyName(path, date, taken);
|
|
3289
|
+
taken.add(copy.toLowerCase());
|
|
3290
|
+
importWrites[copy] = text3;
|
|
3291
|
+
report.conflicts.push(`${path}: this folder's text; the imported one is beside it as ${copy}`);
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
folder.names = taken;
|
|
3295
|
+
if (!opts.dryRun) {
|
|
3296
|
+
for (const [path, text3] of Object.entries(importWrites)) {
|
|
3297
|
+
const full = toPath(dir, path);
|
|
3298
|
+
await mkdir3(dirname3(full), { recursive: true });
|
|
3299
|
+
await writeFile3(full, text3, "utf8");
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
const found = await people(run2, ws, shared, report, hint);
|
|
3304
|
+
for (const path of found.leftOut) {
|
|
3305
|
+
delete found.files[path];
|
|
3306
|
+
notShared.add(path);
|
|
3307
|
+
}
|
|
3308
|
+
const excluded = /* @__PURE__ */ new Set([...found.held, ...notShared]);
|
|
3309
|
+
for (const path of found.held) delete found.files[path];
|
|
3310
|
+
for (let attempt = 1; ; attempt++) {
|
|
3311
|
+
const input = { base: base.manifest, team: team.manifest, local: found.files, excluded, taken: folder.names, date };
|
|
3312
|
+
const { rows, needed } = decideAll(input);
|
|
3313
|
+
const texts = needed.length ? await readTexts(client, ws, project, needed) : {};
|
|
3314
|
+
const plan = planMerge(input, rows, texts);
|
|
3315
|
+
report.add(plan);
|
|
3316
|
+
if (opts.dryRun) {
|
|
3317
|
+
const skipped = report.skipped.size;
|
|
3318
|
+
out(
|
|
3319
|
+
...report.lines(
|
|
3320
|
+
`Dry run of ${ws}/${project} against version ${team.version}: would pull ${report.pulled.size}, push ${report.pushed.size} and delete ${report.deletedHere.size + report.deletedFromTeam.size}; ${report.conflicts.length} conflicts, ${skipped} skipped. Nothing was asked, written or sent.`,
|
|
3321
|
+
true
|
|
3322
|
+
)
|
|
3323
|
+
);
|
|
3324
|
+
return 0;
|
|
3325
|
+
}
|
|
3326
|
+
await applyPlan(dir, plan);
|
|
3327
|
+
let version = team.version;
|
|
3328
|
+
if (!sameManifest(plan.manifest, team.manifest)) {
|
|
3329
|
+
await upload(client, ws, project, plan, team.manifest, report);
|
|
3330
|
+
}
|
|
3331
|
+
if (!sameManifest(plan.manifest, team.manifest)) {
|
|
3332
|
+
try {
|
|
3333
|
+
version = (await client.pushMemoryVersion(ws, project, { baseVersion: team.version, manifest: plan.manifest })).version;
|
|
3334
|
+
} catch (e) {
|
|
3335
|
+
if (!(refusal(e).status === 409 && refusal(e).code === "SYD-K010")) throw e;
|
|
3336
|
+
if (attempt === 2) {
|
|
3337
|
+
out(
|
|
3338
|
+
...report.lines(
|
|
3339
|
+
`Not pushed: ${ws}/${project} moved on twice while this sync merged. Your folder holds the merge; run sync-memory again.`,
|
|
3340
|
+
false
|
|
3341
|
+
)
|
|
3342
|
+
);
|
|
3343
|
+
return 2;
|
|
3344
|
+
}
|
|
3345
|
+
base = team;
|
|
3346
|
+
for (const p of plan.pushed) report.pushed.delete(p);
|
|
3347
|
+
const again = await readFolder(dir);
|
|
3348
|
+
folder.names = again.names;
|
|
3349
|
+
found.files = {};
|
|
3350
|
+
for (const [path, text3] of Object.entries(again.files)) {
|
|
3351
|
+
if (excluded.has(path) || !isShareable(path, text3) || !isFilePath(path)) continue;
|
|
3352
|
+
found.files[path] = text3;
|
|
3353
|
+
}
|
|
3354
|
+
team = await pullTeam(client, ws, project);
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
await updateState(ws, (state) => {
|
|
3359
|
+
state.memory = { ...state.memory, [project]: { version, manifest: plan.manifest } };
|
|
3360
|
+
});
|
|
3361
|
+
out(
|
|
3362
|
+
...report.lines(
|
|
3363
|
+
`Synced ${ws}/${project} at version ${version}: ${report.pulled.size} pulled, ${report.pushed.size} pushed, ${report.deletedHere.size + report.deletedFromTeam.size} deleted, ${report.conflicts.length} conflicts, ${report.skipped.size} skipped.`,
|
|
3364
|
+
false
|
|
3365
|
+
)
|
|
3366
|
+
);
|
|
3367
|
+
return 0;
|
|
3368
|
+
}
|
|
3369
|
+
} catch (e) {
|
|
3370
|
+
out(e.message);
|
|
3371
|
+
return 1;
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
// src/tools/team-setup.ts
|
|
3376
|
+
var LAYOUT = {
|
|
3377
|
+
hooks: { files: ["hooks/hooks.json"], folders: [{ dir: "scripts", md: false }], required: "hooks/hooks.json" },
|
|
3378
|
+
mcp: { files: [".mcp.json"], folders: [], required: ".mcp.json" },
|
|
3379
|
+
agents: { files: [], folders: [{ dir: "agents", md: true }] },
|
|
3380
|
+
commands: { files: [], folders: [{ dir: "commands", md: true }] },
|
|
3381
|
+
instructions: { files: ["CLAUDE.md"], folders: [], required: "CLAUDE.md" }
|
|
3382
|
+
};
|
|
3383
|
+
function expandHome2(path) {
|
|
3384
|
+
return path.replace(/^~(?=$|[\\/])/, homedir4());
|
|
3385
|
+
}
|
|
3386
|
+
function text2(t) {
|
|
3387
|
+
return { content: [{ type: "text", text: t }] };
|
|
3388
|
+
}
|
|
3389
|
+
async function isLink(path) {
|
|
3390
|
+
try {
|
|
3391
|
+
return (await lstat2(path)).isSymbolicLink();
|
|
3392
|
+
} catch (e) {
|
|
3393
|
+
if (e.code === "ENOENT") return false;
|
|
3394
|
+
throw e;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
async function readPartDirectory(part, directory) {
|
|
3398
|
+
const root = resolve4(directory);
|
|
3399
|
+
const layout = LAYOUT[part];
|
|
3400
|
+
const paths = [];
|
|
3401
|
+
const leftOut = [];
|
|
3402
|
+
const links = [];
|
|
3403
|
+
for (const path of layout.files) {
|
|
3404
|
+
const segments = path.split("/");
|
|
3405
|
+
let present = true;
|
|
3406
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
3407
|
+
const rel = segments.slice(0, i).join("/");
|
|
3408
|
+
if (await isLink(join5(root, ...segments.slice(0, i)))) {
|
|
3409
|
+
links.push(rel);
|
|
3410
|
+
present = false;
|
|
3411
|
+
break;
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
if (present && existsSync4(join5(root, ...segments))) paths.push(path);
|
|
3415
|
+
}
|
|
3416
|
+
for (const { dir, md } of layout.folders) {
|
|
3417
|
+
const full = join5(root, dir);
|
|
3418
|
+
if (await isLink(full)) {
|
|
3419
|
+
links.push(dir);
|
|
3420
|
+
continue;
|
|
3421
|
+
}
|
|
3422
|
+
if (!existsSync4(full)) continue;
|
|
3423
|
+
const walked = await walk(full, { skipHidden: true });
|
|
3424
|
+
links.push(...walked.links.map((p) => `${dir}/${p}`));
|
|
3425
|
+
leftOut.push(...walked.hidden.map((p) => `${dir}/${p}`));
|
|
3426
|
+
for (const p of walked.files) {
|
|
3427
|
+
if (md && !p.endsWith(".md")) leftOut.push(`${dir}/${p}`);
|
|
3428
|
+
else paths.push(`${dir}/${p}`);
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
if (links.length) {
|
|
3432
|
+
throw new Error(
|
|
3433
|
+
`${links.join(", ")}: links are not followed; copy the file into the folder. Nothing was sent.`
|
|
3434
|
+
);
|
|
3435
|
+
}
|
|
3436
|
+
if (layout.required && !paths.includes(layout.required)) {
|
|
3437
|
+
throw new Error(`${root} has no ${layout.required}: the ${part} part needs it. Nothing was sent.`);
|
|
3438
|
+
}
|
|
3439
|
+
if (!paths.length) {
|
|
3440
|
+
throw new Error(
|
|
3441
|
+
`${root} has no ${layout.folders.map((f) => `${f.dir}/*.md`).join(" or ")}: nothing to publish for ${part}.`
|
|
3442
|
+
);
|
|
3443
|
+
}
|
|
3444
|
+
return { files: await readTextFiles(root, paths.sort(), `the ${part} part`), leftOut };
|
|
3445
|
+
}
|
|
3446
|
+
function describePerson(id, person) {
|
|
3447
|
+
const lines = [`**${person.label ?? "(no label)"}**, \`${id}\``];
|
|
3448
|
+
if (person.mergedInto) {
|
|
3449
|
+
lines.push(`\`${id}\` was merged into \`${person.mergedInto}\`: these are \`${person.mergedInto}\`'s details.`);
|
|
3450
|
+
}
|
|
3451
|
+
lines.push(`- Name: ${person.name ?? "(none held)"}`);
|
|
3452
|
+
lines.push(`- Phone number: ${person.phone ?? "(none held)"}`);
|
|
3453
|
+
lines.push(`- Email address: ${person.email ?? "(none held)"}`);
|
|
3454
|
+
const token = `\`${person.mergedInto ?? id}\`${person.label ? ` (${person.label})` : ""}`;
|
|
3455
|
+
lines.push(
|
|
3456
|
+
"",
|
|
3457
|
+
`These details are for this session only. Do not write them into a memory file, a note or a commit: a memory names this person by the identifier, as ${token}. A memory file that holds the details is offered for rewriting again at the next sync.`
|
|
3458
|
+
);
|
|
3459
|
+
return lines.join("\n");
|
|
3460
|
+
}
|
|
3461
|
+
function registerTeamSetupTools(server, apiClient, { localFiles }) {
|
|
3462
|
+
const fileSchema = z16.object({
|
|
3463
|
+
path: z16.string().describe(`Path inside the part's folder, forward slashes, e.g. "hooks/hooks.json"`),
|
|
3464
|
+
content: z16.string().describe("The file's full text, exactly as it is")
|
|
3465
|
+
});
|
|
3466
|
+
server.registerTool(
|
|
3467
|
+
"publish_setup_part",
|
|
3468
|
+
{
|
|
3469
|
+
title: "Publish Setup Part",
|
|
3470
|
+
description: "Publish one part of your team's Claude Code setup to SystemDox: `hooks` (hooks/hooks.json and the scripts/ it calls), `mcp` (.mcp.json), `agents` (agents/*.md), `commands` (commands/*.md) or `instructions` (CLAUDE.md). Every part but instructions makes a new version of the team plugin, which each member accepts at their next update. Publishing hooks, mcp, agents or commands needs the admin role. " + (localFiles ? "Pass `directory`: the server reads only the files the part accepts, byte for byte, and refuses a link, a binary file or a file too large before anything is sent. Never retype the files into `files` when the folder is on this computer." : "Pass every file of the part in `files`, exactly as it is."),
|
|
3471
|
+
inputSchema: {
|
|
3472
|
+
part: z16.enum(PARTS2).describe("Which part of the setup"),
|
|
3473
|
+
...localFiles ? {
|
|
3474
|
+
directory: z16.string().optional().describe(
|
|
3475
|
+
"The folder on this computer: for hooks, the one holding hooks/hooks.json and scripts/; for mcp, the one holding .mcp.json; for instructions, the one holding CLAUDE.md; for agents or commands, the one holding agents/ or commands/"
|
|
3476
|
+
)
|
|
3477
|
+
} : {},
|
|
3478
|
+
files: z16.array(fileSchema).optional().describe(localFiles ? "Only when there is no folder to read" : "Every file of the part")
|
|
3479
|
+
},
|
|
3480
|
+
annotations: { readOnlyHint: false }
|
|
3481
|
+
},
|
|
3482
|
+
async (args) => {
|
|
3483
|
+
let files;
|
|
3484
|
+
let leftOut = [];
|
|
3485
|
+
if (localFiles && args.directory) {
|
|
3486
|
+
({ files, leftOut } = await readPartDirectory(args.part, expandHome2(args.directory)));
|
|
3487
|
+
} else if (args.files?.length) {
|
|
3488
|
+
files = args.files;
|
|
3489
|
+
} else {
|
|
3490
|
+
throw new Error(localFiles ? "Pass `directory` (or `files`)." : "Pass `files`.");
|
|
3491
|
+
}
|
|
3492
|
+
const { workspace } = await apiClient.getOwnTeamSetup();
|
|
3493
|
+
const saved = await apiClient.publishPartVersion(workspace, args.part, files);
|
|
3494
|
+
const lines = [`Published **${saved.part}** version ${saved.version}.`];
|
|
3495
|
+
if (saved.part !== "instructions" && saved.pluginVersion !== void 0) {
|
|
3496
|
+
lines.push(`Team plugin version ${saved.pluginVersion}; each member accepts it at their next update.`);
|
|
3497
|
+
}
|
|
3498
|
+
if (saved.replacedValues?.length) {
|
|
3499
|
+
lines.push(
|
|
3500
|
+
"Replaced by a reference to each member's own environment variable:",
|
|
3501
|
+
...saved.replacedValues.map((r) => `- ${r.server} ${r.field} ${r.name}: \${${r.name}}`)
|
|
3502
|
+
);
|
|
3503
|
+
}
|
|
3504
|
+
if (leftOut.length) lines.push(`Left out (not part of ${args.part}): ${leftOut.join(", ")}`);
|
|
3505
|
+
return text2(lines.join("\n"));
|
|
3506
|
+
}
|
|
3507
|
+
);
|
|
3508
|
+
server.registerTool(
|
|
3509
|
+
"resolve_person",
|
|
3510
|
+
{
|
|
3511
|
+
title: "Resolve Person",
|
|
3512
|
+
description: "Read back a person that a team memory names by identifier, written as `con_\u2026` (label), `ctc_\u2026` or `usr_\u2026`: their label, name, phone number and email address, from the vault that holds them. The details are for this session only: never write them into a memory file; the memory keeps the identifier. Writes nothing.",
|
|
3513
|
+
inputSchema: {
|
|
3514
|
+
id: z16.string().regex(PERSON_ID_RE, "a con_, ctc_ or usr_ identifier, as written in the memory").describe("The identifier, e.g. con_01JC2Q8Y4M7T0X3K5N9P2R6S8V")
|
|
3515
|
+
},
|
|
3516
|
+
annotations: { readOnlyHint: true }
|
|
3517
|
+
},
|
|
3518
|
+
async (args) => {
|
|
3519
|
+
const { workspace } = await apiClient.getOwnTeamSetup();
|
|
3520
|
+
const person = await apiClient.resolvePerson(workspace, args.id);
|
|
3521
|
+
return text2(describePerson(args.id, person));
|
|
3522
|
+
}
|
|
3523
|
+
);
|
|
3524
|
+
if (!localFiles) return;
|
|
3525
|
+
server.registerTool(
|
|
3526
|
+
"sync_memory",
|
|
3527
|
+
{
|
|
3528
|
+
title: "Sync Memory",
|
|
3529
|
+
description: `Sync one project's team memory on this computer with SystemDox: pull the files the team changed, push the ones changed here, keep both texts of a file changed on both sides. It never answers for a person: a file that still holds a phone number, an email address or a home-folder path is skipped and listed, with the command for the member to run in their own terminal (\`${SYNC_MEMORY_COMMAND}\`). Pass dryRun to see what it would do without writing or sending anything.`,
|
|
3530
|
+
inputSchema: {
|
|
3531
|
+
project: z16.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "a project name: lower case letters, digits and -").optional().describe("The project's team memory, e.g. puglieseweb-ltd; needed when the workspace has more than one"),
|
|
3532
|
+
dryRun: z16.boolean().optional().describe("Show what would be pulled, pushed, deleted, conflicted and skipped, and change nothing")
|
|
3533
|
+
},
|
|
3534
|
+
annotations: { readOnlyHint: false }
|
|
3535
|
+
},
|
|
3536
|
+
async (args) => {
|
|
3537
|
+
const lines = [];
|
|
3538
|
+
const code = await syncMemory({
|
|
3539
|
+
project: args.project,
|
|
3540
|
+
dryRun: !!args.dryRun,
|
|
3541
|
+
client: apiClient,
|
|
3542
|
+
out: (...l) => lines.push(...l)
|
|
3543
|
+
});
|
|
3544
|
+
return { ...text2(lines.join("\n")), ...code ? { isError: true } : {} };
|
|
3545
|
+
}
|
|
3546
|
+
);
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
// src/server.ts
|
|
3550
|
+
var SERVER_VERSION = true ? "0.1.51" : "0.0.0-dev";
|
|
3551
|
+
function createServer(apiClient, { localFiles = false, instructions, keyProblem } = {}) {
|
|
3552
|
+
const server = new McpServer(
|
|
3553
|
+
{
|
|
3554
|
+
name: "systemdox-mcp-server",
|
|
3555
|
+
version: SERVER_VERSION
|
|
3556
|
+
},
|
|
3557
|
+
instructions ? { instructions } : void 0
|
|
3558
|
+
);
|
|
3559
|
+
registerGetGuardrails(server, apiClient);
|
|
3560
|
+
registerGetSpecs(server, apiClient);
|
|
3561
|
+
registerGetDocs(server, apiClient);
|
|
3562
|
+
registerGetTechStack(server, apiClient);
|
|
3563
|
+
registerGetPlanningContext(server, apiClient);
|
|
3564
|
+
registerGetPlanBoard(server, apiClient);
|
|
3565
|
+
registerGetChange(server, apiClient);
|
|
3566
|
+
registerGetContext(server, apiClient);
|
|
3567
|
+
registerGetProjectRules(server, apiClient);
|
|
3568
|
+
registerGetPromptTemplates(server, apiClient);
|
|
3569
|
+
registerGetTeamSetup(server, apiClient, { localFiles, keyProblem });
|
|
3570
|
+
registerDocumentGraph(server, apiClient);
|
|
3571
|
+
registerCreateGuardrail(server, apiClient);
|
|
3572
|
+
registerUpdateSpec(server, apiClient);
|
|
3573
|
+
registerReportDecision(server, apiClient);
|
|
3574
|
+
registerCreateProjectRule(server, apiClient);
|
|
3575
|
+
registerCreatePromptTemplate(server, apiClient);
|
|
3576
|
+
registerSkillTools(server, apiClient, { localFiles });
|
|
3577
|
+
registerTeamSetupTools(server, apiClient, { localFiles });
|
|
3578
|
+
return server;
|
|
3579
|
+
}
|
|
3580
|
+
export {
|
|
3581
|
+
createServer
|
|
3582
|
+
};
|