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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafetyNet — Automatic limits and protection for automated sessions.
|
|
3
|
+
*
|
|
4
|
+
* Enforces:
|
|
5
|
+
* - Cost limit — Kill session if cumulative cost exceeds threshold
|
|
6
|
+
* - Turn limit — Kill session if total agent turns exceed threshold
|
|
7
|
+
* - Time limit — Kill session if a single interaction takes too long
|
|
8
|
+
* - File protection — Prevent modifications to critical files/paths
|
|
9
|
+
*
|
|
10
|
+
* Integrates with StreamSession via the 'result' event.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const safety = new SafetyNet({
|
|
14
|
+
* maxCostUsd: 1.00, // Kill if total cost exceeds $1
|
|
15
|
+
* maxTurns: 30, // Kill if total turns exceed 30
|
|
16
|
+
* maxDurationMs: 300000, // Kill if single interaction > 5 min
|
|
17
|
+
* protectedPaths: ['.env', 'node_modules', '.git'],
|
|
18
|
+
* });
|
|
19
|
+
* safety.attach(streamSession);
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { EventEmitter } = require('events');
|
|
23
|
+
|
|
24
|
+
// Default safety limits
|
|
25
|
+
const DEFAULTS = {
|
|
26
|
+
maxCostUsd: 2.00, // $2 per session
|
|
27
|
+
maxTurns: 50, // 50 agent turns
|
|
28
|
+
maxDurationMs: 300000, // 5 minutes per interaction
|
|
29
|
+
protectedPaths: [ // Files that should never be modified
|
|
30
|
+
'.env',
|
|
31
|
+
'.env.local',
|
|
32
|
+
'.env.production',
|
|
33
|
+
'credentials',
|
|
34
|
+
'secrets',
|
|
35
|
+
'.git/',
|
|
36
|
+
'node_modules/',
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
class SafetyNet extends EventEmitter {
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} [limits] - Override default limits
|
|
43
|
+
* @param {number} [limits.maxCostUsd] - Max total cost in USD
|
|
44
|
+
* @param {number} [limits.maxTurns] - Max total agent turns
|
|
45
|
+
* @param {number} [limits.maxDurationMs] - Max time per interaction
|
|
46
|
+
* @param {string[]} [limits.protectedPaths] - Paths that can't be modified
|
|
47
|
+
*/
|
|
48
|
+
constructor(limits = {}) {
|
|
49
|
+
super();
|
|
50
|
+
this.maxCostUsd = limits.maxCostUsd ?? DEFAULTS.maxCostUsd;
|
|
51
|
+
this.maxTurns = limits.maxTurns ?? DEFAULTS.maxTurns;
|
|
52
|
+
this.maxDurationMs = limits.maxDurationMs ?? DEFAULTS.maxDurationMs;
|
|
53
|
+
this.protectedPaths = limits.protectedPaths || DEFAULTS.protectedPaths;
|
|
54
|
+
|
|
55
|
+
this.violations = []; // Record of all limit violations
|
|
56
|
+
this.attached = null; // The session we're monitoring
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Attach to a StreamSession to monitor it.
|
|
61
|
+
* @param {StreamSession} session
|
|
62
|
+
*/
|
|
63
|
+
attach(session) {
|
|
64
|
+
this.attached = session;
|
|
65
|
+
|
|
66
|
+
// Check limits after each result
|
|
67
|
+
session.on('result', (response) => {
|
|
68
|
+
this._checkCost(session, response);
|
|
69
|
+
this._checkTurns(session, response);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Check for protected file access in tool calls
|
|
73
|
+
session.on('tool-use', (toolCall) => {
|
|
74
|
+
this._checkProtectedPaths(session, toolCall);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if cost limit is exceeded.
|
|
80
|
+
*/
|
|
81
|
+
_checkCost(session, response) {
|
|
82
|
+
if (this.maxCostUsd && session.totalCostUsd > this.maxCostUsd) {
|
|
83
|
+
const violation = {
|
|
84
|
+
type: 'cost_exceeded',
|
|
85
|
+
limit: this.maxCostUsd,
|
|
86
|
+
actual: session.totalCostUsd,
|
|
87
|
+
message: `Cost limit exceeded: $${session.totalCostUsd.toFixed(4)} > $${this.maxCostUsd.toFixed(2)} limit`,
|
|
88
|
+
timestamp: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
this.violations.push(violation);
|
|
91
|
+
this.emit('violation', violation);
|
|
92
|
+
this.emit('kill', violation);
|
|
93
|
+
|
|
94
|
+
// Force kill the session
|
|
95
|
+
session.kill();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if turn limit is exceeded.
|
|
101
|
+
*/
|
|
102
|
+
_checkTurns(session, response) {
|
|
103
|
+
if (this.maxTurns && session.totalTurns > this.maxTurns) {
|
|
104
|
+
const violation = {
|
|
105
|
+
type: 'turns_exceeded',
|
|
106
|
+
limit: this.maxTurns,
|
|
107
|
+
actual: session.totalTurns,
|
|
108
|
+
message: `Turn limit exceeded: ${session.totalTurns} > ${this.maxTurns} limit`,
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
};
|
|
111
|
+
this.violations.push(violation);
|
|
112
|
+
this.emit('violation', violation);
|
|
113
|
+
this.emit('kill', violation);
|
|
114
|
+
|
|
115
|
+
session.kill();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if a tool call targets a protected path.
|
|
121
|
+
*/
|
|
122
|
+
_checkProtectedPaths(session, toolCall) {
|
|
123
|
+
if (!toolCall.input) return;
|
|
124
|
+
|
|
125
|
+
// Check common file path fields in tool inputs
|
|
126
|
+
const pathFields = ['file_path', 'path', 'command'];
|
|
127
|
+
for (const field of pathFields) {
|
|
128
|
+
const value = toolCall.input[field];
|
|
129
|
+
if (typeof value !== 'string') continue;
|
|
130
|
+
|
|
131
|
+
for (const protected_path of this.protectedPaths) {
|
|
132
|
+
if (value.includes(protected_path)) {
|
|
133
|
+
// Only flag write operations
|
|
134
|
+
const writeTools = ['Write', 'Edit', 'NotebookEdit', 'Bash'];
|
|
135
|
+
if (writeTools.includes(toolCall.name)) {
|
|
136
|
+
const violation = {
|
|
137
|
+
type: 'protected_path',
|
|
138
|
+
tool: toolCall.name,
|
|
139
|
+
path: value,
|
|
140
|
+
protectedPattern: protected_path,
|
|
141
|
+
message: `Attempted to modify protected path: ${value} (matches "${protected_path}")`,
|
|
142
|
+
timestamp: new Date().toISOString(),
|
|
143
|
+
};
|
|
144
|
+
this.violations.push(violation);
|
|
145
|
+
this.emit('violation', violation);
|
|
146
|
+
// Don't kill — just warn. The permission system handles blocking.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get a summary of all violations.
|
|
155
|
+
*/
|
|
156
|
+
getSummary() {
|
|
157
|
+
return {
|
|
158
|
+
totalViolations: this.violations.length,
|
|
159
|
+
violations: this.violations,
|
|
160
|
+
limits: {
|
|
161
|
+
maxCostUsd: this.maxCostUsd,
|
|
162
|
+
maxTurns: this.maxTurns,
|
|
163
|
+
maxDurationMs: this.maxDurationMs,
|
|
164
|
+
protectedPaths: this.protectedPaths,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = SafetyNet;
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
const { atomicWriteJson, readJsonSafe } = require('./atomic-io');
|
|
7
|
+
const { acquireLock, releaseLock } = require('./file-lock');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SessionSnapshot - Captures lightweight snapshots of project state for the Session Continuity Engine.
|
|
11
|
+
*
|
|
12
|
+
* This module provides snapshot capabilities for tracking project state across sessions.
|
|
13
|
+
* It captures git state, file tree information, and working context to enable session continuity.
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Lightweight git state capture (branch, commits, dirty files)
|
|
17
|
+
* - Working context tracking (active files, tasks, blockers)
|
|
18
|
+
* - Snapshot history management
|
|
19
|
+
* - Project-specific storage using content-addressed directories
|
|
20
|
+
*
|
|
21
|
+
* Storage structure:
|
|
22
|
+
* ~/.clearctx/continuity/{projectHash}/
|
|
23
|
+
* ├── snapshots/ # Individual snapshot files
|
|
24
|
+
* ├── locks/ # Lock files for safe concurrent access
|
|
25
|
+
* └── meta.json # Project metadata and latest snapshot pointer
|
|
26
|
+
*/
|
|
27
|
+
class SessionSnapshot {
|
|
28
|
+
/**
|
|
29
|
+
* Creates a new SessionSnapshot instance for a project.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} projectPath - Absolute path to the project directory (e.g., 'E:\MyProject')
|
|
32
|
+
*
|
|
33
|
+
* How it works:
|
|
34
|
+
* 1. Computes a unique hash from the project path to create isolated storage
|
|
35
|
+
* 2. Sets up storage directories in the user's home directory
|
|
36
|
+
* 3. Creates necessary subdirectories (snapshots/, locks/)
|
|
37
|
+
* 4. Initializes meta.json with project information if it doesn't exist
|
|
38
|
+
*/
|
|
39
|
+
constructor(projectPath) {
|
|
40
|
+
// Store the absolute path to the project we're tracking
|
|
41
|
+
this.projectPath = projectPath;
|
|
42
|
+
|
|
43
|
+
// Create a unique hash from the project path
|
|
44
|
+
// This allows us to store snapshots for different projects separately
|
|
45
|
+
// We use SHA256 and take the first 16 characters for a short, unique identifier
|
|
46
|
+
this.projectHash = crypto
|
|
47
|
+
.createHash('sha256')
|
|
48
|
+
.update(projectPath)
|
|
49
|
+
.digest('hex')
|
|
50
|
+
.slice(0, 16);
|
|
51
|
+
|
|
52
|
+
// Set up the main storage directory for this project's continuity data
|
|
53
|
+
// Example: C:\Users\username\.clearctx\continuity\a1b2c3d4e5f6g7h8\
|
|
54
|
+
this.storageDir = path.join(
|
|
55
|
+
os.homedir(),
|
|
56
|
+
'.clearctx',
|
|
57
|
+
'continuity',
|
|
58
|
+
this.projectHash
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Set up subdirectories for organizing different types of data
|
|
62
|
+
this.snapshotsDir = path.join(this.storageDir, 'snapshots');
|
|
63
|
+
this.locksDir = path.join(this.storageDir, 'locks');
|
|
64
|
+
this.metaPath = path.join(this.storageDir, 'meta.json');
|
|
65
|
+
|
|
66
|
+
// Create all necessary directories if they don't exist
|
|
67
|
+
// recursive: true means it will create parent directories too
|
|
68
|
+
fs.mkdirSync(this.snapshotsDir, { recursive: true });
|
|
69
|
+
fs.mkdirSync(this.locksDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
// Initialize meta.json if it doesn't exist
|
|
72
|
+
// This file stores project metadata and points to the latest snapshot
|
|
73
|
+
if (!fs.existsSync(this.metaPath)) {
|
|
74
|
+
const metaData = {
|
|
75
|
+
projectPath: this.projectPath,
|
|
76
|
+
projectHash: this.projectHash,
|
|
77
|
+
createdAt: new Date().toISOString()
|
|
78
|
+
};
|
|
79
|
+
atomicWriteJson(this.metaPath, metaData);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Captures a lightweight snapshot of the current project state.
|
|
85
|
+
*
|
|
86
|
+
* This is the main method for creating snapshots. It captures:
|
|
87
|
+
* - Git state (branch, commits, modified files, staged files)
|
|
88
|
+
* - File tree information (tracked file count)
|
|
89
|
+
* - Working context (active files, tasks, questions, blockers)
|
|
90
|
+
*
|
|
91
|
+
* @param {string} sessionName - Name of the session capturing this snapshot
|
|
92
|
+
* @param {object} workingContext - Optional context object with:
|
|
93
|
+
* - activeFiles: Array of file paths currently being worked on
|
|
94
|
+
* - taskSummary: Brief description of current task
|
|
95
|
+
* - openQuestions: Array of questions or uncertainties
|
|
96
|
+
* - blockers: Array of blocking issues
|
|
97
|
+
* @returns {object} The complete snapshot object
|
|
98
|
+
*
|
|
99
|
+
* Example:
|
|
100
|
+
* const snapshot = sessionSnapshot.capture('build-session', {
|
|
101
|
+
* activeFiles: ['src/auth.js', 'src/routes.js'],
|
|
102
|
+
* taskSummary: 'Implementing user authentication',
|
|
103
|
+
* openQuestions: ['Which hash algorithm to use?'],
|
|
104
|
+
* blockers: ['Need API key from client']
|
|
105
|
+
* });
|
|
106
|
+
*/
|
|
107
|
+
capture(sessionName, workingContext = {}) {
|
|
108
|
+
// Generate a unique ID for this snapshot using timestamp
|
|
109
|
+
// Example: 'snap_1707849600000'
|
|
110
|
+
const snapshotId = 'snap_' + Date.now();
|
|
111
|
+
|
|
112
|
+
// Capture git state by running git commands
|
|
113
|
+
const gitState = this._captureGitState();
|
|
114
|
+
|
|
115
|
+
// Capture file tree information
|
|
116
|
+
const fileTree = this._captureFileTree();
|
|
117
|
+
|
|
118
|
+
// Build the complete snapshot object
|
|
119
|
+
const snapshot = {
|
|
120
|
+
snapshotId: snapshotId,
|
|
121
|
+
projectPath: this.projectPath,
|
|
122
|
+
capturedAt: new Date().toISOString(),
|
|
123
|
+
capturedBy: sessionName,
|
|
124
|
+
git: gitState,
|
|
125
|
+
fileTree: fileTree,
|
|
126
|
+
workingContext: {
|
|
127
|
+
activeFiles: workingContext.activeFiles || [],
|
|
128
|
+
taskSummary: workingContext.taskSummary || '',
|
|
129
|
+
openQuestions: workingContext.openQuestions || [],
|
|
130
|
+
blockers: workingContext.blockers || []
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Write the snapshot to disk using atomic write for safety
|
|
135
|
+
const snapshotPath = path.join(this.snapshotsDir, `${snapshotId}.json`);
|
|
136
|
+
atomicWriteJson(snapshotPath, snapshot);
|
|
137
|
+
|
|
138
|
+
// Update meta.json to point to this latest snapshot
|
|
139
|
+
// We use locks to prevent concurrent writes from corrupting the file
|
|
140
|
+
acquireLock(this.locksDir, 'meta');
|
|
141
|
+
try {
|
|
142
|
+
const meta = readJsonSafe(this.metaPath, {});
|
|
143
|
+
meta.lastSnapshotId = snapshotId;
|
|
144
|
+
atomicWriteJson(this.metaPath, meta);
|
|
145
|
+
} finally {
|
|
146
|
+
// Always release the lock, even if there was an error
|
|
147
|
+
releaseLock(this.locksDir, 'meta');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return snapshot;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Captures git state by running git commands.
|
|
155
|
+
*
|
|
156
|
+
* This is an internal helper method that executes various git commands
|
|
157
|
+
* to gather information about the repository state.
|
|
158
|
+
*
|
|
159
|
+
* @private
|
|
160
|
+
* @returns {object|null} Git state object, or null if not a git repository
|
|
161
|
+
*
|
|
162
|
+
* The returned object contains:
|
|
163
|
+
* - branch: Current branch name (or 'DETACHED' if in detached HEAD)
|
|
164
|
+
* - headCommit: SHA hash of the current commit
|
|
165
|
+
* - headMessage: Commit message of the current commit
|
|
166
|
+
* - isDirty: Boolean indicating if there are uncommitted changes
|
|
167
|
+
* - untrackedCount: Number of untracked files
|
|
168
|
+
* - modifiedFiles: Array of modified file paths (unstaged)
|
|
169
|
+
* - stagedFiles: Array of staged file paths
|
|
170
|
+
* - recentCommits: Array of recent commit objects [{hash, message}, ...]
|
|
171
|
+
*/
|
|
172
|
+
_captureGitState() {
|
|
173
|
+
try {
|
|
174
|
+
// First, verify this is a git repository
|
|
175
|
+
// This will throw an error if we're not in a git repo
|
|
176
|
+
execSync('git rev-parse --show-toplevel', {
|
|
177
|
+
cwd: this.projectPath,
|
|
178
|
+
encoding: 'utf-8',
|
|
179
|
+
timeout: 10000,
|
|
180
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Get the current commit hash (HEAD)
|
|
184
|
+
const headCommit = execSync('git rev-parse HEAD', {
|
|
185
|
+
cwd: this.projectPath,
|
|
186
|
+
encoding: 'utf-8',
|
|
187
|
+
timeout: 10000,
|
|
188
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
189
|
+
}).trim();
|
|
190
|
+
|
|
191
|
+
// Get the current branch name
|
|
192
|
+
// If we're in detached HEAD state, this will be empty
|
|
193
|
+
let branch = execSync('git branch --show-current', {
|
|
194
|
+
cwd: this.projectPath,
|
|
195
|
+
encoding: 'utf-8',
|
|
196
|
+
timeout: 10000,
|
|
197
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
198
|
+
}).trim();
|
|
199
|
+
|
|
200
|
+
// Handle detached HEAD state
|
|
201
|
+
if (!branch) {
|
|
202
|
+
branch = 'DETACHED';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Get recent commits (last 10)
|
|
206
|
+
// Format: "abc1234 Commit message here"
|
|
207
|
+
const logOutput = execSync('git log --oneline -n 10', {
|
|
208
|
+
cwd: this.projectPath,
|
|
209
|
+
encoding: 'utf-8',
|
|
210
|
+
timeout: 10000,
|
|
211
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
212
|
+
}).trim();
|
|
213
|
+
|
|
214
|
+
// Parse the log output into an array of commit objects
|
|
215
|
+
const recentCommits = logOutput
|
|
216
|
+
.split('\n')
|
|
217
|
+
.filter(line => line.trim()) // Remove empty lines
|
|
218
|
+
.map(line => {
|
|
219
|
+
// Split the line into hash and message
|
|
220
|
+
const spaceIndex = line.indexOf(' ');
|
|
221
|
+
if (spaceIndex === -1) {
|
|
222
|
+
return { hash: line, message: '' };
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
hash: line.substring(0, spaceIndex),
|
|
226
|
+
message: line.substring(spaceIndex + 1)
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Get the message of the current commit (first line only)
|
|
231
|
+
const headMessage = recentCommits.length > 0 ? recentCommits[0].message : '';
|
|
232
|
+
|
|
233
|
+
// Get status information using porcelain format
|
|
234
|
+
// Porcelain format is machine-readable and stable across git versions
|
|
235
|
+
const statusOutput = execSync('git status --porcelain', {
|
|
236
|
+
cwd: this.projectPath,
|
|
237
|
+
encoding: 'utf-8',
|
|
238
|
+
timeout: 10000,
|
|
239
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
240
|
+
}).trim();
|
|
241
|
+
|
|
242
|
+
// Parse the status output to extract file states
|
|
243
|
+
const statusInfo = this._parseGitStatus(statusOutput);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
branch: branch,
|
|
247
|
+
headCommit: headCommit,
|
|
248
|
+
headMessage: headMessage,
|
|
249
|
+
isDirty: statusInfo.isDirty,
|
|
250
|
+
untrackedCount: statusInfo.untrackedCount,
|
|
251
|
+
modifiedFiles: statusInfo.modifiedFiles,
|
|
252
|
+
stagedFiles: statusInfo.stagedFiles,
|
|
253
|
+
recentCommits: recentCommits
|
|
254
|
+
};
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// If any git command fails, this is likely not a git repository
|
|
257
|
+
// or git is not available, so we return null
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Parses git status --porcelain output into structured information.
|
|
264
|
+
*
|
|
265
|
+
* Git status porcelain format uses two columns:
|
|
266
|
+
* - First column (X): staged status
|
|
267
|
+
* - Second column (Y): unstaged status
|
|
268
|
+
*
|
|
269
|
+
* Examples:
|
|
270
|
+
* "M file.js" → staged modification
|
|
271
|
+
* " M file.js" → unstaged modification
|
|
272
|
+
* "MM file.js" → staged and unstaged modifications
|
|
273
|
+
* "?? file.js" → untracked file
|
|
274
|
+
* "A file.js" → newly added (staged)
|
|
275
|
+
*
|
|
276
|
+
* @private
|
|
277
|
+
* @param {string} statusOutput - Raw output from 'git status --porcelain'
|
|
278
|
+
* @returns {object} Parsed status information
|
|
279
|
+
*/
|
|
280
|
+
_parseGitStatus(statusOutput) {
|
|
281
|
+
const modifiedFiles = [];
|
|
282
|
+
const stagedFiles = [];
|
|
283
|
+
let untrackedCount = 0;
|
|
284
|
+
let isDirty = false;
|
|
285
|
+
|
|
286
|
+
// If there's any status output, the working tree is dirty
|
|
287
|
+
if (statusOutput.length > 0) {
|
|
288
|
+
isDirty = true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Parse each line of the status output
|
|
292
|
+
const lines = statusOutput.split('\n').filter(line => line.trim());
|
|
293
|
+
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
if (line.length < 4) continue; // Skip invalid lines
|
|
296
|
+
|
|
297
|
+
// Extract the status codes and file path
|
|
298
|
+
// Format: "XY filename" where X=staged, Y=unstaged
|
|
299
|
+
const stagedCode = line[0];
|
|
300
|
+
const unstagedCode = line[1];
|
|
301
|
+
const filePath = line.substring(3); // Skip "XY " prefix
|
|
302
|
+
|
|
303
|
+
// Check for untracked files (marked with ??)
|
|
304
|
+
if (stagedCode === '?' && unstagedCode === '?') {
|
|
305
|
+
untrackedCount++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check for staged changes (first column is not space)
|
|
310
|
+
// Staged codes: M (modified), A (added), D (deleted), R (renamed), C (copied)
|
|
311
|
+
if (stagedCode !== ' ' && stagedCode !== '?') {
|
|
312
|
+
stagedFiles.push(filePath);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Check for unstaged modifications (second column is M)
|
|
316
|
+
if (unstagedCode === 'M') {
|
|
317
|
+
modifiedFiles.push(filePath);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
isDirty: isDirty,
|
|
323
|
+
untrackedCount: untrackedCount,
|
|
324
|
+
modifiedFiles: modifiedFiles,
|
|
325
|
+
stagedFiles: stagedFiles
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Captures file tree information.
|
|
331
|
+
*
|
|
332
|
+
* For git repositories, this counts the number of tracked files.
|
|
333
|
+
* For non-git projects, returns null.
|
|
334
|
+
*
|
|
335
|
+
* @private
|
|
336
|
+
* @returns {object|null} File tree information
|
|
337
|
+
*/
|
|
338
|
+
_captureFileTree() {
|
|
339
|
+
try {
|
|
340
|
+
// Use 'git ls-files' to list all tracked files
|
|
341
|
+
const lsOutput = execSync('git ls-files', {
|
|
342
|
+
cwd: this.projectPath,
|
|
343
|
+
encoding: 'utf-8',
|
|
344
|
+
timeout: 10000,
|
|
345
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Count the lines (each line is a file)
|
|
349
|
+
const lines = lsOutput.trim().split('\n').filter(line => line.trim());
|
|
350
|
+
const trackedFileCount = lines.length;
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
trackedFileCount: trackedFileCount
|
|
354
|
+
};
|
|
355
|
+
} catch (error) {
|
|
356
|
+
// Not a git repository or git command failed
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Returns the most recent snapshot, or null if none exist.
|
|
363
|
+
*
|
|
364
|
+
* This method first checks meta.json for the lastSnapshotId pointer.
|
|
365
|
+
* If that's not available, it scans the snapshots directory for the
|
|
366
|
+
* most recent file based on filename timestamp.
|
|
367
|
+
*
|
|
368
|
+
* @returns {object|null} The latest snapshot object, or null if none exist
|
|
369
|
+
*/
|
|
370
|
+
getLatest() {
|
|
371
|
+
// Try to get the latest snapshot ID from meta.json
|
|
372
|
+
const meta = readJsonSafe(this.metaPath, {});
|
|
373
|
+
|
|
374
|
+
if (meta.lastSnapshotId) {
|
|
375
|
+
// We have a pointer to the latest snapshot
|
|
376
|
+
const snapshotPath = path.join(this.snapshotsDir, `${meta.lastSnapshotId}.json`);
|
|
377
|
+
const snapshot = readJsonSafe(snapshotPath, null);
|
|
378
|
+
if (snapshot) {
|
|
379
|
+
return snapshot;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// If meta.json doesn't have lastSnapshotId or the file doesn't exist,
|
|
384
|
+
// scan the snapshots directory for the most recent file
|
|
385
|
+
try {
|
|
386
|
+
const files = fs.readdirSync(this.snapshotsDir);
|
|
387
|
+
|
|
388
|
+
// Filter for snapshot files (snap_*.json)
|
|
389
|
+
const snapshotFiles = files.filter(f => f.startsWith('snap_') && f.endsWith('.json'));
|
|
390
|
+
|
|
391
|
+
if (snapshotFiles.length === 0) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Sort by timestamp (extracted from filename) in descending order
|
|
396
|
+
snapshotFiles.sort((a, b) => {
|
|
397
|
+
// Extract timestamp from filename: snap_1707849600000.json → 1707849600000
|
|
398
|
+
const timestampA = parseInt(a.replace('snap_', '').replace('.json', ''));
|
|
399
|
+
const timestampB = parseInt(b.replace('snap_', '').replace('.json', ''));
|
|
400
|
+
return timestampB - timestampA; // Descending order
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Read and return the most recent snapshot
|
|
404
|
+
const latestFile = snapshotFiles[0];
|
|
405
|
+
const snapshotPath = path.join(this.snapshotsDir, latestFile);
|
|
406
|
+
return readJsonSafe(snapshotPath, null);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Returns a specific snapshot by ID, or null if not found.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} snapshotId - The snapshot ID to retrieve (e.g., 'snap_1707849600000')
|
|
416
|
+
* @returns {object|null} The snapshot object, or null if not found
|
|
417
|
+
*/
|
|
418
|
+
get(snapshotId) {
|
|
419
|
+
const snapshotPath = path.join(this.snapshotsDir, `${snapshotId}.json`);
|
|
420
|
+
return readJsonSafe(snapshotPath, null);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Returns an array of snapshot summaries, newest first.
|
|
425
|
+
*
|
|
426
|
+
* This method lists all snapshots and returns basic information about each one.
|
|
427
|
+
* Useful for displaying snapshot history to users or finding specific snapshots.
|
|
428
|
+
*
|
|
429
|
+
* @param {number} limit - Maximum number of snapshots to return (default: 20)
|
|
430
|
+
* @returns {Array} Array of snapshot summary objects
|
|
431
|
+
*
|
|
432
|
+
* Each summary contains:
|
|
433
|
+
* - snapshotId: The unique snapshot identifier
|
|
434
|
+
* - capturedAt: ISO timestamp of when the snapshot was captured
|
|
435
|
+
* - capturedBy: Session name that captured the snapshot
|
|
436
|
+
* - branch: Git branch at the time of capture
|
|
437
|
+
* - headCommit: Git commit hash at the time of capture
|
|
438
|
+
*/
|
|
439
|
+
list(limit = 20) {
|
|
440
|
+
try {
|
|
441
|
+
// Read all files in the snapshots directory
|
|
442
|
+
const files = fs.readdirSync(this.snapshotsDir);
|
|
443
|
+
|
|
444
|
+
// Filter for snapshot files
|
|
445
|
+
const snapshotFiles = files.filter(f => f.startsWith('snap_') && f.endsWith('.json'));
|
|
446
|
+
|
|
447
|
+
// Read each snapshot and extract summary information
|
|
448
|
+
const summaries = [];
|
|
449
|
+
for (const file of snapshotFiles) {
|
|
450
|
+
const snapshotPath = path.join(this.snapshotsDir, file);
|
|
451
|
+
const snapshot = readJsonSafe(snapshotPath, null);
|
|
452
|
+
|
|
453
|
+
if (snapshot) {
|
|
454
|
+
// Extract only the summary fields we need
|
|
455
|
+
summaries.push({
|
|
456
|
+
snapshotId: snapshot.snapshotId,
|
|
457
|
+
capturedAt: snapshot.capturedAt,
|
|
458
|
+
capturedBy: snapshot.capturedBy,
|
|
459
|
+
branch: snapshot.git ? snapshot.git.branch : null,
|
|
460
|
+
headCommit: snapshot.git ? snapshot.git.headCommit : null
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Sort by capturedAt timestamp in descending order (newest first)
|
|
466
|
+
summaries.sort((a, b) => {
|
|
467
|
+
return new Date(b.capturedAt) - new Date(a.capturedAt);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Return only the first 'limit' items
|
|
471
|
+
return summaries.slice(0, limit);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
// If there's an error reading the directory, return empty array
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Returns the continuity storage directory path for this project.
|
|
480
|
+
*
|
|
481
|
+
* This is the root directory where all continuity data is stored
|
|
482
|
+
* for the current project.
|
|
483
|
+
*
|
|
484
|
+
* @returns {string} Absolute path to the storage directory
|
|
485
|
+
*
|
|
486
|
+
* Example: 'C:\Users\username\.clearctx\continuity\a1b2c3d4e5f6g7h8'
|
|
487
|
+
*/
|
|
488
|
+
getProjectDir() {
|
|
489
|
+
return this.storageDir;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Returns the SHA256 hash prefix used for storage.
|
|
494
|
+
*
|
|
495
|
+
* This is the 16-character hash derived from the project path,
|
|
496
|
+
* used to create a unique storage directory for each project.
|
|
497
|
+
*
|
|
498
|
+
* @returns {string} The project hash (16 hex characters)
|
|
499
|
+
*
|
|
500
|
+
* Example: 'a1b2c3d4e5f6g7h8'
|
|
501
|
+
*/
|
|
502
|
+
getProjectHash() {
|
|
503
|
+
return this.projectHash;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Export the class directly (CommonJS style)
|
|
508
|
+
module.exports = SessionSnapshot;
|