spec-and-loop 1.0.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QUICKSTART.md +265 -53
- package/README.md +210 -175
- package/lib/mini-ralph/context.js +99 -0
- package/lib/mini-ralph/errors.js +112 -0
- package/lib/mini-ralph/history.js +99 -0
- package/lib/mini-ralph/index.js +93 -0
- package/lib/mini-ralph/invoker.js +243 -0
- package/lib/mini-ralph/prompt.js +116 -0
- package/lib/mini-ralph/runner.js +495 -0
- package/lib/mini-ralph/state.js +93 -0
- package/lib/mini-ralph/status.js +238 -0
- package/lib/mini-ralph/tasks.js +209 -0
- package/package.json +3 -3
- package/scripts/mini-ralph-cli.js +226 -0
- package/scripts/ralph-run.sh +182 -72
- package/scripts/setup.js +39 -31
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const ERRORS_FILE = 'errors.md';
|
|
7
|
+
|
|
8
|
+
function errorsPath(ralphDir) {
|
|
9
|
+
return path.join(ralphDir, ERRORS_FILE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function read(ralphDir, limit) {
|
|
13
|
+
const entries = _readRawEntries(ralphDir);
|
|
14
|
+
if (!entries.length) return '';
|
|
15
|
+
if (limit !== undefined && limit < entries.length) {
|
|
16
|
+
return entries.slice(-limit).join('\n---\n');
|
|
17
|
+
}
|
|
18
|
+
return entries.join('\n---\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readEntries(ralphDir, limit) {
|
|
22
|
+
const entries = _readRawEntries(ralphDir).map(_parseEntry).filter(Boolean);
|
|
23
|
+
if (limit !== undefined && limit < entries.length) {
|
|
24
|
+
return entries.slice(-limit);
|
|
25
|
+
}
|
|
26
|
+
return entries;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function count(ralphDir) {
|
|
30
|
+
return _readRawEntries(ralphDir).length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function latest(ralphDir) {
|
|
34
|
+
const entries = readEntries(ralphDir, 1);
|
|
35
|
+
return entries.length > 0 ? entries[0] : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function append(ralphDir, entry) {
|
|
39
|
+
_ensureDir(ralphDir);
|
|
40
|
+
const file = errorsPath(ralphDir);
|
|
41
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
42
|
+
const separator = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
43
|
+
const timestamp = new Date().toISOString();
|
|
44
|
+
const text = [
|
|
45
|
+
'---',
|
|
46
|
+
`Timestamp: ${timestamp}`,
|
|
47
|
+
`Iteration: ${entry.iteration}`,
|
|
48
|
+
`Task: ${entry.task}`,
|
|
49
|
+
`Exit Code: ${entry.exitCode}`,
|
|
50
|
+
'',
|
|
51
|
+
'### stderr',
|
|
52
|
+
entry.stderr || '',
|
|
53
|
+
'',
|
|
54
|
+
'### stdout',
|
|
55
|
+
entry.stdout || '',
|
|
56
|
+
'',
|
|
57
|
+
].join('\n');
|
|
58
|
+
fs.writeFileSync(file, `${existing}${separator}${text}`, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clear(ralphDir) {
|
|
62
|
+
const file = errorsPath(ralphDir);
|
|
63
|
+
if (fs.existsSync(file)) {
|
|
64
|
+
fs.unlinkSync(file);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function archive(ralphDir) {
|
|
69
|
+
const file = errorsPath(ralphDir);
|
|
70
|
+
if (!fs.existsSync(file)) return null;
|
|
71
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
72
|
+
const archiveFile = path.join(ralphDir, `errors_${timestamp}.md`);
|
|
73
|
+
fs.copyFileSync(file, archiveFile);
|
|
74
|
+
return archiveFile;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _ensureDir(ralphDir) {
|
|
78
|
+
if (!fs.existsSync(ralphDir)) {
|
|
79
|
+
fs.mkdirSync(ralphDir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _readRawEntries(ralphDir) {
|
|
84
|
+
const file = errorsPath(ralphDir);
|
|
85
|
+
if (!fs.existsSync(file)) return [];
|
|
86
|
+
const content = fs.readFileSync(file, 'utf8').trim();
|
|
87
|
+
if (!content) return [];
|
|
88
|
+
return content.split(/^---$/m).filter((entry) => entry.trim());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _parseEntry(entry) {
|
|
92
|
+
const trimmed = entry.trim();
|
|
93
|
+
if (!trimmed) return null;
|
|
94
|
+
|
|
95
|
+
const timestampMatch = trimmed.match(/^Timestamp: (.+)$/m);
|
|
96
|
+
const iterationMatch = trimmed.match(/^Iteration: (.+)$/m);
|
|
97
|
+
const taskMatch = trimmed.match(/^Task: (.+)$/m);
|
|
98
|
+
const exitCodeMatch = trimmed.match(/^Exit Code: (.+)$/m);
|
|
99
|
+
const stderrMatch = trimmed.match(/(?:^|\n)### stderr\n([\s\S]*?)(?=\n### stdout\n|$)/);
|
|
100
|
+
const stdoutMatch = trimmed.match(/(?:^|\n)### stdout\n([\s\S]*?)$/);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
timestamp: timestampMatch ? timestampMatch[1].trim() : '',
|
|
104
|
+
iteration: iterationMatch ? Number(iterationMatch[1].trim()) : NaN,
|
|
105
|
+
task: taskMatch ? taskMatch[1].trim() : '',
|
|
106
|
+
exitCode: exitCodeMatch ? Number(exitCodeMatch[1].trim()) : NaN,
|
|
107
|
+
stderr: stderrMatch ? stderrMatch[1].trim() : '',
|
|
108
|
+
stdout: stdoutMatch ? stdoutMatch[1].trim() : '',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { errorsPath, read, readEntries, count, latest, append, clear, archive };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* history.js - Iteration history persistence for mini-ralph.
|
|
5
|
+
*
|
|
6
|
+
* Manages reading and writing ralph-history.json under the .ralph/ directory.
|
|
7
|
+
* Each completed iteration appends an entry with duration, completion
|
|
8
|
+
* detection, tool usage summaries, and file-change information.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const HISTORY_FILE = 'ralph-history.json';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return the absolute path to the history file.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} ralphDir
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function historyPath(ralphDir) {
|
|
23
|
+
return path.join(ralphDir, HISTORY_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read history entries. Returns an empty array if the file does not exist.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} ralphDir
|
|
30
|
+
* @returns {Array<object>}
|
|
31
|
+
*/
|
|
32
|
+
function read(ralphDir) {
|
|
33
|
+
const file = historyPath(ralphDir);
|
|
34
|
+
if (!fs.existsSync(file)) return [];
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
37
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Append an iteration result entry to history.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} ralphDir
|
|
47
|
+
* @param {object} entry
|
|
48
|
+
* @param {number} entry.iteration - Iteration number
|
|
49
|
+
* @param {number} entry.duration - Duration in milliseconds
|
|
50
|
+
* @param {boolean} entry.completionDetected
|
|
51
|
+
* @param {boolean} entry.taskDetected
|
|
52
|
+
* @param {Array} entry.toolUsage - Tool usage summary array
|
|
53
|
+
* @param {Array} entry.filesChanged - Files changed in this iteration
|
|
54
|
+
* @param {number} entry.exitCode - OpenCode exit code
|
|
55
|
+
*/
|
|
56
|
+
function append(ralphDir, entry) {
|
|
57
|
+
_ensureDir(ralphDir);
|
|
58
|
+
const entries = read(ralphDir);
|
|
59
|
+
entries.push(Object.assign({ timestamp: new Date().toISOString() }, entry));
|
|
60
|
+
_write(ralphDir, entries);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Return the N most recent history entries.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} ralphDir
|
|
67
|
+
* @param {number} [n=5]
|
|
68
|
+
* @returns {Array<object>}
|
|
69
|
+
*/
|
|
70
|
+
function recent(ralphDir, n = 5) {
|
|
71
|
+
const entries = read(ralphDir);
|
|
72
|
+
return entries.slice(-n);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clear all history.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} ralphDir
|
|
79
|
+
*/
|
|
80
|
+
function clear(ralphDir) {
|
|
81
|
+
_write(ralphDir, []);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Internal helpers
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
function _ensureDir(ralphDir) {
|
|
89
|
+
if (!fs.existsSync(ralphDir)) {
|
|
90
|
+
fs.mkdirSync(ralphDir, { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _write(ralphDir, data) {
|
|
95
|
+
_ensureDir(ralphDir);
|
|
96
|
+
fs.writeFileSync(historyPath(ralphDir), JSON.stringify(data, null, 2), 'utf8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { read, append, recent, clear, historyPath };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mini-ralph: Internal Ralph-style iteration engine for spec-and-loop.
|
|
5
|
+
*
|
|
6
|
+
* This module provides a self-contained loop runtime that replaces the
|
|
7
|
+
* external @th0rgal/ralph-wiggum dependency. It supports the critical
|
|
8
|
+
* OpenSpec-first subset: iterative OpenCode invocation, task-mode
|
|
9
|
+
* progression, prompt file/template support, completion/task promises,
|
|
10
|
+
* loop state/history persistence, status dashboard, add/clear context,
|
|
11
|
+
* and --no-commit.
|
|
12
|
+
*
|
|
13
|
+
* This is an internal implementation detail. The documented user-facing
|
|
14
|
+
* interface is ralph-run.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const runner = require('./runner');
|
|
18
|
+
const state = require('./state');
|
|
19
|
+
const history = require('./history');
|
|
20
|
+
const context = require('./context');
|
|
21
|
+
const errors = require('./errors');
|
|
22
|
+
const tasks = require('./tasks');
|
|
23
|
+
const status = require('./status');
|
|
24
|
+
const prompt = require('./prompt');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run the mini Ralph loop with the provided options.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {string} options.promptFile - Path to the prompt file (required unless promptText given)
|
|
31
|
+
* @param {string} [options.promptText] - Inline prompt text
|
|
32
|
+
* @param {string} [options.promptTemplate] - Path to a prompt template file
|
|
33
|
+
* @param {string} options.ralphDir - Path to the .ralph/ working directory
|
|
34
|
+
* @param {number} [options.minIterations] - Minimum iterations before completion (default: 1)
|
|
35
|
+
* @param {number} [options.maxIterations] - Maximum iterations (default: 50)
|
|
36
|
+
* @param {string} [options.completionPromise] - Promise string signaling loop completion (default: "COMPLETE")
|
|
37
|
+
* @param {string} [options.taskPromise] - Promise string signaling task completion (default: "READY_FOR_NEXT_TASK")
|
|
38
|
+
* @param {boolean} [options.tasksMode] - Enable tasks mode (default: false)
|
|
39
|
+
* @param {string} [options.tasksFile] - Path to tasks file when tasksMode is true
|
|
40
|
+
* @param {boolean} [options.noCommit] - Suppress auto-commit (default: false)
|
|
41
|
+
* @param {string} [options.model] - Optional model override
|
|
42
|
+
* @param {boolean} [options.verbose] - Enable verbose output (default: false)
|
|
43
|
+
* @returns {Promise<object>} Result object with { completed, iterations, exitReason }
|
|
44
|
+
*/
|
|
45
|
+
async function run(options) {
|
|
46
|
+
return runner.run(options);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Print the status dashboard for the current loop state.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} ralphDir - Path to the .ralph/ working directory
|
|
53
|
+
* @param {string} [tasksFile] - Optional path to the tasks.md file for task progress
|
|
54
|
+
* @returns {string} Formatted status output
|
|
55
|
+
*/
|
|
56
|
+
function getStatus(ralphDir, tasksFile) {
|
|
57
|
+
return status.render(ralphDir, tasksFile || null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Add pending context to be injected into the next iteration.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} ralphDir - Path to the .ralph/ working directory
|
|
64
|
+
* @param {string} text - Context text to add
|
|
65
|
+
*/
|
|
66
|
+
function addContext(ralphDir, text) {
|
|
67
|
+
context.add(ralphDir, text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Clear all pending context.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} ralphDir - Path to the .ralph/ working directory
|
|
74
|
+
*/
|
|
75
|
+
function clearContext(ralphDir) {
|
|
76
|
+
context.clear(ralphDir);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
run,
|
|
81
|
+
getStatus,
|
|
82
|
+
addContext,
|
|
83
|
+
clearContext,
|
|
84
|
+
// Expose sub-modules for internal use and testing
|
|
85
|
+
_state: state,
|
|
86
|
+
_history: history,
|
|
87
|
+
_context: context,
|
|
88
|
+
_errors: errors,
|
|
89
|
+
_tasks: tasks,
|
|
90
|
+
_prompt: prompt,
|
|
91
|
+
_runner: runner,
|
|
92
|
+
_status: status,
|
|
93
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* invoker.js - OpenCode process invocation for mini-ralph.
|
|
5
|
+
*
|
|
6
|
+
* Isolates child_process.spawn() calls so the runner module can be tested
|
|
7
|
+
* with a mocked invoker. Streams stdout/stderr to the terminal while also
|
|
8
|
+
* capturing output for promise detection and history recording.
|
|
9
|
+
*
|
|
10
|
+
* This module focuses on a single agent: opencode. Multi-agent support is
|
|
11
|
+
* explicitly out of scope for the first-pass mini Ralph subset.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { spawn, execFileSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Invoke OpenCode with the given prompt and return a result object.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} opts
|
|
20
|
+
* @param {string} opts.prompt - Rendered prompt text to send to OpenCode
|
|
21
|
+
* @param {string} [opts.model] - Optional model override
|
|
22
|
+
* @param {boolean} [opts.noCommit] - Skip git commit if true
|
|
23
|
+
* @param {boolean} [opts.verbose] - Enable verbose output
|
|
24
|
+
* @param {string} [opts.ralphDir] - Reserved for caller compatibility
|
|
25
|
+
* @returns {Promise<{stdout: string, exitCode: number, toolUsage: Array, filesChanged: Array}>}
|
|
26
|
+
*/
|
|
27
|
+
async function invoke(opts) {
|
|
28
|
+
const {
|
|
29
|
+
prompt,
|
|
30
|
+
model,
|
|
31
|
+
noCommit = false,
|
|
32
|
+
verbose = false,
|
|
33
|
+
} = opts;
|
|
34
|
+
|
|
35
|
+
if (!prompt || !prompt.trim()) {
|
|
36
|
+
throw new Error('mini-ralph invoker: prompt is empty');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const args = ['run'];
|
|
40
|
+
if (model) {
|
|
41
|
+
args.push('--model', model);
|
|
42
|
+
}
|
|
43
|
+
args.push(prompt);
|
|
44
|
+
|
|
45
|
+
if (verbose) {
|
|
46
|
+
process.stderr.write(
|
|
47
|
+
`[mini-ralph] invoking: opencode ${args.slice(0, -1).join(' ')} <prompt>\n`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Snapshot git-tracked files before invocation for file-change detection
|
|
52
|
+
const preSnapshot = _gitSnapshot();
|
|
53
|
+
|
|
54
|
+
const result = await _spawnOpenCode(args, verbose);
|
|
55
|
+
const combinedOutput = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
56
|
+
|
|
57
|
+
if (_looksLikeCliHelp(combinedOutput)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'mini-ralph invoker: opencode printed CLI help instead of running the prompt. ' +
|
|
60
|
+
'The installed opencode CLI is likely incompatible with this version of spec-and-loop.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Detect which files changed during this iteration
|
|
65
|
+
const postSnapshot = _gitSnapshot();
|
|
66
|
+
const filesChanged = _diffSnapshots(preSnapshot, postSnapshot);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
stdout: result.stdout,
|
|
70
|
+
stderr: result.stderr,
|
|
71
|
+
exitCode: result.exitCode,
|
|
72
|
+
toolUsage: _extractToolUsage(result.stdout),
|
|
73
|
+
filesChanged,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Spawn the opencode process and stream output to terminal while capturing.
|
|
79
|
+
*
|
|
80
|
+
* @param {Array<string>} args
|
|
81
|
+
* @param {boolean} verbose
|
|
82
|
+
* @returns {Promise<{stdout: string, exitCode: number}>}
|
|
83
|
+
*/
|
|
84
|
+
function _spawnOpenCode(args, verbose) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const child = spawn('opencode', args, {
|
|
87
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
88
|
+
env: process.env,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let stdout = '';
|
|
92
|
+
let stderr = '';
|
|
93
|
+
|
|
94
|
+
child.stdout.on('data', (chunk) => {
|
|
95
|
+
const text = chunk.toString();
|
|
96
|
+
stdout += text;
|
|
97
|
+
process.stdout.write(chunk);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
child.stderr.on('data', (chunk) => {
|
|
101
|
+
const text = chunk.toString();
|
|
102
|
+
stderr += text;
|
|
103
|
+
process.stderr.write(chunk);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
child.on('error', (err) => {
|
|
107
|
+
if (err.code === 'ENOENT') {
|
|
108
|
+
reject(new Error('mini-ralph invoker: opencode CLI not found. Please install opencode: npm install -g opencode-ai'));
|
|
109
|
+
} else {
|
|
110
|
+
reject(new Error(`mini-ralph invoker: failed to start opencode: ${err.message}`));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
child.on('close', (code) => {
|
|
115
|
+
resolve({ stdout, stderr, exitCode: code || 0 });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Detect whether OpenCode printed its CLI help banner instead of executing the
|
|
122
|
+
* requested prompt. This usually means the invocation contract drifted.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} text
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
function _looksLikeCliHelp(text) {
|
|
128
|
+
if (!text) return false;
|
|
129
|
+
|
|
130
|
+
// Only inspect the opening portion of the transcript so help-like strings
|
|
131
|
+
// echoed later in diffs or test output do not masquerade as a CLI banner.
|
|
132
|
+
const normalized = text
|
|
133
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
134
|
+
.replace(/\r/g, '');
|
|
135
|
+
const lines = normalized
|
|
136
|
+
.split('\n')
|
|
137
|
+
.map((line) => line.trim())
|
|
138
|
+
.filter(Boolean);
|
|
139
|
+
const openingText = lines.slice(0, 40).join('\n');
|
|
140
|
+
|
|
141
|
+
const looksLikeRunHelp =
|
|
142
|
+
openingText.includes('opencode run [message..]') &&
|
|
143
|
+
openingText.includes('run opencode with a message') &&
|
|
144
|
+
openingText.includes('Positionals:') &&
|
|
145
|
+
openingText.includes('Options:');
|
|
146
|
+
|
|
147
|
+
const looksLikeTopLevelHelp =
|
|
148
|
+
openingText.includes('Commands:') &&
|
|
149
|
+
openingText.includes('Options:') &&
|
|
150
|
+
openingText.includes('opencode [project]') &&
|
|
151
|
+
openingText.includes('opencode run [message..]');
|
|
152
|
+
|
|
153
|
+
return looksLikeRunHelp || looksLikeTopLevelHelp;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extract a compact tool usage summary from OpenCode output.
|
|
158
|
+
* Returns an array of { tool, count } objects.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} text
|
|
161
|
+
* @returns {Array<{tool: string, count: number}>}
|
|
162
|
+
*/
|
|
163
|
+
function _extractToolUsage(text) {
|
|
164
|
+
if (!text) return [];
|
|
165
|
+
|
|
166
|
+
// Heuristic: count occurrences of common tool call patterns in output
|
|
167
|
+
const toolPatterns = [
|
|
168
|
+
'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'Task',
|
|
169
|
+
'TodoWrite', 'WebFetch',
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const usage = [];
|
|
173
|
+
for (const tool of toolPatterns) {
|
|
174
|
+
const regex = new RegExp(`\\b${tool}\\b`, 'g');
|
|
175
|
+
const matches = text.match(regex);
|
|
176
|
+
if (matches && matches.length > 0) {
|
|
177
|
+
usage.push({ tool, count: matches.length });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return usage;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Take a snapshot of modified/untracked files via git status.
|
|
186
|
+
* Returns a Set of file paths relative to the repo root.
|
|
187
|
+
* Returns an empty Set if git is unavailable or not in a repo.
|
|
188
|
+
*
|
|
189
|
+
* @returns {Set<string>}
|
|
190
|
+
*/
|
|
191
|
+
function _gitSnapshot() {
|
|
192
|
+
try {
|
|
193
|
+
// git status --porcelain outputs modified, added, deleted, untracked files
|
|
194
|
+
const output = execFileSync('git', ['status', '--porcelain'], {
|
|
195
|
+
encoding: 'utf8',
|
|
196
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
197
|
+
});
|
|
198
|
+
const files = new Set();
|
|
199
|
+
for (const line of output.split('\n')) {
|
|
200
|
+
const trimmed = line.trim();
|
|
201
|
+
if (!trimmed) continue;
|
|
202
|
+
// Format: XY filename (first two chars are status, then space, then path)
|
|
203
|
+
const filePath = line.slice(3).trim();
|
|
204
|
+
if (filePath) files.add(filePath);
|
|
205
|
+
}
|
|
206
|
+
return files;
|
|
207
|
+
} catch {
|
|
208
|
+
return new Set();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Return an array of files that appear in postSnapshot but not preSnapshot,
|
|
214
|
+
* or whose presence changed between the two snapshots.
|
|
215
|
+
*
|
|
216
|
+
* @param {Set<string>} preSnapshot
|
|
217
|
+
* @param {Set<string>} postSnapshot
|
|
218
|
+
* @returns {Array<string>}
|
|
219
|
+
*/
|
|
220
|
+
function _diffSnapshots(preSnapshot, postSnapshot) {
|
|
221
|
+
const changed = [];
|
|
222
|
+
for (const file of postSnapshot) {
|
|
223
|
+
if (!preSnapshot.has(file)) {
|
|
224
|
+
changed.push(file);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Also capture files that were in pre but are gone (e.g., deleted)
|
|
228
|
+
for (const file of preSnapshot) {
|
|
229
|
+
if (!postSnapshot.has(file)) {
|
|
230
|
+
changed.push(file);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return changed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
invoke,
|
|
238
|
+
_spawnOpenCode,
|
|
239
|
+
_looksLikeCliHelp,
|
|
240
|
+
_extractToolUsage,
|
|
241
|
+
_gitSnapshot,
|
|
242
|
+
_diffSnapshots,
|
|
243
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prompt.js - Prompt loading and template rendering for mini-ralph.
|
|
5
|
+
*
|
|
6
|
+
* Supports three prompt sources:
|
|
7
|
+
* 1. Inline prompt text (options.promptText)
|
|
8
|
+
* 2. Prompt file (options.promptFile)
|
|
9
|
+
* 3. Prompt template file (options.promptTemplate) which wraps sources 1 or 2
|
|
10
|
+
*
|
|
11
|
+
* Template variables (mustache-style double-braces):
|
|
12
|
+
* {{iteration}} - Current iteration number
|
|
13
|
+
* {{max_iterations}} - Configured max iterations
|
|
14
|
+
* {{change_dir}} - Change directory path (from options.changeDir)
|
|
15
|
+
* {{tasks}} - Raw tasks file content
|
|
16
|
+
* {{task_context}} - Fresh current-task and completed-task summary
|
|
17
|
+
* {{task_promise}} - Configured task promise string
|
|
18
|
+
* {{completion_promise}} - Configured completion promise string
|
|
19
|
+
* {{context}} - Pending context (passed in, already consumed)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const tasks = require('./tasks');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load the base prompt text from the configured source.
|
|
28
|
+
* Throws a clear error if the prompt file is missing or empty.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function loadBase(options) {
|
|
34
|
+
if (options.promptText) {
|
|
35
|
+
if (!options.promptText.trim()) {
|
|
36
|
+
throw new Error('mini-ralph prompt: promptText is empty');
|
|
37
|
+
}
|
|
38
|
+
return options.promptText;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.promptFile) {
|
|
42
|
+
if (!fs.existsSync(options.promptFile)) {
|
|
43
|
+
throw new Error(`mini-ralph prompt: prompt file not found: ${options.promptFile}`);
|
|
44
|
+
}
|
|
45
|
+
const text = fs.readFileSync(options.promptFile, 'utf8');
|
|
46
|
+
if (!text.trim()) {
|
|
47
|
+
throw new Error(`mini-ralph prompt: prompt file is empty: ${options.promptFile}`);
|
|
48
|
+
}
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error('mini-ralph prompt: no prompt source configured');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render the prompt for a given iteration.
|
|
57
|
+
* If a promptTemplate is specified, renders the template with iteration variables.
|
|
58
|
+
* Otherwise returns the base prompt as-is.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} options
|
|
61
|
+
* @param {number} iteration - Current 1-based iteration number
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function render(options, iteration) {
|
|
65
|
+
const base = loadBase(options);
|
|
66
|
+
|
|
67
|
+
if (!options.promptTemplate) {
|
|
68
|
+
return base;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const templatePath = options.promptTemplate;
|
|
72
|
+
if (!fs.existsSync(templatePath)) {
|
|
73
|
+
throw new Error(`mini-ralph prompt: template file not found: ${templatePath}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const template = fs.readFileSync(templatePath, 'utf8');
|
|
77
|
+
if (!template.trim()) {
|
|
78
|
+
throw new Error(`mini-ralph prompt: template file is empty: ${templatePath}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Load tasks content if a tasksFile is configured
|
|
82
|
+
let tasksContent = '';
|
|
83
|
+
if (options.tasksFile && fs.existsSync(options.tasksFile)) {
|
|
84
|
+
tasksContent = fs.readFileSync(options.tasksFile, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const taskContext = options.tasksFile ? tasks.taskContext(options.tasksFile) : '';
|
|
88
|
+
|
|
89
|
+
const vars = {
|
|
90
|
+
iteration: String(iteration),
|
|
91
|
+
max_iterations: String(options.maxIterations || 50),
|
|
92
|
+
change_dir: options.changeDir || '',
|
|
93
|
+
tasks: tasksContent,
|
|
94
|
+
task_context: taskContext,
|
|
95
|
+
task_promise: options.taskPromise || 'READY_FOR_NEXT_TASK',
|
|
96
|
+
completion_promise: options.completionPromise || 'COMPLETE',
|
|
97
|
+
context: '', // Pending context is injected by runner after rendering
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return _renderTemplate(template, vars);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Replace all {{key}} occurrences in a template string with the provided values.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} template
|
|
107
|
+
* @param {object} vars - Map of variable name -> value
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function _renderTemplate(template, vars) {
|
|
111
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
112
|
+
return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { loadBase, render, _renderTemplate };
|