speclock 5.5.5 → 5.5.7

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.
@@ -1,457 +1,466 @@
1
- // ===================================================================
2
- // SpecLock Guardian — Zero-Config Protection from AI Rule Files
3
- // Reads existing .cursorrules, CLAUDE.md, AGENTS.md, copilot-instructions.md
4
- // and auto-extracts enforceable constraints. One command. No flags.
5
- //
6
- // "Your AI has rules. SpecLock makes them unbreakable."
7
- //
8
- // Developed by Sandeep Roy (https://github.com/sgroy10)
9
- // ===================================================================
10
-
11
- import fs from "fs";
12
- import path from "path";
13
- import { ensureInit, addLock, addDecision } from "./memory.js";
14
- import { readBrain } from "./storage.js";
15
- import { installHook, isHookInstalled } from "./hooks.js";
16
- import { syncRules } from "./rules-sync.js";
17
- import { generateContext } from "./context.js";
18
-
19
- // --- Starter CLAUDE.md for greenfield projects ---
20
-
21
- const STARTER_CLAUDE_MD = `# Project Rules
22
-
23
- These rules are enforced by SpecLock — your AI coding assistant will respect them.
24
-
25
- ## Database & Storage
26
- - NEVER delete user data without explicit confirmation
27
- - NEVER modify production database schema without migration
28
-
29
- ## Authentication & Security
30
- - NEVER modify authentication files without security review
31
- - NEVER commit secrets, API keys, or credentials
32
- - NEVER disable security checks "temporarily"
33
-
34
- ## Code Quality
35
- - ALWAYS write tests for new features
36
- - NEVER push directly to main branch
37
- - NEVER skip code review on critical paths
38
-
39
- ## Edit these rules to match your project. Add your own with:
40
- ## speclock add-lock "Your rule here"
41
- `;
42
-
43
- /**
44
- * Create a starter CLAUDE.md with safe defaults for greenfield projects.
45
- * Used when `protect` is called on a project with no existing rule files.
46
- */
47
- export function createStarterClaudeMd(root) {
48
- const filePath = path.join(root, "CLAUDE.md");
49
- if (fs.existsSync(filePath)) {
50
- return { created: false, path: filePath, reason: "already exists" };
51
- }
52
- fs.writeFileSync(filePath, STARTER_CLAUDE_MD);
53
- return { created: true, path: filePath };
54
- }
55
-
56
- // --- Rule file discovery ---
57
-
58
- export const RULE_FILES = [
59
- { file: ".cursorrules", tool: "Cursor" },
60
- { file: ".cursor/rules/rules.mdc", tool: "Cursor (MDC)" },
61
- { file: "CLAUDE.md", tool: "Claude Code" },
62
- { file: "AGENTS.md", tool: "AGENTS.md" },
63
- { file: ".github/copilot-instructions.md", tool: "GitHub Copilot" },
64
- { file: ".windsurfrules", tool: "Windsurf" },
65
- { file: ".windsurf/rules/rules.md", tool: "Windsurf (dir)" },
66
- { file: "GEMINI.md", tool: "Gemini" },
67
- { file: ".aider.conf.yml", tool: "Aider" },
68
- { file: "COPILOT.md", tool: "Copilot (alt)" },
69
- { file: ".github/instructions.md", tool: "GitHub (alt)" },
70
- ];
71
-
72
- // Files that SpecLock's sync creates — these are OUTPUT, not INPUT.
73
- // Never read these back as source rule files.
74
- const SPECLOCK_OUTPUT_FILES = new Set([
75
- ".cursor/rules/speclock.mdc",
76
- ".windsurf/rules/speclock.md",
77
- ]);
78
-
79
- // Header markers that indicate a file was auto-generated by SpecLock sync.
80
- // If ANY of these appear in the first 8 lines, skip the file.
81
- const SPECLOCK_SYNC_MARKERS = [
82
- "Auto-synced from SpecLock",
83
- "Auto-synced by SpecLock",
84
- "Auto-synced.",
85
- "(SpecLock)",
86
- "# SpecLock Constraints",
87
- "# Generated:",
88
- "Do not edit manually — run `speclock sync`",
89
- "speclock sync --format",
90
- "speclock_session_briefing",
91
- ];
92
-
93
- /**
94
- * Discover all AI rule files in the project.
95
- */
96
- export function discoverRuleFiles(root) {
97
- const found = [];
98
- for (const entry of RULE_FILES) {
99
- // Skip known SpecLock output files
100
- if (SPECLOCK_OUTPUT_FILES.has(entry.file)) continue;
101
-
102
- const fullPath = path.join(root, entry.file);
103
- if (fs.existsSync(fullPath)) {
104
- const content = fs.readFileSync(fullPath, "utf-8").trim();
105
- if (content.length === 0) continue;
106
-
107
- // Skip files that were auto-generated by SpecLock sync
108
- if (isSpeclockGenerated(content)) continue;
109
-
110
- found.push({
111
- file: entry.file,
112
- tool: entry.tool,
113
- path: fullPath,
114
- content,
115
- size: content.length,
116
- lines: content.split("\n").length,
117
- });
118
- }
119
- }
120
- return found;
121
- }
122
-
123
- /**
124
- * Check if file content was auto-generated by SpecLock sync.
125
- * Looks at the first 5 lines for sync markers.
126
- */
127
- function isSpeclockGenerated(content) {
128
- const header = content.split("\n").slice(0, 8).join("\n");
129
- return SPECLOCK_SYNC_MARKERS.some((marker) => header.includes(marker));
130
- }
131
-
132
- // --- Heuristic constraint extraction (no API key needed) ---
133
-
134
- // Patterns that signal a constraint/rule
135
- const CONSTRAINT_PATTERNS = [
136
- // Strong imperative (NEVER, ALWAYS, MUST, DO NOT)
137
- /^[-*•]\s*(NEVER|ALWAYS|MUST|DO NOT|DON'T|DONT|SHALL NOT|REQUIRED|MANDATORY|CRITICAL|IMPORTANT)\b/i,
138
- /^(NEVER|ALWAYS|MUST|DO NOT|DON'T|DONT|SHALL NOT)\b/i,
139
- // "Do not..." / "Don't..." at line start
140
- /^[-*•]\s*(Do not|Don't|Dont|Never|Always|Must)\b/,
141
- /^(Do not|Don't|Dont)\b/,
142
- // Emphasis markers suggesting importance
143
- /^\*\*(NEVER|ALWAYS|MUST|DO NOT|DON'T|IMPORTANT|CRITICAL|REQUIRED)\*\*/i,
144
- // "X is required" / "X is mandatory" / "X is non-negotiable"
145
- /\b(is required|is mandatory|is non-negotiable|is critical|is forbidden|is prohibited)\b/i,
146
- // "Keep X" / "Preserve X" / "Protect X"
147
- /^[-*•]\s*(Keep|Preserve|Protect|Maintain|Ensure|Enforce)\b/,
148
- // Negative imperatives
149
- /^[-*•]\s*(Avoid|Prevent|Prohibit|Forbid|Restrict|Disallow)\b/i,
150
- // "should never" / "must never" / "must always"
151
- /\b(should never|must never|must always|should always|must not|should not|cannot|can not)\b/i,
152
- ];
153
-
154
- // Patterns that signal a decision/choice
155
- const DECISION_PATTERNS = [
156
- /^[-*•]\s*(Use|Using|Tech stack|Stack|Framework|We use|Built with|Powered by)\b/i,
157
- /\b(tech stack|architecture|we chose|we decided|we use|built with)\b/i,
158
- ];
159
-
160
- // Lines to skip (headers, empty, comments, boilerplate)
161
- const SKIP_PATTERNS = [
162
- /^#+\s/, // Markdown headers
163
- /^---+$/, // Horizontal rules
164
- /^\s*$/, // Empty lines
165
- /^```/, // Code fences
166
- /^<!--/, // HTML comments
167
- /^>\s/, // Blockquotes (context, not rules)
168
- /^Auto-synced by SpecLock/, // Our own output
169
- /^Powered by \[SpecLock\]/, // Our own footer
170
- /^#\s*SpecLock/, // SpecLock headers
171
- ];
172
-
173
- /**
174
- * Extract constraints from raw text using heuristic pattern matching.
175
- * No API key required — works offline, instantly.
176
- */
177
- export function extractConstraints(content, sourceFile) {
178
- const lines = content.split("\n");
179
- const locks = [];
180
- const decisions = [];
181
- const seen = new Set();
182
-
183
- for (let i = 0; i < lines.length; i++) {
184
- const raw = lines[i];
185
- const trimmed = raw.trim();
186
-
187
- // Skip non-content lines
188
- if (SKIP_PATTERNS.some((p) => p.test(trimmed))) continue;
189
-
190
- // Clean up: remove leading bullet/dash, markdown bold
191
- const cleaned = trimmed
192
- .replace(/^[-*•]\s*/, "")
193
- .replace(/\*\*/g, "")
194
- .trim();
195
-
196
- if (cleaned.length < 10 || cleaned.length > 300) continue;
197
-
198
- // Deduplicate
199
- const key = cleaned.toLowerCase().replace(/\s+/g, " ");
200
- if (seen.has(key)) continue;
201
-
202
- // Check for constraint patterns
203
- if (CONSTRAINT_PATTERNS.some((p) => p.test(trimmed) || p.test(cleaned))) {
204
- seen.add(key);
205
- locks.push({
206
- text: cleaned,
207
- tags: [sourceFile.replace(/[/.]/g, "_").replace(/^_+|_+$/g, "")],
208
- source: "guardian",
209
- line: i + 1,
210
- });
211
- continue;
212
- }
213
-
214
- // Check for decision patterns
215
- if (DECISION_PATTERNS.some((p) => p.test(trimmed) || p.test(cleaned))) {
216
- seen.add(key);
217
- decisions.push({
218
- text: cleaned,
219
- tags: [sourceFile.replace(/[/.]/g, "_").replace(/^_+|_+$/g, "")],
220
- line: i + 1,
221
- });
222
- }
223
- }
224
-
225
- return { locks, decisions };
226
- }
227
-
228
- /**
229
- * Run the full Guardian protect flow:
230
- * 1. Init SpecLock if needed
231
- * 2. Discover rule files
232
- * 3. Extract constraints from each
233
- * 4. Add as locks (skip duplicates with existing)
234
- * 5. Install pre-commit hook
235
- * 6. Sync rules back to all formats
236
- * 7. Generate context
237
- *
238
- * Returns a report object.
239
- */
240
- export function protect(root, options = {}) {
241
- const report = {
242
- discovered: [],
243
- extracted: { locks: 0, decisions: 0 },
244
- added: { locks: 0, decisions: 0, skipped: 0 },
245
- hookInstalled: false,
246
- hookStatus: "",
247
- synced: [],
248
- errors: [],
249
- starterCreated: false,
250
- starterPath: null,
251
- strict: options.strict === true,
252
- };
253
-
254
- // 1. Init
255
- const brain = ensureInit(root);
256
-
257
- // 2. Discover
258
- let ruleFiles = discoverRuleFiles(root);
259
-
260
- // 2b. Greenfield support: if no rule files found, auto-create a starter
261
- // CLAUDE.md with safe defaults (unless explicitly disabled).
262
- if (ruleFiles.length === 0 && !options.skipStarter) {
263
- const starter = createStarterClaudeMd(root);
264
- if (starter.created) {
265
- report.starterCreated = true;
266
- report.starterPath = "CLAUDE.md";
267
- // Re-run discovery so the flow continues normally with the new file.
268
- ruleFiles = discoverRuleFiles(root);
269
- }
270
- }
271
-
272
- report.discovered = ruleFiles.map((f) => ({
273
- file: f.file,
274
- tool: f.tool,
275
- lines: f.lines,
276
- size: f.size,
277
- }));
278
-
279
- if (ruleFiles.length === 0) {
280
- report.errors.push(
281
- "No AI rule files found (.cursorrules, CLAUDE.md, AGENTS.md, etc). " +
282
- "Create one first, or use 'speclock setup' to start from scratch."
283
- );
284
- return report;
285
- }
286
-
287
- // 3. Extract constraints from each file
288
- const allLocks = [];
289
- const allDecisions = [];
290
-
291
- for (const rf of ruleFiles) {
292
- const result = extractConstraints(rf.content, rf.file);
293
- allLocks.push(...result.locks);
294
- allDecisions.push(...result.decisions);
295
- }
296
-
297
- report.extracted.locks = allLocks.length;
298
- report.extracted.decisions = allDecisions.length;
299
-
300
- // 4. Add locks (skip duplicates against existing brain locks)
301
- const existingTexts = new Set(
302
- (brain.specLock?.items || [])
303
- .filter((l) => l.active !== false)
304
- .map((l) => l.text.toLowerCase().replace(/\s+/g, " "))
305
- );
306
-
307
- for (const lock of allLocks) {
308
- const normalized = lock.text.toLowerCase().replace(/\s+/g, " ");
309
- if (existingTexts.has(normalized)) {
310
- report.added.skipped++;
311
- continue;
312
- }
313
- existingTexts.add(normalized);
314
- addLock(root, lock.text, lock.tags, lock.source || "guardian");
315
- report.added.locks++;
316
- }
317
-
318
- for (const dec of allDecisions) {
319
- const normalized = dec.text.toLowerCase().replace(/\s+/g, " ");
320
- if (existingTexts.has(normalized)) {
321
- report.added.skipped++;
322
- continue;
323
- }
324
- existingTexts.add(normalized);
325
- addDecision(root, dec.text, dec.tags, "guardian");
326
- report.added.decisions++;
327
- }
328
-
329
- // 5. Install pre-commit hook
330
- if (!options.skipHook) {
331
- if (isHookInstalled(root)) {
332
- report.hookInstalled = true;
333
- report.hookStatus = "already installed";
334
- } else {
335
- const hookResult = installHook(root);
336
- report.hookInstalled = hookResult.success;
337
- report.hookStatus = hookResult.success
338
- ? "installed"
339
- : hookResult.error || "failed";
340
- }
341
- } else {
342
- report.hookStatus = "skipped (--no-hook)";
343
- }
344
-
345
- // 6. Sync rules to formats that WEREN'T source files (don't overwrite user's originals)
346
- if (!options.skipSync) {
347
- const sourceFiles = new Set(ruleFiles.map((f) => f.file));
348
- try {
349
- const syncResult = syncRules(root, { format: "all", excludeFiles: sourceFiles });
350
- report.synced = (syncResult.synced || []).map((s) => s.file || s);
351
- } catch (e) {
352
- report.errors.push(`Sync failed: ${e.message}`);
353
- }
354
- }
355
-
356
- // 7. Generate context
357
- try {
358
- generateContext(root);
359
- } catch (_) {
360
- // Non-critical
361
- }
362
-
363
- return report;
364
- }
365
-
366
- /**
367
- * Format protect report for CLI output.
368
- */
369
- export function formatProtectReport(report) {
370
- const lines = [];
371
-
372
- lines.push("");
373
- lines.push(" SpecLock Protect — Guardian Mode");
374
- lines.push(" " + "=".repeat(50));
375
- lines.push("");
376
-
377
- // Starter CLAUDE.md was auto-created (greenfield support)
378
- if (report.starterCreated) {
379
- lines.push(" No rule files found.");
380
- lines.push(` [+] Created starter CLAUDE.md with safe defaults — edit it to match your project.`);
381
- lines.push("");
382
- }
383
-
384
- // Discovered files
385
- if (report.discovered.length > 0) {
386
- lines.push(" Rule files found:");
387
- for (const f of report.discovered) {
388
- lines.push(` [+] ${f.file} (${f.tool}, ${f.lines} lines)`);
389
- }
390
- } else if (!report.starterCreated) {
391
- lines.push(" [!] No rule files found.");
392
- }
393
- lines.push("");
394
-
395
- // Extracted
396
- lines.push(` Extracted: ${report.extracted.locks} constraints, ${report.extracted.decisions} decisions`);
397
-
398
- // Added
399
- if (report.added.locks > 0 || report.added.decisions > 0) {
400
- lines.push(` Added: ${report.added.locks} new locks, ${report.added.decisions} new decisions`);
401
- }
402
- if (report.added.skipped > 0) {
403
- lines.push(` Skipped: ${report.added.skipped} (already existed)`);
404
- }
405
- lines.push("");
406
-
407
- // Hook
408
- if (report.hookStatus === "installed") {
409
- lines.push(" Pre-commit hook: INSTALLED");
410
- lines.push(" Every commit will now be checked against your constraints.");
411
- } else if (report.hookStatus === "already installed") {
412
- lines.push(" Pre-commit hook: already active");
413
- } else {
414
- lines.push(` Pre-commit hook: ${report.hookStatus}`);
415
- }
416
- lines.push("");
417
-
418
- // Sync
419
- if (report.synced.length > 0) {
420
- lines.push(" Rules synced to:");
421
- for (const s of report.synced) {
422
- lines.push(` [+] ${s}`);
423
- }
424
- lines.push("");
425
- }
426
-
427
- // Errors
428
- if (report.errors.length > 0) {
429
- for (const e of report.errors) {
430
- lines.push(` [!] ${e}`);
431
- }
432
- lines.push("");
433
- }
434
-
435
- // Final message
436
- const total = report.added.locks + report.added.skipped;
437
- if (total > 0) {
438
- if (report.strict) {
439
- lines.push(" Your rules are now ENFORCED (strict mode).");
440
- lines.push(" Commits that violate constraints will be BLOCKED.");
441
- } else {
442
- lines.push(" Your rules are now TRACKED (warning mode — default).");
443
- lines.push(" Violations will be printed loudly, but commits will NOT be blocked.");
444
- lines.push(" Opt in to hard enforcement any time with: speclock protect --strict");
445
- }
446
- }
447
-
448
- // Greenfield guidance tell the user to edit the starter file
449
- if (report.starterCreated) {
450
- lines.push("");
451
- lines.push(" Next: edit CLAUDE.md to add project-specific rules, then run:");
452
- lines.push(' speclock check "your action here"');
453
- }
454
- lines.push("");
455
-
456
- return lines.join("\n");
457
- }
1
+ // ===================================================================
2
+ // SpecLock Guardian — Zero-Config Protection from AI Rule Files
3
+ // Reads existing .cursorrules, CLAUDE.md, AGENTS.md, copilot-instructions.md
4
+ // and auto-extracts enforceable constraints. One command. No flags.
5
+ //
6
+ // "Your AI has rules. SpecLock makes them unbreakable."
7
+ //
8
+ // Developed by Sandeep Roy (https://github.com/sgroy10)
9
+ // ===================================================================
10
+
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { ensureInit, addLock, addDecision } from "./memory.js";
14
+ import { readBrain } from "./storage.js";
15
+ import { installHook, isHookInstalled } from "./hooks.js";
16
+ import { syncRules } from "./rules-sync.js";
17
+ import { generateContext } from "./context.js";
18
+
19
+ // --- Starter CLAUDE.md for greenfield projects ---
20
+
21
+ const STARTER_CLAUDE_MD = `# Project Rules
22
+
23
+ These rules are enforced by SpecLock — your AI coding assistant will respect them.
24
+
25
+ ## Database & Storage
26
+ - NEVER delete user data without explicit confirmation
27
+ - NEVER modify production database schema without migration
28
+
29
+ ## Authentication & Security
30
+ - NEVER modify authentication files without security review
31
+ - NEVER commit secrets, API keys, or credentials
32
+ - NEVER disable security checks "temporarily"
33
+
34
+ ## Code Quality
35
+ - ALWAYS write tests for new features
36
+ - NEVER push directly to main branch
37
+ - NEVER skip code review on critical paths
38
+
39
+ ## Edit these rules to match your project. Add your own with:
40
+ ## speclock add-lock "Your rule here"
41
+ `;
42
+
43
+ /**
44
+ * Create a starter CLAUDE.md with safe defaults for greenfield projects.
45
+ * Used when `protect` is called on a project with no existing rule files.
46
+ */
47
+ export function createStarterClaudeMd(root) {
48
+ const filePath = path.join(root, "CLAUDE.md");
49
+ if (fs.existsSync(filePath)) {
50
+ return { created: false, path: filePath, reason: "already exists" };
51
+ }
52
+ fs.writeFileSync(filePath, STARTER_CLAUDE_MD);
53
+ return { created: true, path: filePath };
54
+ }
55
+
56
+ // --- Rule file discovery ---
57
+
58
+ export const RULE_FILES = [
59
+ { file: ".cursorrules", tool: "Cursor" },
60
+ { file: ".cursor/rules/rules.mdc", tool: "Cursor (MDC)" },
61
+ { file: "CLAUDE.md", tool: "Claude Code" },
62
+ { file: "AGENTS.md", tool: "AGENTS.md" },
63
+ { file: ".github/copilot-instructions.md", tool: "GitHub Copilot" },
64
+ { file: ".windsurfrules", tool: "Windsurf" },
65
+ { file: ".windsurf/rules/rules.md", tool: "Windsurf (dir)" },
66
+ { file: "GEMINI.md", tool: "Gemini" },
67
+ { file: ".aider.conf.yml", tool: "Aider" },
68
+ { file: "COPILOT.md", tool: "Copilot (alt)" },
69
+ { file: ".github/instructions.md", tool: "GitHub (alt)" },
70
+ ];
71
+
72
+ // Files that SpecLock's sync creates — these are OUTPUT, not INPUT.
73
+ // Never read these back as source rule files.
74
+ const SPECLOCK_OUTPUT_FILES = new Set([
75
+ ".cursor/rules/speclock.mdc",
76
+ ".windsurf/rules/speclock.md",
77
+ ]);
78
+
79
+ // Header markers that indicate a file was auto-generated by SpecLock sync.
80
+ // If ANY of these appear in the first 8 lines, skip the file.
81
+ const SPECLOCK_SYNC_MARKERS = [
82
+ "Auto-synced from SpecLock",
83
+ "Auto-synced by SpecLock",
84
+ "Auto-synced.",
85
+ "(SpecLock)",
86
+ "# SpecLock Constraints",
87
+ "# Generated:",
88
+ "Do not edit manually — run `speclock sync`",
89
+ "speclock sync --format",
90
+ "speclock_session_briefing",
91
+ ];
92
+
93
+ /**
94
+ * Discover all AI rule files in the project.
95
+ */
96
+ export function discoverRuleFiles(root) {
97
+ const found = [];
98
+ for (const entry of RULE_FILES) {
99
+ // Skip known SpecLock output files
100
+ if (SPECLOCK_OUTPUT_FILES.has(entry.file)) continue;
101
+
102
+ const fullPath = path.join(root, entry.file);
103
+ if (fs.existsSync(fullPath)) {
104
+ const content = fs.readFileSync(fullPath, "utf-8").trim();
105
+ if (content.length === 0) continue;
106
+
107
+ // Skip files that were auto-generated by SpecLock sync
108
+ if (isSpeclockGenerated(content)) continue;
109
+
110
+ found.push({
111
+ file: entry.file,
112
+ tool: entry.tool,
113
+ path: fullPath,
114
+ content,
115
+ size: content.length,
116
+ lines: content.split("\n").length,
117
+ });
118
+ }
119
+ }
120
+ return found;
121
+ }
122
+
123
+ /**
124
+ * Check if file content was auto-generated by SpecLock sync.
125
+ * Looks at the first 5 lines for sync markers.
126
+ */
127
+ function isSpeclockGenerated(content) {
128
+ const header = content.split("\n").slice(0, 8).join("\n");
129
+ return SPECLOCK_SYNC_MARKERS.some((marker) => header.includes(marker));
130
+ }
131
+
132
+ // --- Heuristic constraint extraction (no API key needed) ---
133
+
134
+ // Patterns that signal a constraint/rule
135
+ const CONSTRAINT_PATTERNS = [
136
+ // Strong imperative (NEVER, ALWAYS, MUST, DO NOT)
137
+ /^[-*•]\s*(NEVER|ALWAYS|MUST|DO NOT|DON'T|DONT|SHALL NOT|REQUIRED|MANDATORY|CRITICAL|IMPORTANT)\b/i,
138
+ /^(NEVER|ALWAYS|MUST|DO NOT|DON'T|DONT|SHALL NOT)\b/i,
139
+ // "Do not..." / "Don't..." at line start
140
+ /^[-*•]\s*(Do not|Don't|Dont|Never|Always|Must)\b/,
141
+ /^(Do not|Don't|Dont)\b/,
142
+ // Emphasis markers suggesting importance
143
+ /^\*\*(NEVER|ALWAYS|MUST|DO NOT|DON'T|IMPORTANT|CRITICAL|REQUIRED)\*\*/i,
144
+ // "X is required" / "X is mandatory" / "X is non-negotiable"
145
+ /\b(is required|is mandatory|is non-negotiable|is critical|is forbidden|is prohibited)\b/i,
146
+ // "Keep X" / "Preserve X" / "Protect X"
147
+ /^[-*•]\s*(Keep|Preserve|Protect|Maintain|Ensure|Enforce)\b/,
148
+ // Negative imperatives
149
+ /^[-*•]\s*(Avoid|Prevent|Prohibit|Forbid|Restrict|Disallow)\b/i,
150
+ // "should never" / "must never" / "must always"
151
+ /\b(should never|must never|must always|should always|must not|should not|cannot|can not)\b/i,
152
+ ];
153
+
154
+ // Patterns that signal a decision/choice
155
+ const DECISION_PATTERNS = [
156
+ /^[-*•]\s*(Use|Using|Tech stack|Stack|Framework|We use|Built with|Powered by)\b/i,
157
+ /\b(tech stack|architecture|we chose|we decided|we use|built with)\b/i,
158
+ ];
159
+
160
+ // Lines to skip (headers, empty, comments, boilerplate)
161
+ const SKIP_PATTERNS = [
162
+ /^#+\s/, // Markdown headers
163
+ /^---+$/, // Horizontal rules
164
+ /^\s*$/, // Empty lines
165
+ /^```/, // Code fences
166
+ /^<!--/, // HTML comments
167
+ /^>\s/, // Blockquotes (context, not rules)
168
+ /^Auto-synced by SpecLock/, // Our own output
169
+ /^Powered by \[SpecLock\]/, // Our own footer
170
+ /^#\s*SpecLock/, // SpecLock headers
171
+ ];
172
+
173
+ /**
174
+ * Extract constraints from raw text using heuristic pattern matching.
175
+ * No API key required — works offline, instantly.
176
+ */
177
+ export function extractConstraints(content, sourceFile) {
178
+ const lines = content.split("\n");
179
+ const locks = [];
180
+ const decisions = [];
181
+ const seen = new Set();
182
+
183
+ for (let i = 0; i < lines.length; i++) {
184
+ const raw = lines[i];
185
+ const trimmed = raw.trim();
186
+
187
+ // Skip non-content lines
188
+ if (SKIP_PATTERNS.some((p) => p.test(trimmed))) continue;
189
+
190
+ // Clean up: remove leading bullet/dash, markdown bold
191
+ const cleaned = trimmed
192
+ .replace(/^[-*•]\s*/, "")
193
+ .replace(/\*\*/g, "")
194
+ .trim();
195
+
196
+ if (cleaned.length < 10 || cleaned.length > 300) continue;
197
+
198
+ // Deduplicate
199
+ const key = cleaned.toLowerCase().replace(/\s+/g, " ");
200
+ if (seen.has(key)) continue;
201
+
202
+ // Check for constraint patterns
203
+ if (CONSTRAINT_PATTERNS.some((p) => p.test(trimmed) || p.test(cleaned))) {
204
+ seen.add(key);
205
+ locks.push({
206
+ text: cleaned,
207
+ tags: [sourceFile.replace(/[/.]/g, "_").replace(/^_+|_+$/g, "")],
208
+ source: "guardian",
209
+ line: i + 1,
210
+ });
211
+ continue;
212
+ }
213
+
214
+ // Check for decision patterns
215
+ if (DECISION_PATTERNS.some((p) => p.test(trimmed) || p.test(cleaned))) {
216
+ seen.add(key);
217
+ decisions.push({
218
+ text: cleaned,
219
+ tags: [sourceFile.replace(/[/.]/g, "_").replace(/^_+|_+$/g, "")],
220
+ line: i + 1,
221
+ });
222
+ }
223
+ }
224
+
225
+ return { locks, decisions };
226
+ }
227
+
228
+ /**
229
+ * Run the full Guardian protect flow:
230
+ * 1. Init SpecLock if needed
231
+ * 2. Discover rule files
232
+ * 3. Extract constraints from each
233
+ * 4. Add as locks (skip duplicates with existing)
234
+ * 5. Install pre-commit hook
235
+ * 6. Sync rules back to all formats
236
+ * 7. Generate context
237
+ *
238
+ * Returns a report object.
239
+ */
240
+ export function protect(root, options = {}) {
241
+ const report = {
242
+ discovered: [],
243
+ extracted: { locks: 0, decisions: 0 },
244
+ added: { locks: 0, decisions: 0, skipped: 0 },
245
+ hookInstalled: false,
246
+ hookStatus: "",
247
+ synced: [],
248
+ errors: [],
249
+ starterCreated: false,
250
+ starterPath: null,
251
+ strict: options.strict === true,
252
+ };
253
+
254
+ // 1. Init
255
+ const brain = ensureInit(root);
256
+
257
+ // 2. Discover
258
+ let ruleFiles = discoverRuleFiles(root);
259
+
260
+ // 2b. Greenfield support: if no rule files found, auto-create a starter
261
+ // CLAUDE.md with safe defaults (unless explicitly disabled).
262
+ if (ruleFiles.length === 0 && !options.skipStarter) {
263
+ const starter = createStarterClaudeMd(root);
264
+ if (starter.created) {
265
+ report.starterCreated = true;
266
+ report.starterPath = "CLAUDE.md";
267
+ // Re-run discovery so the flow continues normally with the new file.
268
+ ruleFiles = discoverRuleFiles(root);
269
+ }
270
+ }
271
+
272
+ report.discovered = ruleFiles.map((f) => ({
273
+ file: f.file,
274
+ tool: f.tool,
275
+ lines: f.lines,
276
+ size: f.size,
277
+ }));
278
+
279
+ if (ruleFiles.length === 0) {
280
+ report.errors.push(
281
+ "No AI rule files found (.cursorrules, CLAUDE.md, AGENTS.md, etc). " +
282
+ "Create one first, or use 'speclock setup' to start from scratch."
283
+ );
284
+ return report;
285
+ }
286
+
287
+ // 3. Extract constraints from each file
288
+ const allLocks = [];
289
+ const allDecisions = [];
290
+
291
+ for (const rf of ruleFiles) {
292
+ const result = extractConstraints(rf.content, rf.file);
293
+ allLocks.push(...result.locks);
294
+ allDecisions.push(...result.decisions);
295
+ }
296
+
297
+ report.extracted.locks = allLocks.length;
298
+ report.extracted.decisions = allDecisions.length;
299
+
300
+ // 4. Add locks (skip duplicates against existing brain locks)
301
+ const existingTexts = new Set(
302
+ (brain.specLock?.items || [])
303
+ .filter((l) => l.active !== false)
304
+ .map((l) => l.text.toLowerCase().replace(/\s+/g, " "))
305
+ );
306
+
307
+ for (const lock of allLocks) {
308
+ const normalized = lock.text.toLowerCase().replace(/\s+/g, " ");
309
+ if (existingTexts.has(normalized)) {
310
+ report.added.skipped++;
311
+ continue;
312
+ }
313
+ existingTexts.add(normalized);
314
+ addLock(root, lock.text, lock.tags, lock.source || "guardian");
315
+ report.added.locks++;
316
+ }
317
+
318
+ for (const dec of allDecisions) {
319
+ const normalized = dec.text.toLowerCase().replace(/\s+/g, " ");
320
+ if (existingTexts.has(normalized)) {
321
+ report.added.skipped++;
322
+ continue;
323
+ }
324
+ existingTexts.add(normalized);
325
+ addDecision(root, dec.text, dec.tags, "guardian");
326
+ report.added.decisions++;
327
+ }
328
+
329
+ // 5. Install pre-commit hook
330
+ if (!options.skipHook) {
331
+ if (isHookInstalled(root)) {
332
+ report.hookInstalled = true;
333
+ report.hookStatus = "already installed";
334
+ } else {
335
+ const hookResult = installHook(root);
336
+ report.hookInstalled = hookResult.success;
337
+ report.hookStatus = hookResult.success
338
+ ? "installed"
339
+ : hookResult.error || "failed";
340
+ }
341
+ } else {
342
+ report.hookStatus = "skipped (--no-hook)";
343
+ }
344
+
345
+ // 6. Sync rules to formats that WEREN'T source files (don't overwrite user's originals)
346
+ if (!options.skipSync) {
347
+ const sourceFiles = new Set(ruleFiles.map((f) => f.file));
348
+ try {
349
+ const syncResult = syncRules(root, { format: "all", excludeFiles: sourceFiles });
350
+ report.synced = (syncResult.synced || []).map((s) => s.file || s);
351
+ } catch (e) {
352
+ report.errors.push(`Sync failed: ${e.message}`);
353
+ }
354
+ }
355
+
356
+ // 7. Generate context
357
+ try {
358
+ generateContext(root);
359
+ } catch (_) {
360
+ // Non-critical
361
+ }
362
+
363
+ return report;
364
+ }
365
+
366
+ /**
367
+ * Format protect report for CLI output.
368
+ */
369
+ export function formatProtectReport(report) {
370
+ const lines = [];
371
+
372
+ lines.push("");
373
+ lines.push(" SpecLock Protect — Guardian Mode");
374
+ lines.push(" " + "=".repeat(50));
375
+ lines.push("");
376
+
377
+ // Starter CLAUDE.md was auto-created (greenfield support)
378
+ if (report.starterCreated) {
379
+ lines.push(" No rule files found.");
380
+ lines.push(` [+] Created starter CLAUDE.md with safe defaults — edit it to match your project.`);
381
+ lines.push("");
382
+ }
383
+
384
+ // Discovered files
385
+ if (report.discovered.length > 0) {
386
+ lines.push(" Rule files found:");
387
+ for (const f of report.discovered) {
388
+ lines.push(` [+] ${f.file} (${f.tool}, ${f.lines} lines)`);
389
+ }
390
+ } else if (!report.starterCreated) {
391
+ lines.push(" [!] No rule files found.");
392
+ }
393
+ lines.push("");
394
+
395
+ // Extracted
396
+ lines.push(` Extracted: ${report.extracted.locks} constraints, ${report.extracted.decisions} decisions`);
397
+
398
+ // Added
399
+ if (report.added.locks > 0 || report.added.decisions > 0) {
400
+ lines.push(` Added: ${report.added.locks} new locks, ${report.added.decisions} new decisions`);
401
+ }
402
+ if (report.added.skipped > 0) {
403
+ lines.push(` Skipped: ${report.added.skipped} (already existed)`);
404
+ }
405
+ lines.push("");
406
+
407
+ // Hook
408
+ if (report.hookStatus === "installed") {
409
+ lines.push(" Pre-commit hook: INSTALLED");
410
+ lines.push(" Every commit will now be checked against your constraints.");
411
+ } else if (report.hookStatus === "already installed") {
412
+ lines.push(" Pre-commit hook: already active");
413
+ } else if (
414
+ typeof report.hookStatus === "string" &&
415
+ /not a git repository/i.test(report.hookStatus)
416
+ ) {
417
+ lines.push(" Pre-commit hook: SKIPPED (not a git repository)");
418
+ lines.push(" Tip: Run 'git init' first, then re-run 'speclock protect'.");
419
+ } else {
420
+ // Single-line status (e.g. "skipped (--no-hook)", "failed"); collapse any
421
+ // embedded newlines so the label never ends with a blank line.
422
+ const status = String(report.hookStatus || "").replace(/\s*\r?\n\s*/g, " — ").trim();
423
+ lines.push(` Pre-commit hook: ${status || "unknown"}`);
424
+ }
425
+ lines.push("");
426
+
427
+ // Sync
428
+ if (report.synced.length > 0) {
429
+ lines.push(" Rules synced to:");
430
+ for (const s of report.synced) {
431
+ lines.push(` [+] ${s}`);
432
+ }
433
+ lines.push("");
434
+ }
435
+
436
+ // Errors
437
+ if (report.errors.length > 0) {
438
+ for (const e of report.errors) {
439
+ lines.push(` [!] ${e}`);
440
+ }
441
+ lines.push("");
442
+ }
443
+
444
+ // Final message
445
+ const total = report.added.locks + report.added.skipped;
446
+ if (total > 0) {
447
+ if (report.strict) {
448
+ lines.push(" Your rules are now ENFORCED (strict mode).");
449
+ lines.push(" Commits that violate constraints will be BLOCKED.");
450
+ } else {
451
+ lines.push(" Your rules are now TRACKED (warning mode default).");
452
+ lines.push(" Violations will be printed loudly, but commits will NOT be blocked.");
453
+ lines.push(" Opt in to hard enforcement any time with: speclock protect --strict");
454
+ }
455
+ }
456
+
457
+ // Greenfield guidance — tell the user to edit the starter file
458
+ if (report.starterCreated) {
459
+ lines.push("");
460
+ lines.push(" Next: edit CLAUDE.md to add project-specific rules, then run:");
461
+ lines.push(' speclock check "your action here"');
462
+ }
463
+ lines.push("");
464
+
465
+ return lines.join("\n");
466
+ }