speclock 2.1.1 → 3.0.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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * SpecLock Memory Module
3
+ * Goal, lock, decision, note, deploy facts CRUD operations.
4
+ * Extracted from engine.js for modularity.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import {
12
+ nowIso,
13
+ newId,
14
+ readBrain,
15
+ writeBrain,
16
+ appendEvent,
17
+ bumpEvents,
18
+ ensureSpeclockDirs,
19
+ speclockDir,
20
+ makeBrain,
21
+ } from "./storage.js";
22
+ import { hasGit, getHead, getDefaultBranch } from "./git.js";
23
+ import { ensureAuditKeyGitignored } from "./audit.js";
24
+
25
+ // --- Internal helpers ---
26
+
27
+ function recordEvent(root, brain, event) {
28
+ bumpEvents(brain, event.eventId);
29
+ appendEvent(root, event);
30
+ writeBrain(root, brain);
31
+ }
32
+
33
+ // --- Core functions ---
34
+
35
+ export function ensureInit(root) {
36
+ ensureSpeclockDirs(root);
37
+ try { ensureAuditKeyGitignored(root); } catch { /* non-critical */ }
38
+ let brain = readBrain(root);
39
+ if (!brain) {
40
+ const gitExists = hasGit(root);
41
+ const defaultBranch = gitExists ? getDefaultBranch(root) : "";
42
+ brain = makeBrain(root, gitExists, defaultBranch);
43
+ if (gitExists) {
44
+ const head = getHead(root);
45
+ brain.state.head.gitBranch = head.gitBranch;
46
+ brain.state.head.gitCommit = head.gitCommit;
47
+ brain.state.head.capturedAt = nowIso();
48
+ }
49
+ const eventId = newId("evt");
50
+ const event = {
51
+ eventId,
52
+ type: "init",
53
+ at: nowIso(),
54
+ files: [],
55
+ summary: "Initialized SpecLock",
56
+ patchPath: "",
57
+ };
58
+ bumpEvents(brain, eventId);
59
+ appendEvent(root, event);
60
+ writeBrain(root, brain);
61
+ }
62
+ return brain;
63
+ }
64
+
65
+ export function setGoal(root, text) {
66
+ const brain = ensureInit(root);
67
+ brain.goal.text = text;
68
+ brain.goal.updatedAt = nowIso();
69
+ const eventId = newId("evt");
70
+ const event = {
71
+ eventId,
72
+ type: "goal_updated",
73
+ at: nowIso(),
74
+ files: [],
75
+ summary: `Goal set: ${text.substring(0, 80)}`,
76
+ patchPath: "",
77
+ };
78
+ recordEvent(root, brain, event);
79
+ return brain;
80
+ }
81
+
82
+ export function addLock(root, text, tags, source) {
83
+ const brain = ensureInit(root);
84
+ const lockId = newId("lock");
85
+ brain.specLock.items.unshift({
86
+ id: lockId,
87
+ text,
88
+ createdAt: nowIso(),
89
+ source: source || "user",
90
+ tags: tags || [],
91
+ active: true,
92
+ });
93
+ const eventId = newId("evt");
94
+ const event = {
95
+ eventId,
96
+ type: "lock_added",
97
+ at: nowIso(),
98
+ files: [],
99
+ summary: `Lock added: ${text.substring(0, 80)}`,
100
+ patchPath: "",
101
+ };
102
+ recordEvent(root, brain, event);
103
+ return { brain, lockId };
104
+ }
105
+
106
+ export function removeLock(root, lockId) {
107
+ const brain = ensureInit(root);
108
+ const lock = brain.specLock.items.find((l) => l.id === lockId);
109
+ if (!lock) {
110
+ return { brain, removed: false, error: `Lock not found: ${lockId}` };
111
+ }
112
+ lock.active = false;
113
+ const eventId = newId("evt");
114
+ const event = {
115
+ eventId,
116
+ type: "lock_removed",
117
+ at: nowIso(),
118
+ files: [],
119
+ summary: `Lock removed: ${lock.text.substring(0, 80)}`,
120
+ patchPath: "",
121
+ };
122
+ recordEvent(root, brain, event);
123
+ return { brain, removed: true, lockText: lock.text };
124
+ }
125
+
126
+ export function addDecision(root, text, tags, source) {
127
+ const brain = ensureInit(root);
128
+ const decId = newId("dec");
129
+ brain.decisions.unshift({
130
+ id: decId,
131
+ text,
132
+ createdAt: nowIso(),
133
+ source: source || "user",
134
+ tags: tags || [],
135
+ });
136
+ const eventId = newId("evt");
137
+ const event = {
138
+ eventId,
139
+ type: "decision_added",
140
+ at: nowIso(),
141
+ files: [],
142
+ summary: `Decision: ${text.substring(0, 80)}`,
143
+ patchPath: "",
144
+ };
145
+ recordEvent(root, brain, event);
146
+ return { brain, decId };
147
+ }
148
+
149
+ export function addNote(root, text, pinned = true) {
150
+ const brain = ensureInit(root);
151
+ const noteId = newId("note");
152
+ brain.notes.unshift({
153
+ id: noteId,
154
+ text,
155
+ createdAt: nowIso(),
156
+ pinned,
157
+ });
158
+ const eventId = newId("evt");
159
+ const event = {
160
+ eventId,
161
+ type: "note_added",
162
+ at: nowIso(),
163
+ files: [],
164
+ summary: `Note: ${text.substring(0, 80)}`,
165
+ patchPath: "",
166
+ };
167
+ recordEvent(root, brain, event);
168
+ return { brain, noteId };
169
+ }
170
+
171
+ export function updateDeployFacts(root, payload) {
172
+ const brain = ensureInit(root);
173
+ const deploy = brain.facts.deploy;
174
+ if (payload.provider !== undefined) deploy.provider = payload.provider;
175
+ if (typeof payload.autoDeploy === "boolean")
176
+ deploy.autoDeploy = payload.autoDeploy;
177
+ if (payload.branch !== undefined) deploy.branch = payload.branch;
178
+ if (payload.url !== undefined) deploy.url = payload.url;
179
+ if (payload.notes !== undefined) deploy.notes = payload.notes;
180
+ const eventId = newId("evt");
181
+ const event = {
182
+ eventId,
183
+ type: "fact_updated",
184
+ at: nowIso(),
185
+ files: [],
186
+ summary: "Updated deploy facts",
187
+ patchPath: "",
188
+ };
189
+ recordEvent(root, brain, event);
190
+ return brain;
191
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * SpecLock Semantic Pre-Commit Engine
3
+ * Replaces filename-only pre-commit with actual code-level semantic analysis.
4
+ *
5
+ * Parses git diff output, extracts code changes per file, and runs
6
+ * analyzeConflict() against each change block + active locks.
7
+ *
8
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
9
+ */
10
+
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { execSync } from "child_process";
14
+ import { readBrain } from "./storage.js";
15
+ import { analyzeConflict } from "./semantics.js";
16
+ import { getEnforcementConfig } from "./enforcer.js";
17
+
18
+ const GUARD_TAG = "SPECLOCK-GUARD";
19
+ const MAX_LINES_PER_FILE = 500;
20
+ const BINARY_EXTENSIONS = new Set([
21
+ "png", "jpg", "jpeg", "gif", "bmp", "ico", "svg", "webp",
22
+ "mp3", "mp4", "wav", "avi", "mov", "mkv",
23
+ "zip", "tar", "gz", "rar", "7z",
24
+ "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
25
+ "exe", "dll", "so", "dylib", "bin",
26
+ "woff", "woff2", "ttf", "eot", "otf",
27
+ "lock", "map",
28
+ ]);
29
+
30
+ /**
31
+ * Parse a unified diff into per-file change blocks.
32
+ * Returns array of { file, addedLines, removedLines, hunks }.
33
+ */
34
+ export function parseDiff(diffText) {
35
+ if (!diffText || !diffText.trim()) return [];
36
+
37
+ const files = [];
38
+ const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
39
+
40
+ for (const section of fileSections) {
41
+ const lines = section.split("\n");
42
+
43
+ // Extract filename from "a/path b/path"
44
+ const headerMatch = lines[0]?.match(/a\/(.+?) b\/(.+)/);
45
+ if (!headerMatch) continue;
46
+
47
+ const file = headerMatch[2];
48
+ const ext = path.extname(file).slice(1).toLowerCase();
49
+
50
+ // Skip binary files
51
+ if (BINARY_EXTENSIONS.has(ext)) continue;
52
+ if (section.includes("Binary files")) continue;
53
+
54
+ const addedLines = [];
55
+ const removedLines = [];
56
+ const hunks = [];
57
+ let currentHunk = null;
58
+ let lineCount = 0;
59
+
60
+ for (const line of lines) {
61
+ if (lineCount >= MAX_LINES_PER_FILE) break;
62
+
63
+ if (line.startsWith("@@")) {
64
+ // New hunk
65
+ const hunkMatch = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/);
66
+ if (hunkMatch) {
67
+ currentHunk = {
68
+ oldStart: parseInt(hunkMatch[1]),
69
+ newStart: parseInt(hunkMatch[3]),
70
+ context: hunkMatch[5]?.trim() || "",
71
+ changes: [],
72
+ };
73
+ hunks.push(currentHunk);
74
+ }
75
+ continue;
76
+ }
77
+
78
+ if (!currentHunk) continue;
79
+
80
+ if (line.startsWith("+") && !line.startsWith("+++")) {
81
+ const content = line.substring(1).trim();
82
+ if (content) {
83
+ addedLines.push(content);
84
+ currentHunk.changes.push({ type: "add", content });
85
+ lineCount++;
86
+ }
87
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
88
+ const content = line.substring(1).trim();
89
+ if (content) {
90
+ removedLines.push(content);
91
+ currentHunk.changes.push({ type: "remove", content });
92
+ lineCount++;
93
+ }
94
+ }
95
+ }
96
+
97
+ if (addedLines.length > 0 || removedLines.length > 0) {
98
+ files.push({ file, addedLines, removedLines, hunks });
99
+ }
100
+ }
101
+
102
+ return files;
103
+ }
104
+
105
+ /**
106
+ * Get the staged diff from git.
107
+ */
108
+ export function getStagedDiff(root) {
109
+ try {
110
+ return execSync("git diff --cached --unified=3", {
111
+ cwd: root,
112
+ encoding: "utf-8",
113
+ maxBuffer: 5 * 1024 * 1024, // 5MB
114
+ timeout: 10000,
115
+ });
116
+ } catch {
117
+ return "";
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Build a semantic summary of changes in a file for conflict checking.
123
+ * Combines added/removed lines into meaningful phrases.
124
+ */
125
+ function buildChangeSummary(fileChanges) {
126
+ const summaries = [];
127
+
128
+ // Summarize removals (deletions are more dangerous)
129
+ if (fileChanges.removedLines.length > 0) {
130
+ const sample = fileChanges.removedLines.slice(0, 10).join(" ");
131
+ summaries.push(`Removing code: ${sample}`);
132
+ }
133
+
134
+ // Summarize additions
135
+ if (fileChanges.addedLines.length > 0) {
136
+ const sample = fileChanges.addedLines.slice(0, 10).join(" ");
137
+ summaries.push(`Adding code: ${sample}`);
138
+ }
139
+
140
+ // Add hunk contexts (function names, class names)
141
+ for (const hunk of fileChanges.hunks) {
142
+ if (hunk.context) {
143
+ summaries.push(`In context: ${hunk.context}`);
144
+ }
145
+ }
146
+
147
+ return summaries.join(". ");
148
+ }
149
+
150
+ /**
151
+ * Run semantic pre-commit audit.
152
+ * Parses the staged diff, analyzes each file's changes against locks.
153
+ */
154
+ export function semanticAudit(root) {
155
+ const brain = readBrain(root);
156
+ if (!brain) {
157
+ return {
158
+ passed: true,
159
+ violations: [],
160
+ filesChecked: 0,
161
+ activeLocks: 0,
162
+ mode: "advisory",
163
+ message: "SpecLock not initialized. Audit skipped.",
164
+ };
165
+ }
166
+
167
+ const config = getEnforcementConfig(brain);
168
+ const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
169
+
170
+ if (activeLocks.length === 0) {
171
+ return {
172
+ passed: true,
173
+ violations: [],
174
+ filesChecked: 0,
175
+ activeLocks: 0,
176
+ mode: config.mode,
177
+ message: "No active locks. Semantic audit passed.",
178
+ };
179
+ }
180
+
181
+ // Get staged diff
182
+ const diff = getStagedDiff(root);
183
+ if (!diff) {
184
+ return {
185
+ passed: true,
186
+ violations: [],
187
+ filesChecked: 0,
188
+ activeLocks: activeLocks.length,
189
+ mode: config.mode,
190
+ message: "No staged changes. Semantic audit passed.",
191
+ };
192
+ }
193
+
194
+ // Parse diff into per-file changes
195
+ const fileChanges = parseDiff(diff);
196
+ const violations = [];
197
+
198
+ for (const fc of fileChanges) {
199
+ // Check 1: Guard tag violation
200
+ const fullPath = path.join(root, fc.file);
201
+ if (fs.existsSync(fullPath)) {
202
+ try {
203
+ const content = fs.readFileSync(fullPath, "utf-8");
204
+ if (content.includes(GUARD_TAG)) {
205
+ violations.push({
206
+ file: fc.file,
207
+ lockId: null,
208
+ lockText: "(file-level guard)",
209
+ confidence: 100,
210
+ level: "HIGH",
211
+ reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
212
+ source: "guard",
213
+ });
214
+ continue; // Don't double-report guarded files
215
+ }
216
+ } catch { /* file read error, continue */ }
217
+ }
218
+
219
+ // Check 2: Semantic analysis of code changes against each lock
220
+ const changeSummary = buildChangeSummary(fc);
221
+ if (!changeSummary) continue;
222
+
223
+ // Prepend file path for context
224
+ const fullSummary = `Modifying file ${fc.file}: ${changeSummary}`;
225
+
226
+ for (const lock of activeLocks) {
227
+ const result = analyzeConflict(fullSummary, lock.text);
228
+
229
+ if (result.isConflict) {
230
+ violations.push({
231
+ file: fc.file,
232
+ lockId: lock.id,
233
+ lockText: lock.text,
234
+ confidence: result.confidence,
235
+ level: result.level,
236
+ reason: result.reasons.join("; "),
237
+ source: "semantic",
238
+ addedLines: fc.addedLines.length,
239
+ removedLines: fc.removedLines.length,
240
+ });
241
+ }
242
+ }
243
+ }
244
+
245
+ // Deduplicate: keep highest confidence per file+lock pair
246
+ const dedupKey = (v) => `${v.file}::${v.lockId || v.lockText}`;
247
+ const bestByKey = new Map();
248
+ for (const v of violations) {
249
+ const key = dedupKey(v);
250
+ const existing = bestByKey.get(key);
251
+ if (!existing || v.confidence > existing.confidence) {
252
+ bestByKey.set(key, v);
253
+ }
254
+ }
255
+ const uniqueViolations = [...bestByKey.values()];
256
+
257
+ // Sort by confidence descending
258
+ uniqueViolations.sort((a, b) => b.confidence - a.confidence);
259
+
260
+ // In hard mode, check if any violation meets the block threshold
261
+ const blocked = config.mode === "hard" &&
262
+ uniqueViolations.some((v) => v.confidence >= config.blockThreshold);
263
+
264
+ const passed = uniqueViolations.length === 0;
265
+ let message;
266
+ if (passed) {
267
+ message = `Semantic audit passed. ${fileChanges.length} file(s) analyzed against ${activeLocks.length} lock(s).`;
268
+ } else if (blocked) {
269
+ message = `BLOCKED: ${uniqueViolations.length} violation(s) detected in ${fileChanges.length} file(s). Hard enforcement active — commit rejected.`;
270
+ } else {
271
+ message = `WARNING: ${uniqueViolations.length} violation(s) detected in ${fileChanges.length} file(s). Advisory mode — review before proceeding.`;
272
+ }
273
+
274
+ return {
275
+ passed,
276
+ blocked,
277
+ violations: uniqueViolations,
278
+ filesChecked: fileChanges.length,
279
+ activeLocks: activeLocks.length,
280
+ mode: config.mode,
281
+ threshold: config.blockThreshold,
282
+ message,
283
+ };
284
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SpecLock Sessions Module
3
+ * Session briefing, start, end, and history management.
4
+ * Extracted from engine.js for modularity.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import {
10
+ nowIso,
11
+ newId,
12
+ writeBrain,
13
+ appendEvent,
14
+ bumpEvents,
15
+ readEvents,
16
+ } from "./storage.js";
17
+ import { ensureInit } from "./memory.js";
18
+
19
+ // --- Internal helpers ---
20
+
21
+ function recordEvent(root, brain, event) {
22
+ bumpEvents(brain, event.eventId);
23
+ appendEvent(root, event);
24
+ writeBrain(root, brain);
25
+ }
26
+
27
+ // --- Core functions ---
28
+
29
+ export function startSession(root, toolName = "unknown") {
30
+ const brain = ensureInit(root);
31
+
32
+ // Auto-close previous session if open
33
+ if (brain.sessions.current) {
34
+ const prev = brain.sessions.current;
35
+ prev.endedAt = nowIso();
36
+ prev.summary = prev.summary || "Session auto-closed (new session started)";
37
+ brain.sessions.history.unshift(prev);
38
+ if (brain.sessions.history.length > 50) {
39
+ brain.sessions.history = brain.sessions.history.slice(0, 50);
40
+ }
41
+ }
42
+
43
+ const session = {
44
+ id: newId("ses"),
45
+ startedAt: nowIso(),
46
+ endedAt: null,
47
+ summary: "",
48
+ toolUsed: toolName,
49
+ eventsInSession: 0,
50
+ };
51
+ brain.sessions.current = session;
52
+
53
+ const eventId = newId("evt");
54
+ const event = {
55
+ eventId,
56
+ type: "session_started",
57
+ at: nowIso(),
58
+ files: [],
59
+ summary: `Session started (${toolName})`,
60
+ patchPath: "",
61
+ };
62
+ recordEvent(root, brain, event);
63
+ return { brain, session };
64
+ }
65
+
66
+ export function endSession(root, summary) {
67
+ const brain = ensureInit(root);
68
+ if (!brain.sessions.current) {
69
+ return { brain, ended: false, error: "No active session to end." };
70
+ }
71
+
72
+ const session = brain.sessions.current;
73
+ session.endedAt = nowIso();
74
+ session.summary = summary;
75
+
76
+ const events = readEvents(root, { since: session.startedAt });
77
+ session.eventsInSession = events.length;
78
+
79
+ brain.sessions.history.unshift(session);
80
+ if (brain.sessions.history.length > 50) {
81
+ brain.sessions.history = brain.sessions.history.slice(0, 50);
82
+ }
83
+ brain.sessions.current = null;
84
+
85
+ const eventId = newId("evt");
86
+ const event = {
87
+ eventId,
88
+ type: "session_ended",
89
+ at: nowIso(),
90
+ files: [],
91
+ summary: `Session ended: ${summary.substring(0, 100)}`,
92
+ patchPath: "",
93
+ };
94
+ recordEvent(root, brain, event);
95
+ return { brain, ended: true, session };
96
+ }
97
+
98
+ export function getSessionBriefing(root, toolName = "unknown") {
99
+ const { brain, session } = startSession(root, toolName);
100
+
101
+ const lastSession =
102
+ brain.sessions.history.length > 0 ? brain.sessions.history[0] : null;
103
+
104
+ let changesSinceLastSession = 0;
105
+ let warnings = [];
106
+
107
+ if (lastSession && lastSession.endedAt) {
108
+ const eventsSince = readEvents(root, { since: lastSession.endedAt });
109
+ changesSinceLastSession = eventsSince.length;
110
+
111
+ const revertsSince = eventsSince.filter(
112
+ (e) => e.type === "revert_detected"
113
+ );
114
+ if (revertsSince.length > 0) {
115
+ warnings.push(
116
+ `${revertsSince.length} revert(s) detected since last session. Verify current state before proceeding.`
117
+ );
118
+ }
119
+ }
120
+
121
+ return {
122
+ brain,
123
+ session,
124
+ lastSession,
125
+ changesSinceLastSession,
126
+ warnings,
127
+ };
128
+ }
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
4
  import { signEvent, isAuditEnabled } from "./audit.js";
5
+ import { isEncryptionEnabled, encrypt, decrypt, isEncrypted } from "./crypto.js";
5
6
 
6
7
  export function nowIso() {
7
8
  return new Date().toISOString();
@@ -121,7 +122,11 @@ export function migrateBrainV1toV2(brain) {
121
122
  export function readBrain(root) {
122
123
  const p = brainPath(root);
123
124
  if (!fs.existsSync(p)) return null;
124
- const raw = fs.readFileSync(p, "utf8");
125
+ let raw = fs.readFileSync(p, "utf8");
126
+ // Transparent decryption (v3.0)
127
+ if (isEncrypted(raw)) {
128
+ try { raw = decrypt(raw); } catch { return null; }
129
+ }
125
130
  let brain = JSON.parse(raw);
126
131
  if (brain.version < 2) {
127
132
  brain = migrateBrainV1toV2(brain);
@@ -137,7 +142,12 @@ export function readBrain(root) {
137
142
  export function writeBrain(root, brain) {
138
143
  brain.project.updatedAt = nowIso();
139
144
  const p = brainPath(root);
140
- fs.writeFileSync(p, JSON.stringify(brain, null, 2));
145
+ let data = JSON.stringify(brain, null, 2);
146
+ // Transparent encryption (v3.0)
147
+ if (isEncryptionEnabled()) {
148
+ data = encrypt(data);
149
+ }
150
+ fs.writeFileSync(p, data);
141
151
  }
142
152
 
143
153
  export function appendEvent(root, event) {
@@ -149,7 +159,11 @@ export function appendEvent(root, event) {
149
159
  } catch {
150
160
  // Audit error — write event without hash (graceful degradation)
151
161
  }
152
- const line = JSON.stringify(event);
162
+ let line = JSON.stringify(event);
163
+ // Transparent per-line encryption (v3.0)
164
+ if (isEncryptionEnabled()) {
165
+ line = encrypt(line);
166
+ }
153
167
  fs.appendFileSync(eventsPath(root), `${line}\n`);
154
168
  }
155
169
 
@@ -163,7 +177,12 @@ export function readEvents(root, opts = {}) {
163
177
 
164
178
  let events = raw.split("\n").map((line) => {
165
179
  try {
166
- return JSON.parse(line);
180
+ // Transparent per-line decryption (v3.0)
181
+ let decoded = line;
182
+ if (isEncrypted(decoded)) {
183
+ try { decoded = decrypt(decoded); } catch { return null; }
184
+ }
185
+ return JSON.parse(decoded);
167
186
  } catch {
168
187
  return null;
169
188
  }