spec-and-loop 2.1.0 → 2.1.2

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,15 +111,18 @@ 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
122
+ const recentErrors = errors.readEntries(ralphDir, 5);
104
123
  const errorCount = errors.count(ralphDir);
105
124
  if (errorCount > 0) {
106
- const latestError = errors.latest(ralphDir);
125
+ const latestError = recentErrors.length > 0 ? recentErrors[recentErrors.length - 1] : errors.latest(ralphDir);
107
126
  const preview = _formatErrorPreview(latestError);
108
127
  lines.push('');
109
128
  lines.push('--- Error History ---');
@@ -113,7 +132,7 @@ function render(ralphDir, tasksFile) {
113
132
  }
114
133
 
115
134
  // Struggle indicators
116
- const struggles = _detectStruggles(recentHistory);
135
+ const struggles = _detectStruggles(recentHistory, recentErrors);
117
136
  if (struggles.length > 0) {
118
137
  lines.push('');
119
138
  lines.push('--- Struggle Indicators ---');
@@ -189,13 +208,33 @@ function _formatToolUsage(toolUsage) {
189
208
  return toolUsage.map((t) => `${t.tool}(${t.count})`).join(', ');
190
209
  }
191
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
+
192
231
  /**
193
232
  * Detect struggle indicators from recent history.
194
233
  *
195
234
  * @param {Array<object>} recentHistory
196
235
  * @returns {Array<string>} Warning messages
197
236
  */
198
- function _detectStruggles(recentHistory) {
237
+ function _detectStruggles(recentHistory, errorEntries = []) {
199
238
  const warnings = [];
200
239
  if (recentHistory.length < 2) return warnings;
201
240
 
@@ -210,22 +249,94 @@ function _detectStruggles(recentHistory) {
210
249
  );
211
250
  }
212
251
 
213
- // Repeated errors: multiple non-zero exit codes
214
- const errorCount = recentHistory.filter((e) => e.exitCode !== 0).length;
215
- if (errorCount >= 2) {
252
+ const repeatedError = _detectRepeatedError(recentHistory, errorEntries);
253
+ if (repeatedError) {
216
254
  warnings.push(
217
- `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}`
218
256
  );
219
257
  }
220
258
 
221
259
  return warnings;
222
260
  }
223
261
 
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
+
224
326
  function _formatErrorPreview(entry) {
225
327
  if (!entry) return '';
226
328
 
227
329
  const source = entry.stderr || entry.stdout || _fallbackErrorPreview(entry);
228
- return source.substring(0, 200).trim();
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();
229
340
  }
230
341
 
231
342
  function _fallbackErrorPreview(entry) {
@@ -235,4 +346,25 @@ function _fallbackErrorPreview(entry) {
235
346
  return parts.join(' | ');
236
347
  }
237
348
 
238
- module.exports = { render, _elapsed, _detectStruggles, _formatToolUsage, _formatErrorPreview };
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.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "OpenSpec + Ralph Loop integration for iterative development with opencode",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -17,13 +17,13 @@
17
17
  "lint": "shellcheck scripts/*.sh"
18
18
  },
19
19
  "dependencies": {
20
- "@fission-ai/openspec": "1.2.0",
21
- "spec-and-loop": "^2.0.0"
20
+ "@fission-ai/openspec": "1.3.0",
21
+ "spec-and-loop": "^2.1.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@types/jest": "^29.5.0",
24
+ "@types/jest": "^30.0.0",
25
25
  "bats": "^1.13.0",
26
- "jest": "^29.7.0"
26
+ "jest": "^30.0.0"
27
27
  },
28
28
  "jest": {
29
29
  "testEnvironment": "node",
@@ -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) {