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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Decision Journal — tracks architectural decisions, choices, and rationale
3
+ * Part of the Session Continuity Engine (Layer 0)
4
+ *
5
+ * This module helps maintain a record of important decisions made during
6
+ * development, including the reasoning behind them. This helps future sessions
7
+ * understand why certain choices were made.
8
+ *
9
+ * Storage: JSONL format (one JSON object per line)
10
+ * Location: ~/.clearctx/continuity/{projectHash}/journal.jsonl
11
+ */
12
+
13
+ const crypto = require('crypto');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const { readJsonSafe, appendJsonl } = require('./atomic-io');
18
+
19
+ /**
20
+ * DecisionJournal class
21
+ *
22
+ * Manages a journal of decisions for a specific project.
23
+ * Each decision includes:
24
+ * - What was decided
25
+ * - Why it was decided
26
+ * - Related files
27
+ * - Tags for categorization
28
+ * - Additional context
29
+ */
30
+ class DecisionJournal {
31
+ /**
32
+ * Create a new Decision Journal
33
+ *
34
+ * @param {string} projectPath - Absolute path to the project directory
35
+ *
36
+ * The journal is stored in a directory based on a hash of the project path,
37
+ * so each project gets its own isolated journal.
38
+ */
39
+ constructor(projectPath) {
40
+ // Create a unique hash for this project (16 characters)
41
+ // This ensures each project has its own journal storage
42
+ const projectHash = crypto
43
+ .createHash('sha256')
44
+ .update(projectPath)
45
+ .digest('hex')
46
+ .slice(0, 16);
47
+
48
+ // Set up storage directory: ~/.clearctx/continuity/{hash}
49
+ this.storageDir = path.join(
50
+ os.homedir(),
51
+ '.clearctx',
52
+ 'continuity',
53
+ projectHash
54
+ );
55
+
56
+ // The journal file is a JSONL file (one JSON object per line)
57
+ this.journalPath = path.join(this.storageDir, 'journal.jsonl');
58
+
59
+ // Create the storage directory if it doesn't exist yet
60
+ if (!fs.existsSync(this.storageDir)) {
61
+ fs.mkdirSync(this.storageDir, { recursive: true });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Add a new decision to the journal
67
+ *
68
+ * @param {Object} params - Decision parameters
69
+ * @param {string} params.decision - The decision that was made (required)
70
+ * @param {string} [params.reason] - Why this decision was made
71
+ * @param {string[]} [params.files] - Related file paths
72
+ * @param {string[]} [params.tags] - Tags for categorization (e.g., ['auth', 'security'])
73
+ * @param {string} [params.context] - Additional context or notes
74
+ * @returns {Object} The created decision entry with ID and timestamp
75
+ *
76
+ * Example:
77
+ * journal.add({
78
+ * decision: 'Use bcrypt for password hashing',
79
+ * reason: 'More mature and widely tested than argon2',
80
+ * files: ['src/auth.js'],
81
+ * tags: ['security', 'auth']
82
+ * })
83
+ */
84
+ add({ decision, reason, files, tags, context }) {
85
+ // Generate a unique ID for this decision
86
+ // Format: d_{timestamp}_{random} (e.g., d_1707840000000_a3f2b1c4)
87
+ const id = 'd_' + Date.now() + '_' + crypto.randomBytes(4).toString('hex');
88
+
89
+ // Build the decision entry
90
+ const entry = {
91
+ id,
92
+ decision, // The decision itself (required)
93
+ reason: reason || '', // Why it was made (optional)
94
+ files: files || [], // Related files (optional)
95
+ tags: tags || [], // Tags for filtering (optional)
96
+ context: context || '', // Extra context (optional)
97
+ createdAt: new Date().toISOString() // When it was created
98
+ };
99
+
100
+ // Append to the journal file (one line per decision)
101
+ appendJsonl(this.journalPath, entry);
102
+
103
+ // Return the entry so the caller knows what was saved
104
+ return entry;
105
+ }
106
+
107
+ /**
108
+ * List decisions from the journal
109
+ *
110
+ * @param {Object} [options] - Filter and pagination options
111
+ * @param {number} [options.limit=50] - Maximum number of decisions to return
112
+ * @param {string} [options.tag] - Only return decisions with this tag
113
+ * @param {string} [options.since] - Only return decisions after this date (ISO string)
114
+ * @returns {Object[]} Array of decision entries, newest first
115
+ *
116
+ * Example:
117
+ * journal.list({ limit: 10, tag: 'auth' })
118
+ * journal.list({ since: '2024-01-01T00:00:00Z' })
119
+ */
120
+ list({ limit = 50, tag, since } = {}) {
121
+ // If the journal file doesn't exist yet, return empty array
122
+ if (!fs.existsSync(this.journalPath)) {
123
+ return [];
124
+ }
125
+
126
+ // Read the entire journal file
127
+ const content = fs.readFileSync(this.journalPath, 'utf-8');
128
+
129
+ // Parse each line as JSON (JSONL format)
130
+ const entries = content
131
+ .split('\n') // Split into lines
132
+ .filter(line => line.trim()) // Remove empty lines
133
+ .map(line => JSON.parse(line)); // Parse each line as JSON
134
+
135
+ // Start with all entries
136
+ let filtered = entries;
137
+
138
+ // Filter by tag if specified
139
+ if (tag) {
140
+ filtered = filtered.filter(entry => entry.tags.includes(tag));
141
+ }
142
+
143
+ // Filter by date if specified
144
+ if (since) {
145
+ const sinceDate = new Date(since);
146
+ filtered = filtered.filter(entry => new Date(entry.createdAt) >= sinceDate);
147
+ }
148
+
149
+ // Sort by creation date, newest first
150
+ // (Reverse because JSONL is in chronological order)
151
+ filtered.reverse();
152
+
153
+ // Return only the requested number of entries
154
+ return filtered.slice(0, limit);
155
+ }
156
+
157
+ /**
158
+ * Search for decisions by keyword
159
+ *
160
+ * @param {string} keyword - Search term (case-insensitive)
161
+ * @returns {Object[]} Array of matching decision entries, newest first
162
+ *
163
+ * Searches across the decision text, reason, and context fields.
164
+ *
165
+ * Example:
166
+ * journal.search('bcrypt')
167
+ * journal.search('authentication')
168
+ */
169
+ search(keyword) {
170
+ // If the journal file doesn't exist yet, return empty array
171
+ if (!fs.existsSync(this.journalPath)) {
172
+ return [];
173
+ }
174
+
175
+ // Read and parse all entries (same as list())
176
+ const content = fs.readFileSync(this.journalPath, 'utf-8');
177
+ const entries = content
178
+ .split('\n')
179
+ .filter(line => line.trim())
180
+ .map(line => JSON.parse(line));
181
+
182
+ // Convert keyword to lowercase for case-insensitive search
183
+ const searchTerm = keyword.toLowerCase();
184
+
185
+ // Find entries where the keyword appears in decision, reason, or context
186
+ const matches = entries.filter(entry => {
187
+ return (
188
+ entry.decision.toLowerCase().includes(searchTerm) ||
189
+ entry.reason.toLowerCase().includes(searchTerm) ||
190
+ entry.context.toLowerCase().includes(searchTerm)
191
+ );
192
+ });
193
+
194
+ // Return matches, newest first
195
+ return matches.reverse();
196
+ }
197
+
198
+ /**
199
+ * Get a specific decision by its ID
200
+ *
201
+ * @param {string} decisionId - The decision ID to retrieve
202
+ * @returns {Object|null} The decision entry, or null if not found
203
+ *
204
+ * Example:
205
+ * journal.get('d_1707840000000_a3f2b1c4')
206
+ */
207
+ get(decisionId) {
208
+ // If the journal file doesn't exist yet, return null
209
+ if (!fs.existsSync(this.journalPath)) {
210
+ return null;
211
+ }
212
+
213
+ // Read and parse all entries
214
+ const content = fs.readFileSync(this.journalPath, 'utf-8');
215
+ const entries = content
216
+ .split('\n')
217
+ .filter(line => line.trim())
218
+ .map(line => JSON.parse(line));
219
+
220
+ // Find the entry with matching ID
221
+ const entry = entries.find(e => e.id === decisionId);
222
+
223
+ // Return the entry or null if not found
224
+ return entry || null;
225
+ }
226
+ }
227
+
228
+ // Export the class so other modules can use it
229
+ module.exports = DecisionJournal;
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Delegate — The Control Loop for intelligent task delegation.
3
+ *
4
+ * This is what makes the system truly human-like. Instead of just
5
+ * sending messages and getting responses, the Delegate system:
6
+ *
7
+ * 1. Spawns a child session with the right permissions
8
+ * 2. Sends the task
9
+ * 3. Monitors the response for issues (permission denials, errors, going off-track)
10
+ * 4. Auto-handles common problems (permission retries, error recovery)
11
+ * 5. Returns structured output for the parent Claude to evaluate
12
+ * 6. Accepts follow-up instructions and continues the loop
13
+ *
14
+ * The parent Claude acts as the "brain" — it reads the structured output,
15
+ * decides if the work is good, and sends corrections or approvals.
16
+ *
17
+ * Usage:
18
+ * const delegate = new Delegate(manager);
19
+ *
20
+ * // Simple one-shot delegation
21
+ * const result = await delegate.run('fix-auth', {
22
+ * task: 'Fix the authentication bug in auth.service.ts',
23
+ * model: 'sonnet',
24
+ * maxCost: 0.50,
25
+ * });
26
+ * console.log(result.status); // 'completed', 'needs_input', 'failed', 'budget_exceeded'
27
+ * console.log(result.response); // The actual work done
28
+ *
29
+ * // Multi-step delegation with evaluation
30
+ * const r1 = await delegate.run('build-feature', { task: 'Build login page' });
31
+ * // Parent evaluates r1.response...
32
+ * const r2 = await delegate.continue('build-feature', 'Good, now add form validation');
33
+ * // Parent evaluates r2.response...
34
+ * const r3 = await delegate.continue('build-feature', 'Perfect. Now write tests.');
35
+ * delegate.finish('build-feature');
36
+ */
37
+
38
+ const SafetyNet = require('./safety-net');
39
+
40
+ // Permission mode recommendations based on task type
41
+ const PERMISSION_PRESETS = {
42
+ // Read-only: can search and read but not modify
43
+ 'read-only': {
44
+ permissionMode: 'default',
45
+ allowedTools: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'TaskOutput'],
46
+ },
47
+ // Code review: read + analyze
48
+ 'review': {
49
+ permissionMode: 'default',
50
+ allowedTools: ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'TaskOutput'],
51
+ },
52
+ // Edit files: can read and edit but limited bash
53
+ 'edit': {
54
+ permissionMode: 'acceptEdits',
55
+ allowedTools: [], // All tools, but auto-accept edits
56
+ },
57
+ // Full access: can do anything (use with caution)
58
+ 'full': {
59
+ permissionMode: 'bypassPermissions',
60
+ allowedTools: [],
61
+ },
62
+ // Plan only: can explore but not execute
63
+ 'plan': {
64
+ permissionMode: 'plan',
65
+ allowedTools: [],
66
+ },
67
+ };
68
+
69
+ class Delegate {
70
+ /**
71
+ * @param {SessionManager} manager - The session manager instance
72
+ */
73
+ constructor(manager) {
74
+ this.manager = manager;
75
+ this.safetyNets = new Map(); // name -> SafetyNet
76
+ }
77
+
78
+ /**
79
+ * Run a task in a new delegated session.
80
+ *
81
+ * This is the main entry point. It:
82
+ * 1. Creates a session with appropriate permissions
83
+ * 2. Attaches safety limits
84
+ * 3. Sends the task
85
+ * 4. Detects and auto-handles permission issues
86
+ * 5. Returns structured output
87
+ *
88
+ * @param {string} name - Session name
89
+ * @param {object} options - Task configuration
90
+ * @param {string} options.task - The task description
91
+ * @param {string} [options.model='sonnet'] - Model to use
92
+ * @param {string} [options.preset='edit'] - Permission preset
93
+ * @param {string} [options.workDir] - Working directory
94
+ * @param {number} [options.maxCost=2.00] - Max cost in USD
95
+ * @param {number} [options.maxTurns=50] - Max agent turns
96
+ * @param {string} [options.context] - Extra context to prepend
97
+ * @param {string} [options.systemPrompt] - System prompt append
98
+ * @param {string} [options.agent] - Agent to use
99
+ * @param {boolean} [options.safety=true] - Enable safety net (set false to disable)
100
+ * @returns {Promise<DelegateResult>} Structured result
101
+ */
102
+ async run(name, options = {}) {
103
+ const task = options.task;
104
+ if (!task) throw new Error('options.task is required');
105
+
106
+ // Get permission preset
107
+ const preset = PERMISSION_PRESETS[options.preset || 'edit'] || PERMISSION_PRESETS.edit;
108
+
109
+ // Create safety net (only if not disabled)
110
+ // options.safety defaults to true — pass safety: false to disable
111
+ const useSafety = options.safety !== false;
112
+ const safety = useSafety
113
+ ? new SafetyNet({
114
+ maxCostUsd: options.maxCost ?? 2.00,
115
+ maxTurns: options.maxTurns ?? 50,
116
+ maxDurationMs: options.maxDuration ?? 300000,
117
+ protectedPaths: options.protectedPaths,
118
+ })
119
+ : null;
120
+
121
+ // Build the full prompt with context and instructions
122
+ const fullPrompt = this._buildPrompt(task, options.context, name);
123
+
124
+ // Spawn the session
125
+ let spawnResult;
126
+ try {
127
+ spawnResult = await this.manager.spawn(name, {
128
+ prompt: fullPrompt,
129
+ model: options.model || 'sonnet',
130
+ workDir: options.workDir || process.cwd(),
131
+ permissionMode: preset.permissionMode,
132
+ allowedTools: preset.allowedTools.length > 0 ? preset.allowedTools : undefined,
133
+ systemPrompt: options.systemPrompt,
134
+ agent: options.agent,
135
+ });
136
+ } catch (err) {
137
+ return this._makeResult('failed', null, null, {
138
+ error: err.message,
139
+ name,
140
+ });
141
+ }
142
+
143
+ // Attach safety net to the live session (only if enabled)
144
+ if (safety) {
145
+ const session = this.manager.sessions.get(name);
146
+ if (session) {
147
+ safety.attach(session);
148
+ this.safetyNets.set(name, safety);
149
+ }
150
+ }
151
+
152
+ const response = spawnResult.response;
153
+
154
+ // Check if the response indicates a permission issue
155
+ if (response && this._isPermissionDenied(response.text)) {
156
+ // Auto-retry with permission granted
157
+ const retryResult = await this._handlePermissionRetry(name, response.text);
158
+ if (retryResult) {
159
+ return this._makeResult('completed', retryResult, safety, { name });
160
+ }
161
+ }
162
+
163
+ // Check safety violations (only if safety is enabled)
164
+ if (safety && safety.violations.length > 0) {
165
+ const lastViolation = safety.violations[safety.violations.length - 1];
166
+ return this._makeResult(lastViolation.type, response, safety, { name });
167
+ }
168
+
169
+ // Return structured result
170
+ return this._makeResult('completed', response, safety, { name });
171
+ }
172
+
173
+ /**
174
+ * Continue a delegated task with follow-up instructions.
175
+ *
176
+ * The parent Claude evaluates the previous result and sends corrections,
177
+ * additional instructions, or approval.
178
+ *
179
+ * @param {string} name - Session name
180
+ * @param {string} message - Follow-up instruction
181
+ * @returns {Promise<DelegateResult>} Structured result
182
+ */
183
+ async continue(name, message) {
184
+ const safety = this.safetyNets.get(name);
185
+
186
+ let response;
187
+ try {
188
+ response = await this.manager.send(name, message);
189
+ } catch (err) {
190
+ // Session might be stopped — try auto-resume
191
+ try {
192
+ response = await this.manager.resume(name, message);
193
+ // Re-attach safety net to new session
194
+ const session = this.manager.sessions.get(name);
195
+ if (session && safety) {
196
+ safety.attach(session);
197
+ }
198
+ } catch (resumeErr) {
199
+ return this._makeResult('failed', null, safety, {
200
+ error: resumeErr.message,
201
+ name,
202
+ });
203
+ }
204
+ }
205
+
206
+ // Check for permission issues
207
+ if (response && this._isPermissionDenied(response.text)) {
208
+ const retryResult = await this._handlePermissionRetry(name, response.text);
209
+ if (retryResult) {
210
+ return this._makeResult('completed', retryResult, safety, { name });
211
+ }
212
+ }
213
+
214
+ // Check safety violations
215
+ if (safety && safety.violations.length > 0) {
216
+ const lastViolation = safety.violations[safety.violations.length - 1];
217
+ return this._makeResult(lastViolation.type, response, safety, { name });
218
+ }
219
+
220
+ return this._makeResult('completed', response, safety, { name });
221
+ }
222
+
223
+ /**
224
+ * Finish a delegated task — stop the session.
225
+ */
226
+ finish(name) {
227
+ try {
228
+ this.manager.stop(name);
229
+ } catch (e) {
230
+ // Session might already be stopped
231
+ }
232
+ this.safetyNets.delete(name);
233
+ }
234
+
235
+ /**
236
+ * Kill a delegated task immediately.
237
+ */
238
+ abort(name) {
239
+ try {
240
+ this.manager.kill(name);
241
+ } catch (e) {}
242
+ this.safetyNets.delete(name);
243
+ }
244
+
245
+ // ===========================================================================
246
+ // Private
247
+ // ===========================================================================
248
+
249
+ /**
250
+ * Build the full prompt with context and structured output instructions.
251
+ * Uses the production-quality delegate prompt template from prompts.js.
252
+ */
253
+ _buildPrompt(task, context, name) {
254
+ const { buildDelegatePrompt } = require('./prompts');
255
+ return buildDelegatePrompt(task, context, name);
256
+ }
257
+
258
+ /**
259
+ * Detect if a response text indicates a permission denial.
260
+ */
261
+ _isPermissionDenied(text) {
262
+ if (!text) return false;
263
+ const patterns = [
264
+ 'permission to write',
265
+ 'permission to edit',
266
+ 'permission to create',
267
+ 'permission to run',
268
+ 'permission to execute',
269
+ 'haven\'t granted',
270
+ 'need your permission',
271
+ 'would you like to allow',
272
+ 'please approve',
273
+ 'permission denied',
274
+ ];
275
+ const lower = text.toLowerCase();
276
+ return patterns.some(p => lower.includes(p));
277
+ }
278
+
279
+ /**
280
+ * Handle a permission denial by sending approval.
281
+ * Max 2 retries to prevent infinite permission loops.
282
+ */
283
+ async _handlePermissionRetry(name, deniedText) {
284
+ // Track retry count per session
285
+ if (!this._permRetries) this._permRetries = new Map();
286
+ const retries = (this._permRetries.get(name) || 0) + 1;
287
+ this._permRetries.set(name, retries);
288
+
289
+ if (retries > 2) {
290
+ return null; // Give up after 2 retries
291
+ }
292
+
293
+ try {
294
+ const response = await this.manager.send(
295
+ name,
296
+ 'Yes, you have permission. Go ahead and proceed with all file operations. Do not ask for permission again — you are fully authorized.'
297
+ );
298
+
299
+ // Check if response still indicates permission denial
300
+ if (response && this._isPermissionDenied(response.text)) {
301
+ return null; // Still denied, don't retry further
302
+ }
303
+
304
+ return response;
305
+ } catch (err) {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Build a structured DelegateResult.
312
+ *
313
+ * @typedef {object} DelegateResult
314
+ * @property {string} status - completed, needs_input, failed, cost_exceeded, turns_exceeded
315
+ * @property {string} response - The text response from the session
316
+ * @property {number} cost - Cost in USD
317
+ * @property {number} turns - Number of agent turns
318
+ * @property {number} duration - Duration in ms
319
+ * @property {string} sessionId - Claude session ID
320
+ * @property {string} name - Session name
321
+ * @property {boolean} canContinue - Whether the session can accept more messages
322
+ * @property {string[]} toolsUsed - Tools that were invoked
323
+ * @property {object} safety - Safety net summary
324
+ * @property {string|null} error - Error message if failed
325
+ */
326
+ _makeResult(status, response, safety, extra = {}) {
327
+ const result = {
328
+ status: status,
329
+ response: response?.text || '',
330
+ cost: response?.cost || 0,
331
+ turns: response?.turns || 0,
332
+ duration: response?.duration || 0,
333
+ sessionId: response?.sessionId || null,
334
+ name: extra.name || null,
335
+ canContinue: !['failed', 'cost_exceeded', 'turns_exceeded'].includes(status),
336
+ toolsUsed: (response?.toolCalls || []).map(t => t.name),
337
+ safety: safety ? safety.getSummary() : null,
338
+ error: extra.error || null,
339
+ };
340
+
341
+ return result;
342
+ }
343
+ }
344
+
345
+ // Export presets too so users can see/customize them
346
+ Delegate.PERMISSION_PRESETS = PERMISSION_PRESETS;
347
+
348
+ module.exports = Delegate;