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.
- package/QUICKSTART.md +29 -16
- package/README.md +40 -38
- package/lib/mini-ralph/errors.js +144 -9
- package/lib/mini-ralph/invoker.js +154 -32
- package/lib/mini-ralph/prompt.js +9 -0
- package/lib/mini-ralph/runner.js +479 -163
- package/lib/mini-ralph/state.js +138 -1
- package/lib/mini-ralph/status.js +159 -14
- 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
|
}
|
|
@@ -127,13 +136,30 @@ function _spawnOpenCode(args, verbose) {
|
|
|
127
136
|
function _looksLikeCliHelp(text) {
|
|
128
137
|
if (!text) return false;
|
|
129
138
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
* Returns a
|
|
170
|
-
* 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.
|
|
171
197
|
*
|
|
172
|
-
* @returns {
|
|
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
|
|
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
|
-
|
|
186
|
-
const filePath
|
|
187
|
-
|
|
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
|
|
219
|
+
return new Map();
|
|
192
220
|
}
|
|
193
221
|
}
|
|
194
222
|
|
|
195
223
|
/**
|
|
196
|
-
* Return an array of files
|
|
197
|
-
*
|
|
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 {
|
|
200
|
-
* @param {
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 = {
|
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);
|