speclock 3.5.4 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speclock",
3
- "version": "3.5.4",
3
+ "version": "4.1.0",
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",
@@ -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
 
@@ -117,15 +122,81 @@ export function checkConflict(root, proposedAction) {
117
122
  return result;
118
123
  }
119
124
 
125
+ /**
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.
133
+ */
120
134
  export async function checkConflictAsync(root, proposedAction) {
135
+ // 1. Always run the fast heuristic first
136
+ const heuristicResult = checkConflict(root, proposedAction);
137
+
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: zero signal anywhere → truly unrelated, skip LLM
146
+ if (maxScore === 0 && !heuristicResult.hasConflict) {
147
+ return heuristicResult;
148
+ }
149
+
150
+ // 4. Fast path: all conflicts are HIGH (>70%) → heuristic is certain, skip LLM
151
+ if (
152
+ heuristicResult.hasConflict &&
153
+ heuristicResult.conflictingLocks.every((c) => c.confidence > 70)
154
+ ) {
155
+ return heuristicResult;
156
+ }
157
+
158
+ // 5. GREY ZONE: some signal (1-70%) or low-confidence conflicts → call LLM
121
159
  try {
122
160
  const { llmCheckConflict } = await import("./llm-checker.js");
123
161
  const llmResult = await llmCheckConflict(root, proposedAction);
124
- if (llmResult) return llmResult;
162
+ if (llmResult) {
163
+ // Keep HIGH heuristic conflicts (>70%) — they're already certain
164
+ const highConfidence = heuristicResult.conflictingLocks.filter(
165
+ (c) => c.confidence > 70
166
+ );
167
+ const llmConflicts = llmResult.conflictingLocks || [];
168
+ const merged = [...highConfidence, ...llmConflicts];
169
+
170
+ // Deduplicate by lock text, keeping the higher-confidence entry
171
+ const byText = new Map();
172
+ for (const c of merged) {
173
+ const existing = byText.get(c.text);
174
+ if (!existing || c.confidence > existing.confidence) {
175
+ byText.set(c.text, c);
176
+ }
177
+ }
178
+ const unique = [...byText.values()];
179
+
180
+ if (unique.length === 0) {
181
+ return {
182
+ hasConflict: false,
183
+ conflictingLocks: [],
184
+ analysis: `Heuristic had partial signal, LLM verified as safe. No conflicts.`,
185
+ };
186
+ }
187
+
188
+ unique.sort((a, b) => b.confidence - a.confidence);
189
+ return {
190
+ hasConflict: true,
191
+ conflictingLocks: unique,
192
+ analysis: `${unique.length} conflict(s) confirmed (${highConfidence.length} heuristic + ${llmConflicts.length} LLM-verified).`,
193
+ };
194
+ }
125
195
  } catch (_) {
126
- // LLM checker not available — fall through
196
+ // LLM not available — return heuristic result as-is
127
197
  }
128
- return checkConflict(root, proposedAction);
198
+
199
+ return heuristicResult;
129
200
  }
130
201
 
131
202
  export function suggestLocks(root) {
@@ -594,3 +594,13 @@ export {
594
594
  revokeSession,
595
595
  listSessions,
596
596
  } from "./sso.js";
597
+
598
+ // --- Smart Lock Authoring (v4.0) ---
599
+ export {
600
+ normalizeLock,
601
+ detectVerbContamination,
602
+ extractSubjects,
603
+ compareSubjects,
604
+ extractLockSubject,
605
+ rewriteLock,
606
+ } from "./lock-author.js";
@@ -1,8 +1,10 @@
1
1
  // ===================================================================
2
2
  // SpecLock LLM-Powered Conflict Checker (Optional)
3
- // Uses OpenAI or Anthropic APIs for enterprise-grade detection.
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: env var > brain.json config
42
- const apiKey = process.env.SPECLOCK_LLM_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY;
43
- const provider = process.env.SPECLOCK_LLM_PROVIDER || "openai"; // "openai" or "anthropic"
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 || "openai",
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 === "anthropic") {
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);