spec-and-loop 2.0.1 → 2.1.1

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.
@@ -12,6 +12,7 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
 
14
14
  const STATE_FILE = 'ralph-loop.state.json';
15
+ const LOCK_FILE = 'ralph-loop.lock.json';
15
16
 
16
17
  /**
17
18
  * Return the absolute path to the state file.
@@ -23,6 +24,16 @@ function statePath(ralphDir) {
23
24
  return path.join(ralphDir, STATE_FILE);
24
25
  }
25
26
 
27
+ /**
28
+ * Return the absolute path to the per-change run lock file.
29
+ *
30
+ * @param {string} ralphDir
31
+ * @returns {string}
32
+ */
33
+ function lockPath(ralphDir) {
34
+ return path.join(ralphDir, LOCK_FILE);
35
+ }
36
+
26
37
  /**
27
38
  * Initialize the state file with the provided data.
28
39
  * Creates ralphDir if it does not exist.
@@ -75,6 +86,90 @@ function remove(ralphDir) {
75
86
  }
76
87
  }
77
88
 
89
+ /**
90
+ * Acquire the per-change loop lock.
91
+ *
92
+ * If a prior lock exists and still points to a live process, this throws a
93
+ * descriptive error. If the prior lock is stale or unreadable, it is replaced.
94
+ *
95
+ * @param {string} ralphDir
96
+ * @param {object} metadata
97
+ * @returns {object}
98
+ */
99
+ function acquireRunLock(ralphDir, metadata = {}) {
100
+ _ensureDir(ralphDir);
101
+
102
+ const file = lockPath(ralphDir);
103
+ const lock = Object.assign({}, metadata, {
104
+ pid: process.pid,
105
+ acquiredAt: new Date().toISOString(),
106
+ token: _createLockToken(),
107
+ });
108
+
109
+ while (true) {
110
+ try {
111
+ const fd = fs.openSync(file, 'wx');
112
+ fs.writeFileSync(fd, JSON.stringify(lock, null, 2), 'utf8');
113
+ fs.closeSync(fd);
114
+ return lock;
115
+ } catch (err) {
116
+ if (err.code !== 'EEXIST') {
117
+ throw err;
118
+ }
119
+
120
+ const existingLock = readRunLock(ralphDir);
121
+ if (_isLiveLock(existingLock)) {
122
+ const liveError = new Error(
123
+ `another loop is already active for this change (pid ${existingLock.pid})`
124
+ );
125
+ liveError.code = 'RALPH_ACTIVE_LOOP_LOCK';
126
+ liveError.lock = existingLock;
127
+ throw liveError;
128
+ }
129
+
130
+ _removeLockIfPresent(file);
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Read the current run lock. Returns null when the lock is absent or invalid.
137
+ *
138
+ * @param {string} ralphDir
139
+ * @returns {object|null}
140
+ */
141
+ function readRunLock(ralphDir) {
142
+ const file = lockPath(ralphDir);
143
+ if (!fs.existsSync(file)) return null;
144
+
145
+ try {
146
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Release the current run lock when it still belongs to the provided owner.
154
+ *
155
+ * @param {string} ralphDir
156
+ * @param {object|null} lock
157
+ */
158
+ function releaseRunLock(ralphDir, lock) {
159
+ const file = lockPath(ralphDir);
160
+ if (!fs.existsSync(file)) return;
161
+
162
+ if (!lock || !lock.token) {
163
+ _removeLockIfPresent(file);
164
+ return;
165
+ }
166
+
167
+ const existingLock = readRunLock(ralphDir);
168
+ if (!existingLock || existingLock.token === lock.token) {
169
+ _removeLockIfPresent(file);
170
+ }
171
+ }
172
+
78
173
  // ---------------------------------------------------------------------------
79
174
  // Internal helpers
80
175
  // ---------------------------------------------------------------------------
@@ -90,4 +185,46 @@ function _write(ralphDir, data) {
90
185
  fs.writeFileSync(statePath(ralphDir), JSON.stringify(data, null, 2), 'utf8');
91
186
  }
92
187
 
93
- module.exports = { init, read, update, remove, statePath };
188
+ function _createLockToken() {
189
+ return `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
190
+ }
191
+
192
+ function _isLiveLock(lock) {
193
+ if (!lock || typeof lock.pid !== 'number' || lock.pid <= 0) {
194
+ return false;
195
+ }
196
+
197
+ try {
198
+ process.kill(lock.pid, 0);
199
+ return true;
200
+ } catch (err) {
201
+ if (err && err.code === 'EPERM') {
202
+ return true;
203
+ }
204
+ return false;
205
+ }
206
+ }
207
+
208
+ function _removeLockIfPresent(file) {
209
+ try {
210
+ if (fs.existsSync(file)) {
211
+ fs.unlinkSync(file);
212
+ }
213
+ } catch (err) {
214
+ if (err.code !== 'ENOENT') {
215
+ throw err;
216
+ }
217
+ }
218
+ }
219
+
220
+ module.exports = {
221
+ init,
222
+ read,
223
+ update,
224
+ remove,
225
+ statePath,
226
+ lockPath,
227
+ acquireRunLock,
228
+ readRunLock,
229
+ releaseRunLock,
230
+ };
@@ -40,6 +40,11 @@ function render(ralphDir, tasksFile) {
40
40
  lines.push(`Status: ${active}`);
41
41
  lines.push(`Iteration: ${loopState.iteration || '?'} / ${loopState.maxIterations || '?'}`);
42
42
 
43
+ const lifecycle = loopState.active
44
+ ? 'running'
45
+ : (loopState.completedAt ? 'completed' : 'stopped (incomplete)');
46
+ lines.push(`Lifecycle: ${lifecycle}`);
47
+
43
48
  if (loopState.startedAt) {
44
49
  const elapsed = _elapsed(loopState.startedAt);
45
50
  lines.push(`Started: ${loopState.startedAt} (${elapsed} ago)`);
@@ -47,6 +52,17 @@ function render(ralphDir, tasksFile) {
47
52
 
48
53
  if (loopState.completedAt) {
49
54
  lines.push(`Completed: ${loopState.completedAt}`);
55
+ } else if (loopState.stoppedAt) {
56
+ lines.push(`Stopped: ${loopState.stoppedAt}`);
57
+ }
58
+
59
+ if (loopState.exitReason) {
60
+ lines.push(`Exit reason: ${loopState.exitReason}`);
61
+ }
62
+
63
+ const latestCommitAnomaly = _latestCommitAnomaly(history.recent(ralphDir, 20));
64
+ if (latestCommitAnomaly) {
65
+ lines.push(`Commit issue: ${latestCommitAnomaly.commitAnomaly}`);
50
66
  }
51
67
 
52
68
  lines.push(`Tasks mode: ${loopState.tasksMode ? 'yes' : 'no'}`);
@@ -95,26 +111,28 @@ function render(ralphDir, tasksFile) {
95
111
  const durationSec = entry.duration ? `${(entry.duration / 1000).toFixed(1)}s` : '?';
96
112
  const completed = entry.completionDetected ? ' [COMPLETE]' : (entry.taskDetected ? ' [TASK]' : '');
97
113
  const toolSummary = _formatToolUsage(entry.toolUsage);
98
- lines.push(` Iteration ${entry.iteration}: ${durationSec}${completed}${toolSummary ? ` tools: ${toolSummary}` : ''}`);
114
+ const failureSummary = _formatHistoryFailure(entry);
115
+ const commitSuffix = entry.commitAnomaly ? ` commit: ${entry.commitAnomaly}` : '';
116
+ lines.push(` Iteration ${entry.iteration}: ${durationSec}${completed}${toolSummary ? ` tools: ${toolSummary}` : ''}${failureSummary ? ` failure: ${failureSummary}` : ''}${commitSuffix}`);
99
117
  }
100
118
  lines.push('-'.repeat(50));
101
119
  }
102
120
 
103
121
  // 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();
122
+ const recentErrors = errors.readEntries(ralphDir, 5);
123
+ const errorCount = errors.count(ralphDir);
124
+ if (errorCount > 0) {
125
+ const latestError = recentErrors.length > 0 ? recentErrors[recentErrors.length - 1] : errors.latest(ralphDir);
126
+ const preview = _formatErrorPreview(latestError);
109
127
  lines.push('');
110
128
  lines.push('--- Error History ---');
111
- lines.push(` Errors: ${count}`);
129
+ lines.push(` Errors: ${errorCount}`);
112
130
  lines.push(` Most recent: ${preview}`);
113
131
  lines.push('-'.repeat(50));
114
132
  }
115
133
 
116
134
  // Struggle indicators
117
- const struggles = _detectStruggles(recentHistory);
135
+ const struggles = _detectStruggles(recentHistory, recentErrors);
118
136
  if (struggles.length > 0) {
119
137
  lines.push('');
120
138
  lines.push('--- Struggle Indicators ---');
@@ -190,13 +208,33 @@ function _formatToolUsage(toolUsage) {
190
208
  return toolUsage.map((t) => `${t.tool}(${t.count})`).join(', ');
191
209
  }
192
210
 
211
+ function _isFailedHistoryEntry(entry) {
212
+ if (!entry || typeof entry !== 'object') return false;
213
+ if (entry.signal) return true;
214
+ if (entry.failureStage) return true;
215
+ return entry.exitCode !== 0;
216
+ }
217
+
218
+ function _formatHistoryFailure(entry) {
219
+ if (!entry || typeof entry !== 'object') return '';
220
+
221
+ if (entry.signal) return `signal ${entry.signal}`;
222
+ if (entry.failureStage) return `stage ${entry.failureStage}`;
223
+
224
+ if (entry.exitCode !== null && entry.exitCode !== undefined && entry.exitCode !== 0) {
225
+ return `exit code ${entry.exitCode}`;
226
+ }
227
+
228
+ return '';
229
+ }
230
+
193
231
  /**
194
232
  * Detect struggle indicators from recent history.
195
233
  *
196
234
  * @param {Array<object>} recentHistory
197
235
  * @returns {Array<string>} Warning messages
198
236
  */
199
- function _detectStruggles(recentHistory) {
237
+ function _detectStruggles(recentHistory, errorEntries = []) {
200
238
  const warnings = [];
201
239
  if (recentHistory.length < 2) return warnings;
202
240
 
@@ -211,15 +249,122 @@ function _detectStruggles(recentHistory) {
211
249
  );
212
250
  }
213
251
 
214
- // Repeated errors: multiple non-zero exit codes
215
- const errorCount = recentHistory.filter((e) => e.exitCode !== 0).length;
216
- if (errorCount >= 2) {
252
+ const repeatedError = _detectRepeatedError(recentHistory, errorEntries);
253
+ if (repeatedError) {
217
254
  warnings.push(
218
- `OpenCode exited with errors in ${errorCount} of the last ${recentHistory.length} iterations.`
255
+ `Repeated error detected in ${repeatedError.count} of the last ${recentHistory.length} iterations: ${repeatedError.preview}`
219
256
  );
220
257
  }
221
258
 
222
259
  return warnings;
223
260
  }
224
261
 
225
- module.exports = { render, _elapsed, _detectStruggles, _formatToolUsage };
262
+ function _detectRepeatedError(recentHistory, errorEntries) {
263
+ if (!Array.isArray(errorEntries) || errorEntries.length < 2) {
264
+ return null;
265
+ }
266
+
267
+ const recentFailedIterations = new Set(
268
+ recentHistory
269
+ .filter((entry) => _isFailedHistoryEntry(entry))
270
+ .map((entry) => entry.iteration)
271
+ );
272
+
273
+ if (recentFailedIterations.size < 2) {
274
+ return null;
275
+ }
276
+
277
+ const signatureCounts = new Map();
278
+
279
+ for (const entry of errorEntries) {
280
+ if (!entry || !recentFailedIterations.has(entry.iteration)) {
281
+ continue;
282
+ }
283
+
284
+ const preview = _formatErrorPreview(entry);
285
+ const signature = _normalizeErrorSignature(preview);
286
+ if (!signature) {
287
+ continue;
288
+ }
289
+
290
+ const existing = signatureCounts.get(signature) || { count: 0, preview };
291
+ existing.count += 1;
292
+ if (!existing.preview && preview) {
293
+ existing.preview = preview;
294
+ }
295
+ signatureCounts.set(signature, existing);
296
+ }
297
+
298
+ let bestMatch = null;
299
+ for (const candidate of signatureCounts.values()) {
300
+ if (!bestMatch || candidate.count > bestMatch.count) {
301
+ bestMatch = candidate;
302
+ }
303
+ }
304
+
305
+ if (!bestMatch || bestMatch.count < 2) {
306
+ return null;
307
+ }
308
+
309
+ return bestMatch;
310
+ }
311
+
312
+ function _normalizeErrorSignature(text) {
313
+ if (!text) return '';
314
+
315
+ return text
316
+ .toLowerCase()
317
+ .replace(/\b0x[0-9a-f]+\b/g, '<hex>')
318
+ .replace(/[A-Z]:\\[^\s]+/g, '<path>')
319
+ .replace(/(?:\/[^\s:]+)+/g, '<path>')
320
+ .replace(/:\d+:\d+/g, ':<n>:<n>')
321
+ .replace(/\b\d+\b/g, '<n>')
322
+ .replace(/\s+/g, ' ')
323
+ .trim();
324
+ }
325
+
326
+ function _formatErrorPreview(entry) {
327
+ if (!entry) return '';
328
+
329
+ const source = entry.stderr || entry.stdout || _fallbackErrorPreview(entry);
330
+ const metadata = [];
331
+
332
+ if (entry.signal) metadata.push(`signal ${entry.signal}`);
333
+ if (entry.failureStage) metadata.push(`stage ${entry.failureStage}`);
334
+
335
+ const preview = metadata.length > 0
336
+ ? `${metadata.join(' | ')} | ${source}`
337
+ : source;
338
+
339
+ return preview.substring(0, 200).trim();
340
+ }
341
+
342
+ function _fallbackErrorPreview(entry) {
343
+ const parts = [];
344
+ if (entry.task) parts.push(entry.task);
345
+ if (!Number.isNaN(entry.exitCode)) parts.push(`exit code ${entry.exitCode}`);
346
+ return parts.join(' | ');
347
+ }
348
+
349
+ function _latestCommitAnomaly(recentHistory) {
350
+ if (!Array.isArray(recentHistory) || recentHistory.length === 0) return null;
351
+
352
+ for (let idx = recentHistory.length - 1; idx >= 0; idx--) {
353
+ if (recentHistory[idx] && recentHistory[idx].commitAnomaly) {
354
+ return recentHistory[idx];
355
+ }
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ module.exports = {
362
+ render,
363
+ _elapsed,
364
+ _detectStruggles,
365
+ _formatToolUsage,
366
+ _formatErrorPreview,
367
+ _latestCommitAnomaly,
368
+ _formatHistoryFailure,
369
+ _isFailedHistoryEntry,
370
+ };
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.1",
4
4
  "description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -181,7 +181,7 @@ EXAMPLES:
181
181
 
182
182
  PREREQUISITES:
183
183
  - Git repository (git init)
184
- - OpenSpec artifacts created (openspec init, openspec new, openspec ff)
184
+ - OpenSpec artifacts created (openspec init, openspec new change, then complete the generated artifacts)
185
185
  - opencode CLI installed (npm install -g opencode-ai)
186
186
 
187
187
  EOF
@@ -757,6 +757,10 @@ Include full context from openspec artifacts in {{change_dir}}:
757
757
  - Read {{change_dir}}/design.md for the technical design approach
758
758
  - Read {{change_dir}}/specs/*/spec.md for the detailed specifications
759
759
 
760
+ ## Invocation-Time PRD Snapshot
761
+
762
+ {{base_prompt}}
763
+
760
764
  ## Task List
761
765
 
762
766
  {{tasks}}
@@ -780,8 +784,7 @@ Include full context from openspec artifacts in {{change_dir}}:
780
784
  3. **Complete** task:
781
785
  - Verify that the implementation meets the requirements
782
786
  - When the task is successfully completed, mark it as [x] in the tasks file
783
- - Create a git commit using the required format below
784
- - Output: `<promise>{{task_promise}}</promise>`
787
+ - Output: `<promise>{{task_promise}}</promise>`
785
788
 
786
789
  4. **Continue** to the next task:
787
790
  - The loop will continue with the next iteration
@@ -800,41 +803,9 @@ Include full context from openspec artifacts in {{change_dir}}:
800
803
  - If stuck, try a different approach
801
804
  - Check your work before claiming completion
802
805
 
803
- ## CRITICAL: Git Commit Format (MANDATORY)
804
-
805
- When making git commits, you MUST use this EXACT format:
806
-
807
- ```
808
- Ralph iteration <N>: <brief description of work completed>
809
-
810
- Tasks completed:
811
- - [x] <task.number> <task description text>
812
- - [x] <task.number> <task description text>
813
- ...
814
- ```
815
-
816
- **Requirements:**
817
- 1. Use iteration number from Ralph's state (e.g., "Ralph iteration 7")
818
- 2. Include a BRIEF description summarizing what was done
819
- 3. List ALL completed tasks with their numbers and full descriptions
820
- 4. Use the EXACT format: "- [x] <task.number> <task description>"
821
- 5. Read the "## Completed Tasks for Git Commit" section from the PRD for the task list
822
-
823
- **FORBIDDEN:**
824
- - DO NOT use generic messages like "work in progress" or "iteration N"
825
- - DO NOT skip task numbers
826
- - DO NOT truncate task descriptions
827
- - DO NOT create commits without task information
828
-
829
- **Example:**
830
- ```
831
- Ralph iteration 7: Implement unit tests for response processing
832
-
833
- Tasks completed:
834
- - [x] 11.6 Write unit test for personality state management
835
- - [x] 11.7 Write unit test for personality validation
836
- - [x] 11.8 Write unit test for system prompt validation
837
- ```
806
+ ## Commit Contract
807
+
808
+ {{commit_contract}}
838
809
 
839
810
  {{context}}
840
811
  EOF
package/scripts/setup.js CHANGED
@@ -32,16 +32,17 @@ function runSetup() {
32
32
  console.log('Usage:');
33
33
  console.log(' cd /path/to/your/project');
34
34
  console.log(' openspec init # Initialize OpenSpec');
35
- console.log(' openspec new <name> # Create a new change');
36
- console.log(' openspec ff <name> # Fast-forward artifacts');
35
+ console.log(' openspec new change <name> # Create a new change');
37
36
  console.log(' ralph-run --change <name> # Run ralph loop');
38
37
  console.log(' ralph-run # Auto-detect change');
38
+ console.log(' ralph-run --status # Show loop status');
39
39
  console.log('');
40
40
  console.log('Prerequisites:');
41
- console.log(' - openspec CLI: npm install -g openspec');
41
+ console.log(' - openspec CLI: npm install -g @fission-ai/openspec');
42
42
  console.log(' - opencode CLI: npm install -g opencode-ai');
43
43
  console.log(' - jq CLI: apt install jq / brew install jq');
44
44
  console.log(' - git: git init');
45
+ console.log(' - supported OS: Linux or macOS');
45
46
  }
46
47
 
47
48
  if (require.main === module) {