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 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 a constraint
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.5.0 — Because remembering isn't enough. AI needs to respect boundaries.*
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.5.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.5.0 — AI Constraint Engine
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 — SpecLock marker added (AI auto-discovery)
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;
@@ -849,9 +849,9 @@ export function guardFile(root, relativeFilePath, lockText) {
849
849
  return { success: true };
850
850
  }
851
851
 
852
- // --- Package.json marker injection (makes Bolt.new auto-discover SpecLock) ---
852
+ // --- Package.json lock sync (Solution 2: embed active locks directly in package.json) ---
853
853
 
854
- export function injectPackageJsonMarker(root) {
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
- rules: "IMPORTANT: Read SPECLOCK.md before making ANY changes to this project. Read .speclock/context/latest.md for project memory, goals, locks, and decisions. Run 'npx speclock check' before modifying locked files.",
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 modify package.json: ${err.message}` };
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
  {