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.
- package/package.json +1 -1
- package/src/cli/index.js +354 -24
- package/src/core/compliance.js +1 -1
- package/src/core/guardian.js +466 -457
- package/src/core/hooks.js +109 -91
- package/src/core/pre-commit-semantic.js +102 -2
- package/src/core/semantics.js +3019 -2717
- package/src/core/telemetry.js +940 -852
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +1 -1
- package/src/mcp/server.js +1 -1
package/src/core/guardian.js
CHANGED
|
@@ -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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
if (report.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
lines.push(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
+
}
|