speclock 2.1.0 → 2.5.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/README.md +101 -6
- package/package.json +4 -3
- package/src/cli/index.js +107 -3
- package/src/core/compliance.js +1 -1
- package/src/core/conflict.js +363 -0
- package/src/core/enforcer.js +314 -0
- package/src/core/engine.js +87 -781
- package/src/core/memory.js +191 -0
- package/src/core/pre-commit-semantic.js +284 -0
- package/src/core/sessions.js +128 -0
- package/src/core/tracking.js +98 -0
- package/src/mcp/http-server.js +42 -5
- package/src/mcp/server.js +183 -4
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Conflict Detection Module
|
|
3
|
+
* Conflict checking, drift detection, lock suggestions, audit.
|
|
4
|
+
* Extracted from engine.js for modularity.
|
|
5
|
+
*
|
|
6
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import {
|
|
12
|
+
nowIso,
|
|
13
|
+
readBrain,
|
|
14
|
+
writeBrain,
|
|
15
|
+
readEvents,
|
|
16
|
+
addViolation,
|
|
17
|
+
} from "./storage.js";
|
|
18
|
+
import { getStagedFiles } from "./git.js";
|
|
19
|
+
import { analyzeConflict } from "./semantics.js";
|
|
20
|
+
import { ensureInit } from "./memory.js";
|
|
21
|
+
|
|
22
|
+
// --- Legacy helpers (kept for pre-commit audit backward compat) ---
|
|
23
|
+
|
|
24
|
+
const NEGATION_WORDS = ["no", "not", "never", "without", "dont", "don't", "cannot", "can't", "shouldn't", "mustn't", "avoid", "prevent", "prohibit", "forbid", "disallow"];
|
|
25
|
+
|
|
26
|
+
function hasNegation(text) {
|
|
27
|
+
const lower = text.toLowerCase();
|
|
28
|
+
return NEGATION_WORDS.some((neg) => lower.includes(neg));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FILE_KEYWORD_PATTERNS = [
|
|
32
|
+
{ keywords: ["auth", "authentication", "login", "signup", "signin", "sign-in", "sign-up"], patterns: ["**/Auth*", "**/auth*", "**/Login*", "**/login*", "**/SignUp*", "**/signup*", "**/SignIn*", "**/signin*", "**/*Auth*", "**/*auth*"] },
|
|
33
|
+
{ keywords: ["database", "db", "supabase", "firebase", "mongo", "postgres", "sql", "prisma"], patterns: ["**/supabase*", "**/firebase*", "**/database*", "**/db.*", "**/db/**", "**/prisma/**", "**/*Client*", "**/*client*"] },
|
|
34
|
+
{ keywords: ["payment", "pay", "stripe", "billing", "checkout", "subscription"], patterns: ["**/payment*", "**/Payment*", "**/pay*", "**/Pay*", "**/stripe*", "**/Stripe*", "**/billing*", "**/Billing*", "**/checkout*", "**/Checkout*"] },
|
|
35
|
+
{ keywords: ["api", "endpoint", "route", "routes"], patterns: ["**/api/**", "**/routes/**", "**/endpoints/**"] },
|
|
36
|
+
{ keywords: ["config", "configuration", "settings", "env"], patterns: ["**/config*", "**/Config*", "**/settings*", "**/Settings*"] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function patternMatchesFile(pattern, filePath) {
|
|
40
|
+
const clean = pattern.replace(/\\/g, "/");
|
|
41
|
+
const fileLower = filePath.toLowerCase();
|
|
42
|
+
const patternLower = clean.toLowerCase();
|
|
43
|
+
const namePattern = patternLower.replace(/^\*\*\//, "");
|
|
44
|
+
if (!namePattern.includes("/")) {
|
|
45
|
+
const fileName = fileLower.split("/").pop();
|
|
46
|
+
const regex = new RegExp("^" + namePattern.replace(/\*/g, ".*") + "$");
|
|
47
|
+
if (regex.test(fileName)) return true;
|
|
48
|
+
const corePattern = namePattern.replace(/\*/g, "");
|
|
49
|
+
if (corePattern.length > 2 && fileName.includes(corePattern)) return true;
|
|
50
|
+
}
|
|
51
|
+
const regex = new RegExp("^" + patternLower.replace(/\*\*\//g, "(.*/)?").replace(/\*/g, "[^/]*") + "$");
|
|
52
|
+
return regex.test(fileLower);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const GUARD_TAG = "SPECLOCK-GUARD";
|
|
56
|
+
|
|
57
|
+
// --- Core functions ---
|
|
58
|
+
|
|
59
|
+
export function checkConflict(root, proposedAction) {
|
|
60
|
+
const brain = ensureInit(root);
|
|
61
|
+
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
62
|
+
if (activeLocks.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
hasConflict: false,
|
|
65
|
+
conflictingLocks: [],
|
|
66
|
+
analysis: "No active locks. No constraints to check against.",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const conflicting = [];
|
|
71
|
+
for (const lock of activeLocks) {
|
|
72
|
+
const result = analyzeConflict(proposedAction, lock.text);
|
|
73
|
+
if (result.isConflict) {
|
|
74
|
+
conflicting.push({
|
|
75
|
+
id: lock.id,
|
|
76
|
+
text: lock.text,
|
|
77
|
+
matchedKeywords: [],
|
|
78
|
+
confidence: result.confidence,
|
|
79
|
+
level: result.level,
|
|
80
|
+
reasons: result.reasons,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (conflicting.length === 0) {
|
|
86
|
+
return {
|
|
87
|
+
hasConflict: false,
|
|
88
|
+
conflictingLocks: [],
|
|
89
|
+
analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
conflicting.sort((a, b) => b.confidence - a.confidence);
|
|
94
|
+
|
|
95
|
+
const details = conflicting
|
|
96
|
+
.map(
|
|
97
|
+
(c) =>
|
|
98
|
+
`- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
|
|
99
|
+
)
|
|
100
|
+
.join("\n");
|
|
101
|
+
|
|
102
|
+
const result = {
|
|
103
|
+
hasConflict: true,
|
|
104
|
+
conflictingLocks: conflicting,
|
|
105
|
+
analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
addViolation(brain, {
|
|
109
|
+
at: nowIso(),
|
|
110
|
+
action: proposedAction,
|
|
111
|
+
locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
|
|
112
|
+
topLevel: conflicting[0].level,
|
|
113
|
+
topConfidence: conflicting[0].confidence,
|
|
114
|
+
});
|
|
115
|
+
writeBrain(root, brain);
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function checkConflictAsync(root, proposedAction) {
|
|
121
|
+
try {
|
|
122
|
+
const { llmCheckConflict } = await import("./llm-checker.js");
|
|
123
|
+
const llmResult = await llmCheckConflict(root, proposedAction);
|
|
124
|
+
if (llmResult) return llmResult;
|
|
125
|
+
} catch (_) {
|
|
126
|
+
// LLM checker not available — fall through
|
|
127
|
+
}
|
|
128
|
+
return checkConflict(root, proposedAction);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function suggestLocks(root) {
|
|
132
|
+
const brain = ensureInit(root);
|
|
133
|
+
const suggestions = [];
|
|
134
|
+
|
|
135
|
+
for (const dec of brain.decisions) {
|
|
136
|
+
const lower = dec.text.toLowerCase();
|
|
137
|
+
if (/\b(always|must|only|exclusively|never|required)\b/.test(lower)) {
|
|
138
|
+
suggestions.push({
|
|
139
|
+
text: dec.text,
|
|
140
|
+
source: "decision",
|
|
141
|
+
sourceId: dec.id,
|
|
142
|
+
reason: `Decision contains strong commitment language — consider promoting to a lock`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const note of brain.notes) {
|
|
148
|
+
const lower = note.text.toLowerCase();
|
|
149
|
+
if (/\b(never|must not|do not|don't|avoid|prohibit|forbidden)\b/.test(lower)) {
|
|
150
|
+
suggestions.push({
|
|
151
|
+
text: note.text,
|
|
152
|
+
source: "note",
|
|
153
|
+
sourceId: note.id,
|
|
154
|
+
reason: `Note contains prohibitive language — consider promoting to a lock`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const existingLockTexts = brain.specLock.items
|
|
160
|
+
.filter((l) => l.active)
|
|
161
|
+
.map((l) => l.text.toLowerCase());
|
|
162
|
+
|
|
163
|
+
const commonPatterns = [
|
|
164
|
+
{ keyword: "api", suggestion: "No breaking changes to public API" },
|
|
165
|
+
{ keyword: "database", suggestion: "No destructive database migrations without backup" },
|
|
166
|
+
{ keyword: "deploy", suggestion: "All deployments must pass CI checks" },
|
|
167
|
+
{ keyword: "security", suggestion: "No secrets or credentials in source code" },
|
|
168
|
+
{ keyword: "test", suggestion: "No merging without passing tests" },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const allText = [
|
|
172
|
+
brain.goal.text,
|
|
173
|
+
...brain.decisions.map((d) => d.text),
|
|
174
|
+
...brain.notes.map((n) => n.text),
|
|
175
|
+
].join(" ").toLowerCase();
|
|
176
|
+
|
|
177
|
+
for (const pattern of commonPatterns) {
|
|
178
|
+
if (allText.includes(pattern.keyword)) {
|
|
179
|
+
const alreadyLocked = existingLockTexts.some((t) =>
|
|
180
|
+
t.includes(pattern.keyword)
|
|
181
|
+
);
|
|
182
|
+
if (!alreadyLocked) {
|
|
183
|
+
suggestions.push({
|
|
184
|
+
text: pattern.suggestion,
|
|
185
|
+
source: "pattern",
|
|
186
|
+
sourceId: null,
|
|
187
|
+
reason: `Project mentions "${pattern.keyword}" but has no lock protecting it`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { suggestions, totalLocks: brain.specLock.items.filter((l) => l.active).length };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function detectDrift(root) {
|
|
197
|
+
const brain = ensureInit(root);
|
|
198
|
+
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
199
|
+
if (activeLocks.length === 0) {
|
|
200
|
+
return { drifts: [], status: "no_locks", message: "No active locks to check against." };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const drifts = [];
|
|
204
|
+
|
|
205
|
+
for (const change of brain.state.recentChanges) {
|
|
206
|
+
for (const lock of activeLocks) {
|
|
207
|
+
const result = analyzeConflict(change.summary, lock.text);
|
|
208
|
+
if (result.isConflict) {
|
|
209
|
+
drifts.push({
|
|
210
|
+
lockId: lock.id,
|
|
211
|
+
lockText: lock.text,
|
|
212
|
+
changeEventId: change.eventId,
|
|
213
|
+
changeSummary: change.summary,
|
|
214
|
+
changeAt: change.at,
|
|
215
|
+
matchedTerms: result.reasons,
|
|
216
|
+
severity: result.level === "HIGH" ? "high" : "medium",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const revert of brain.state.reverts) {
|
|
223
|
+
drifts.push({
|
|
224
|
+
lockId: null,
|
|
225
|
+
lockText: "(git revert detected)",
|
|
226
|
+
changeEventId: revert.eventId,
|
|
227
|
+
changeSummary: `Git ${revert.kind} to ${revert.target.substring(0, 12)}`,
|
|
228
|
+
changeAt: revert.at,
|
|
229
|
+
matchedTerms: ["revert"],
|
|
230
|
+
severity: "high",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const status = drifts.length === 0 ? "clean" : "drift_detected";
|
|
235
|
+
const message = drifts.length === 0
|
|
236
|
+
? `All clear. ${activeLocks.length} lock(s) checked against ${brain.state.recentChanges.length} recent change(s). No drift detected.`
|
|
237
|
+
: `WARNING: ${drifts.length} potential drift(s) detected. Review immediately.`;
|
|
238
|
+
|
|
239
|
+
return { drifts, status, message };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function generateReport(root) {
|
|
243
|
+
const brain = ensureInit(root);
|
|
244
|
+
const violations = brain.state.violations || [];
|
|
245
|
+
|
|
246
|
+
if (violations.length === 0) {
|
|
247
|
+
return {
|
|
248
|
+
totalViolations: 0,
|
|
249
|
+
violationsByLock: {},
|
|
250
|
+
mostTestedLocks: [],
|
|
251
|
+
recentViolations: [],
|
|
252
|
+
summary: "No violations recorded yet. SpecLock is watching.",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const byLock = {};
|
|
257
|
+
for (const v of violations) {
|
|
258
|
+
for (const lock of v.locks) {
|
|
259
|
+
if (!byLock[lock.text]) {
|
|
260
|
+
byLock[lock.text] = { count: 0, lockId: lock.id, text: lock.text };
|
|
261
|
+
}
|
|
262
|
+
byLock[lock.text].count++;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const mostTested = Object.values(byLock).sort((a, b) => b.count - a.count);
|
|
267
|
+
const recent = violations.slice(0, 10).map((v) => ({
|
|
268
|
+
at: v.at,
|
|
269
|
+
action: v.action,
|
|
270
|
+
topLevel: v.topLevel,
|
|
271
|
+
topConfidence: v.topConfidence,
|
|
272
|
+
lockCount: v.locks.length,
|
|
273
|
+
}));
|
|
274
|
+
|
|
275
|
+
const oldest = violations[violations.length - 1];
|
|
276
|
+
const newest = violations[0];
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
totalViolations: violations.length,
|
|
280
|
+
timeRange: { from: oldest.at, to: newest.at },
|
|
281
|
+
violationsByLock: byLock,
|
|
282
|
+
mostTestedLocks: mostTested.slice(0, 5),
|
|
283
|
+
recentViolations: recent,
|
|
284
|
+
summary: `SpecLock blocked ${violations.length} violation(s). Most tested lock: "${mostTested[0].text}" (${mostTested[0].count} blocks).`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function auditStagedFiles(root) {
|
|
289
|
+
const brain = ensureInit(root);
|
|
290
|
+
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
291
|
+
|
|
292
|
+
if (activeLocks.length === 0) {
|
|
293
|
+
return { passed: true, violations: [], checkedFiles: 0, activeLocks: 0, message: "No active locks. Audit passed." };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const stagedFiles = getStagedFiles(root);
|
|
297
|
+
if (stagedFiles.length === 0) {
|
|
298
|
+
return { passed: true, violations: [], checkedFiles: 0, activeLocks: activeLocks.length, message: "No staged files. Audit passed." };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const violations = [];
|
|
302
|
+
|
|
303
|
+
for (const file of stagedFiles) {
|
|
304
|
+
const fullPath = path.join(root, file);
|
|
305
|
+
if (fs.existsSync(fullPath)) {
|
|
306
|
+
try {
|
|
307
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
308
|
+
if (content.includes(GUARD_TAG)) {
|
|
309
|
+
violations.push({
|
|
310
|
+
file,
|
|
311
|
+
reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
|
|
312
|
+
lockText: "(file-level guard)",
|
|
313
|
+
severity: "HIGH",
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
} catch (_) {}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const fileLower = file.toLowerCase();
|
|
321
|
+
for (const lock of activeLocks) {
|
|
322
|
+
const lockLower = lock.text.toLowerCase();
|
|
323
|
+
const lockHasNegation = hasNegation(lockLower);
|
|
324
|
+
if (!lockHasNegation) continue;
|
|
325
|
+
|
|
326
|
+
for (const group of FILE_KEYWORD_PATTERNS) {
|
|
327
|
+
const lockMatchesKeyword = group.keywords.some((kw) => lockLower.includes(kw));
|
|
328
|
+
if (!lockMatchesKeyword) continue;
|
|
329
|
+
|
|
330
|
+
const fileMatchesPattern = group.patterns.some((pattern) => patternMatchesFile(pattern, fileLower) || patternMatchesFile(pattern, fileLower.split("/").pop()));
|
|
331
|
+
if (fileMatchesPattern) {
|
|
332
|
+
violations.push({
|
|
333
|
+
file,
|
|
334
|
+
reason: `File matches lock keyword pattern`,
|
|
335
|
+
lockText: lock.text,
|
|
336
|
+
severity: "MEDIUM",
|
|
337
|
+
});
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
const unique = violations.filter((v) => {
|
|
346
|
+
if (seen.has(v.file)) return false;
|
|
347
|
+
seen.add(v.file);
|
|
348
|
+
return true;
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const passed = unique.length === 0;
|
|
352
|
+
const message = passed
|
|
353
|
+
? `Audit passed. ${stagedFiles.length} file(s) checked against ${activeLocks.length} lock(s).`
|
|
354
|
+
: `AUDIT FAILED: ${unique.length} violation(s) in ${stagedFiles.length} staged file(s).`;
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
passed,
|
|
358
|
+
violations: unique,
|
|
359
|
+
checkedFiles: stagedFiles.length,
|
|
360
|
+
activeLocks: activeLocks.length,
|
|
361
|
+
message,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Hard Enforcement Engine
|
|
3
|
+
* Moves from advisory-only to blocking enforcement.
|
|
4
|
+
*
|
|
5
|
+
* Modes:
|
|
6
|
+
* - "advisory" (default): Returns warnings, AI decides what to do
|
|
7
|
+
* - "hard": Returns isError:true in MCP, exit code 1 in CLI, 409 in HTTP
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Configurable block threshold (default 70%)
|
|
11
|
+
* - Override mechanism with reason logging to audit trail
|
|
12
|
+
* - Escalation: 3+ overrides on same lock → auto-note for review
|
|
13
|
+
*
|
|
14
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readBrain,
|
|
19
|
+
writeBrain,
|
|
20
|
+
appendEvent,
|
|
21
|
+
bumpEvents,
|
|
22
|
+
newId,
|
|
23
|
+
nowIso,
|
|
24
|
+
addViolation,
|
|
25
|
+
} from "./storage.js";
|
|
26
|
+
import { analyzeConflict } from "./semantics.js";
|
|
27
|
+
|
|
28
|
+
// --- Enforcement config helpers ---
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get enforcement config from brain, with defaults.
|
|
32
|
+
*/
|
|
33
|
+
export function getEnforcementConfig(brain) {
|
|
34
|
+
const defaults = {
|
|
35
|
+
mode: "advisory", // "advisory" | "hard"
|
|
36
|
+
blockThreshold: 70, // minimum confidence % to block in hard mode
|
|
37
|
+
allowOverride: true, // whether overrides are permitted
|
|
38
|
+
escalationLimit: 3, // overrides before auto-note
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (!brain.enforcement) return { ...defaults };
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
...defaults,
|
|
45
|
+
...brain.enforcement,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set enforcement mode on a project.
|
|
51
|
+
*/
|
|
52
|
+
export function setEnforcementMode(root, mode, options = {}) {
|
|
53
|
+
const brain = readBrain(root);
|
|
54
|
+
if (!brain) {
|
|
55
|
+
return { success: false, error: "SpecLock not initialized." };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (mode !== "advisory" && mode !== "hard") {
|
|
59
|
+
return { success: false, error: `Invalid mode: "${mode}". Must be "advisory" or "hard".` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!brain.enforcement) {
|
|
63
|
+
brain.enforcement = {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
brain.enforcement.mode = mode;
|
|
67
|
+
if (options.blockThreshold !== undefined) {
|
|
68
|
+
brain.enforcement.blockThreshold = Math.max(0, Math.min(100, options.blockThreshold));
|
|
69
|
+
}
|
|
70
|
+
if (options.allowOverride !== undefined) {
|
|
71
|
+
brain.enforcement.allowOverride = !!options.allowOverride;
|
|
72
|
+
}
|
|
73
|
+
if (options.escalationLimit !== undefined) {
|
|
74
|
+
brain.enforcement.escalationLimit = Math.max(1, options.escalationLimit);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const eventId = newId("evt");
|
|
78
|
+
const event = {
|
|
79
|
+
eventId,
|
|
80
|
+
type: "enforcement_mode_changed",
|
|
81
|
+
at: nowIso(),
|
|
82
|
+
files: [],
|
|
83
|
+
summary: `Enforcement mode set to: ${mode}${options.blockThreshold ? ` (threshold: ${options.blockThreshold}%)` : ""}`,
|
|
84
|
+
patchPath: "",
|
|
85
|
+
};
|
|
86
|
+
bumpEvents(brain, eventId);
|
|
87
|
+
appendEvent(root, event);
|
|
88
|
+
writeBrain(root, brain);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
success: true,
|
|
92
|
+
mode,
|
|
93
|
+
config: getEnforcementConfig(brain),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Enforce a conflict check — returns enriched result with enforcement metadata.
|
|
99
|
+
* This wraps the existing checkConflict logic with hard/advisory behavior.
|
|
100
|
+
*/
|
|
101
|
+
export function enforceConflictCheck(root, proposedAction) {
|
|
102
|
+
const brain = readBrain(root);
|
|
103
|
+
if (!brain) {
|
|
104
|
+
return {
|
|
105
|
+
hasConflict: false,
|
|
106
|
+
blocked: false,
|
|
107
|
+
mode: "advisory",
|
|
108
|
+
conflictingLocks: [],
|
|
109
|
+
analysis: "SpecLock not initialized. No enforcement.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const config = getEnforcementConfig(brain);
|
|
114
|
+
const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
|
|
115
|
+
|
|
116
|
+
if (activeLocks.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
hasConflict: false,
|
|
119
|
+
blocked: false,
|
|
120
|
+
mode: config.mode,
|
|
121
|
+
conflictingLocks: [],
|
|
122
|
+
analysis: "No active locks. No constraints to check against.",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Run semantic analysis against all active locks
|
|
127
|
+
const conflicting = [];
|
|
128
|
+
for (const lock of activeLocks) {
|
|
129
|
+
const result = analyzeConflict(proposedAction, lock.text);
|
|
130
|
+
if (result.isConflict) {
|
|
131
|
+
conflicting.push({
|
|
132
|
+
id: lock.id,
|
|
133
|
+
text: lock.text,
|
|
134
|
+
confidence: result.confidence,
|
|
135
|
+
level: result.level,
|
|
136
|
+
reasons: result.reasons,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (conflicting.length === 0) {
|
|
142
|
+
return {
|
|
143
|
+
hasConflict: false,
|
|
144
|
+
blocked: false,
|
|
145
|
+
mode: config.mode,
|
|
146
|
+
conflictingLocks: [],
|
|
147
|
+
analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Sort by confidence descending
|
|
152
|
+
conflicting.sort((a, b) => b.confidence - a.confidence);
|
|
153
|
+
|
|
154
|
+
// Determine if this should be BLOCKED (hard mode + above threshold)
|
|
155
|
+
const topConfidence = conflicting[0].confidence;
|
|
156
|
+
const meetsThreshold = topConfidence >= config.blockThreshold;
|
|
157
|
+
const blocked = config.mode === "hard" && meetsThreshold;
|
|
158
|
+
|
|
159
|
+
const details = conflicting
|
|
160
|
+
.map(
|
|
161
|
+
(c) =>
|
|
162
|
+
`- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
|
|
163
|
+
)
|
|
164
|
+
.join("\n");
|
|
165
|
+
|
|
166
|
+
// Record violation
|
|
167
|
+
addViolation(brain, {
|
|
168
|
+
at: nowIso(),
|
|
169
|
+
action: proposedAction,
|
|
170
|
+
locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
|
|
171
|
+
topLevel: conflicting[0].level,
|
|
172
|
+
topConfidence,
|
|
173
|
+
enforced: blocked,
|
|
174
|
+
mode: config.mode,
|
|
175
|
+
});
|
|
176
|
+
writeBrain(root, brain);
|
|
177
|
+
|
|
178
|
+
const modeLabel = blocked
|
|
179
|
+
? "BLOCKED — Hard enforcement active. This action cannot proceed."
|
|
180
|
+
: "WARNING — Advisory mode. Review before proceeding.";
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
hasConflict: true,
|
|
184
|
+
blocked,
|
|
185
|
+
mode: config.mode,
|
|
186
|
+
threshold: config.blockThreshold,
|
|
187
|
+
topConfidence,
|
|
188
|
+
conflictingLocks: conflicting,
|
|
189
|
+
analysis: `${modeLabel}\n\nConflict with ${conflicting.length} lock(s):\n${details}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Override a lock for a specific action, with a reason.
|
|
195
|
+
* Logged to audit trail. Triggers escalation if overridden too many times.
|
|
196
|
+
*/
|
|
197
|
+
export function overrideLock(root, lockId, action, reason) {
|
|
198
|
+
const brain = readBrain(root);
|
|
199
|
+
if (!brain) {
|
|
200
|
+
return { success: false, error: "SpecLock not initialized." };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const config = getEnforcementConfig(brain);
|
|
204
|
+
if (!config.allowOverride) {
|
|
205
|
+
return { success: false, error: "Overrides are disabled for this project." };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const lock = (brain.specLock?.items || []).find((l) => l.id === lockId);
|
|
209
|
+
if (!lock) {
|
|
210
|
+
return { success: false, error: `Lock not found: ${lockId}` };
|
|
211
|
+
}
|
|
212
|
+
if (!lock.active) {
|
|
213
|
+
return { success: false, error: `Lock is already inactive: ${lockId}` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Initialize overrides tracking on the lock
|
|
217
|
+
if (!lock.overrides) lock.overrides = [];
|
|
218
|
+
lock.overrides.push({
|
|
219
|
+
at: nowIso(),
|
|
220
|
+
action,
|
|
221
|
+
reason,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Record override event in audit trail
|
|
225
|
+
const eventId = newId("evt");
|
|
226
|
+
const event = {
|
|
227
|
+
eventId,
|
|
228
|
+
type: "lock_overridden",
|
|
229
|
+
at: nowIso(),
|
|
230
|
+
files: [],
|
|
231
|
+
summary: `Lock overridden: "${lock.text.substring(0, 60)}" — Reason: ${reason.substring(0, 100)}`,
|
|
232
|
+
patchPath: "",
|
|
233
|
+
meta: { lockId, action, reason },
|
|
234
|
+
};
|
|
235
|
+
bumpEvents(brain, eventId);
|
|
236
|
+
appendEvent(root, event);
|
|
237
|
+
|
|
238
|
+
// Check for escalation
|
|
239
|
+
let escalated = false;
|
|
240
|
+
const overrideCount = lock.overrides.length;
|
|
241
|
+
if (overrideCount >= config.escalationLimit) {
|
|
242
|
+
escalated = true;
|
|
243
|
+
|
|
244
|
+
// Auto-create a note flagging this for review
|
|
245
|
+
const noteId = newId("note");
|
|
246
|
+
brain.notes.unshift({
|
|
247
|
+
id: noteId,
|
|
248
|
+
text: `ESCALATION: Lock "${lock.text}" has been overridden ${overrideCount} times. Review whether this lock is still appropriate or if it should be removed.`,
|
|
249
|
+
createdAt: nowIso(),
|
|
250
|
+
pinned: true,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const noteEventId = newId("evt");
|
|
254
|
+
const noteEvent = {
|
|
255
|
+
eventId: noteEventId,
|
|
256
|
+
type: "note_added",
|
|
257
|
+
at: nowIso(),
|
|
258
|
+
files: [],
|
|
259
|
+
summary: `Escalation note: Lock "${lock.text.substring(0, 40)}" overridden ${overrideCount} times`,
|
|
260
|
+
patchPath: "",
|
|
261
|
+
};
|
|
262
|
+
bumpEvents(brain, noteEventId);
|
|
263
|
+
appendEvent(root, noteEvent);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
writeBrain(root, brain);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
success: true,
|
|
270
|
+
lockId,
|
|
271
|
+
lockText: lock.text,
|
|
272
|
+
overrideCount,
|
|
273
|
+
escalated,
|
|
274
|
+
escalationMessage: escalated
|
|
275
|
+
? `WARNING: This lock has been overridden ${overrideCount} times (limit: ${config.escalationLimit}). An escalation note has been created for review.`
|
|
276
|
+
: null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get override history for a specific lock or all locks.
|
|
282
|
+
*/
|
|
283
|
+
export function getOverrideHistory(root, lockId = null) {
|
|
284
|
+
const brain = readBrain(root);
|
|
285
|
+
if (!brain) {
|
|
286
|
+
return { overrides: [], total: 0 };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const locks = (brain.specLock?.items || []).filter((l) => {
|
|
290
|
+
if (lockId) return l.id === lockId;
|
|
291
|
+
return l.overrides && l.overrides.length > 0;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const overrides = [];
|
|
295
|
+
for (const lock of locks) {
|
|
296
|
+
if (!lock.overrides) continue;
|
|
297
|
+
for (const ov of lock.overrides) {
|
|
298
|
+
overrides.push({
|
|
299
|
+
lockId: lock.id,
|
|
300
|
+
lockText: lock.text,
|
|
301
|
+
lockActive: lock.active,
|
|
302
|
+
...ov,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Sort by date descending
|
|
308
|
+
overrides.sort((a, b) => (b.at > a.at ? 1 : -1));
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
overrides,
|
|
312
|
+
total: overrides.length,
|
|
313
|
+
};
|
|
314
|
+
}
|