speclock 4.0.0 → 4.1.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 +1 -1
- package/src/core/conflict.js +40 -24
- package/src/core/llm-checker.js +60 -6
- package/src/mcp/server.js +13 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speclock",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.1",
|
|
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. 100% detection, 0% false positives. 31 MCP tools + CLI. Enterprise platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
package/src/core/conflict.js
CHANGED
|
@@ -68,6 +68,7 @@ export function checkConflict(root, proposedAction) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const conflicting = [];
|
|
71
|
+
let maxNonConflictScore = 0;
|
|
71
72
|
for (const lock of activeLocks) {
|
|
72
73
|
const result = analyzeConflict(proposedAction, lock.text);
|
|
73
74
|
if (result.isConflict) {
|
|
@@ -79,6 +80,8 @@ export function checkConflict(root, proposedAction) {
|
|
|
79
80
|
level: result.level,
|
|
80
81
|
reasons: result.reasons,
|
|
81
82
|
});
|
|
83
|
+
} else if (result.confidence > maxNonConflictScore) {
|
|
84
|
+
maxNonConflictScore = result.confidence;
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -86,6 +89,7 @@ export function checkConflict(root, proposedAction) {
|
|
|
86
89
|
return {
|
|
87
90
|
hasConflict: false,
|
|
88
91
|
conflictingLocks: [],
|
|
92
|
+
_maxNonConflictScore: maxNonConflictScore,
|
|
89
93
|
analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
|
|
90
94
|
};
|
|
91
95
|
}
|
|
@@ -102,6 +106,7 @@ export function checkConflict(root, proposedAction) {
|
|
|
102
106
|
const result = {
|
|
103
107
|
hasConflict: true,
|
|
104
108
|
conflictingLocks: conflicting,
|
|
109
|
+
_maxNonConflictScore: maxNonConflictScore,
|
|
105
110
|
analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
|
|
106
111
|
};
|
|
107
112
|
|
|
@@ -118,51 +123,62 @@ export function checkConflict(root, proposedAction) {
|
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
121
|
-
* Async conflict check with LLM fallback for
|
|
122
|
-
* Strategy: Run heuristic first (fast, free).
|
|
123
|
-
*
|
|
124
|
-
*
|
|
126
|
+
* Async conflict check with LLM fallback for grey-zone cases.
|
|
127
|
+
* Strategy: Run heuristic first (fast, free, offline).
|
|
128
|
+
* - Score > 70% on ALL conflicts → trust heuristic (skip LLM)
|
|
129
|
+
* - Score == 0 everywhere (no signal at all) → trust heuristic (skip LLM)
|
|
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.
|
|
125
133
|
*/
|
|
126
134
|
export async function checkConflictAsync(root, proposedAction) {
|
|
127
135
|
// 1. Always run the fast heuristic first
|
|
128
136
|
const heuristicResult = checkConflict(root, proposedAction);
|
|
129
137
|
|
|
130
|
-
// 2.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// 2. Determine the max score across ALL locks (conflict + non-conflict)
|
|
139
|
+
const maxConflictScore = heuristicResult.conflictingLocks.length > 0
|
|
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
|
|
146
|
+
if (
|
|
147
|
+
heuristicResult.hasConflict &&
|
|
148
|
+
heuristicResult.conflictingLocks.every((c) => c.confidence > 70)
|
|
149
|
+
) {
|
|
150
|
+
return heuristicResult;
|
|
151
|
+
}
|
|
140
152
|
|
|
141
|
-
// 4.
|
|
153
|
+
// 4. Call LLM for everything else — including score 0.
|
|
154
|
+
// Score 0 means "heuristic vocabulary doesn't cover this domain",
|
|
155
|
+
// which is EXACTLY when an LLM (which knows every domain) adds value.
|
|
142
156
|
try {
|
|
143
157
|
const { llmCheckConflict } = await import("./llm-checker.js");
|
|
144
158
|
const llmResult = await llmCheckConflict(root, proposedAction);
|
|
145
159
|
if (llmResult) {
|
|
146
|
-
//
|
|
160
|
+
// Keep HIGH heuristic conflicts (>70%) — they're already certain
|
|
147
161
|
const highConfidence = heuristicResult.conflictingLocks.filter(
|
|
148
162
|
(c) => c.confidence > 70
|
|
149
163
|
);
|
|
150
164
|
const llmConflicts = llmResult.conflictingLocks || [];
|
|
151
165
|
const merged = [...highConfidence, ...llmConflicts];
|
|
152
166
|
|
|
153
|
-
// Deduplicate by lock text
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
// Deduplicate by lock text, keeping the higher-confidence entry
|
|
168
|
+
const byText = new Map();
|
|
169
|
+
for (const c of merged) {
|
|
170
|
+
const existing = byText.get(c.text);
|
|
171
|
+
if (!existing || c.confidence > existing.confidence) {
|
|
172
|
+
byText.set(c.text, c);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const unique = [...byText.values()];
|
|
160
176
|
|
|
161
177
|
if (unique.length === 0) {
|
|
162
178
|
return {
|
|
163
179
|
hasConflict: false,
|
|
164
180
|
conflictingLocks: [],
|
|
165
|
-
analysis: `Heuristic
|
|
181
|
+
analysis: `Heuristic had partial signal, LLM verified as safe. No conflicts.`,
|
|
166
182
|
};
|
|
167
183
|
}
|
|
168
184
|
|
package/src/core/llm-checker.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// ===================================================================
|
|
2
2
|
// SpecLock LLM-Powered Conflict Checker (Optional)
|
|
3
|
-
// Uses OpenAI or Anthropic APIs for
|
|
3
|
+
// Uses Gemini, OpenAI, or Anthropic APIs for universal detection.
|
|
4
4
|
// Zero mandatory dependencies — uses built-in fetch().
|
|
5
5
|
// Falls back gracefully if no API key is configured.
|
|
6
|
+
//
|
|
7
|
+
// Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
6
8
|
// ===================================================================
|
|
7
9
|
|
|
8
10
|
import { readBrain } from "./storage.js";
|
|
@@ -38,9 +40,22 @@ function cacheSet(key, value) {
|
|
|
38
40
|
// --- Configuration ---
|
|
39
41
|
|
|
40
42
|
function getConfig(root) {
|
|
41
|
-
// Priority:
|
|
42
|
-
const apiKey =
|
|
43
|
-
|
|
43
|
+
// Priority: explicit SPECLOCK key > provider-specific keys > brain.json
|
|
44
|
+
const apiKey =
|
|
45
|
+
process.env.SPECLOCK_LLM_KEY ||
|
|
46
|
+
process.env.GEMINI_API_KEY ||
|
|
47
|
+
process.env.GOOGLE_API_KEY ||
|
|
48
|
+
process.env.OPENAI_API_KEY ||
|
|
49
|
+
process.env.ANTHROPIC_API_KEY;
|
|
50
|
+
|
|
51
|
+
// Auto-detect provider from which env var is set
|
|
52
|
+
const provider =
|
|
53
|
+
process.env.SPECLOCK_LLM_PROVIDER ||
|
|
54
|
+
(process.env.SPECLOCK_LLM_KEY ? "gemini" : null) ||
|
|
55
|
+
(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY ? "gemini" : null) ||
|
|
56
|
+
(process.env.OPENAI_API_KEY ? "openai" : null) ||
|
|
57
|
+
(process.env.ANTHROPIC_API_KEY ? "anthropic" : null) ||
|
|
58
|
+
"gemini"; // default to gemini (cheapest, free tier)
|
|
44
59
|
|
|
45
60
|
if (apiKey) {
|
|
46
61
|
return { apiKey, provider };
|
|
@@ -52,7 +67,7 @@ function getConfig(root) {
|
|
|
52
67
|
if (brain?.facts?.llm) {
|
|
53
68
|
return {
|
|
54
69
|
apiKey: brain.facts.llm.apiKey,
|
|
55
|
-
provider: brain.facts.llm.provider || "
|
|
70
|
+
provider: brain.facts.llm.provider || "gemini",
|
|
56
71
|
};
|
|
57
72
|
}
|
|
58
73
|
} catch (_) {}
|
|
@@ -156,6 +171,43 @@ async function callAnthropic(apiKey, userPrompt) {
|
|
|
156
171
|
}
|
|
157
172
|
}
|
|
158
173
|
|
|
174
|
+
async function callGemini(apiKey, userPrompt) {
|
|
175
|
+
const resp = await fetch(
|
|
176
|
+
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
|
|
177
|
+
{
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
contents: [
|
|
182
|
+
{
|
|
183
|
+
parts: [
|
|
184
|
+
{ text: SYSTEM_PROMPT + "\n\n" + userPrompt },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
generationConfig: {
|
|
189
|
+
temperature: 0.1,
|
|
190
|
+
maxOutputTokens: 1000,
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!resp.ok) return null;
|
|
197
|
+
const data = await resp.json();
|
|
198
|
+
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
199
|
+
if (!content) return null;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(content);
|
|
203
|
+
} catch (_) {
|
|
204
|
+
// Try to extract JSON from markdown code block
|
|
205
|
+
const match = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
206
|
+
if (match) return JSON.parse(match[1]);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
159
211
|
// --- Main export ---
|
|
160
212
|
|
|
161
213
|
/**
|
|
@@ -199,7 +251,9 @@ export async function llmCheckConflict(root, proposedAction, activeLocks) {
|
|
|
199
251
|
// Call LLM
|
|
200
252
|
let llmResult = null;
|
|
201
253
|
try {
|
|
202
|
-
if (config.provider === "
|
|
254
|
+
if (config.provider === "gemini") {
|
|
255
|
+
llmResult = await callGemini(config.apiKey, userPrompt);
|
|
256
|
+
} else if (config.provider === "anthropic") {
|
|
203
257
|
llmResult = await callAnthropic(config.apiKey, userPrompt);
|
|
204
258
|
} else {
|
|
205
259
|
llmResult = await callOpenAI(config.apiKey, userPrompt);
|
package/src/mcp/server.js
CHANGED
|
@@ -470,10 +470,10 @@ server.tool(
|
|
|
470
470
|
// CONTINUITY PROTECTION TOOLS
|
|
471
471
|
// ========================================
|
|
472
472
|
|
|
473
|
-
// Tool 12: speclock_check_conflict (
|
|
473
|
+
// Tool 12: speclock_check_conflict (v4.1: hybrid heuristic + Gemini LLM)
|
|
474
474
|
server.tool(
|
|
475
475
|
"speclock_check_conflict",
|
|
476
|
-
"Check if a proposed action conflicts with any active SpecLock.
|
|
476
|
+
"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 (isError: true).",
|
|
477
477
|
{
|
|
478
478
|
proposedAction: z
|
|
479
479
|
.string()
|
|
@@ -481,18 +481,18 @@ server.tool(
|
|
|
481
481
|
.describe("Description of the action you plan to take"),
|
|
482
482
|
},
|
|
483
483
|
async ({ proposedAction }) => {
|
|
484
|
-
//
|
|
485
|
-
let result;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
484
|
+
// Hybrid check: heuristic first, LLM for grey-zone (1-70%)
|
|
485
|
+
let result = await checkConflictAsync(PROJECT_ROOT, proposedAction);
|
|
486
|
+
|
|
487
|
+
// If async hybrid returned no conflict, also check enforcer for hard mode
|
|
488
|
+
if (!result.hasConflict) {
|
|
489
|
+
const enforced = enforceConflictCheck(PROJECT_ROOT, proposedAction);
|
|
490
|
+
if (enforced.blocked) {
|
|
491
|
+
return {
|
|
492
|
+
content: [{ type: "text", text: enforced.analysis }],
|
|
493
|
+
isError: true,
|
|
494
|
+
};
|
|
491
495
|
}
|
|
492
|
-
} catch (_) {}
|
|
493
|
-
|
|
494
|
-
if (!result) {
|
|
495
|
-
result = enforceConflictCheck(PROJECT_ROOT, proposedAction);
|
|
496
496
|
}
|
|
497
497
|
|
|
498
498
|
// In hard mode with blocking conflict, return isError: true
|