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,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;
|
package/src/delegate.js
ADDED
|
@@ -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;
|