speclock 1.1.0
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/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/speclock.js +2 -0
- package/package.json +59 -0
- package/src/cli/index.js +285 -0
- package/src/core/context.js +201 -0
- package/src/core/engine.js +698 -0
- package/src/core/git.js +110 -0
- package/src/core/storage.js +186 -0
- package/src/mcp/server.js +730 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import {
|
|
6
|
+
ensureInit,
|
|
7
|
+
setGoal,
|
|
8
|
+
addLock,
|
|
9
|
+
removeLock,
|
|
10
|
+
addDecision,
|
|
11
|
+
addNote,
|
|
12
|
+
updateDeployFacts,
|
|
13
|
+
logChange,
|
|
14
|
+
checkConflict,
|
|
15
|
+
getSessionBriefing,
|
|
16
|
+
endSession,
|
|
17
|
+
suggestLocks,
|
|
18
|
+
detectDrift,
|
|
19
|
+
} from "../core/engine.js";
|
|
20
|
+
import { generateContext, generateContextPack } from "../core/context.js";
|
|
21
|
+
import {
|
|
22
|
+
readBrain,
|
|
23
|
+
readEvents,
|
|
24
|
+
newId,
|
|
25
|
+
nowIso,
|
|
26
|
+
appendEvent,
|
|
27
|
+
bumpEvents,
|
|
28
|
+
writeBrain,
|
|
29
|
+
} from "../core/storage.js";
|
|
30
|
+
import {
|
|
31
|
+
captureStatus,
|
|
32
|
+
createTag,
|
|
33
|
+
getDiffSummary,
|
|
34
|
+
} from "../core/git.js";
|
|
35
|
+
|
|
36
|
+
// --- Project root resolution ---
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const args = { project: null };
|
|
39
|
+
for (let i = 2; i < argv.length; i++) {
|
|
40
|
+
if (argv[i] === "--project" && argv[i + 1]) {
|
|
41
|
+
args.project = argv[i + 1];
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const args = parseArgs(process.argv);
|
|
49
|
+
const PROJECT_ROOT =
|
|
50
|
+
args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
51
|
+
|
|
52
|
+
// --- MCP Server ---
|
|
53
|
+
const VERSION = "1.1.0";
|
|
54
|
+
const AUTHOR = "Sandeep Roy";
|
|
55
|
+
|
|
56
|
+
const server = new McpServer(
|
|
57
|
+
{ name: "speclock", version: VERSION },
|
|
58
|
+
{
|
|
59
|
+
instructions:
|
|
60
|
+
`SpecLock is an AI continuity engine. Developed by ${AUTHOR}. Call speclock_session_briefing at the start of a new session, and speclock_session_summary before ending. Use speclock_get_context to refresh your project understanding at any time.`,
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// ========================================
|
|
65
|
+
// MEMORY MANAGEMENT TOOLS
|
|
66
|
+
// ========================================
|
|
67
|
+
|
|
68
|
+
// Tool 1: speclock_init
|
|
69
|
+
server.tool(
|
|
70
|
+
"speclock_init",
|
|
71
|
+
"Initialize SpecLock in the current project directory. Creates .speclock/ with brain.json, events.log, and supporting directories.",
|
|
72
|
+
{},
|
|
73
|
+
async () => {
|
|
74
|
+
const brain = ensureInit(PROJECT_ROOT);
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: "text",
|
|
79
|
+
text: `SpecLock initialized for "${brain.project.name}" at ${brain.project.root}`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Tool 2: speclock_get_context — THE KEY TOOL
|
|
87
|
+
server.tool(
|
|
88
|
+
"speclock_get_context",
|
|
89
|
+
"THE KEY TOOL. Returns the full structured context pack including goal, locks, decisions, recent changes, deploy facts, reverts, session history, and notes. Call this at the start of every session or whenever you need to refresh your understanding of the project.",
|
|
90
|
+
{
|
|
91
|
+
format: z
|
|
92
|
+
.enum(["markdown", "json"])
|
|
93
|
+
.optional()
|
|
94
|
+
.default("markdown")
|
|
95
|
+
.describe("Output format: markdown (readable) or json (structured)"),
|
|
96
|
+
},
|
|
97
|
+
async ({ format }) => {
|
|
98
|
+
if (format === "json") {
|
|
99
|
+
const pack = generateContextPack(PROJECT_ROOT);
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: "text", text: JSON.stringify(pack, null, 2) }],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const md = generateContext(PROJECT_ROOT);
|
|
105
|
+
return { content: [{ type: "text", text: md }] };
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Tool 3: speclock_set_goal
|
|
110
|
+
server.tool(
|
|
111
|
+
"speclock_set_goal",
|
|
112
|
+
"Set or update the project goal. This is the high-level objective that guides all work.",
|
|
113
|
+
{
|
|
114
|
+
text: z.string().min(1).describe("The project goal text"),
|
|
115
|
+
},
|
|
116
|
+
async ({ text }) => {
|
|
117
|
+
setGoal(PROJECT_ROOT, text);
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `Goal set: "${text}"` }],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Tool 4: speclock_add_lock
|
|
125
|
+
server.tool(
|
|
126
|
+
"speclock_add_lock",
|
|
127
|
+
"Add a non-negotiable constraint (SpecLock). These are rules that must NEVER be violated during development.",
|
|
128
|
+
{
|
|
129
|
+
text: z.string().min(1).describe("The constraint text"),
|
|
130
|
+
tags: z
|
|
131
|
+
.array(z.string())
|
|
132
|
+
.optional()
|
|
133
|
+
.default([])
|
|
134
|
+
.describe("Category tags"),
|
|
135
|
+
source: z
|
|
136
|
+
.enum(["user", "agent"])
|
|
137
|
+
.optional()
|
|
138
|
+
.default("agent")
|
|
139
|
+
.describe("Who created this lock"),
|
|
140
|
+
},
|
|
141
|
+
async ({ text, tags, source }) => {
|
|
142
|
+
const { lockId } = addLock(PROJECT_ROOT, text, tags, source);
|
|
143
|
+
return {
|
|
144
|
+
content: [
|
|
145
|
+
{ type: "text", text: `Lock added (${lockId}): "${text}"` },
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Tool 5: speclock_remove_lock
|
|
152
|
+
server.tool(
|
|
153
|
+
"speclock_remove_lock",
|
|
154
|
+
"Remove (deactivate) a SpecLock by its ID. The lock is soft-deleted and kept in history.",
|
|
155
|
+
{
|
|
156
|
+
lockId: z.string().min(1).describe("The lock ID to remove"),
|
|
157
|
+
},
|
|
158
|
+
async ({ lockId }) => {
|
|
159
|
+
const result = removeLock(PROJECT_ROOT, lockId);
|
|
160
|
+
if (!result.removed) {
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: result.error }],
|
|
163
|
+
isError: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
content: [
|
|
168
|
+
{
|
|
169
|
+
type: "text",
|
|
170
|
+
text: `Lock removed: "${result.lockText}"`,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Tool 6: speclock_add_decision
|
|
178
|
+
server.tool(
|
|
179
|
+
"speclock_add_decision",
|
|
180
|
+
"Record an architectural or design decision. Decisions guide future work and prevent contradictory changes.",
|
|
181
|
+
{
|
|
182
|
+
text: z.string().min(1).describe("The decision text"),
|
|
183
|
+
tags: z.array(z.string()).optional().default([]),
|
|
184
|
+
source: z
|
|
185
|
+
.enum(["user", "agent"])
|
|
186
|
+
.optional()
|
|
187
|
+
.default("agent"),
|
|
188
|
+
},
|
|
189
|
+
async ({ text, tags, source }) => {
|
|
190
|
+
const { decId } = addDecision(PROJECT_ROOT, text, tags, source);
|
|
191
|
+
return {
|
|
192
|
+
content: [
|
|
193
|
+
{ type: "text", text: `Decision recorded (${decId}): "${text}"` },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Tool 7: speclock_add_note
|
|
200
|
+
server.tool(
|
|
201
|
+
"speclock_add_note",
|
|
202
|
+
"Add a pinned note for reference. Notes persist across sessions as reminders.",
|
|
203
|
+
{
|
|
204
|
+
text: z.string().min(1).describe("The note text"),
|
|
205
|
+
pinned: z
|
|
206
|
+
.boolean()
|
|
207
|
+
.optional()
|
|
208
|
+
.default(true)
|
|
209
|
+
.describe("Whether to pin this note in context"),
|
|
210
|
+
},
|
|
211
|
+
async ({ text, pinned }) => {
|
|
212
|
+
const { noteId } = addNote(PROJECT_ROOT, text, pinned);
|
|
213
|
+
return {
|
|
214
|
+
content: [
|
|
215
|
+
{ type: "text", text: `Note added (${noteId}): "${text}"` },
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Tool 8: speclock_set_deploy_facts
|
|
222
|
+
server.tool(
|
|
223
|
+
"speclock_set_deploy_facts",
|
|
224
|
+
"Record deployment configuration facts (provider, branch, auto-deploy settings).",
|
|
225
|
+
{
|
|
226
|
+
provider: z
|
|
227
|
+
.string()
|
|
228
|
+
.optional()
|
|
229
|
+
.describe("Deploy provider (vercel, railway, aws, netlify, etc.)"),
|
|
230
|
+
branch: z.string().optional().describe("Deploy branch"),
|
|
231
|
+
autoDeploy: z
|
|
232
|
+
.boolean()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Whether auto-deploy is enabled"),
|
|
235
|
+
url: z.string().optional().describe("Deployment URL"),
|
|
236
|
+
notes: z.string().optional().describe("Additional deploy notes"),
|
|
237
|
+
},
|
|
238
|
+
async (params) => {
|
|
239
|
+
updateDeployFacts(PROJECT_ROOT, params);
|
|
240
|
+
return {
|
|
241
|
+
content: [{ type: "text", text: "Deploy facts updated." }],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// ========================================
|
|
247
|
+
// CHANGE TRACKING TOOLS
|
|
248
|
+
// ========================================
|
|
249
|
+
|
|
250
|
+
// Tool 9: speclock_log_change
|
|
251
|
+
server.tool(
|
|
252
|
+
"speclock_log_change",
|
|
253
|
+
"Manually log a significant change. Use this when you make an important modification that should be tracked in the context.",
|
|
254
|
+
{
|
|
255
|
+
summary: z
|
|
256
|
+
.string()
|
|
257
|
+
.min(1)
|
|
258
|
+
.describe("Brief description of the change"),
|
|
259
|
+
files: z
|
|
260
|
+
.array(z.string())
|
|
261
|
+
.optional()
|
|
262
|
+
.default([])
|
|
263
|
+
.describe("Files affected"),
|
|
264
|
+
},
|
|
265
|
+
async ({ summary, files }) => {
|
|
266
|
+
const { eventId } = logChange(PROJECT_ROOT, summary, files);
|
|
267
|
+
return {
|
|
268
|
+
content: [
|
|
269
|
+
{ type: "text", text: `Change logged (${eventId}): "${summary}"` },
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// Tool 10: speclock_get_changes
|
|
276
|
+
server.tool(
|
|
277
|
+
"speclock_get_changes",
|
|
278
|
+
"Get recent file changes tracked by SpecLock.",
|
|
279
|
+
{
|
|
280
|
+
limit: z
|
|
281
|
+
.number()
|
|
282
|
+
.int()
|
|
283
|
+
.min(1)
|
|
284
|
+
.max(100)
|
|
285
|
+
.optional()
|
|
286
|
+
.default(20),
|
|
287
|
+
},
|
|
288
|
+
async ({ limit }) => {
|
|
289
|
+
const brain = readBrain(PROJECT_ROOT);
|
|
290
|
+
if (!brain) {
|
|
291
|
+
return {
|
|
292
|
+
content: [
|
|
293
|
+
{
|
|
294
|
+
type: "text",
|
|
295
|
+
text: "SpecLock not initialized. Run speclock_init first.",
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
isError: true,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const changes = brain.state.recentChanges.slice(0, limit);
|
|
302
|
+
if (changes.length === 0) {
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: "text", text: "No changes tracked yet." }],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const formatted = changes
|
|
308
|
+
.map((ch) => {
|
|
309
|
+
const files =
|
|
310
|
+
ch.files && ch.files.length > 0
|
|
311
|
+
? ` (${ch.files.join(", ")})`
|
|
312
|
+
: "";
|
|
313
|
+
return `- [${ch.at.substring(0, 19)}] ${ch.summary}${files}`;
|
|
314
|
+
})
|
|
315
|
+
.join("\n");
|
|
316
|
+
return {
|
|
317
|
+
content: [
|
|
318
|
+
{ type: "text", text: `Recent changes (${changes.length}):\n${formatted}` },
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Tool 11: speclock_get_events
|
|
325
|
+
server.tool(
|
|
326
|
+
"speclock_get_events",
|
|
327
|
+
"Get the event log, optionally filtered by type. Event types: init, goal_updated, lock_added, lock_removed, decision_added, note_added, fact_updated, file_created, file_changed, file_deleted, revert_detected, context_generated, session_started, session_ended, manual_change, checkpoint_created.",
|
|
328
|
+
{
|
|
329
|
+
type: z.string().optional().describe("Filter by event type"),
|
|
330
|
+
limit: z
|
|
331
|
+
.number()
|
|
332
|
+
.int()
|
|
333
|
+
.min(1)
|
|
334
|
+
.max(200)
|
|
335
|
+
.optional()
|
|
336
|
+
.default(50),
|
|
337
|
+
since: z
|
|
338
|
+
.string()
|
|
339
|
+
.optional()
|
|
340
|
+
.describe("ISO timestamp; only return events after this time"),
|
|
341
|
+
},
|
|
342
|
+
async ({ type, limit, since }) => {
|
|
343
|
+
const events = readEvents(PROJECT_ROOT, { type, limit, since });
|
|
344
|
+
if (events.length === 0) {
|
|
345
|
+
return {
|
|
346
|
+
content: [{ type: "text", text: "No events found." }],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const formatted = events
|
|
350
|
+
.map(
|
|
351
|
+
(e) =>
|
|
352
|
+
`- [${e.at.substring(0, 19)}] ${e.type}: ${e.summary || ""}`
|
|
353
|
+
)
|
|
354
|
+
.join("\n");
|
|
355
|
+
return {
|
|
356
|
+
content: [
|
|
357
|
+
{
|
|
358
|
+
type: "text",
|
|
359
|
+
text: `Events (${events.length}):\n${formatted}`,
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// ========================================
|
|
367
|
+
// CONTINUITY PROTECTION TOOLS
|
|
368
|
+
// ========================================
|
|
369
|
+
|
|
370
|
+
// Tool 12: speclock_check_conflict
|
|
371
|
+
server.tool(
|
|
372
|
+
"speclock_check_conflict",
|
|
373
|
+
"Check if a proposed action conflicts with any active SpecLock. Use before making significant changes.",
|
|
374
|
+
{
|
|
375
|
+
proposedAction: z
|
|
376
|
+
.string()
|
|
377
|
+
.min(1)
|
|
378
|
+
.describe("Description of the action you plan to take"),
|
|
379
|
+
},
|
|
380
|
+
async ({ proposedAction }) => {
|
|
381
|
+
const result = checkConflict(PROJECT_ROOT, proposedAction);
|
|
382
|
+
return {
|
|
383
|
+
content: [{ type: "text", text: result.analysis }],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Tool 13: speclock_session_briefing
|
|
389
|
+
server.tool(
|
|
390
|
+
"speclock_session_briefing",
|
|
391
|
+
"Start a new session and get a full briefing. Returns context pack plus what happened since the last session. Call this at the very beginning of a new conversation.",
|
|
392
|
+
{
|
|
393
|
+
toolName: z
|
|
394
|
+
.enum(["claude-code", "cursor", "codex", "windsurf", "cline", "unknown"])
|
|
395
|
+
.optional()
|
|
396
|
+
.default("unknown")
|
|
397
|
+
.describe("Which AI tool is being used"),
|
|
398
|
+
},
|
|
399
|
+
async ({ toolName }) => {
|
|
400
|
+
const briefing = getSessionBriefing(PROJECT_ROOT, toolName);
|
|
401
|
+
const contextMd = generateContext(PROJECT_ROOT);
|
|
402
|
+
|
|
403
|
+
const parts = [];
|
|
404
|
+
|
|
405
|
+
// Session info
|
|
406
|
+
parts.push(`# SpecLock Session Briefing`);
|
|
407
|
+
parts.push(`Session started (${toolName}). ID: ${briefing.session.id}`);
|
|
408
|
+
parts.push("");
|
|
409
|
+
|
|
410
|
+
// Last session summary
|
|
411
|
+
if (briefing.lastSession) {
|
|
412
|
+
parts.push("## Last Session");
|
|
413
|
+
parts.push(`- Tool: **${briefing.lastSession.toolUsed}**`);
|
|
414
|
+
parts.push(`- Ended: ${briefing.lastSession.endedAt || "unknown"}`);
|
|
415
|
+
if (briefing.lastSession.summary)
|
|
416
|
+
parts.push(`- Summary: ${briefing.lastSession.summary}`);
|
|
417
|
+
parts.push(
|
|
418
|
+
`- Events: ${briefing.lastSession.eventsInSession || 0}`
|
|
419
|
+
);
|
|
420
|
+
parts.push(
|
|
421
|
+
`- Changes since then: ${briefing.changesSinceLastSession}`
|
|
422
|
+
);
|
|
423
|
+
parts.push("");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Warnings
|
|
427
|
+
if (briefing.warnings.length > 0) {
|
|
428
|
+
parts.push("## ⚠ Warnings");
|
|
429
|
+
for (const w of briefing.warnings) {
|
|
430
|
+
parts.push(`- ${w}`);
|
|
431
|
+
}
|
|
432
|
+
parts.push("");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Full context
|
|
436
|
+
parts.push("---");
|
|
437
|
+
parts.push(contextMd);
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
content: [{ type: "text", text: parts.join("\n") }],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// Tool 14: speclock_session_summary
|
|
446
|
+
server.tool(
|
|
447
|
+
"speclock_session_summary",
|
|
448
|
+
"End the current session and record what was accomplished. Call this before ending a conversation.",
|
|
449
|
+
{
|
|
450
|
+
summary: z
|
|
451
|
+
.string()
|
|
452
|
+
.min(1)
|
|
453
|
+
.describe("Summary of what was accomplished in this session"),
|
|
454
|
+
},
|
|
455
|
+
async ({ summary }) => {
|
|
456
|
+
const result = endSession(PROJECT_ROOT, summary);
|
|
457
|
+
if (!result.ended) {
|
|
458
|
+
return {
|
|
459
|
+
content: [{ type: "text", text: result.error }],
|
|
460
|
+
isError: true,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const session = result.session;
|
|
464
|
+
const duration =
|
|
465
|
+
session.startedAt && session.endedAt
|
|
466
|
+
? Math.round(
|
|
467
|
+
(new Date(session.endedAt) - new Date(session.startedAt)) /
|
|
468
|
+
60000
|
|
469
|
+
)
|
|
470
|
+
: 0;
|
|
471
|
+
return {
|
|
472
|
+
content: [
|
|
473
|
+
{
|
|
474
|
+
type: "text",
|
|
475
|
+
text: `Session ended. Duration: ${duration} min. Events: ${session.eventsInSession}. Summary: "${summary}"`,
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// ========================================
|
|
483
|
+
// GIT INTEGRATION TOOLS
|
|
484
|
+
// ========================================
|
|
485
|
+
|
|
486
|
+
// Tool 15: speclock_checkpoint
|
|
487
|
+
server.tool(
|
|
488
|
+
"speclock_checkpoint",
|
|
489
|
+
"Create a named git tag checkpoint for easy rollback.",
|
|
490
|
+
{
|
|
491
|
+
name: z
|
|
492
|
+
.string()
|
|
493
|
+
.min(1)
|
|
494
|
+
.describe("Checkpoint name (alphanumeric, hyphens, underscores)"),
|
|
495
|
+
},
|
|
496
|
+
async ({ name }) => {
|
|
497
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
498
|
+
const tag = `sl_${safeName}_${Date.now()}`;
|
|
499
|
+
const result = createTag(PROJECT_ROOT, tag);
|
|
500
|
+
|
|
501
|
+
if (!result.ok) {
|
|
502
|
+
return {
|
|
503
|
+
content: [
|
|
504
|
+
{
|
|
505
|
+
type: "text",
|
|
506
|
+
text: `Failed to create checkpoint: ${result.error}`,
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
isError: true,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Record event
|
|
514
|
+
const brain = readBrain(PROJECT_ROOT);
|
|
515
|
+
if (brain) {
|
|
516
|
+
const eventId = newId("evt");
|
|
517
|
+
const event = {
|
|
518
|
+
eventId,
|
|
519
|
+
type: "checkpoint_created",
|
|
520
|
+
at: nowIso(),
|
|
521
|
+
files: [],
|
|
522
|
+
summary: `Checkpoint: ${tag}`,
|
|
523
|
+
patchPath: "",
|
|
524
|
+
};
|
|
525
|
+
bumpEvents(brain, eventId);
|
|
526
|
+
appendEvent(PROJECT_ROOT, event);
|
|
527
|
+
writeBrain(PROJECT_ROOT, brain);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
content: [
|
|
532
|
+
{ type: "text", text: `Checkpoint created: ${tag}` },
|
|
533
|
+
],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// Tool 16: speclock_repo_status
|
|
539
|
+
server.tool(
|
|
540
|
+
"speclock_repo_status",
|
|
541
|
+
"Get current git repository status including branch, commit, changed files, and diff summary.",
|
|
542
|
+
{},
|
|
543
|
+
async () => {
|
|
544
|
+
const status = captureStatus(PROJECT_ROOT);
|
|
545
|
+
const diffSummary = getDiffSummary(PROJECT_ROOT);
|
|
546
|
+
|
|
547
|
+
const lines = [
|
|
548
|
+
`Branch: ${status.branch}`,
|
|
549
|
+
`Commit: ${status.commit}`,
|
|
550
|
+
"",
|
|
551
|
+
`Changed files (${status.changedFiles.length}):`,
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
if (status.changedFiles.length > 0) {
|
|
555
|
+
for (const f of status.changedFiles) {
|
|
556
|
+
lines.push(` ${f.status} ${f.file}`);
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
lines.push(" (clean working tree)");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (diffSummary) {
|
|
563
|
+
lines.push("");
|
|
564
|
+
lines.push("Diff summary:");
|
|
565
|
+
lines.push(diffSummary);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
// ========================================
|
|
575
|
+
// INTELLIGENCE TOOLS
|
|
576
|
+
// ========================================
|
|
577
|
+
|
|
578
|
+
// Tool 17: speclock_suggest_locks
|
|
579
|
+
server.tool(
|
|
580
|
+
"speclock_suggest_locks",
|
|
581
|
+
"Analyze project decisions, notes, and patterns to suggest new SpecLock constraints. Returns auto-generated lock suggestions based on commitment language, prohibitive patterns, and common best practices.",
|
|
582
|
+
{},
|
|
583
|
+
async () => {
|
|
584
|
+
const result = suggestLocks(PROJECT_ROOT);
|
|
585
|
+
|
|
586
|
+
if (result.suggestions.length === 0) {
|
|
587
|
+
return {
|
|
588
|
+
content: [
|
|
589
|
+
{
|
|
590
|
+
type: "text",
|
|
591
|
+
text: `No suggestions at this time. You have ${result.totalLocks} active lock(s). Add more decisions and notes to get AI-powered lock suggestions.`,
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const formatted = result.suggestions
|
|
598
|
+
.map(
|
|
599
|
+
(s, i) =>
|
|
600
|
+
`${i + 1}. **"${s.text}"**\n Source: ${s.source}${s.sourceId ? ` (${s.sourceId})` : ""}\n Reason: ${s.reason}`
|
|
601
|
+
)
|
|
602
|
+
.join("\n\n");
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
content: [
|
|
606
|
+
{
|
|
607
|
+
type: "text",
|
|
608
|
+
text: `## Lock Suggestions (${result.suggestions.length})\n\nCurrent active locks: ${result.totalLocks}\n\n${formatted}\n\nTo add any suggestion as a lock, call \`speclock_add_lock\` with the text.`,
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// Tool 18: speclock_detect_drift
|
|
616
|
+
server.tool(
|
|
617
|
+
"speclock_detect_drift",
|
|
618
|
+
"Scan recent changes and events against active SpecLock constraints to detect potential violations or drift. Use this proactively to ensure project integrity.",
|
|
619
|
+
{},
|
|
620
|
+
async () => {
|
|
621
|
+
const result = detectDrift(PROJECT_ROOT);
|
|
622
|
+
|
|
623
|
+
if (result.status === "no_locks") {
|
|
624
|
+
return {
|
|
625
|
+
content: [{ type: "text", text: result.message }],
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (result.status === "clean") {
|
|
630
|
+
return {
|
|
631
|
+
content: [{ type: "text", text: result.message }],
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const formatted = result.drifts
|
|
636
|
+
.map(
|
|
637
|
+
(d) =>
|
|
638
|
+
`- [${d.severity.toUpperCase()}] Change: "${d.changeSummary}" (${d.changeAt.substring(0, 19)})\n Lock: "${d.lockText}"\n Matched: ${d.matchedTerms.join(", ")}`
|
|
639
|
+
)
|
|
640
|
+
.join("\n\n");
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
content: [
|
|
644
|
+
{
|
|
645
|
+
type: "text",
|
|
646
|
+
text: `## Drift Report\n\n${result.message}\n\n${formatted}\n\nReview each drift and take corrective action if needed.`,
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// Tool 19: speclock_health
|
|
654
|
+
server.tool(
|
|
655
|
+
"speclock_health",
|
|
656
|
+
"Get a health check of the SpecLock setup including completeness score, missing recommended items, and multi-agent session timeline.",
|
|
657
|
+
{},
|
|
658
|
+
async () => {
|
|
659
|
+
const brain = ensureInit(PROJECT_ROOT);
|
|
660
|
+
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
661
|
+
|
|
662
|
+
// Calculate health score
|
|
663
|
+
let score = 0;
|
|
664
|
+
const checks = [];
|
|
665
|
+
|
|
666
|
+
if (brain.goal.text) { score += 20; checks.push("[PASS] Goal is set"); }
|
|
667
|
+
else checks.push("[MISS] No project goal set");
|
|
668
|
+
|
|
669
|
+
if (activeLocks.length > 0) { score += 25; checks.push(`[PASS] ${activeLocks.length} active lock(s)`); }
|
|
670
|
+
else checks.push("[MISS] No SpecLock constraints defined");
|
|
671
|
+
|
|
672
|
+
if (brain.decisions.length > 0) { score += 15; checks.push(`[PASS] ${brain.decisions.length} decision(s) recorded`); }
|
|
673
|
+
else checks.push("[MISS] No decisions recorded");
|
|
674
|
+
|
|
675
|
+
if (brain.notes.length > 0) { score += 10; checks.push(`[PASS] ${brain.notes.length} note(s)`); }
|
|
676
|
+
else checks.push("[MISS] No notes added");
|
|
677
|
+
|
|
678
|
+
if (brain.sessions.history.length > 0) { score += 15; checks.push(`[PASS] ${brain.sessions.history.length} session(s) in history`); }
|
|
679
|
+
else checks.push("[MISS] No session history yet");
|
|
680
|
+
|
|
681
|
+
if (brain.state.recentChanges.length > 0) { score += 10; checks.push(`[PASS] ${brain.state.recentChanges.length} change(s) tracked`); }
|
|
682
|
+
else checks.push("[MISS] No changes tracked");
|
|
683
|
+
|
|
684
|
+
if (brain.facts.deploy.provider !== "unknown") { score += 5; checks.push("[PASS] Deploy facts configured"); }
|
|
685
|
+
else checks.push("[MISS] Deploy facts not configured");
|
|
686
|
+
|
|
687
|
+
// Multi-agent timeline
|
|
688
|
+
const agentMap = {};
|
|
689
|
+
for (const session of brain.sessions.history) {
|
|
690
|
+
const tool = session.toolUsed || "unknown";
|
|
691
|
+
if (!agentMap[tool]) agentMap[tool] = { count: 0, lastUsed: "", summaries: [] };
|
|
692
|
+
agentMap[tool].count++;
|
|
693
|
+
if (!agentMap[tool].lastUsed || session.endedAt > agentMap[tool].lastUsed) {
|
|
694
|
+
agentMap[tool].lastUsed = session.endedAt || session.startedAt;
|
|
695
|
+
}
|
|
696
|
+
if (session.summary && agentMap[tool].summaries.length < 3) {
|
|
697
|
+
agentMap[tool].summaries.push(session.summary.substring(0, 80));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
let agentTimeline = "";
|
|
702
|
+
if (Object.keys(agentMap).length > 0) {
|
|
703
|
+
agentTimeline = "\n\n## Multi-Agent Timeline\n" +
|
|
704
|
+
Object.entries(agentMap)
|
|
705
|
+
.map(([tool, info]) =>
|
|
706
|
+
`- **${tool}**: ${info.count} session(s), last active ${info.lastUsed ? info.lastUsed.substring(0, 16) : "unknown"}\n Recent: ${info.summaries.length > 0 ? info.summaries.map(s => `"${s}"`).join(", ") : "(no summaries)"}`
|
|
707
|
+
)
|
|
708
|
+
.join("\n");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
content: [
|
|
715
|
+
{
|
|
716
|
+
type: "text",
|
|
717
|
+
text: `## SpecLock Health Check\n\nScore: **${score}/100** (Grade: ${grade})\nEvents: ${brain.events.count} | Reverts: ${brain.state.reverts.length}\n\n### Checks\n${checks.join("\n")}${agentTimeline}\n\n---\n*SpecLock v${VERSION} — Developed by ${AUTHOR}*`,
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
// --- Start server ---
|
|
725
|
+
const transport = new StdioServerTransport();
|
|
726
|
+
await server.connect(transport);
|
|
727
|
+
|
|
728
|
+
process.stderr.write(
|
|
729
|
+
`SpecLock MCP v${VERSION} running (stdio) — Developed by ${AUTHOR}. Root: ${PROJECT_ROOT}${os.EOL}`
|
|
730
|
+
);
|