spec-and-loop 2.1.0 → 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
  }
@@ -182,11 +191,11 @@ function _extractToolUsage(text) {
182
191
  }
183
192
 
184
193
  /**
185
- * Take a snapshot of modified/untracked files via git status.
186
- * Returns a Set of file paths relative to the repo root.
187
- * 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.
188
197
  *
189
- * @returns {Set<string>}
198
+ * @returns {Map<string, string>}
190
199
  */
191
200
  function _gitSnapshot() {
192
201
  try {
@@ -195,42 +204,138 @@ function _gitSnapshot() {
195
204
  encoding: 'utf8',
196
205
  stdio: ['pipe', 'pipe', 'pipe'],
197
206
  });
198
- const files = new Set();
207
+ const files = new Map();
199
208
  for (const line of output.split('\n')) {
200
209
  const trimmed = line.trim();
201
210
  if (!trimmed) continue;
202
- // Format: XY filename (first two chars are status, then space, then path)
203
- const filePath = line.slice(3).trim();
204
- if (filePath) files.add(filePath);
211
+
212
+ for (const filePath of _parsePorcelainPaths(line)) {
213
+ if (!filePath) continue;
214
+ files.set(filePath, _fingerprintPath(filePath));
215
+ }
205
216
  }
206
217
  return files;
207
218
  } catch {
208
- return new Set();
219
+ return new Map();
209
220
  }
210
221
  }
211
222
 
212
223
  /**
213
- * Return an array of files that appear in postSnapshot but not preSnapshot,
214
- * 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.
215
227
  *
216
- * @param {Set<string>} preSnapshot
217
- * @param {Set<string>} postSnapshot
228
+ * @param {Map<string, string>} preSnapshot
229
+ * @param {Map<string, string>} postSnapshot
218
230
  * @returns {Array<string>}
219
231
  */
220
232
  function _diffSnapshots(preSnapshot, postSnapshot) {
221
- const changed = [];
222
- for (const file of postSnapshot) {
223
- if (!preSnapshot.has(file)) {
224
- 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);
225
244
  }
226
245
  }
227
- // Also capture files that were in pre but are gone (e.g., deleted)
228
- for (const file of preSnapshot) {
229
- if (!postSnapshot.has(file)) {
230
- 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)}`;
231
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`);
232
338
  }
233
- return changed;
234
339
  }
235
340
 
236
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);