spec-and-loop 2.0.1 → 2.1.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.
@@ -10,11 +10,7 @@ function errorsPath(ralphDir) {
10
10
  }
11
11
 
12
12
  function read(ralphDir, limit) {
13
- const file = errorsPath(ralphDir);
14
- if (!fs.existsSync(file)) return '';
15
- const content = fs.readFileSync(file, 'utf8').trim();
16
- if (!content) return '';
17
- const entries = content.split(/^---$/m).filter(e => e.trim());
13
+ const entries = _readRawEntries(ralphDir);
18
14
  if (!entries.length) return '';
19
15
  if (limit !== undefined && limit < entries.length) {
20
16
  return entries.slice(-limit).join('\n---\n');
@@ -22,6 +18,23 @@ function read(ralphDir, limit) {
22
18
  return entries.join('\n---\n');
23
19
  }
24
20
 
21
+ function readEntries(ralphDir, limit) {
22
+ const entries = _readRawEntries(ralphDir).map(_parseEntry).filter(Boolean);
23
+ if (limit !== undefined && limit < entries.length) {
24
+ return entries.slice(-limit);
25
+ }
26
+ return entries;
27
+ }
28
+
29
+ function count(ralphDir) {
30
+ return _readRawEntries(ralphDir).length;
31
+ }
32
+
33
+ function latest(ralphDir) {
34
+ const entries = readEntries(ralphDir, 1);
35
+ return entries.length > 0 ? entries[0] : null;
36
+ }
37
+
25
38
  function append(ralphDir, entry) {
26
39
  _ensureDir(ralphDir);
27
40
  const file = errorsPath(ralphDir);
@@ -67,4 +80,33 @@ function _ensureDir(ralphDir) {
67
80
  }
68
81
  }
69
82
 
70
- module.exports = { errorsPath, read, append, clear, archive };
83
+ function _readRawEntries(ralphDir) {
84
+ const file = errorsPath(ralphDir);
85
+ if (!fs.existsSync(file)) return [];
86
+ const content = fs.readFileSync(file, 'utf8').trim();
87
+ if (!content) return [];
88
+ return content.split(/^---$/m).filter((entry) => entry.trim());
89
+ }
90
+
91
+ function _parseEntry(entry) {
92
+ const trimmed = entry.trim();
93
+ if (!trimmed) return null;
94
+
95
+ const timestampMatch = trimmed.match(/^Timestamp: (.+)$/m);
96
+ const iterationMatch = trimmed.match(/^Iteration: (.+)$/m);
97
+ const taskMatch = trimmed.match(/^Task: (.+)$/m);
98
+ const exitCodeMatch = trimmed.match(/^Exit Code: (.+)$/m);
99
+ const stderrMatch = trimmed.match(/(?:^|\n)### stderr\n([\s\S]*?)(?=\n### stdout\n|$)/);
100
+ const stdoutMatch = trimmed.match(/(?:^|\n)### stdout\n([\s\S]*?)$/);
101
+
102
+ return {
103
+ timestamp: timestampMatch ? timestampMatch[1].trim() : '',
104
+ iteration: iterationMatch ? Number(iterationMatch[1].trim()) : NaN,
105
+ task: taskMatch ? taskMatch[1].trim() : '',
106
+ exitCode: exitCodeMatch ? Number(exitCodeMatch[1].trim()) : NaN,
107
+ stderr: stderrMatch ? stderrMatch[1].trim() : '',
108
+ stdout: stdoutMatch ? stdoutMatch[1].trim() : '',
109
+ };
110
+ }
111
+
112
+ module.exports = { errorsPath, read, readEntries, count, latest, append, clear, archive };
@@ -127,13 +127,30 @@ function _spawnOpenCode(args, verbose) {
127
127
  function _looksLikeCliHelp(text) {
128
128
  if (!text) return false;
129
129
 
130
- const hasHelpSections = text.includes('Commands:') && text.includes('Options:');
131
- const hasOpenCodeUsage =
132
- text.includes('opencode [project]') ||
133
- text.includes('opencode run [message..]') ||
134
- text.includes('run opencode with a message');
135
-
136
- return hasHelpSections && hasOpenCodeUsage;
130
+ // Only inspect the opening portion of the transcript so help-like strings
131
+ // echoed later in diffs or test output do not masquerade as a CLI banner.
132
+ const normalized = text
133
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
134
+ .replace(/\r/g, '');
135
+ const lines = normalized
136
+ .split('\n')
137
+ .map((line) => line.trim())
138
+ .filter(Boolean);
139
+ const openingText = lines.slice(0, 40).join('\n');
140
+
141
+ const looksLikeRunHelp =
142
+ openingText.includes('opencode run [message..]') &&
143
+ openingText.includes('run opencode with a message') &&
144
+ openingText.includes('Positionals:') &&
145
+ openingText.includes('Options:');
146
+
147
+ const looksLikeTopLevelHelp =
148
+ openingText.includes('Commands:') &&
149
+ openingText.includes('Options:') &&
150
+ openingText.includes('opencode [project]') &&
151
+ openingText.includes('opencode run [message..]');
152
+
153
+ return looksLikeRunHelp || looksLikeTopLevelHelp;
137
154
  }
138
155
 
139
156
  /**
@@ -94,8 +94,8 @@ async function run(opts) {
94
94
 
95
95
  // Build the prompt for this iteration
96
96
  const renderedPrompt = await prompt.render(options, iterationCount);
97
- const errorContent = errors.read(ralphDir, 3);
98
- const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorContent);
97
+ const errorEntries = errors.readEntries(ralphDir, 3);
98
+ const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorEntries);
99
99
 
100
100
  // Inject any pending context
101
101
  const pendingContext = context.consume(ralphDir);
@@ -187,17 +187,16 @@ async function run(opts) {
187
187
  }
188
188
  }
189
189
 
190
- if (completed) {
191
- const archivePath = errors.archive(ralphDir);
192
- if (archivePath && options.verbose) {
193
- process.stderr.write(`[mini-ralph] errors archived to ${archivePath}\n`);
190
+ try {
191
+ if (completed) {
192
+ _cleanupCompletedErrors(ralphDir, options.verbose);
194
193
  }
195
- errors.clear(ralphDir);
194
+ } finally {
195
+ // A completed loop must not remain marked active because post-completion
196
+ // cleanup failed.
197
+ state.update(ralphDir, { active: false, completedAt: new Date().toISOString() });
196
198
  }
197
199
 
198
- // Mark loop as inactive
199
- state.update(ralphDir, { active: false, completedAt: new Date().toISOString() });
200
-
201
200
  return { completed, iterations: iterationCount, exitReason };
202
201
  }
203
202
 
@@ -342,7 +341,7 @@ function _formatAutoCommitMessage(iteration, completedTasks) {
342
341
  * @param {Array<object>} recentHistory
343
342
  * @returns {string}
344
343
  */
345
- function _buildIterationFeedback(recentHistory, errorContent) {
344
+ function _buildIterationFeedback(recentHistory, errorEntries) {
346
345
  if (!Array.isArray(recentHistory) || recentHistory.length === 0) {
347
346
  return '';
348
347
  }
@@ -367,8 +366,8 @@ function _buildIterationFeedback(recentHistory, errorContent) {
367
366
  if (issues.length > 0) {
368
367
  let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`;
369
368
 
370
- if (entry.exitCode !== 0 && errorContent) {
371
- const errorDetails = _extractErrorForIteration(errorContent, entry.iteration);
369
+ if (entry.exitCode !== 0 && errorEntries) {
370
+ const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration);
372
371
  if (errorDetails) {
373
372
  line += '\n Error output:';
374
373
  if (errorDetails.stderr) {
@@ -394,30 +393,19 @@ function _buildIterationFeedback(recentHistory, errorContent) {
394
393
  ].join('\n');
395
394
  }
396
395
 
397
- function _extractErrorForIteration(errorContent, iteration) {
398
- if (!errorContent) return null;
399
-
400
- const entries = errorContent.split(/^---$/m).filter((e) => e.trim());
401
-
402
- for (const entry of entries) {
403
- if (!entry.includes(`Iteration: ${iteration}`)) continue;
396
+ function _extractErrorForIteration(errorEntries, iteration) {
397
+ if (!Array.isArray(errorEntries) || errorEntries.length === 0) return null;
404
398
 
405
- let stderr = '';
406
- let stdout = '';
399
+ const match = errorEntries.find((entry) => entry.iteration === iteration);
400
+ if (!match) return null;
407
401
 
408
- const stderrMatch = entry.match(/### stderr\n([\s\S]*?)(?=\n### stdout|$)/);
409
- const stdoutMatch = entry.match(/### stdout\n([\s\S]*?)$/);
402
+ let stderr = match.stderr || '';
403
+ let stdout = match.stdout || '';
410
404
 
411
- if (stderrMatch) stderr = stderrMatch[1].trim();
412
- if (stdoutMatch) stdout = stdoutMatch[1].trim();
405
+ if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
406
+ if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
413
407
 
414
- if (stderr.length > 2000) stderr = stderr.substring(0, 2000) + '...';
415
- if (stdout.length > 500) stdout = stdout.substring(0, 500) + '...';
416
-
417
- return { stderr, stdout };
418
- }
419
-
420
- return null;
408
+ return { stderr, stdout };
421
409
  }
422
410
 
423
411
  function _getCurrentTaskDescription(tasksBefore) {
@@ -427,6 +415,26 @@ function _getCurrentTaskDescription(tasksBefore) {
427
415
  return 'N/A';
428
416
  }
429
417
 
418
+ function _cleanupCompletedErrors(ralphDir, verbose) {
419
+ let archivePath = null;
420
+
421
+ try {
422
+ archivePath = errors.archive(ralphDir);
423
+ if (archivePath && verbose) {
424
+ process.stderr.write(`[mini-ralph] errors archived to ${archivePath}\n`);
425
+ }
426
+ } catch (err) {
427
+ process.stderr.write(`[mini-ralph] warning: failed to archive error history: ${err.message}\n`);
428
+ return;
429
+ }
430
+
431
+ try {
432
+ errors.clear(ralphDir);
433
+ } catch (err) {
434
+ process.stderr.write(`[mini-ralph] warning: failed to clear active error history: ${err.message}\n`);
435
+ }
436
+ }
437
+
430
438
  function _taskIdentity(task) {
431
439
  return task.number
432
440
  ? `${task.number}|${task.fullDescription || task.description}`
@@ -483,4 +491,5 @@ module.exports = {
483
491
  _buildIterationFeedback,
484
492
  _extractErrorForIteration,
485
493
  _getCurrentTaskDescription,
494
+ _cleanupCompletedErrors,
486
495
  };
@@ -101,14 +101,13 @@ function render(ralphDir, tasksFile) {
101
101
  }
102
102
 
103
103
  // Error history
104
- const errorContent = errors.read(ralphDir, 3);
105
- if (errorContent) {
106
- const entries = errorContent.split(/^---$/m).filter(e => e.trim());
107
- const count = entries.length;
108
- const preview = entries[entries.length - 1].substring(0, 200).trim();
104
+ const errorCount = errors.count(ralphDir);
105
+ if (errorCount > 0) {
106
+ const latestError = errors.latest(ralphDir);
107
+ const preview = _formatErrorPreview(latestError);
109
108
  lines.push('');
110
109
  lines.push('--- Error History ---');
111
- lines.push(` Errors: ${count}`);
110
+ lines.push(` Errors: ${errorCount}`);
112
111
  lines.push(` Most recent: ${preview}`);
113
112
  lines.push('-'.repeat(50));
114
113
  }
@@ -222,4 +221,18 @@ function _detectStruggles(recentHistory) {
222
221
  return warnings;
223
222
  }
224
223
 
225
- module.exports = { render, _elapsed, _detectStruggles, _formatToolUsage };
224
+ function _formatErrorPreview(entry) {
225
+ if (!entry) return '';
226
+
227
+ const source = entry.stderr || entry.stdout || _fallbackErrorPreview(entry);
228
+ return source.substring(0, 200).trim();
229
+ }
230
+
231
+ function _fallbackErrorPreview(entry) {
232
+ const parts = [];
233
+ if (entry.task) parts.push(entry.task);
234
+ if (!Number.isNaN(entry.exitCode)) parts.push(`exit code ${entry.exitCode}`);
235
+ return parts.join(' | ');
236
+ }
237
+
238
+ module.exports = { render, _elapsed, _detectStruggles, _formatToolUsage, _formatErrorPreview };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-and-loop",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
5
5
  "main": "index.js",
6
6
  "bin": {