@xelth/eck-snapshot 2.2.0 → 4.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -225
  3. package/index.js +14 -776
  4. package/package.json +25 -7
  5. package/setup.json +805 -0
  6. package/src/cli/cli.js +427 -0
  7. package/src/cli/commands/askGpt.js +29 -0
  8. package/src/cli/commands/autoDocs.js +150 -0
  9. package/src/cli/commands/consilium.js +86 -0
  10. package/src/cli/commands/createSnapshot.js +601 -0
  11. package/src/cli/commands/detectProfiles.js +98 -0
  12. package/src/cli/commands/detectProject.js +112 -0
  13. package/src/cli/commands/generateProfileGuide.js +91 -0
  14. package/src/cli/commands/pruneSnapshot.js +106 -0
  15. package/src/cli/commands/restoreSnapshot.js +173 -0
  16. package/src/cli/commands/setupGemini.js +149 -0
  17. package/src/cli/commands/setupGemini.test.js +115 -0
  18. package/src/cli/commands/trainTokens.js +38 -0
  19. package/src/config.js +81 -0
  20. package/src/services/authService.js +20 -0
  21. package/src/services/claudeCliService.js +621 -0
  22. package/src/services/claudeCliService.test.js +267 -0
  23. package/src/services/dispatcherService.js +33 -0
  24. package/src/services/gptService.js +302 -0
  25. package/src/services/gptService.test.js +120 -0
  26. package/src/templates/agent-prompt.template.md +29 -0
  27. package/src/templates/architect-prompt.template.md +50 -0
  28. package/src/templates/envScanRequest.md +4 -0
  29. package/src/templates/gitWorkflow.md +32 -0
  30. package/src/templates/multiAgent.md +164 -0
  31. package/src/templates/vectorMode.md +22 -0
  32. package/src/utils/aiHeader.js +303 -0
  33. package/src/utils/fileUtils.js +928 -0
  34. package/src/utils/projectDetector.js +704 -0
  35. package/src/utils/tokenEstimator.js +198 -0
  36. package/.ecksnapshot.config.js +0 -35
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { askClaude } from './claudeCliService.js';
3
+
4
+ // Mock execa
5
+ vi.mock('execa', () => ({
6
+ execa: vi.fn()
7
+ }));
8
+
9
+ // Mock p-retry to control retry behavior in tests
10
+ vi.mock('p-retry', () => ({
11
+ default: async (fn, options) => {
12
+ // For tests, we'll execute the function directly without retries
13
+ return await fn();
14
+ }
15
+ }));
16
+
17
+ describe('claudeCliService', () => {
18
+ let mockExeca;
19
+
20
+ beforeEach(async () => {
21
+ const { execa } = await import('execa');
22
+ mockExeca = execa;
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.resetAllMocks();
28
+ });
29
+
30
+ describe('askClaude', () => {
31
+ it('should successfully execute gemini-cli claude command', async () => {
32
+ const mockResponse = {
33
+ stdout: '{"result": "test response", "success": true}',
34
+ stderr: '',
35
+ exitCode: 0
36
+ };
37
+
38
+ mockExeca.mockResolvedValue(mockResponse);
39
+
40
+ const result = await askClaude('test prompt');
41
+
42
+ expect(mockExeca).toHaveBeenCalledWith('gemini-cli', ['claude', 'test prompt'], {
43
+ timeout: 120000
44
+ });
45
+ expect(result).toEqual({
46
+ stdout: mockResponse.stdout,
47
+ stderr: mockResponse.stderr,
48
+ success: true,
49
+ mcp_feedback: null
50
+ });
51
+ });
52
+
53
+ it('should handle non-transient errors without retry', async () => {
54
+ const mockError = new Error('EACCES: permission denied');
55
+ mockError.code = 'EACCES';
56
+ mockError.stdout = '';
57
+ mockError.stderr = 'permission denied';
58
+
59
+ mockExeca.mockRejectedValue(mockError);
60
+
61
+ const result = await askClaude('test prompt');
62
+
63
+ expect(result).toEqual({
64
+ stdout: '',
65
+ stderr: 'permission denied',
66
+ success: false,
67
+ error: 'EACCES: permission denied'
68
+ });
69
+ });
70
+
71
+ it('should identify transient network errors', async () => {
72
+ const mockError = new Error('Connection timeout');
73
+ mockError.stdout = '';
74
+ mockError.stderr = 'network timeout occurred';
75
+
76
+ const { isTransientError } = await import('./claudeCliService.js');
77
+
78
+ expect(isTransientError(mockError)).toBe(true);
79
+ });
80
+
81
+
82
+ it('should handle JSON parsing in gemini-cli response', async () => {
83
+ const complexJsonResponse = {
84
+ stdout: JSON.stringify({
85
+ type: 'result',
86
+ data: {
87
+ analysis: 'test analysis',
88
+ metrics: { tokens: 100, cost: 0.05 }
89
+ },
90
+ timestamp: new Date().toISOString()
91
+ }),
92
+ stderr: '',
93
+ exitCode: 0
94
+ };
95
+
96
+ mockExeca.mockResolvedValue(complexJsonResponse);
97
+
98
+ const result = await askClaude('analyze this code');
99
+
100
+ expect(result.success).toBe(true);
101
+ expect(result.stdout).toContain('test analysis');
102
+ expect(result.stdout).toContain('tokens');
103
+ });
104
+
105
+ it('should handle empty responses gracefully', async () => {
106
+ const mockResponse = {
107
+ stdout: '',
108
+ stderr: '',
109
+ exitCode: 0
110
+ };
111
+
112
+ mockExeca.mockResolvedValue(mockResponse);
113
+
114
+ const result = await askClaude('test prompt');
115
+
116
+ expect(result).toEqual({
117
+ stdout: '',
118
+ stderr: '',
119
+ success: true,
120
+ mcp_feedback: null
121
+ });
122
+ });
123
+
124
+ it('should handle stderr warnings without failing', async () => {
125
+ const mockResponse = {
126
+ stdout: '{"result": "success"}',
127
+ stderr: 'Warning: deprecated feature used',
128
+ exitCode: 0
129
+ };
130
+
131
+ mockExeca.mockResolvedValue(mockResponse);
132
+
133
+ const result = await askClaude('test prompt');
134
+
135
+ expect(result.success).toBe(true);
136
+ expect(result.stderr).toContain('deprecated feature');
137
+ });
138
+
139
+ it('should respect timeout configuration', async () => {
140
+ mockExeca.mockResolvedValue({
141
+ stdout: 'response',
142
+ stderr: '',
143
+ exitCode: 0
144
+ });
145
+
146
+ await askClaude('test prompt');
147
+
148
+ expect(mockExeca).toHaveBeenCalledWith(
149
+ 'gemini-cli',
150
+ ['claude', 'test prompt'],
151
+ { timeout: 120000 }
152
+ );
153
+ });
154
+
155
+ it('should parse mcp_feedback from JSON prompt', async () => {
156
+ const mockResponse = {
157
+ stdout: 'success',
158
+ stderr: '',
159
+ exitCode: 0
160
+ };
161
+
162
+ mockExeca.mockResolvedValue(mockResponse);
163
+
164
+ const promptWithFeedback = JSON.stringify({
165
+ payload: {
166
+ post_execution_steps: {
167
+ mcp_feedback: {
168
+ success: true,
169
+ errors: [],
170
+ mcp_version: '1.0'
171
+ }
172
+ }
173
+ }
174
+ });
175
+
176
+ const result = await askClaude(promptWithFeedback);
177
+
178
+ expect(result.mcp_feedback).toEqual({
179
+ success: true,
180
+ errors: [],
181
+ mcp_version: '1.0'
182
+ });
183
+ });
184
+
185
+ it('should log warning when mcp_feedback contains errors', async () => {
186
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
187
+
188
+ const mockResponse = {
189
+ stdout: 'success',
190
+ stderr: '',
191
+ exitCode: 0
192
+ };
193
+
194
+ mockExeca.mockResolvedValue(mockResponse);
195
+
196
+ const promptWithErrors = JSON.stringify({
197
+ payload: {
198
+ post_execution_steps: {
199
+ mcp_feedback: {
200
+ success: false,
201
+ errors: ['Error 1', 'Error 2'],
202
+ mcp_version: '1.0'
203
+ }
204
+ }
205
+ }
206
+ });
207
+
208
+ await askClaude(promptWithErrors);
209
+
210
+ expect(consoleSpy).toHaveBeenCalledWith('MCP feedback contains errors:', ['Error 1', 'Error 2']);
211
+
212
+ consoleSpy.mockRestore();
213
+ });
214
+ });
215
+
216
+ describe('transient error detection', () => {
217
+ it('should treat network errors as transient', async () => {
218
+ const { isTransientError } = await import('./claudeCliService.js');
219
+
220
+ const networkErrors = [
221
+ 'network error',
222
+ 'timeout',
223
+ 'connection refused',
224
+ 'ECONNRESET',
225
+ 'ENOTFOUND',
226
+ 'socket hang up'
227
+ ];
228
+
229
+ networkErrors.forEach(errorMsg => {
230
+ const error = new Error(errorMsg);
231
+ expect(isTransientError(error)).toBe(true);
232
+ });
233
+ });
234
+
235
+ it('should treat quota errors as transient', async () => {
236
+ const { isTransientError } = await import('./claudeCliService.js');
237
+
238
+ const quotaErrors = [
239
+ 'quota exceeded',
240
+ 'rate limit',
241
+ 'too many requests',
242
+ '429',
243
+ '503'
244
+ ];
245
+
246
+ quotaErrors.forEach(errorMsg => {
247
+ const error = new Error(errorMsg);
248
+ expect(isTransientError(error)).toBe(true);
249
+ });
250
+ });
251
+
252
+ it('should not treat permission errors as transient', async () => {
253
+ const { isTransientError } = await import('./claudeCliService.js');
254
+
255
+ const permanentErrors = [
256
+ 'EACCES: permission denied',
257
+ 'Invalid API key',
258
+ 'Authentication failed'
259
+ ];
260
+
261
+ permanentErrors.forEach(errorMsg => {
262
+ const error = new Error(errorMsg);
263
+ expect(isTransientError(error)).toBe(false);
264
+ });
265
+ });
266
+ });
267
+ });
@@ -0,0 +1,33 @@
1
+ import { ask as askGpt } from './gptService.js';
2
+ import { executePrompt as askClaude } from './claudeCliService.js';
3
+
4
+ /**
5
+ * Dispatches an analytical task to the most efficient AI model with a fallback.
6
+ * Priority 1: Codex (GPT) with low reasoning for speed and cost.
7
+ * Priority 2: Claude as a reliable fallback.
8
+ * @param {string} prompt The JSON payload or prompt string for the task.
9
+ * @returns {Promise<object>} The result from the successful AI agent.
10
+ */
11
+ export async function dispatchAnalysisTask(prompt) {
12
+ try {
13
+ console.log('🧠 Dispatcher: Attempting analysis with Codex (low reasoning)...');
14
+ const gptOptions = {
15
+ model: 'gpt-5-codex',
16
+ reasoning: 'low'
17
+ };
18
+ // The 'ask' function expects payload as first arg, and options as second.
19
+ // Since prompt is a string here, we wrap it in an object for consistency if needed,
20
+ // but for simple prompts it can often be passed directly.
21
+ const payload = (typeof prompt === 'string' && prompt.startsWith('{')) ? prompt : JSON.stringify({ objective: prompt });
22
+ return await askGpt(payload, { verbose: false, ...gptOptions });
23
+ } catch (gptError) {
24
+ console.warn(`⚠️ Codex (low reasoning) failed: ${gptError.message}`);
25
+ console.log('🔄 Failing over to Claude for analysis...');
26
+ try {
27
+ return await askClaude(prompt);
28
+ } catch (claudeError) {
29
+ console.error(`❌ Critical Failure: Both Codex and Claude failed for analysis task.`);
30
+ throw new Error(`Primary (Codex) Error: ${gptError.message}\nFallback (Claude) Error: ${claudeError.message}`);
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,302 @@
1
+ import { execa } from 'execa';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import pRetry from 'p-retry';
5
+ import ora from 'ora';
6
+ import { loadProjectEckManifest } from '../utils/fileUtils.js';
7
+ import { initiateLogin } from './authService.js';
8
+ import which from 'which';
9
+
10
+ const SYSTEM_PROMPT = 'You are a Coder agent. Apply code changes per JSON spec. Respond only in JSON: {success: bool, changes: array, errors: array, post_steps: object}';
11
+
12
+ class AuthError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'AuthError';
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Checks if the codex CLI tool is available in the system's PATH.
21
+ * Throws an error if not found.
22
+ */
23
+ async function ensureCodexCliExists() {
24
+ try {
25
+ await which('codex');
26
+ } catch (error) {
27
+ throw new Error('The `codex` CLI tool is not installed or not in your PATH. Please install it from https://github.com/openai/codex to use this command.');
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Delegates an apply_code_changes payload to the codex CLI with auto-login.
33
+ * @param {string|object} payload - JSON string or object payload to forward to the agent.
34
+ * @param {{ verbose?: boolean, model?: string, reasoning?: string }} [options]
35
+ * @returns {Promise<object>}
36
+ */
37
+ export async function ask(payload, options = {}) {
38
+ const { verbose = false, model = 'gpt-5-codex', reasoning = 'high' } = options;
39
+ await ensureCodexCliExists();
40
+
41
+ const run = async () => {
42
+ const spinner = verbose ? null : ora('Sending payload to Codex agent...').start();
43
+ try {
44
+ const payloadObject = await parsePayload(payload);
45
+ const manifest = await loadProjectEckManifest(process.cwd());
46
+ const userPrompt = buildUserPrompt(payloadObject, manifest);
47
+ const promptInput = `${SYSTEM_PROMPT}\n\n${userPrompt}`;
48
+
49
+ const args = [
50
+ 'exec',
51
+ // Use full-auto mode to prevent interactive prompts from the agent,
52
+ // as this service is designed for non-interactive delegation.
53
+ '--full-auto',
54
+ '--model', model,
55
+ '-c', `model_reasoning_effort=${reasoning}`
56
+ ];
57
+
58
+ debug(verbose, `Executing: codex ${args.join(' ')} <stdin>`);
59
+
60
+ const cliResult = await execa('codex', args, {
61
+ cwd: process.cwd(),
62
+ timeout: 300000, // 5-minute timeout
63
+ input: promptInput // Stream large prompts via stdin to avoid argv limits
64
+ });
65
+
66
+ const output = cliResult?.stdout?.trim();
67
+ if (!output) {
68
+ throw new Error('codex CLI returned empty response');
69
+ }
70
+
71
+ const parsed = extractFinalJson(output);
72
+ if (parsed) {
73
+ if (parsed.post_steps || parsed.post_execution_steps) {
74
+ const postSteps = parsed.post_steps || parsed.post_execution_steps;
75
+ await handlePostExecutionSteps(postSteps, payloadObject);
76
+ parsed.mcp_feedback = postSteps?.mcp_feedback || null;
77
+ }
78
+ spinner?.succeed('Codex agent completed the task.');
79
+ return parsed;
80
+ }
81
+
82
+ // If parsing fails, surface the raw response text for upstream handling.
83
+ spinner?.succeed('Codex agent completed the task.');
84
+ return { success: true, changes: [], errors: [], response_text: output };
85
+
86
+ } catch (error) {
87
+ spinner?.fail('Codex execution failed.');
88
+ handleCliError(error); // This will throw a specific error type
89
+ }
90
+ };
91
+
92
+ return pRetry(run, {
93
+ retries: 1, // Only retry once after a successful login
94
+ minTimeout: 0,
95
+ onFailedAttempt: async (error) => {
96
+ if (error.name === 'AuthError') {
97
+ await initiateLogin();
98
+ } else {
99
+ throw error; // Don't retry for other errors, fail immediately
100
+ }
101
+ }
102
+ });
103
+ }
104
+
105
+
106
+ async function parsePayload(payload) {
107
+ if (typeof payload === 'string') {
108
+ try {
109
+ return JSON.parse(payload);
110
+ } catch (error) {
111
+ throw new Error(`Failed to parse payload JSON: ${error.message}`);
112
+ }
113
+ }
114
+ if (typeof payload === 'object' && payload !== null) {
115
+ return payload;
116
+ }
117
+ throw new Error('Invalid payload type. Expected JSON string or object.');
118
+ }
119
+
120
+ function buildUserPrompt(payloadObject, manifest) {
121
+ const payloadString = JSON.stringify(payloadObject);
122
+ if (!manifest) {
123
+ return payloadString;
124
+ }
125
+
126
+ const sections = [];
127
+ if (manifest.context) {
128
+ sections.push('## .eck Context\n' + manifest.context);
129
+ }
130
+ if (manifest.operations) {
131
+ sections.push('## .eck Operations\n' + manifest.operations);
132
+ }
133
+ if (manifest.journal) {
134
+ sections.push('## .eck Journal\n' + manifest.journal);
135
+ }
136
+ if (manifest.environment && Object.keys(manifest.environment).length > 0) {
137
+ sections.push('## .eck Environment\n' + JSON.stringify(manifest.environment, null, 2));
138
+ }
139
+
140
+ if (sections.length === 0) {
141
+ return payloadString;
142
+ }
143
+
144
+ return `${payloadString}\n\n# Project Context\n${sections.join('\n\n')}`;
145
+ }
146
+
147
+ function debug(verbose, message) {
148
+ if (verbose) {
149
+ console.log(`[ask-gpt] ${message}`);
150
+ }
151
+ }
152
+
153
+ function extractFinalJson(text) {
154
+ const trimmed = text?.trim();
155
+ if (!trimmed) {
156
+ return null;
157
+ }
158
+
159
+ try {
160
+ return JSON.parse(trimmed);
161
+ } catch (error) {
162
+ // Continue with fallback parsing when logs precede the JSON payload.
163
+ }
164
+
165
+ const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
166
+ if (fencedMatch && fencedMatch[1]) {
167
+ const fencedContent = fencedMatch[1].trim();
168
+ try {
169
+ return JSON.parse(fencedContent);
170
+ } catch (error) {
171
+ // Ignore and fall through to final brace search.
172
+ }
173
+ }
174
+
175
+ const lastBraceIndex = trimmed.lastIndexOf('{');
176
+ if (lastBraceIndex === -1) {
177
+ return null;
178
+ }
179
+
180
+ const jsonCandidate = trimmed.slice(lastBraceIndex);
181
+ try {
182
+ return JSON.parse(jsonCandidate);
183
+ } catch (error) {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function handleCliError(error) {
189
+ const combined = `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`.toLowerCase();
190
+ // Check for text that `codex` outputs when auth is missing.
191
+ if (combined.includes('authentication is required') || combined.includes('please run `codex login`')) {
192
+ const authError = new Error('Codex authentication is required. Attempting to log in.');
193
+ authError.name = 'AuthError';
194
+ throw authError;
195
+ }
196
+
197
+ throw new Error(`codex CLI failed: ${error.stderr || error.message}`);
198
+ }
199
+
200
+ async function handlePostExecutionSteps(postSteps, payloadObject) {
201
+ if (!postSteps || typeof postSteps !== 'object') {
202
+ return;
203
+ }
204
+
205
+ if (postSteps.journal_entry) {
206
+ await applyJournalEntry(postSteps.journal_entry, payloadObject);
207
+ }
208
+
209
+ if (postSteps.mcp_feedback) {
210
+ logMcpFeedback(postSteps.mcp_feedback);
211
+ }
212
+ }
213
+
214
+ async function applyJournalEntry(entry, payloadObject) {
215
+ const journalEntry = normalizeJournalEntry(entry);
216
+ const journalPath = path.join(process.cwd(), '.eck', 'JOURNAL.md');
217
+
218
+ await fs.mkdir(path.dirname(journalPath), { recursive: true });
219
+
220
+ let existing = '';
221
+ try {
222
+ existing = await fs.readFile(journalPath, 'utf-8');
223
+ } catch (error) {
224
+ if (error.code !== 'ENOENT') {
225
+ throw new Error(`Failed to read JOURNAL.md: ${error.message}`);
226
+ }
227
+ }
228
+
229
+ const taskId = payloadObject?.task_id || payloadObject?.payload?.task_id || journalEntry.task_id || 'ask-gpt';
230
+ const isoDate = new Date().toISOString();
231
+
232
+ const frontmatter = [
233
+ '---',
234
+ `task_id: ${taskId}`,
235
+ `date: ${isoDate}`,
236
+ `type: ${journalEntry.type}`,
237
+ `scope: ${journalEntry.scope}`,
238
+ '---',
239
+ ''
240
+ ].join('\n');
241
+
242
+ const summary = journalEntry.summary ? `## ${journalEntry.summary}\n` : '';
243
+ const details = journalEntry.details ? `${journalEntry.details}\n` : '';
244
+
245
+ const entryBlock = `${frontmatter}${summary ? `${summary}\n` : ''}${details}\n`;
246
+
247
+ const existingTrimmed = existing ? existing.replace(/^\n+/, '') : '';
248
+ const newContent = `${entryBlock}${existingTrimmed}`.replace(/\n{3,}/g, '\n\n');
249
+
250
+ await fs.writeFile(journalPath, newContent.trimEnd() + '\n');
251
+
252
+ await stageJournal(journalPath);
253
+ await commitJournal(journalEntry);
254
+ }
255
+
256
+ function normalizeJournalEntry(entry) {
257
+ return {
258
+ type: entry.type || 'chore',
259
+ scope: entry.scope || 'journal',
260
+ summary: entry.summary || 'Update journal entry',
261
+ details: entry.details || ''
262
+ };
263
+ }
264
+
265
+ async function stageJournal(journalPath) {
266
+ const relativePath = path.relative(process.cwd(), journalPath);
267
+ try {
268
+ await execa('git', ['add', relativePath], { cwd: process.cwd() });
269
+ } catch (error) {
270
+ throw new Error(`Failed to stage journal entry: ${error.message}`);
271
+ }
272
+ }
273
+
274
+ async function commitJournal(entry) {
275
+ const scopePart = entry.scope ? `(${entry.scope})` : '';
276
+ const summary = (entry.summary || 'Update journal entry').replace(/\s+/g, ' ').trim();
277
+ const commitMessage = `${entry.type}${scopePart}: ${summary}`;
278
+
279
+ try {
280
+ await execa('git', ['commit', '-m', commitMessage], { cwd: process.cwd() });
281
+ } catch (error) {
282
+ const text = `${error?.stderr || ''} ${error?.stdout || ''}`.toLowerCase();
283
+ if (text.includes('nothing to commit')) {
284
+ console.warn('Journal entry already committed or no changes to commit.');
285
+ return;
286
+ }
287
+ throw new Error(`Failed to commit journal entry: ${error.message}`);
288
+ }
289
+ }
290
+
291
+ function logMcpFeedback(feedback) {
292
+ if (!feedback) {
293
+ return;
294
+ }
295
+
296
+ const errors = Array.isArray(feedback.errors) ? feedback.errors : [];
297
+ if (!feedback.success || errors.length > 0) {
298
+ console.warn('MCP feedback indicates issues:', errors.length > 0 ? errors : feedback);
299
+ } else {
300
+ console.log('MCP feedback:', feedback);
301
+ }
302
+ }