speclock 2.1.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,622 +1,79 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import {
4
- nowIso,
5
- ensureSpeclockDirs,
6
- speclockDir,
7
- newId,
8
- readBrain,
9
- writeBrain,
10
- appendEvent,
11
- makeBrain,
12
- bumpEvents,
13
- addRecentChange,
14
- addRevert,
15
- readEvents,
16
- addViolation,
17
- } from "./storage.js";
18
- import { hasGit, getHead, getDefaultBranch, captureDiff, getStagedFiles } from "./git.js";
19
- import { getTemplateNames, getTemplate } from "./templates.js";
20
- import { analyzeConflict } from "./semantics.js";
21
- import { ensureAuditKeyGitignored } from "./audit.js";
22
- import { verifyAuditChain } from "./audit.js";
23
- import { exportCompliance } from "./compliance.js";
24
- import { checkFeature, checkLimits, getLicenseInfo } from "./license.js";
25
-
26
- // --- Internal helpers ---
27
-
28
- function recordEvent(root, brain, event) {
29
- bumpEvents(brain, event.eventId);
30
- appendEvent(root, event);
31
- writeBrain(root, brain);
32
- }
33
-
34
- function writePatch(root, eventId, content) {
35
- const patchPath = path.join(
36
- speclockDir(root),
37
- "patches",
38
- `${eventId}.patch`
39
- );
40
- fs.writeFileSync(patchPath, content);
41
- return path.join(".speclock", "patches", `${eventId}.patch`);
42
- }
43
-
44
- // --- Core functions (ported + extended) ---
45
-
46
- export function ensureInit(root) {
47
- ensureSpeclockDirs(root);
48
- try { ensureAuditKeyGitignored(root); } catch { /* non-critical */ }
49
- let brain = readBrain(root);
50
- if (!brain) {
51
- const gitExists = hasGit(root);
52
- const defaultBranch = gitExists ? getDefaultBranch(root) : "";
53
- brain = makeBrain(root, gitExists, defaultBranch);
54
- if (gitExists) {
55
- const head = getHead(root);
56
- brain.state.head.gitBranch = head.gitBranch;
57
- brain.state.head.gitCommit = head.gitCommit;
58
- brain.state.head.capturedAt = nowIso();
59
- }
60
- const eventId = newId("evt");
61
- const event = {
62
- eventId,
63
- type: "init",
64
- at: nowIso(),
65
- files: [],
66
- summary: "Initialized SpecLock",
67
- patchPath: "",
68
- };
69
- bumpEvents(brain, eventId);
70
- appendEvent(root, event);
71
- writeBrain(root, brain);
72
- }
73
- return brain;
74
- }
75
-
76
- export function setGoal(root, text) {
77
- const brain = ensureInit(root);
78
- brain.goal.text = text;
79
- brain.goal.updatedAt = nowIso();
80
- const eventId = newId("evt");
81
- const event = {
82
- eventId,
83
- type: "goal_updated",
84
- at: nowIso(),
85
- files: [],
86
- summary: `Goal set: ${text.substring(0, 80)}`,
87
- patchPath: "",
88
- };
89
- recordEvent(root, brain, event);
90
- return brain;
91
- }
92
-
93
- export function addLock(root, text, tags, source) {
94
- const brain = ensureInit(root);
95
- const lockId = newId("lock");
96
- brain.specLock.items.unshift({
97
- id: lockId,
98
- text,
99
- createdAt: nowIso(),
100
- source: source || "user",
101
- tags: tags || [],
102
- active: true,
103
- });
104
- const eventId = newId("evt");
105
- const event = {
106
- eventId,
107
- type: "lock_added",
108
- at: nowIso(),
109
- files: [],
110
- summary: `Lock added: ${text.substring(0, 80)}`,
111
- patchPath: "",
112
- };
113
- recordEvent(root, brain, event);
114
- return { brain, lockId };
115
- }
116
-
117
- export function removeLock(root, lockId) {
118
- const brain = ensureInit(root);
119
- const lock = brain.specLock.items.find((l) => l.id === lockId);
120
- if (!lock) {
121
- return { brain, removed: false, error: `Lock not found: ${lockId}` };
122
- }
123
- lock.active = false;
124
- const eventId = newId("evt");
125
- const event = {
126
- eventId,
127
- type: "lock_removed",
128
- at: nowIso(),
129
- files: [],
130
- summary: `Lock removed: ${lock.text.substring(0, 80)}`,
131
- patchPath: "",
132
- };
133
- recordEvent(root, brain, event);
134
- return { brain, removed: true, lockText: lock.text };
135
- }
136
-
137
- export function addDecision(root, text, tags, source) {
138
- const brain = ensureInit(root);
139
- const decId = newId("dec");
140
- brain.decisions.unshift({
141
- id: decId,
142
- text,
143
- createdAt: nowIso(),
144
- source: source || "user",
145
- tags: tags || [],
146
- });
147
- const eventId = newId("evt");
148
- const event = {
149
- eventId,
150
- type: "decision_added",
151
- at: nowIso(),
152
- files: [],
153
- summary: `Decision: ${text.substring(0, 80)}`,
154
- patchPath: "",
155
- };
156
- recordEvent(root, brain, event);
157
- return { brain, decId };
158
- }
159
-
160
- export function addNote(root, text, pinned = true) {
161
- const brain = ensureInit(root);
162
- const noteId = newId("note");
163
- brain.notes.unshift({
164
- id: noteId,
165
- text,
166
- createdAt: nowIso(),
167
- pinned,
168
- });
169
- const eventId = newId("evt");
170
- const event = {
171
- eventId,
172
- type: "note_added",
173
- at: nowIso(),
174
- files: [],
175
- summary: `Note: ${text.substring(0, 80)}`,
176
- patchPath: "",
177
- };
178
- recordEvent(root, brain, event);
179
- return { brain, noteId };
180
- }
181
-
182
- export function updateDeployFacts(root, payload) {
183
- const brain = ensureInit(root);
184
- const deploy = brain.facts.deploy;
185
- if (payload.provider !== undefined) deploy.provider = payload.provider;
186
- if (typeof payload.autoDeploy === "boolean")
187
- deploy.autoDeploy = payload.autoDeploy;
188
- if (payload.branch !== undefined) deploy.branch = payload.branch;
189
- if (payload.url !== undefined) deploy.url = payload.url;
190
- if (payload.notes !== undefined) deploy.notes = payload.notes;
191
- const eventId = newId("evt");
192
- const event = {
193
- eventId,
194
- type: "fact_updated",
195
- at: nowIso(),
196
- files: [],
197
- summary: "Updated deploy facts",
198
- patchPath: "",
199
- };
200
- recordEvent(root, brain, event);
201
- return brain;
202
- }
203
-
204
- export function logChange(root, summary, files = []) {
205
- const brain = ensureInit(root);
206
- const eventId = newId("evt");
207
- let patchPath = "";
208
- if (brain.facts.repo.hasGit) {
209
- const diff = captureDiff(root);
210
- if (diff && diff.trim().length > 0) {
211
- patchPath = writePatch(root, eventId, diff);
212
- }
213
- }
214
- const event = {
215
- eventId,
216
- type: "manual_change",
217
- at: nowIso(),
218
- files,
219
- summary,
220
- patchPath,
221
- };
222
- addRecentChange(brain, {
223
- eventId,
224
- summary,
225
- files,
226
- at: event.at,
227
- });
228
- recordEvent(root, brain, event);
229
- return { brain, eventId };
230
- }
231
-
232
- export function handleFileEvent(root, brain, type, filePath) {
233
- const eventId = newId("evt");
234
- const rel = path.relative(root, filePath);
235
- let patchPath = "";
236
- if (brain.facts.repo.hasGit) {
237
- const diff = captureDiff(root);
238
- const patchContent =
239
- diff && diff.trim().length > 0 ? diff : "(no diff available)";
240
- patchPath = writePatch(root, eventId, patchContent);
241
- }
242
- const summary = `${type.replace("_", " ")}: ${rel}`;
243
- const event = {
244
- eventId,
245
- type,
246
- at: nowIso(),
247
- files: [rel],
248
- summary,
249
- patchPath,
250
- };
251
- addRecentChange(brain, {
252
- eventId,
253
- summary,
254
- files: [rel],
255
- at: event.at,
256
- });
257
- recordEvent(root, brain, event);
258
- }
259
-
260
- // --- Legacy synonym groups (deprecated — kept for backward compatibility) ---
261
- // @deprecated Use analyzeConflict() from semantics.js instead
262
- const SYNONYM_GROUPS = [
263
- ["remove", "delete", "drop", "eliminate", "destroy", "kill", "purge", "wipe"],
264
- ["add", "create", "introduce", "insert", "new"],
265
- ["change", "modify", "alter", "update", "mutate", "transform", "rewrite"],
266
- ["break", "breaking", "incompatible", "destabilize"],
267
- ["public", "external", "exposed", "user-facing", "client-facing"],
268
- ["private", "internal", "hidden", "encapsulated"],
269
- ["database", "db", "schema", "table", "migration", "sql"],
270
- ["api", "endpoint", "route", "rest", "graphql"],
271
- ["test", "testing", "spec", "coverage", "assertion"],
272
- ["deploy", "deployment", "release", "ship", "publish", "production"],
273
- ["security", "auth", "authentication", "authorization", "token", "credential"],
274
- ["dependency", "package", "library", "module", "import"],
275
- ["refactor", "restructure", "reorganize", "cleanup"],
276
- ["disable", "deactivate", "turn-off", "switch-off"],
277
- ["enable", "activate", "turn-on", "switch-on"],
278
- ];
279
-
280
- // @deprecated
281
- const NEGATION_WORDS = ["no", "not", "never", "without", "dont", "don't", "cannot", "can't", "shouldn't", "mustn't", "avoid", "prevent", "prohibit", "forbid", "disallow"];
282
-
283
- // @deprecated
284
- const DESTRUCTIVE_WORDS = ["remove", "delete", "drop", "destroy", "kill", "purge", "wipe", "break", "disable", "revert", "rollback", "undo"];
285
-
286
- // @deprecated — use analyzeConflict() from semantics.js
287
- function expandWithSynonyms(words) {
288
- const expanded = new Set(words);
289
- for (const word of words) {
290
- for (const group of SYNONYM_GROUPS) {
291
- if (group.includes(word)) {
292
- for (const syn of group) expanded.add(syn);
293
- }
294
- }
295
- }
296
- return [...expanded];
297
- }
298
-
299
- // @deprecated
300
- function hasNegation(text) {
301
- const lower = text.toLowerCase();
302
- return NEGATION_WORDS.some((neg) => lower.includes(neg));
303
- }
304
-
305
- // @deprecated
306
- function isDestructiveAction(text) {
307
- const lower = text.toLowerCase();
308
- return DESTRUCTIVE_WORDS.some((w) => lower.includes(w));
309
- }
310
-
311
- // Check if a proposed action conflicts with any active SpecLock
312
- // v2: Uses the semantic analysis engine from semantics.js
313
- export function checkConflict(root, proposedAction) {
314
- const brain = ensureInit(root);
315
- const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
316
- if (activeLocks.length === 0) {
317
- return {
318
- hasConflict: false,
319
- conflictingLocks: [],
320
- analysis: "No active locks. No constraints to check against.",
321
- };
322
- }
323
-
324
- const conflicting = [];
325
- for (const lock of activeLocks) {
326
- const result = analyzeConflict(proposedAction, lock.text);
327
-
328
- if (result.isConflict) {
329
- conflicting.push({
330
- id: lock.id,
331
- text: lock.text,
332
- matchedKeywords: [],
333
- confidence: result.confidence,
334
- level: result.level,
335
- reasons: result.reasons,
336
- });
337
- }
338
- }
339
-
340
- if (conflicting.length === 0) {
341
- return {
342
- hasConflict: false,
343
- conflictingLocks: [],
344
- analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
345
- };
346
- }
347
-
348
- // Sort by confidence descending
349
- conflicting.sort((a, b) => b.confidence - a.confidence);
350
-
351
- const details = conflicting
352
- .map(
353
- (c) =>
354
- `- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
355
- )
356
- .join("\n");
357
-
358
- const result = {
359
- hasConflict: true,
360
- conflictingLocks: conflicting,
361
- analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
362
- };
363
-
364
- // Record violation for reporting
365
- addViolation(brain, {
366
- at: nowIso(),
367
- action: proposedAction,
368
- locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
369
- topLevel: conflicting[0].level,
370
- topConfidence: conflicting[0].confidence,
371
- });
372
- writeBrain(root, brain);
373
-
374
- return result;
375
- }
376
-
377
- // Async version — uses LLM if available, falls back to heuristic
378
- export async function checkConflictAsync(root, proposedAction) {
379
- // Try LLM first (if llm-checker is available)
380
- try {
381
- const { llmCheckConflict } = await import("./llm-checker.js");
382
- const llmResult = await llmCheckConflict(root, proposedAction);
383
- if (llmResult) return llmResult;
384
- } catch (_) {
385
- // LLM checker not available or failed — fall through to heuristic
386
- }
387
-
388
- // Fallback to heuristic
389
- return checkConflict(root, proposedAction);
390
- }
391
-
392
- // --- Auto-lock suggestions ---
393
- export function suggestLocks(root) {
394
- const brain = ensureInit(root);
395
- const suggestions = [];
396
-
397
- // Analyze decisions for implicit constraints
398
- for (const dec of brain.decisions) {
399
- const lower = dec.text.toLowerCase();
400
- // Decisions with strong commitment language become lock candidates
401
- if (/\b(always|must|only|exclusively|never|required)\b/.test(lower)) {
402
- suggestions.push({
403
- text: dec.text,
404
- source: "decision",
405
- sourceId: dec.id,
406
- reason: `Decision contains strong commitment language — consider promoting to a lock`,
407
- });
408
- }
409
- }
410
-
411
- // Analyze notes for implicit constraints
412
- for (const note of brain.notes) {
413
- const lower = note.text.toLowerCase();
414
- if (/\b(never|must not|do not|don't|avoid|prohibit|forbidden)\b/.test(lower)) {
415
- suggestions.push({
416
- text: note.text,
417
- source: "note",
418
- sourceId: note.id,
419
- reason: `Note contains prohibitive language — consider promoting to a lock`,
420
- });
421
- }
422
- }
1
+ /**
2
+ * SpecLock Engine — Orchestrator
3
+ * Re-exports all functionality from focused modules.
4
+ * This file exists for backward compatibility — all imports from engine.js still work.
5
+ *
6
+ * Module structure (v2.5):
7
+ * - memory.js: Goal, lock, decision, note, deploy facts CRUD
8
+ * - tracking.js: Change logging, file event handling
9
+ * - conflict.js: Conflict checking, drift detection, suggestions, audit
10
+ * - sessions.js: Session management (briefing, start, end)
11
+ * - enforcer.js: Hard/advisory enforcement, overrides, escalation
12
+ * - pre-commit-semantic.js: Semantic pre-commit analysis
13
+ * - audit.js: HMAC audit chain (v2.1)
14
+ * - compliance.js: SOC 2/HIPAA/CSV exports (v2.1)
15
+ * - license.js: Freemium tier system (v2.1)
16
+ *
17
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
18
+ */
19
+
20
+ // --- Memory (CRUD) ---
21
+ export {
22
+ ensureInit,
23
+ setGoal,
24
+ addLock,
25
+ removeLock,
26
+ addDecision,
27
+ addNote,
28
+ updateDeployFacts,
29
+ } from "./memory.js";
30
+
31
+ // --- Tracking ---
32
+ export {
33
+ logChange,
34
+ handleFileEvent,
35
+ } from "./tracking.js";
36
+
37
+ // --- Conflict Detection & Enforcement ---
38
+ export {
39
+ checkConflict,
40
+ checkConflictAsync,
41
+ suggestLocks,
42
+ detectDrift,
43
+ generateReport,
44
+ auditStagedFiles,
45
+ } from "./conflict.js";
46
+
47
+ // --- Sessions ---
48
+ export {
49
+ startSession,
50
+ endSession,
51
+ getSessionBriefing,
52
+ } from "./sessions.js";
53
+
54
+ // --- Hard Enforcement (v2.5) ---
55
+ export {
56
+ getEnforcementConfig,
57
+ setEnforcementMode,
58
+ enforceConflictCheck,
59
+ overrideLock,
60
+ getOverrideHistory,
61
+ } from "./enforcer.js";
62
+
63
+ // --- Semantic Pre-Commit (v2.5) ---
64
+ export {
65
+ parseDiff,
66
+ semanticAudit,
67
+ } from "./pre-commit-semantic.js";
68
+
69
+ // --- File Watcher ---
70
+ // watchRepo stays here because it uses multiple modules
423
71
 
424
- // Check for common patterns that should be locked
425
- const existingLockTexts = brain.specLock.items
426
- .filter((l) => l.active)
427
- .map((l) => l.text.toLowerCase());
428
-
429
- // Suggest common locks if not already present
430
- const commonPatterns = [
431
- { keyword: "api", suggestion: "No breaking changes to public API" },
432
- { keyword: "database", suggestion: "No destructive database migrations without backup" },
433
- { keyword: "deploy", suggestion: "All deployments must pass CI checks" },
434
- { keyword: "security", suggestion: "No secrets or credentials in source code" },
435
- { keyword: "test", suggestion: "No merging without passing tests" },
436
- ];
437
-
438
- // Check if project context suggests these
439
- const allText = [
440
- brain.goal.text,
441
- ...brain.decisions.map((d) => d.text),
442
- ...brain.notes.map((n) => n.text),
443
- ].join(" ").toLowerCase();
444
-
445
- for (const pattern of commonPatterns) {
446
- if (allText.includes(pattern.keyword)) {
447
- const alreadyLocked = existingLockTexts.some((t) =>
448
- t.includes(pattern.keyword)
449
- );
450
- if (!alreadyLocked) {
451
- suggestions.push({
452
- text: pattern.suggestion,
453
- source: "pattern",
454
- sourceId: null,
455
- reason: `Project mentions "${pattern.keyword}" but has no lock protecting it`,
456
- });
457
- }
458
- }
459
- }
460
-
461
- return { suggestions, totalLocks: brain.specLock.items.filter((l) => l.active).length };
462
- }
463
-
464
- // --- Drift detection (v2: uses semantic engine) ---
465
- export function detectDrift(root) {
466
- const brain = ensureInit(root);
467
- const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
468
- if (activeLocks.length === 0) {
469
- return { drifts: [], status: "no_locks", message: "No active locks to check against." };
470
- }
471
-
472
- const drifts = [];
473
-
474
- // Check recent changes against locks using the semantic engine
475
- for (const change of brain.state.recentChanges) {
476
- for (const lock of activeLocks) {
477
- const result = analyzeConflict(change.summary, lock.text);
478
-
479
- if (result.isConflict) {
480
- drifts.push({
481
- lockId: lock.id,
482
- lockText: lock.text,
483
- changeEventId: change.eventId,
484
- changeSummary: change.summary,
485
- changeAt: change.at,
486
- matchedTerms: result.reasons,
487
- severity: result.level === "HIGH" ? "high" : "medium",
488
- });
489
- }
490
- }
491
- }
492
-
493
- // Check for reverts (always a drift signal)
494
- for (const revert of brain.state.reverts) {
495
- drifts.push({
496
- lockId: null,
497
- lockText: "(git revert detected)",
498
- changeEventId: revert.eventId,
499
- changeSummary: `Git ${revert.kind} to ${revert.target.substring(0, 12)}`,
500
- changeAt: revert.at,
501
- matchedTerms: ["revert"],
502
- severity: "high",
503
- });
504
- }
505
-
506
- const status = drifts.length === 0 ? "clean" : "drift_detected";
507
- const message = drifts.length === 0
508
- ? `All clear. ${activeLocks.length} lock(s) checked against ${brain.state.recentChanges.length} recent change(s). No drift detected.`
509
- : `WARNING: ${drifts.length} potential drift(s) detected. Review immediately.`;
510
-
511
- return { drifts, status, message };
512
- }
513
-
514
- // --- Session management ---
515
-
516
- export function startSession(root, toolName = "unknown") {
517
- const brain = ensureInit(root);
518
-
519
- // Auto-close previous session if open
520
- if (brain.sessions.current) {
521
- const prev = brain.sessions.current;
522
- prev.endedAt = nowIso();
523
- prev.summary = prev.summary || "Session auto-closed (new session started)";
524
- brain.sessions.history.unshift(prev);
525
- if (brain.sessions.history.length > 50) {
526
- brain.sessions.history = brain.sessions.history.slice(0, 50);
527
- }
528
- }
529
-
530
- const session = {
531
- id: newId("ses"),
532
- startedAt: nowIso(),
533
- endedAt: null,
534
- summary: "",
535
- toolUsed: toolName,
536
- eventsInSession: 0,
537
- };
538
- brain.sessions.current = session;
539
-
540
- const eventId = newId("evt");
541
- const event = {
542
- eventId,
543
- type: "session_started",
544
- at: nowIso(),
545
- files: [],
546
- summary: `Session started (${toolName})`,
547
- patchPath: "",
548
- };
549
- recordEvent(root, brain, event);
550
- return { brain, session };
551
- }
552
-
553
- export function endSession(root, summary) {
554
- const brain = ensureInit(root);
555
- if (!brain.sessions.current) {
556
- return { brain, ended: false, error: "No active session to end." };
557
- }
558
-
559
- const session = brain.sessions.current;
560
- session.endedAt = nowIso();
561
- session.summary = summary;
562
-
563
- // Count events during this session
564
- const events = readEvents(root, { since: session.startedAt });
565
- session.eventsInSession = events.length;
566
-
567
- brain.sessions.history.unshift(session);
568
- if (brain.sessions.history.length > 50) {
569
- brain.sessions.history = brain.sessions.history.slice(0, 50);
570
- }
571
- brain.sessions.current = null;
572
-
573
- const eventId = newId("evt");
574
- const event = {
575
- eventId,
576
- type: "session_ended",
577
- at: nowIso(),
578
- files: [],
579
- summary: `Session ended: ${summary.substring(0, 100)}`,
580
- patchPath: "",
581
- };
582
- recordEvent(root, brain, event);
583
- return { brain, ended: true, session };
584
- }
585
-
586
- export function getSessionBriefing(root, toolName = "unknown") {
587
- const { brain, session } = startSession(root, toolName);
588
-
589
- const lastSession =
590
- brain.sessions.history.length > 0 ? brain.sessions.history[0] : null;
591
-
592
- let changesSinceLastSession = 0;
593
- let warnings = [];
594
-
595
- if (lastSession && lastSession.endedAt) {
596
- const eventsSince = readEvents(root, { since: lastSession.endedAt });
597
- changesSinceLastSession = eventsSince.length;
598
-
599
- // Check for reverts since last session
600
- const revertsSince = eventsSince.filter(
601
- (e) => e.type === "revert_detected"
602
- );
603
- if (revertsSince.length > 0) {
604
- warnings.push(
605
- `${revertsSince.length} revert(s) detected since last session. Verify current state before proceeding.`
606
- );
607
- }
608
- }
609
-
610
- return {
611
- brain,
612
- session,
613
- lastSession,
614
- changesSinceLastSession,
615
- warnings,
616
- };
617
- }
618
-
619
- // --- File watcher ---
72
+ import path from "path";
73
+ import { nowIso, newId, readBrain, writeBrain, appendEvent, bumpEvents, addRevert } from "./storage.js";
74
+ import { getHead } from "./git.js";
75
+ import { ensureInit, addLock as addLockFn, addDecision as addDecisionFn } from "./memory.js";
76
+ import { handleFileEvent } from "./tracking.js";
620
77
 
621
78
  export async function watchRepo(root) {
622
79
  const { default: chokidar } = await import("chokidar");
@@ -672,7 +129,9 @@ export async function watchRepo(root) {
672
129
  at: event.at,
673
130
  note: "",
674
131
  });
675
- recordEvent(root, brain, event);
132
+ bumpEvents(brain, eventId);
133
+ appendEvent(root, event);
134
+ writeBrain(root, brain);
676
135
  }
677
136
  brain.state.head.gitBranch = head.gitBranch;
678
137
  brain.state.head.gitCommit = head.gitCommit;
@@ -685,7 +144,9 @@ export async function watchRepo(root) {
685
144
  return watcher;
686
145
  }
687
146
 
688
- // --- SPECLOCK.md generator (for npm dependency / file-based mode) ---
147
+ // --- SPECLOCK.md generator ---
148
+
149
+ import fs from "fs";
689
150
 
690
151
  export function createSpecLockMd(root) {
691
152
  const mdContent = `# SpecLock — AI Constraint Engine Active
@@ -819,7 +280,6 @@ export function guardFile(root, relativeFilePath, lockText) {
819
280
  const content = fs.readFileSync(fullPath, "utf-8");
820
281
  const style = getCommentStyle(fullPath);
821
282
 
822
- // Check if already guarded
823
283
  if (content.includes(GUARD_TAG)) {
824
284
  return { success: false, error: `File already guarded: ${relativeFilePath}` };
825
285
  }
@@ -842,8 +302,6 @@ export function guardFile(root, relativeFilePath, lockText) {
842
302
  return { success: true };
843
303
  }
844
304
 
845
- // --- Package.json lock sync (Solution 2: embed active locks directly in package.json) ---
846
-
847
305
  export function syncLocksToPackageJson(root) {
848
306
  const pkgPath = path.join(root, "package.json");
849
307
  if (!fs.existsSync(pkgPath)) {
@@ -879,12 +337,11 @@ export function syncLocksToPackageJson(root) {
879
337
  }
880
338
  }
881
339
 
882
- // Backward-compatible alias
883
340
  export function injectPackageJsonMarker(root) {
884
341
  return syncLocksToPackageJson(root);
885
342
  }
886
343
 
887
- // --- Auto-guard related files (Solution 1: scan project and guard files matching lock keywords) ---
344
+ // --- Auto-guard related files ---
888
345
 
889
346
  const FILE_KEYWORD_PATTERNS = [
890
347
  { keywords: ["auth", "authentication", "login", "signup", "signin", "sign-in", "sign-up"], patterns: ["**/Auth*", "**/auth*", "**/Login*", "**/login*", "**/SignUp*", "**/signup*", "**/SignIn*", "**/signin*", "**/*Auth*", "**/*auth*"] },
@@ -898,7 +355,6 @@ function findRelatedFiles(root, lockText) {
898
355
  const lockLower = lockText.toLowerCase();
899
356
  const matchedFiles = [];
900
357
 
901
- // Find which keyword patterns match this lock text
902
358
  const matchingPatterns = [];
903
359
  for (const group of FILE_KEYWORD_PATTERNS) {
904
360
  const hasMatch = group.keywords.some((kw) => lockLower.includes(kw));
@@ -909,7 +365,6 @@ function findRelatedFiles(root, lockText) {
909
365
 
910
366
  if (matchingPatterns.length === 0) return matchedFiles;
911
367
 
912
- // Scan the src/ directory (and common directories) for matching files
913
368
  const searchDirs = ["src", "app", "components", "pages", "lib", "utils", "contexts", "hooks", "services"];
914
369
 
915
370
  for (const dir of searchDirs) {
@@ -918,7 +373,6 @@ function findRelatedFiles(root, lockText) {
918
373
  scanDirForMatches(root, dirPath, matchingPatterns, matchedFiles);
919
374
  }
920
375
 
921
- // Also check root-level files
922
376
  try {
923
377
  const rootFiles = fs.readdirSync(root);
924
378
  for (const file of rootFiles) {
@@ -964,27 +418,17 @@ function scanDirForMatches(root, dirPath, patterns, results) {
964
418
  }
965
419
 
966
420
  function patternMatchesFile(pattern, filePath) {
967
- // Simple glob matching: convert glob to regex
968
- // Handle ** (any path), * (any chars in segment)
969
421
  const clean = pattern.replace(/\\/g, "/");
970
422
  const fileLower = filePath.toLowerCase();
971
423
  const patternLower = clean.toLowerCase();
972
-
973
- // Strip leading **/ for simple name matching
974
424
  const namePattern = patternLower.replace(/^\*\*\//, "");
975
-
976
- // Check if pattern is just a name pattern (no path separators)
977
425
  if (!namePattern.includes("/")) {
978
426
  const fileName = fileLower.split("/").pop();
979
- // Convert glob * to regex .*
980
427
  const regex = new RegExp("^" + namePattern.replace(/\*/g, ".*") + "$");
981
428
  if (regex.test(fileName)) return true;
982
- // Also check if the pattern appears anywhere in the filename
983
429
  const corePattern = namePattern.replace(/\*/g, "");
984
430
  if (corePattern.length > 2 && fileName.includes(corePattern)) return true;
985
431
  }
986
-
987
- // Full path match
988
432
  const regex = new RegExp("^" + patternLower.replace(/\*\*\//g, "(.*/)?").replace(/\*/g, "[^/]*") + "$");
989
433
  return regex.test(fileLower);
990
434
  }
@@ -1017,14 +461,13 @@ export function unguardFile(root, relativeFilePath) {
1017
461
  return { success: false, error: `File is not guarded: ${relativeFilePath}` };
1018
462
  }
1019
463
 
1020
- // Remove everything from first marker line to the blank line after last marker
1021
464
  const lines = content.split("\n");
1022
465
  let guardEnd = 0;
1023
466
  let inGuard = false;
1024
467
  for (let i = 0; i < lines.length; i++) {
1025
468
  if (lines[i].includes(GUARD_TAG)) inGuard = true;
1026
469
  if (inGuard && lines[i].includes("=".repeat(60)) && i > 0) {
1027
- guardEnd = i + 1; // Skip the blank line after
470
+ guardEnd = i + 1;
1028
471
  if (lines[guardEnd] === "") guardEnd++;
1029
472
  break;
1030
473
  }
@@ -1036,7 +479,8 @@ export function unguardFile(root, relativeFilePath) {
1036
479
  return { success: true };
1037
480
  }
1038
481
 
1039
- // --- Constraint Templates ---
482
+ // --- Templates ---
483
+ import { getTemplateNames, getTemplate } from "./templates.js";
1040
484
 
1041
485
  export function listTemplates() {
1042
486
  const names = getTemplateNames();
@@ -1062,15 +506,14 @@ export function applyTemplate(root, templateName) {
1062
506
 
1063
507
  let locksAdded = 0;
1064
508
  let decisionsAdded = 0;
1065
-
1066
509
  for (const lockText of template.locks) {
1067
- addLock(root, lockText, [template.name], "agent");
510
+ addLockFn(root, lockText, [template.name], "agent");
1068
511
  autoGuardRelatedFiles(root, lockText);
1069
512
  locksAdded++;
1070
513
  }
1071
514
 
1072
515
  for (const decText of template.decisions) {
1073
- addDecision(root, decText, [template.name], "agent");
516
+ addDecisionFn(root, decText, [template.name], "agent");
1074
517
  decisionsAdded++;
1075
518
  }
1076
519
 
@@ -1085,144 +528,7 @@ export function applyTemplate(root, templateName) {
1085
528
  };
1086
529
  }
1087
530
 
1088
- // --- Violation Report ---
1089
-
1090
- export function generateReport(root) {
1091
- const brain = ensureInit(root);
1092
- const violations = brain.state.violations || [];
1093
-
1094
- if (violations.length === 0) {
1095
- return {
1096
- totalViolations: 0,
1097
- violationsByLock: {},
1098
- mostTestedLocks: [],
1099
- recentViolations: [],
1100
- summary: "No violations recorded yet. SpecLock is watching.",
1101
- };
1102
- }
1103
-
1104
- // Count violations per lock
1105
- const byLock = {};
1106
- for (const v of violations) {
1107
- for (const lock of v.locks) {
1108
- if (!byLock[lock.text]) {
1109
- byLock[lock.text] = { count: 0, lockId: lock.id, text: lock.text };
1110
- }
1111
- byLock[lock.text].count++;
1112
- }
1113
- }
1114
-
1115
- // Sort by count descending
1116
- const mostTested = Object.values(byLock).sort((a, b) => b.count - a.count);
1117
-
1118
- // Recent 10
1119
- const recent = violations.slice(0, 10).map((v) => ({
1120
- at: v.at,
1121
- action: v.action,
1122
- topLevel: v.topLevel,
1123
- topConfidence: v.topConfidence,
1124
- lockCount: v.locks.length,
1125
- }));
1126
-
1127
- // Time range
1128
- const oldest = violations[violations.length - 1];
1129
- const newest = violations[0];
1130
-
1131
- return {
1132
- totalViolations: violations.length,
1133
- timeRange: { from: oldest.at, to: newest.at },
1134
- violationsByLock: byLock,
1135
- mostTestedLocks: mostTested.slice(0, 5),
1136
- recentViolations: recent,
1137
- summary: `SpecLock blocked ${violations.length} violation(s). Most tested lock: "${mostTested[0].text}" (${mostTested[0].count} blocks).`,
1138
- };
1139
- }
1140
-
1141
- // --- Pre-commit Audit ---
1142
-
1143
- export function auditStagedFiles(root) {
1144
- const brain = ensureInit(root);
1145
- const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
1146
-
1147
- if (activeLocks.length === 0) {
1148
- return { passed: true, violations: [], checkedFiles: 0, activeLocks: 0, message: "No active locks. Audit passed." };
1149
- }
1150
-
1151
- const stagedFiles = getStagedFiles(root);
1152
- if (stagedFiles.length === 0) {
1153
- return { passed: true, violations: [], checkedFiles: 0, activeLocks: activeLocks.length, message: "No staged files. Audit passed." };
1154
- }
1155
-
1156
- const violations = [];
1157
-
1158
- for (const file of stagedFiles) {
1159
- // Check 1: Does the file have a SPECLOCK-GUARD header?
1160
- const fullPath = path.join(root, file);
1161
- if (fs.existsSync(fullPath)) {
1162
- try {
1163
- const content = fs.readFileSync(fullPath, "utf-8");
1164
- if (content.includes(GUARD_TAG)) {
1165
- violations.push({
1166
- file,
1167
- reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
1168
- lockText: "(file-level guard)",
1169
- severity: "HIGH",
1170
- });
1171
- continue;
1172
- }
1173
- } catch (_) {}
1174
- }
1175
-
1176
- // Check 2: Does the file path match any lock keywords?
1177
- const fileLower = file.toLowerCase();
1178
- for (const lock of activeLocks) {
1179
- const lockLower = lock.text.toLowerCase();
1180
- const lockHasNegation = hasNegation(lockLower);
1181
- if (!lockHasNegation) continue;
1182
-
1183
- // Check if any FILE_KEYWORD_PATTERNS keywords from the lock match this file
1184
- for (const group of FILE_KEYWORD_PATTERNS) {
1185
- const lockMatchesKeyword = group.keywords.some((kw) => lockLower.includes(kw));
1186
- if (!lockMatchesKeyword) continue;
1187
-
1188
- const fileMatchesPattern = group.patterns.some((pattern) => patternMatchesFile(pattern, fileLower) || patternMatchesFile(pattern, fileLower.split("/").pop()));
1189
- if (fileMatchesPattern) {
1190
- violations.push({
1191
- file,
1192
- reason: `File matches lock keyword pattern`,
1193
- lockText: lock.text,
1194
- severity: "MEDIUM",
1195
- });
1196
- break;
1197
- }
1198
- }
1199
- }
1200
- }
1201
-
1202
- // Deduplicate by file
1203
- const seen = new Set();
1204
- const unique = violations.filter((v) => {
1205
- if (seen.has(v.file)) return false;
1206
- seen.add(v.file);
1207
- return true;
1208
- });
1209
-
1210
- const passed = unique.length === 0;
1211
- const message = passed
1212
- ? `Audit passed. ${stagedFiles.length} file(s) checked against ${activeLocks.length} lock(s).`
1213
- : `AUDIT FAILED: ${unique.length} violation(s) in ${stagedFiles.length} staged file(s).`;
1214
-
1215
- return {
1216
- passed,
1217
- violations: unique,
1218
- checkedFiles: stagedFiles.length,
1219
- activeLocks: activeLocks.length,
1220
- message,
1221
- };
1222
- }
1223
-
1224
531
  // --- Enterprise features (v2.1) ---
1225
-
1226
532
  export { verifyAuditChain } from "./audit.js";
1227
533
  export { exportCompliance } from "./compliance.js";
1228
534
  export { checkFeature, checkLimits, getLicenseInfo } from "./license.js";