spec-and-loop 2.1.1 → 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.
@@ -55,11 +55,27 @@ function init(ralphDir, data) {
55
55
  function read(ralphDir) {
56
56
  const file = statePath(ralphDir);
57
57
  if (!fs.existsSync(file)) return null;
58
- try {
59
- return JSON.parse(fs.readFileSync(file, 'utf8'));
60
- } catch {
61
- return null;
58
+ // One-shot retry to paper over the vanishingly-rare race where we read the
59
+ // state file between `openSync('wx')` on the temp file and the rename. On
60
+ // POSIX `renameSync` is atomic, so the retry window is only meaningful on
61
+ // filesystems where it is not -- but the cost of retrying is tiny, so we
62
+ // do it uniformly for robustness.
63
+ for (let attempt = 0; attempt < 2; attempt++) {
64
+ try {
65
+ const raw = fs.readFileSync(file, 'utf8');
66
+ if (!raw) {
67
+ if (attempt === 0) continue;
68
+ return null;
69
+ }
70
+ return JSON.parse(raw);
71
+ } catch (err) {
72
+ if (attempt === 0 && (err.code === 'ENOENT' || err instanceof SyntaxError)) {
73
+ continue;
74
+ }
75
+ return null;
76
+ }
62
77
  }
78
+ return null;
63
79
  }
64
80
 
65
81
  /**
@@ -182,7 +198,43 @@ function _ensureDir(ralphDir) {
182
198
 
183
199
  function _write(ralphDir, data) {
184
200
  _ensureDir(ralphDir);
185
- fs.writeFileSync(statePath(ralphDir), JSON.stringify(data, null, 2), 'utf8');
201
+ const target = statePath(ralphDir);
202
+ // Atomic write: serialize to a temp file in the same directory, then rename.
203
+ // A concurrent `read()` either sees the fully-written old file or the fully-
204
+ // written new file -- never a partially-written one. This matters because
205
+ // `ralph-run --status` can race with the live loop's per-iteration
206
+ // `state.update()` calls, and a torn read used to surface as JSON.parse
207
+ // errors or the dashboard reporting a stale iteration counter.
208
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
209
+ const serialized = JSON.stringify(data, null, 2);
210
+
211
+ let handle = null;
212
+ try {
213
+ handle = fs.openSync(tmp, 'wx');
214
+ fs.writeFileSync(handle, serialized, 'utf8');
215
+ fs.fsyncSync(handle);
216
+ } finally {
217
+ if (handle !== null) {
218
+ try {
219
+ fs.closeSync(handle);
220
+ } catch {
221
+ /* best-effort close */
222
+ }
223
+ }
224
+ }
225
+
226
+ try {
227
+ fs.renameSync(tmp, target);
228
+ } catch (err) {
229
+ // Clean up the temp file if rename failed, then rethrow so the caller
230
+ // sees the real error (disk full, permissions, etc.).
231
+ try {
232
+ fs.unlinkSync(tmp);
233
+ } catch {
234
+ /* best-effort cleanup */
235
+ }
236
+ throw err;
237
+ }
186
238
  }
187
239
 
188
240
  function _createLockToken() {
@@ -157,24 +157,19 @@ function taskContext(tasksFile) {
157
157
  all.find((task) => task.status === 'in_progress') ||
158
158
  all.find((task) => task.status === 'incomplete') ||
159
159
  null;
160
- const completed = all.filter((task) => task.status === 'completed');
160
+ const completedCount = all.filter((task) => task.status === 'completed').length;
161
+ const total = all.length;
161
162
 
162
163
  const sections = [];
163
164
 
164
165
  if (current) {
165
166
  sections.push('## Current Task');
166
167
  sections.push(`- ${current.fullDescription || current.description}`);
168
+ sections.push('');
167
169
  }
168
170
 
169
- if (completed.length > 0) {
170
- if (sections.length > 0) {
171
- sections.push('');
172
- }
173
- sections.push('## Completed Tasks for Git Commit');
174
- sections.push(
175
- ...completed.map((task) => `- [x] ${task.fullDescription || task.description}`)
176
- );
177
- }
171
+ sections.push('## Progress');
172
+ sections.push(`- ${completedCount} of ${total} tasks complete`);
178
173
 
179
174
  return sections.join('\n');
180
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-and-loop",
3
- "version": "2.1.1",
3
+ "version": "3.0.0",
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.1",
21
+ "spec-and-loop": "^2.1.2"
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.3.0"
27
27
  },
28
28
  "jest": {
29
29
  "testEnvironment": "node",
@@ -63,6 +63,7 @@
63
63
  "bin/",
64
64
  "lib/",
65
65
  "scripts/",
66
+ "OPENSPEC-RALPH-BP.md",
66
67
  "README.md",
67
68
  "QUICKSTART.md"
68
69
  ],
@@ -19,11 +19,13 @@
19
19
  * --tasks Enable tasks mode
20
20
  * --min-iterations <n> Minimum iterations (default: 1)
21
21
  * --max-iterations <n> Maximum iterations (default: 50)
22
+ * --stall-threshold <n> Halt after N consecutive no-op iterations (default: 3; 0 disables)
22
23
  * --completion-promise <s> Completion promise string (default: COMPLETE)
23
24
  * --task-promise <s> Task promise string (default: READY_FOR_NEXT_TASK)
24
25
  * --no-commit Suppress auto-commit
25
26
  * --model <name> Optional model override
26
27
  * --verbose Verbose output
28
+ * --quiet Suppress the per-iteration progress stream
27
29
  * --status Print status dashboard and exit
28
30
  * --add-context <text> Add pending context and exit
29
31
  * --clear-context Clear pending context and exit
@@ -48,11 +50,13 @@ function parseArgs(argv) {
48
50
  tasksMode: false,
49
51
  minIterations: 1,
50
52
  maxIterations: 50,
53
+ stallThreshold: 3,
51
54
  completionPromise: 'COMPLETE',
52
55
  taskPromise: 'READY_FOR_NEXT_TASK',
53
56
  noCommit: false,
54
57
  model: '',
55
58
  verbose: false,
59
+ quiet: false,
56
60
  // Control commands
57
61
  status: false,
58
62
  addContext: null,
@@ -88,6 +92,9 @@ function parseArgs(argv) {
88
92
  case '--max-iterations':
89
93
  opts.maxIterations = parseInt(args[++i], 10);
90
94
  break;
95
+ case '--stall-threshold':
96
+ opts.stallThreshold = parseInt(args[++i], 10);
97
+ break;
91
98
  case '--completion-promise':
92
99
  opts.completionPromise = args[++i];
93
100
  break;
@@ -103,6 +110,9 @@ function parseArgs(argv) {
103
110
  case '--verbose':
104
111
  opts.verbose = true;
105
112
  break;
113
+ case '--quiet':
114
+ opts.quiet = true;
115
+ break;
106
116
  case '--status':
107
117
  opts.status = true;
108
118
  break;
@@ -141,11 +151,13 @@ Options:
141
151
  --tasks Enable tasks mode
142
152
  --min-iterations <n> Minimum iterations (default: 1)
143
153
  --max-iterations <n> Maximum iterations (default: 50)
154
+ --stall-threshold <n> Halt after N consecutive no-op iterations (default: 3; 0 disables)
144
155
  --completion-promise <s> Completion promise string
145
156
  --task-promise <s> Task promise string
146
157
  --no-commit Suppress auto-commit
147
158
  --model <name> Model override
148
159
  --verbose Verbose output
160
+ --quiet Suppress the per-iteration progress stream
149
161
  --status Print status dashboard and exit
150
162
  --add-context <text> Add pending context and exit
151
163
  --clear-context Clear pending context and exit
@@ -197,11 +209,13 @@ async function main() {
197
209
  tasksMode: opts.tasksMode,
198
210
  minIterations: opts.minIterations,
199
211
  maxIterations: opts.maxIterations,
212
+ stallThreshold: opts.stallThreshold,
200
213
  completionPromise: opts.completionPromise,
201
214
  taskPromise: opts.taskPromise,
202
215
  noCommit: opts.noCommit,
203
216
  model: opts.model,
204
217
  verbose: opts.verbose,
218
+ quiet: opts.quiet,
205
219
  };
206
220
 
207
221
  try {
@@ -213,13 +227,15 @@ async function main() {
213
227
  );
214
228
  }
215
229
 
216
- process.exit(result.completed ? 0 : 1);
230
+ process.exitCode = result.completed ? 0 : 1;
231
+ setTimeout(() => process.exit(result.completed ? 0 : 1), 5000).unref();
217
232
  } catch (err) {
218
233
  process.stderr.write(`[mini-ralph] error: ${err.message}\n`);
219
234
  if (opts.verbose && err.stack) {
220
235
  process.stderr.write(err.stack + '\n');
221
236
  }
222
- process.exit(1);
237
+ process.exitCode = 1;
238
+ setTimeout(() => process.exit(1), 5000).unref();
223
239
  }
224
240
  }
225
241