clearctx 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/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +1006 -0
- package/STRATEGY.md +485 -0
- package/bin/cli.js +1756 -0
- package/bin/continuity-hook.js +118 -0
- package/bin/mcp.js +27 -0
- package/bin/setup.js +929 -0
- package/package.json +56 -0
- package/src/artifact-store.js +710 -0
- package/src/atomic-io.js +99 -0
- package/src/briefing-generator.js +451 -0
- package/src/continuity-hooks.js +253 -0
- package/src/contract-store.js +525 -0
- package/src/decision-journal.js +229 -0
- package/src/delegate.js +348 -0
- package/src/dependency-resolver.js +453 -0
- package/src/diff-engine.js +473 -0
- package/src/file-lock.js +161 -0
- package/src/index.js +61 -0
- package/src/lineage-graph.js +402 -0
- package/src/manager.js +510 -0
- package/src/mcp-server.js +3501 -0
- package/src/pattern-registry.js +221 -0
- package/src/pipeline-engine.js +618 -0
- package/src/prompts.js +1217 -0
- package/src/safety-net.js +170 -0
- package/src/session-snapshot.js +508 -0
- package/src/snapshot-engine.js +490 -0
- package/src/stale-detector.js +169 -0
- package/src/store.js +131 -0
- package/src/stream-session.js +463 -0
- package/src/team-hub.js +615 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff-engine.js
|
|
3
|
+
*
|
|
4
|
+
* The Diff Engine computes what changed between sessions.
|
|
5
|
+
* This is Layer 0 of the Session Continuity Engine.
|
|
6
|
+
*
|
|
7
|
+
* Primary use case: "What changed since my last session?"
|
|
8
|
+
* - Compares latest snapshot to current git state
|
|
9
|
+
* - Detects new commits, file changes, and stale context
|
|
10
|
+
* - Provides time-aware session continuity
|
|
11
|
+
*
|
|
12
|
+
* Uses synchronous git commands and zero external dependencies.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { execSync } = require('child_process');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const SessionSnapshot = require('./session-snapshot');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DiffEngine - Computes differences between snapshots and current state
|
|
23
|
+
*
|
|
24
|
+
* This class analyzes what changed between sessions by comparing
|
|
25
|
+
* git state, file modifications, and working context.
|
|
26
|
+
*/
|
|
27
|
+
class DiffEngine {
|
|
28
|
+
/**
|
|
29
|
+
* Create a new DiffEngine
|
|
30
|
+
* @param {string} projectPath - Absolute path to the project directory
|
|
31
|
+
*/
|
|
32
|
+
constructor(projectPath) {
|
|
33
|
+
// Store the project path for git operations
|
|
34
|
+
this.projectPath = projectPath;
|
|
35
|
+
|
|
36
|
+
// Create a SessionSnapshot instance to read snapshots
|
|
37
|
+
this.snapshot = new SessionSnapshot(projectPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compare latest snapshot to current git state
|
|
42
|
+
*
|
|
43
|
+
* This is the PRIMARY use case — answers "what changed since my last session?"
|
|
44
|
+
*
|
|
45
|
+
* @returns {Object} Diff object with git changes, file changes, and context impact
|
|
46
|
+
*/
|
|
47
|
+
diffFromLatest() {
|
|
48
|
+
// Step 1: Get the latest snapshot
|
|
49
|
+
const snapshot = this.snapshot.getLatest();
|
|
50
|
+
|
|
51
|
+
// If no snapshot exists, this is the first session
|
|
52
|
+
if (!snapshot) {
|
|
53
|
+
return {
|
|
54
|
+
noSnapshot: true,
|
|
55
|
+
message: 'No previous snapshot found. This appears to be the first session.'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Step 2: Get the snapshot's git commit
|
|
60
|
+
const snapshotHead = snapshot.git?.headCommit || null;
|
|
61
|
+
|
|
62
|
+
// If snapshot has no git state (repo had no commits), return a special diff
|
|
63
|
+
if (!snapshotHead) {
|
|
64
|
+
return {
|
|
65
|
+
noGitState: true,
|
|
66
|
+
message: 'Previous snapshot had no git state (repository had no commits at that time).',
|
|
67
|
+
fromSnapshot: snapshot.snapshotId,
|
|
68
|
+
toState: 'current',
|
|
69
|
+
timeDelta: Date.now() - new Date(snapshot.capturedAt).getTime(),
|
|
70
|
+
timeDeltaHuman: this._formatTimeDelta(Date.now() - new Date(snapshot.capturedAt).getTime()),
|
|
71
|
+
gitChanges: {
|
|
72
|
+
newCommitCount: 0,
|
|
73
|
+
commits: [],
|
|
74
|
+
branchChanged: false,
|
|
75
|
+
oldBranch: null,
|
|
76
|
+
newBranch: null,
|
|
77
|
+
diffFailed: false
|
|
78
|
+
},
|
|
79
|
+
fileChanges: {
|
|
80
|
+
added: [],
|
|
81
|
+
modified: [],
|
|
82
|
+
deleted: [],
|
|
83
|
+
renamed: [],
|
|
84
|
+
diffFailed: false
|
|
85
|
+
},
|
|
86
|
+
contextImpact: {
|
|
87
|
+
activeFilesModified: [],
|
|
88
|
+
staleFiles: [],
|
|
89
|
+
previousTask: snapshot.workingContext?.taskSummary || '',
|
|
90
|
+
previousQuestions: snapshot.workingContext?.openQuestions || []
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 3: Compute current git state
|
|
96
|
+
let currentHead, currentBranch;
|
|
97
|
+
try {
|
|
98
|
+
// Get current commit hash
|
|
99
|
+
currentHead = execSync('git rev-parse HEAD', {
|
|
100
|
+
cwd: this.projectPath,
|
|
101
|
+
encoding: 'utf8'
|
|
102
|
+
}).trim();
|
|
103
|
+
|
|
104
|
+
// Get current branch name
|
|
105
|
+
currentBranch = execSync('git branch --show-current', {
|
|
106
|
+
cwd: this.projectPath,
|
|
107
|
+
encoding: 'utf8'
|
|
108
|
+
}).trim();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
error: true,
|
|
112
|
+
message: 'Failed to get current git state: ' + err.message
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Step 4: Compute git changes between snapshot and now
|
|
117
|
+
let commits = [];
|
|
118
|
+
let commitDiffFailed = false;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// Get new commits since snapshot
|
|
122
|
+
const logOutput = execSync(`git log --oneline ${snapshotHead}..HEAD`, {
|
|
123
|
+
cwd: this.projectPath,
|
|
124
|
+
encoding: 'utf8'
|
|
125
|
+
}).trim();
|
|
126
|
+
|
|
127
|
+
if (logOutput) {
|
|
128
|
+
commits = this._parseOneline(logOutput);
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// Snapshot commit might not exist (rebased/force-pushed)
|
|
132
|
+
commitDiffFailed = true;
|
|
133
|
+
commits = [{
|
|
134
|
+
hash: 'N/A',
|
|
135
|
+
message: `Cannot compute commits (snapshot commit ${snapshotHead.slice(0, 7)} no longer exists)`
|
|
136
|
+
}];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Step 5: Compute file changes
|
|
140
|
+
let fileChanges = { added: [], modified: [], deleted: [], renamed: [] };
|
|
141
|
+
let fileDiffFailed = false;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
if (!commitDiffFailed && snapshotHead !== currentHead) {
|
|
145
|
+
// Use git diff to find changes between commits
|
|
146
|
+
const diffOutput = execSync(`git diff --name-status ${snapshotHead} HEAD`, {
|
|
147
|
+
cwd: this.projectPath,
|
|
148
|
+
encoding: 'utf8'
|
|
149
|
+
}).trim();
|
|
150
|
+
|
|
151
|
+
if (diffOutput) {
|
|
152
|
+
fileChanges = this._parseNameStatus(diffOutput);
|
|
153
|
+
}
|
|
154
|
+
} else if (commitDiffFailed) {
|
|
155
|
+
// Fallback: use git status if snapshot commit doesn't exist
|
|
156
|
+
const statusOutput = execSync('git status --porcelain', {
|
|
157
|
+
cwd: this.projectPath,
|
|
158
|
+
encoding: 'utf8'
|
|
159
|
+
}).trim();
|
|
160
|
+
|
|
161
|
+
if (statusOutput) {
|
|
162
|
+
fileChanges = this._parseStatusPorcelain(statusOutput);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// If snapshotHead === currentHead, no file changes (already initialized empty)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
fileDiffFailed = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Step 6: Compute context impact
|
|
171
|
+
const activeFiles = snapshot.workingContext?.activeFiles || [];
|
|
172
|
+
const allChangedFiles = [
|
|
173
|
+
...fileChanges.added,
|
|
174
|
+
...fileChanges.modified,
|
|
175
|
+
...fileChanges.deleted,
|
|
176
|
+
...fileChanges.renamed.map(r => r.from),
|
|
177
|
+
...fileChanges.renamed.map(r => r.to)
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
// Find which active files were modified
|
|
181
|
+
const activeFilesModified = activeFiles.filter(file =>
|
|
182
|
+
allChangedFiles.includes(file)
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Step 7: Compute time delta
|
|
186
|
+
const timeDeltaMs = Date.now() - new Date(snapshot.capturedAt).getTime();
|
|
187
|
+
|
|
188
|
+
// Step 8: Return comprehensive diff object
|
|
189
|
+
return {
|
|
190
|
+
fromSnapshot: snapshot.snapshotId,
|
|
191
|
+
toState: 'current',
|
|
192
|
+
timeDelta: timeDeltaMs,
|
|
193
|
+
timeDeltaHuman: this._formatTimeDelta(timeDeltaMs),
|
|
194
|
+
gitChanges: {
|
|
195
|
+
newCommitCount: commits.length,
|
|
196
|
+
commits: commits,
|
|
197
|
+
branchChanged: snapshot.git?.branch !== currentBranch,
|
|
198
|
+
oldBranch: snapshot.git?.branch || 'unknown',
|
|
199
|
+
newBranch: currentBranch,
|
|
200
|
+
diffFailed: commitDiffFailed
|
|
201
|
+
},
|
|
202
|
+
fileChanges: {
|
|
203
|
+
added: fileChanges.added,
|
|
204
|
+
modified: fileChanges.modified,
|
|
205
|
+
deleted: fileChanges.deleted,
|
|
206
|
+
renamed: fileChanges.renamed,
|
|
207
|
+
diffFailed: fileDiffFailed
|
|
208
|
+
},
|
|
209
|
+
contextImpact: {
|
|
210
|
+
activeFilesModified: activeFilesModified,
|
|
211
|
+
staleFiles: activeFilesModified, // Alias for clarity
|
|
212
|
+
previousTask: snapshot.workingContext?.taskSummary || '',
|
|
213
|
+
previousQuestions: snapshot.workingContext?.openQuestions || []
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Compare two specific snapshots
|
|
220
|
+
*
|
|
221
|
+
* @param {string} snapshotIdA - First snapshot ID
|
|
222
|
+
* @param {string} snapshotIdB - Second snapshot ID
|
|
223
|
+
* @returns {Object} Diff object between the two snapshots
|
|
224
|
+
*/
|
|
225
|
+
diffBetween(snapshotIdA, snapshotIdB) {
|
|
226
|
+
// Step 1: Get both snapshots
|
|
227
|
+
const snapshotA = this.snapshot.get(snapshotIdA);
|
|
228
|
+
const snapshotB = this.snapshot.get(snapshotIdB);
|
|
229
|
+
|
|
230
|
+
if (!snapshotA) {
|
|
231
|
+
throw new Error(`Snapshot not found: ${snapshotIdA}`);
|
|
232
|
+
}
|
|
233
|
+
if (!snapshotB) {
|
|
234
|
+
throw new Error(`Snapshot not found: ${snapshotIdB}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Step 2: Get git commits from both snapshots
|
|
238
|
+
const commitA = snapshotA.git?.headCommit;
|
|
239
|
+
const commitB = snapshotB.git?.headCommit;
|
|
240
|
+
|
|
241
|
+
if (!commitA || !commitB) {
|
|
242
|
+
throw new Error('One or both snapshots missing git headCommit');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Step 3: Compute commit list
|
|
246
|
+
let commits = [];
|
|
247
|
+
let commitDiffFailed = false;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const logOutput = execSync(`git log --oneline ${commitA}..${commitB}`, {
|
|
251
|
+
cwd: this.projectPath,
|
|
252
|
+
encoding: 'utf8'
|
|
253
|
+
}).trim();
|
|
254
|
+
|
|
255
|
+
if (logOutput) {
|
|
256
|
+
commits = this._parseOneline(logOutput);
|
|
257
|
+
}
|
|
258
|
+
} catch (err) {
|
|
259
|
+
commitDiffFailed = true;
|
|
260
|
+
commits = [{
|
|
261
|
+
hash: 'N/A',
|
|
262
|
+
message: 'Cannot compute commits (one or both commits no longer exist)'
|
|
263
|
+
}];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Step 4: Compute file changes
|
|
267
|
+
let fileChanges = { added: [], modified: [], deleted: [], renamed: [] };
|
|
268
|
+
let fileDiffFailed = false;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
if (!commitDiffFailed) {
|
|
272
|
+
const diffOutput = execSync(`git diff --name-status ${commitA} ${commitB}`, {
|
|
273
|
+
cwd: this.projectPath,
|
|
274
|
+
encoding: 'utf8'
|
|
275
|
+
}).trim();
|
|
276
|
+
|
|
277
|
+
if (diffOutput) {
|
|
278
|
+
fileChanges = this._parseNameStatus(diffOutput);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
fileDiffFailed = true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Step 5: Compute time delta
|
|
286
|
+
const timeDeltaMs = new Date(snapshotB.capturedAt).getTime() -
|
|
287
|
+
new Date(snapshotA.capturedAt).getTime();
|
|
288
|
+
|
|
289
|
+
// Step 6: Return diff object
|
|
290
|
+
return {
|
|
291
|
+
fromSnapshot: snapshotIdA,
|
|
292
|
+
toSnapshot: snapshotIdB,
|
|
293
|
+
timeDelta: timeDeltaMs,
|
|
294
|
+
timeDeltaHuman: this._formatTimeDelta(timeDeltaMs),
|
|
295
|
+
gitChanges: {
|
|
296
|
+
newCommitCount: commits.length,
|
|
297
|
+
commits: commits,
|
|
298
|
+
branchChanged: snapshotA.git?.branch !== snapshotB.git?.branch,
|
|
299
|
+
oldBranch: snapshotA.git?.branch || 'unknown',
|
|
300
|
+
newBranch: snapshotB.git?.branch || 'unknown',
|
|
301
|
+
diffFailed: commitDiffFailed
|
|
302
|
+
},
|
|
303
|
+
fileChanges: {
|
|
304
|
+
added: fileChanges.added,
|
|
305
|
+
modified: fileChanges.modified,
|
|
306
|
+
deleted: fileChanges.deleted,
|
|
307
|
+
renamed: fileChanges.renamed,
|
|
308
|
+
diffFailed: fileDiffFailed
|
|
309
|
+
},
|
|
310
|
+
contextImpact: {
|
|
311
|
+
activeFilesModified: [], // Not computed for snapshot-to-snapshot diff
|
|
312
|
+
staleFiles: [],
|
|
313
|
+
previousTask: snapshotA.workingContext?.taskSummary || '',
|
|
314
|
+
previousQuestions: snapshotA.workingContext?.openQuestions || []
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Parse git diff --name-status output
|
|
321
|
+
*
|
|
322
|
+
* Format examples:
|
|
323
|
+
* A src/new-file.ts → added
|
|
324
|
+
* M src/modified.ts → modified
|
|
325
|
+
* D src/deleted.ts → deleted
|
|
326
|
+
* R100 src/old.ts src/new.ts → renamed
|
|
327
|
+
*
|
|
328
|
+
* @param {string} output - Output from git diff --name-status
|
|
329
|
+
* @returns {Object} Categorized file changes
|
|
330
|
+
* @private
|
|
331
|
+
*/
|
|
332
|
+
_parseNameStatus(output) {
|
|
333
|
+
const result = {
|
|
334
|
+
added: [],
|
|
335
|
+
modified: [],
|
|
336
|
+
deleted: [],
|
|
337
|
+
renamed: []
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (!output) return result;
|
|
341
|
+
|
|
342
|
+
const lines = output.split('\n');
|
|
343
|
+
for (const line of lines) {
|
|
344
|
+
if (!line.trim()) continue;
|
|
345
|
+
|
|
346
|
+
// Split by tabs or multiple spaces
|
|
347
|
+
const parts = line.split(/\s+/);
|
|
348
|
+
const status = parts[0];
|
|
349
|
+
|
|
350
|
+
if (status.startsWith('A')) {
|
|
351
|
+
// Added file
|
|
352
|
+
result.added.push(parts[1]);
|
|
353
|
+
} else if (status.startsWith('M')) {
|
|
354
|
+
// Modified file
|
|
355
|
+
result.modified.push(parts[1]);
|
|
356
|
+
} else if (status.startsWith('D')) {
|
|
357
|
+
// Deleted file
|
|
358
|
+
result.deleted.push(parts[1]);
|
|
359
|
+
} else if (status.startsWith('R')) {
|
|
360
|
+
// Renamed file (R100 means 100% similarity)
|
|
361
|
+
result.renamed.push({
|
|
362
|
+
from: parts[1],
|
|
363
|
+
to: parts[2]
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Parse git status --porcelain output (fallback when diff fails)
|
|
373
|
+
*
|
|
374
|
+
* Format examples:
|
|
375
|
+
* ?? new-file.ts → added (untracked)
|
|
376
|
+
* M modified.ts → modified
|
|
377
|
+
* D deleted.ts → deleted
|
|
378
|
+
*
|
|
379
|
+
* @param {string} output - Output from git status --porcelain
|
|
380
|
+
* @returns {Object} Categorized file changes
|
|
381
|
+
* @private
|
|
382
|
+
*/
|
|
383
|
+
_parseStatusPorcelain(output) {
|
|
384
|
+
const result = {
|
|
385
|
+
added: [],
|
|
386
|
+
modified: [],
|
|
387
|
+
deleted: [],
|
|
388
|
+
renamed: []
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
if (!output) return result;
|
|
392
|
+
|
|
393
|
+
const lines = output.split('\n');
|
|
394
|
+
for (const line of lines) {
|
|
395
|
+
if (!line.trim()) continue;
|
|
396
|
+
|
|
397
|
+
// First two characters are status codes
|
|
398
|
+
const status = line.substring(0, 2);
|
|
399
|
+
const file = line.substring(3).trim();
|
|
400
|
+
|
|
401
|
+
if (status.includes('?')) {
|
|
402
|
+
result.added.push(file);
|
|
403
|
+
} else if (status.includes('M') || status.includes('A')) {
|
|
404
|
+
result.modified.push(file);
|
|
405
|
+
} else if (status.includes('D')) {
|
|
406
|
+
result.deleted.push(file);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Parse git log --oneline output
|
|
415
|
+
*
|
|
416
|
+
* Format: "abc123d Some commit message"
|
|
417
|
+
*
|
|
418
|
+
* @param {string} output - Output from git log --oneline
|
|
419
|
+
* @returns {Array<Object>} Array of {hash, message} objects
|
|
420
|
+
* @private
|
|
421
|
+
*/
|
|
422
|
+
_parseOneline(output) {
|
|
423
|
+
const commits = [];
|
|
424
|
+
|
|
425
|
+
if (!output) return commits;
|
|
426
|
+
|
|
427
|
+
const lines = output.split('\n');
|
|
428
|
+
for (const line of lines) {
|
|
429
|
+
if (!line.trim()) continue;
|
|
430
|
+
|
|
431
|
+
// First word is the hash, rest is the message
|
|
432
|
+
const spaceIndex = line.indexOf(' ');
|
|
433
|
+
if (spaceIndex === -1) continue;
|
|
434
|
+
|
|
435
|
+
const hash = line.substring(0, spaceIndex);
|
|
436
|
+
const message = line.substring(spaceIndex + 1);
|
|
437
|
+
|
|
438
|
+
commits.push({ hash, message });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return commits;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Convert milliseconds to human-readable time string
|
|
446
|
+
*
|
|
447
|
+
* @param {number} ms - Milliseconds
|
|
448
|
+
* @returns {string} Human-readable time (e.g., "5 minutes ago")
|
|
449
|
+
* @private
|
|
450
|
+
*/
|
|
451
|
+
_formatTimeDelta(ms) {
|
|
452
|
+
const seconds = Math.floor(ms / 1000);
|
|
453
|
+
const minutes = Math.floor(seconds / 60);
|
|
454
|
+
const hours = Math.floor(minutes / 60);
|
|
455
|
+
const days = Math.floor(hours / 24);
|
|
456
|
+
const weeks = Math.floor(days / 7);
|
|
457
|
+
|
|
458
|
+
if (seconds < 60) {
|
|
459
|
+
return 'just now';
|
|
460
|
+
} else if (minutes < 60) {
|
|
461
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
462
|
+
} else if (hours < 24) {
|
|
463
|
+
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
464
|
+
} else if (days < 7) {
|
|
465
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
466
|
+
} else {
|
|
467
|
+
return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Export the DiffEngine class directly
|
|
473
|
+
module.exports = DiffEngine;
|
package/src/file-lock.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process file locking with PID-aware stale lock recovery.
|
|
3
|
+
*
|
|
4
|
+
* Uses exclusive file creation (wx flag) for atomic lock acquisition.
|
|
5
|
+
* Handles stale locks from crashed processes by checking PID liveness.
|
|
6
|
+
*
|
|
7
|
+
* Lock stealing rules:
|
|
8
|
+
* 1. If lock owner PID is dead → steal immediately
|
|
9
|
+
* 2. If lock owner PID is alive but lock is stale → steal after timeout
|
|
10
|
+
* 3. If lock owner PID is alive and lock is fresh → retry until timeout
|
|
11
|
+
*
|
|
12
|
+
* Configuration via environment variables:
|
|
13
|
+
* - CMS_LOCK_STALE_MS: Milliseconds before a lock is considered stale (default: 30000)
|
|
14
|
+
* - CMS_LOCK_MAX_RETRIES: Max retry attempts (default: 200)
|
|
15
|
+
* - CMS_LOCK_INTERVAL_MS: Wait time between retries (default: 50)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Acquire an exclusive lock using atomic file creation.
|
|
23
|
+
* Retries with backoff if lock is held. Steals stale locks.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} lockDir - Directory for lock files
|
|
26
|
+
* @param {string} lockName - Name of lock (e.g., 'session-foo')
|
|
27
|
+
* @param {object} [options={}] - Lock options
|
|
28
|
+
* @param {number} [options.staleMs] - Lock staleness timeout (default: 30000)
|
|
29
|
+
* @param {number} [options.maxRetries] - Max acquisition attempts (default: 200)
|
|
30
|
+
* @param {number} [options.retryIntervalMs] - Wait between retries (default: 50)
|
|
31
|
+
* @throws {Error} Throws if lock cannot be acquired within timeout
|
|
32
|
+
*/
|
|
33
|
+
function acquireLock(lockDir, lockName, options = {}) {
|
|
34
|
+
// Configuration with env var overrides
|
|
35
|
+
const staleMs = options.staleMs || Number(process.env.CMS_LOCK_STALE_MS) || 30000;
|
|
36
|
+
const maxRetries = options.maxRetries || Number(process.env.CMS_LOCK_MAX_RETRIES) || 200;
|
|
37
|
+
const retryIntervalMs = options.retryIntervalMs || Number(process.env.CMS_LOCK_INTERVAL_MS) || 50;
|
|
38
|
+
|
|
39
|
+
const lockFile = path.join(lockDir, `${lockName}.lock`);
|
|
40
|
+
|
|
41
|
+
// Ensure lock directory exists
|
|
42
|
+
if (!fs.existsSync(lockDir)) {
|
|
43
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Lock payload: PID for ownership verification, timestamp for staleness check
|
|
47
|
+
const lockData = {
|
|
48
|
+
pid: process.pid,
|
|
49
|
+
timestamp: Date.now()
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Retry loop with exponential backoff
|
|
53
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
54
|
+
try {
|
|
55
|
+
// Try to create lock file exclusively (atomic operation)
|
|
56
|
+
fs.writeFileSync(lockFile, JSON.stringify(lockData), { flag: 'wx' });
|
|
57
|
+
return; // Success! Lock acquired
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code !== 'EEXIST') {
|
|
60
|
+
// Unexpected error (permission denied, disk full, etc.)
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Lock file exists — check if we can steal it
|
|
65
|
+
try {
|
|
66
|
+
const existingLock = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
|
|
67
|
+
const ownerPid = existingLock.pid;
|
|
68
|
+
const lockTimestamp = existingLock.timestamp;
|
|
69
|
+
|
|
70
|
+
// Check if owner process is still alive
|
|
71
|
+
let isAlive = false;
|
|
72
|
+
try {
|
|
73
|
+
// process.kill(pid, 0) throws if process doesn't exist
|
|
74
|
+
// Signal 0 = no signal sent, just checks existence
|
|
75
|
+
process.kill(ownerPid, 0);
|
|
76
|
+
isAlive = true;
|
|
77
|
+
} catch (killErr) {
|
|
78
|
+
// Process is dead (ESRCH = no such process)
|
|
79
|
+
isAlive = false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!isAlive) {
|
|
83
|
+
// Owner process is dead — steal the lock immediately
|
|
84
|
+
try {
|
|
85
|
+
fs.unlinkSync(lockFile);
|
|
86
|
+
} catch (unlinkErr) {
|
|
87
|
+
// Lock might have been deleted by another process — retry
|
|
88
|
+
}
|
|
89
|
+
continue; // Retry immediately
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Owner process is alive — check if lock is stale
|
|
93
|
+
const lockAge = Date.now() - lockTimestamp;
|
|
94
|
+
if (lockAge > staleMs) {
|
|
95
|
+
// Lock is held too long — assume deadlock, steal it
|
|
96
|
+
try {
|
|
97
|
+
fs.unlinkSync(lockFile);
|
|
98
|
+
} catch (unlinkErr) {
|
|
99
|
+
// Lock might have been deleted by another process — retry
|
|
100
|
+
}
|
|
101
|
+
continue; // Retry immediately
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Lock is fresh and owner is alive — wait and retry
|
|
105
|
+
} catch (readErr) {
|
|
106
|
+
// Lock file is corrupt or deleted — retry immediately
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Wait before retrying (synchronous sleep using Atomics)
|
|
112
|
+
// Creates a shared buffer and waits on it (will always timeout)
|
|
113
|
+
const sharedBuffer = new SharedArrayBuffer(4);
|
|
114
|
+
const sharedArray = new Int32Array(sharedBuffer);
|
|
115
|
+
Atomics.wait(sharedArray, 0, 0, retryIntervalMs);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Failed to acquire lock within timeout
|
|
119
|
+
throw new Error(`Lock acquisition timeout for ${lockName} after ${maxRetries} retries`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Release a lock if owned by current process.
|
|
124
|
+
* Verifies ownership by checking PID before deletion.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} lockDir - Directory for lock files
|
|
127
|
+
* @param {string} lockName - Name of lock to release
|
|
128
|
+
*/
|
|
129
|
+
function releaseLock(lockDir, lockName) {
|
|
130
|
+
const lockFile = path.join(lockDir, `${lockName}.lock`);
|
|
131
|
+
|
|
132
|
+
// Check if lock file exists
|
|
133
|
+
if (!fs.existsSync(lockFile)) {
|
|
134
|
+
// Already released or never acquired — success
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
// Read lock to verify ownership
|
|
140
|
+
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
|
|
141
|
+
|
|
142
|
+
if (lockData.pid === process.pid) {
|
|
143
|
+
// We own this lock — safe to delete
|
|
144
|
+
fs.unlinkSync(lockFile);
|
|
145
|
+
} else {
|
|
146
|
+
// Someone else owns this lock (stolen from us?)
|
|
147
|
+
console.error(
|
|
148
|
+
`WARNING: Lock ${lockName} is owned by PID ${lockData.pid}, not ${process.pid}. ` +
|
|
149
|
+
`Not releasing. This may indicate a stolen lock or timing issue.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
// Lock file disappeared or is corrupt — consider it released
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
acquireLock,
|
|
160
|
+
releaseLock
|
|
161
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clearctx — Main exports
|
|
3
|
+
*
|
|
4
|
+
* Programmatic API for managing multiple Claude Code sessions.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { SessionManager, Delegate, StreamSession, SafetyNet } = require('clearctx');
|
|
8
|
+
*
|
|
9
|
+
* // High-level: delegate tasks with safety and control
|
|
10
|
+
* const mgr = new SessionManager();
|
|
11
|
+
* const delegate = new Delegate(mgr);
|
|
12
|
+
* const result = await delegate.run('fix-bug', { task: 'Fix the auth bug', maxCost: 0.50 });
|
|
13
|
+
*
|
|
14
|
+
* // Mid-level: manage sessions directly
|
|
15
|
+
* const { response } = await mgr.spawn('my-task', { prompt: 'Fix the auth bug' });
|
|
16
|
+
*
|
|
17
|
+
* // Low-level: single streaming session
|
|
18
|
+
* const session = new StreamSession({ name: 'direct', model: 'haiku' });
|
|
19
|
+
* session.start();
|
|
20
|
+
* const r = await session.send('Hello');
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SessionManager = require('./manager');
|
|
24
|
+
const StreamSession = require('./stream-session');
|
|
25
|
+
const Store = require('./store');
|
|
26
|
+
const Delegate = require('./delegate');
|
|
27
|
+
const SafetyNet = require('./safety-net');
|
|
28
|
+
const TeamHub = require('./team-hub');
|
|
29
|
+
const ArtifactStore = require('./artifact-store');
|
|
30
|
+
const ContractStore = require('./contract-store');
|
|
31
|
+
const DependencyResolver = require('./dependency-resolver');
|
|
32
|
+
const LineageGraph = require('./lineage-graph');
|
|
33
|
+
const PipelineEngine = require('./pipeline-engine');
|
|
34
|
+
const SnapshotEngine = require('./snapshot-engine');
|
|
35
|
+
const SessionSnapshot = require('./session-snapshot');
|
|
36
|
+
const DiffEngine = require('./diff-engine');
|
|
37
|
+
const BriefingGenerator = require('./briefing-generator');
|
|
38
|
+
const DecisionJournal = require('./decision-journal');
|
|
39
|
+
const PatternRegistry = require('./pattern-registry');
|
|
40
|
+
const StaleDetector = require('./stale-detector');
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
SessionManager,
|
|
44
|
+
StreamSession,
|
|
45
|
+
Store,
|
|
46
|
+
Delegate,
|
|
47
|
+
SafetyNet,
|
|
48
|
+
TeamHub,
|
|
49
|
+
ArtifactStore,
|
|
50
|
+
ContractStore,
|
|
51
|
+
DependencyResolver,
|
|
52
|
+
LineageGraph,
|
|
53
|
+
PipelineEngine,
|
|
54
|
+
SnapshotEngine,
|
|
55
|
+
SessionSnapshot,
|
|
56
|
+
DiffEngine,
|
|
57
|
+
BriefingGenerator,
|
|
58
|
+
DecisionJournal,
|
|
59
|
+
PatternRegistry,
|
|
60
|
+
StaleDetector,
|
|
61
|
+
};
|