speclock 4.2.0 → 4.3.1
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 +2 -2
- package/src/cli/index.js +1 -1
- package/src/core/compliance.js +1 -1
- package/src/core/conflict.js +109 -20
- package/src/core/engine.js +3 -0
- package/src/core/semantics.js +28 -11
- package/src/core/telemetry.js +1 -1
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +114 -26
- package/src/mcp/server.js +103 -87
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speclock",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "AI constraint engine with Policy-as-Code DSL, OAuth/OIDC SSO, admin dashboard, telemetry, API key auth, RBAC, AES-256-GCM encryption, hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance.
|
|
3
|
+
"version": "4.3.1",
|
|
4
|
+
"description": "AI constraint engine with Gemini LLM universal detection, Policy-as-Code DSL, OAuth/OIDC SSO, admin dashboard, telemetry, API key auth, RBAC, AES-256-GCM encryption, hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance. Cross-platform: MCP + direct API. 31 MCP tools + CLI. Enterprise platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
|
7
7
|
"bin": {
|
package/src/cli/index.js
CHANGED
|
@@ -116,7 +116,7 @@ function refreshContext(root) {
|
|
|
116
116
|
|
|
117
117
|
function printHelp() {
|
|
118
118
|
console.log(`
|
|
119
|
-
SpecLock
|
|
119
|
+
SpecLock v4.3.1 — AI Constraint Engine (Gemini LLM + Policy-as-Code + SSO + Dashboard + Telemetry + Auth + RBAC + Encryption)
|
|
120
120
|
Developed by Sandeep Roy (github.com/sgroy10)
|
|
121
121
|
|
|
122
122
|
Usage: speclock <command> [options]
|
package/src/core/compliance.js
CHANGED
package/src/core/conflict.js
CHANGED
|
@@ -56,13 +56,95 @@ const GUARD_TAG = "SPECLOCK-GUARD";
|
|
|
56
56
|
|
|
57
57
|
// --- Core functions ---
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Detect if the first argument is a file-system path (brain mode)
|
|
61
|
+
* or natural text (direct mode for cross-platform usage).
|
|
62
|
+
*/
|
|
63
|
+
function isDirectoryPath(str) {
|
|
64
|
+
if (!str || typeof str !== "string") return false;
|
|
65
|
+
// Absolute paths: /foo, C:\foo, \\server
|
|
66
|
+
if (str.startsWith("/") || str.startsWith("\\") || /^[A-Z]:/i.test(str)) return true;
|
|
67
|
+
// Relative path with separator or current dir
|
|
68
|
+
if (str === "." || str === ".." || str.includes("/") || str.includes("\\")) return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Direct-mode conflict check: action text + lock text(s) directly.
|
|
74
|
+
* No brain.json needed. Works on any platform.
|
|
75
|
+
* @param {string} actionText - The proposed action
|
|
76
|
+
* @param {string|string[]} locks - Lock text or array of lock texts
|
|
77
|
+
* @returns {Object} Same shape as brain-mode checkConflict
|
|
78
|
+
*/
|
|
79
|
+
function checkConflictDirect(actionText, locks) {
|
|
80
|
+
const lockList = Array.isArray(locks) ? locks : [locks];
|
|
81
|
+
const conflicting = [];
|
|
82
|
+
let maxNonConflictScore = 0;
|
|
83
|
+
|
|
84
|
+
for (const lockText of lockList) {
|
|
85
|
+
const result = analyzeConflict(actionText, lockText);
|
|
86
|
+
if (result.isConflict) {
|
|
87
|
+
conflicting.push({
|
|
88
|
+
id: "direct",
|
|
89
|
+
text: lockText,
|
|
90
|
+
matchedKeywords: [],
|
|
91
|
+
confidence: result.confidence,
|
|
92
|
+
level: result.level,
|
|
93
|
+
reasons: result.reasons,
|
|
94
|
+
});
|
|
95
|
+
} else if (result.confidence > maxNonConflictScore) {
|
|
96
|
+
maxNonConflictScore = result.confidence;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (conflicting.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
hasConflict: false,
|
|
103
|
+
conflictingLocks: [],
|
|
104
|
+
_maxNonConflictScore: maxNonConflictScore,
|
|
105
|
+
analysis: `Checked against ${lockList.length} lock(s). No conflicts detected.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
conflicting.sort((a, b) => b.confidence - a.confidence);
|
|
110
|
+
const details = conflicting
|
|
111
|
+
.map(
|
|
112
|
+
(c) =>
|
|
113
|
+
`- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
|
|
114
|
+
)
|
|
115
|
+
.join("\n");
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
hasConflict: true,
|
|
119
|
+
conflictingLocks: conflicting,
|
|
120
|
+
_maxNonConflictScore: maxNonConflictScore,
|
|
121
|
+
analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check conflicts. Supports TWO calling patterns:
|
|
127
|
+
* 1. Brain mode: checkConflict(rootDir, proposedAction)
|
|
128
|
+
* 2. Direct mode: checkConflict(actionText, lockText)
|
|
129
|
+
* checkConflict(actionText, [lock1, lock2, ...])
|
|
130
|
+
* Automatically detects which mode based on the first argument.
|
|
131
|
+
*/
|
|
132
|
+
export function checkConflict(rootOrAction, proposedActionOrLock) {
|
|
133
|
+
// Direct mode: first arg is not a directory path → treat as (action, lock)
|
|
134
|
+
if (!isDirectoryPath(rootOrAction)) {
|
|
135
|
+
return checkConflictDirect(rootOrAction, proposedActionOrLock);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Brain mode: first arg is a directory path → read locks from brain.json
|
|
139
|
+
const root = rootOrAction;
|
|
140
|
+
const proposedAction = proposedActionOrLock;
|
|
60
141
|
const brain = ensureInit(root);
|
|
61
|
-
const activeLocks = brain.specLock
|
|
142
|
+
const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
|
|
62
143
|
if (activeLocks.length === 0) {
|
|
63
144
|
return {
|
|
64
145
|
hasConflict: false,
|
|
65
146
|
conflictingLocks: [],
|
|
147
|
+
_maxNonConflictScore: 0,
|
|
66
148
|
analysis: "No active locks. No constraints to check against.",
|
|
67
149
|
};
|
|
68
150
|
}
|
|
@@ -124,25 +206,17 @@ export function checkConflict(root, proposedAction) {
|
|
|
124
206
|
|
|
125
207
|
/**
|
|
126
208
|
* Async conflict check with LLM fallback for grey-zone cases.
|
|
209
|
+
* Supports both brain mode and direct mode (same as checkConflict).
|
|
127
210
|
* Strategy: Run heuristic first (fast, free, offline).
|
|
128
211
|
* - Score > 70% on ALL conflicts → trust heuristic (skip LLM)
|
|
129
|
-
* -
|
|
130
|
-
* - Score 1–70% on ANY lock → GREY ZONE → call LLM for universal domain coverage
|
|
131
|
-
* This catches vocabulary gaps where the heuristic has partial/no signal
|
|
132
|
-
* but an LLM (which knows every domain) would detect the conflict.
|
|
212
|
+
* - Everything else → call LLM for universal domain coverage
|
|
133
213
|
*/
|
|
134
|
-
export async function checkConflictAsync(
|
|
135
|
-
// 1. Always run the fast heuristic first
|
|
136
|
-
const heuristicResult = checkConflict(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
? Math.max(...heuristicResult.conflictingLocks.map((c) => c.confidence))
|
|
141
|
-
: 0;
|
|
142
|
-
const maxNonConflictScore = heuristicResult._maxNonConflictScore || 0;
|
|
143
|
-
const maxScore = Math.max(maxConflictScore, maxNonConflictScore);
|
|
144
|
-
|
|
145
|
-
// 3. Fast path: all conflicts are HIGH (>70%) → heuristic is certain, skip LLM
|
|
214
|
+
export async function checkConflictAsync(rootOrAction, proposedActionOrLock) {
|
|
215
|
+
// 1. Always run the fast heuristic first (handles both brain + direct mode)
|
|
216
|
+
const heuristicResult = checkConflict(rootOrAction, proposedActionOrLock);
|
|
217
|
+
const isDirect = !isDirectoryPath(rootOrAction);
|
|
218
|
+
|
|
219
|
+
// 2. Fast path: all conflicts are HIGH (>70%) → heuristic is certain, skip LLM
|
|
146
220
|
if (
|
|
147
221
|
heuristicResult.hasConflict &&
|
|
148
222
|
heuristicResult.conflictingLocks.every((c) => c.confidence > 70)
|
|
@@ -150,12 +224,27 @@ export async function checkConflictAsync(root, proposedAction) {
|
|
|
150
224
|
return heuristicResult;
|
|
151
225
|
}
|
|
152
226
|
|
|
153
|
-
//
|
|
227
|
+
// 3. Call LLM for everything else — including score 0.
|
|
154
228
|
// Score 0 means "heuristic vocabulary doesn't cover this domain",
|
|
155
229
|
// which is EXACTLY when an LLM (which knows every domain) adds value.
|
|
156
230
|
try {
|
|
157
231
|
const { llmCheckConflict } = await import("./llm-checker.js");
|
|
158
|
-
|
|
232
|
+
// In direct mode, build activeLocks from the lock text(s) passed directly
|
|
233
|
+
let llmResult;
|
|
234
|
+
if (isDirect) {
|
|
235
|
+
const lockTexts = Array.isArray(proposedActionOrLock)
|
|
236
|
+
? proposedActionOrLock
|
|
237
|
+
: [proposedActionOrLock];
|
|
238
|
+
const activeLocks = lockTexts.map((t, i) => ({
|
|
239
|
+
id: `direct-${i}`,
|
|
240
|
+
text: t,
|
|
241
|
+
active: true,
|
|
242
|
+
}));
|
|
243
|
+
llmResult = await llmCheckConflict(null, rootOrAction, activeLocks);
|
|
244
|
+
} else {
|
|
245
|
+
llmResult = await llmCheckConflict(rootOrAction, proposedActionOrLock);
|
|
246
|
+
}
|
|
247
|
+
|
|
159
248
|
if (llmResult) {
|
|
160
249
|
// Keep HIGH heuristic conflicts (>70%) — they're already certain
|
|
161
250
|
const highConfidence = heuristicResult.conflictingLocks.filter(
|
package/src/core/engine.js
CHANGED
package/src/core/semantics.js
CHANGED
|
@@ -379,8 +379,7 @@ export const CONCEPT_MAP = {
|
|
|
379
379
|
"anti-fraud"],
|
|
380
380
|
"posting": ["transaction", "ledger entry", "journal entry", "record"],
|
|
381
381
|
"reconciliation": ["balance", "ledger", "account", "transaction", "audit"],
|
|
382
|
-
"checkout": ["
|
|
383
|
-
"payment processing", "order"],
|
|
382
|
+
"checkout": ["cart", "purchase", "order"],
|
|
384
383
|
"revenue": ["payment", "billing", "income", "sales", "earnings",
|
|
385
384
|
"transaction"],
|
|
386
385
|
"invoice": ["billing", "payment", "charge", "transaction", "accounts receivable"],
|
|
@@ -425,9 +424,9 @@ export const CONCEPT_MAP = {
|
|
|
425
424
|
|
|
426
425
|
// E-commerce
|
|
427
426
|
"cart": ["checkout", "purchase", "shopping cart"],
|
|
428
|
-
"payment processing":["payment", "
|
|
427
|
+
"payment processing":["payment", "billing", "transaction",
|
|
429
428
|
"stripe", "payment gateway"],
|
|
430
|
-
"payment gateway": ["payment processing", "stripe", "paypal",
|
|
429
|
+
"payment gateway": ["payment processing", "stripe", "paypal",
|
|
431
430
|
"billing", "transaction"],
|
|
432
431
|
"product": ["item", "sku", "catalog", "merchandise", "product listing"],
|
|
433
432
|
"price": ["pricing", "cost", "amount", "rate", "charge"],
|
|
@@ -1386,17 +1385,35 @@ function _compareSubjectsInline(actionText, lockText) {
|
|
|
1386
1385
|
};
|
|
1387
1386
|
}
|
|
1388
1387
|
|
|
1388
|
+
// --- Performance cache: avoid re-computing action-side analysis across multiple locks ---
|
|
1389
|
+
const _actionCache = new Map();
|
|
1390
|
+
const _ACTION_CACHE_MAX = 50;
|
|
1391
|
+
|
|
1392
|
+
function _getCachedAction(text) {
|
|
1393
|
+
const cached = _actionCache.get(text);
|
|
1394
|
+
if (cached) return cached;
|
|
1395
|
+
const tokens = tokenize(text);
|
|
1396
|
+
const expanded = expandSemantics(tokens.all);
|
|
1397
|
+
const intent = classifyIntent(text);
|
|
1398
|
+
const temporal = detectTemporalModifier(text);
|
|
1399
|
+
const entry = { tokens, expanded, intent, temporal };
|
|
1400
|
+
if (_actionCache.size >= _ACTION_CACHE_MAX) {
|
|
1401
|
+
_actionCache.delete(_actionCache.keys().next().value);
|
|
1402
|
+
}
|
|
1403
|
+
_actionCache.set(text, entry);
|
|
1404
|
+
return entry;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1389
1407
|
export function scoreConflict({ actionText, lockText }) {
|
|
1390
|
-
const
|
|
1391
|
-
const
|
|
1408
|
+
const actionCached = _getCachedAction(actionText);
|
|
1409
|
+
const actionTokens = actionCached.tokens;
|
|
1410
|
+
const actionExpanded = actionCached.expanded;
|
|
1411
|
+
const actionIntent = actionCached.intent;
|
|
1412
|
+
const hasTemporalMod = actionCached.temporal;
|
|
1392
1413
|
|
|
1393
|
-
const
|
|
1414
|
+
const lockTokens = tokenize(lockText);
|
|
1394
1415
|
const lockExpanded = expandSemantics(lockTokens.all);
|
|
1395
|
-
|
|
1396
|
-
const actionIntent = classifyIntent(actionText);
|
|
1397
1416
|
const lockIntent = classifyIntent(lockText);
|
|
1398
|
-
|
|
1399
|
-
const hasTemporalMod = detectTemporalModifier(actionText);
|
|
1400
1417
|
const lockIsProhibitive = isProhibitiveLock(lockText);
|
|
1401
1418
|
|
|
1402
1419
|
let score = 0;
|
package/src/core/telemetry.js
CHANGED
|
@@ -257,7 +257,7 @@ export async function flushToRemote(root) {
|
|
|
257
257
|
// Build anonymized payload
|
|
258
258
|
const payload = {
|
|
259
259
|
instanceId: summary.instanceId,
|
|
260
|
-
version: "3.
|
|
260
|
+
version: "4.3.1",
|
|
261
261
|
totalCalls: summary.totalCalls,
|
|
262
262
|
avgResponseMs: summary.avgResponseMs,
|
|
263
263
|
conflicts: summary.conflicts,
|
package/src/dashboard/index.html
CHANGED
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
<div class="header">
|
|
90
90
|
<div>
|
|
91
91
|
<h1><span>SpecLock</span> Dashboard</h1>
|
|
92
|
-
<div class="meta">
|
|
92
|
+
<div class="meta">v4.3.1 — AI Constraint Engine</div>
|
|
93
93
|
</div>
|
|
94
94
|
<div style="display:flex;align-items:center;gap:12px;">
|
|
95
95
|
<span id="health-badge" class="status-badge healthy">Loading...</span>
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
</div>
|
|
183
183
|
|
|
184
184
|
<div style="text-align:center;padding:24px;color:var(--muted);font-size:12px;">
|
|
185
|
-
SpecLock
|
|
185
|
+
SpecLock v4.3.1 — Developed by Sandeep Roy — <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
|
|
186
186
|
</div>
|
|
187
187
|
|
|
188
188
|
<script>
|
package/src/mcp/http-server.js
CHANGED
|
@@ -91,7 +91,7 @@ import { fileURLToPath } from "url";
|
|
|
91
91
|
import _path from "path";
|
|
92
92
|
|
|
93
93
|
const PROJECT_ROOT = process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
94
|
-
const VERSION = "3.
|
|
94
|
+
const VERSION = "4.3.1";
|
|
95
95
|
const AUTHOR = "Sandeep Roy";
|
|
96
96
|
const START_TIME = Date.now();
|
|
97
97
|
|
|
@@ -262,21 +262,63 @@ function createSpecLockServer() {
|
|
|
262
262
|
return { content: [{ type: "text", text: events.length ? JSON.stringify(events, null, 2) : "No matching events." }] };
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
// Tool 12: speclock_check_conflict (
|
|
266
|
-
server.tool("speclock_check_conflict", "Check if a proposed action conflicts with any active SpecLock. In hard mode,
|
|
265
|
+
// Tool 12: speclock_check_conflict (v4.3: hybrid heuristic + Gemini LLM)
|
|
266
|
+
server.tool("speclock_check_conflict", "Check if a proposed action conflicts with any active SpecLock. Uses fast heuristic + Gemini LLM for universal domain coverage. In hard enforcement mode, conflicts above the threshold will BLOCK the action.", { proposedAction: z.string().min(1).describe("Description of the action") }, async ({ proposedAction }) => {
|
|
267
267
|
ensureInit(PROJECT_ROOT);
|
|
268
|
-
|
|
268
|
+
// Hybrid check: heuristic first, LLM for grey-zone
|
|
269
|
+
let result = await checkConflictAsync(PROJECT_ROOT, proposedAction);
|
|
270
|
+
|
|
271
|
+
// If async hybrid returned no conflict, also check enforcer for hard mode
|
|
272
|
+
if (!result.hasConflict) {
|
|
273
|
+
const enforced = enforceConflictCheck(PROJECT_ROOT, proposedAction);
|
|
274
|
+
if (enforced.blocked) {
|
|
275
|
+
return { content: [{ type: "text", text: enforced.analysis }], isError: true };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// In hard mode with blocking conflict, return isError: true
|
|
269
280
|
if (result.blocked) {
|
|
270
281
|
return { content: [{ type: "text", text: result.analysis }], isError: true };
|
|
271
282
|
}
|
|
283
|
+
|
|
272
284
|
return { content: [{ type: "text", text: result.analysis }] };
|
|
273
285
|
});
|
|
274
286
|
|
|
275
|
-
// Tool 13: speclock_session_briefing
|
|
287
|
+
// Tool 13: speclock_session_briefing (v4.3: try-catch + rich output)
|
|
276
288
|
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 }) => {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
289
|
+
try {
|
|
290
|
+
ensureInit(PROJECT_ROOT);
|
|
291
|
+
const briefing = getSessionBriefing(PROJECT_ROOT, toolName);
|
|
292
|
+
const contextMd = generateContext(PROJECT_ROOT);
|
|
293
|
+
|
|
294
|
+
const parts = [];
|
|
295
|
+
parts.push(`# SpecLock Session Briefing`);
|
|
296
|
+
parts.push(`Session started (${toolName}). ID: ${briefing.session?.id || "new"}`);
|
|
297
|
+
parts.push("");
|
|
298
|
+
|
|
299
|
+
if (briefing.lastSession) {
|
|
300
|
+
parts.push("## Last Session");
|
|
301
|
+
parts.push(`- Tool: **${briefing.lastSession.toolUsed || "unknown"}**`);
|
|
302
|
+
parts.push(`- Ended: ${briefing.lastSession.endedAt || "unknown"}`);
|
|
303
|
+
if (briefing.lastSession.summary) parts.push(`- Summary: ${briefing.lastSession.summary}`);
|
|
304
|
+
parts.push(`- Events: ${briefing.lastSession.eventsInSession || 0}`);
|
|
305
|
+
parts.push(`- Changes since then: ${briefing.changesSinceLastSession || 0}`);
|
|
306
|
+
parts.push("");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (briefing.warnings?.length > 0) {
|
|
310
|
+
parts.push("## Warnings");
|
|
311
|
+
for (const w of briefing.warnings) parts.push(`- ${w}`);
|
|
312
|
+
parts.push("");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
parts.push("---");
|
|
316
|
+
parts.push(contextMd);
|
|
317
|
+
|
|
318
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return { content: [{ type: "text", text: `# SpecLock Session Briefing\n\nError loading session: ${err.message}\n\nTry running speclock_init first.\n\n---\n*SpecLock v${VERSION}*` }] };
|
|
321
|
+
}
|
|
280
322
|
});
|
|
281
323
|
|
|
282
324
|
// Tool 14: speclock_session_summary
|
|
@@ -329,24 +371,70 @@ function createSpecLockServer() {
|
|
|
329
371
|
return { content: [{ type: "text", text: `## Drift Detected\n\n${text}` }] };
|
|
330
372
|
});
|
|
331
373
|
|
|
332
|
-
// Tool 19: speclock_health
|
|
374
|
+
// Tool 19: speclock_health (v4.3: null-safe)
|
|
333
375
|
server.tool("speclock_health", "Health check with completeness score and multi-agent timeline.", {}, async () => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
376
|
+
try {
|
|
377
|
+
const brain = ensureInit(PROJECT_ROOT);
|
|
378
|
+
const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
|
|
379
|
+
|
|
380
|
+
let score = 0;
|
|
381
|
+
const checks = [];
|
|
382
|
+
|
|
383
|
+
if (brain.goal?.text) { score += 20; checks.push("[PASS] Goal is set"); }
|
|
384
|
+
else checks.push("[MISS] No project goal set");
|
|
385
|
+
|
|
386
|
+
if (activeLocks.length > 0) { score += 25; checks.push(`[PASS] ${activeLocks.length} active lock(s)`); }
|
|
387
|
+
else checks.push("[MISS] No SpecLock constraints defined");
|
|
388
|
+
|
|
389
|
+
if ((brain.decisions || []).length > 0) { score += 15; checks.push(`[PASS] ${brain.decisions.length} decision(s) recorded`); }
|
|
390
|
+
else checks.push("[MISS] No decisions recorded");
|
|
391
|
+
|
|
392
|
+
if ((brain.notes || []).length > 0) { score += 10; checks.push(`[PASS] ${brain.notes.length} note(s)`); }
|
|
393
|
+
else checks.push("[MISS] No notes added");
|
|
394
|
+
|
|
395
|
+
const sessionHistory = brain.sessions?.history || [];
|
|
396
|
+
if (sessionHistory.length > 0) { score += 15; checks.push(`[PASS] ${sessionHistory.length} session(s) in history`); }
|
|
397
|
+
else checks.push("[MISS] No session history yet");
|
|
398
|
+
|
|
399
|
+
const recentChanges = brain.state?.recentChanges || [];
|
|
400
|
+
if (recentChanges.length > 0) { score += 10; checks.push(`[PASS] ${recentChanges.length} change(s) tracked`); }
|
|
401
|
+
else checks.push("[MISS] No changes tracked");
|
|
402
|
+
|
|
403
|
+
if (brain.facts?.deploy?.provider && brain.facts.deploy.provider !== "unknown") { score += 5; checks.push("[PASS] Deploy facts configured"); }
|
|
404
|
+
else checks.push("[MISS] Deploy facts not configured");
|
|
405
|
+
|
|
406
|
+
// Multi-agent timeline
|
|
407
|
+
const agentMap = {};
|
|
408
|
+
for (const session of sessionHistory) {
|
|
409
|
+
const tool = session.toolUsed || "unknown";
|
|
410
|
+
if (!agentMap[tool]) agentMap[tool] = { count: 0, lastUsed: "", summaries: [] };
|
|
411
|
+
agentMap[tool].count++;
|
|
412
|
+
if (!agentMap[tool].lastUsed || (session.endedAt && session.endedAt > agentMap[tool].lastUsed)) {
|
|
413
|
+
agentMap[tool].lastUsed = session.endedAt || session.startedAt || "";
|
|
414
|
+
}
|
|
415
|
+
if (session.summary && agentMap[tool].summaries.length < 3) {
|
|
416
|
+
agentMap[tool].summaries.push(session.summary.substring(0, 80));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let agentTimeline = "";
|
|
421
|
+
if (Object.keys(agentMap).length > 0) {
|
|
422
|
+
agentTimeline = "\n\n## Multi-Agent Timeline\n" +
|
|
423
|
+
Object.entries(agentMap)
|
|
424
|
+
.map(([tool, info]) =>
|
|
425
|
+
`- **${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)"}`
|
|
426
|
+
)
|
|
427
|
+
.join("\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
|
|
431
|
+
const evtCount = brain.events?.count || 0;
|
|
432
|
+
const revertCount = (brain.state?.reverts || []).length;
|
|
433
|
+
|
|
434
|
+
return { content: [{ type: "text", text: `## SpecLock Health Check\n\nScore: **${score}/100** (Grade: ${grade})\nEvents: ${evtCount} | Reverts: ${revertCount}\n\n### Checks\n${checks.join("\n")}${agentTimeline}\n\n---\n*SpecLock v${VERSION} — Developed by ${AUTHOR}*` }] };
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return { content: [{ type: "text", text: `## SpecLock Health Check\n\nError: ${err.message}\n\nTry running speclock_init first to initialize the project.\n\n---\n*SpecLock v${VERSION}*` }] };
|
|
437
|
+
}
|
|
350
438
|
});
|
|
351
439
|
|
|
352
440
|
// Tool 20: speclock_apply_template
|
|
@@ -612,7 +700,7 @@ app.get("/.well-known/mcp/server-card.json", (req, res) => {
|
|
|
612
700
|
res.json({
|
|
613
701
|
name: "SpecLock",
|
|
614
702
|
version: VERSION,
|
|
615
|
-
description: "AI Constraint Engine — memory + enforcement for AI coding tools. Policy-as-Code DSL, OAuth/OIDC SSO, admin dashboard, telemetry, API key auth, RBAC, AES-256-GCM encryption, hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance.
|
|
703
|
+
description: "AI Constraint Engine — memory + enforcement for AI coding tools. Hybrid heuristic + Gemini LLM for universal domain coverage. Policy-as-Code DSL, OAuth/OIDC SSO, admin dashboard, telemetry, API key auth, RBAC, AES-256-GCM encryption, hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance. 31 MCP tools + CLI. Works with Claude Code, Cursor, Windsurf, Cline, Bolt.new, Lovable.",
|
|
616
704
|
author: {
|
|
617
705
|
name: "Sandeep Roy",
|
|
618
706
|
url: "https://github.com/sgroy10",
|
package/src/mcp/server.js
CHANGED
|
@@ -100,7 +100,7 @@ const PROJECT_ROOT =
|
|
|
100
100
|
args.project || process.env.SPECLOCK_PROJECT_ROOT || process.cwd();
|
|
101
101
|
|
|
102
102
|
// --- MCP Server ---
|
|
103
|
-
const VERSION = "3.
|
|
103
|
+
const VERSION = "4.3.1";
|
|
104
104
|
const AUTHOR = "Sandeep Roy";
|
|
105
105
|
|
|
106
106
|
const server = new McpServer(
|
|
@@ -521,48 +521,54 @@ server.tool(
|
|
|
521
521
|
.describe("Which AI tool is being used"),
|
|
522
522
|
},
|
|
523
523
|
async ({ toolName }) => {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// Last session summary
|
|
535
|
-
if (briefing.lastSession) {
|
|
536
|
-
parts.push("## Last Session");
|
|
537
|
-
parts.push(`- Tool: **${briefing.lastSession.toolUsed}**`);
|
|
538
|
-
parts.push(`- Ended: ${briefing.lastSession.endedAt || "unknown"}`);
|
|
539
|
-
if (briefing.lastSession.summary)
|
|
540
|
-
parts.push(`- Summary: ${briefing.lastSession.summary}`);
|
|
541
|
-
parts.push(
|
|
542
|
-
`- Events: ${briefing.lastSession.eventsInSession || 0}`
|
|
543
|
-
);
|
|
544
|
-
parts.push(
|
|
545
|
-
`- Changes since then: ${briefing.changesSinceLastSession}`
|
|
546
|
-
);
|
|
524
|
+
try {
|
|
525
|
+
const briefing = getSessionBriefing(PROJECT_ROOT, toolName);
|
|
526
|
+
const contextMd = generateContext(PROJECT_ROOT);
|
|
527
|
+
|
|
528
|
+
const parts = [];
|
|
529
|
+
|
|
530
|
+
// Session info
|
|
531
|
+
parts.push(`# SpecLock Session Briefing`);
|
|
532
|
+
parts.push(`Session started (${toolName}). ID: ${briefing.session?.id || "new"}`);
|
|
547
533
|
parts.push("");
|
|
548
|
-
}
|
|
549
534
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
parts.push(`- ${
|
|
535
|
+
// Last session summary
|
|
536
|
+
if (briefing.lastSession) {
|
|
537
|
+
parts.push("## Last Session");
|
|
538
|
+
parts.push(`- Tool: **${briefing.lastSession.toolUsed || "unknown"}**`);
|
|
539
|
+
parts.push(`- Ended: ${briefing.lastSession.endedAt || "unknown"}`);
|
|
540
|
+
if (briefing.lastSession.summary)
|
|
541
|
+
parts.push(`- Summary: ${briefing.lastSession.summary}`);
|
|
542
|
+
parts.push(
|
|
543
|
+
`- Events: ${briefing.lastSession.eventsInSession || 0}`
|
|
544
|
+
);
|
|
545
|
+
parts.push(
|
|
546
|
+
`- Changes since then: ${briefing.changesSinceLastSession || 0}`
|
|
547
|
+
);
|
|
548
|
+
parts.push("");
|
|
555
549
|
}
|
|
556
|
-
parts.push("");
|
|
557
|
-
}
|
|
558
550
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
551
|
+
// Warnings
|
|
552
|
+
if (briefing.warnings?.length > 0) {
|
|
553
|
+
parts.push("## Warnings");
|
|
554
|
+
for (const w of briefing.warnings) {
|
|
555
|
+
parts.push(`- ${w}`);
|
|
556
|
+
}
|
|
557
|
+
parts.push("");
|
|
558
|
+
}
|
|
562
559
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
560
|
+
// Full context
|
|
561
|
+
parts.push("---");
|
|
562
|
+
parts.push(contextMd);
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
content: [{ type: "text", text: parts.join("\n") }],
|
|
566
|
+
};
|
|
567
|
+
} catch (err) {
|
|
568
|
+
return {
|
|
569
|
+
content: [{ type: "text", text: `# SpecLock Session Briefing\n\nError loading session: ${err.message}\n\nTry running speclock_init first.\n\n---\n*SpecLock v${VERSION}*` }],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
566
572
|
}
|
|
567
573
|
);
|
|
568
574
|
|
|
@@ -780,68 +786,78 @@ server.tool(
|
|
|
780
786
|
"Get a health check of the SpecLock setup including completeness score, missing recommended items, and multi-agent session timeline.",
|
|
781
787
|
{},
|
|
782
788
|
async () => {
|
|
783
|
-
|
|
784
|
-
|
|
789
|
+
try {
|
|
790
|
+
const brain = ensureInit(PROJECT_ROOT);
|
|
791
|
+
const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
|
|
785
792
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
793
|
+
// Calculate health score
|
|
794
|
+
let score = 0;
|
|
795
|
+
const checks = [];
|
|
789
796
|
|
|
790
|
-
|
|
791
|
-
|
|
797
|
+
if (brain.goal?.text) { score += 20; checks.push("[PASS] Goal is set"); }
|
|
798
|
+
else checks.push("[MISS] No project goal set");
|
|
792
799
|
|
|
793
|
-
|
|
794
|
-
|
|
800
|
+
if (activeLocks.length > 0) { score += 25; checks.push(`[PASS] ${activeLocks.length} active lock(s)`); }
|
|
801
|
+
else checks.push("[MISS] No SpecLock constraints defined");
|
|
795
802
|
|
|
796
|
-
|
|
797
|
-
|
|
803
|
+
if ((brain.decisions || []).length > 0) { score += 15; checks.push(`[PASS] ${brain.decisions.length} decision(s) recorded`); }
|
|
804
|
+
else checks.push("[MISS] No decisions recorded");
|
|
798
805
|
|
|
799
|
-
|
|
800
|
-
|
|
806
|
+
if ((brain.notes || []).length > 0) { score += 10; checks.push(`[PASS] ${brain.notes.length} note(s)`); }
|
|
807
|
+
else checks.push("[MISS] No notes added");
|
|
801
808
|
|
|
802
|
-
|
|
803
|
-
|
|
809
|
+
const sessionHistory = brain.sessions?.history || [];
|
|
810
|
+
if (sessionHistory.length > 0) { score += 15; checks.push(`[PASS] ${sessionHistory.length} session(s) in history`); }
|
|
811
|
+
else checks.push("[MISS] No session history yet");
|
|
804
812
|
|
|
805
|
-
|
|
806
|
-
|
|
813
|
+
const recentChanges = brain.state?.recentChanges || [];
|
|
814
|
+
if (recentChanges.length > 0) { score += 10; checks.push(`[PASS] ${recentChanges.length} change(s) tracked`); }
|
|
815
|
+
else checks.push("[MISS] No changes tracked");
|
|
807
816
|
|
|
808
|
-
|
|
809
|
-
|
|
817
|
+
if (brain.facts?.deploy?.provider && brain.facts.deploy.provider !== "unknown") { score += 5; checks.push("[PASS] Deploy facts configured"); }
|
|
818
|
+
else checks.push("[MISS] Deploy facts not configured");
|
|
810
819
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
820
|
+
// Multi-agent timeline
|
|
821
|
+
const agentMap = {};
|
|
822
|
+
for (const session of sessionHistory) {
|
|
823
|
+
const tool = session.toolUsed || "unknown";
|
|
824
|
+
if (!agentMap[tool]) agentMap[tool] = { count: 0, lastUsed: "", summaries: [] };
|
|
825
|
+
agentMap[tool].count++;
|
|
826
|
+
if (!agentMap[tool].lastUsed || (session.endedAt && session.endedAt > agentMap[tool].lastUsed)) {
|
|
827
|
+
agentMap[tool].lastUsed = session.endedAt || session.startedAt || "";
|
|
828
|
+
}
|
|
829
|
+
if (session.summary && agentMap[tool].summaries.length < 3) {
|
|
830
|
+
agentMap[tool].summaries.push(session.summary.substring(0, 80));
|
|
831
|
+
}
|
|
822
832
|
}
|
|
823
|
-
}
|
|
824
833
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
+
let agentTimeline = "";
|
|
835
|
+
if (Object.keys(agentMap).length > 0) {
|
|
836
|
+
agentTimeline = "\n\n## Multi-Agent Timeline\n" +
|
|
837
|
+
Object.entries(agentMap)
|
|
838
|
+
.map(([tool, info]) =>
|
|
839
|
+
`- **${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)"}`
|
|
840
|
+
)
|
|
841
|
+
.join("\n");
|
|
842
|
+
}
|
|
834
843
|
|
|
835
|
-
|
|
844
|
+
const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
|
|
845
|
+
const evtCount = brain.events?.count || 0;
|
|
846
|
+
const revertCount = (brain.state?.reverts || []).length;
|
|
836
847
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
848
|
+
return {
|
|
849
|
+
content: [
|
|
850
|
+
{
|
|
851
|
+
type: "text",
|
|
852
|
+
text: `## SpecLock Health Check\n\nScore: **${score}/100** (Grade: ${grade})\nEvents: ${evtCount} | Reverts: ${revertCount}\n\n### Checks\n${checks.join("\n")}${agentTimeline}\n\n---\n*SpecLock v${VERSION} — Developed by ${AUTHOR}*`,
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
};
|
|
856
|
+
} catch (err) {
|
|
857
|
+
return {
|
|
858
|
+
content: [{ type: "text", text: `## SpecLock Health Check\n\nError: ${err.message}\n\nTry running speclock_init first to initialize the project.\n\n---\n*SpecLock v${VERSION}*` }],
|
|
859
|
+
};
|
|
860
|
+
}
|
|
845
861
|
}
|
|
846
862
|
);
|
|
847
863
|
|