speclock 2.1.1 → 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 +32 -4
- 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
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ No other tool does this. Not Claude's native memory. Not Mem0. Not CLAUDE.md fil
|
|
|
58
58
|
| Git-aware (checkpoints, rollback) | No | No | No | **Yes** |
|
|
59
59
|
| Drift detection | No | No | No | **Yes — scans changes against locks** |
|
|
60
60
|
| CI/CD integration | No | No | No | **Yes — GitHub Actions** |
|
|
61
|
+
| **Hard enforcement (block violations)** | No | No | No | **Yes — hard mode blocks above threshold** |
|
|
61
62
|
| Multi-agent timeline | No | No | No | **Yes** |
|
|
62
63
|
| Cross-platform | Claude only | MCP only | Tool-specific | **Universal (MCP + npm)** |
|
|
63
64
|
|
|
@@ -192,10 +193,10 @@ Result: NO CONFLICT (confidence: 7%)
|
|
|
192
193
|
| Mode | Platforms | How It Works |
|
|
193
194
|
|------|-----------|--------------|
|
|
194
195
|
| **MCP Remote** | Lovable, bolt.diy, Base44 | Connect via URL — no install needed |
|
|
195
|
-
| **MCP Local** | Claude Code, Cursor, Windsurf, Cline | `npx speclock serve` —
|
|
196
|
+
| **MCP Local** | Claude Code, Cursor, Windsurf, Cline | `npx speclock serve` — 28 tools via MCP |
|
|
196
197
|
| **npm File-Based** | Bolt.new, Aider, Rocket.new | `npx speclock setup` — AI reads SPECLOCK.md + uses CLI |
|
|
197
198
|
|
|
198
|
-
##
|
|
199
|
+
## 28 MCP Tools
|
|
199
200
|
|
|
200
201
|
### Memory Management
|
|
201
202
|
| Tool | Purpose |
|
|
@@ -249,6 +250,14 @@ Result: NO CONFLICT (confidence: 7%)
|
|
|
249
250
|
| `speclock_verify_audit` | Verify HMAC audit chain integrity — tamper detection |
|
|
250
251
|
| `speclock_export_compliance` | Generate SOC 2 / HIPAA / CSV compliance reports |
|
|
251
252
|
|
|
253
|
+
### Hard Enforcement (v2.5)
|
|
254
|
+
| Tool | Purpose |
|
|
255
|
+
|------|---------|
|
|
256
|
+
| `speclock_set_enforcement` | Set enforcement mode: advisory (warn) or hard (block) |
|
|
257
|
+
| `speclock_override_lock` | Override a lock with justification — logged to audit trail |
|
|
258
|
+
| `speclock_semantic_audit` | Semantic pre-commit: analyze code changes vs locks |
|
|
259
|
+
| `speclock_override_history` | View lock override history for audit review |
|
|
260
|
+
|
|
252
261
|
## Auto-Guard: Locks That Actually Work
|
|
253
262
|
|
|
254
263
|
When you add a lock, SpecLock **automatically finds and guards related files**:
|
|
@@ -317,6 +326,12 @@ speclock audit-verify # Verify HMAC audit chain integrity
|
|
|
317
326
|
speclock export --format <soc2|hipaa|csv> # Compliance export
|
|
318
327
|
speclock license # Show license tier and usage
|
|
319
328
|
|
|
329
|
+
# Hard Enforcement (v2.5)
|
|
330
|
+
speclock enforce <advisory|hard> # Set enforcement mode
|
|
331
|
+
speclock override <lockId> <reason> # Override a lock with justification
|
|
332
|
+
speclock overrides [--lock <id>] # Show override history
|
|
333
|
+
speclock audit-semantic # Semantic pre-commit audit
|
|
334
|
+
|
|
320
335
|
# Other
|
|
321
336
|
speclock status # Show brain summary
|
|
322
337
|
speclock serve [--project <path>] # Start MCP server
|
|
@@ -372,6 +387,19 @@ SOC 2 reports include: constraint change history, access logs, decision audit tr
|
|
|
372
387
|
```
|
|
373
388
|
Audits changed files against locks, posts PR comments, fails workflow on violations.
|
|
374
389
|
|
|
390
|
+
## Hard Enforcement (v2.5)
|
|
391
|
+
|
|
392
|
+
### Advisory vs Hard Mode
|
|
393
|
+
```
|
|
394
|
+
Advisory mode (default): AI gets a warning, decides what to do
|
|
395
|
+
Hard mode: AI is BLOCKED — cannot proceed (MCP returns isError: true)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- **Block threshold**: Configurable (default 70%). Only HIGH confidence conflicts block.
|
|
399
|
+
- **Override mechanism**: `speclock_override_lock` with a reason (logged to audit trail)
|
|
400
|
+
- **Escalation**: Lock overridden 3+ times → auto-creates a review note
|
|
401
|
+
- **Semantic pre-commit**: Parses actual git diff content, runs semantic analysis against locks
|
|
402
|
+
|
|
375
403
|
---
|
|
376
404
|
|
|
377
405
|
## Architecture
|
|
@@ -382,7 +410,7 @@ Audits changed files against locks, posts PR comments, fails workflow on violati
|
|
|
382
410
|
└──────────────┬──────────────────┬────────────────────┘
|
|
383
411
|
│ │
|
|
384
412
|
MCP Protocol File-Based (npm)
|
|
385
|
-
(
|
|
413
|
+
(28 tool calls) (reads SPECLOCK.md +
|
|
386
414
|
.speclock/context/latest.md,
|
|
387
415
|
runs CLI commands)
|
|
388
416
|
│ │
|
|
@@ -419,4 +447,4 @@ MIT License - see [LICENSE](LICENSE) file.
|
|
|
419
447
|
|
|
420
448
|
---
|
|
421
449
|
|
|
422
|
-
*SpecLock v2.
|
|
450
|
+
*SpecLock v2.5.0 — Semantic conflict detection + enterprise audit & compliance. 100% detection, 0% false positives. HMAC audit chain, SOC 2/HIPAA exports. Hard enforcement mode. Because remembering isn't enough — AI needs to respect boundaries.*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speclock",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "AI constraint engine with semantic
|
|
3
|
+
"version": "2.5.0",
|
|
4
|
+
"description": "AI constraint engine with hard enforcement, semantic pre-commit, HMAC audit chain, SOC 2/HIPAA compliance. 100% detection, 0% false positives. 28 MCP tools + CLI. Enterprise-ready.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
|
7
7
|
"bin": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"LICENSE"
|
|
65
65
|
],
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"esbuild": "^0.27.3"
|
|
67
|
+
"esbuild": "^0.27.3",
|
|
68
|
+
"jest": "^30.2.0"
|
|
68
69
|
}
|
|
69
70
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
verifyAuditChain,
|
|
24
24
|
exportCompliance,
|
|
25
25
|
getLicenseInfo,
|
|
26
|
+
enforceConflictCheck,
|
|
27
|
+
setEnforcementMode,
|
|
28
|
+
overrideLock,
|
|
29
|
+
getOverrideHistory,
|
|
30
|
+
getEnforcementConfig,
|
|
31
|
+
semanticAudit,
|
|
26
32
|
} from "../core/engine.js";
|
|
27
33
|
import { generateContext } from "../core/context.js";
|
|
28
34
|
import { readBrain } from "../core/storage.js";
|
|
@@ -82,7 +88,7 @@ function refreshContext(root) {
|
|
|
82
88
|
|
|
83
89
|
function printHelp() {
|
|
84
90
|
console.log(`
|
|
85
|
-
SpecLock v2.
|
|
91
|
+
SpecLock v2.5.0 — AI Constraint Engine (Hard Enforcement + Semantic Pre-Commit)
|
|
86
92
|
Developed by Sandeep Roy (github.com/sgroy10)
|
|
87
93
|
|
|
88
94
|
Usage: speclock <command> [options]
|
|
@@ -105,7 +111,11 @@ Commands:
|
|
|
105
111
|
hook install Install git pre-commit hook
|
|
106
112
|
hook remove Remove git pre-commit hook
|
|
107
113
|
audit Audit staged files against locks
|
|
114
|
+
audit-semantic Semantic audit: analyze code changes vs locks
|
|
108
115
|
audit-verify Verify HMAC audit chain integrity
|
|
116
|
+
enforce <advisory|hard> Set enforcement mode (advisory=warn, hard=block)
|
|
117
|
+
override <lockId> <reason> Override a lock with justification
|
|
118
|
+
overrides [--lock <id>] Show override history
|
|
109
119
|
export --format <soc2|hipaa|csv> Export compliance report
|
|
110
120
|
license Show license tier and usage info
|
|
111
121
|
context Generate and print context pack
|
|
@@ -385,10 +395,12 @@ Tip: When starting a new chat, tell the AI:
|
|
|
385
395
|
console.error('Usage: speclock check "what you plan to do"');
|
|
386
396
|
process.exit(1);
|
|
387
397
|
}
|
|
388
|
-
const result =
|
|
398
|
+
const result = enforceConflictCheck(root, text);
|
|
389
399
|
if (result.hasConflict) {
|
|
390
|
-
console.log(`\
|
|
400
|
+
console.log(`\n${result.blocked ? "BLOCKED" : "CONFLICT DETECTED"}`);
|
|
391
401
|
console.log("=".repeat(50));
|
|
402
|
+
console.log(`Mode: ${result.mode} | Threshold: ${result.threshold}%`);
|
|
403
|
+
console.log("");
|
|
392
404
|
for (const lock of result.conflictingLocks) {
|
|
393
405
|
console.log(` [${lock.level}] "${lock.text}"`);
|
|
394
406
|
console.log(` Confidence: ${lock.confidence}%`);
|
|
@@ -400,6 +412,9 @@ Tip: When starting a new chat, tell the AI:
|
|
|
400
412
|
console.log("");
|
|
401
413
|
}
|
|
402
414
|
console.log(result.analysis);
|
|
415
|
+
if (result.blocked) {
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
403
418
|
} else {
|
|
404
419
|
console.log(`No conflicts found. Safe to proceed with: "${text}"`);
|
|
405
420
|
}
|
|
@@ -674,6 +689,95 @@ Tip: When starting a new chat, tell the AI:
|
|
|
674
689
|
return;
|
|
675
690
|
}
|
|
676
691
|
|
|
692
|
+
// --- ENFORCE (v2.5) ---
|
|
693
|
+
if (cmd === "enforce") {
|
|
694
|
+
const mode = args[0];
|
|
695
|
+
if (!mode || (mode !== "advisory" && mode !== "hard")) {
|
|
696
|
+
console.error("Usage: speclock enforce <advisory|hard> [--threshold 70]");
|
|
697
|
+
process.exit(1);
|
|
698
|
+
}
|
|
699
|
+
const flags = parseFlags(args.slice(1));
|
|
700
|
+
const options = {};
|
|
701
|
+
if (flags.threshold) options.blockThreshold = parseInt(flags.threshold, 10);
|
|
702
|
+
if (flags.override !== undefined) options.allowOverride = flags.override !== "false";
|
|
703
|
+
const result = setEnforcementMode(root, mode, options);
|
|
704
|
+
if (!result.success) {
|
|
705
|
+
console.error(result.error);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
console.log(`\nEnforcement mode: ${result.mode.toUpperCase()}`);
|
|
709
|
+
console.log(`Block threshold: ${result.config.blockThreshold}%`);
|
|
710
|
+
console.log(`Overrides: ${result.config.allowOverride ? "allowed" : "disabled"}`);
|
|
711
|
+
if (result.mode === "hard") {
|
|
712
|
+
console.log(`\nHard mode active — conflicts above ${result.config.blockThreshold}% confidence will BLOCK actions.`);
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// --- OVERRIDE (v2.5) ---
|
|
718
|
+
if (cmd === "override") {
|
|
719
|
+
const lockId = args[0];
|
|
720
|
+
const reason = args.slice(1).join(" ");
|
|
721
|
+
if (!lockId || !reason) {
|
|
722
|
+
console.error("Usage: speclock override <lockId> <reason>");
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
const result = overrideLock(root, lockId, "(CLI override)", reason);
|
|
726
|
+
if (!result.success) {
|
|
727
|
+
console.error(result.error);
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
console.log(`Lock overridden: "${result.lockText}"`);
|
|
731
|
+
console.log(`Override count: ${result.overrideCount}`);
|
|
732
|
+
if (result.escalated) {
|
|
733
|
+
console.log(`\n${result.escalationMessage}`);
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// --- OVERRIDES (v2.5) ---
|
|
739
|
+
if (cmd === "overrides") {
|
|
740
|
+
const flags = parseFlags(args);
|
|
741
|
+
const result = getOverrideHistory(root, flags.lock || null);
|
|
742
|
+
if (result.total === 0) {
|
|
743
|
+
console.log("No overrides recorded.");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
console.log(`\nOverride History (${result.total})`);
|
|
747
|
+
console.log("=".repeat(50));
|
|
748
|
+
for (const o of result.overrides) {
|
|
749
|
+
console.log(`[${o.at.substring(0, 19)}] Lock: "${o.lockText}"`);
|
|
750
|
+
console.log(` Action: ${o.action}`);
|
|
751
|
+
console.log(` Reason: ${o.reason}`);
|
|
752
|
+
console.log("");
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// --- AUDIT-SEMANTIC (v2.5) ---
|
|
758
|
+
if (cmd === "audit-semantic") {
|
|
759
|
+
const result = semanticAudit(root);
|
|
760
|
+
console.log(`\nSemantic Pre-Commit Audit`);
|
|
761
|
+
console.log("=".repeat(50));
|
|
762
|
+
console.log(`Mode: ${result.mode} | Threshold: ${result.threshold}%`);
|
|
763
|
+
console.log(`Files analyzed: ${result.filesChecked}`);
|
|
764
|
+
console.log(`Active locks: ${result.activeLocks}`);
|
|
765
|
+
console.log(`Violations: ${result.violations.length}`);
|
|
766
|
+
if (result.violations.length > 0) {
|
|
767
|
+
console.log("");
|
|
768
|
+
for (const v of result.violations) {
|
|
769
|
+
console.log(` [${v.level}] ${v.file} (confidence: ${v.confidence}%)`);
|
|
770
|
+
console.log(` Lock: "${v.lockText}"`);
|
|
771
|
+
console.log(` Reason: ${v.reason}`);
|
|
772
|
+
if (v.addedLines !== undefined) {
|
|
773
|
+
console.log(` Changes: +${v.addedLines} / -${v.removedLines} lines`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
console.log(`\n${result.message}`);
|
|
778
|
+
process.exit(result.blocked ? 1 : 0);
|
|
779
|
+
}
|
|
780
|
+
|
|
677
781
|
// --- STATUS ---
|
|
678
782
|
if (cmd === "status") {
|
|
679
783
|
showStatus(root);
|
package/src/core/compliance.js
CHANGED
|
@@ -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
|
+
}
|