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.
- package/OPENSPEC-RALPH-BP.md +564 -0
- package/QUICKSTART.md +32 -10
- package/README.md +70 -6
- package/lib/mini-ralph/history.js +37 -0
- package/lib/mini-ralph/invoker.js +108 -7
- package/lib/mini-ralph/lessons.js +93 -0
- package/lib/mini-ralph/progress.js +404 -0
- package/lib/mini-ralph/prompt.js +78 -6
- package/lib/mini-ralph/runner.js +592 -33
- package/lib/mini-ralph/state.js +57 -5
- package/lib/mini-ralph/tasks.js +5 -10
- package/package.json +6 -5
- package/scripts/mini-ralph-cli.js +18 -2
- package/scripts/ralph-run.sh +402 -79
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* progress.js - Human-readable runtime progress reporter for the mini-ralph
|
|
5
|
+
* loop.
|
|
6
|
+
*
|
|
7
|
+
* Responsible only for formatting and emitting a concise, live status stream
|
|
8
|
+
* so an operator watching the loop can see, per iteration:
|
|
9
|
+
*
|
|
10
|
+
* - iteration number / cap
|
|
11
|
+
* - current task number + short description
|
|
12
|
+
* - outcome (ok / stalled / failed / committed)
|
|
13
|
+
* - wall-clock duration for the iteration
|
|
14
|
+
* - rolling counters: successes, failures, commits, stall streak
|
|
15
|
+
* - cumulative + average per-iteration time
|
|
16
|
+
*
|
|
17
|
+
* Design choices:
|
|
18
|
+
* - Output goes to stderr so piping stdout elsewhere still works.
|
|
19
|
+
* - ANSI colors are used when the destination is a TTY and `NO_COLOR` is
|
|
20
|
+
* not set. Respects the de-facto `NO_COLOR` convention
|
|
21
|
+
* (https://no-color.org/).
|
|
22
|
+
* - Timestamps are local-time HH:MM:SS so an operator can correlate with
|
|
23
|
+
* wall-clock events without deciphering ISO strings.
|
|
24
|
+
* - All helpers are pure (no I/O) except for `emit`, which is the only
|
|
25
|
+
* function that writes to `stream`. This keeps the module trivially
|
|
26
|
+
* testable.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const ANSI = {
|
|
30
|
+
reset: '\u001b[0m',
|
|
31
|
+
dim: '\u001b[2m',
|
|
32
|
+
bold: '\u001b[1m',
|
|
33
|
+
red: '\u001b[31m',
|
|
34
|
+
green: '\u001b[32m',
|
|
35
|
+
yellow: '\u001b[33m',
|
|
36
|
+
blue: '\u001b[34m',
|
|
37
|
+
magenta: '\u001b[35m',
|
|
38
|
+
cyan: '\u001b[36m',
|
|
39
|
+
gray: '\u001b[90m',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a progress reporter.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {NodeJS.WritableStream} [opts.stream=process.stderr] Destination.
|
|
47
|
+
* @param {boolean} [opts.enabled=true] Hard switch to silence all output.
|
|
48
|
+
* @param {boolean} [opts.color] Force color on/off; otherwise auto-detect.
|
|
49
|
+
* @param {number} [opts.maxIterations] Optional cap, shown as `i/max`.
|
|
50
|
+
* @param {string} [opts.label='mini-ralph'] Short tag prefix.
|
|
51
|
+
* @param {() => number} [opts.now=Date.now] Clock (injectable for tests).
|
|
52
|
+
* @returns {object} reporter
|
|
53
|
+
*/
|
|
54
|
+
function create(opts = {}) {
|
|
55
|
+
const stream = opts.stream || process.stderr;
|
|
56
|
+
const enabled = opts.enabled !== false;
|
|
57
|
+
const color = typeof opts.color === 'boolean' ? opts.color : _detectColor(stream);
|
|
58
|
+
const maxIterations =
|
|
59
|
+
typeof opts.maxIterations === 'number' && opts.maxIterations > 0
|
|
60
|
+
? opts.maxIterations
|
|
61
|
+
: null;
|
|
62
|
+
const label = typeof opts.label === 'string' ? opts.label : 'mini-ralph';
|
|
63
|
+
const now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
64
|
+
|
|
65
|
+
const runStart = now();
|
|
66
|
+
const stats = {
|
|
67
|
+
iterations: 0,
|
|
68
|
+
successes: 0,
|
|
69
|
+
failures: 0,
|
|
70
|
+
stalled: 0,
|
|
71
|
+
commits: 0,
|
|
72
|
+
cumulativeMs: 0,
|
|
73
|
+
completedTasks: 0,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Emit a single formatted line. Always appends a trailing newline.
|
|
78
|
+
*/
|
|
79
|
+
function emit(line) {
|
|
80
|
+
if (!enabled) return;
|
|
81
|
+
stream.write(line + '\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Announce the loop start. Prints a single header line with the iteration
|
|
86
|
+
* cap and (when provided) the model name.
|
|
87
|
+
*/
|
|
88
|
+
function runStarted(meta = {}) {
|
|
89
|
+
if (!enabled) return;
|
|
90
|
+
const parts = [`${_tag(label, color)} ${_kw('run started', color, 'bold')}`];
|
|
91
|
+
if (meta.tasksMode) parts.push(_dim('mode=tasks', color));
|
|
92
|
+
if (meta.model) parts.push(_dim(`model=${meta.model}`, color));
|
|
93
|
+
if (maxIterations) parts.push(_dim(`cap=${maxIterations}`, color));
|
|
94
|
+
if (meta.resumed) parts.push(_dim(`resumed-from=${meta.resumed}`, color));
|
|
95
|
+
parts.push(_dim(_clockStamp(new Date(runStart)), color));
|
|
96
|
+
emit(parts.join(' '));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Report the beginning of a single iteration.
|
|
101
|
+
*/
|
|
102
|
+
function iterationStarted(info = {}) {
|
|
103
|
+
if (!enabled) return;
|
|
104
|
+
const iter = _iterLabel(info.iteration, maxIterations, color);
|
|
105
|
+
const task = _taskLabel(info.taskNumber, info.taskDescription, color);
|
|
106
|
+
const line = `${_tag(label, color)} ${_paint('▶', color, 'cyan')} ${iter}${task ? ' ' + task : ''}`;
|
|
107
|
+
emit(line);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Report the outcome of a single iteration and update rolling counters.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} info
|
|
114
|
+
* @param {number} info.iteration
|
|
115
|
+
* @param {number} info.durationMs
|
|
116
|
+
* @param {('success'|'failure'|'stalled')} info.outcome
|
|
117
|
+
* @param {boolean} [info.committed]
|
|
118
|
+
* @param {boolean} [info.hasCompletion]
|
|
119
|
+
* @param {boolean} [info.hasTask]
|
|
120
|
+
* @param {number} [info.completedTasksCount]
|
|
121
|
+
* @param {number} [info.filesChangedCount]
|
|
122
|
+
* @param {string} [info.failureReason]
|
|
123
|
+
* @param {number} [info.stallStreak]
|
|
124
|
+
*/
|
|
125
|
+
function iterationFinished(info = {}) {
|
|
126
|
+
const duration = _coerceInt(info.durationMs, 0);
|
|
127
|
+
stats.iterations += 1;
|
|
128
|
+
stats.cumulativeMs += duration;
|
|
129
|
+
stats.completedTasks += _coerceInt(info.completedTasksCount, 0);
|
|
130
|
+
if (info.committed) stats.commits += 1;
|
|
131
|
+
|
|
132
|
+
if (info.outcome === 'failure') stats.failures += 1;
|
|
133
|
+
else if (info.outcome === 'stalled') stats.stalled += 1;
|
|
134
|
+
else stats.successes += 1;
|
|
135
|
+
|
|
136
|
+
if (!enabled) return;
|
|
137
|
+
|
|
138
|
+
const iter = _iterLabel(info.iteration, maxIterations, color);
|
|
139
|
+
const badge = _outcomeBadge(info.outcome, color);
|
|
140
|
+
const timing = _paint(
|
|
141
|
+
`${_formatDuration(duration)} (avg ${_formatDuration(_average(stats))} · total ${_formatDuration(stats.cumulativeMs)})`,
|
|
142
|
+
color,
|
|
143
|
+
'gray'
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const fragments = [];
|
|
147
|
+
if (info.committed) fragments.push(_paint('committed', color, 'green'));
|
|
148
|
+
if (info.hasCompletion) fragments.push(_paint('COMPLETE', color, 'magenta'));
|
|
149
|
+
else if (info.hasTask) fragments.push(_paint('next-task', color, 'cyan'));
|
|
150
|
+
if (_coerceInt(info.filesChangedCount, 0) > 0) {
|
|
151
|
+
fragments.push(_dim(`files+=${info.filesChangedCount}`, color));
|
|
152
|
+
}
|
|
153
|
+
if (_coerceInt(info.completedTasksCount, 0) > 0) {
|
|
154
|
+
fragments.push(_dim(`tasks+=${info.completedTasksCount}`, color));
|
|
155
|
+
}
|
|
156
|
+
if (info.outcome === 'stalled' && _coerceInt(info.stallStreak, 0) > 0) {
|
|
157
|
+
fragments.push(_dim(`stall-streak=${info.stallStreak}`, color));
|
|
158
|
+
}
|
|
159
|
+
if (info.outcome === 'failure' && info.failureReason) {
|
|
160
|
+
fragments.push(_paint(_truncate(info.failureReason, 80), color, 'red'));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const counters = _paint(
|
|
164
|
+
`ok=${stats.successes} fail=${stats.failures} stall=${stats.stalled} commits=${stats.commits}`,
|
|
165
|
+
color,
|
|
166
|
+
'gray'
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const line = [
|
|
170
|
+
_tag(label, color),
|
|
171
|
+
badge,
|
|
172
|
+
iter,
|
|
173
|
+
fragments.length > 0 ? fragments.join(' ') : '',
|
|
174
|
+
timing,
|
|
175
|
+
counters,
|
|
176
|
+
]
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join(' ');
|
|
179
|
+
emit(line);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Emit a single line announcing that the iteration's prompt is ready to
|
|
184
|
+
* be sent to the model, with size telemetry.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} info
|
|
187
|
+
* @param {number} info.iteration
|
|
188
|
+
* @param {number} info.promptBytes
|
|
189
|
+
* @param {number} info.promptChars
|
|
190
|
+
* @param {number} info.promptTokens
|
|
191
|
+
*/
|
|
192
|
+
function iterationPromptReady(info = {}) {
|
|
193
|
+
if (!enabled) return;
|
|
194
|
+
const iter = _iterLabel(info.iteration, maxIterations, color);
|
|
195
|
+
const bytes = _coerceInt(info.promptBytes, 0);
|
|
196
|
+
const chars = _coerceInt(info.promptChars, 0);
|
|
197
|
+
const tokens = _coerceInt(info.promptTokens, 0);
|
|
198
|
+
const size = _dim(`prompt=${_formatBytes(bytes)} chars=${chars} tokens≈${tokens}`, color);
|
|
199
|
+
emit(`${_tag(label, color)} ${_paint('↑', color, 'blue')} ${iter} ${size}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Emit a single line announcing that the model's response has been received,
|
|
204
|
+
* with size telemetry. Prints a yellow TRUNCATED marker when truncated.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} info
|
|
207
|
+
* @param {number} info.iteration
|
|
208
|
+
* @param {number} info.responseBytes
|
|
209
|
+
* @param {number} info.responseChars
|
|
210
|
+
* @param {number} info.responseTokens
|
|
211
|
+
* @param {boolean} [info.truncated]
|
|
212
|
+
*/
|
|
213
|
+
function iterationResponseReceived(info = {}) {
|
|
214
|
+
if (!enabled) return;
|
|
215
|
+
const iter = _iterLabel(info.iteration, maxIterations, color);
|
|
216
|
+
const bytes = _coerceInt(info.responseBytes, 0);
|
|
217
|
+
const chars = _coerceInt(info.responseChars, 0);
|
|
218
|
+
const tokens = _coerceInt(info.responseTokens, 0);
|
|
219
|
+
const size = _dim(`response=${_formatBytes(bytes)} chars=${chars} tokens≈${tokens}`, color);
|
|
220
|
+
const parts = [`${_tag(label, color)} ${_paint('↓', color, 'blue')} ${iter} ${size}`];
|
|
221
|
+
if (info.truncated) parts.push(_paint('TRUNCATED', color, 'yellow'));
|
|
222
|
+
emit(parts.join(' '));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Emit a one-off informational note (e.g. resume detected, stall halted).
|
|
227
|
+
*/
|
|
228
|
+
function note(message, level = 'info') {
|
|
229
|
+
if (!enabled || !message) return;
|
|
230
|
+
const glyph =
|
|
231
|
+
level === 'warn' ? _paint('!', color, 'yellow')
|
|
232
|
+
: level === 'error' ? _paint('✖', color, 'red')
|
|
233
|
+
: _paint('•', color, 'blue');
|
|
234
|
+
emit(`${_tag(label, color)} ${glyph} ${message}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Print the final summary line for the run.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} outcome
|
|
241
|
+
* @param {boolean} outcome.completed
|
|
242
|
+
* @param {string} outcome.exitReason
|
|
243
|
+
* @param {number} [outcome.iterations]
|
|
244
|
+
*/
|
|
245
|
+
function runFinished(outcome = {}) {
|
|
246
|
+
if (!enabled) return;
|
|
247
|
+
const wall = now() - runStart;
|
|
248
|
+
const ok = outcome.completed === true;
|
|
249
|
+
const head = ok
|
|
250
|
+
? _paint('✓ run complete', color, 'green')
|
|
251
|
+
: _paint('✗ run ended', color, 'yellow');
|
|
252
|
+
const reason = outcome.exitReason ? ` reason=${outcome.exitReason}` : '';
|
|
253
|
+
const avg = _average(stats);
|
|
254
|
+
const body = [
|
|
255
|
+
`iterations=${stats.iterations}`,
|
|
256
|
+
`ok=${stats.successes}`,
|
|
257
|
+
`fail=${stats.failures}`,
|
|
258
|
+
`stall=${stats.stalled}`,
|
|
259
|
+
`commits=${stats.commits}`,
|
|
260
|
+
`tasks=${stats.completedTasks}`,
|
|
261
|
+
`avg=${_formatDuration(avg)}`,
|
|
262
|
+
`total=${_formatDuration(stats.cumulativeMs)}`,
|
|
263
|
+
`wall=${_formatDuration(wall)}`,
|
|
264
|
+
].join(' ');
|
|
265
|
+
|
|
266
|
+
emit(`${_tag(label, color)} ${head}${reason} ${_dim(body, color)}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Snapshot of rolling stats. Exposed for tests and programmatic callers.
|
|
271
|
+
*/
|
|
272
|
+
function snapshot() {
|
|
273
|
+
return Object.assign({}, stats, { averageMs: _average(stats), wallMs: now() - runStart });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
runStarted,
|
|
278
|
+
iterationStarted,
|
|
279
|
+
iterationPromptReady,
|
|
280
|
+
iterationResponseReceived,
|
|
281
|
+
iterationFinished,
|
|
282
|
+
note,
|
|
283
|
+
runFinished,
|
|
284
|
+
snapshot,
|
|
285
|
+
enabled,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Pure formatting helpers (exported for unit testing)
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
function _detectColor(stream) {
|
|
294
|
+
if (process.env && process.env.NO_COLOR) return false;
|
|
295
|
+
if (process.env && process.env.FORCE_COLOR) return true;
|
|
296
|
+
if (!stream) return false;
|
|
297
|
+
return Boolean(stream.isTTY);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _tag(label, color) {
|
|
301
|
+
return _paint(`[${label}]`, color, 'gray');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _iterLabel(iteration, maxIterations, color) {
|
|
305
|
+
const safeIter = _coerceInt(iteration, 0);
|
|
306
|
+
const body = maxIterations ? `iter ${safeIter}/${maxIterations}` : `iter ${safeIter}`;
|
|
307
|
+
return _paint(body, color, 'bold');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function _taskLabel(taskNumber, taskDescription, color) {
|
|
311
|
+
const num = taskNumber && String(taskNumber).trim();
|
|
312
|
+
const desc = taskDescription && String(taskDescription).trim();
|
|
313
|
+
if (!num && !desc) return '';
|
|
314
|
+
const head = num ? `task ${num}` : 'task';
|
|
315
|
+
const tail = desc ? ` ${_truncate(_collapse(desc), 72)}` : '';
|
|
316
|
+
return `${_paint(head, color, 'blue')}${_paint(tail, color, 'gray')}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function _outcomeBadge(outcome, color) {
|
|
320
|
+
if (outcome === 'failure') return _paint('✖ fail', color, 'red');
|
|
321
|
+
if (outcome === 'stalled') return _paint('∅ stall', color, 'yellow');
|
|
322
|
+
return _paint('✓ ok', color, 'green');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function _kw(text, color, style) {
|
|
326
|
+
return _paint(text, color, style);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function _dim(text, color) {
|
|
330
|
+
return _paint(text, color, 'dim');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function _paint(text, color, style) {
|
|
334
|
+
if (!color || !text) return text || '';
|
|
335
|
+
const code = ANSI[style];
|
|
336
|
+
if (!code) return text;
|
|
337
|
+
return `${code}${text}${ANSI.reset}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Format a millisecond duration as a short human string, e.g.
|
|
342
|
+
* 850ms, 12.3s, 2m 04s, 1h 02m.
|
|
343
|
+
*/
|
|
344
|
+
function _formatDuration(ms) {
|
|
345
|
+
const n = Math.max(0, _coerceInt(ms, 0));
|
|
346
|
+
if (n < 1000) return `${n}ms`;
|
|
347
|
+
const seconds = n / 1000;
|
|
348
|
+
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
|
|
349
|
+
const mins = Math.floor(seconds / 60);
|
|
350
|
+
const remSec = Math.floor(seconds - mins * 60);
|
|
351
|
+
if (mins < 60) return `${mins}m ${String(remSec).padStart(2, '0')}s`;
|
|
352
|
+
const hours = Math.floor(mins / 60);
|
|
353
|
+
const remMin = mins - hours * 60;
|
|
354
|
+
return `${hours}h ${String(remMin).padStart(2, '0')}m`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function _average(stats) {
|
|
358
|
+
if (!stats || !stats.iterations) return 0;
|
|
359
|
+
return Math.round(stats.cumulativeMs / stats.iterations);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function _truncate(text, budget) {
|
|
363
|
+
const s = String(text == null ? '' : text);
|
|
364
|
+
if (s.length <= budget) return s;
|
|
365
|
+
const hard = Math.max(1, budget - 1);
|
|
366
|
+
return `${s.slice(0, hard)}…`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function _collapse(text) {
|
|
370
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _coerceInt(value, fallback) {
|
|
374
|
+
const n = Number(value);
|
|
375
|
+
if (!Number.isFinite(n)) return fallback;
|
|
376
|
+
return Math.trunc(n);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Format a byte count as a short human-readable string, e.g. 512B, 1.5KB, 2.3MB.
|
|
381
|
+
*/
|
|
382
|
+
function _formatBytes(bytes) {
|
|
383
|
+
const n = Math.max(0, _coerceInt(bytes, 0));
|
|
384
|
+
if (n < 1024) return `${n}B`;
|
|
385
|
+
const kb = n / 1024;
|
|
386
|
+
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`;
|
|
387
|
+
const mb = kb / 1024;
|
|
388
|
+
return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function _clockStamp(date) {
|
|
392
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
393
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
module.exports = {
|
|
397
|
+
create,
|
|
398
|
+
_formatDuration,
|
|
399
|
+
_formatBytes,
|
|
400
|
+
_truncate,
|
|
401
|
+
_collapse,
|
|
402
|
+
_detectColor,
|
|
403
|
+
_average,
|
|
404
|
+
};
|
package/lib/mini-ralph/prompt.js
CHANGED
|
@@ -12,19 +12,68 @@
|
|
|
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
|
+
* {{base_prompt}} - Underlying prompt text from promptText/promptFile.
|
|
16
|
+
* Lazy-loaded: loadBase() is called ONLY when the
|
|
17
|
+
* template contains the literal substring
|
|
18
|
+
* {{base_prompt}}. If the template omits it, no
|
|
19
|
+
* prompt source is required and the prompt-source
|
|
20
|
+
* errors ("no prompt source configured", "prompt file
|
|
21
|
+
* not found", "prompt file is empty") do not fire.
|
|
16
22
|
* {{tasks}} - Raw tasks file content
|
|
17
23
|
* {{task_context}} - Fresh current-task and completed-task summary
|
|
18
24
|
* {{task_promise}} - Configured task promise string
|
|
19
25
|
* {{completion_promise}} - Configured completion promise string
|
|
20
|
-
* {{context}} - Pending context (passed in, already consumed)
|
|
21
26
|
* {{commit_contract}} - Commit instructions derived from options.noCommit
|
|
27
|
+
*
|
|
28
|
+
* Oversized-substitution warning:
|
|
29
|
+
* When {{base_prompt}} is used and the resolved substitution exceeds
|
|
30
|
+
* RALPH_BASE_PROMPT_WARN_BYTES (default 4096, 0 disables, invalid values fall
|
|
31
|
+
* back to 4096 with a one-time fallback notice), process.stderr receives one
|
|
32
|
+
* line:
|
|
33
|
+
* [mini-ralph] warning: {{base_prompt}} resolved to N bytes from <path>;
|
|
34
|
+
* consider migrating to the manifest-style template
|
|
35
|
+
* (see scripts/ralph-run.sh::create_prompt_template).
|
|
22
36
|
*/
|
|
23
37
|
|
|
24
38
|
const fs = require('fs');
|
|
25
39
|
const path = require('path');
|
|
26
40
|
const tasks = require('./tasks');
|
|
27
41
|
|
|
42
|
+
// One-time fallback notice flag for invalid RALPH_BASE_PROMPT_WARN_BYTES
|
|
43
|
+
let _warnBytesInvalidNoticed = false;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Return the active byte threshold for the oversized-substitution warning.
|
|
47
|
+
* Reads RALPH_BASE_PROMPT_WARN_BYTES each call so tests can set it per-case.
|
|
48
|
+
* - 0 → disabled (returns 0)
|
|
49
|
+
* - positive int → threshold
|
|
50
|
+
* - invalid → 4096 + emits one fallback notice per process
|
|
51
|
+
*
|
|
52
|
+
* @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
function _warnThreshold() {
|
|
55
|
+
const raw = process.env.RALPH_BASE_PROMPT_WARN_BYTES;
|
|
56
|
+
if (raw === undefined || raw === null) return 4096;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
if (raw.trim() === '0') return 0;
|
|
59
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
60
|
+
// invalid
|
|
61
|
+
if (!_warnBytesInvalidNoticed) {
|
|
62
|
+
_warnBytesInvalidNoticed = true;
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
`[mini-ralph] notice: RALPH_BASE_PROMPT_WARN_BYTES="${raw}" is not a valid non-negative integer; falling back to 4096.\n`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return 4096;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reset the one-time invalid-notice flag. Exposed for test isolation only.
|
|
72
|
+
*/
|
|
73
|
+
function _resetWarnNotice() {
|
|
74
|
+
_warnBytesInvalidNoticed = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
28
77
|
/**
|
|
29
78
|
* Load the base prompt text from the configured source.
|
|
30
79
|
* Throws a clear error if the prompt file is missing or empty.
|
|
@@ -59,14 +108,18 @@ function loadBase(options) {
|
|
|
59
108
|
* If a promptTemplate is specified, renders the template with iteration variables.
|
|
60
109
|
* Otherwise returns the base prompt as-is.
|
|
61
110
|
*
|
|
111
|
+
* loadBase() is called ONLY when the template contains {{base_prompt}}.
|
|
112
|
+
* When no template is used, loadBase() is always called to produce the
|
|
113
|
+
* raw prompt.
|
|
114
|
+
*
|
|
62
115
|
* @param {object} options
|
|
63
116
|
* @param {number} iteration - Current 1-based iteration number
|
|
64
117
|
* @returns {string}
|
|
65
118
|
*/
|
|
66
119
|
function render(options, iteration) {
|
|
67
|
-
const base = loadBase(options);
|
|
68
|
-
|
|
69
120
|
if (!options.promptTemplate) {
|
|
121
|
+
// No template — base prompt is the whole output
|
|
122
|
+
const base = loadBase(options);
|
|
70
123
|
return base;
|
|
71
124
|
}
|
|
72
125
|
|
|
@@ -80,6 +133,26 @@ function render(options, iteration) {
|
|
|
80
133
|
throw new Error(`mini-ralph prompt: template file is empty: ${templatePath}`);
|
|
81
134
|
}
|
|
82
135
|
|
|
136
|
+
// Determine whether the template actually uses {{base_prompt}}
|
|
137
|
+
const templateUsesBase = template.indexOf('{{base_prompt}}') !== -1;
|
|
138
|
+
|
|
139
|
+
let base = '';
|
|
140
|
+
if (templateUsesBase) {
|
|
141
|
+
base = loadBase(options);
|
|
142
|
+
|
|
143
|
+
// Oversized-substitution warning
|
|
144
|
+
const threshold = _warnThreshold();
|
|
145
|
+
if (threshold > 0) {
|
|
146
|
+
const byteLen = Buffer.byteLength(base, 'utf8');
|
|
147
|
+
if (byteLen > threshold) {
|
|
148
|
+
const src = options.promptFile || '(inline text)';
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`[mini-ralph] warning: {{base_prompt}} resolved to ${byteLen} bytes from ${src}; consider migrating to the manifest-style template (see scripts/ralph-run.sh::create_prompt_template).\n`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
83
156
|
// Load tasks content if a tasksFile is configured
|
|
84
157
|
let tasksContent = '';
|
|
85
158
|
if (options.tasksFile && fs.existsSync(options.tasksFile)) {
|
|
@@ -97,7 +170,6 @@ function render(options, iteration) {
|
|
|
97
170
|
task_context: taskContext,
|
|
98
171
|
task_promise: options.taskPromise || 'READY_FOR_NEXT_TASK',
|
|
99
172
|
completion_promise: options.completionPromise || 'COMPLETE',
|
|
100
|
-
context: '', // Pending context is injected by runner after rendering
|
|
101
173
|
commit_contract: options.noCommit
|
|
102
174
|
? [
|
|
103
175
|
'- Do not create, amend, or finalize git commits in this run.',
|
|
@@ -122,4 +194,4 @@ function _renderTemplate(template, vars) {
|
|
|
122
194
|
});
|
|
123
195
|
}
|
|
124
196
|
|
|
125
|
-
module.exports = { loadBase, render, _renderTemplate };
|
|
197
|
+
module.exports = { loadBase, render, _renderTemplate, _warnThreshold, _resetWarnNotice };
|