speclock 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speclock",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "AI continuity engine — MCP server + CLI that kills AI amnesia. Maintains project memory, enforces constraints, and detects drift across AI coding sessions.",
5
5
  "type": "module",
6
6
  "main": "src/mcp/server.js",
@@ -55,5 +55,8 @@
55
55
  "src/",
56
56
  "README.md",
57
57
  "LICENSE"
58
- ]
58
+ ],
59
+ "devDependencies": {
60
+ "esbuild": "^0.27.3"
61
+ }
59
62
  }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * SpecLock MCP HTTP Server — for Railway / remote deployment
3
+ * Wraps the same 19 tools as the stdio server using Streamable HTTP transport.
4
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
5
+ */
6
+
7
+ import os from "os";
8
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
11
+ import { z } from "zod";
12
+ import {
13
+ ensureInit,
14
+ setGoal,
15
+ addLock,
16
+ removeLock,
17
+ addDecision,
18
+ addNote,
19
+ updateDeployFacts,
20
+ logChange,
21
+ checkConflict,
22
+ getSessionBriefing,
23
+ endSession,
24
+ suggestLocks,
25
+ detectDrift,
26
+ } from "../core/engine.js";
27
+ import { generateContext, generateContextPack } from "../core/context.js";
28
+ import {
29
+ readBrain,
30
+ readEvents,
31
+ newId,
32
+ nowIso,
33
+ appendEvent,
34
+ bumpEvents,
35
+ writeBrain,
36
+ } from "../core/storage.js";
37
+ import {
38
+ captureStatus,
39
+ createTag,
40
+ getDiffSummary,
41
+ } from "../core/git.js";
42
+
43
+ const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
44
+ const VERSION = "1.1.0";
45
+ const AUTHOR = "Sandeep Roy";
46
+
47
+ function createSpecLockServer() {
48
+ const server = new McpServer(
49
+ { name: "speclock", version: VERSION },
50
+ {
51
+ instructions: [
52
+ `You have SpecLock connected — an AI Continuity Engine by ${AUTHOR}. Follow these rules AUTOMATICALLY without the user ever having to ask:`,
53
+ ``,
54
+ `## ON EVERY NEW CONVERSATION / SESSION START:`,
55
+ `1. IMMEDIATELY call speclock_session_briefing BEFORE responding to the user's first message.`,
56
+ `2. If it returns "not initialized", call speclock_init first, then proceed normally.`,
57
+ `3. Use the briefing context to understand the project's goal, constraints, decisions, and history.`,
58
+ `4. Never ask the user to "initialize SpecLock" or "start a session" — just do it silently.`,
59
+ ``,
60
+ `## AUTOMATIC CONTEXT CAPTURE (do this silently as you work):`,
61
+ `- When the user describes what they're building → call speclock_set_goal with a concise summary.`,
62
+ `- When the user says something is non-negotiable (e.g., "always use Supabase", "never remove auth", "must support mobile") → call speclock_add_lock to save it as an immutable constraint.`,
63
+ `- When the user makes a technology/architecture choice (e.g., "let's use React Query", "we'll go with Stripe") → call speclock_add_decision.`,
64
+ `- When you complete a significant piece of work → call speclock_log_change.`,
65
+ `- Look for keywords like "always", "never", "must", "don't ever", "make sure to keep" — these signal constraints that should be locked.`,
66
+ ``,
67
+ `## BEFORE MAKING SIGNIFICANT CHANGES:`,
68
+ `- Call speclock_check_conflict with a description of what you're about to do.`,
69
+ `- If a conflict is detected (confidence HIGH), WARN the user and explain which constraint would be violated.`,
70
+ `- Do NOT proceed with the change unless the user explicitly says to override or remove the lock.`,
71
+ ``,
72
+ `## WHEN THE USER ASKS TO REMOVE SOMETHING THEY PREVIOUSLY LOCKED:`,
73
+ `- Remind them: "You previously locked this constraint: [constraint text]. Want me to remove the lock and proceed?"`,
74
+ `- If they confirm, call speclock_remove_lock, then proceed.`,
75
+ ``,
76
+ `## AT THE END OF A CONVERSATION (when the user says goodbye, or the conversation naturally ends):`,
77
+ `- Call speclock_session_summary with a brief description of what was accomplished.`,
78
+ ``,
79
+ `## IMPORTANT:`,
80
+ `- NEVER mention "SpecLock" to the user unless they ask about it. Just use it silently in the background.`,
81
+ `- The user should feel like the AI simply "remembers everything" — SpecLock is invisible infrastructure.`,
82
+ `- If the user asks "how do you remember this?" you can explain SpecLock is connected as a memory layer.`,
83
+ `- When showing the user what you know from previous sessions, present it naturally, not as a data dump.`,
84
+ ].join("\n"),
85
+ }
86
+ );
87
+
88
+ // Tool 1: speclock_init
89
+ server.tool("speclock_init", "Initialize SpecLock in the current project directory.", {}, async () => {
90
+ const brain = ensureInit(PROJECT_ROOT);
91
+ return { content: [{ type: "text", text: `SpecLock initialized for "${brain.project.name}" at ${brain.project.root}` }] };
92
+ });
93
+
94
+ // Tool 2: speclock_get_context
95
+ server.tool("speclock_get_context", "THE KEY TOOL. Returns the full structured context pack.", { format: z.enum(["markdown", "json"]).default("markdown").describe("Output format") }, async ({ format }) => {
96
+ ensureInit(PROJECT_ROOT);
97
+ const text = format === "json" ? JSON.stringify(generateContextPack(PROJECT_ROOT), null, 2) : generateContext(PROJECT_ROOT);
98
+ return { content: [{ type: "text", text }] };
99
+ });
100
+
101
+ // Tool 3: speclock_set_goal
102
+ server.tool("speclock_set_goal", "Set or update the project goal.", { text: z.string().min(1).describe("The project goal text") }, async ({ text }) => {
103
+ ensureInit(PROJECT_ROOT);
104
+ setGoal(PROJECT_ROOT, text);
105
+ return { content: [{ type: "text", text: `Goal updated: ${text}` }] };
106
+ });
107
+
108
+ // Tool 4: speclock_add_lock
109
+ server.tool("speclock_add_lock", "Add a non-negotiable constraint (SpecLock).", { text: z.string().min(1).describe("The constraint text"), tags: z.array(z.string()).default([]).describe("Category tags"), source: z.enum(["user", "agent"]).default("agent").describe("Who created this lock") }, async ({ text, tags, source }) => {
110
+ ensureInit(PROJECT_ROOT);
111
+ const lock = addLock(PROJECT_ROOT, text, tags, source);
112
+ return { content: [{ type: "text", text: `Lock added [${lock.id}]: ${text}` }] };
113
+ });
114
+
115
+ // Tool 5: speclock_remove_lock
116
+ server.tool("speclock_remove_lock", "Remove (deactivate) a SpecLock by its ID.", { lockId: z.string().min(1).describe("The lock ID to remove") }, async ({ lockId }) => {
117
+ ensureInit(PROJECT_ROOT);
118
+ removeLock(PROJECT_ROOT, lockId);
119
+ return { content: [{ type: "text", text: `Lock ${lockId} removed.` }] };
120
+ });
121
+
122
+ // Tool 6: speclock_add_decision
123
+ server.tool("speclock_add_decision", "Record an architectural or design decision.", { text: z.string().min(1).describe("The decision text"), tags: z.array(z.string()).default([]), source: z.enum(["user", "agent"]).default("agent") }, async ({ text, tags, source }) => {
124
+ ensureInit(PROJECT_ROOT);
125
+ const d = addDecision(PROJECT_ROOT, text, tags, source);
126
+ return { content: [{ type: "text", text: `Decision recorded [${d.id}]: ${text}` }] };
127
+ });
128
+
129
+ // Tool 7: speclock_add_note
130
+ server.tool("speclock_add_note", "Add a pinned note for reference.", { text: z.string().min(1).describe("The note text"), pinned: z.boolean().default(true).describe("Whether to pin this note") }, async ({ text, pinned }) => {
131
+ ensureInit(PROJECT_ROOT);
132
+ const brain = readBrain(PROJECT_ROOT);
133
+ const note = { id: newId(), text, pinned, createdAt: nowIso() };
134
+ brain.state.notes.push(note);
135
+ writeBrain(PROJECT_ROOT, brain);
136
+ appendEvent(PROJECT_ROOT, { type: "note_added", noteId: note.id, text });
137
+ bumpEvents(PROJECT_ROOT);
138
+ return { content: [{ type: "text", text: `Note added [${note.id}]: ${text}` }] };
139
+ });
140
+
141
+ // Tool 8: speclock_set_deploy_facts
142
+ server.tool("speclock_set_deploy_facts", "Record deployment configuration facts.", { provider: z.string().optional(), branch: z.string().optional(), autoDeploy: z.boolean().optional(), url: z.string().optional(), notes: z.string().optional() }, async (params) => {
143
+ ensureInit(PROJECT_ROOT);
144
+ updateDeployFacts(PROJECT_ROOT, params);
145
+ return { content: [{ type: "text", text: `Deploy facts updated: ${JSON.stringify(params)}` }] };
146
+ });
147
+
148
+ // Tool 9: speclock_log_change
149
+ server.tool("speclock_log_change", "Manually log a significant change.", { summary: z.string().min(1).describe("Brief description of the change"), files: z.array(z.string()).default([]).describe("Files affected") }, async ({ summary, files }) => {
150
+ ensureInit(PROJECT_ROOT);
151
+ logChange(PROJECT_ROOT, summary, files);
152
+ return { content: [{ type: "text", text: `Change logged: ${summary}` }] };
153
+ });
154
+
155
+ // Tool 10: speclock_get_changes
156
+ server.tool("speclock_get_changes", "Get recent file changes tracked by SpecLock.", { limit: z.number().int().min(1).max(100).default(20) }, async ({ limit }) => {
157
+ ensureInit(PROJECT_ROOT);
158
+ const events = readEvents(PROJECT_ROOT);
159
+ const changes = events.filter((e) => ["file_created", "file_changed", "file_deleted", "manual_change"].includes(e.type)).slice(-limit);
160
+ return { content: [{ type: "text", text: changes.length ? JSON.stringify(changes, null, 2) : "No recent changes." }] };
161
+ });
162
+
163
+ // Tool 11: speclock_get_events
164
+ server.tool("speclock_get_events", "Get the event log, optionally filtered by type.", { type: z.string().optional(), limit: z.number().int().min(1).max(200).default(50), since: z.string().optional() }, async ({ type, limit, since }) => {
165
+ ensureInit(PROJECT_ROOT);
166
+ let events = readEvents(PROJECT_ROOT);
167
+ if (type) events = events.filter((e) => e.type === type);
168
+ if (since) events = events.filter((e) => e.ts > since);
169
+ events = events.slice(-limit);
170
+ return { content: [{ type: "text", text: events.length ? JSON.stringify(events, null, 2) : "No matching events." }] };
171
+ });
172
+
173
+ // Tool 12: speclock_check_conflict
174
+ server.tool("speclock_check_conflict", "Check if a proposed action conflicts with any active SpecLock.", { proposedAction: z.string().min(1).describe("Description of the action") }, async ({ proposedAction }) => {
175
+ ensureInit(PROJECT_ROOT);
176
+ const result = checkConflict(PROJECT_ROOT, proposedAction);
177
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
178
+ });
179
+
180
+ // Tool 13: speclock_session_briefing
181
+ server.tool("speclock_session_briefing", "Start a new session and get a full briefing.", { toolName: z.enum(["claude-code", "cursor", "codex", "windsurf", "cline", "unknown"]).default("unknown") }, async ({ toolName }) => {
182
+ ensureInit(PROJECT_ROOT);
183
+ const briefing = getSessionBriefing(PROJECT_ROOT, toolName);
184
+ return { content: [{ type: "text", text: briefing }] };
185
+ });
186
+
187
+ // Tool 14: speclock_session_summary
188
+ server.tool("speclock_session_summary", "End the current session and record what was accomplished.", { summary: z.string().min(1) }, async ({ summary }) => {
189
+ ensureInit(PROJECT_ROOT);
190
+ endSession(PROJECT_ROOT, summary);
191
+ return { content: [{ type: "text", text: `Session ended. Summary recorded: ${summary}` }] };
192
+ });
193
+
194
+ // Tool 15: speclock_checkpoint
195
+ server.tool("speclock_checkpoint", "Create a named git tag checkpoint for easy rollback.", { name: z.string().min(1) }, async ({ name }) => {
196
+ ensureInit(PROJECT_ROOT);
197
+ const tag = `speclock-${name}`;
198
+ try {
199
+ createTag(PROJECT_ROOT, tag);
200
+ appendEvent(PROJECT_ROOT, { type: "checkpoint_created", tag });
201
+ return { content: [{ type: "text", text: `Checkpoint created: ${tag}` }] };
202
+ } catch (err) {
203
+ return { content: [{ type: "text", text: `Checkpoint failed: ${err.message}` }] };
204
+ }
205
+ });
206
+
207
+ // Tool 16: speclock_repo_status
208
+ server.tool("speclock_repo_status", "Get current git repository status.", {}, async () => {
209
+ ensureInit(PROJECT_ROOT);
210
+ try {
211
+ const status = captureStatus(PROJECT_ROOT);
212
+ const diff = getDiffSummary(PROJECT_ROOT);
213
+ return { content: [{ type: "text", text: JSON.stringify({ ...status, diffSummary: diff }, null, 2) }] };
214
+ } catch (err) {
215
+ return { content: [{ type: "text", text: `Git status failed: ${err.message}` }] };
216
+ }
217
+ });
218
+
219
+ // Tool 17: speclock_suggest_locks
220
+ server.tool("speclock_suggest_locks", "AI-powered lock suggestions based on project patterns.", {}, async () => {
221
+ ensureInit(PROJECT_ROOT);
222
+ const suggestions = suggestLocks(PROJECT_ROOT);
223
+ if (!suggestions.length) return { content: [{ type: "text", text: "No lock suggestions at this time." }] };
224
+ const text = suggestions.map((s, i) => `${i + 1}. **${s.text}**\n Source: ${s.source}\n Reason: ${s.reason}`).join("\n\n");
225
+ return { content: [{ type: "text", text: `## Lock Suggestions\n\n${text}\n\n---\n*SpecLock v${VERSION} — Developed by ${AUTHOR}*` }] };
226
+ });
227
+
228
+ // Tool 18: speclock_detect_drift
229
+ server.tool("speclock_detect_drift", "Scan recent changes for constraint violations.", {}, async () => {
230
+ ensureInit(PROJECT_ROOT);
231
+ const drift = detectDrift(PROJECT_ROOT);
232
+ if (!drift.length) return { content: [{ type: "text", text: "No drift detected. All changes align with active locks." }] };
233
+ const text = drift.map((d, i) => `${i + 1}. **${d.type}**: ${d.summary}\n Lock: ${d.lockText}\n Confidence: ${d.confidence}`).join("\n\n");
234
+ return { content: [{ type: "text", text: `## Drift Detected\n\n${text}` }] };
235
+ });
236
+
237
+ // Tool 19: speclock_health
238
+ server.tool("speclock_health", "Health check with completeness score and multi-agent timeline.", {}, async () => {
239
+ ensureInit(PROJECT_ROOT);
240
+ const brain = readBrain(PROJECT_ROOT);
241
+ const events = readEvents(PROJECT_ROOT);
242
+ let score = 0;
243
+ const checks = [];
244
+ if (brain.project.goal) { score += 20; checks.push("- [x] Goal set"); } else checks.push("- [ ] Goal missing");
245
+ if (brain.state.locks.filter(l => l.active).length > 0) { score += 20; checks.push("- [x] Locks defined"); } else checks.push("- [ ] No active locks");
246
+ if (brain.state.decisions.length > 0) { score += 15; checks.push("- [x] Decisions recorded"); } else checks.push("- [ ] No decisions");
247
+ if (brain.state.sessions.length > 0) { score += 15; checks.push("- [x] Sessions tracked"); } else checks.push("- [ ] No sessions");
248
+ if (brain.deploy?.provider) { score += 10; checks.push("- [x] Deploy facts set"); } else checks.push("- [ ] Deploy facts missing");
249
+ if (brain.state.notes.length > 0) { score += 10; checks.push("- [x] Notes present"); } else checks.push("- [ ] No notes");
250
+ if (events.length > 10) { score += 10; checks.push("- [x] Rich event history"); } else checks.push("- [ ] Limited events");
251
+ const grade = score >= 90 ? "A" : score >= 70 ? "B" : score >= 50 ? "C" : score >= 30 ? "D" : "F";
252
+ const sessions = brain.state.sessions.slice(-5);
253
+ const agentTimeline = sessions.length ? "\n\n### Recent Sessions\n" + sessions.map(s => `- **${s.tool || "unknown"}** @ ${s.startedAt}${s.summary ? ": " + s.summary : ""}`).join("\n") : "";
254
+ return { content: [{ type: "text", 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}*` }] };
255
+ });
256
+
257
+ return server;
258
+ }
259
+
260
+ // --- HTTP Server ---
261
+ const app = createMcpExpressApp({ host: "0.0.0.0" });
262
+
263
+ app.post("/mcp", async (req, res) => {
264
+ const server = createSpecLockServer();
265
+ try {
266
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
267
+ await server.connect(transport);
268
+ await transport.handleRequest(req, res, req.body);
269
+ res.on("close", () => {
270
+ transport.close();
271
+ server.close();
272
+ });
273
+ } catch (error) {
274
+ console.error("Error handling MCP request:", error);
275
+ if (!res.headersSent) {
276
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
277
+ }
278
+ }
279
+ });
280
+
281
+ app.get("/mcp", async (req, res) => {
282
+ res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
283
+ });
284
+
285
+ app.delete("/mcp", async (req, res) => {
286
+ res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
287
+ });
288
+
289
+ // Health check endpoint
290
+ app.get("/", (req, res) => {
291
+ res.json({
292
+ name: "speclock",
293
+ version: VERSION,
294
+ author: AUTHOR,
295
+ description: "AI Continuity Engine — Kill AI amnesia",
296
+ tools: 19,
297
+ mcp_endpoint: "/mcp",
298
+ npm: "https://www.npmjs.com/package/speclock",
299
+ github: "https://github.com/sgroy10/speclock",
300
+ });
301
+ });
302
+
303
+ const PORT = parseInt(process.env.PORT || "3000", 10);
304
+ app.listen(PORT, "0.0.0.0", () => {
305
+ console.log(`SpecLock MCP HTTP Server v${VERSION} running on port ${PORT} — Developed by ${AUTHOR}`);
306
+ });
package/src/mcp/server.js CHANGED
@@ -56,8 +56,40 @@ const AUTHOR = "Sandeep Roy";
56
56
  const server = new McpServer(
57
57
  { name: "speclock", version: VERSION },
58
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.`,
59
+ instructions: [
60
+ `You have SpecLock connected an AI Continuity Engine by ${AUTHOR}. Follow these rules AUTOMATICALLY without the user ever having to ask:`,
61
+ ``,
62
+ `## ON EVERY NEW CONVERSATION / SESSION START:`,
63
+ `1. IMMEDIATELY call speclock_session_briefing BEFORE responding to the user's first message.`,
64
+ `2. If it returns "not initialized", call speclock_init first, then proceed normally.`,
65
+ `3. Use the briefing context to understand the project's goal, constraints, decisions, and history.`,
66
+ `4. Never ask the user to "initialize SpecLock" or "start a session" — just do it silently.`,
67
+ ``,
68
+ `## AUTOMATIC CONTEXT CAPTURE (do this silently as you work):`,
69
+ `- When the user describes what they're building → call speclock_set_goal with a concise summary.`,
70
+ `- When the user says something is non-negotiable (e.g., "always use Supabase", "never remove auth", "must support mobile") → call speclock_add_lock to save it as an immutable constraint.`,
71
+ `- When the user makes a technology/architecture choice (e.g., "let's use React Query", "we'll go with Stripe") → call speclock_add_decision.`,
72
+ `- When you complete a significant piece of work → call speclock_log_change.`,
73
+ `- Look for keywords like "always", "never", "must", "don't ever", "make sure to keep" — these signal constraints that should be locked.`,
74
+ ``,
75
+ `## BEFORE MAKING SIGNIFICANT CHANGES:`,
76
+ `- Call speclock_check_conflict with a description of what you're about to do.`,
77
+ `- If a conflict is detected (confidence HIGH), WARN the user and explain which constraint would be violated.`,
78
+ `- Do NOT proceed with the change unless the user explicitly says to override or remove the lock.`,
79
+ ``,
80
+ `## WHEN THE USER ASKS TO REMOVE SOMETHING THEY PREVIOUSLY LOCKED:`,
81
+ `- Remind them: "You previously locked this constraint: [constraint text]. Want me to remove the lock and proceed?"`,
82
+ `- If they confirm, call speclock_remove_lock, then proceed.`,
83
+ ``,
84
+ `## AT THE END OF A CONVERSATION (when the user says goodbye, or the conversation naturally ends):`,
85
+ `- Call speclock_session_summary with a brief description of what was accomplished.`,
86
+ ``,
87
+ `## IMPORTANT:`,
88
+ `- NEVER mention "SpecLock" to the user unless they ask about it. Just use it silently in the background.`,
89
+ `- The user should feel like the AI simply "remembers everything" — SpecLock is invisible infrastructure.`,
90
+ `- If the user asks "how do you remember this?" you can explain SpecLock is connected as a memory layer.`,
91
+ `- When showing the user what you know from previous sessions, present it naturally, not as a data dump.`,
92
+ ].join("\n"),
61
93
  }
62
94
  );
63
95
 
@@ -721,10 +753,22 @@ server.tool(
721
753
  }
722
754
  );
723
755
 
724
- // --- Start server ---
725
- const transport = new StdioServerTransport();
726
- await server.connect(transport);
756
+ // --- Smithery sandbox export ---
757
+ export default function createSandboxServer() {
758
+ return server;
759
+ }
727
760
 
728
- process.stderr.write(
729
- `SpecLock MCP v${VERSION} running (stdio) — Developed by ${AUTHOR}. Root: ${PROJECT_ROOT}${os.EOL}`
730
- );
761
+ // --- Start server (skip when bundled as CJS for Smithery scanning) ---
762
+ const isScanMode = typeof import.meta.url === "undefined";
763
+
764
+ if (!isScanMode) {
765
+ const transport = new StdioServerTransport();
766
+ server.connect(transport).then(() => {
767
+ process.stderr.write(
768
+ `SpecLock MCP v${VERSION} running (stdio) — Developed by ${AUTHOR}. Root: ${PROJECT_ROOT}${os.EOL}`
769
+ );
770
+ }).catch((err) => {
771
+ process.stderr.write(`SpecLock fatal: ${err.message}${os.EOL}`);
772
+ process.exit(1);
773
+ });
774
+ }