speclock 1.5.1 → 1.6.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 +36 -5
- package/package.json +1 -1
- package/src/cli/index.js +25 -5
- package/src/core/engine.js +143 -7
- package/src/mcp/server.js +15 -1
package/README.md
CHANGED
|
@@ -218,6 +218,35 @@ Result: [HIGH] Conflict detected (confidence: 85%)
|
|
|
218
218
|
| `speclock_detect_drift` | Scan changes for constraint violations |
|
|
219
219
|
| `speclock_health` | Health score + multi-agent timeline |
|
|
220
220
|
|
|
221
|
+
## Auto-Guard: Locks That Actually Work
|
|
222
|
+
|
|
223
|
+
When you add a lock, SpecLock **automatically finds and guards related files**:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
speclock lock "Never modify auth files"
|
|
227
|
+
→ Auto-guarded 2 related file(s):
|
|
228
|
+
🔒 src/components/Auth.tsx
|
|
229
|
+
🔒 src/contexts/AuthContext.tsx
|
|
230
|
+
|
|
231
|
+
speclock lock "Database must always be Supabase"
|
|
232
|
+
→ Auto-guarded 1 related file(s):
|
|
233
|
+
🔒 src/lib/supabase.ts
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The guard injects a warning **directly inside the file**. When the AI opens the file to edit it, it sees:
|
|
237
|
+
```
|
|
238
|
+
// ============================================================
|
|
239
|
+
// SPECLOCK-GUARD — DO NOT MODIFY THIS FILE
|
|
240
|
+
// LOCKED: Never modify auth files
|
|
241
|
+
// THIS FILE IS LOCKED. DO NOT EDIT, CHANGE, OR REWRITE ANY PART OF IT.
|
|
242
|
+
// The user must say "unlock" before this file can be changed.
|
|
243
|
+
// A question is NOT permission. Asking about features is NOT permission.
|
|
244
|
+
// ONLY "unlock" or "remove the lock" is permission to edit this file.
|
|
245
|
+
// ============================================================
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Active locks are also embedded in `package.json` — so the AI sees your constraints every time it reads the project config.
|
|
249
|
+
|
|
221
250
|
## CLI Commands
|
|
222
251
|
|
|
223
252
|
```bash
|
|
@@ -226,18 +255,20 @@ speclock setup --goal "Build my app" # One-shot: init + rules + context
|
|
|
226
255
|
|
|
227
256
|
# Memory
|
|
228
257
|
speclock goal <text> # Set project goal
|
|
229
|
-
speclock lock <text> [--tags a,b] # Add
|
|
258
|
+
speclock lock <text> [--tags a,b] # Add constraint + auto-guard files
|
|
230
259
|
speclock lock remove <id> # Remove a lock
|
|
231
260
|
speclock decide <text> # Record a decision
|
|
232
261
|
speclock note <text> # Add a note
|
|
233
262
|
|
|
263
|
+
# Enforcement
|
|
264
|
+
speclock check <text> # Check for lock conflicts
|
|
265
|
+
speclock guard <file> --lock "text" # Manually guard a specific file
|
|
266
|
+
speclock unguard <file> # Remove guard from file
|
|
267
|
+
|
|
234
268
|
# Tracking
|
|
235
269
|
speclock log-change <text> --files x # Log a change
|
|
236
270
|
speclock context # Regenerate context file
|
|
237
271
|
|
|
238
|
-
# Enforcement
|
|
239
|
-
speclock check <text> # Check for lock conflicts
|
|
240
|
-
|
|
241
272
|
# Other
|
|
242
273
|
speclock status # Show brain summary
|
|
243
274
|
speclock serve [--project <path>] # Start MCP server
|
|
@@ -287,4 +318,4 @@ MIT License - see [LICENSE](LICENSE) file.
|
|
|
287
318
|
|
|
288
319
|
---
|
|
289
320
|
|
|
290
|
-
*SpecLock v1.
|
|
321
|
+
*SpecLock v1.6.0 — Because remembering isn't enough. AI needs to respect boundaries.*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speclock",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "AI constraint engine — MCP server + CLI with active enforcement. Memory + guardrails for AI coding tools. Works with Bolt.new, Claude Code, Cursor, Lovable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/mcp/server.js",
|
package/src/cli/index.js
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
guardFile,
|
|
15
15
|
unguardFile,
|
|
16
16
|
injectPackageJsonMarker,
|
|
17
|
+
syncLocksToPackageJson,
|
|
18
|
+
autoGuardRelatedFiles,
|
|
17
19
|
} from "../core/engine.js";
|
|
18
20
|
import { generateContext } from "../core/context.js";
|
|
19
21
|
import { readBrain } from "../core/storage.js";
|
|
@@ -72,7 +74,7 @@ function refreshContext(root) {
|
|
|
72
74
|
|
|
73
75
|
function printHelp() {
|
|
74
76
|
console.log(`
|
|
75
|
-
SpecLock v1.
|
|
77
|
+
SpecLock v1.6.0 — AI Constraint Engine
|
|
76
78
|
Developed by Sandeep Roy (github.com/sgroy10)
|
|
77
79
|
|
|
78
80
|
Usage: speclock <command> [options]
|
|
@@ -200,16 +202,16 @@ Files created/updated:
|
|
|
200
202
|
.speclock/brain.json — Project memory
|
|
201
203
|
.speclock/context/latest.md — Context for AI (read this)
|
|
202
204
|
SPECLOCK.md — AI rules (read this)
|
|
203
|
-
package.json —
|
|
205
|
+
package.json — Active locks embedded (AI auto-discovery)
|
|
204
206
|
|
|
205
207
|
Next steps:
|
|
206
|
-
The AI should read SPECLOCK.md for rules and
|
|
207
|
-
.speclock/context/latest.md for project context.
|
|
208
|
-
|
|
209
208
|
To add constraints: npx speclock lock "Never touch auth files"
|
|
210
209
|
To check conflicts: npx speclock check "Modifying auth page"
|
|
211
210
|
To log changes: npx speclock log-change "Built landing page"
|
|
212
211
|
To see status: npx speclock status
|
|
212
|
+
|
|
213
|
+
Tip: When starting a new chat, tell the AI:
|
|
214
|
+
"Check speclock status and read the project constraints before doing anything"
|
|
213
215
|
`);
|
|
214
216
|
return;
|
|
215
217
|
}
|
|
@@ -250,6 +252,8 @@ Next steps:
|
|
|
250
252
|
}
|
|
251
253
|
const result = removeLock(root, lockId);
|
|
252
254
|
if (result.removed) {
|
|
255
|
+
// Sync updated locks to package.json
|
|
256
|
+
syncLocksToPackageJson(root);
|
|
253
257
|
refreshContext(root);
|
|
254
258
|
console.log(`Lock removed: "${result.lockText}"`);
|
|
255
259
|
} else {
|
|
@@ -267,6 +271,22 @@ Next steps:
|
|
|
267
271
|
process.exit(1);
|
|
268
272
|
}
|
|
269
273
|
const { lockId } = addLock(root, text, parseTags(flags.tags), flags.source || "user");
|
|
274
|
+
|
|
275
|
+
// Auto-guard related files (Solution 1)
|
|
276
|
+
const guardResult = autoGuardRelatedFiles(root, text);
|
|
277
|
+
if (guardResult.guarded.length > 0) {
|
|
278
|
+
console.log(`Auto-guarded ${guardResult.guarded.length} related file(s):`);
|
|
279
|
+
for (const f of guardResult.guarded) {
|
|
280
|
+
console.log(` 🔒 ${f}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Sync locks to package.json (Solution 2)
|
|
285
|
+
const syncResult = syncLocksToPackageJson(root);
|
|
286
|
+
if (syncResult.success) {
|
|
287
|
+
console.log(`Synced ${syncResult.lockCount} lock(s) to package.json.`);
|
|
288
|
+
}
|
|
289
|
+
|
|
270
290
|
refreshContext(root);
|
|
271
291
|
console.log(`Locked (${lockId}): "${text}"`);
|
|
272
292
|
return;
|
package/src/core/engine.js
CHANGED
|
@@ -849,9 +849,9 @@ export function guardFile(root, relativeFilePath, lockText) {
|
|
|
849
849
|
return { success: true };
|
|
850
850
|
}
|
|
851
851
|
|
|
852
|
-
// --- Package.json
|
|
852
|
+
// --- Package.json lock sync (Solution 2: embed active locks directly in package.json) ---
|
|
853
853
|
|
|
854
|
-
export function
|
|
854
|
+
export function syncLocksToPackageJson(root) {
|
|
855
855
|
const pkgPath = path.join(root, "package.json");
|
|
856
856
|
if (!fs.existsSync(pkgPath)) {
|
|
857
857
|
return { success: false, error: "No package.json found in project root." };
|
|
@@ -860,23 +860,159 @@ export function injectPackageJsonMarker(root) {
|
|
|
860
860
|
try {
|
|
861
861
|
const raw = fs.readFileSync(pkgPath, "utf-8");
|
|
862
862
|
const pkg = JSON.parse(raw);
|
|
863
|
+
const brain = readBrain(root);
|
|
864
|
+
if (!brain) {
|
|
865
|
+
return { success: false, error: "SpecLock not initialized." };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const activeLocks = brain.specLock.items
|
|
869
|
+
.filter((l) => l.active !== false)
|
|
870
|
+
.map((l) => l.text);
|
|
863
871
|
|
|
864
|
-
// Add speclock marker
|
|
865
872
|
pkg.speclock = {
|
|
866
873
|
active: true,
|
|
867
|
-
|
|
874
|
+
message: "STOP — This project has SpecLock constraints. Read SPECLOCK.md and .speclock/context/latest.md BEFORE making ANY changes. Run 'npx speclock check' before ALL code changes. If a lock below is violated, STOP and ask user to unlock.",
|
|
875
|
+
locks: activeLocks,
|
|
876
|
+
context: ".speclock/context/latest.md",
|
|
877
|
+
rules: "SPECLOCK.md",
|
|
868
878
|
};
|
|
869
879
|
|
|
870
|
-
// Write back with same formatting
|
|
871
880
|
const indent = raw.match(/^(\s+)/m)?.[1] || " ";
|
|
872
881
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + "\n");
|
|
873
882
|
|
|
874
|
-
return { success: true };
|
|
883
|
+
return { success: true, lockCount: activeLocks.length };
|
|
875
884
|
} catch (err) {
|
|
876
|
-
return { success: false, error: `Failed to
|
|
885
|
+
return { success: false, error: `Failed to sync locks to package.json: ${err.message}` };
|
|
877
886
|
}
|
|
878
887
|
}
|
|
879
888
|
|
|
889
|
+
// Backward-compatible alias
|
|
890
|
+
export function injectPackageJsonMarker(root) {
|
|
891
|
+
return syncLocksToPackageJson(root);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// --- Auto-guard related files (Solution 1: scan project and guard files matching lock keywords) ---
|
|
895
|
+
|
|
896
|
+
const FILE_KEYWORD_PATTERNS = [
|
|
897
|
+
{ keywords: ["auth", "authentication", "login", "signup", "signin", "sign-in", "sign-up"], patterns: ["**/Auth*", "**/auth*", "**/Login*", "**/login*", "**/SignUp*", "**/signup*", "**/SignIn*", "**/signin*", "**/*Auth*", "**/*auth*"] },
|
|
898
|
+
{ keywords: ["database", "db", "supabase", "firebase", "mongo", "postgres", "sql", "prisma"], patterns: ["**/supabase*", "**/firebase*", "**/database*", "**/db.*", "**/db/**", "**/prisma/**", "**/*Client*", "**/*client*"] },
|
|
899
|
+
{ keywords: ["payment", "pay", "stripe", "billing", "checkout", "subscription"], patterns: ["**/payment*", "**/Payment*", "**/pay*", "**/Pay*", "**/stripe*", "**/Stripe*", "**/billing*", "**/Billing*", "**/checkout*", "**/Checkout*"] },
|
|
900
|
+
{ keywords: ["api", "endpoint", "route", "routes"], patterns: ["**/api/**", "**/routes/**", "**/endpoints/**"] },
|
|
901
|
+
{ keywords: ["config", "configuration", "settings", "env"], patterns: ["**/config*", "**/Config*", "**/settings*", "**/Settings*"] },
|
|
902
|
+
];
|
|
903
|
+
|
|
904
|
+
function findRelatedFiles(root, lockText) {
|
|
905
|
+
const lockLower = lockText.toLowerCase();
|
|
906
|
+
const matchedFiles = [];
|
|
907
|
+
|
|
908
|
+
// Find which keyword patterns match this lock text
|
|
909
|
+
const matchingPatterns = [];
|
|
910
|
+
for (const group of FILE_KEYWORD_PATTERNS) {
|
|
911
|
+
const hasMatch = group.keywords.some((kw) => lockLower.includes(kw));
|
|
912
|
+
if (hasMatch) {
|
|
913
|
+
matchingPatterns.push(...group.patterns);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (matchingPatterns.length === 0) return matchedFiles;
|
|
918
|
+
|
|
919
|
+
// Scan the src/ directory (and common directories) for matching files
|
|
920
|
+
const searchDirs = ["src", "app", "components", "pages", "lib", "utils", "contexts", "hooks", "services"];
|
|
921
|
+
|
|
922
|
+
for (const dir of searchDirs) {
|
|
923
|
+
const dirPath = path.join(root, dir);
|
|
924
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
925
|
+
scanDirForMatches(root, dirPath, matchingPatterns, matchedFiles);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Also check root-level files
|
|
929
|
+
try {
|
|
930
|
+
const rootFiles = fs.readdirSync(root);
|
|
931
|
+
for (const file of rootFiles) {
|
|
932
|
+
const fullPath = path.join(root, file);
|
|
933
|
+
if (!fs.statSync(fullPath).isFile()) continue;
|
|
934
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
935
|
+
if (!GUARD_MARKERS[ext]) continue;
|
|
936
|
+
|
|
937
|
+
for (const pattern of matchingPatterns) {
|
|
938
|
+
const simpleMatch = patternMatchesFile(pattern, file);
|
|
939
|
+
if (simpleMatch) {
|
|
940
|
+
const rel = path.relative(root, fullPath).replace(/\\/g, "/");
|
|
941
|
+
if (!matchedFiles.includes(rel)) matchedFiles.push(rel);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
} catch (_) {}
|
|
946
|
+
|
|
947
|
+
return matchedFiles;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function scanDirForMatches(root, dirPath, patterns, results) {
|
|
951
|
+
try {
|
|
952
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
953
|
+
for (const entry of entries) {
|
|
954
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
955
|
+
if (entry.isDirectory()) {
|
|
956
|
+
if (entry.name === "node_modules" || entry.name === ".speclock" || entry.name === ".git") continue;
|
|
957
|
+
scanDirForMatches(root, fullPath, patterns, results);
|
|
958
|
+
} else if (entry.isFile()) {
|
|
959
|
+
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
960
|
+
if (!GUARD_MARKERS[ext]) continue;
|
|
961
|
+
const relPath = path.relative(root, fullPath).replace(/\\/g, "/");
|
|
962
|
+
for (const pattern of patterns) {
|
|
963
|
+
if (patternMatchesFile(pattern, relPath) || patternMatchesFile(pattern, entry.name)) {
|
|
964
|
+
if (!results.includes(relPath)) results.push(relPath);
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
} catch (_) {}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function patternMatchesFile(pattern, filePath) {
|
|
974
|
+
// Simple glob matching: convert glob to regex
|
|
975
|
+
// Handle ** (any path), * (any chars in segment)
|
|
976
|
+
const clean = pattern.replace(/\\/g, "/");
|
|
977
|
+
const fileLower = filePath.toLowerCase();
|
|
978
|
+
const patternLower = clean.toLowerCase();
|
|
979
|
+
|
|
980
|
+
// Strip leading **/ for simple name matching
|
|
981
|
+
const namePattern = patternLower.replace(/^\*\*\//, "");
|
|
982
|
+
|
|
983
|
+
// Check if pattern is just a name pattern (no path separators)
|
|
984
|
+
if (!namePattern.includes("/")) {
|
|
985
|
+
const fileName = fileLower.split("/").pop();
|
|
986
|
+
// Convert glob * to regex .*
|
|
987
|
+
const regex = new RegExp("^" + namePattern.replace(/\*/g, ".*") + "$");
|
|
988
|
+
if (regex.test(fileName)) return true;
|
|
989
|
+
// Also check if the pattern appears anywhere in the filename
|
|
990
|
+
const corePattern = namePattern.replace(/\*/g, "");
|
|
991
|
+
if (corePattern.length > 2 && fileName.includes(corePattern)) return true;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Full path match
|
|
995
|
+
const regex = new RegExp("^" + patternLower.replace(/\*\*\//g, "(.*/)?").replace(/\*/g, "[^/]*") + "$");
|
|
996
|
+
return regex.test(fileLower);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export function autoGuardRelatedFiles(root, lockText) {
|
|
1000
|
+
const relatedFiles = findRelatedFiles(root, lockText);
|
|
1001
|
+
const guarded = [];
|
|
1002
|
+
const skipped = [];
|
|
1003
|
+
|
|
1004
|
+
for (const relFile of relatedFiles) {
|
|
1005
|
+
const result = guardFile(root, relFile, lockText);
|
|
1006
|
+
if (result.success) {
|
|
1007
|
+
guarded.push(relFile);
|
|
1008
|
+
} else {
|
|
1009
|
+
skipped.push({ file: relFile, reason: result.error });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return { guarded, skipped, scannedPatterns: relatedFiles.length };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
880
1016
|
export function unguardFile(root, relativeFilePath) {
|
|
881
1017
|
const fullPath = path.join(root, relativeFilePath);
|
|
882
1018
|
if (!fs.existsSync(fullPath)) {
|
package/src/mcp/server.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
endSession,
|
|
17
17
|
suggestLocks,
|
|
18
18
|
detectDrift,
|
|
19
|
+
syncLocksToPackageJson,
|
|
20
|
+
autoGuardRelatedFiles,
|
|
19
21
|
} from "../core/engine.js";
|
|
20
22
|
import { generateContext, generateContextPack } from "../core/context.js";
|
|
21
23
|
import {
|
|
@@ -171,9 +173,19 @@ server.tool(
|
|
|
171
173
|
},
|
|
172
174
|
async ({ text, tags, source }) => {
|
|
173
175
|
const { lockId } = addLock(PROJECT_ROOT, text, tags, source);
|
|
176
|
+
|
|
177
|
+
// Auto-guard related files
|
|
178
|
+
const guardResult = autoGuardRelatedFiles(PROJECT_ROOT, text);
|
|
179
|
+
const guardMsg = guardResult.guarded.length > 0
|
|
180
|
+
? `\nAuto-guarded ${guardResult.guarded.length} file(s): ${guardResult.guarded.join(", ")}`
|
|
181
|
+
: "";
|
|
182
|
+
|
|
183
|
+
// Sync active locks to package.json
|
|
184
|
+
syncLocksToPackageJson(PROJECT_ROOT);
|
|
185
|
+
|
|
174
186
|
return {
|
|
175
187
|
content: [
|
|
176
|
-
{ type: "text", text: `Lock added (${lockId}): "${text}"` },
|
|
188
|
+
{ type: "text", text: `Lock added (${lockId}): "${text}"${guardMsg}` },
|
|
177
189
|
],
|
|
178
190
|
};
|
|
179
191
|
}
|
|
@@ -194,6 +206,8 @@ server.tool(
|
|
|
194
206
|
isError: true,
|
|
195
207
|
};
|
|
196
208
|
}
|
|
209
|
+
// Sync updated locks to package.json
|
|
210
|
+
syncLocksToPackageJson(PROJECT_ROOT);
|
|
197
211
|
return {
|
|
198
212
|
content: [
|
|
199
213
|
{
|