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.
- package/QUICKSTART.md +29 -16
- package/README.md +40 -38
- package/lib/mini-ralph/errors.js +118 -25
- package/lib/mini-ralph/invoker.js +131 -26
- package/lib/mini-ralph/prompt.js +9 -0
- package/lib/mini-ralph/runner.js +448 -141
- package/lib/mini-ralph/state.js +138 -1
- package/lib/mini-ralph/status.js +142 -10
- package/package.json +1 -1
- package/scripts/ralph-run.sh +9 -38
- package/scripts/setup.js +4 -3
|
@@ -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({
|
|
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
|
|
186
|
-
* Returns a
|
|
187
|
-
* Returns an empty
|
|
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 {
|
|
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
|
|
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
|
-
|
|
203
|
-
const filePath
|
|
204
|
-
|
|
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
|
|
219
|
+
return new Map();
|
|
209
220
|
}
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
/**
|
|
213
|
-
* Return an array of files
|
|
214
|
-
*
|
|
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 {
|
|
217
|
-
* @param {
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 = {
|
package/lib/mini-ralph/prompt.js
CHANGED
|
@@ -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);
|