speclock 1.1.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/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/speclock.js +2 -0
- package/package.json +59 -0
- package/src/cli/index.js +285 -0
- package/src/core/context.js +201 -0
- package/src/core/engine.js +698 -0
- package/src/core/git.js +110 -0
- package/src/core/storage.js +186 -0
- package/src/mcp/server.js +730 -0
|
@@ -0,0 +1,698 @@
|
|
|
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
|
+
} from "./storage.js";
|
|
17
|
+
import { hasGit, getHead, getDefaultBranch, captureDiff } from "./git.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
|
+
function writePatch(root, eventId, content) {
|
|
28
|
+
const patchPath = path.join(
|
|
29
|
+
speclockDir(root),
|
|
30
|
+
"patches",
|
|
31
|
+
`${eventId}.patch`
|
|
32
|
+
);
|
|
33
|
+
fs.writeFileSync(patchPath, content);
|
|
34
|
+
return path.join(".speclock", "patches", `${eventId}.patch`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- Core functions (ported + extended) ---
|
|
38
|
+
|
|
39
|
+
export function ensureInit(root) {
|
|
40
|
+
ensureSpeclockDirs(root);
|
|
41
|
+
let brain = readBrain(root);
|
|
42
|
+
if (!brain) {
|
|
43
|
+
const gitExists = hasGit(root);
|
|
44
|
+
const defaultBranch = gitExists ? getDefaultBranch(root) : "";
|
|
45
|
+
brain = makeBrain(root, gitExists, defaultBranch);
|
|
46
|
+
if (gitExists) {
|
|
47
|
+
const head = getHead(root);
|
|
48
|
+
brain.state.head.gitBranch = head.gitBranch;
|
|
49
|
+
brain.state.head.gitCommit = head.gitCommit;
|
|
50
|
+
brain.state.head.capturedAt = nowIso();
|
|
51
|
+
}
|
|
52
|
+
const eventId = newId("evt");
|
|
53
|
+
const event = {
|
|
54
|
+
eventId,
|
|
55
|
+
type: "init",
|
|
56
|
+
at: nowIso(),
|
|
57
|
+
files: [],
|
|
58
|
+
summary: "Initialized SpecLock",
|
|
59
|
+
patchPath: "",
|
|
60
|
+
};
|
|
61
|
+
bumpEvents(brain, eventId);
|
|
62
|
+
appendEvent(root, event);
|
|
63
|
+
writeBrain(root, brain);
|
|
64
|
+
}
|
|
65
|
+
return brain;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setGoal(root, text) {
|
|
69
|
+
const brain = ensureInit(root);
|
|
70
|
+
brain.goal.text = text;
|
|
71
|
+
brain.goal.updatedAt = nowIso();
|
|
72
|
+
const eventId = newId("evt");
|
|
73
|
+
const event = {
|
|
74
|
+
eventId,
|
|
75
|
+
type: "goal_updated",
|
|
76
|
+
at: nowIso(),
|
|
77
|
+
files: [],
|
|
78
|
+
summary: `Goal set: ${text.substring(0, 80)}`,
|
|
79
|
+
patchPath: "",
|
|
80
|
+
};
|
|
81
|
+
recordEvent(root, brain, event);
|
|
82
|
+
return brain;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function addLock(root, text, tags, source) {
|
|
86
|
+
const brain = ensureInit(root);
|
|
87
|
+
const lockId = newId("lock");
|
|
88
|
+
brain.specLock.items.unshift({
|
|
89
|
+
id: lockId,
|
|
90
|
+
text,
|
|
91
|
+
createdAt: nowIso(),
|
|
92
|
+
source: source || "user",
|
|
93
|
+
tags: tags || [],
|
|
94
|
+
active: true,
|
|
95
|
+
});
|
|
96
|
+
const eventId = newId("evt");
|
|
97
|
+
const event = {
|
|
98
|
+
eventId,
|
|
99
|
+
type: "lock_added",
|
|
100
|
+
at: nowIso(),
|
|
101
|
+
files: [],
|
|
102
|
+
summary: `Lock added: ${text.substring(0, 80)}`,
|
|
103
|
+
patchPath: "",
|
|
104
|
+
};
|
|
105
|
+
recordEvent(root, brain, event);
|
|
106
|
+
return { brain, lockId };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function removeLock(root, lockId) {
|
|
110
|
+
const brain = ensureInit(root);
|
|
111
|
+
const lock = brain.specLock.items.find((l) => l.id === lockId);
|
|
112
|
+
if (!lock) {
|
|
113
|
+
return { brain, removed: false, error: `Lock not found: ${lockId}` };
|
|
114
|
+
}
|
|
115
|
+
lock.active = false;
|
|
116
|
+
const eventId = newId("evt");
|
|
117
|
+
const event = {
|
|
118
|
+
eventId,
|
|
119
|
+
type: "lock_removed",
|
|
120
|
+
at: nowIso(),
|
|
121
|
+
files: [],
|
|
122
|
+
summary: `Lock removed: ${lock.text.substring(0, 80)}`,
|
|
123
|
+
patchPath: "",
|
|
124
|
+
};
|
|
125
|
+
recordEvent(root, brain, event);
|
|
126
|
+
return { brain, removed: true, lockText: lock.text };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function addDecision(root, text, tags, source) {
|
|
130
|
+
const brain = ensureInit(root);
|
|
131
|
+
const decId = newId("dec");
|
|
132
|
+
brain.decisions.unshift({
|
|
133
|
+
id: decId,
|
|
134
|
+
text,
|
|
135
|
+
createdAt: nowIso(),
|
|
136
|
+
source: source || "user",
|
|
137
|
+
tags: tags || [],
|
|
138
|
+
});
|
|
139
|
+
const eventId = newId("evt");
|
|
140
|
+
const event = {
|
|
141
|
+
eventId,
|
|
142
|
+
type: "decision_added",
|
|
143
|
+
at: nowIso(),
|
|
144
|
+
files: [],
|
|
145
|
+
summary: `Decision: ${text.substring(0, 80)}`,
|
|
146
|
+
patchPath: "",
|
|
147
|
+
};
|
|
148
|
+
recordEvent(root, brain, event);
|
|
149
|
+
return { brain, decId };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function addNote(root, text, pinned = true) {
|
|
153
|
+
const brain = ensureInit(root);
|
|
154
|
+
const noteId = newId("note");
|
|
155
|
+
brain.notes.unshift({
|
|
156
|
+
id: noteId,
|
|
157
|
+
text,
|
|
158
|
+
createdAt: nowIso(),
|
|
159
|
+
pinned,
|
|
160
|
+
});
|
|
161
|
+
const eventId = newId("evt");
|
|
162
|
+
const event = {
|
|
163
|
+
eventId,
|
|
164
|
+
type: "note_added",
|
|
165
|
+
at: nowIso(),
|
|
166
|
+
files: [],
|
|
167
|
+
summary: `Note: ${text.substring(0, 80)}`,
|
|
168
|
+
patchPath: "",
|
|
169
|
+
};
|
|
170
|
+
recordEvent(root, brain, event);
|
|
171
|
+
return { brain, noteId };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function updateDeployFacts(root, payload) {
|
|
175
|
+
const brain = ensureInit(root);
|
|
176
|
+
const deploy = brain.facts.deploy;
|
|
177
|
+
if (payload.provider !== undefined) deploy.provider = payload.provider;
|
|
178
|
+
if (typeof payload.autoDeploy === "boolean")
|
|
179
|
+
deploy.autoDeploy = payload.autoDeploy;
|
|
180
|
+
if (payload.branch !== undefined) deploy.branch = payload.branch;
|
|
181
|
+
if (payload.url !== undefined) deploy.url = payload.url;
|
|
182
|
+
if (payload.notes !== undefined) deploy.notes = payload.notes;
|
|
183
|
+
const eventId = newId("evt");
|
|
184
|
+
const event = {
|
|
185
|
+
eventId,
|
|
186
|
+
type: "fact_updated",
|
|
187
|
+
at: nowIso(),
|
|
188
|
+
files: [],
|
|
189
|
+
summary: "Updated deploy facts",
|
|
190
|
+
patchPath: "",
|
|
191
|
+
};
|
|
192
|
+
recordEvent(root, brain, event);
|
|
193
|
+
return brain;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function logChange(root, summary, files = []) {
|
|
197
|
+
const brain = ensureInit(root);
|
|
198
|
+
const eventId = newId("evt");
|
|
199
|
+
let patchPath = "";
|
|
200
|
+
if (brain.facts.repo.hasGit) {
|
|
201
|
+
const diff = captureDiff(root);
|
|
202
|
+
if (diff && diff.trim().length > 0) {
|
|
203
|
+
patchPath = writePatch(root, eventId, diff);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const event = {
|
|
207
|
+
eventId,
|
|
208
|
+
type: "manual_change",
|
|
209
|
+
at: nowIso(),
|
|
210
|
+
files,
|
|
211
|
+
summary,
|
|
212
|
+
patchPath,
|
|
213
|
+
};
|
|
214
|
+
addRecentChange(brain, {
|
|
215
|
+
eventId,
|
|
216
|
+
summary,
|
|
217
|
+
files,
|
|
218
|
+
at: event.at,
|
|
219
|
+
});
|
|
220
|
+
recordEvent(root, brain, event);
|
|
221
|
+
return { brain, eventId };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function handleFileEvent(root, brain, type, filePath) {
|
|
225
|
+
const eventId = newId("evt");
|
|
226
|
+
const rel = path.relative(root, filePath);
|
|
227
|
+
let patchPath = "";
|
|
228
|
+
if (brain.facts.repo.hasGit) {
|
|
229
|
+
const diff = captureDiff(root);
|
|
230
|
+
const patchContent =
|
|
231
|
+
diff && diff.trim().length > 0 ? diff : "(no diff available)";
|
|
232
|
+
patchPath = writePatch(root, eventId, patchContent);
|
|
233
|
+
}
|
|
234
|
+
const summary = `${type.replace("_", " ")}: ${rel}`;
|
|
235
|
+
const event = {
|
|
236
|
+
eventId,
|
|
237
|
+
type,
|
|
238
|
+
at: nowIso(),
|
|
239
|
+
files: [rel],
|
|
240
|
+
summary,
|
|
241
|
+
patchPath,
|
|
242
|
+
};
|
|
243
|
+
addRecentChange(brain, {
|
|
244
|
+
eventId,
|
|
245
|
+
summary,
|
|
246
|
+
files: [rel],
|
|
247
|
+
at: event.at,
|
|
248
|
+
});
|
|
249
|
+
recordEvent(root, brain, event);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- Synonym groups for semantic matching ---
|
|
253
|
+
const SYNONYM_GROUPS = [
|
|
254
|
+
["remove", "delete", "drop", "eliminate", "destroy", "kill", "purge", "wipe"],
|
|
255
|
+
["add", "create", "introduce", "insert", "new"],
|
|
256
|
+
["change", "modify", "alter", "update", "mutate", "transform", "rewrite"],
|
|
257
|
+
["break", "breaking", "incompatible", "destabilize"],
|
|
258
|
+
["public", "external", "exposed", "user-facing", "client-facing"],
|
|
259
|
+
["private", "internal", "hidden", "encapsulated"],
|
|
260
|
+
["database", "db", "schema", "table", "migration", "sql"],
|
|
261
|
+
["api", "endpoint", "route", "rest", "graphql"],
|
|
262
|
+
["test", "testing", "spec", "coverage", "assertion"],
|
|
263
|
+
["deploy", "deployment", "release", "ship", "publish", "production"],
|
|
264
|
+
["security", "auth", "authentication", "authorization", "token", "credential"],
|
|
265
|
+
["dependency", "package", "library", "module", "import"],
|
|
266
|
+
["refactor", "restructure", "reorganize", "cleanup"],
|
|
267
|
+
["disable", "deactivate", "turn-off", "switch-off"],
|
|
268
|
+
["enable", "activate", "turn-on", "switch-on"],
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
// Negation words that invert meaning
|
|
272
|
+
const NEGATION_WORDS = ["no", "not", "never", "without", "dont", "don't", "cannot", "can't", "shouldn't", "mustn't", "avoid", "prevent", "prohibit", "forbid", "disallow"];
|
|
273
|
+
|
|
274
|
+
// Destructive action words
|
|
275
|
+
const DESTRUCTIVE_WORDS = ["remove", "delete", "drop", "destroy", "kill", "purge", "wipe", "break", "disable", "revert", "rollback", "undo"];
|
|
276
|
+
|
|
277
|
+
function expandWithSynonyms(words) {
|
|
278
|
+
const expanded = new Set(words);
|
|
279
|
+
for (const word of words) {
|
|
280
|
+
for (const group of SYNONYM_GROUPS) {
|
|
281
|
+
if (group.includes(word)) {
|
|
282
|
+
for (const syn of group) expanded.add(syn);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return [...expanded];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function hasNegation(text) {
|
|
290
|
+
const lower = text.toLowerCase();
|
|
291
|
+
return NEGATION_WORDS.some((neg) => lower.includes(neg));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isDestructiveAction(text) {
|
|
295
|
+
const lower = text.toLowerCase();
|
|
296
|
+
return DESTRUCTIVE_WORDS.some((w) => lower.includes(w));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Check if a proposed action conflicts with any active SpecLock
|
|
300
|
+
export function checkConflict(root, proposedAction) {
|
|
301
|
+
const brain = ensureInit(root);
|
|
302
|
+
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
303
|
+
if (activeLocks.length === 0) {
|
|
304
|
+
return {
|
|
305
|
+
hasConflict: false,
|
|
306
|
+
conflictingLocks: [],
|
|
307
|
+
analysis: "No active locks. No constraints to check against.",
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const actionLower = proposedAction.toLowerCase();
|
|
312
|
+
const actionWords = actionLower.split(/\s+/).filter((w) => w.length > 2);
|
|
313
|
+
const actionExpanded = expandWithSynonyms(actionWords);
|
|
314
|
+
const actionIsDestructive = isDestructiveAction(actionLower);
|
|
315
|
+
|
|
316
|
+
const conflicting = [];
|
|
317
|
+
for (const lock of activeLocks) {
|
|
318
|
+
const lockLower = lock.text.toLowerCase();
|
|
319
|
+
const lockWords = lockLower.split(/\s+/).filter((w) => w.length > 2);
|
|
320
|
+
const lockExpanded = expandWithSynonyms(lockWords);
|
|
321
|
+
|
|
322
|
+
// Direct keyword overlap
|
|
323
|
+
const directOverlap = actionWords.filter((w) => lockWords.includes(w));
|
|
324
|
+
|
|
325
|
+
// Synonym-expanded overlap
|
|
326
|
+
const synonymOverlap = actionExpanded.filter((w) => lockExpanded.includes(w));
|
|
327
|
+
const uniqueSynonymMatches = synonymOverlap.filter((w) => !directOverlap.includes(w));
|
|
328
|
+
|
|
329
|
+
// Negation analysis: lock says "No X" and action does X
|
|
330
|
+
const lockHasNegation = hasNegation(lockLower);
|
|
331
|
+
const actionHasNegation = hasNegation(actionLower);
|
|
332
|
+
const negationConflict = lockHasNegation && !actionHasNegation && synonymOverlap.length > 0;
|
|
333
|
+
|
|
334
|
+
// Calculate confidence score
|
|
335
|
+
let confidence = 0;
|
|
336
|
+
let reasons = [];
|
|
337
|
+
|
|
338
|
+
if (directOverlap.length > 0) {
|
|
339
|
+
confidence += directOverlap.length * 30;
|
|
340
|
+
reasons.push(`direct keyword match: ${directOverlap.join(", ")}`);
|
|
341
|
+
}
|
|
342
|
+
if (uniqueSynonymMatches.length > 0) {
|
|
343
|
+
confidence += uniqueSynonymMatches.length * 15;
|
|
344
|
+
reasons.push(`synonym match: ${uniqueSynonymMatches.join(", ")}`);
|
|
345
|
+
}
|
|
346
|
+
if (negationConflict) {
|
|
347
|
+
confidence += 40;
|
|
348
|
+
reasons.push("lock prohibits this action (negation detected)");
|
|
349
|
+
}
|
|
350
|
+
if (actionIsDestructive && synonymOverlap.length > 0) {
|
|
351
|
+
confidence += 20;
|
|
352
|
+
reasons.push("destructive action against locked constraint");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
confidence = Math.min(confidence, 100);
|
|
356
|
+
|
|
357
|
+
if (confidence >= 15) {
|
|
358
|
+
const level = confidence >= 70 ? "HIGH" : confidence >= 40 ? "MEDIUM" : "LOW";
|
|
359
|
+
conflicting.push({
|
|
360
|
+
id: lock.id,
|
|
361
|
+
text: lock.text,
|
|
362
|
+
matchedKeywords: [...new Set([...directOverlap, ...uniqueSynonymMatches])],
|
|
363
|
+
confidence,
|
|
364
|
+
level,
|
|
365
|
+
reasons,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (conflicting.length === 0) {
|
|
371
|
+
return {
|
|
372
|
+
hasConflict: false,
|
|
373
|
+
conflictingLocks: [],
|
|
374
|
+
analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (keyword + synonym + negation analysis). Proceed with caution.`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Sort by confidence descending
|
|
379
|
+
conflicting.sort((a, b) => b.confidence - a.confidence);
|
|
380
|
+
|
|
381
|
+
const details = conflicting
|
|
382
|
+
.map(
|
|
383
|
+
(c) =>
|
|
384
|
+
`- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
|
|
385
|
+
)
|
|
386
|
+
.join("\n");
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
hasConflict: true,
|
|
390
|
+
conflictingLocks: conflicting,
|
|
391
|
+
analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// --- Auto-lock suggestions ---
|
|
396
|
+
export function suggestLocks(root) {
|
|
397
|
+
const brain = ensureInit(root);
|
|
398
|
+
const suggestions = [];
|
|
399
|
+
|
|
400
|
+
// Analyze decisions for implicit constraints
|
|
401
|
+
for (const dec of brain.decisions) {
|
|
402
|
+
const lower = dec.text.toLowerCase();
|
|
403
|
+
// Decisions with strong commitment language become lock candidates
|
|
404
|
+
if (/\b(always|must|only|exclusively|never|required)\b/.test(lower)) {
|
|
405
|
+
suggestions.push({
|
|
406
|
+
text: dec.text,
|
|
407
|
+
source: "decision",
|
|
408
|
+
sourceId: dec.id,
|
|
409
|
+
reason: `Decision contains strong commitment language — consider promoting to a lock`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Analyze notes for implicit constraints
|
|
415
|
+
for (const note of brain.notes) {
|
|
416
|
+
const lower = note.text.toLowerCase();
|
|
417
|
+
if (/\b(never|must not|do not|don't|avoid|prohibit|forbidden)\b/.test(lower)) {
|
|
418
|
+
suggestions.push({
|
|
419
|
+
text: note.text,
|
|
420
|
+
source: "note",
|
|
421
|
+
sourceId: note.id,
|
|
422
|
+
reason: `Note contains prohibitive language — consider promoting to a lock`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Check for common patterns that should be locked
|
|
428
|
+
const existingLockTexts = brain.specLock.items
|
|
429
|
+
.filter((l) => l.active)
|
|
430
|
+
.map((l) => l.text.toLowerCase());
|
|
431
|
+
|
|
432
|
+
// Suggest common locks if not already present
|
|
433
|
+
const commonPatterns = [
|
|
434
|
+
{ keyword: "api", suggestion: "No breaking changes to public API" },
|
|
435
|
+
{ keyword: "database", suggestion: "No destructive database migrations without backup" },
|
|
436
|
+
{ keyword: "deploy", suggestion: "All deployments must pass CI checks" },
|
|
437
|
+
{ keyword: "security", suggestion: "No secrets or credentials in source code" },
|
|
438
|
+
{ keyword: "test", suggestion: "No merging without passing tests" },
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
// Check if project context suggests these
|
|
442
|
+
const allText = [
|
|
443
|
+
brain.goal.text,
|
|
444
|
+
...brain.decisions.map((d) => d.text),
|
|
445
|
+
...brain.notes.map((n) => n.text),
|
|
446
|
+
].join(" ").toLowerCase();
|
|
447
|
+
|
|
448
|
+
for (const pattern of commonPatterns) {
|
|
449
|
+
if (allText.includes(pattern.keyword)) {
|
|
450
|
+
const alreadyLocked = existingLockTexts.some((t) =>
|
|
451
|
+
t.includes(pattern.keyword)
|
|
452
|
+
);
|
|
453
|
+
if (!alreadyLocked) {
|
|
454
|
+
suggestions.push({
|
|
455
|
+
text: pattern.suggestion,
|
|
456
|
+
source: "pattern",
|
|
457
|
+
sourceId: null,
|
|
458
|
+
reason: `Project mentions "${pattern.keyword}" but has no lock protecting it`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { suggestions, totalLocks: brain.specLock.items.filter((l) => l.active).length };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --- Drift detection ---
|
|
468
|
+
export function detectDrift(root) {
|
|
469
|
+
const brain = ensureInit(root);
|
|
470
|
+
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
471
|
+
if (activeLocks.length === 0) {
|
|
472
|
+
return { drifts: [], status: "no_locks", message: "No active locks to check against." };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const drifts = [];
|
|
476
|
+
|
|
477
|
+
// Check recent changes against locks
|
|
478
|
+
for (const change of brain.state.recentChanges) {
|
|
479
|
+
const changeLower = change.summary.toLowerCase();
|
|
480
|
+
const changeWords = changeLower.split(/\s+/).filter((w) => w.length > 2);
|
|
481
|
+
const changeExpanded = expandWithSynonyms(changeWords);
|
|
482
|
+
|
|
483
|
+
for (const lock of activeLocks) {
|
|
484
|
+
const lockLower = lock.text.toLowerCase();
|
|
485
|
+
const lockWords = lockLower.split(/\s+/).filter((w) => w.length > 2);
|
|
486
|
+
const lockExpanded = expandWithSynonyms(lockWords);
|
|
487
|
+
|
|
488
|
+
const overlap = changeExpanded.filter((w) => lockExpanded.includes(w));
|
|
489
|
+
const lockHasNegation = hasNegation(lockLower);
|
|
490
|
+
|
|
491
|
+
if (overlap.length >= 2 && lockHasNegation) {
|
|
492
|
+
drifts.push({
|
|
493
|
+
lockId: lock.id,
|
|
494
|
+
lockText: lock.text,
|
|
495
|
+
changeEventId: change.eventId,
|
|
496
|
+
changeSummary: change.summary,
|
|
497
|
+
changeAt: change.at,
|
|
498
|
+
matchedTerms: overlap,
|
|
499
|
+
severity: overlap.length >= 3 ? "high" : "medium",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Check for reverts (always a drift signal)
|
|
506
|
+
for (const revert of brain.state.reverts) {
|
|
507
|
+
drifts.push({
|
|
508
|
+
lockId: null,
|
|
509
|
+
lockText: "(git revert detected)",
|
|
510
|
+
changeEventId: revert.eventId,
|
|
511
|
+
changeSummary: `Git ${revert.kind} to ${revert.target.substring(0, 12)}`,
|
|
512
|
+
changeAt: revert.at,
|
|
513
|
+
matchedTerms: ["revert"],
|
|
514
|
+
severity: "high",
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const status = drifts.length === 0 ? "clean" : "drift_detected";
|
|
519
|
+
const message = drifts.length === 0
|
|
520
|
+
? `All clear. ${activeLocks.length} lock(s) checked against ${brain.state.recentChanges.length} recent change(s). No drift detected.`
|
|
521
|
+
: `WARNING: ${drifts.length} potential drift(s) detected. Review immediately.`;
|
|
522
|
+
|
|
523
|
+
return { drifts, status, message };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// --- Session management ---
|
|
527
|
+
|
|
528
|
+
export function startSession(root, toolName = "unknown") {
|
|
529
|
+
const brain = ensureInit(root);
|
|
530
|
+
|
|
531
|
+
// Auto-close previous session if open
|
|
532
|
+
if (brain.sessions.current) {
|
|
533
|
+
const prev = brain.sessions.current;
|
|
534
|
+
prev.endedAt = nowIso();
|
|
535
|
+
prev.summary = prev.summary || "Session auto-closed (new session started)";
|
|
536
|
+
brain.sessions.history.unshift(prev);
|
|
537
|
+
if (brain.sessions.history.length > 50) {
|
|
538
|
+
brain.sessions.history = brain.sessions.history.slice(0, 50);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const session = {
|
|
543
|
+
id: newId("ses"),
|
|
544
|
+
startedAt: nowIso(),
|
|
545
|
+
endedAt: null,
|
|
546
|
+
summary: "",
|
|
547
|
+
toolUsed: toolName,
|
|
548
|
+
eventsInSession: 0,
|
|
549
|
+
};
|
|
550
|
+
brain.sessions.current = session;
|
|
551
|
+
|
|
552
|
+
const eventId = newId("evt");
|
|
553
|
+
const event = {
|
|
554
|
+
eventId,
|
|
555
|
+
type: "session_started",
|
|
556
|
+
at: nowIso(),
|
|
557
|
+
files: [],
|
|
558
|
+
summary: `Session started (${toolName})`,
|
|
559
|
+
patchPath: "",
|
|
560
|
+
};
|
|
561
|
+
recordEvent(root, brain, event);
|
|
562
|
+
return { brain, session };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function endSession(root, summary) {
|
|
566
|
+
const brain = ensureInit(root);
|
|
567
|
+
if (!brain.sessions.current) {
|
|
568
|
+
return { brain, ended: false, error: "No active session to end." };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const session = brain.sessions.current;
|
|
572
|
+
session.endedAt = nowIso();
|
|
573
|
+
session.summary = summary;
|
|
574
|
+
|
|
575
|
+
// Count events during this session
|
|
576
|
+
const events = readEvents(root, { since: session.startedAt });
|
|
577
|
+
session.eventsInSession = events.length;
|
|
578
|
+
|
|
579
|
+
brain.sessions.history.unshift(session);
|
|
580
|
+
if (brain.sessions.history.length > 50) {
|
|
581
|
+
brain.sessions.history = brain.sessions.history.slice(0, 50);
|
|
582
|
+
}
|
|
583
|
+
brain.sessions.current = null;
|
|
584
|
+
|
|
585
|
+
const eventId = newId("evt");
|
|
586
|
+
const event = {
|
|
587
|
+
eventId,
|
|
588
|
+
type: "session_ended",
|
|
589
|
+
at: nowIso(),
|
|
590
|
+
files: [],
|
|
591
|
+
summary: `Session ended: ${summary.substring(0, 100)}`,
|
|
592
|
+
patchPath: "",
|
|
593
|
+
};
|
|
594
|
+
recordEvent(root, brain, event);
|
|
595
|
+
return { brain, ended: true, session };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function getSessionBriefing(root, toolName = "unknown") {
|
|
599
|
+
const { brain, session } = startSession(root, toolName);
|
|
600
|
+
|
|
601
|
+
const lastSession =
|
|
602
|
+
brain.sessions.history.length > 0 ? brain.sessions.history[0] : null;
|
|
603
|
+
|
|
604
|
+
let changesSinceLastSession = 0;
|
|
605
|
+
let warnings = [];
|
|
606
|
+
|
|
607
|
+
if (lastSession && lastSession.endedAt) {
|
|
608
|
+
const eventsSince = readEvents(root, { since: lastSession.endedAt });
|
|
609
|
+
changesSinceLastSession = eventsSince.length;
|
|
610
|
+
|
|
611
|
+
// Check for reverts since last session
|
|
612
|
+
const revertsSince = eventsSince.filter(
|
|
613
|
+
(e) => e.type === "revert_detected"
|
|
614
|
+
);
|
|
615
|
+
if (revertsSince.length > 0) {
|
|
616
|
+
warnings.push(
|
|
617
|
+
`${revertsSince.length} revert(s) detected since last session. Verify current state before proceeding.`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
brain,
|
|
624
|
+
session,
|
|
625
|
+
lastSession,
|
|
626
|
+
changesSinceLastSession,
|
|
627
|
+
warnings,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// --- File watcher ---
|
|
632
|
+
|
|
633
|
+
export async function watchRepo(root) {
|
|
634
|
+
const { default: chokidar } = await import("chokidar");
|
|
635
|
+
const brain = ensureInit(root);
|
|
636
|
+
const ignore = [
|
|
637
|
+
"**/node_modules/**",
|
|
638
|
+
"**/.git/**",
|
|
639
|
+
"**/.speclock/**",
|
|
640
|
+
];
|
|
641
|
+
|
|
642
|
+
let lastFileEventAt = 0;
|
|
643
|
+
|
|
644
|
+
const watcher = chokidar.watch(root, {
|
|
645
|
+
ignored: ignore,
|
|
646
|
+
ignoreInitial: true,
|
|
647
|
+
persistent: true,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
watcher.on("add", (p) => {
|
|
651
|
+
lastFileEventAt = Date.now();
|
|
652
|
+
handleFileEvent(root, brain, "file_created", p);
|
|
653
|
+
});
|
|
654
|
+
watcher.on("change", (p) => {
|
|
655
|
+
lastFileEventAt = Date.now();
|
|
656
|
+
handleFileEvent(root, brain, "file_changed", p);
|
|
657
|
+
});
|
|
658
|
+
watcher.on("unlink", (p) => {
|
|
659
|
+
lastFileEventAt = Date.now();
|
|
660
|
+
handleFileEvent(root, brain, "file_deleted", p);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Revert detection via HEAD polling
|
|
664
|
+
if (brain.facts.repo.hasGit) {
|
|
665
|
+
setInterval(() => {
|
|
666
|
+
const head = getHead(root);
|
|
667
|
+
if (!head.gitCommit) return;
|
|
668
|
+
const prev = brain.state.head.gitCommit;
|
|
669
|
+
const now = Date.now();
|
|
670
|
+
if (prev && head.gitCommit !== prev && now - lastFileEventAt > 2000) {
|
|
671
|
+
const eventId = newId("evt");
|
|
672
|
+
const event = {
|
|
673
|
+
eventId,
|
|
674
|
+
type: "revert_detected",
|
|
675
|
+
at: nowIso(),
|
|
676
|
+
files: [],
|
|
677
|
+
summary: `HEAD moved to ${head.gitCommit.substring(0, 12)}`,
|
|
678
|
+
patchPath: "",
|
|
679
|
+
};
|
|
680
|
+
addRevert(brain, {
|
|
681
|
+
eventId,
|
|
682
|
+
kind: "git_checkout",
|
|
683
|
+
target: head.gitCommit,
|
|
684
|
+
at: event.at,
|
|
685
|
+
note: "",
|
|
686
|
+
});
|
|
687
|
+
recordEvent(root, brain, event);
|
|
688
|
+
}
|
|
689
|
+
brain.state.head.gitBranch = head.gitBranch;
|
|
690
|
+
brain.state.head.gitCommit = head.gitCommit;
|
|
691
|
+
brain.state.head.capturedAt = nowIso();
|
|
692
|
+
writeBrain(root, brain);
|
|
693
|
+
}, 5000);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
console.log("SpecLock watching for changes...");
|
|
697
|
+
return watcher;
|
|
698
|
+
}
|