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.
@@ -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
+ };
@@ -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 };