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.
- package/README.md +55 -5
- package/package.json +10 -4
- package/src/cli/index.js +247 -3
- package/src/core/auth.js +341 -0
- package/src/core/compliance.js +1 -1
- package/src/core/conflict.js +363 -0
- package/src/core/crypto.js +158 -0
- package/src/core/enforcer.js +314 -0
- package/src/core/engine.js +111 -781
- package/src/core/memory.js +191 -0
- package/src/core/pre-commit-semantic.js +284 -0
- package/src/core/sessions.js +128 -0
- package/src/core/storage.js +23 -4
- package/src/core/tracking.js +98 -0
- package/src/mcp/http-server.js +134 -7
- package/src/mcp/server.js +206 -4
|
@@ -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
|
+
}
|
package/src/core/storage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|