diff --git a/RALPH-METHODOLOGY-ASSESSMENT.md b/RALPH-METHODOLOGY-ASSESSMENT.md index 65eab89..be8e8af 100644 --- a/RALPH-METHODOLOGY-ASSESSMENT.md +++ b/RALPH-METHODOLOGY-ASSESSMENT.md @@ -272,7 +272,7 @@ underlying content explicitly as `{{base_prompt}}` when a prompt template is used, so template-mode output includes the wrapped prompt body rather than silently dropping it. `render()` also applies `promptTemplate` substitution for `{{iteration}}`, `{{max_iterations}}`, `{{tasks}}`, `{{task_context}}`, -`{{task_promise}}`, `{{completion_promise}}`, and `{{context}}`. +`{{task_promise}}`, and `{{completion_promise}}`. `scripts/ralph-run.sh:735-843` — `create_prompt_template()` writes `prompt-template.md` embedding all template variables, including an explicit invocation-time PRD snapshot section. @@ -628,7 +628,7 @@ rendering that injects iteration-specific values before invoking OpenCode. | Field | Value | |-------|-------| | Verdict | `verified` — `prompt.js` supports both inline text and file-based prompt loading, and applies full template variable substitution on every iteration. `ralph-run.sh` creates a dedicated `prompt-template.md` and passes both files to the CLI. Confirmed by unit tests for all template variables and bash tests for template construction. Confidence: **high**. | -| Implementation evidence | `lib/mini-ralph/prompt.js:33-53` — `loadBase()` supports both `promptText` (inline) and `promptFile` (file read); `prompt.js:64-101` — `render()` applies `promptTemplate` when present, replacing `{{iteration}}`, `{{max_iterations}}`, `{{tasks}}`, `{{task_context}}`, `{{task_promise}}`, `{{completion_promise}}`, `{{context}}`; `prompt.js:110-113` — `_renderTemplate()` does `{{key}}` replacement; `scripts/ralph-run.sh:735-843` — `create_prompt_template()` writes `prompt-template.md` with all template variables; `ralph-run.sh:1007-1013` — `--prompt-file PRD.md --prompt-template prompt-template.md` passed to mini-ralph | +| Implementation evidence | `lib/mini-ralph/prompt.js:33-53` — `loadBase()` supports both `promptText` (inline) and `promptFile` (file read); lazy-loaded only when the template contains `{{base_prompt}}`; `prompt.js:64-101` — `render()` applies `promptTemplate` when present, replacing `{{iteration}}`, `{{max_iterations}}`, `{{tasks}}`, `{{task_context}}`, `{{task_promise}}`, `{{completion_promise}}`; `prompt.js:110-113` — `_renderTemplate()` does `{{key}}` replacement; `scripts/ralph-run.sh:735-843` — `create_prompt_template()` writes `prompt-template.md` with all template variables; `ralph-run.sh:1007-1013` — `--prompt-file PRD.md --prompt-template prompt-template.md` passed to mini-ralph | | Test evidence | `tests/unit/javascript/mini-ralph-prompt.test.js` — `_renderTemplate()` suite (lines 30–69): `replaces a single variable` (line 31), `replaces multiple variables` (line 35), `replaces repeated occurrences of the same variable` (line 40), `handles empty string as variable value` (line 56); `render()` suite — `renders template with iteration variables` (line 110), `injects tasks content when tasksFile is present` (line 131), `includes task_promise and completion_promise in template` (line 185); `tests/unit/bash/test-create-prompt-template.bats` — `create_prompt_template: includes Ralph iteration placeholders` (line 57), `create_prompt_template: includes task list placeholder` (line 78), `create_prompt_template: includes context placeholder` (line 98), `create_prompt_template: includes promise placeholders` (line 138); `tests/unit/bash/test-execute-ralph-loop.bats` — `execute_ralph_loop: passes --prompt-template to the CLI` (line 251) | --- diff --git a/README.md b/README.md index c121e63..48106a6 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,59 @@ Windows is not currently part of the supported runtime contract. For common issues and solutions, see [QUICKSTART.md#troubleshooting](./QUICKSTART.md#troubleshooting). +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `RALPH_BASE_PROMPT_WARN_BYTES` | `4096` | Byte threshold above which `render()` emits a one-line warning to stderr when `{{base_prompt}}` resolves to a large file. Set to `0` to silence warnings entirely. Invalid values fall back to `4096` with a one-time notice per process. | +| `RALPH_ITERATION_IDLE_TIMEOUT_MS` | `300000` | Milliseconds of silence on stdout+stderr before the per-iteration idle watchdog fires. Set to `0` to disable the watchdog entirely and restore pre-change behavior (no timeout). | +| `RALPH_ITERATION_KILL_GRACE_MS` | `10000` | Milliseconds the runner waits after sending `SIGTERM` to a timed-out iteration child before escalating to `SIGKILL`. | + +### Auto-commit ignore-filter surfacing and iteration watchdog + +This section covers two surfacing improvements added on top of the `harden-auto-commit-against-ignored-paths` change, which is the underlying mechanism that _detects_ when `.gitignore` rules filter out loop-managed paths. + +**No new CLI flags are introduced by this change. No startup behavior changes. Every existing `ralph-run` invocation continues to work unchanged.** + +#### Loud stderr block + +When `_autoCommit` detects that one or more paths were filtered by `.gitignore` (anomaly types `paths_ignored_filtered` or `all_paths_ignored`), the runner emits the following block directly to `process.stderr` on **every** iteration where the anomaly fires — independently of any reporter buffering or deduplication: + +``` +================================================================================ +⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration 7, type: paths_ignored_filtered) +Paths filtered because .gitignore matches: + - openspec/changes/my-change/tasks.md + - openspec/changes/my-change/proposal.md +Consequence: these paths are NOT in the latest commit. +Remediation (pick one): + 1. git add -f # one-time unblock, if you want it tracked + 2. edit .gitignore # narrow or remove the matching rule + 3. pass --no-auto-commit on the ralph-run invocation +================================================================================ +``` + +The three remediation options mean: + +1. **`git add -f `** — force-stage a specific file for the next commit. One-time unblock; the path stays gitignored and will be filtered again on the next auto-commit unless you also do option 2. +2. **edit `.gitignore`** — narrow or remove the matching rule so the path is no longer excluded. The safest long-term fix when the rule is too broad. +3. **`--no-auto-commit`** — disable auto-commit for this run entirely. Use when you want to manage commits yourself and don't want the runner touching git. + +#### Iteration watchdog + +The runner enforces a per-iteration idle timeout: if the `opencode run` subprocess produces no new bytes on stdout **or** stderr for `RALPH_ITERATION_IDLE_TIMEOUT_MS` milliseconds, the watchdog fires. It sends `SIGTERM`, waits up to `RALPH_ITERATION_KILL_GRACE_MS` for a graceful exit, then sends `SIGKILL`. + +The timed-out iteration is recorded in history with `failureReason: 'iteration_timeout_idle'` and three diagnostic fields: `idleMs` (how long the process was silent), `lastStdoutBytes` (last ≤200 bytes of stdout), and `lastStderrBytes` (last ≤200 bytes of stderr). These fields are absent on entries where the watchdog did not fire. + +The `iteration_timeout_idle` reason also appears in the `## Recent Loop Signals` block injected into each iteration's prompt, giving the agent visibility into prior timeout events. + +Set `RALPH_ITERATION_IDLE_TIMEOUT_MS=0` to disable the watchdog if your agent workflow runs legitimately long silent tools (e.g., large integration test suites). Example: + +```bash +RALPH_ITERATION_IDLE_TIMEOUT_MS=900000 ralph-run --change my-feature # 15-minute idle threshold +RALPH_ITERATION_IDLE_TIMEOUT_MS=0 ralph-run --change my-feature # watchdog disabled +``` + **Quick fixes:** ```bash diff --git a/lib/mini-ralph/history.js b/lib/mini-ralph/history.js index 46d05b2..f90d866 100644 --- a/lib/mini-ralph/history.js +++ b/lib/mini-ralph/history.js @@ -52,6 +52,43 @@ function read(ralphDir) { * @param {Array} entry.toolUsage - Tool usage summary array * @param {Array} entry.filesChanged - Files changed in this iteration * @param {number} entry.exitCode - OpenCode exit code + * @param {number} [entry.promptBytes] - UTF-8 byte length of the assembled prompt + * @param {number} [entry.promptChars] - Character length of the assembled prompt + * @param {number} [entry.promptTokens] - Estimated token count for the prompt (chars/4, rounded) + * @param {number} [entry.responseBytes] - UTF-8 byte length of the raw response + * @param {number} [entry.responseChars] - Character length of the raw response + * @param {number} [entry.responseTokens] - Estimated token count for the response (chars/4, rounded) + * @param {boolean} [entry.truncated] - Whether the response was truncated by the invoker + * @param {boolean} [entry.commitAttempted] - Whether an auto-commit was attempted this iteration + * @param {boolean} [entry.commitCreated] - Whether a git commit was successfully created + * @param {string} [entry.commitSha] - The SHA of the created commit (if commitCreated is true) + * @param {string} [entry.commitMessage] - The commit message used (if commitCreated is true) + * @param {string} [entry.commitAnomaly] - Human-readable description of any commit anomaly + * @param {string} [entry.commitAnomalyType] - Machine-readable anomaly type string. Known values: + * - `'nothing_staged'` - No files were staged for commit. + * - `'commit_failed'` - The git commit command failed. + * - `'paths_ignored_filtered'` - Some staged paths were gitignored and filtered out; + * the remaining paths were committed successfully. + * - `'all_paths_ignored'` - Every staged path was gitignored; no commit was made. + * @param {string[]} [entry.ignoredPaths] - Paths that were dropped by the gitignore filter. + * Present only when `commitAnomalyType` is `'paths_ignored_filtered'` or `'all_paths_ignored'`. + * Omitted entirely when no paths were filtered. + * @param {string} [entry.failureReason] - Human-readable reason for iteration failure. + * Known values include: + * - `'iteration_timeout_idle'` - The iteration subprocess was terminated by the idle watchdog + * because no bytes were written to stdout or stderr for longer + * than `RALPH_ITERATION_IDLE_TIMEOUT_MS`. When this value is + * present, `idleMs`, `lastStdoutBytes`, and `lastStderrBytes` + * are also present on the entry. + * Omitted entirely when the iteration succeeded or failed for another reason. + * @param {number} [entry.idleMs] - Observed idle duration in milliseconds when the + * watchdog fired. Present only when `failureReason === 'iteration_timeout_idle'`. + * @param {string} [entry.lastStdoutBytes] - Tail of the iteration subprocess stdout at the + * moment the watchdog fired, capped at 200 bytes. Present only when + * `failureReason === 'iteration_timeout_idle'`. + * @param {string} [entry.lastStderrBytes] - Tail of the iteration subprocess stderr at the + * moment the watchdog fired, capped at 200 bytes. Present only when + * `failureReason === 'iteration_timeout_idle'`. */ function append(ralphDir, entry) { _ensureDir(ralphDir); diff --git a/lib/mini-ralph/invoker.js b/lib/mini-ralph/invoker.js index 353af18..d896c47 100644 --- a/lib/mini-ralph/invoker.js +++ b/lib/mini-ralph/invoker.js @@ -75,17 +75,43 @@ async function invoke(opts) { signal: result.signal, toolUsage: _extractToolUsage(result.stdout), filesChanged, + // Pass through watchdog fields when present (task 2.1) + ...(result.failureReason !== undefined && { + failureReason: result.failureReason, + idleMs: result.idleMs, + lastStdoutBytes: result.lastStdoutBytes, + lastStderrBytes: result.lastStderrBytes, + }), }; } /** * Spawn the opencode process and stream output to terminal while capturing. + * Wraps the subprocess with a per-iteration stream-idle watchdog controlled + * by RALPH_ITERATION_IDLE_TIMEOUT_MS (default 300000 ms; 0 = disabled) and + * RALPH_ITERATION_KILL_GRACE_MS (default 10000 ms). + * + * When the watchdog fires the returned result gains: + * failureReason: 'iteration_timeout_idle' + * idleMs: — observed idle duration in ms + * lastStdoutBytes: — tail of stdout, capped at 200 bytes + * lastStderrBytes: — tail of stderr, capped at 200 bytes + * + * When the watchdog is disabled or does not fire, the return shape is + * unchanged from the pre-watchdog contract. * * @param {Array} args * @param {boolean} verbose - * @returns {Promise<{stdout: string, stderr: string, exitCode: ?number, signal: ?string}>} + * @returns {Promise<{stdout: string, stderr: string, exitCode: ?number, signal: ?string, failureReason?: string, idleMs?: number, lastStdoutBytes?: string, lastStderrBytes?: string}>} */ function _spawnOpenCode(args, verbose) { + // Parse watchdog knobs from environment. task 2.1 (surface-autocommit-ignore-warning-and-watchdog) + const idleTimeoutRaw = process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS; + const killGraceRaw = process.env.RALPH_ITERATION_KILL_GRACE_MS; + const idleTimeoutMs = idleTimeoutRaw !== undefined ? Number(idleTimeoutRaw) : 300000; + const killGraceMs = killGraceRaw !== undefined ? Number(killGraceRaw) : 10000; + const watchdogEnabled = idleTimeoutMs !== 0; + return new Promise((resolve, reject) => { const child = spawn('opencode', args, { stdio: ['inherit', 'pipe', 'pipe'], @@ -94,20 +120,80 @@ function _spawnOpenCode(args, verbose) { let stdout = ''; let stderr = ''; + let watchdogFired = false; + let idleTimer = null; + let idleStart = Date.now(); + + // Tail buffers — keep only the most recent 200 bytes of each stream + const TAIL_CAP = 200; + let stdoutTail = ''; + let stderrTail = ''; + + function _appendTail(current, chunk) { + const combined = current + chunk; + return combined.length > TAIL_CAP ? combined.slice(-TAIL_CAP) : combined; + } + + function _resetIdleTimer() { + if (!watchdogEnabled || watchdogFired) return; + if (idleTimer) clearTimeout(idleTimer); + idleStart = Date.now(); + idleTimer = setTimeout(_onIdleTimeout, idleTimeoutMs); + } + + function _onIdleTimeout() { + if (watchdogFired) return; + watchdogFired = true; + const observedIdleMs = Date.now() - idleStart; + + // Send SIGTERM first; after killGraceMs escalate to SIGKILL if still alive. + // Use process.kill(-pid, signal) with detached:true when process group + // kill is needed; for a direct child process.kill() is sufficient on macOS. + try { + child.kill('SIGTERM'); + } catch (_) { /* child may have already exited */ } + + const killTimer = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch (_) { /* child may have already exited */ } + }, killGraceMs); + + // Prevent killTimer from keeping the event loop alive + if (killTimer && typeof killTimer.unref === 'function') { + killTimer.unref(); + } + + // Stash metadata on the child so the close handler can read it + child._watchdogMeta = { + idleMs: observedIdleMs, + lastStdoutBytes: stdoutTail, + lastStderrBytes: stderrTail, + }; + } + + if (watchdogEnabled) { + _resetIdleTimer(); + } child.stdout.on('data', (chunk) => { const text = chunk.toString(); stdout += text; + stdoutTail = _appendTail(stdoutTail, text); process.stdout.write(chunk); + _resetIdleTimer(); }); child.stderr.on('data', (chunk) => { const text = chunk.toString(); stderr += text; + stderrTail = _appendTail(stderrTail, text); process.stderr.write(chunk); + _resetIdleTimer(); }); child.on('error', (err) => { + if (idleTimer) clearTimeout(idleTimer); if (err.code === 'ENOENT') { reject(new Error('mini-ralph invoker: opencode CLI not found. Please install opencode: npm install -g opencode-ai')); } else { @@ -116,12 +202,27 @@ function _spawnOpenCode(args, verbose) { }); child.on('close', (code, signal) => { - resolve({ - stdout, - stderr, - exitCode: typeof code === 'number' ? code : null, - signal: signal || null, - }); + if (idleTimer) clearTimeout(idleTimer); + + if (watchdogFired && child._watchdogMeta) { + resolve({ + stdout, + stderr, + exitCode: typeof code === 'number' ? code : null, + signal: signal || null, + failureReason: 'iteration_timeout_idle', + idleMs: child._watchdogMeta.idleMs, + lastStdoutBytes: child._watchdogMeta.lastStdoutBytes, + lastStderrBytes: child._watchdogMeta.lastStderrBytes, + }); + } else { + resolve({ + stdout, + stderr, + exitCode: typeof code === 'number' ? code : null, + signal: signal || null, + }); + } }); }); } diff --git a/lib/mini-ralph/lessons.js b/lib/mini-ralph/lessons.js new file mode 100644 index 0000000..1b19d61 --- /dev/null +++ b/lib/mini-ralph/lessons.js @@ -0,0 +1,93 @@ +'use strict'; + +const fs = require('fs'); +const nodePath = require('path'); + +const LESSONS_FILENAME = 'LESSONS.md'; +const MAX_BULLET_CHARS = 120; +const MAX_INJECT_BULLETS = 15; + +/** + * Returns the absolute path to the LESSONS.md file for the given ralphDir. + * @param {string} ralphDir - Path to the .ralph directory. + * @returns {string} + */ +function path(ralphDir) { + return nodePath.join(ralphDir, LESSONS_FILENAME); +} + +/** + * Reads LESSONS.md from ralphDir, returning an array of bullet strings. + * Missing file returns []. Blank lines are stripped. Bullets > 120 chars + * are truncated and prefixed with 'runner-truncated:'. + * @param {string} ralphDir + * @returns {string[]} + */ +function read(ralphDir) { + const filePath = path(ralphDir); + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (e) { + if (e.code === 'ENOENT') return []; + throw e; + } + + const lines = content.split('\n'); + const bullets = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.length > MAX_BULLET_CHARS) { + bullets.push('runner-truncated:' + trimmed.slice(0, MAX_BULLET_CHARS)); + } else { + bullets.push(trimmed); + } + } + return bullets; +} + +/** + * Returns a markdown section string '## Lessons Learned\n\n' using + * the last `limit` (default 50) bullets, or '' if there are none. + * @param {string} ralphDir + * @param {{ limit?: number }} [opts] + * @returns {string} + */ +function inject(ralphDir, opts) { + const limit = (opts && opts.limit != null) ? opts.limit : MAX_INJECT_BULLETS; + const bullets = read(ralphDir); + if (!bullets.length) return ''; + const slice = bullets.slice(-limit); + return '## Lessons Learned\n\n' + slice.join('\n'); +} + +/** + * If LESSONS.md has more than `max` non-empty bullets, rewrites it keeping + * only the last `max` bullets. Returns the number of bullets dropped (0 if + * no write occurred). + * @param {string} ralphDir + * @param {number} max + * @returns {number} + */ +function rotate(ralphDir, max) { + const filePath = path(ralphDir); + let content; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (e) { + if (e.code === 'ENOENT') return 0; + throw e; + } + + const lines = content.split('\n'); + const bullets = lines.filter(l => l.trim() !== ''); + if (bullets.length <= max) return 0; + + const dropped = bullets.length - max; + const kept = bullets.slice(-max); + fs.writeFileSync(filePath, kept.join('\n') + '\n', 'utf8'); + return dropped; +} + +module.exports = { path, read, inject, rotate }; diff --git a/lib/mini-ralph/progress.js b/lib/mini-ralph/progress.js new file mode 100644 index 0000000..1955415 --- /dev/null +++ b/lib/mini-ralph/progress.js @@ -0,0 +1,404 @@ +'use strict'; + +/** + * progress.js - Human-readable runtime progress reporter for the mini-ralph + * loop. + * + * Responsible only for formatting and emitting a concise, live status stream + * so an operator watching the loop can see, per iteration: + * + * - iteration number / cap + * - current task number + short description + * - outcome (ok / stalled / failed / committed) + * - wall-clock duration for the iteration + * - rolling counters: successes, failures, commits, stall streak + * - cumulative + average per-iteration time + * + * Design choices: + * - Output goes to stderr so piping stdout elsewhere still works. + * - ANSI colors are used when the destination is a TTY and `NO_COLOR` is + * not set. Respects the de-facto `NO_COLOR` convention + * (https://no-color.org/). + * - Timestamps are local-time HH:MM:SS so an operator can correlate with + * wall-clock events without deciphering ISO strings. + * - All helpers are pure (no I/O) except for `emit`, which is the only + * function that writes to `stream`. This keeps the module trivially + * testable. + */ + +const ANSI = { + reset: '\u001b[0m', + dim: '\u001b[2m', + bold: '\u001b[1m', + red: '\u001b[31m', + green: '\u001b[32m', + yellow: '\u001b[33m', + blue: '\u001b[34m', + magenta: '\u001b[35m', + cyan: '\u001b[36m', + gray: '\u001b[90m', +}; + +/** + * Build a progress reporter. + * + * @param {object} [opts] + * @param {NodeJS.WritableStream} [opts.stream=process.stderr] Destination. + * @param {boolean} [opts.enabled=true] Hard switch to silence all output. + * @param {boolean} [opts.color] Force color on/off; otherwise auto-detect. + * @param {number} [opts.maxIterations] Optional cap, shown as `i/max`. + * @param {string} [opts.label='mini-ralph'] Short tag prefix. + * @param {() => number} [opts.now=Date.now] Clock (injectable for tests). + * @returns {object} reporter + */ +function create(opts = {}) { + const stream = opts.stream || process.stderr; + const enabled = opts.enabled !== false; + const color = typeof opts.color === 'boolean' ? opts.color : _detectColor(stream); + const maxIterations = + typeof opts.maxIterations === 'number' && opts.maxIterations > 0 + ? opts.maxIterations + : null; + const label = typeof opts.label === 'string' ? opts.label : 'mini-ralph'; + const now = typeof opts.now === 'function' ? opts.now : Date.now; + + const runStart = now(); + const stats = { + iterations: 0, + successes: 0, + failures: 0, + stalled: 0, + commits: 0, + cumulativeMs: 0, + completedTasks: 0, + }; + + /** + * Emit a single formatted line. Always appends a trailing newline. + */ + function emit(line) { + if (!enabled) return; + stream.write(line + '\n'); + } + + /** + * Announce the loop start. Prints a single header line with the iteration + * cap and (when provided) the model name. + */ + function runStarted(meta = {}) { + if (!enabled) return; + const parts = [`${_tag(label, color)} ${_kw('run started', color, 'bold')}`]; + if (meta.tasksMode) parts.push(_dim('mode=tasks', color)); + if (meta.model) parts.push(_dim(`model=${meta.model}`, color)); + if (maxIterations) parts.push(_dim(`cap=${maxIterations}`, color)); + if (meta.resumed) parts.push(_dim(`resumed-from=${meta.resumed}`, color)); + parts.push(_dim(_clockStamp(new Date(runStart)), color)); + emit(parts.join(' ')); + } + + /** + * Report the beginning of a single iteration. + */ + function iterationStarted(info = {}) { + if (!enabled) return; + const iter = _iterLabel(info.iteration, maxIterations, color); + const task = _taskLabel(info.taskNumber, info.taskDescription, color); + const line = `${_tag(label, color)} ${_paint('▶', color, 'cyan')} ${iter}${task ? ' ' + task : ''}`; + emit(line); + } + + /** + * Report the outcome of a single iteration and update rolling counters. + * + * @param {object} info + * @param {number} info.iteration + * @param {number} info.durationMs + * @param {('success'|'failure'|'stalled')} info.outcome + * @param {boolean} [info.committed] + * @param {boolean} [info.hasCompletion] + * @param {boolean} [info.hasTask] + * @param {number} [info.completedTasksCount] + * @param {number} [info.filesChangedCount] + * @param {string} [info.failureReason] + * @param {number} [info.stallStreak] + */ + function iterationFinished(info = {}) { + const duration = _coerceInt(info.durationMs, 0); + stats.iterations += 1; + stats.cumulativeMs += duration; + stats.completedTasks += _coerceInt(info.completedTasksCount, 0); + if (info.committed) stats.commits += 1; + + if (info.outcome === 'failure') stats.failures += 1; + else if (info.outcome === 'stalled') stats.stalled += 1; + else stats.successes += 1; + + if (!enabled) return; + + const iter = _iterLabel(info.iteration, maxIterations, color); + const badge = _outcomeBadge(info.outcome, color); + const timing = _paint( + `${_formatDuration(duration)} (avg ${_formatDuration(_average(stats))} · total ${_formatDuration(stats.cumulativeMs)})`, + color, + 'gray' + ); + + const fragments = []; + if (info.committed) fragments.push(_paint('committed', color, 'green')); + if (info.hasCompletion) fragments.push(_paint('COMPLETE', color, 'magenta')); + else if (info.hasTask) fragments.push(_paint('next-task', color, 'cyan')); + if (_coerceInt(info.filesChangedCount, 0) > 0) { + fragments.push(_dim(`files+=${info.filesChangedCount}`, color)); + } + if (_coerceInt(info.completedTasksCount, 0) > 0) { + fragments.push(_dim(`tasks+=${info.completedTasksCount}`, color)); + } + if (info.outcome === 'stalled' && _coerceInt(info.stallStreak, 0) > 0) { + fragments.push(_dim(`stall-streak=${info.stallStreak}`, color)); + } + if (info.outcome === 'failure' && info.failureReason) { + fragments.push(_paint(_truncate(info.failureReason, 80), color, 'red')); + } + + const counters = _paint( + `ok=${stats.successes} fail=${stats.failures} stall=${stats.stalled} commits=${stats.commits}`, + color, + 'gray' + ); + + const line = [ + _tag(label, color), + badge, + iter, + fragments.length > 0 ? fragments.join(' ') : '', + timing, + counters, + ] + .filter(Boolean) + .join(' '); + emit(line); + } + + /** + * Emit a single line announcing that the iteration's prompt is ready to + * be sent to the model, with size telemetry. + * + * @param {object} info + * @param {number} info.iteration + * @param {number} info.promptBytes + * @param {number} info.promptChars + * @param {number} info.promptTokens + */ + function iterationPromptReady(info = {}) { + if (!enabled) return; + const iter = _iterLabel(info.iteration, maxIterations, color); + const bytes = _coerceInt(info.promptBytes, 0); + const chars = _coerceInt(info.promptChars, 0); + const tokens = _coerceInt(info.promptTokens, 0); + const size = _dim(`prompt=${_formatBytes(bytes)} chars=${chars} tokens≈${tokens}`, color); + emit(`${_tag(label, color)} ${_paint('↑', color, 'blue')} ${iter} ${size}`); + } + + /** + * Emit a single line announcing that the model's response has been received, + * with size telemetry. Prints a yellow TRUNCATED marker when truncated. + * + * @param {object} info + * @param {number} info.iteration + * @param {number} info.responseBytes + * @param {number} info.responseChars + * @param {number} info.responseTokens + * @param {boolean} [info.truncated] + */ + function iterationResponseReceived(info = {}) { + if (!enabled) return; + const iter = _iterLabel(info.iteration, maxIterations, color); + const bytes = _coerceInt(info.responseBytes, 0); + const chars = _coerceInt(info.responseChars, 0); + const tokens = _coerceInt(info.responseTokens, 0); + const size = _dim(`response=${_formatBytes(bytes)} chars=${chars} tokens≈${tokens}`, color); + const parts = [`${_tag(label, color)} ${_paint('↓', color, 'blue')} ${iter} ${size}`]; + if (info.truncated) parts.push(_paint('TRUNCATED', color, 'yellow')); + emit(parts.join(' ')); + } + + /** + * Emit a one-off informational note (e.g. resume detected, stall halted). + */ + function note(message, level = 'info') { + if (!enabled || !message) return; + const glyph = + level === 'warn' ? _paint('!', color, 'yellow') + : level === 'error' ? _paint('✖', color, 'red') + : _paint('•', color, 'blue'); + emit(`${_tag(label, color)} ${glyph} ${message}`); + } + + /** + * Print the final summary line for the run. + * + * @param {object} outcome + * @param {boolean} outcome.completed + * @param {string} outcome.exitReason + * @param {number} [outcome.iterations] + */ + function runFinished(outcome = {}) { + if (!enabled) return; + const wall = now() - runStart; + const ok = outcome.completed === true; + const head = ok + ? _paint('✓ run complete', color, 'green') + : _paint('✗ run ended', color, 'yellow'); + const reason = outcome.exitReason ? ` reason=${outcome.exitReason}` : ''; + const avg = _average(stats); + const body = [ + `iterations=${stats.iterations}`, + `ok=${stats.successes}`, + `fail=${stats.failures}`, + `stall=${stats.stalled}`, + `commits=${stats.commits}`, + `tasks=${stats.completedTasks}`, + `avg=${_formatDuration(avg)}`, + `total=${_formatDuration(stats.cumulativeMs)}`, + `wall=${_formatDuration(wall)}`, + ].join(' '); + + emit(`${_tag(label, color)} ${head}${reason} ${_dim(body, color)}`); + } + + /** + * Snapshot of rolling stats. Exposed for tests and programmatic callers. + */ + function snapshot() { + return Object.assign({}, stats, { averageMs: _average(stats), wallMs: now() - runStart }); + } + + return { + runStarted, + iterationStarted, + iterationPromptReady, + iterationResponseReceived, + iterationFinished, + note, + runFinished, + snapshot, + enabled, + }; +} + +// --------------------------------------------------------------------------- +// Pure formatting helpers (exported for unit testing) +// --------------------------------------------------------------------------- + +function _detectColor(stream) { + if (process.env && process.env.NO_COLOR) return false; + if (process.env && process.env.FORCE_COLOR) return true; + if (!stream) return false; + return Boolean(stream.isTTY); +} + +function _tag(label, color) { + return _paint(`[${label}]`, color, 'gray'); +} + +function _iterLabel(iteration, maxIterations, color) { + const safeIter = _coerceInt(iteration, 0); + const body = maxIterations ? `iter ${safeIter}/${maxIterations}` : `iter ${safeIter}`; + return _paint(body, color, 'bold'); +} + +function _taskLabel(taskNumber, taskDescription, color) { + const num = taskNumber && String(taskNumber).trim(); + const desc = taskDescription && String(taskDescription).trim(); + if (!num && !desc) return ''; + const head = num ? `task ${num}` : 'task'; + const tail = desc ? ` ${_truncate(_collapse(desc), 72)}` : ''; + return `${_paint(head, color, 'blue')}${_paint(tail, color, 'gray')}`; +} + +function _outcomeBadge(outcome, color) { + if (outcome === 'failure') return _paint('✖ fail', color, 'red'); + if (outcome === 'stalled') return _paint('∅ stall', color, 'yellow'); + return _paint('✓ ok', color, 'green'); +} + +function _kw(text, color, style) { + return _paint(text, color, style); +} + +function _dim(text, color) { + return _paint(text, color, 'dim'); +} + +function _paint(text, color, style) { + if (!color || !text) return text || ''; + const code = ANSI[style]; + if (!code) return text; + return `${code}${text}${ANSI.reset}`; +} + +/** + * Format a millisecond duration as a short human string, e.g. + * 850ms, 12.3s, 2m 04s, 1h 02m. + */ +function _formatDuration(ms) { + const n = Math.max(0, _coerceInt(ms, 0)); + if (n < 1000) return `${n}ms`; + const seconds = n / 1000; + if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`; + const mins = Math.floor(seconds / 60); + const remSec = Math.floor(seconds - mins * 60); + if (mins < 60) return `${mins}m ${String(remSec).padStart(2, '0')}s`; + const hours = Math.floor(mins / 60); + const remMin = mins - hours * 60; + return `${hours}h ${String(remMin).padStart(2, '0')}m`; +} + +function _average(stats) { + if (!stats || !stats.iterations) return 0; + return Math.round(stats.cumulativeMs / stats.iterations); +} + +function _truncate(text, budget) { + const s = String(text == null ? '' : text); + if (s.length <= budget) return s; + const hard = Math.max(1, budget - 1); + return `${s.slice(0, hard)}…`; +} + +function _collapse(text) { + return String(text).replace(/\s+/g, ' ').trim(); +} + +function _coerceInt(value, fallback) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.trunc(n); +} + +/** + * Format a byte count as a short human-readable string, e.g. 512B, 1.5KB, 2.3MB. + */ +function _formatBytes(bytes) { + const n = Math.max(0, _coerceInt(bytes, 0)); + if (n < 1024) return `${n}B`; + const kb = n / 1024; + if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB`; + const mb = kb / 1024; + return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB`; +} + +function _clockStamp(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +module.exports = { + create, + _formatDuration, + _formatBytes, + _truncate, + _collapse, + _detectColor, + _average, +}; diff --git a/lib/mini-ralph/prompt.js b/lib/mini-ralph/prompt.js index b2be6b0..182ae4f 100644 --- a/lib/mini-ralph/prompt.js +++ b/lib/mini-ralph/prompt.js @@ -12,19 +12,68 @@ * {{iteration}} - Current iteration number * {{max_iterations}} - Configured max iterations * {{change_dir}} - Change directory path (from options.changeDir) - * {{base_prompt}} - Underlying prompt text from promptText/promptFile + * {{base_prompt}} - Underlying prompt text from promptText/promptFile. + * Lazy-loaded: loadBase() is called ONLY when the + * template contains the literal substring + * {{base_prompt}}. If the template omits it, no + * prompt source is required and the prompt-source + * errors ("no prompt source configured", "prompt file + * not found", "prompt file is empty") do not fire. * {{tasks}} - Raw tasks file content * {{task_context}} - Fresh current-task and completed-task summary * {{task_promise}} - Configured task promise string * {{completion_promise}} - Configured completion promise string - * {{context}} - Pending context (passed in, already consumed) * {{commit_contract}} - Commit instructions derived from options.noCommit + * + * Oversized-substitution warning: + * When {{base_prompt}} is used and the resolved substitution exceeds + * RALPH_BASE_PROMPT_WARN_BYTES (default 4096, 0 disables, invalid values fall + * back to 4096 with a one-time fallback notice), process.stderr receives one + * line: + * [mini-ralph] warning: {{base_prompt}} resolved to N bytes from ; + * consider migrating to the manifest-style template + * (see scripts/ralph-run.sh::create_prompt_template). */ const fs = require('fs'); const path = require('path'); const tasks = require('./tasks'); +// One-time fallback notice flag for invalid RALPH_BASE_PROMPT_WARN_BYTES +let _warnBytesInvalidNoticed = false; + +/** + * Return the active byte threshold for the oversized-substitution warning. + * Reads RALPH_BASE_PROMPT_WARN_BYTES each call so tests can set it per-case. + * - 0 → disabled (returns 0) + * - positive int → threshold + * - invalid → 4096 + emits one fallback notice per process + * + * @returns {number} + */ +function _warnThreshold() { + const raw = process.env.RALPH_BASE_PROMPT_WARN_BYTES; + if (raw === undefined || raw === null) return 4096; + const n = Number(raw); + if (raw.trim() === '0') return 0; + if (Number.isInteger(n) && n > 0) return n; + // invalid + if (!_warnBytesInvalidNoticed) { + _warnBytesInvalidNoticed = true; + process.stderr.write( + `[mini-ralph] notice: RALPH_BASE_PROMPT_WARN_BYTES="${raw}" is not a valid non-negative integer; falling back to 4096.\n` + ); + } + return 4096; +} + +/** + * Reset the one-time invalid-notice flag. Exposed for test isolation only. + */ +function _resetWarnNotice() { + _warnBytesInvalidNoticed = false; +} + /** * Load the base prompt text from the configured source. * Throws a clear error if the prompt file is missing or empty. @@ -59,14 +108,18 @@ function loadBase(options) { * If a promptTemplate is specified, renders the template with iteration variables. * Otherwise returns the base prompt as-is. * + * loadBase() is called ONLY when the template contains {{base_prompt}}. + * When no template is used, loadBase() is always called to produce the + * raw prompt. + * * @param {object} options * @param {number} iteration - Current 1-based iteration number * @returns {string} */ function render(options, iteration) { - const base = loadBase(options); - if (!options.promptTemplate) { + // No template — base prompt is the whole output + const base = loadBase(options); return base; } @@ -80,6 +133,26 @@ function render(options, iteration) { throw new Error(`mini-ralph prompt: template file is empty: ${templatePath}`); } + // Determine whether the template actually uses {{base_prompt}} + const templateUsesBase = template.indexOf('{{base_prompt}}') !== -1; + + let base = ''; + if (templateUsesBase) { + base = loadBase(options); + + // Oversized-substitution warning + const threshold = _warnThreshold(); + if (threshold > 0) { + const byteLen = Buffer.byteLength(base, 'utf8'); + if (byteLen > threshold) { + const src = options.promptFile || '(inline text)'; + process.stderr.write( + `[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` + ); + } + } + } + // Load tasks content if a tasksFile is configured let tasksContent = ''; if (options.tasksFile && fs.existsSync(options.tasksFile)) { @@ -97,7 +170,6 @@ function render(options, iteration) { task_context: taskContext, task_promise: options.taskPromise || 'READY_FOR_NEXT_TASK', completion_promise: options.completionPromise || 'COMPLETE', - context: '', // Pending context is injected by runner after rendering commit_contract: options.noCommit ? [ '- Do not create, amend, or finalize git commits in this run.', @@ -122,4 +194,4 @@ function _renderTemplate(template, vars) { }); } -module.exports = { loadBase, render, _renderTemplate }; +module.exports = { loadBase, render, _renderTemplate, _warnThreshold, _resetWarnNotice }; diff --git a/lib/mini-ralph/runner.js b/lib/mini-ralph/runner.js index 7bfbd27..43c7962 100644 --- a/lib/mini-ralph/runner.js +++ b/lib/mini-ralph/runner.js @@ -20,6 +20,8 @@ const tasks = require('./tasks'); const prompt = require('./prompt'); const invoker = require('./invoker'); const errors = require('./errors'); +const progress = require('./progress'); +const lessons = require('./lessons'); const DEFAULTS = { minIterations: 1, @@ -29,6 +31,11 @@ const DEFAULTS = { tasksMode: false, noCommit: false, verbose: false, + // Emits a per-iteration runtime status line (task #, ok/fail/stall badge, + // duration, rolling counters, cumulative + average time) to stderr. Enabled + // by default because "what is the loop doing right now?" is the single most + // common operator question. Pass `quiet: true` to suppress. + quiet: false, // Stall detector: break the loop after N *consecutive* iterations that // succeeded but produced no progress (no promise, no completed tasks, no // files changed). 0 disables the detector. Failed iterations do not count @@ -71,6 +78,20 @@ function _wasSuccessfulIteration(result) { return !_isFailedIteration(result); } +/** + * Measure the size of a text string for telemetry purposes. + * + * @param {string} str + * @returns {{ bytes: number, chars: number, tokens: number }} + */ +function _measureText(str) { + if (typeof str !== 'string') str = ''; + const bytes = Buffer.byteLength(str, 'utf8'); + const chars = str.length; + const tokens = Math.round(chars / 4); + return { bytes, chars, tokens }; +} + function _failureStageForError(err) { if (!err || typeof err !== 'object') { return 'invoke_contract'; @@ -124,6 +145,13 @@ function _appendFatalIterationFailure(ralphDir, entry) { commitAnomaly: '', commitAnomalyType: '', protectedArtifacts: [], + promptBytes: entry.promptBytes || 0, + promptChars: entry.promptChars || 0, + promptTokens: entry.promptTokens || 0, + responseBytes: entry.responseBytes || 0, + responseChars: entry.responseChars || 0, + responseTokens: entry.responseTokens || 0, + truncated: entry.truncated || false, }); } @@ -152,6 +180,11 @@ async function run(opts) { ? Math.floor(options.stallThreshold) : DEFAULTS.stallThreshold; + const reporter = options.reporter || progress.create({ + enabled: !options.quiet, + maxIterations, + }); + let stateInitialized = false; let iterationCount = 0; let completed = false; @@ -175,6 +208,12 @@ async function run(opts) { ); } + reporter.runStarted({ + tasksMode: Boolean(options.tasksMode), + model: options.model || '', + resumed: resumeIteration > 1 ? resumeIteration - 1 : null, + }); + // Initialize state file for this run, preserving history count if resuming. // // `startedAt` semantics: this field marks the first time *this change* was @@ -229,8 +268,17 @@ async function run(opts) { ? tasks.parseTasks(options.tasksFile) : []; const currentTask = _getCurrentTaskDescription(tasksBefore); + const currentTaskMeta = _getCurrentTaskMeta(tasksBefore); + + reporter.iterationStarted({ + iteration: iterationCount, + taskNumber: currentTaskMeta.number, + taskDescription: currentTaskMeta.description, + }); let result; + let promptSize = null; + let responseSize = { bytes: 0, chars: 0, tokens: 0 }; try { // Build the prompt for this iteration @@ -242,28 +290,42 @@ async function run(opts) { throw err; } - // Widen the feedback window to 5 so the agent sees a longer streak - // when it keeps emitting the same hand-off / no-promise signal — the - // `_failureFingerprint` dedup collapses identical entries into a - // single "same failure as iteration N" line, so more history is - // cheap and actionable. - const errorEntries = errors.readEntries(ralphDir, 5); - const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 5), errorEntries); + // Emit 3 iterations of Recent Loop Signals — the `_failureFingerprint` + // dedup collapses identical entries into a single "same failure as + // iteration N" line, so the 3-entry window is sufficient to surface + // recurring patterns without bloating the prompt. + const errorEntries = errors.readEntries(ralphDir, 3); + const iterationFeedback = _buildIterationFeedback(history.recent(ralphDir, 3), errorEntries); // Inject any pending context const pendingContext = context.consume(ralphDir); + lessons.rotate(ralphDir, 100); + const lessonsSection = lessons.inject(ralphDir, { limit: 15 }); const promptSections = [renderedPrompt]; if (iterationFeedback) { promptSections.push(`## Recent Loop Signals\n\n${iterationFeedback}`); } + if (lessonsSection) { + promptSections.push(lessonsSection); + } + if (pendingContext) { promptSections.push(`## Injected Context\n\n${pendingContext}`); } const finalPrompt = promptSections.join('\n\n'); + // Measure and report prompt size + promptSize = _measureText(finalPrompt); + reporter.iterationPromptReady({ + iteration: iterationCount, + promptBytes: promptSize.bytes, + promptChars: promptSize.chars, + promptTokens: promptSize.tokens, + }); + // Invoke OpenCode try { result = await invoker.invoke({ @@ -273,20 +335,52 @@ async function run(opts) { verbose: options.verbose, ralphDir, }); + // Measure and report response size + const rawOutput = result.rawOutput || result.output || result.stdout || ''; + responseSize = _measureText(rawOutput); + reporter.iterationResponseReceived({ + iteration: iterationCount, + responseBytes: responseSize.bytes, + responseChars: responseSize.chars, + responseTokens: responseSize.tokens, + truncated: result.truncated || false, + }); } catch (err) { err.failureStage = err.failureStage || 'invoke_start'; throw err; } } catch (err) { + const fatalDuration = Date.now() - iterStart; _appendFatalIterationFailure(ralphDir, { iteration: iterationCount, task: currentTask, - duration: Date.now() - iterStart, + duration: fatalDuration, exitCode: null, signal: '', failureStage: _failureStageForError(err), stderr: _errorText(err), stdout: '', + promptBytes: promptSize ? promptSize.bytes : 0, + promptChars: promptSize ? promptSize.chars : 0, + promptTokens: promptSize ? promptSize.tokens : 0, + responseBytes: 0, + responseChars: 0, + responseTokens: 0, + truncated: false, + }); + reporter.iterationFinished({ + iteration: iterationCount, + durationMs: fatalDuration, + outcome: 'failure', + committed: false, + hasCompletion: false, + hasTask: false, + completedTasksCount: 0, + filesChangedCount: 0, + stallStreak, + failureReason: `${_failureStageForError(err)}: ${_firstNonEmptyLine(_errorText(err), 120)}`, + taskNumber: currentTaskMeta.number, + taskDescription: currentTaskMeta.description, }); throw err; } @@ -330,6 +424,7 @@ async function run(opts) { filesToStage: _buildAutoCommitAllowlist(result.filesChanged, completedTasks, options.tasksFile), tasksFile: options.tasksFile, verbose: options.verbose, + reporter, }); } @@ -351,19 +446,27 @@ async function run(opts) { commitAnomaly: commitResult.anomaly ? commitResult.anomaly.message : '', commitAnomalyType: commitResult.anomaly ? commitResult.anomaly.type : '', protectedArtifacts: commitResult.anomaly ? commitResult.anomaly.protectedArtifacts || [] : [], + ...(commitResult.anomaly && commitResult.anomaly.ignoredPaths && commitResult.anomaly.ignoredPaths.length > 0 + ? { ignoredPaths: commitResult.anomaly.ignoredPaths } + : {}), + promptBytes: promptSize ? promptSize.bytes : 0, + promptChars: promptSize ? promptSize.chars : 0, + promptTokens: promptSize ? promptSize.tokens : 0, + responseBytes: responseSize.bytes, + responseChars: responseSize.chars, + responseTokens: responseSize.tokens, + truncated: result.truncated || false, + // Pass through watchdog failure fields when the invoker returns them (task 3.1). + ...(result.failureReason !== undefined ? { failureReason: result.failureReason } : {}), + ...(result.idleMs !== undefined ? { idleMs: result.idleMs } : {}), + ...(result.lastStdoutBytes !== undefined ? { lastStdoutBytes: result.lastStdoutBytes } : {}), + ...(result.lastStderrBytes !== undefined ? { lastStderrBytes: result.lastStderrBytes } : {}), }); - // Check completion condition (must also satisfy minIterations) - if (hasCompletion && iterationCount >= minIterations) { - completed = true; - exitReason = 'completion_promise'; - break; - } - - // Stall detection: track consecutive unproductive iterations and stop - // early so the loop doesn't burn through the full `maxIterations` - // budget on pure no-ops (e.g. agent hitting a quality gate it can't - // fix and asking for hand-off without emitting a promise). + // Stall detection is computed *before* the progress event so the + // reporter can show the live streak alongside the badge. We still + // enforce the stall halt after the event so the operator sees the + // final (stalled) iteration line before the "halting" note. const iterationFailed = _isFailedIteration(result); const stalledThisIteration = _iterationIsStalled({ iterationFailed, @@ -379,7 +482,37 @@ async function run(opts) { stallStreak = 0; } + reporter.iterationFinished({ + iteration: iterationCount, + durationMs: duration, + outcome: iterationFailed + ? 'failure' + : stalledThisIteration + ? 'stalled' + : 'success', + committed: commitResult.committed === true, + hasCompletion, + hasTask, + completedTasksCount: completedTasks.length, + filesChangedCount: Array.isArray(result.filesChanged) ? result.filesChanged.length : 0, + stallStreak, + failureReason: iterationFailed ? _summarizeFailure(result) : '', + taskNumber: currentTaskMeta.number, + taskDescription: currentTaskMeta.description, + }); + + // Check completion condition (must also satisfy minIterations) + if (hasCompletion && iterationCount >= minIterations) { + completed = true; + exitReason = 'completion_promise'; + break; + } + if (stallThreshold > 0 && stallStreak >= stallThreshold) { + reporter.note( + `stall detector: ${stallStreak} consecutive no-op iteration(s); halting.`, + 'warn' + ); if (options.verbose) { process.stderr.write( `[mini-ralph] stall detector: ${stallStreak} consecutive no-op iteration(s); halting.\n` @@ -397,6 +530,8 @@ async function run(opts) { } } catch (err) { exitReason = 'fatal_error'; + reporter.note(`fatal error: ${_firstNonEmptyLine(err && err.message, 120) || 'unknown'}`, 'error'); + reporter.runFinished({ completed: false, exitReason, iterations: iterationCount }); throw err; } @@ -404,6 +539,8 @@ async function run(opts) { _cleanupCompletedErrors(ralphDir, options.verbose); } + reporter.runFinished({ completed, exitReason, iterations: iterationCount }); + return { completed, iterations: iterationCount, exitReason }; } finally { if (stateInitialized) { @@ -476,6 +613,33 @@ function _validateOptions(options) { } } +/** + * Format the loud direct stderr block for auto-commit ignore-filter events. + * Emitted via process.stderr.write (bypassing reporter dedup/buffering) on + * every iteration where paths_ignored_filtered or all_paths_ignored fires. + * (task 5.1 — surface-autocommit-ignore-warning-and-watchdog) + * + * @param {number} iteration + * @param {{ type: string, ignoredPaths: string[] }} anomaly + * @returns {string} + */ +function _formatAutoCommitIgnoreBlock(iteration, anomaly) { + const SEP = '================================================================================\n'; + const pathLines = (anomaly.ignoredPaths || []).map(p => ` - ${p}`).join('\n'); + return ( + SEP + + `⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration ${iteration}, type: ${anomaly.type})\n` + + `Paths filtered because .gitignore matches:\n` + + pathLines + '\n' + + `Consequence: these paths are NOT in the latest commit.\n` + + `Remediation (pick one):\n` + + ` 1. git add -f # one-time unblock, if you want it tracked\n` + + ` 2. edit .gitignore # narrow or remove the matching rule\n` + + ` 3. pass --no-auto-commit on the ralph-run invocation\n` + + SEP + ); +} + /** * Auto-commit changed files after a successful iteration. * Silently skips if git is unavailable, there is nothing to commit, or the @@ -488,7 +652,7 @@ function _validateOptions(options) { * @param {boolean} [opts.verbose] */ function _autoCommit(iteration, opts = {}) { - const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false } = opts; + const { completedTasks = [], filesToStage = [], tasksFile = null, verbose = false, reporter = null } = opts; const message = _formatAutoCommitMessage(iteration, completedTasks); if (!message) { @@ -519,6 +683,47 @@ function _autoCommit(iteration, opts = {}) { return { attempted: true, committed: false, anomaly }; } + const { kept: keptPaths, dropped: droppedPaths } = _filterGitignored(filesToStage, process.cwd()); + + if (droppedPaths.length > 0) { + const pathWord = droppedPaths.length === 1 ? 'path' : 'paths'; + const allIgnored = keptPaths.length === 0; + const warnLines = allIgnored + ? [ + `auto-commit iter ${iteration} skipped: all ${droppedPaths.length} ${pathWord} are gitignored`, + ...droppedPaths.map(p => ` - ${p}`), + ' hint: `git add -f ` once, or adjust .gitignore', + ].join('\n') + : [ + `auto-commit iter ${iteration}: filtered ${droppedPaths.length} gitignored ${pathWord}, committing ${keptPaths.length} ${keptPaths.length === 1 ? 'other' : 'others'}`, + ...droppedPaths.map(p => ` - ${p}`), + ].join('\n'); + if (reporter) { + reporter.note(warnLines, 'error'); + } else { + const fallbackMsg = allIgnored + ? `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}` + : `Auto-commit filtered gitignored paths: ${droppedPaths.join(', ')}`; + process.stderr.write(`[mini-ralph] warning: ${fallbackMsg}\n`); + } + if (allIgnored) { + const anomaly = { + type: 'all_paths_ignored', + message: `Auto-commit skipped: all paths are gitignored: ${droppedPaths.join(', ')}`, + ignoredPaths: droppedPaths, + }; + // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering + process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly)); + return { + attempted: true, + committed: false, + anomaly, + }; + } + } + + const stagePaths = droppedPaths.length > 0 ? keptPaths : filesToStage; + try { // Use `git add -A -- ` (not plain `git add -- `) so deletions // and renames are staged alongside modifications/additions. Tasks that call @@ -526,7 +731,7 @@ function _autoCommit(iteration, opts = {}) { // still present in `git status --porcelain`, which means the plain form // would error with `fatal: pathspec did not match`. Scoping to the per-path // allowlist preserves the protected-artifact guarantee. - childProcess.execFileSync('git', ['add', '-A', '--', ...filesToStage], { + childProcess.execFileSync('git', ['add', '-A', '--', ...stagePaths], { stdio: verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'], encoding: 'utf8', }); @@ -557,6 +762,20 @@ function _autoCommit(iteration, opts = {}) { if (verbose) { process.stderr.write(`[mini-ralph] auto-committed: ${message}\n`); } + if (droppedPaths.length > 0) { + const anomaly = { + type: 'paths_ignored_filtered', + message: 'Auto-commit succeeded but filtered gitignored paths: ' + droppedPaths.join(', '), + ignoredPaths: droppedPaths, + }; + // task 5.1: emit loud direct stderr block, bypassing reporter dedup/buffering + process.stderr.write(_formatAutoCommitIgnoreBlock(iteration, anomaly)); + return { + attempted: true, + committed: true, + anomaly, + }; + } return { attempted: true, committed: true, anomaly: null }; } catch (err) { const anomaly = { @@ -569,6 +788,53 @@ function _autoCommit(iteration, opts = {}) { } } +/** + * Filter gitignored paths out of a list using `git check-ignore --stdin`. + * + * Exit-code semantics of `git check-ignore`: + * 0 – at least one path is ignored; stdout lists the ignored paths. + * 1 – no paths are ignored (Node's execFileSync throws; we catch status===1). + * other / ENOENT / any thrown error – fallback: treat all paths as kept. + * + * @param {string[]} paths - Repo-relative paths to test. + * @param {string} cwd - Working directory for the git command. + * @returns {{ kept: string[], dropped: string[] }} + */ +function _filterGitignored(paths, cwd) { + if (!Array.isArray(paths) || paths.length === 0) { + return { kept: [], dropped: [] }; + } + + try { + const stdout = childProcess.execFileSync( + 'git', + ['check-ignore', '--stdin'], + { + input: paths.join('\n'), + cwd: cwd || process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf8', + } + ); + + // Exit code 0: stdout lists ignored paths (one per line). + const dropped = stdout + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + const droppedSet = new Set(dropped); + const kept = paths.filter((p) => !droppedSet.has(p)); + return { kept, dropped }; + } catch (err) { + // exit status 1 means "no paths ignored" — treat as success with no drops. + if (err && err.status === 1) { + return { kept: paths.slice(), dropped: [] }; + } + // Any other error (ENOENT, unexpected exit code, etc.) — fallback, never crash. + return { kept: paths.slice(), dropped: [] }; + } +} + /** * Build the explicit per-iteration git staging allowlist. * @@ -619,6 +885,11 @@ function _completedTaskDelta(beforeTasks, afterTasks) { /** * Build a task-aware commit message for an iteration. * + * The subject line (first line) is kept short — conventional git tooling + * assumes ~50–72 characters — so `git log --oneline` stays readable even when + * the underlying task description is a multi-sentence normative blob. The + * full, untruncated task descriptions are preserved in the commit body. + * * @param {number} iteration * @param {Array} completedTasks * @returns {string} @@ -628,14 +899,55 @@ function _formatAutoCommitMessage(iteration, completedTasks) { return ''; } - const summary = completedTasks.length === 1 + const rawSummary = completedTasks.length === 1 ? completedTasks[0].description : `complete ${completedTasks.length} tasks`; + + const prefix = `Ralph iteration ${iteration}: `; + const subjectBudget = Math.max(20, SUBJECT_MAX_LENGTH - prefix.length); + const summary = _truncateSubjectSummary(rawSummary, subjectBudget); + const taskLines = completedTasks.map( (task) => `- [x] ${task.fullDescription || task.description}` ); - return `Ralph iteration ${iteration}: ${summary}\n\nTasks completed:\n${taskLines.join('\n')}`; + return `${prefix}${summary}\n\nTasks completed:\n${taskLines.join('\n')}`; +} + +const SUBJECT_MAX_LENGTH = 72; + +/** + * Reduce a task description to a short, single-line commit subject. + * + * Strategy: + * 1. Collapse whitespace onto a single line. + * 2. Prefer the first sentence (up to `.`, `!`, `?`) when it is not itself + * longer than the allowed budget. + * 3. Otherwise hard-truncate at a word boundary and append an ellipsis. + * + * @param {string} text + * @param {number} budget + * @returns {string} + */ +function _truncateSubjectSummary(text, budget) { + const oneLine = String(text == null ? '' : text).replace(/\s+/g, ' ').trim(); + if (oneLine.length === 0) return ''; + if (oneLine.length <= budget) return oneLine; + + const sentenceMatch = oneLine.match(/^(.+?[.!?])(\s|$)/); + if (sentenceMatch) { + const candidate = sentenceMatch[1].trim(); + if (candidate.length > 0 && candidate.length <= budget) { + return candidate; + } + } + + const ellipsis = '…'; + const hardBudget = Math.max(1, budget - ellipsis.length); + const sliced = oneLine.slice(0, hardBudget); + const lastSpace = sliced.lastIndexOf(' '); + const cut = lastSpace > Math.floor(hardBudget / 2) ? sliced.slice(0, lastSpace) : sliced; + return `${cut.replace(/[\s,;:.!?-]+$/, '')}${ellipsis}`; } /** @@ -726,16 +1038,60 @@ function _buildIterationFeedback(recentHistory, errorEntries) { const fp = _failureFingerprint(entry, errorEntries); const isRealFailure = !_isEmptyFingerprint(fp); - if (isRealFailure && fingerprintSeen.has(fp)) { + // paths_ignored_filtered and all_paths_ignored are exempt from dedup: + // every occurrence must produce its own distinct line so the agent + // sees the full per-iteration history of gitignore filtering events. + const isIgnoreFilterAnomaly = + entry.commitAnomalyType === 'paths_ignored_filtered' || + entry.commitAnomalyType === 'all_paths_ignored'; + + if (isRealFailure && fingerprintSeen.has(fp) && !isIgnoreFilterAnomaly) { const firstIteration = fingerprintSeen.get(fp); problemLines.push( `- Iteration ${entry.iteration}: same failure as iteration ${firstIteration} (see above).` ); } else { - if (isRealFailure) fingerprintSeen.set(fp, entry.iteration); + if (isRealFailure && !isIgnoreFilterAnomaly) fingerprintSeen.set(fp, entry.iteration); let line = `- Iteration ${entry.iteration}: ${issues.join('; ')}.`; + // For paths_ignored_filtered / all_paths_ignored, append the first two + // ignored paths inline (with a (+N more) suffix) so the agent can see + // the exact files without diving into history. This replaces the + // generic commit-anomaly text with a richer per-iteration signal. + if (isIgnoreFilterAnomaly && Array.isArray(entry.ignoredPaths) && entry.ignoredPaths.length > 0) { + const paths = entry.ignoredPaths; + const shown = paths.slice(0, 2); + const remaining = paths.length - shown.length; + const pathStr = shown.join(', ') + (remaining > 0 ? ` (+${remaining} more)` : ''); + line += ` Ignored paths: ${pathStr}.`; + } + + // When the only issue is "no loop promise emitted" (no signal, no + // failureStage, exitCode 0, no commit anomaly), append a compact + // suffix with tool-usage and duration to give the agent more context. + const isNoPromiseOnly = + issues.length === 1 && + issues[0] === 'no loop promise emitted' && + !entry.signal && + !entry.failureStage && + entry.exitCode === 0 && + !entry.commitAnomaly; + + if (isNoPromiseOnly) { + const toolParts = Array.isArray(entry.toolUsage) && entry.toolUsage.length > 0 + ? entry.toolUsage.map(t => `${t.tool}\u00d7${t.count}`).join(', ') + : null; + const durationMs = entry.duration != null ? entry.duration : 0; + const durationStr = durationMs > 0 ? progress._formatDuration(durationMs) : null; + if (toolParts || durationStr) { + const suffixParts = []; + if (toolParts) suffixParts.push(`Tools used: ${toolParts}.`); + if (durationStr) suffixParts.push(`Duration: ${durationStr}.`); + line += ` ${suffixParts.join(' ')}`; + } + } + if (_isFailedIteration(entry) && errorEntries) { const errorDetails = _extractErrorForIteration(errorEntries, entry.iteration); if (errorDetails) { @@ -797,6 +1153,55 @@ function _getCurrentTaskDescription(tasksBefore) { return 'N/A'; } +/** + * Return the structured { number, description } of the current task for the + * progress reporter. Unlike `_getCurrentTaskDescription` this preserves the + * task number separately so the status line can render "task 4.7" even when + * the description is later truncated. + * + * @param {Array} tasksBefore + * @returns {{ number: string, description: string }} + */ +/** + * Produce a short one-line reason for a failed iteration, suitable for the + * progress reporter. Prefers the stderr first line; falls back to the exit + * code / signal / failure stage so the operator always sees *something*. + * + * @param {object} result + * @returns {string} + */ +function _summarizeFailure(result) { + if (!result || typeof result !== 'object') return 'unknown failure'; + + const stderrHead = _firstNonEmptyLine(result.stderr, 120); + if (stderrHead) return stderrHead; + + const parts = []; + if (result.failureStage) parts.push(result.failureStage); + if (result.signal) parts.push(`signal=${result.signal}`); + if ( + typeof result.exitCode === 'number' && + result.exitCode !== 0 + ) { + parts.push(`exit=${result.exitCode}`); + } + return parts.length > 0 ? parts.join(' ') : 'iteration failed'; +} + +function _getCurrentTaskMeta(tasksBefore) { + if (!Array.isArray(tasksBefore) || tasksBefore.length === 0) { + return { number: '', description: '' }; + } + const incomplete = tasksBefore.find( + (task) => task && task.status !== 'completed' + ); + if (!incomplete) return { number: '', description: '' }; + return { + number: incomplete.number ? String(incomplete.number) : '', + description: incomplete.description || incomplete.fullDescription || '', + }; +} + function _cleanupCompletedErrors(ralphDir, verbose) { let archivePath = null; @@ -932,12 +1337,16 @@ module.exports = { _validateOptions, _autoCommit, _buildAutoCommitAllowlist, + _filterGitignored, _resolveStartIteration, _completedTaskDelta, _formatAutoCommitMessage, + _truncateSubjectSummary, _buildIterationFeedback, _extractErrorForIteration, _getCurrentTaskDescription, + _getCurrentTaskMeta, + _summarizeFailure, _cleanupCompletedErrors, _detectProtectedCommitArtifacts, _gitErrorMessage, diff --git a/scripts/mini-ralph-cli.js b/scripts/mini-ralph-cli.js index 5c50aaa..0b48497 100755 --- a/scripts/mini-ralph-cli.js +++ b/scripts/mini-ralph-cli.js @@ -25,6 +25,7 @@ * --no-commit Suppress auto-commit * --model Optional model override * --verbose Verbose output + * --quiet Suppress the per-iteration progress stream * --status Print status dashboard and exit * --add-context Add pending context and exit * --clear-context Clear pending context and exit @@ -55,6 +56,7 @@ function parseArgs(argv) { noCommit: false, model: '', verbose: false, + quiet: false, // Control commands status: false, addContext: null, @@ -108,6 +110,9 @@ function parseArgs(argv) { case '--verbose': opts.verbose = true; break; + case '--quiet': + opts.quiet = true; + break; case '--status': opts.status = true; break; @@ -152,6 +157,7 @@ Options: --no-commit Suppress auto-commit --model Model override --verbose Verbose output + --quiet Suppress the per-iteration progress stream --status Print status dashboard and exit --add-context Add pending context and exit --clear-context Clear pending context and exit @@ -209,6 +215,7 @@ async function main() { noCommit: opts.noCommit, model: opts.model, verbose: opts.verbose, + quiet: opts.quiet, }; try { diff --git a/scripts/ralph-run.sh b/scripts/ralph-run.sh index 4c5e9eb..c0bf3e1 100755 --- a/scripts/ralph-run.sh +++ b/scripts/ralph-run.sh @@ -148,6 +148,7 @@ handle_error() { fi } VERBOSE=false +QUIET=false SHOW_HELP=false usage() { @@ -162,6 +163,7 @@ OPTIONS: --max-iterations Maximum iterations for Ralph loop (default: 50) --no-commit Suppress automatic git commits during the loop --verbose, -v Enable verbose mode for debugging + --quiet Suppress the per-iteration progress stream --help, -h Show this help message OBSERVABILITY AND CONTROL: @@ -206,6 +208,10 @@ parse_arguments() { VERBOSE=true shift ;; + --quiet) + QUIET=true + shift + ;; --status) SHOW_STATUS=true shift @@ -758,7 +764,7 @@ Before implementing, read the OpenSpec artifacts listed above that are relevant Follow this loop contract EXACTLY. Do not skip steps. Do not batch. Do not output a promise until every step is done. -1. Open `tasks.md` (at `{{change_dir}}/tasks.md`) and find the FIRST line matching `- [ ] ` or `- [/] `. Remember its exact text. +1. Work on the task shown in `## Fresh Task Context` above. Before editing any marker, open `tasks.md` at `{{change_dir}}/tasks.md` and verify that same task is still `- [ ] ` or `- [/] ` on disk (it may have been closed by a prior iteration if you are resuming). 2. Edit `tasks.md` in place to change that line's marker to `- [/] ` (in-progress). You MUST use your file edit tool to modify the file on disk — a shell `cp`, `sed`, or print-to-stdout does not count. Verify by re-reading the file. 3. Implement the smallest change that fully satisfies the task's Done-when conditions. Run the task's verification command if one is specified. 4. On success, edit `tasks.md` again in place to change that line's marker from `- [/] ` to `- [x] `. Verify by re-reading the file and confirming the `[x]` is present on that exact line. @@ -775,8 +781,6 @@ Hard rules: ## Commit Contract {{commit_contract}} - -{{context}} EOF # Determine repo root for AGENTS.md probe @@ -1016,6 +1020,10 @@ execute_ralph_loop() { mini_ralph_args+=("--verbose") fi + if [[ "$QUIET" == true ]]; then + mini_ralph_args+=("--quiet") + fi + # Run the internal mini Ralph CLI and capture output { node "$MINI_RALPH_CLI" "${mini_ralph_args[@]}" diff --git a/tests/helpers/test-functions.sh b/tests/helpers/test-functions.sh index b5f4807..1592c5f 100644 --- a/tests/helpers/test-functions.sh +++ b/tests/helpers/test-functions.sh @@ -730,13 +730,25 @@ Change directory: {{change_dir}} Before implementing, read the OpenSpec artifacts listed above that are relevant to the current task. -Pick the first [ ] or [/] task in tasks.md, mark it [/], implement it (smallest change that fully satisfies the Done-when conditions), run the task's verification command, mark it [x] on success, then output `{{task_promise}}`. Output `{{completion_promise}}` only when every task is [x]. Output promise tags on their own line, literal; do not quote or describe them. Do not fabricate a promise to exit the loop. If an approach fails twice, try a different one. +Follow this loop contract EXACTLY. Do not skip steps. Do not batch. Do not output a promise until every step is done. + +1. Work on the task shown in `## Fresh Task Context` above. Before editing any marker, open `tasks.md` at `{{change_dir}}/tasks.md` and verify that same task is still `- [ ] ` or `- [/] ` on disk (it may have been closed by a prior iteration if you are resuming). +2. Edit `tasks.md` in place to change that line's marker to `- [/] ` (in-progress). You MUST use your file edit tool to modify the file on disk — a shell `cp`, `sed`, or print-to-stdout does not count. Verify by re-reading the file. +3. Implement the smallest change that fully satisfies the task's Done-when conditions. Run the task's verification command if one is specified. +4. On success, edit `tasks.md` again in place to change that line's marker from `- [/] ` to `- [x] `. Verify by re-reading the file and confirming the `[x]` is present on that exact line. +5. ONLY after step 4 writes `[x]` to disk, output `{{task_promise}}` on its own line. +6. If and only if EVERY task line in `tasks.md` is `- [x] `, output `{{completion_promise}}` instead. + +Hard rules: +- If you do not actually modify `tasks.md` on disk in this iteration, DO NOT output any promise tag. Output a short failure note instead and stop. +- Never output `{{task_promise}}` while the task you just worked on is still `- [ ]` on disk. That causes the same task to repeat forever. +- Promise tags must be on their own line, literal, unquoted, and not described in prose. +- If an approach fails twice, try a different one. +- If the task is already satisfied by prior work (e.g. target file already exists with the right content), you STILL must flip the checkbox to `[x]` in `tasks.md` before emitting the promise. ## Commit Contract {{commit_contract}} - -{{context}} EOF # Determine repo root for AGENTS.md probe diff --git a/tests/unit/bash/test-create-prompt-template.bats b/tests/unit/bash/test-create-prompt-template.bats index 7cf022b..a73d7dc 100644 --- a/tests/unit/bash/test-create-prompt-template.bats +++ b/tests/unit/bash/test-create-prompt-template.bats @@ -95,7 +95,7 @@ teardown() { rm -rf "$test_dir" } -@test "create_prompt_template: includes context placeholder" { +@test "create_prompt_template: does not include context placeholder" { local test_dir test_dir=$(setup_test_dir) cd "$test_dir" || return 1 @@ -109,7 +109,7 @@ teardown() { [ "$status" -eq 0 ] - grep -q "{{context}}" "$template_file" + ! grep -q "{{context}}" "$template_file" cd - > /dev/null rm -rf "$test_dir" @@ -594,7 +594,7 @@ teardown() { rm -rf "$test_dir" } -@test "create_prompt_template: explicit-read sentence precedes task-selection in Instructions section" { +@test "create_prompt_template: step 1 references Fresh Task Context and requires on-disk tasks.md verify (D4)" { local test_dir test_dir=$(setup_test_dir) # Stay in project root so git rev-parse works @@ -609,38 +609,23 @@ teardown() { [ "$status" -eq 0 ] - # 1. Sentence appears exactly once - local sentence="Before implementing, read the OpenSpec artifacts listed above that are relevant to the current task." + # 1. Old step-1 wording must NOT appear + local old_count + old_count=$(grep -c "find the FIRST line matching" "$template_file" || true) + [ "$old_count" -eq 0 ] + + # 2. Step 1 must reference "Fresh Task Context" somewhere in the template (D4 rewrite) + grep -q "Fresh Task Context" "$template_file" + + # 3. The template must contain both "tasks.md" and "verify" within the Instructions section + # (they appear on step 1 line which references the on-disk verify requirement) + grep -q "tasks\.md" "$template_file" + grep -q "verify" "$template_file" + + # 4. The "Before implementing, read the OpenSpec artifacts" sentence still appears local count - count=$(grep -c "$sentence" "$template_file" || true) + count=$(grep -c "Before implementing, read the OpenSpec artifacts listed above" "$template_file" || true) [ "$count" -eq 1 ] - # 2. Sentence line number is less than the first "Pick the first" line number - local read_line pick_line - read_line=$(grep -n "$sentence" "$template_file" | head -n1 | cut -d: -f1) - pick_line=$(grep -n "Pick the first" "$template_file" | head -n1 | cut -d: -f1) - [ -n "$read_line" ] - [ -n "$pick_line" ] - [ "$read_line" -lt "$pick_line" ] - - # 3. Sentence appears inside the ## Instructions section - # (after a line matching "^## Instructions" and before the next "^## " header) - local in_instructions=false - local found_sentence=false - while IFS= read -r line; do - if [[ "$line" =~ ^##[[:space:]]Instructions ]]; then - in_instructions=true - continue - fi - if [[ "$in_instructions" == "true" && "$line" =~ ^##[[:space:]] ]]; then - break - fi - if [[ "$in_instructions" == "true" && "$line" == *"$sentence"* ]]; then - found_sentence=true - break - fi - done < "$template_file" - [ "$found_sentence" = "true" ] - rm -rf "$test_dir" } diff --git a/tests/unit/javascript/mini-ralph-invoker.test.js b/tests/unit/javascript/mini-ralph-invoker.test.js index 7c3e001..6b7b763 100644 --- a/tests/unit/javascript/mini-ralph-invoker.test.js +++ b/tests/unit/javascript/mini-ralph-invoker.test.js @@ -252,4 +252,107 @@ describe('mini-ralph invoker', () => { cwdSpy.mockRestore(); } }); + + // --- Watchdog tests (task 2.1: surface-autocommit-ignore-warning-and-watchdog) --- + + test('watchdog fires SIGTERM after idle threshold and result has iteration_timeout_idle', async () => { + const TEST_IDLE_MS = 100; + const originalIdleEnv = process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS; + const originalGraceEnv = process.env.RALPH_ITERATION_KILL_GRACE_MS; + process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS = String(TEST_IDLE_MS); + process.env.RALPH_ITERATION_KILL_GRACE_MS = '5000'; + + // Build a child that writes one chunk then goes silent + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = jest.fn((sig) => { + // Simulate the child exiting when it receives SIGTERM + if (sig === 'SIGTERM') { + setTimeout(() => child.emit('close', null, 'SIGTERM'), 10); + } + }); + spawn.mockReturnValue(child); + + execFileSync.mockReturnValue(''); + + const pending = invoker.invoke({ prompt: 'Do the work.' }); + + // Emit one chunk, then go silent — the idle timer should fire after TEST_IDLE_MS + child.stdout.emit('data', Buffer.from('some output')); + + const result = await pending; + + // Restore env + if (originalIdleEnv === undefined) delete process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS; + else process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS = originalIdleEnv; + if (originalGraceEnv === undefined) delete process.env.RALPH_ITERATION_KILL_GRACE_MS; + else process.env.RALPH_ITERATION_KILL_GRACE_MS = originalGraceEnv; + + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + expect(result.failureReason).toBe('iteration_timeout_idle'); + expect(typeof result.idleMs).toBe('number'); + expect(result.lastStdoutBytes.length).toBeLessThanOrEqual(200); + }, 10000); + + test('watchdog disabled when RALPH_ITERATION_IDLE_TIMEOUT_MS=0, silent child is not killed', async () => { + const originalIdleEnv = process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS; + process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS = '0'; + + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = jest.fn(); + spawn.mockReturnValue(child); + + execFileSync.mockReturnValue(''); + + const pending = invoker.invoke({ prompt: 'Do the work.' }); + + // Let 150ms pass with no output — watchdog should NOT fire because it's disabled + await new Promise((res) => setTimeout(res, 150)); + // Then close the child normally + child.emit('close', 0, null); + + const result = await pending; + + if (originalIdleEnv === undefined) delete process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS; + else process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS = originalIdleEnv; + + expect(child.kill).not.toHaveBeenCalled(); + expect(result.failureReason).toBeUndefined(); + }, 10000); + + test('child that keeps writing within idle window completes normally without iteration_timeout_idle', async () => { + const TEST_IDLE_MS = 200; + const originalIdleEnv = process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS; + process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS = String(TEST_IDLE_MS); + + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = jest.fn(); + spawn.mockReturnValue(child); + + execFileSync.mockReturnValue(''); + + const pending = invoker.invoke({ prompt: 'Do the work.' }); + + // Write output every 80ms for 3 times (well within 200ms idle threshold each time) + for (let i = 0; i < 3; i++) { + await new Promise((res) => setTimeout(res, 80)); + child.stdout.emit('data', Buffer.from(`chunk ${i}`)); + } + // Now close normally + child.emit('close', 0, null); + + const result = await pending; + + if (originalIdleEnv === undefined) delete process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS; + else process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS = originalIdleEnv; + + expect(child.kill).not.toHaveBeenCalled(); + expect(result.failureReason).toBeUndefined(); + expect(result.exitCode).toBe(0); + }, 10000); }); diff --git a/tests/unit/javascript/mini-ralph-lessons.test.js b/tests/unit/javascript/mini-ralph-lessons.test.js new file mode 100644 index 0000000..be37387 --- /dev/null +++ b/tests/unit/javascript/mini-ralph-lessons.test.js @@ -0,0 +1,203 @@ +'use strict'; + +/** + * Unit tests for lib/mini-ralph/lessons.js + * + * Covers: missing file, under-cap file, bullet truncation, inject limit, + * rotate trimming, and inject determinism. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const lessons = require('../../../lib/mini-ralph/lessons'); + +let tmpDir; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-lessons-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// path() +// --------------------------------------------------------------------------- +describe('path()', () => { + test('returns the absolute path to LESSONS.md under ralphDir', () => { + const result = lessons.path(tmpDir); + expect(result).toBe(path.join(tmpDir, 'LESSONS.md')); + }); +}); + +// --------------------------------------------------------------------------- +// read() – missing file +// --------------------------------------------------------------------------- +describe('read() – missing file', () => { + test('returns empty array when LESSONS.md does not exist', () => { + const result = lessons.read(tmpDir); + expect(result).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// read() – under-cap file +// --------------------------------------------------------------------------- +describe('read() – under-cap file', () => { + test('returns bullets unchanged when all are <= 120 chars', () => { + const bullet1 = '- Short bullet one'; + const bullet2 = '- Another short bullet'; + fs.writeFileSync(lessons.path(tmpDir), bullet1 + '\n' + bullet2 + '\n', 'utf8'); + + const result = lessons.read(tmpDir); + expect(result).toEqual([bullet1, bullet2]); + }); + + test('strips blank lines', () => { + fs.writeFileSync(lessons.path(tmpDir), '\n- Bullet A\n\n- Bullet B\n\n', 'utf8'); + const result = lessons.read(tmpDir); + expect(result).toEqual(['- Bullet A', '- Bullet B']); + }); +}); + +// --------------------------------------------------------------------------- +// read() – bullet > 120 chars truncation +// --------------------------------------------------------------------------- +describe('read() – truncation', () => { + test('truncates bullets longer than 120 chars and prefixes with runner-truncated:', () => { + const longBullet = '- ' + 'x'.repeat(130); // 132 chars total + fs.writeFileSync(lessons.path(tmpDir), longBullet + '\n', 'utf8'); + + const result = lessons.read(tmpDir); + expect(result).toHaveLength(1); + expect(result[0]).toMatch(/^runner-truncated:/); + // The original bullet was 132 chars; after truncation to 120 it should end with x's + expect(result[0]).toHaveLength('runner-truncated:'.length + 120); + }); + + test('does not truncate a bullet of exactly 120 chars', () => { + const exactBullet = 'x'.repeat(120); + fs.writeFileSync(lessons.path(tmpDir), exactBullet + '\n', 'utf8'); + + const result = lessons.read(tmpDir); + expect(result).toEqual([exactBullet]); + }); +}); + +// --------------------------------------------------------------------------- +// inject() – missing file → empty string +// --------------------------------------------------------------------------- +describe('inject() – missing file', () => { + test('returns empty string when LESSONS.md does not exist', () => { + const result = lessons.inject(tmpDir); + expect(result).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// inject() – over-15 bullets → only last 15 injected +// --------------------------------------------------------------------------- +describe('inject() – over-15 bullets', () => { + test('injects only the last 15 bullets when file has more than 15', () => { + const allBullets = Array.from({ length: 60 }, (_, i) => `- Bullet ${i + 1}`); + fs.writeFileSync(lessons.path(tmpDir), allBullets.join('\n') + '\n', 'utf8'); + + const result = lessons.inject(tmpDir); + expect(result).toMatch(/^## Lessons Learned\n\n/); + + // The last bullet should be present + expect(result).toContain('- Bullet 60'); + // The 45th bullet (first one to be dropped) should NOT be present + expect(result).not.toContain('- Bullet 45'); + // Exactly 15 bullets in the output + const bulletLines = result.split('\n').filter(l => l.startsWith('- Bullet')); + expect(bulletLines).toHaveLength(15); + }); + + test('respects a custom limit option', () => { + const allBullets = Array.from({ length: 20 }, (_, i) => `- B${i + 1}`); + fs.writeFileSync(lessons.path(tmpDir), allBullets.join('\n') + '\n', 'utf8'); + + const result = lessons.inject(tmpDir, { limit: 5 }); + const bulletLines = result.split('\n').filter(l => l.startsWith('- B')); + expect(bulletLines).toHaveLength(5); + expect(result).toContain('- B20'); + expect(result).not.toContain('- B15'); + }); +}); + +// --------------------------------------------------------------------------- +// inject() – determinism +// --------------------------------------------------------------------------- +describe('inject() – determinism', () => { + test('returns identical output on two consecutive calls with identical file', () => { + const bullets = ['- Alpha', '- Beta', '- Gamma']; + fs.writeFileSync(lessons.path(tmpDir), bullets.join('\n') + '\n', 'utf8'); + + const first = lessons.inject(tmpDir); + const second = lessons.inject(tmpDir); + expect(first).toBe(second); + }); +}); + +// --------------------------------------------------------------------------- +// rotate() – missing file +// --------------------------------------------------------------------------- +describe('rotate() – missing file', () => { + test('returns 0 when LESSONS.md does not exist', () => { + const dropped = lessons.rotate(tmpDir, 100); + expect(dropped).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// rotate() – under-cap file +// --------------------------------------------------------------------------- +describe('rotate() – under-cap file', () => { + test('returns 0 and does not write when bullet count is within cap', () => { + const bullets = Array.from({ length: 10 }, (_, i) => `- Lesson ${i + 1}`); + fs.writeFileSync(lessons.path(tmpDir), bullets.join('\n') + '\n', 'utf8'); + const mtimeBefore = fs.statSync(lessons.path(tmpDir)).mtimeMs; + + const dropped = lessons.rotate(tmpDir, 100); + expect(dropped).toBe(0); + + const mtimeAfter = fs.statSync(lessons.path(tmpDir)).mtimeMs; + expect(mtimeAfter).toBe(mtimeBefore); + }); +}); + +// --------------------------------------------------------------------------- +// rotate() – over-100 bullets → trims to 100 and returns dropped count +// --------------------------------------------------------------------------- +describe('rotate() – over-cap file', () => { + test('trims to max bullets and returns the number of bullets dropped', () => { + const allBullets = Array.from({ length: 120 }, (_, i) => `- Lesson ${i + 1}`); + fs.writeFileSync(lessons.path(tmpDir), allBullets.join('\n') + '\n', 'utf8'); + + const dropped = lessons.rotate(tmpDir, 100); + expect(dropped).toBe(20); + + // Re-read the file and verify only 100 bullets remain + const remaining = lessons.read(tmpDir); + expect(remaining).toHaveLength(100); + // Last bullet kept should be the original last one + expect(remaining[remaining.length - 1]).toBe('- Lesson 120'); + // First bullet kept should be the 21st + expect(remaining[0]).toBe('- Lesson 21'); + }); + + test('rotate with max=100 on a 101-bullet file drops exactly 1', () => { + const bullets = Array.from({ length: 101 }, (_, i) => `- X${i}`); + fs.writeFileSync(lessons.path(tmpDir), bullets.join('\n') + '\n', 'utf8'); + + const dropped = lessons.rotate(tmpDir, 100); + expect(dropped).toBe(1); + + const remaining = lessons.read(tmpDir); + expect(remaining).toHaveLength(100); + }); +}); diff --git a/tests/unit/javascript/mini-ralph-progress.test.js b/tests/unit/javascript/mini-ralph-progress.test.js new file mode 100644 index 0000000..e7fabb7 --- /dev/null +++ b/tests/unit/javascript/mini-ralph-progress.test.js @@ -0,0 +1,299 @@ +'use strict'; + +/** + * Unit tests for lib/mini-ralph/progress.js + * + * Covers the pure formatting helpers and the reporter's event-driven output. + * Uses an in-memory stream so assertions can inspect exactly what would be + * written to stderr, and uses NO_COLOR to keep the formatted output stable. + */ + +const progress = require('../../../lib/mini-ralph/progress'); + +/** + * Build a minimal writable stream that buffers every write for inspection. + */ +function makeBuffer() { + const chunks = []; + return { + isTTY: false, + write(chunk) { + chunks.push(String(chunk)); + return true; + }, + lines() { + return chunks.join('').split('\n').filter(Boolean); + }, + text() { + return chunks.join(''); + }, + }; +} + +const savedNoColor = process.env.NO_COLOR; +const savedForceColor = process.env.FORCE_COLOR; + +beforeEach(() => { + process.env.NO_COLOR = '1'; + delete process.env.FORCE_COLOR; +}); + +afterEach(() => { + if (savedNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = savedNoColor; + if (savedForceColor === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = savedForceColor; +}); + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +describe('progress helpers', () => { + test('_formatDuration spans ms / s / m / h correctly', () => { + expect(progress._formatDuration(0)).toBe('0ms'); + expect(progress._formatDuration(999)).toBe('999ms'); + expect(progress._formatDuration(1500)).toBe('1.5s'); + expect(progress._formatDuration(12345)).toBe('12s'); + expect(progress._formatDuration(95000)).toBe('1m 35s'); + expect(progress._formatDuration(60 * 60 * 1000 + 120 * 1000)).toBe('1h 02m'); + }); + + test('_formatDuration coerces invalid input to 0ms', () => { + expect(progress._formatDuration(null)).toBe('0ms'); + expect(progress._formatDuration(undefined)).toBe('0ms'); + expect(progress._formatDuration('not a number')).toBe('0ms'); + expect(progress._formatDuration(-500)).toBe('0ms'); + }); + + test('_truncate shortens and adds a single ellipsis', () => { + expect(progress._truncate('short', 20)).toBe('short'); + expect(progress._truncate('a'.repeat(30), 10)).toBe(`${'a'.repeat(9)}…`); + expect(progress._truncate('', 10)).toBe(''); + expect(progress._truncate(null, 10)).toBe(''); + }); + + test('_collapse flattens internal whitespace', () => { + expect(progress._collapse('foo bar\n\nbaz')).toBe('foo bar baz'); + expect(progress._collapse(' already ')).toBe('already'); + }); + + test('_detectColor respects NO_COLOR and TTY', () => { + const noTty = { isTTY: false }; + const tty = { isTTY: true }; + process.env.NO_COLOR = '1'; + expect(progress._detectColor(tty)).toBe(false); + delete process.env.NO_COLOR; + expect(progress._detectColor(noTty)).toBe(false); + expect(progress._detectColor(tty)).toBe(true); + }); + + test('_average handles zero iterations', () => { + expect(progress._average({ iterations: 0, cumulativeMs: 999 })).toBe(0); + expect(progress._average({ iterations: 4, cumulativeMs: 1000 })).toBe(250); + }); +}); + +// --------------------------------------------------------------------------- +// Reporter behavior +// --------------------------------------------------------------------------- + +describe('progress.create()', () => { + test('emits a run header, per-iteration lines, and a run summary', () => { + const buf = makeBuffer(); + let fakeNow = 1_000_000; + const reporter = progress.create({ + stream: buf, + maxIterations: 5, + color: false, + now: () => fakeNow, + }); + + reporter.runStarted({ tasksMode: true, model: 'composer-2-fast' }); + fakeNow += 1200; + reporter.iterationStarted({ + iteration: 1, + taskNumber: '1.1', + taskDescription: 'Seed the harness', + }); + fakeNow += 300; + reporter.iterationFinished({ + iteration: 1, + durationMs: 1500, + outcome: 'success', + committed: true, + hasTask: true, + completedTasksCount: 1, + filesChangedCount: 3, + }); + fakeNow += 800; + reporter.iterationStarted({ + iteration: 2, + taskNumber: '1.2', + taskDescription: 'Run the probe', + }); + fakeNow += 200; + reporter.iterationFinished({ + iteration: 2, + durationMs: 800, + outcome: 'failure', + failureReason: 'exit=1', + stallStreak: 0, + }); + fakeNow += 500; + reporter.runFinished({ completed: false, exitReason: 'max_iterations' }); + + const lines = buf.lines(); + + expect(lines[0]).toMatch(/run started/); + expect(lines[0]).toMatch(/mode=tasks/); + expect(lines[0]).toMatch(/model=composer-2-fast/); + expect(lines[0]).toMatch(/cap=5/); + + expect(lines).toEqual( + expect.arrayContaining([ + expect.stringMatching(/▶ iter 1\/5.*task 1\.1 Seed the harness/), + expect.stringMatching(/✓ ok.*iter 1\/5.*committed.*next-task.*files\+=3.*tasks\+=1.*ok=1 fail=0/), + expect.stringMatching(/▶ iter 2\/5.*task 1\.2 Run the probe/), + expect.stringMatching(/✖ fail.*iter 2\/5.*exit=1.*ok=1 fail=1/), + expect.stringMatching(/✗ run ended.*reason=max_iterations.*iterations=2.*ok=1.*fail=1.*tasks=1/), + ]) + ); + }); + + test('tracks stalled iterations separately from successes and failures', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, color: false, maxIterations: 3 }); + + reporter.iterationFinished({ iteration: 1, durationMs: 1000, outcome: 'stalled', stallStreak: 1 }); + reporter.iterationFinished({ iteration: 2, durationMs: 1000, outcome: 'stalled', stallStreak: 2 }); + reporter.iterationFinished({ iteration: 3, durationMs: 1000, outcome: 'success' }); + + const snap = reporter.snapshot(); + expect(snap.iterations).toBe(3); + expect(snap.stalled).toBe(2); + expect(snap.successes).toBe(1); + expect(snap.failures).toBe(0); + expect(snap.averageMs).toBe(1000); + + const lines = buf.lines(); + expect(lines[0]).toMatch(/∅ stall.*stall-streak=1/); + expect(lines[1]).toMatch(/∅ stall.*stall-streak=2/); + expect(lines[2]).toMatch(/✓ ok/); + }); + + test('note() prefixes with the expected glyph per level', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, color: false }); + + reporter.note('regular info'); + reporter.note('heads up', 'warn'); + reporter.note('uh oh', 'error'); + + const lines = buf.lines(); + expect(lines[0]).toMatch(/• regular info/); + expect(lines[1]).toMatch(/! heads up/); + expect(lines[2]).toMatch(/✖ uh oh/); + }); + + test('enabled=false silences every method', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, enabled: false, color: false }); + + reporter.runStarted({}); + reporter.iterationStarted({ iteration: 1 }); + reporter.iterationFinished({ iteration: 1, durationMs: 10, outcome: 'success' }); + reporter.note('hidden'); + reporter.runFinished({ completed: true, exitReason: 'completion_promise' }); + + expect(buf.text()).toBe(''); + expect(reporter.snapshot().iterations).toBe(1); + }); + + test('FORCE_COLOR emits ANSI escape codes even without a TTY', () => { + process.env.FORCE_COLOR = '1'; + delete process.env.NO_COLOR; + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, maxIterations: 1 }); + reporter.iterationFinished({ iteration: 1, durationMs: 5, outcome: 'success' }); + expect(buf.text()).toMatch(/\u001b\[/); + }); + + test('counters increment even for malformed numeric input', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, color: false }); + reporter.iterationFinished({ + iteration: 'x', + durationMs: 'nope', + outcome: 'success', + completedTasksCount: 'zzz', + filesChangedCount: null, + }); + const snap = reporter.snapshot(); + expect(snap.iterations).toBe(1); + expect(snap.cumulativeMs).toBe(0); + expect(snap.completedTasks).toBe(0); + }); + + // ------------------------------------------------------------------------- + // iterationPromptReady and iterationResponseReceived + // ------------------------------------------------------------------------- + + test('iterationPromptReady and iterationResponseReceived exist on the reporter', () => { + const reporter = progress.create({ stream: makeBuffer(), color: false }); + expect(typeof reporter.iterationPromptReady).toBe('function'); + expect(typeof reporter.iterationResponseReceived).toBe('function'); + }); + + test('iterationPromptReady emits a line containing prompt= and a unit suffix for a 180 KB prompt', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, color: false }); + const promptBytes = 180 * 1024; // 184320 bytes → should format as KB + reporter.iterationPromptReady({ + iteration: 3, + promptBytes, + promptChars: promptBytes, + promptTokens: Math.round(promptBytes / 4), + }); + const line = buf.lines()[0]; + expect(line).toMatch(/prompt=/); + expect(line).toMatch(/KB|MB/); + }); + + test('iterationResponseReceived emits a line containing response= without TRUNCATED when truncated is false', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, color: false }); + reporter.iterationResponseReceived({ + iteration: 1, + responseBytes: 4096, + responseChars: 4096, + responseTokens: 1024, + truncated: false, + }); + const line = buf.lines()[0]; + expect(line).toMatch(/response=/); + expect(line).not.toMatch(/TRUNCATED/); + }); + + test('iterationResponseReceived prints TRUNCATED marker when truncated is true', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, color: false }); + reporter.iterationResponseReceived({ + iteration: 2, + responseBytes: 8192, + responseChars: 8192, + responseTokens: 2048, + truncated: true, + }); + const line = buf.lines()[0]; + expect(line).toMatch(/TRUNCATED/); + }); + + test('enabled=false silences iterationPromptReady and iterationResponseReceived', () => { + const buf = makeBuffer(); + const reporter = progress.create({ stream: buf, enabled: false, color: false }); + reporter.iterationPromptReady({ iteration: 1, promptBytes: 1000, promptChars: 1000, promptTokens: 250 }); + reporter.iterationResponseReceived({ iteration: 1, responseBytes: 500, responseChars: 500, responseTokens: 125 }); + expect(buf.text()).toBe(''); + }); +}); diff --git a/tests/unit/javascript/mini-ralph-prompt.test.js b/tests/unit/javascript/mini-ralph-prompt.test.js index b0aa54d..7208df9 100644 --- a/tests/unit/javascript/mini-ralph-prompt.test.js +++ b/tests/unit/javascript/mini-ralph-prompt.test.js @@ -11,7 +11,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { loadBase, render, _renderTemplate } = require('../../../lib/mini-ralph/prompt'); +const { loadBase, render, _renderTemplate, _resetWarnNotice } = require('../../../lib/mini-ralph/prompt'); let tmpDir; @@ -45,6 +45,13 @@ describe('_renderTemplate()', () => { expect(_renderTemplate('{{unknown}}', {})).toBe('{{unknown}}'); }); + test('regression: {{context}} passes through literally since it was removed from the vars map (D3)', () => { + // {{context}} was removed from the renderer's vars object in task 4.1. + // Templates that still contain it must receive it back verbatim rather than + // being replaced with an empty string or throwing. + expect(_renderTemplate('head\n{{context}}\ntail', {})).toBe('head\n{{context}}\ntail'); + }); + test('handles empty template string', () => { expect(_renderTemplate('', { x: 'y' })).toBe(''); }); @@ -282,3 +289,107 @@ describe('render()', () => { expect(result).toBe('dir=/path/to/change'); }); }); + +// --------------------------------------------------------------------------- +// D5 — Lazy base-prompt loading (spec scenario coverage) +// --------------------------------------------------------------------------- + +describe('render() D5 — Lazy base-prompt loading', () => { + let stderrOutput; + let originalWrite; + + beforeEach(() => { + stderrOutput = []; + originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (msg) => { stderrOutput.push(msg); return true; }; + delete process.env.RALPH_BASE_PROMPT_WARN_BYTES; + _resetWarnNotice(); + }); + + afterEach(() => { + process.stderr.write = originalWrite; + delete process.env.RALPH_BASE_PROMPT_WARN_BYTES; + _resetWarnNotice(); + }); + + test('(a) template without {{base_prompt}} renders without throwing even when promptFile points at a nonexistent path', () => { + const templateFile = path.join(tmpDir, 'no-base.md'); + fs.writeFileSync(templateFile, 'Iteration {{iteration}} — no base here'); + + expect(() => + render( + { + promptFile: '/totally/nonexistent/file.md', + promptTemplate: templateFile, + }, + 1 + ) + ).not.toThrow(); + + const result = render( + { + promptFile: '/totally/nonexistent/file.md', + promptTemplate: templateFile, + }, + 1 + ); + expect(result).toContain('Iteration 1'); + expect(result).not.toContain('{{base_prompt}}'); + }); + + test('(b) template with {{base_prompt}} substitutes the file content verbatim', () => { + const promptFile = path.join(tmpDir, 'base.md'); + fs.writeFileSync(promptFile, 'My exact base content.'); + const templateFile = path.join(tmpDir, 'with-base.md'); + fs.writeFileSync(templateFile, 'START\n{{base_prompt}}\nEND'); + + const result = render({ promptFile, promptTemplate: templateFile }, 1); + + expect(result).toBe('START\nMy exact base content.\nEND'); + }); + + test('(c) template with {{base_prompt}} and a >4 KB file emits exactly one stderr line containing the file path and byte size', () => { + const promptFile = path.join(tmpDir, 'large.md'); + // Write > 4096 bytes + fs.writeFileSync(promptFile, 'x'.repeat(5000)); + const templateFile = path.join(tmpDir, 'with-base.md'); + fs.writeFileSync(templateFile, '{{base_prompt}}'); + + render({ promptFile, promptTemplate: templateFile }, 1); + + expect(stderrOutput).toHaveLength(1); + expect(stderrOutput[0]).toContain('{{base_prompt}}'); + expect(stderrOutput[0]).toContain('5000'); + expect(stderrOutput[0]).toContain(promptFile); + }); + + test('(d) RALPH_BASE_PROMPT_WARN_BYTES=0 silences the warning even for a 100 KB file', () => { + process.env.RALPH_BASE_PROMPT_WARN_BYTES = '0'; + const promptFile = path.join(tmpDir, 'huge.md'); + fs.writeFileSync(promptFile, 'y'.repeat(102400)); + const templateFile = path.join(tmpDir, 'with-base.md'); + fs.writeFileSync(templateFile, '{{base_prompt}}'); + + render({ promptFile, promptTemplate: templateFile }, 1); + + expect(stderrOutput).toHaveLength(0); + }); + + test('(e) an invalid RALPH_BASE_PROMPT_WARN_BYTES falls back to 4096 and emits the fallback notice EXACTLY ONCE across three consecutive render() calls', () => { + process.env.RALPH_BASE_PROMPT_WARN_BYTES = 'notanumber'; + const promptFile = path.join(tmpDir, 'large2.md'); + fs.writeFileSync(promptFile, 'z'.repeat(5000)); + const templateFile = path.join(tmpDir, 'with-base.md'); + fs.writeFileSync(templateFile, '{{base_prompt}}'); + + render({ promptFile, promptTemplate: templateFile }, 1); + render({ promptFile, promptTemplate: templateFile }, 2); + render({ promptFile, promptTemplate: templateFile }, 3); + + // One fallback notice + three oversized warnings (one per call, threshold fell back to 4096) + const fallbackNotices = stderrOutput.filter(m => m.includes('falling back to 4096')); + const oversizedWarnings = stderrOutput.filter(m => m.includes('{{base_prompt}} resolved to')); + expect(fallbackNotices).toHaveLength(1); + expect(oversizedWarnings).toHaveLength(3); + }); +}); diff --git a/tests/unit/javascript/mini-ralph-runner-autocommit.test.js b/tests/unit/javascript/mini-ralph-runner-autocommit.test.js index 6983823..4f64865 100644 --- a/tests/unit/javascript/mini-ralph-runner-autocommit.test.js +++ b/tests/unit/javascript/mini-ralph-runner-autocommit.test.js @@ -75,7 +75,7 @@ describe('runner._autoCommit()', () => { ['diff', '--cached', '--name-only'], expect.any(Object) ); - expect(execFileSync).toHaveBeenCalledTimes(2); + expect(execFileSync).toHaveBeenCalledTimes(3); // check-ignore, add, diff expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining('nothing staged') ); @@ -96,21 +96,21 @@ describe('runner._autoCommit()', () => { }); expect(execFileSync).toHaveBeenNthCalledWith( - 1, + 2, 'git', ['add', '-A', '--', 'tasks.md', 'src/app.js'], expect.any(Object) ); expect(execFileSync).toHaveBeenNthCalledWith( - 2, + 3, 'git', ['diff', '--cached', '--name-only'], expect.any(Object) ); - expect(execFileSync.mock.calls[2][0]).toBe('git'); - expect(execFileSync.mock.calls[2][1][0]).toBe('commit'); - expect(execFileSync.mock.calls[2][1][2]).toContain('Ralph iteration 5: Implement feature'); - expect(execFileSync.mock.calls[2][1][2]).toContain('- [x] 1.1 Implement feature'); + expect(execFileSync.mock.calls[3][0]).toBe('git'); + expect(execFileSync.mock.calls[3][1][0]).toBe('commit'); + expect(execFileSync.mock.calls[3][1][2]).toContain('Ralph iteration 5: Implement feature'); + expect(execFileSync.mock.calls[3][1][2]).toContain('- [x] 1.1 Implement feature'); expect(result).toEqual({ attempted: true, committed: true, anomaly: null }); }); @@ -190,7 +190,7 @@ describe('runner._autoCommit()', () => { }); expect(execFileSync).toHaveBeenNthCalledWith( - 1, + 2, 'git', ['add', '-A', '--', 'deleted/file.webp', 'tasks.md'], expect.any(Object) diff --git a/tests/unit/javascript/mini-ralph-runner.test.js b/tests/unit/javascript/mini-ralph-runner.test.js index db645b7..f58d676 100644 --- a/tests/unit/javascript/mini-ralph-runner.test.js +++ b/tests/unit/javascript/mini-ralph-runner.test.js @@ -19,6 +19,7 @@ const { _completedTaskDelta, _buildAutoCommitAllowlist, _formatAutoCommitMessage, + _truncateSubjectSummary, _buildIterationFeedback, _extractErrorForIteration, _getCurrentTaskDescription, @@ -28,6 +29,8 @@ const { _wasSuccessfulIteration, _failureFingerprint, _firstNonEmptyLine, + _filterGitignored, + _autoCommit, run, } = require('../../../lib/mini-ralph/runner'); @@ -581,6 +584,61 @@ describe('_formatAutoCommitMessage()', () => { test('returns empty string when there are no completed tasks', () => { expect(_formatAutoCommitMessage(2, [])).toBe(''); }); + + test('truncates long task descriptions in the subject line but preserves them in the body', () => { + const longDescription = + 'Write `.components.json` for each of the eight slugs per `design.md` Decision 15 with detailed normative expectations that span several sentences and would otherwise blow out `git log --oneline` readability for every reviewer.'; + const message = _formatAutoCommitMessage(46, [ + { + number: '4.6', + description: longDescription, + fullDescription: `4.6 ${longDescription}`, + status: 'completed', + }, + ]); + + const [subject, , ...bodyLines] = message.split('\n'); + expect(subject.length).toBeLessThanOrEqual(72); + expect(subject.startsWith('Ralph iteration 46: ')).toBe(true); + expect(subject.includes('\n')).toBe(false); + expect(message).toContain(`- [x] 4.6 ${longDescription}`); + expect(bodyLines.join('\n')).toContain('Tasks completed:'); + }); +}); + +describe('_truncateSubjectSummary()', () => { + test('returns the input unchanged when it already fits', () => { + expect(_truncateSubjectSummary('Implement status dashboard', 50)).toBe( + 'Implement status dashboard' + ); + }); + + test('prefers the first sentence when it fits in the budget', () => { + const input = + 'Add a focused unit test. The test covers several scenarios and asserts many things.'; + expect(_truncateSubjectSummary(input, 50)).toBe('Add a focused unit test.'); + }); + + test('hard-truncates at a word boundary with an ellipsis when no short sentence exists', () => { + const input = + 'Write component-spec-slug JSON files that diff-table expect from the components manifest against actual from the live DOM probe'; + const out = _truncateSubjectSummary(input, 50); + expect(out.length).toBeLessThanOrEqual(50); + expect(out.endsWith('…')).toBe(true); + expect(out).not.toMatch(/\s…$/); + }); + + test('collapses internal whitespace onto one line', () => { + const input = 'line one\n\n line two'; + expect(_truncateSubjectSummary(input, 50)).toBe('line one line two'); + }); + + test('returns empty string for empty or whitespace input', () => { + expect(_truncateSubjectSummary('', 50)).toBe(''); + expect(_truncateSubjectSummary(' \n ', 50)).toBe(''); + expect(_truncateSubjectSummary(null, 50)).toBe(''); + expect(_truncateSubjectSummary(undefined, 50)).toBe(''); + }); }); // --------------------------------------------------------------------------- @@ -979,6 +1037,127 @@ describe('_buildIterationFeedback() - fingerprint dedup', () => { }); }); +// --------------------------------------------------------------------------- +// _buildIterationFeedback() - paths_ignored_filtered / all_paths_ignored dedup bypass +// --------------------------------------------------------------------------- + +describe('_buildIterationFeedback() - ignore-filter anomaly dedup bypass', () => { + const makeIgnoreEntry = (iteration, type, ignoredPaths) => ({ + iteration, + exitCode: 0, + signal: '', + failureStage: '', + filesChanged: ['tasks.md'], + completionDetected: false, + taskDetected: true, + commitAnomaly: `Auto-commit succeeded but filtered gitignored paths: ${ignoredPaths[0]}`, + commitAnomalyType: type, + ignoredPaths, + }); + + test('three consecutive paths_ignored_filtered entries produce three distinct lines (no dedup)', () => { + const ignoredPaths = ['tasks.md']; + const history = [ + makeIgnoreEntry(5, 'paths_ignored_filtered', ignoredPaths), + makeIgnoreEntry(6, 'paths_ignored_filtered', ignoredPaths), + makeIgnoreEntry(7, 'paths_ignored_filtered', ignoredPaths), + ]; + const feedback = _buildIterationFeedback(history); + expect(feedback).toContain('Iteration 5:'); + expect(feedback).toContain('Iteration 6:'); + expect(feedback).toContain('Iteration 7:'); + expect(feedback).not.toContain('same failure as iteration'); + }); + + test('dedup still applies to repeated invoke_start (non-filter) anomalies', () => { + const history = [ + { + iteration: 10, + exitCode: 0, + signal: '', + failureStage: '', + filesChanged: [], + completionDetected: false, + taskDetected: true, + commitAnomaly: 'Auto-commit failed: pathspec did not match', + commitAnomalyType: 'commit_failed', + }, + { + iteration: 11, + exitCode: 0, + signal: '', + failureStage: '', + filesChanged: [], + completionDetected: false, + taskDetected: true, + commitAnomaly: 'Auto-commit failed: pathspec did not match', + commitAnomalyType: 'commit_failed', + }, + ]; + const feedback = _buildIterationFeedback(history); + expect(feedback).toContain('Iteration 10: commit anomaly'); + expect(feedback).toContain('same failure as iteration 10'); + }); + + test('ignoredPaths > 2 shows first two paths and (+N more) suffix', () => { + const ignoredPaths = ['a.md', 'b.md', 'c.md', 'd.md']; + const history = [makeIgnoreEntry(3, 'paths_ignored_filtered', ignoredPaths)]; + const feedback = _buildIterationFeedback(history); + expect(feedback).toContain('a.md'); + expect(feedback).toContain('b.md'); + expect(feedback).toContain('(+2 more)'); + expect(feedback).not.toContain('c.md'); + }); +}); + +// --------------------------------------------------------------------------- +// _buildIterationFeedback() - no-promise tool-usage and duration suffix +// --------------------------------------------------------------------------- + +describe('_buildIterationFeedback() - no-promise tool-usage/duration suffix', () => { + test('appends tool-usage and duration suffix for pure no-promise entry', () => { + const history = [ + { + iteration: 9, + exitCode: 0, + signal: '', + failureStage: '', + commitAnomaly: '', + filesChanged: [], + completionDetected: false, + taskDetected: false, + toolUsage: [{ tool: 'Task', count: 4 }], + duration: 418553, + }, + ]; + const feedback = _buildIterationFeedback(history); + expect(feedback).toContain('no loop promise emitted'); + expect(feedback).toMatch(/Tools used: Task\u00d74\./); + expect(feedback).toMatch(/Duration: 6m 58s\./); + }); + + test('produces bare "no loop promise emitted." when toolUsage empty and duration 0', () => { + const history = [ + { + iteration: 9, + exitCode: 0, + signal: '', + failureStage: '', + commitAnomaly: '', + filesChanged: [], + completionDetected: false, + taskDetected: false, + toolUsage: [], + duration: 0, + }, + ]; + const feedback = _buildIterationFeedback(history); + expect(feedback).toContain('no loop promise emitted.'); + expect(feedback).not.toContain('Tools used:'); + expect(feedback).not.toContain('Duration:'); + }); +}); + // --------------------------------------------------------------------------- // _getCurrentTaskDescription // --------------------------------------------------------------------------- @@ -1686,6 +1865,148 @@ describe('run() with mocked invoker', () => { } }); + test('history entry includes ignoredPaths when some paths are filtered', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + const tasksFile = path.join(tmpDir, 'tasks.md'); + fs.writeFileSync(tasksFile, '- [ ] 1.1 Task one\n', 'utf8'); + + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tmpDir); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const execSpy = jest.spyOn(require('child_process'), 'execFileSync').mockImplementation((command, args) => { + // Simulate check-ignore: mark openspec/changes/tasks.md as ignored + if (command === 'git' && args[0] === 'check-ignore') { + const err = new Error('ignored'); + err.status = 0; + err.stdout = 'openspec/changes/tasks.md\n'; + // check-ignore with --stdin: return the ignored path via stdout + // execFileSync returns stdout on exit 0 + return 'openspec/changes/tasks.md\n'; + } + if (command === 'git' && args[0] === 'add') return ''; + if (command === 'git' && args[0] === 'diff') return 'src/app.js\n'; + if (command === 'git' && args[0] === 'commit') return ''; + return ''; + }); + + const restore = mockInvoker(invoker, async () => { + fs.writeFileSync(tasksFile, '- [x] 1.1 Task one\n', 'utf8'); + return { + stdout: 'READY_FOR_NEXT_TASK', + exitCode: 0, + filesChanged: [ + path.join(tmpDir, 'openspec', 'changes', 'tasks.md'), + path.join(tmpDir, 'src', 'app.js'), + ], + toolUsage: [], + }; + }); + + try { + await run(makeOptions({ ralphDir, tasksMode: true, tasksFile, maxIterations: 1 })); + const entries = history.recent(ralphDir, 1); + expect(entries[0].commitCreated).toBe(true); + expect(entries[0].commitAnomalyType).toBe('paths_ignored_filtered'); + expect(Array.isArray(entries[0].ignoredPaths)).toBe(true); + expect(entries[0].ignoredPaths).toContain('openspec/changes/tasks.md'); + } finally { + restore(); + execSpy.mockRestore(); + cwdSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); + + test('history entry omits ignoredPaths when no paths are filtered', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + const tasksFile = path.join(tmpDir, 'tasks.md'); + fs.writeFileSync(tasksFile, '- [ ] 1.1 Task one\n', 'utf8'); + + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tmpDir); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const execSpy = jest.spyOn(require('child_process'), 'execFileSync').mockImplementation((command, args) => { + // Simulate check-ignore exit 1 (no ignored paths) + if (command === 'git' && args[0] === 'check-ignore') { + const err = new Error('no ignored'); + err.status = 1; + throw err; + } + if (command === 'git' && args[0] === 'add') return ''; + if (command === 'git' && args[0] === 'diff') return 'src/app.js\n'; + if (command === 'git' && args[0] === 'commit') return ''; + return ''; + }); + + const restore = mockInvoker(invoker, async () => { + fs.writeFileSync(tasksFile, '- [x] 1.1 Task one\n', 'utf8'); + return { + stdout: 'READY_FOR_NEXT_TASK', + exitCode: 0, + filesChanged: [path.join(tmpDir, 'src', 'app.js')], + toolUsage: [], + }; + }); + + try { + await run(makeOptions({ ralphDir, tasksMode: true, tasksFile, maxIterations: 1 })); + const entries = history.recent(ralphDir, 1); + expect(entries[0].commitCreated).toBe(true); + expect('ignoredPaths' in entries[0]).toBe(false); + expect(entries[0].commitAnomalyType).not.toBe('paths_ignored_filtered'); + expect(entries[0].commitAnomalyType).not.toBe('all_paths_ignored'); + } finally { + restore(); + execSpy.mockRestore(); + cwdSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); + + test('history entry records all_paths_ignored when all paths are gitignored', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + const tasksFile = path.join(tmpDir, 'tasks.md'); + fs.writeFileSync(tasksFile, '- [ ] 1.1 Task one\n', 'utf8'); + + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tmpDir); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + const execSpy = jest.spyOn(require('child_process'), 'execFileSync').mockImplementation((command, args, opts) => { + // Simulate check-ignore returning all paths as ignored + if (command === 'git' && args[0] === 'check-ignore') { + // Return whatever was passed via stdin as all ignored + const inputPaths = (opts && opts.input) ? opts.input.split('\n').filter(Boolean) : []; + return inputPaths.join('\n') + (inputPaths.length ? '\n' : ''); + } + if (command === 'git' && args[0] === 'add') return ''; + if (command === 'git' && args[0] === 'diff') return ''; + if (command === 'git' && args[0] === 'commit') return ''; + return ''; + }); + + const restore = mockInvoker(invoker, async () => { + fs.writeFileSync(tasksFile, '- [x] 1.1 Task one\n', 'utf8'); + return { + stdout: 'READY_FOR_NEXT_TASK', + exitCode: 0, + filesChanged: [path.join(tmpDir, 'openspec', 'changes', 'tasks.md')], + toolUsage: [], + }; + }); + + try { + await run(makeOptions({ ralphDir, tasksMode: true, tasksFile, maxIterations: 1 })); + const entries = history.recent(ralphDir, 1); + expect(entries[0].commitAttempted).toBe(true); + expect(entries[0].commitCreated).toBe(false); + expect(entries[0].commitAnomalyType).toBe('all_paths_ignored'); + expect(Array.isArray(entries[0].ignoredPaths)).toBe(true); + expect(entries[0].ignoredPaths.length).toBeGreaterThan(0); + } finally { + restore(); + execSpy.mockRestore(); + cwdSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); + test('sets resumedAt in state when resuming from prior iteration', async () => { const ralphDir = path.join(tmpDir, '.ralph'); // Seed a prior state simulating iteration 2 having completed @@ -1968,6 +2289,62 @@ describe('run() with mocked invoker', () => { } }); + test('Recent Loop Signals window is 3: oldest of 5 history entries are excluded and dedup back-references still shown', async () => { + // Feed 5 history entries into the ralphDir before running. The runner reads + // only the 3 most recent via history.recent(ralphDir, 3). Entries 4 and 5 + // (oldest) must NOT appear in the prompt. Entries 1–3 share a fingerprint + // so the dedup logic should collapse them into a primary + back-references. + const ralphDir = path.join(tmpDir, '.ralph-window3-test'); + fs.mkdirSync(ralphDir, { recursive: true }); + + // Write 5 history entries manually; iteration numbers 1..5 (oldest first). + // Entries 1–3 share fingerprint "no-promise-stall" (identical outcome). + const sharedFingerprint = 'no-promise-stall'; + const historyEntries = [ + { iteration: 1, outcome: 'stall', signal: sharedFingerprint, summary: 'stall oldest A' }, + { iteration: 2, outcome: 'stall', signal: sharedFingerprint, summary: 'stall oldest B' }, + { iteration: 3, outcome: 'stall', signal: sharedFingerprint, summary: 'stall iter3' }, + { iteration: 4, outcome: 'stall', signal: sharedFingerprint, summary: 'stall iter4' }, + { iteration: 5, outcome: 'stall', signal: sharedFingerprint, summary: 'stall iter5' }, + ]; + fs.writeFileSync( + path.join(ralphDir, 'ralph-history.json'), + JSON.stringify(historyEntries), + ); + + const prompts = []; + let callCount = 0; + const restore = mockInvoker(invoker, async (opts) => { + callCount++; + prompts.push(opts.prompt); + if (callCount === 1) { + return { + stdout: 'no promise', + exitCode: 0, + filesChanged: [], + toolUsage: [], + }; + } + return { + stdout: 'COMPLETE', + exitCode: 0, + filesChanged: ['done.js'], + toolUsage: [], + }; + }); + + try { + await run(makeOptions({ ralphDir, maxIterations: 2, noCommit: true })); + // The second prompt should include Recent Loop Signals + expect(prompts[1]).toContain('## Recent Loop Signals'); + // Entries 1 and 2 (oldest) must NOT appear — window is 3 (entries 3,4,5) + expect(prompts[1]).not.toContain('stall oldest A'); + expect(prompts[1]).not.toContain('stall oldest B'); + } finally { + restore(); + } + }); + test('errors archived and cleared on successful completion', async () => { const ralphDir = path.join(tmpDir, '.ralph'); let callCount = 0; @@ -2240,4 +2617,720 @@ describe('run() with mocked invoker', () => { restore(); } }); + + test('iterationPromptReady is called with finite non-negative prompt size fields', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + const promptReadyCalls = []; + const spyReporter = { + runStarted: () => {}, + iterationStarted: () => {}, + iterationPromptReady: (info) => { promptReadyCalls.push(info); }, + iterationResponseReceived: () => {}, + iterationFinished: () => {}, + note: () => {}, + runFinished: () => {}, + }; + + const restore = mockInvoker(invoker, async () => ({ + stdout: 'COMPLETE', + exitCode: 0, + filesChanged: [], + toolUsage: [], + })); + + try { + await run(makeOptions({ ralphDir, maxIterations: 1, noCommit: true, reporter: spyReporter })); + expect(promptReadyCalls.length).toBeGreaterThanOrEqual(1); + const call = promptReadyCalls[0]; + expect(call.iteration).toBe(1); + expect(Number.isFinite(call.promptBytes)).toBe(true); + expect(call.promptBytes).toBeGreaterThanOrEqual(0); + expect(Number.isFinite(call.promptChars)).toBe(true); + expect(call.promptChars).toBeGreaterThanOrEqual(0); + expect(Number.isFinite(call.promptTokens)).toBe(true); + expect(call.promptTokens).toBeGreaterThanOrEqual(0); + } finally { + restore(); + } + }); + + test('injects ## Lessons Learned section with bullets from LESSONS.md into prompt', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + fs.mkdirSync(ralphDir, { recursive: true }); + fs.writeFileSync( + path.join(ralphDir, 'LESSONS.md'), + '- First lesson bullet\n- Second lesson bullet\n- Third lesson bullet\n', + 'utf8' + ); + + const prompts = []; + const restore = mockInvoker(invoker, async (opts) => { + prompts.push(opts.prompt); + return { + stdout: 'COMPLETE', + exitCode: 0, + filesChanged: [], + toolUsage: [], + }; + }); + + try { + await run(makeOptions({ ralphDir, maxIterations: 1, noCommit: true })); + expect(prompts[0]).toContain('## Lessons Learned'); + expect(prompts[0]).toContain('- First lesson bullet'); + expect(prompts[0]).toContain('- Second lesson bullet'); + expect(prompts[0]).toContain('- Third lesson bullet'); + // Header should appear exactly once + expect((prompts[0].match(/## Lessons Learned/g) || []).length).toBe(1); + } finally { + restore(); + } + }); + + test('iterationResponseReceived is called and history entry contains responseBytes/responseChars', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + const responseCalls = []; + const spyReporter = { + runStarted: () => {}, + iterationStarted: () => {}, + iterationPromptReady: () => {}, + iterationResponseReceived: (info) => { responseCalls.push(info); }, + iterationFinished: () => {}, + note: () => {}, + runFinished: () => {}, + }; + + // Build a known-size response: 2048 ASCII chars = 2048 bytes + const knownOutput = 'x'.repeat(2048) + '\nCOMPLETE'; + + const restore = mockInvoker(invoker, async () => ({ + stdout: knownOutput, + exitCode: 0, + filesChanged: [], + toolUsage: [], + })); + + try { + await run(makeOptions({ ralphDir, maxIterations: 1, noCommit: true, reporter: spyReporter })); + expect(responseCalls.length).toBeGreaterThanOrEqual(1); + const call = responseCalls[0]; + expect(call.iteration).toBe(1); + expect(call.responseBytes).toBe(Buffer.byteLength(knownOutput, 'utf8')); + expect(call.responseChars).toBe(knownOutput.length); + + // Verify history entry + const history = require('../../../lib/mini-ralph/history.js'); + const entries = history.read(ralphDir); + expect(entries.length).toBeGreaterThanOrEqual(1); + const entry = entries[entries.length - 1]; + expect(entry.responseBytes).toBe(Buffer.byteLength(knownOutput, 'utf8')); + expect(entry.responseChars).toBe(knownOutput.length); + } finally { + restore(); + } + }); + + test('fatal-path history entry has zeros for size fields when invoker throws', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + + const restore = mockInvoker(invoker, async () => { + throw Object.assign(new Error('invoker exploded'), { failureStage: 'invoke_start' }); + }); + + try { + await run(makeOptions({ ralphDir, maxIterations: 1, noCommit: true })); + } catch (_) { + // run may or may not throw depending on halt logic + } finally { + restore(); + } + + const history = require('../../../lib/mini-ralph/history.js'); + const entries = history.read(ralphDir); + expect(entries.length).toBeGreaterThanOrEqual(1); + const entry = entries[entries.length - 1]; + expect(entry.responseBytes).toBe(0); + expect(entry.responseChars).toBe(0); + expect(entry.responseTokens).toBe(0); + expect(entry.truncated).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// _filterGitignored +// --------------------------------------------------------------------------- + +describe('_filterGitignored()', () => { + const childProcess = require('child_process'); + let gitRepo; + + /** + * Initialize a real git repo in a temp directory and return its path. + */ + function makeGitRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-filter-gitignored-')); + childProcess.execFileSync('git', ['init'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + return repo; + } + + beforeEach(() => { + gitRepo = makeGitRepo(); + // Write .gitignore with ignored-dir/ and *.log + fs.writeFileSync(path.join(gitRepo, '.gitignore'), 'ignored-dir/\n*.log\n', 'utf8'); + }); + + afterEach(() => { + fs.rmSync(gitRepo, { recursive: true, force: true }); + }); + + test('(a) a path matching *.log is dropped and not kept', () => { + const result = _filterGitignored(['debug.log', 'src/app.js'], gitRepo); + expect(result.dropped).toContain('debug.log'); + expect(result.kept).not.toContain('debug.log'); + }); + + test('(b) a plain path with no ignore match is kept and not dropped', () => { + const result = _filterGitignored(['src/app.js'], gitRepo); + expect(result.kept).toContain('src/app.js'); + expect(result.dropped).not.toContain('src/app.js'); + }); + + test('(c) a tracked file is kept even if it matches .gitignore textually', () => { + // Create a file that would match *.log, add and commit it before .gitignore + // is written. We need to set up the repo slightly differently for this case: + // Re-init with no .gitignore, commit the file, then add .gitignore. + const trackedRepo = makeGitRepo(); + fs.writeFileSync(path.join(trackedRepo, 'tracked.log'), 'content\n', 'utf8'); + childProcess.execFileSync('git', ['add', 'tracked.log'], { cwd: trackedRepo }); + childProcess.execFileSync('git', ['commit', '-m', 'add tracked.log'], { cwd: trackedRepo }); + // Now add .gitignore + fs.writeFileSync(path.join(trackedRepo, '.gitignore'), '*.log\n', 'utf8'); + + const result = _filterGitignored(['tracked.log'], trackedRepo); + // git check-ignore does not ignore tracked files — they stay in kept + expect(result.kept).toContain('tracked.log'); + expect(result.dropped).not.toContain('tracked.log'); + + fs.rmSync(trackedRepo, { recursive: true, force: true }); + }); + + test('(d) empty input returns {kept:[], dropped:[]} and spawns no child process', () => { + const execSpy = jest.spyOn(childProcess, 'execFileSync'); + const callsBefore = execSpy.mock.calls.length; + const result = _filterGitignored([], gitRepo); + const callsAfter = execSpy.mock.calls.length; + expect(result).toEqual({ kept: [], dropped: [] }); + expect(callsAfter).toBe(callsBefore); // no new calls + }); + + test('(e) unresolvable git binary: returns all paths as kept and throws nothing', () => { + const emptyBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-')); + const originalPath = process.env.PATH; + process.env.PATH = emptyBinDir; + try { + let result; + expect(() => { + result = _filterGitignored(['some/path.js'], gitRepo); + }).not.toThrow(); + expect(result.kept).toContain('some/path.js'); + expect(result.dropped).toHaveLength(0); + } finally { + process.env.PATH = originalPath; + fs.rmSync(emptyBinDir, { recursive: true, force: true }); + } + }); + + test('(f) exit 1 from git check-ignore (no ignored paths) returns all inputs as kept', () => { + // A repo with no .gitignore should produce exit 1 from git check-ignore + const cleanRepo = makeGitRepo(); + // No .gitignore — git check-ignore will exit 1 for any path + const result = _filterGitignored(['some/path.js', 'other.txt'], cleanRepo); + expect(result.kept).toEqual(expect.arrayContaining(['some/path.js', 'other.txt'])); + expect(result.dropped).toHaveLength(0); + fs.rmSync(cleanRepo, { recursive: true, force: true }); + }); +}); + +// --------------------------------------------------------------------------- +// _autoCommit reporter.note warnings for gitignored paths +// --------------------------------------------------------------------------- + +describe('_autoCommit reporter.note warnings for gitignored paths', () => { + const childProcess = require('child_process'); + + function makeGitRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-autocommit-warn-')); + childProcess.execFileSync('git', ['init'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + return repo; + } + + function makeStubReporter() { + const noteCalls = []; + return { + reporter: { + note: (msg, level) => noteCalls.push({ msg, level }), + runStarted: () => {}, + runFinished: () => {}, + iterationStarted: () => {}, + iterationFinished: () => {}, + iterationPromptReady: () => {}, + iterationResponseReceived: () => {}, + }, + noteCalls, + }; + } + + test('single ignored path: reporter.note called with message containing dropped path', () => { + const repo = makeGitRepo(); + // .gitignore ignores openspec/* + fs.writeFileSync(path.join(repo, '.gitignore'), 'openspec/*\n', 'utf8'); + childProcess.execFileSync('git', ['add', '.gitignore'], { cwd: repo }); + childProcess.execFileSync('git', ['commit', '-m', 'init'], { cwd: repo }); + + // Create a tracked file and an ignored file + fs.mkdirSync(path.join(repo, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'lib', 'app.js'), 'console.log("hello")\n', 'utf8'); + childProcess.execFileSync('git', ['add', 'lib/app.js'], { cwd: repo }); + childProcess.execFileSync('git', ['commit', '-m', 'add app'], { cwd: repo }); + + // Create an ignored path + fs.mkdirSync(path.join(repo, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'openspec', 'changes', 'tasks.md'), '# tasks\n', 'utf8'); + + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(repo); + const { reporter, noteCalls } = makeStubReporter(); + const ignoredPath = path.join(repo, 'openspec', 'changes', 'tasks.md'); + const keptPath = path.join(repo, 'lib', 'app.js'); + + try { + _autoCommit(6, { + completedTasks: [{ id: '1.1', description: 'Task one' }], + filesToStage: [keptPath, ignoredPath], + reporter, + }); + + // reporter.note must have been called with warn level containing the dropped path + expect(noteCalls.length).toBeGreaterThanOrEqual(1); + const warn = noteCalls.find(c => c.level === 'error'); + expect(warn).toBeDefined(); + expect(warn.msg).toContain('openspec/changes/tasks.md'); + } finally { + cwdSpy.mockRestore(); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('all paths ignored: reporter.note called with message containing git add -f hint', () => { + const repo = makeGitRepo(); + // .gitignore ignores openspec/* + fs.writeFileSync(path.join(repo, '.gitignore'), 'openspec/*\n', 'utf8'); + childProcess.execFileSync('git', ['add', '.gitignore'], { cwd: repo }); + childProcess.execFileSync('git', ['commit', '-m', 'init'], { cwd: repo }); + + // Create only ignored paths + fs.mkdirSync(path.join(repo, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'openspec', 'changes', 'tasks.md'), '# tasks\n', 'utf8'); + + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(repo); + const { reporter, noteCalls } = makeStubReporter(); + + try { + const result = _autoCommit(6, { + completedTasks: [{ id: '1.1', description: 'Task one' }], + filesToStage: [path.join(repo, 'openspec', 'changes', 'tasks.md')], + reporter, + }); + + expect(result.committed).toBe(false); + expect(result.anomaly.type).toBe('all_paths_ignored'); + expect(noteCalls.length).toBeGreaterThanOrEqual(1); + const warn = noteCalls.find(c => c.level === 'error'); + expect(warn).toBeDefined(); + expect(warn.msg).toContain('git add -f'); + } finally { + cwdSpy.mockRestore(); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// auto-commit with gitignored paths (replay of the captured failure fixture) +// --------------------------------------------------------------------------- + +describe('auto-commit with gitignored paths', () => { + const childProcess = require('child_process'); + + /** + * Build a temp git repo that mirrors the shape captured in the task 1.1 + * baseline fixture: .gitignore contains `openspec/*`, one tracked non-ignored + * file and one untracked path under openspec/changes/replay/. + */ + function makeReplayRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-replay-ignored-')); + childProcess.execFileSync('git', ['init'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + + // Seed .gitignore with openspec/* + fs.writeFileSync(path.join(repo, '.gitignore'), 'openspec/*\n', 'utf8'); + childProcess.execFileSync('git', ['add', '.gitignore'], { cwd: repo }); + + // Seed a tracked non-ignored file so there is a valid HEAD commit + fs.mkdirSync(path.join(repo, 'lib', 'mini-ralph'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'lib', 'mini-ralph', 'runner.js'), '// stub\n', 'utf8'); + childProcess.execFileSync('git', ['add', 'lib/mini-ralph/runner.js'], { cwd: repo }); + childProcess.execFileSync('git', ['commit', '-m', 'init'], { cwd: repo }); + + // Create the untracked ignored path (openspec/changes/replay/tasks.md) + fs.mkdirSync(path.join(repo, 'openspec', 'changes', 'replay'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'openspec', 'changes', 'replay', 'tasks.md'), '- [ ] 1.1 Task\n', 'utf8'); + + return repo; + } + + function makeStubReporter() { + const noteCalls = []; + return { + reporter: { + note: (msg, level) => noteCalls.push({ msg, level }), + runStarted: () => {}, + runFinished: () => {}, + iterationStarted: () => {}, + iterationFinished: () => {}, + iterationPromptReady: () => {}, + iterationResponseReceived: () => {}, + }, + noteCalls, + }; + } + + test('Variant A (partial filter): commits kept paths and records paths_ignored_filtered anomaly', () => { + const repo = makeReplayRepo(); + const originalCwd = process.cwd(); + process.chdir(repo); + + // Modify the tracked file so there is something to commit + fs.writeFileSync(path.join(repo, 'lib', 'mini-ralph', 'runner.js'), '// modified\n', 'utf8'); + + const { reporter } = makeStubReporter(); + const keptRelPath = 'lib/mini-ralph/runner.js'; + const ignoredRelPath = 'openspec/changes/replay/tasks.md'; + + const execSpy = jest.spyOn(childProcess, 'execFileSync'); + + try { + const result = _autoCommit(5, { + completedTasks: [{ number: '1.1', description: 'Task one', fullDescription: '1.1 Task one', status: 'completed' }], + filesToStage: [keptRelPath, ignoredRelPath], + reporter, + }); + + expect(result.committed).toBe(true); + expect(result.anomaly).not.toBeNull(); + expect(result.anomaly.type).toBe('paths_ignored_filtered'); + expect(result.anomaly.ignoredPaths).toContain(ignoredRelPath); + + // Verify a new commit was created + const log = childProcess.execFileSync('git', ['log', '--oneline', '-1'], { encoding: 'utf8' }); + expect(log).toContain('Ralph iteration 5'); + + // Assert no -f or --force flag was passed to git add + const gitAddCalls = execSpy.mock.calls.filter( + c => c[0] === 'git' && Array.isArray(c[1]) && c[1][0] === 'add' + ); + for (const call of gitAddCalls) { + const args = call[1]; + expect(args).not.toContain('-f'); + expect(args).not.toContain('--force'); + } + } finally { + execSpy.mockRestore(); + process.chdir(originalCwd); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('Variant B (all ignored): skips commit and emits git add -f hint', () => { + // In this variant all staged paths are under openspec/changes/replay/ + // which matches the openspec/* gitignore rule — nothing is kept. + const repo = makeReplayRepo(); + const originalCwd = process.cwd(); + process.chdir(repo); + + const { reporter, noteCalls } = makeStubReporter(); + + const ignoredRelPath = 'openspec/changes/replay/tasks.md'; + + // Record the HEAD SHA before the attempt so we can assert no new commit + const headBefore = childProcess.execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + + try { + const result = _autoCommit(5, { + completedTasks: [{ number: '1.1', description: 'Task one', fullDescription: '1.1 Task one', status: 'completed' }], + filesToStage: [ignoredRelPath], + reporter, + }); + + expect(result.committed).toBe(false); + expect(result.anomaly).not.toBeNull(); + expect(result.anomaly.type).toBe('all_paths_ignored'); + + // reporter.note must contain the git add -f hint + const warn = noteCalls.find(c => c.level === 'error'); + expect(warn).toBeDefined(); + expect(warn.msg).toContain('git add -f'); + + // No new commit should have been created + const headAfter = childProcess.execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + expect(headAfter).toBe(headBefore); + } finally { + process.chdir(originalCwd); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('pre-existing no-ignored-paths test still passes (regression check)', () => { + // A clean repo with no .gitignore: all paths should be committed normally. + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-no-ignore-')); + childProcess.execFileSync('git', ['init'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + + fs.writeFileSync(path.join(repo, 'init.txt'), 'init\n', 'utf8'); + childProcess.execFileSync('git', ['add', 'init.txt'], { cwd: repo }); + childProcess.execFileSync('git', ['commit', '-m', 'init'], { cwd: repo }); + + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'src', 'app.js'), 'console.log("hello")\n', 'utf8'); + + const originalCwd = process.cwd(); + process.chdir(repo); + const { reporter } = makeStubReporter(); + + try { + const result = _autoCommit(1, { + completedTasks: [{ number: '1.1', description: 'Task', fullDescription: '1.1 Task', status: 'completed' }], + filesToStage: ['src/app.js'], + reporter, + }); + + expect(result.committed).toBe(true); + expect(result.anomaly).toBeNull(); + } finally { + process.chdir(originalCwd); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Task 3.1 — iteration_timeout_idle plumbing through runner → history +// --------------------------------------------------------------------------- +describe('run() — iteration_timeout_idle history plumbing (task 3.1)', () => { + let tmpDir; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-watchdog-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function mockInvokerLocal(invokerModule, mockFn) { + const original = invokerModule.invoke; + invokerModule.invoke = mockFn; + return () => { invokerModule.invoke = original; }; + } + + function makeOpts(overrides = {}) { + return Object.assign( + { + ralphDir: path.join(tmpDir, '.ralph'), + promptText: 'do thing', + maxIterations: 1, + minIterations: 1, + stallThreshold: 0, + }, + overrides + ); + } + + const invoker = require('../../../lib/mini-ralph/invoker'); + + test('persists failureReason, idleMs, lastStdoutBytes, lastStderrBytes when invoker returns iteration_timeout_idle', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + const restore = mockInvokerLocal(invoker, async () => ({ + stdout: '', + stderr: '', + exitCode: null, + signal: 'SIGTERM', + failureReason: 'iteration_timeout_idle', + idleMs: 42, + lastStdoutBytes: 'x', + lastStderrBytes: 'y', + filesChanged: [], + toolUsage: [], + })); + + try { + await run(makeOpts({ ralphDir })); + const entries = history.recent(ralphDir, 1); + expect(entries.length).toBe(1); + expect(entries[0].failureReason).toBe('iteration_timeout_idle'); + expect(entries[0].idleMs).toBe(42); + expect(entries[0].lastStdoutBytes).toBe('x'); + expect(entries[0].lastStderrBytes).toBe('y'); + } finally { + restore(); + } + }); + + test('does NOT persist idleMs, lastStdoutBytes, lastStderrBytes on a normal success result', async () => { + const ralphDir = path.join(tmpDir, '.ralph'); + const restore = mockInvokerLocal(invoker, async () => ({ + stdout: 'COMPLETE', + exitCode: 0, + filesChanged: [], + toolUsage: [], + })); + + try { + await run(makeOpts({ ralphDir, maxIterations: 1 })); + const entries = history.recent(ralphDir, 1); + expect(entries.length).toBe(1); + expect('idleMs' in entries[0]).toBe(false); + expect('lastStdoutBytes' in entries[0]).toBe(false); + expect('lastStderrBytes' in entries[0]).toBe(false); + } finally { + restore(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Task 5.1 — loud direct stderr block for paths_ignored_filtered / all_paths_ignored +// --------------------------------------------------------------------------- +describe('_autoCommit loud stderr block (task 5.1)', () => { + const childProcess = require('child_process'); + + function makeGitRepo5() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-loud-block-')); + childProcess.execFileSync('git', ['init'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + fs.writeFileSync(path.join(repo, '.gitignore'), 'openspec/*\n', 'utf8'); + fs.writeFileSync(path.join(repo, 'init.txt'), 'init\n', 'utf8'); + childProcess.execFileSync('git', ['add', '.'], { cwd: repo }); + childProcess.execFileSync('git', ['commit', '-m', 'init'], { cwd: repo }); + fs.mkdirSync(path.join(repo, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'openspec', 'changes', 'tasks.md'), '- [ ] 1.1 Task\n', 'utf8'); + return repo; + } + + test('paths_ignored_filtered: stderr block contains iteration number, anomaly type, ignored path, and remediation lines', () => { + const repo = makeGitRepo5(); + const originalCwd = process.cwd(); + process.chdir(repo); + fs.writeFileSync(path.join(repo, 'init.txt'), 'modified\n', 'utf8'); + + const stderrChunks = []; + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(chunk => { + stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + + try { + const result = _autoCommit(7, { + completedTasks: [{ number: '1.1', description: 'Task', fullDescription: '1.1 Task', status: 'completed' }], + filesToStage: ['init.txt', 'openspec/changes/tasks.md'], + }); + + expect(result.anomaly.type).toBe('paths_ignored_filtered'); + const combined = stderrChunks.join(''); + expect(combined.split('='.repeat(80)).length).toBeGreaterThanOrEqual(3); + expect(combined).toContain('iteration 7'); + expect(combined).toContain('paths_ignored_filtered'); + expect(combined).toContain('openspec/changes/tasks.md'); + expect(combined).toContain('git add -f'); + expect(combined).toContain('edit .gitignore'); + expect(combined).toContain('--no-auto-commit'); + } finally { + stderrSpy.mockRestore(); + process.chdir(originalCwd); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('all_paths_ignored: stderr block contains iteration number, anomaly type, ignored path, and remediation lines', () => { + const repo = makeGitRepo5(); + const originalCwd = process.cwd(); + process.chdir(repo); + + const stderrChunks = []; + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(chunk => { + stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + + try { + const result = _autoCommit(3, { + completedTasks: [{ number: '1.1', description: 'Task', fullDescription: '1.1 Task', status: 'completed' }], + filesToStage: ['openspec/changes/tasks.md'], + }); + + expect(result.anomaly.type).toBe('all_paths_ignored'); + const combined = stderrChunks.join(''); + expect(combined.split('='.repeat(80)).length).toBeGreaterThanOrEqual(3); + expect(combined).toContain('iteration 3'); + expect(combined).toContain('all_paths_ignored'); + expect(combined).toContain('openspec/changes/tasks.md'); + expect(combined).toContain('git add -f'); + expect(combined).toContain('edit .gitignore'); + expect(combined).toContain('--no-auto-commit'); + } finally { + stderrSpy.mockRestore(); + process.chdir(originalCwd); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + test('no ignore-filter anomaly: loud-block separator NOT written to stderr', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-no-ignore-loud-')); + childProcess.execFileSync('git', ['init'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: repo }); + childProcess.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }); + fs.writeFileSync(path.join(repo, 'init.txt'), 'init\n', 'utf8'); + childProcess.execFileSync('git', ['add', 'init.txt'], { cwd: repo }); + childProcess.execFileSync('git', ['commit', '-m', 'init'], { cwd: repo }); + fs.writeFileSync(path.join(repo, 'init.txt'), 'modified\n', 'utf8'); + + const originalCwd = process.cwd(); + process.chdir(repo); + + let loudBlockCallCount = 0; + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(chunk => { + const s = typeof chunk === 'string' ? chunk : chunk.toString(); + if (s.startsWith('='.repeat(80))) loudBlockCallCount++; + return true; + }); + + try { + const result = _autoCommit(1, { + completedTasks: [{ number: '1.1', description: 'Task', fullDescription: '1.1 Task', status: 'completed' }], + filesToStage: ['init.txt'], + }); + + expect(result.committed).toBe(true); + expect(result.anomaly).toBeNull(); + expect(loudBlockCallCount).toBe(0); + } finally { + stderrSpy.mockRestore(); + process.chdir(originalCwd); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); }); diff --git a/tests/unit/javascript/mini-ralph-status.test.js b/tests/unit/javascript/mini-ralph-status.test.js index af1caaf..1204e01 100644 --- a/tests/unit/javascript/mini-ralph-status.test.js +++ b/tests/unit/javascript/mini-ralph-status.test.js @@ -220,7 +220,7 @@ describe('prompt helpers', () => { const tasksFile = path.join(tmpDir, 'tasks.md'); const templateFile = path.join(tmpDir, 'template.md'); fs.writeFileSync(tasksFile, '- [x] 1.1 Done task\n- [ ] 1.2 Next task\n'); - fs.writeFileSync(templateFile, 'Iter {{iteration}}/{{max_iterations}}\n{{base_prompt}}\n{{tasks}}\n{{task_context}}\n{{task_promise}} {{completion_promise}} {{change_dir}} {{context}}'); + fs.writeFileSync(templateFile, 'Iter {{iteration}}/{{max_iterations}}\n{{base_prompt}}\n{{tasks}}\n{{task_context}}\n{{task_promise}} {{completion_promise}} {{change_dir}}'); const rendered = prompt.render({ promptText: 'Base prompt',