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.
@@ -11,6 +11,9 @@
11
11
  * explicitly out of scope for the first-pass mini Ralph subset.
12
12
  */
13
13
 
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
14
17
  const { spawn, execFileSync } = require('child_process');
15
18
 
16
19
  /**
@@ -22,7 +25,7 @@ const { spawn, execFileSync } = require('child_process');
22
25
  * @param {boolean} [opts.noCommit] - Skip git commit if true
23
26
  * @param {boolean} [opts.verbose] - Enable verbose output
24
27
  * @param {string} [opts.ralphDir] - Reserved for caller compatibility
25
- * @returns {Promise<{stdout: string, exitCode: number, toolUsage: Array, filesChanged: Array}>}
28
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: ?number, signal: ?string, toolUsage: Array, filesChanged: Array}>}
26
29
  */
27
30
  async function invoke(opts) {
28
31
  const {
@@ -69,6 +72,7 @@ async function invoke(opts) {
69
72
  stdout: result.stdout,
70
73
  stderr: result.stderr,
71
74
  exitCode: result.exitCode,
75
+ signal: result.signal,
72
76
  toolUsage: _extractToolUsage(result.stdout),
73
77
  filesChanged,
74
78
  };
@@ -79,7 +83,7 @@ async function invoke(opts) {
79
83
  *
80
84
  * @param {Array<string>} args
81
85
  * @param {boolean} verbose
82
- * @returns {Promise<{stdout: string, exitCode: number}>}
86
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: ?number, signal: ?string}>}
83
87
  */
84
88
  function _spawnOpenCode(args, verbose) {
85
89
  return new Promise((resolve, reject) => {
@@ -111,8 +115,13 @@ function _spawnOpenCode(args, verbose) {
111
115
  }
112
116
  });
113
117
 
114
- child.on('close', (code) => {
115
- resolve({ stdout, stderr, exitCode: code || 0 });
118
+ child.on('close', (code, signal) => {
119
+ resolve({
120
+ stdout,
121
+ stderr,
122
+ exitCode: typeof code === 'number' ? code : null,
123
+ signal: signal || null,
124
+ });
116
125
  });
117
126
  });
118
127
  }
@@ -127,13 +136,30 @@ function _spawnOpenCode(args, verbose) {
127
136
  function _looksLikeCliHelp(text) {
128
137
  if (!text) return false;
129
138
 
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');
139
+ // Only inspect the opening portion of the transcript so help-like strings
140
+ // echoed later in diffs or test output do not masquerade as a CLI banner.
141
+ const normalized = text
142
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
143
+ .replace(/\r/g, '');
144
+ const lines = normalized
145
+ .split('\n')
146
+ .map((line) => line.trim())
147
+ .filter(Boolean);
148
+ const openingText = lines.slice(0, 40).join('\n');
135
149
 
136
- return hasHelpSections && hasOpenCodeUsage;
150
+ const looksLikeRunHelp =
151
+ openingText.includes('opencode run [message..]') &&
152
+ openingText.includes('run opencode with a message') &&
153
+ openingText.includes('Positionals:') &&
154
+ openingText.includes('Options:');
155
+
156
+ const looksLikeTopLevelHelp =
157
+ openingText.includes('Commands:') &&
158
+ openingText.includes('Options:') &&
159
+ openingText.includes('opencode [project]') &&
160
+ openingText.includes('opencode run [message..]');
161
+
162
+ return looksLikeRunHelp || looksLikeTopLevelHelp;
137
163
  }
138
164
 
139
165
  /**
@@ -165,11 +191,11 @@ function _extractToolUsage(text) {
165
191
  }
166
192
 
167
193
  /**
168
- * Take a snapshot of modified/untracked files via git status.
169
- * Returns a Set of file paths relative to the repo root.
170
- * Returns an empty Set if git is unavailable or not in a repo.
194
+ * Take a snapshot of dirty/untracked paths via git status.
195
+ * Returns a Map of repo-relative file paths to existence/content fingerprints.
196
+ * Returns an empty Map if git is unavailable or not in a repo.
171
197
  *
172
- * @returns {Set<string>}
198
+ * @returns {Map<string, string>}
173
199
  */
174
200
  function _gitSnapshot() {
175
201
  try {
@@ -178,42 +204,138 @@ function _gitSnapshot() {
178
204
  encoding: 'utf8',
179
205
  stdio: ['pipe', 'pipe', 'pipe'],
180
206
  });
181
- const files = new Set();
207
+ const files = new Map();
182
208
  for (const line of output.split('\n')) {
183
209
  const trimmed = line.trim();
184
210
  if (!trimmed) continue;
185
- // Format: XY filename (first two chars are status, then space, then path)
186
- const filePath = line.slice(3).trim();
187
- if (filePath) files.add(filePath);
211
+
212
+ for (const filePath of _parsePorcelainPaths(line)) {
213
+ if (!filePath) continue;
214
+ files.set(filePath, _fingerprintPath(filePath));
215
+ }
188
216
  }
189
217
  return files;
190
218
  } catch {
191
- return new Set();
219
+ return new Map();
192
220
  }
193
221
  }
194
222
 
195
223
  /**
196
- * Return an array of files that appear in postSnapshot but not preSnapshot,
197
- * or whose presence changed between the two snapshots.
224
+ * Return an array of files whose per-path fingerprint changed between the two
225
+ * snapshots. A file can count as changed because it became dirty, stopped being
226
+ * dirty, was deleted, was created, or changed content while staying dirty.
198
227
  *
199
- * @param {Set<string>} preSnapshot
200
- * @param {Set<string>} postSnapshot
228
+ * @param {Map<string, string>} preSnapshot
229
+ * @param {Map<string, string>} postSnapshot
201
230
  * @returns {Array<string>}
202
231
  */
203
232
  function _diffSnapshots(preSnapshot, postSnapshot) {
204
- const changed = [];
205
- for (const file of postSnapshot) {
206
- if (!preSnapshot.has(file)) {
207
- changed.push(file);
233
+ const changed = new Set();
234
+ const allPaths = new Set([
235
+ ...Array.from((preSnapshot || new Map()).keys()),
236
+ ...Array.from((postSnapshot || new Map()).keys()),
237
+ ]);
238
+
239
+ // Compare per-path fingerprints so already-dirty files still count when the
240
+ // iteration mutates their contents again.
241
+ for (const file of allPaths) {
242
+ if (preSnapshot.get(file) !== postSnapshot.get(file)) {
243
+ changed.add(file);
208
244
  }
209
245
  }
210
- // Also capture files that were in pre but are gone (e.g., deleted)
211
- for (const file of preSnapshot) {
212
- if (!postSnapshot.has(file)) {
213
- changed.push(file);
246
+
247
+ return Array.from(changed).sort();
248
+ }
249
+
250
+ function _parsePorcelainPaths(line) {
251
+ const rawPath = line.slice(3).trim();
252
+ if (!rawPath) return [];
253
+
254
+ if (rawPath.includes(' -> ')) {
255
+ return rawPath.split(' -> ').map(_stripGitQuotes).filter(Boolean);
256
+ }
257
+
258
+ return [_stripGitQuotes(rawPath)];
259
+ }
260
+
261
+ function _stripGitQuotes(value) {
262
+ if (!value) return '';
263
+
264
+ const trimmed = value.trim();
265
+ if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
266
+ return trimmed;
267
+ }
268
+
269
+ return trimmed
270
+ .slice(1, -1)
271
+ .replace(/\\"/g, '"')
272
+ .replace(/\\\\/g, '\\');
273
+ }
274
+
275
+ function _fingerprintPath(filePath) {
276
+ const absolutePath = path.resolve(process.cwd(), filePath);
277
+
278
+ try {
279
+ const stats = fs.lstatSync(absolutePath);
280
+
281
+ if (stats.isSymbolicLink()) {
282
+ return `symlink:${fs.readlinkSync(absolutePath)}`;
214
283
  }
284
+
285
+ if (stats.isDirectory()) {
286
+ return `directory:${_hashDirectory(absolutePath)}`;
287
+ }
288
+
289
+ if (stats.isFile()) {
290
+ const content = fs.readFileSync(absolutePath);
291
+ const digest = crypto.createHash('sha1').update(content).digest('hex');
292
+ return `file:${stats.size}:${digest}`;
293
+ }
294
+
295
+ return `other:${stats.mode}`;
296
+ } catch (err) {
297
+ if (err && err.code === 'ENOENT') {
298
+ return 'missing';
299
+ }
300
+
301
+ return `error:${err && err.code ? err.code : 'unknown'}`;
302
+ }
303
+ }
304
+
305
+ function _hashDirectory(directoryPath) {
306
+ const hash = crypto.createHash('sha1');
307
+ _walkDirectory(hash, directoryPath, '');
308
+ return hash.digest('hex');
309
+ }
310
+
311
+ function _walkDirectory(hash, directoryPath, relativePrefix) {
312
+ const entries = fs.readdirSync(directoryPath, { withFileTypes: true })
313
+ .sort((a, b) => a.name.localeCompare(b.name));
314
+
315
+ for (const entry of entries) {
316
+ const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
317
+ const absolutePath = path.join(directoryPath, entry.name);
318
+
319
+ if (entry.isDirectory()) {
320
+ hash.update(`dir:${relativePath}\n`);
321
+ _walkDirectory(hash, absolutePath, relativePath);
322
+ continue;
323
+ }
324
+
325
+ if (entry.isSymbolicLink()) {
326
+ hash.update(`symlink:${relativePath}:${fs.readlinkSync(absolutePath)}\n`);
327
+ continue;
328
+ }
329
+
330
+ if (entry.isFile()) {
331
+ hash.update(`file:${relativePath}\n`);
332
+ hash.update(fs.readFileSync(absolutePath));
333
+ continue;
334
+ }
335
+
336
+ const stats = fs.lstatSync(absolutePath);
337
+ hash.update(`other:${relativePath}:${stats.mode}\n`);
215
338
  }
216
- return changed;
217
339
  }
218
340
 
219
341
  module.exports = {
@@ -12,11 +12,13 @@
12
12
  * {{iteration}} - Current iteration number
13
13
  * {{max_iterations}} - Configured max iterations
14
14
  * {{change_dir}} - Change directory path (from options.changeDir)
15
+ * {{base_prompt}} - Underlying prompt text from promptText/promptFile
15
16
  * {{tasks}} - Raw tasks file content
16
17
  * {{task_context}} - Fresh current-task and completed-task summary
17
18
  * {{task_promise}} - Configured task promise string
18
19
  * {{completion_promise}} - Configured completion promise string
19
20
  * {{context}} - Pending context (passed in, already consumed)
21
+ * {{commit_contract}} - Commit instructions derived from options.noCommit
20
22
  */
21
23
 
22
24
  const fs = require('fs');
@@ -90,11 +92,18 @@ function render(options, iteration) {
90
92
  iteration: String(iteration),
91
93
  max_iterations: String(options.maxIterations || 50),
92
94
  change_dir: options.changeDir || '',
95
+ base_prompt: base,
93
96
  tasks: tasksContent,
94
97
  task_context: taskContext,
95
98
  task_promise: options.taskPromise || 'READY_FOR_NEXT_TASK',
96
99
  completion_promise: options.completionPromise || 'COMPLETE',
97
100
  context: '', // Pending context is injected by runner after rendering
101
+ commit_contract: options.noCommit
102
+ ? [
103
+ '- Do not create, amend, or finalize git commits in this run.',
104
+ '- `--no-commit` is active. Do not run `git add` or `git commit`; leave task changes uncommitted.',
105
+ ].join('\n')
106
+ : '- Do not create git commits yourself. The Ralph runner manages automatic task commits when auto-commit is enabled.',
98
107
  };
99
108
 
100
109
  return _renderTemplate(template, vars);