spec-and-loop 2.1.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,16 +47,19 @@ brew install anomalyco/tap/opencode
47
47
  # 1. Initialize OpenSpec in your project
48
48
  openspec init
49
49
 
50
- # 2. Create a new change
50
+ # 2. Ralphify your project (enables Ralph-friendly artifact generation)
51
+ ralph-run init
52
+
53
+ # 3. Create a new change
51
54
  openspec new change add-user-auth
52
55
 
53
- # 3. Review and complete the OpenSpec artifacts
56
+ # 4. Review and complete the OpenSpec artifacts
54
57
  # (openspec/changes/add-user-auth/proposal.md)
55
58
  # (openspec/changes/add-user-auth/design.md)
56
59
  # (openspec/changes/add-user-auth/specs/*/spec.md)
57
60
  # (openspec/changes/add-user-auth/tasks.md)
58
61
 
59
- # 4. Run the ralph loop (executes tasks with opencode)
62
+ # 5. Run the ralph loop (executes tasks with opencode)
60
63
  ralph-run --change add-user-auth
61
64
  ```
62
65
 
@@ -144,6 +147,8 @@ For complete installation instructions, see [QUICKSTART.md](./QUICKSTART.md).
144
147
 
145
148
  ### Ralph Loop Commands
146
149
 
150
+ - `ralph-run init` - Configure project for Ralph-friendly artifact generation (run once after `openspec init`)
151
+
147
152
  ```
148
153
  ralph-run [OPTIONS]
149
154
 
@@ -158,6 +163,9 @@ OBSERVABILITY AND CONTROL:
158
163
  --status Print the current loop status dashboard and exit
159
164
  --add-context <text> Add pending context to inject into the next iteration and exit
160
165
  --clear-context Clear any pending context and exit
166
+
167
+ SUBCOMMANDS:
168
+ init Configure project for Ralph-friendly artifact generation
161
169
  ```
162
170
 
163
171
  ## How It Works
@@ -232,13 +240,16 @@ cd my-web-app
232
240
  git init
233
241
  openspec init
234
242
 
235
- # 2. Create a new change
243
+ # 2. Ralphify your project
244
+ ralph-run init
245
+
246
+ # 3. Create a new change
236
247
  openspec new change user-auth
237
248
 
238
- # 3. Complete OpenSpec artifacts manually or use opencode skills
249
+ # 4. Complete OpenSpec artifacts manually or use opencode skills
239
250
  # (review and fill in proposal.md, design.md, specs/*/spec.md, tasks.md)
240
251
 
241
- # 4. Execute with Ralph
252
+ # 5. Execute with Ralph
242
253
  ralph-run --change user-auth
243
254
 
244
255
  # Output:
@@ -456,6 +467,59 @@ Windows is not currently part of the supported runtime contract.
456
467
 
457
468
  For common issues and solutions, see [QUICKSTART.md#troubleshooting](./QUICKSTART.md#troubleshooting).
458
469
 
470
+ ## Environment Variables
471
+
472
+ | Variable | Default | Description |
473
+ |----------|---------|-------------|
474
+ | `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. |
475
+ | `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). |
476
+ | `RALPH_ITERATION_KILL_GRACE_MS` | `10000` | Milliseconds the runner waits after sending `SIGTERM` to a timed-out iteration child before escalating to `SIGKILL`. |
477
+
478
+ ### Auto-commit ignore-filter surfacing and iteration watchdog
479
+
480
+ 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.
481
+
482
+ **No new CLI flags are introduced by this change. No startup behavior changes. Every existing `ralph-run` invocation continues to work unchanged.**
483
+
484
+ #### Loud stderr block
485
+
486
+ 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:
487
+
488
+ ```
489
+ ================================================================================
490
+ ⚠ AUTO-COMMIT IGNORE FILTER FIRED (iteration 7, type: paths_ignored_filtered)
491
+ Paths filtered because .gitignore matches:
492
+ - openspec/changes/my-change/tasks.md
493
+ - openspec/changes/my-change/proposal.md
494
+ Consequence: these paths are NOT in the latest commit.
495
+ Remediation (pick one):
496
+ 1. git add -f <path> # one-time unblock, if you want it tracked
497
+ 2. edit .gitignore # narrow or remove the matching rule
498
+ 3. pass --no-auto-commit on the ralph-run invocation
499
+ ================================================================================
500
+ ```
501
+
502
+ The three remediation options mean:
503
+
504
+ 1. **`git add -f <path>`** — 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.
505
+ 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.
506
+ 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.
507
+
508
+ #### Iteration watchdog
509
+
510
+ 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`.
511
+
512
+ 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.
513
+
514
+ 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.
515
+
516
+ 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:
517
+
518
+ ```bash
519
+ RALPH_ITERATION_IDLE_TIMEOUT_MS=900000 ralph-run --change my-feature # 15-minute idle threshold
520
+ RALPH_ITERATION_IDLE_TIMEOUT_MS=0 ralph-run --change my-feature # watchdog disabled
521
+ ```
522
+
459
523
  **Quick fixes:**
460
524
 
461
525
  ```bash
@@ -52,6 +52,43 @@ function read(ralphDir) {
52
52
  * @param {Array} entry.toolUsage - Tool usage summary array
53
53
  * @param {Array} entry.filesChanged - Files changed in this iteration
54
54
  * @param {number} entry.exitCode - OpenCode exit code
55
+ * @param {number} [entry.promptBytes] - UTF-8 byte length of the assembled prompt
56
+ * @param {number} [entry.promptChars] - Character length of the assembled prompt
57
+ * @param {number} [entry.promptTokens] - Estimated token count for the prompt (chars/4, rounded)
58
+ * @param {number} [entry.responseBytes] - UTF-8 byte length of the raw response
59
+ * @param {number} [entry.responseChars] - Character length of the raw response
60
+ * @param {number} [entry.responseTokens] - Estimated token count for the response (chars/4, rounded)
61
+ * @param {boolean} [entry.truncated] - Whether the response was truncated by the invoker
62
+ * @param {boolean} [entry.commitAttempted] - Whether an auto-commit was attempted this iteration
63
+ * @param {boolean} [entry.commitCreated] - Whether a git commit was successfully created
64
+ * @param {string} [entry.commitSha] - The SHA of the created commit (if commitCreated is true)
65
+ * @param {string} [entry.commitMessage] - The commit message used (if commitCreated is true)
66
+ * @param {string} [entry.commitAnomaly] - Human-readable description of any commit anomaly
67
+ * @param {string} [entry.commitAnomalyType] - Machine-readable anomaly type string. Known values:
68
+ * - `'nothing_staged'` - No files were staged for commit.
69
+ * - `'commit_failed'` - The git commit command failed.
70
+ * - `'paths_ignored_filtered'` - Some staged paths were gitignored and filtered out;
71
+ * the remaining paths were committed successfully.
72
+ * - `'all_paths_ignored'` - Every staged path was gitignored; no commit was made.
73
+ * @param {string[]} [entry.ignoredPaths] - Paths that were dropped by the gitignore filter.
74
+ * Present only when `commitAnomalyType` is `'paths_ignored_filtered'` or `'all_paths_ignored'`.
75
+ * Omitted entirely when no paths were filtered.
76
+ * @param {string} [entry.failureReason] - Human-readable reason for iteration failure.
77
+ * Known values include:
78
+ * - `'iteration_timeout_idle'` - The iteration subprocess was terminated by the idle watchdog
79
+ * because no bytes were written to stdout or stderr for longer
80
+ * than `RALPH_ITERATION_IDLE_TIMEOUT_MS`. When this value is
81
+ * present, `idleMs`, `lastStdoutBytes`, and `lastStderrBytes`
82
+ * are also present on the entry.
83
+ * Omitted entirely when the iteration succeeded or failed for another reason.
84
+ * @param {number} [entry.idleMs] - Observed idle duration in milliseconds when the
85
+ * watchdog fired. Present only when `failureReason === 'iteration_timeout_idle'`.
86
+ * @param {string} [entry.lastStdoutBytes] - Tail of the iteration subprocess stdout at the
87
+ * moment the watchdog fired, capped at 200 bytes. Present only when
88
+ * `failureReason === 'iteration_timeout_idle'`.
89
+ * @param {string} [entry.lastStderrBytes] - Tail of the iteration subprocess stderr at the
90
+ * moment the watchdog fired, capped at 200 bytes. Present only when
91
+ * `failureReason === 'iteration_timeout_idle'`.
55
92
  */
56
93
  function append(ralphDir, entry) {
57
94
  _ensureDir(ralphDir);
@@ -75,17 +75,43 @@ async function invoke(opts) {
75
75
  signal: result.signal,
76
76
  toolUsage: _extractToolUsage(result.stdout),
77
77
  filesChanged,
78
+ // Pass through watchdog fields when present (task 2.1)
79
+ ...(result.failureReason !== undefined && {
80
+ failureReason: result.failureReason,
81
+ idleMs: result.idleMs,
82
+ lastStdoutBytes: result.lastStdoutBytes,
83
+ lastStderrBytes: result.lastStderrBytes,
84
+ }),
78
85
  };
79
86
  }
80
87
 
81
88
  /**
82
89
  * Spawn the opencode process and stream output to terminal while capturing.
90
+ * Wraps the subprocess with a per-iteration stream-idle watchdog controlled
91
+ * by RALPH_ITERATION_IDLE_TIMEOUT_MS (default 300000 ms; 0 = disabled) and
92
+ * RALPH_ITERATION_KILL_GRACE_MS (default 10000 ms).
93
+ *
94
+ * When the watchdog fires the returned result gains:
95
+ * failureReason: 'iteration_timeout_idle'
96
+ * idleMs: <number> — observed idle duration in ms
97
+ * lastStdoutBytes: <string> — tail of stdout, capped at 200 bytes
98
+ * lastStderrBytes: <string> — tail of stderr, capped at 200 bytes
99
+ *
100
+ * When the watchdog is disabled or does not fire, the return shape is
101
+ * unchanged from the pre-watchdog contract.
83
102
  *
84
103
  * @param {Array<string>} args
85
104
  * @param {boolean} verbose
86
- * @returns {Promise<{stdout: string, stderr: string, exitCode: ?number, signal: ?string}>}
105
+ * @returns {Promise<{stdout: string, stderr: string, exitCode: ?number, signal: ?string, failureReason?: string, idleMs?: number, lastStdoutBytes?: string, lastStderrBytes?: string}>}
87
106
  */
88
107
  function _spawnOpenCode(args, verbose) {
108
+ // Parse watchdog knobs from environment. task 2.1 (surface-autocommit-ignore-warning-and-watchdog)
109
+ const idleTimeoutRaw = process.env.RALPH_ITERATION_IDLE_TIMEOUT_MS;
110
+ const killGraceRaw = process.env.RALPH_ITERATION_KILL_GRACE_MS;
111
+ const idleTimeoutMs = idleTimeoutRaw !== undefined ? Number(idleTimeoutRaw) : 300000;
112
+ const killGraceMs = killGraceRaw !== undefined ? Number(killGraceRaw) : 10000;
113
+ const watchdogEnabled = idleTimeoutMs !== 0;
114
+
89
115
  return new Promise((resolve, reject) => {
90
116
  const child = spawn('opencode', args, {
91
117
  stdio: ['inherit', 'pipe', 'pipe'],
@@ -94,20 +120,80 @@ function _spawnOpenCode(args, verbose) {
94
120
 
95
121
  let stdout = '';
96
122
  let stderr = '';
123
+ let watchdogFired = false;
124
+ let idleTimer = null;
125
+ let idleStart = Date.now();
126
+
127
+ // Tail buffers — keep only the most recent 200 bytes of each stream
128
+ const TAIL_CAP = 200;
129
+ let stdoutTail = '';
130
+ let stderrTail = '';
131
+
132
+ function _appendTail(current, chunk) {
133
+ const combined = current + chunk;
134
+ return combined.length > TAIL_CAP ? combined.slice(-TAIL_CAP) : combined;
135
+ }
136
+
137
+ function _resetIdleTimer() {
138
+ if (!watchdogEnabled || watchdogFired) return;
139
+ if (idleTimer) clearTimeout(idleTimer);
140
+ idleStart = Date.now();
141
+ idleTimer = setTimeout(_onIdleTimeout, idleTimeoutMs);
142
+ }
143
+
144
+ function _onIdleTimeout() {
145
+ if (watchdogFired) return;
146
+ watchdogFired = true;
147
+ const observedIdleMs = Date.now() - idleStart;
148
+
149
+ // Send SIGTERM first; after killGraceMs escalate to SIGKILL if still alive.
150
+ // Use process.kill(-pid, signal) with detached:true when process group
151
+ // kill is needed; for a direct child process.kill() is sufficient on macOS.
152
+ try {
153
+ child.kill('SIGTERM');
154
+ } catch (_) { /* child may have already exited */ }
155
+
156
+ const killTimer = setTimeout(() => {
157
+ try {
158
+ child.kill('SIGKILL');
159
+ } catch (_) { /* child may have already exited */ }
160
+ }, killGraceMs);
161
+
162
+ // Prevent killTimer from keeping the event loop alive
163
+ if (killTimer && typeof killTimer.unref === 'function') {
164
+ killTimer.unref();
165
+ }
166
+
167
+ // Stash metadata on the child so the close handler can read it
168
+ child._watchdogMeta = {
169
+ idleMs: observedIdleMs,
170
+ lastStdoutBytes: stdoutTail,
171
+ lastStderrBytes: stderrTail,
172
+ };
173
+ }
174
+
175
+ if (watchdogEnabled) {
176
+ _resetIdleTimer();
177
+ }
97
178
 
98
179
  child.stdout.on('data', (chunk) => {
99
180
  const text = chunk.toString();
100
181
  stdout += text;
182
+ stdoutTail = _appendTail(stdoutTail, text);
101
183
  process.stdout.write(chunk);
184
+ _resetIdleTimer();
102
185
  });
103
186
 
104
187
  child.stderr.on('data', (chunk) => {
105
188
  const text = chunk.toString();
106
189
  stderr += text;
190
+ stderrTail = _appendTail(stderrTail, text);
107
191
  process.stderr.write(chunk);
192
+ _resetIdleTimer();
108
193
  });
109
194
 
110
195
  child.on('error', (err) => {
196
+ if (idleTimer) clearTimeout(idleTimer);
111
197
  if (err.code === 'ENOENT') {
112
198
  reject(new Error('mini-ralph invoker: opencode CLI not found. Please install opencode: npm install -g opencode-ai'));
113
199
  } else {
@@ -116,12 +202,27 @@ function _spawnOpenCode(args, verbose) {
116
202
  });
117
203
 
118
204
  child.on('close', (code, signal) => {
119
- resolve({
120
- stdout,
121
- stderr,
122
- exitCode: typeof code === 'number' ? code : null,
123
- signal: signal || null,
124
- });
205
+ if (idleTimer) clearTimeout(idleTimer);
206
+
207
+ if (watchdogFired && child._watchdogMeta) {
208
+ resolve({
209
+ stdout,
210
+ stderr,
211
+ exitCode: typeof code === 'number' ? code : null,
212
+ signal: signal || null,
213
+ failureReason: 'iteration_timeout_idle',
214
+ idleMs: child._watchdogMeta.idleMs,
215
+ lastStdoutBytes: child._watchdogMeta.lastStdoutBytes,
216
+ lastStderrBytes: child._watchdogMeta.lastStderrBytes,
217
+ });
218
+ } else {
219
+ resolve({
220
+ stdout,
221
+ stderr,
222
+ exitCode: typeof code === 'number' ? code : null,
223
+ signal: signal || null,
224
+ });
225
+ }
125
226
  });
126
227
  });
127
228
  }
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const nodePath = require('path');
5
+
6
+ const LESSONS_FILENAME = 'LESSONS.md';
7
+ const MAX_BULLET_CHARS = 120;
8
+ const MAX_INJECT_BULLETS = 15;
9
+
10
+ /**
11
+ * Returns the absolute path to the LESSONS.md file for the given ralphDir.
12
+ * @param {string} ralphDir - Path to the .ralph directory.
13
+ * @returns {string}
14
+ */
15
+ function path(ralphDir) {
16
+ return nodePath.join(ralphDir, LESSONS_FILENAME);
17
+ }
18
+
19
+ /**
20
+ * Reads LESSONS.md from ralphDir, returning an array of bullet strings.
21
+ * Missing file returns []. Blank lines are stripped. Bullets > 120 chars
22
+ * are truncated and prefixed with 'runner-truncated:'.
23
+ * @param {string} ralphDir
24
+ * @returns {string[]}
25
+ */
26
+ function read(ralphDir) {
27
+ const filePath = path(ralphDir);
28
+ let content;
29
+ try {
30
+ content = fs.readFileSync(filePath, 'utf8');
31
+ } catch (e) {
32
+ if (e.code === 'ENOENT') return [];
33
+ throw e;
34
+ }
35
+
36
+ const lines = content.split('\n');
37
+ const bullets = [];
38
+ for (const line of lines) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed) continue;
41
+ if (trimmed.length > MAX_BULLET_CHARS) {
42
+ bullets.push('runner-truncated:' + trimmed.slice(0, MAX_BULLET_CHARS));
43
+ } else {
44
+ bullets.push(trimmed);
45
+ }
46
+ }
47
+ return bullets;
48
+ }
49
+
50
+ /**
51
+ * Returns a markdown section string '## Lessons Learned\n\n<bullets>' using
52
+ * the last `limit` (default 50) bullets, or '' if there are none.
53
+ * @param {string} ralphDir
54
+ * @param {{ limit?: number }} [opts]
55
+ * @returns {string}
56
+ */
57
+ function inject(ralphDir, opts) {
58
+ const limit = (opts && opts.limit != null) ? opts.limit : MAX_INJECT_BULLETS;
59
+ const bullets = read(ralphDir);
60
+ if (!bullets.length) return '';
61
+ const slice = bullets.slice(-limit);
62
+ return '## Lessons Learned\n\n' + slice.join('\n');
63
+ }
64
+
65
+ /**
66
+ * If LESSONS.md has more than `max` non-empty bullets, rewrites it keeping
67
+ * only the last `max` bullets. Returns the number of bullets dropped (0 if
68
+ * no write occurred).
69
+ * @param {string} ralphDir
70
+ * @param {number} max
71
+ * @returns {number}
72
+ */
73
+ function rotate(ralphDir, max) {
74
+ const filePath = path(ralphDir);
75
+ let content;
76
+ try {
77
+ content = fs.readFileSync(filePath, 'utf8');
78
+ } catch (e) {
79
+ if (e.code === 'ENOENT') return 0;
80
+ throw e;
81
+ }
82
+
83
+ const lines = content.split('\n');
84
+ const bullets = lines.filter(l => l.trim() !== '');
85
+ if (bullets.length <= max) return 0;
86
+
87
+ const dropped = bullets.length - max;
88
+ const kept = bullets.slice(-max);
89
+ fs.writeFileSync(filePath, kept.join('\n') + '\n', 'utf8');
90
+ return dropped;
91
+ }
92
+
93
+ module.exports = { path, read, inject, rotate };