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/OPENSPEC-RALPH-BP.md +564 -0
- package/QUICKSTART.md +32 -10
- package/README.md +70 -6
- package/lib/mini-ralph/history.js +37 -0
- package/lib/mini-ralph/invoker.js +108 -7
- package/lib/mini-ralph/lessons.js +93 -0
- package/lib/mini-ralph/progress.js +404 -0
- package/lib/mini-ralph/prompt.js +78 -6
- package/lib/mini-ralph/runner.js +592 -33
- package/lib/mini-ralph/state.js +57 -5
- package/lib/mini-ralph/tasks.js +5 -10
- package/package.json +4 -4
- package/scripts/mini-ralph-cli.js +18 -2
- package/scripts/ralph-run.sh +402 -79
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 };
|