spec-and-loop 1.0.8 → 2.0.0-rc.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.
@@ -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,91 @@
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 tasks = require('./tasks');
22
+ const status = require('./status');
23
+ const prompt = require('./prompt');
24
+
25
+ /**
26
+ * Run the mini Ralph loop with the provided options.
27
+ *
28
+ * @param {object} options
29
+ * @param {string} options.promptFile - Path to the prompt file (required unless promptText given)
30
+ * @param {string} [options.promptText] - Inline prompt text
31
+ * @param {string} [options.promptTemplate] - Path to a prompt template file
32
+ * @param {string} options.ralphDir - Path to the .ralph/ working directory
33
+ * @param {number} [options.minIterations] - Minimum iterations before completion (default: 1)
34
+ * @param {number} [options.maxIterations] - Maximum iterations (default: 50)
35
+ * @param {string} [options.completionPromise] - Promise string signaling loop completion (default: "COMPLETE")
36
+ * @param {string} [options.taskPromise] - Promise string signaling task completion (default: "READY_FOR_NEXT_TASK")
37
+ * @param {boolean} [options.tasksMode] - Enable tasks mode (default: false)
38
+ * @param {string} [options.tasksFile] - Path to tasks file when tasksMode is true
39
+ * @param {boolean} [options.noCommit] - Suppress auto-commit (default: false)
40
+ * @param {string} [options.model] - Optional model override
41
+ * @param {boolean} [options.verbose] - Enable verbose output (default: false)
42
+ * @returns {Promise<object>} Result object with { completed, iterations, exitReason }
43
+ */
44
+ async function run(options) {
45
+ return runner.run(options);
46
+ }
47
+
48
+ /**
49
+ * Print the status dashboard for the current loop state.
50
+ *
51
+ * @param {string} ralphDir - Path to the .ralph/ working directory
52
+ * @param {string} [tasksFile] - Optional path to the tasks.md file for task progress
53
+ * @returns {string} Formatted status output
54
+ */
55
+ function getStatus(ralphDir, tasksFile) {
56
+ return status.render(ralphDir, tasksFile || null);
57
+ }
58
+
59
+ /**
60
+ * Add pending context to be injected into the next iteration.
61
+ *
62
+ * @param {string} ralphDir - Path to the .ralph/ working directory
63
+ * @param {string} text - Context text to add
64
+ */
65
+ function addContext(ralphDir, text) {
66
+ context.add(ralphDir, text);
67
+ }
68
+
69
+ /**
70
+ * Clear all pending context.
71
+ *
72
+ * @param {string} ralphDir - Path to the .ralph/ working directory
73
+ */
74
+ function clearContext(ralphDir) {
75
+ context.clear(ralphDir);
76
+ }
77
+
78
+ module.exports = {
79
+ run,
80
+ getStatus,
81
+ addContext,
82
+ clearContext,
83
+ // Expose sub-modules for internal use and testing
84
+ _state: state,
85
+ _history: history,
86
+ _context: context,
87
+ _tasks: tasks,
88
+ _prompt: prompt,
89
+ _runner: runner,
90
+ _status: status,
91
+ };
@@ -0,0 +1,205 @@
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
+ const path = require('path');
16
+
17
+ /**
18
+ * Invoke OpenCode with the given prompt and return a result object.
19
+ *
20
+ * @param {object} opts
21
+ * @param {string} opts.prompt - Rendered prompt text to send to OpenCode
22
+ * @param {string} [opts.model] - Optional model override
23
+ * @param {boolean} [opts.noCommit] - Skip git commit if true
24
+ * @param {boolean} [opts.verbose] - Enable verbose output
25
+ * @param {string} opts.ralphDir - .ralph/ directory (used for temp prompt file)
26
+ * @returns {Promise<{stdout: string, exitCode: number, toolUsage: Array, filesChanged: Array}>}
27
+ */
28
+ async function invoke(opts) {
29
+ const {
30
+ prompt,
31
+ model,
32
+ noCommit = false,
33
+ verbose = false,
34
+ ralphDir,
35
+ } = opts;
36
+
37
+ // Write the prompt to a temp file to avoid shell escaping issues
38
+ const fs = require('fs');
39
+ const os = require('os');
40
+ const tmpPromptFile = path.join(
41
+ os.tmpdir(),
42
+ `ralph-prompt-${Date.now()}-${process.pid}.md`
43
+ );
44
+
45
+ try {
46
+ fs.writeFileSync(tmpPromptFile, prompt, 'utf8');
47
+
48
+ const args = ['--print', tmpPromptFile];
49
+ if (model) {
50
+ args.push('--model', model);
51
+ }
52
+
53
+ if (verbose) {
54
+ process.stderr.write(`[mini-ralph] invoking: opencode ${args.join(' ')}\n`);
55
+ }
56
+
57
+ // Snapshot git-tracked files before invocation for file-change detection
58
+ const preSnapshot = _gitSnapshot();
59
+
60
+ const result = await _spawnOpenCode(args, verbose);
61
+
62
+ // Detect which files changed during this iteration
63
+ const postSnapshot = _gitSnapshot();
64
+ const filesChanged = _diffSnapshots(preSnapshot, postSnapshot);
65
+
66
+ return {
67
+ stdout: result.stdout,
68
+ exitCode: result.exitCode,
69
+ toolUsage: _extractToolUsage(result.stdout),
70
+ filesChanged,
71
+ };
72
+ } finally {
73
+ // Clean up temp prompt file
74
+ try {
75
+ fs.unlinkSync(tmpPromptFile);
76
+ } catch {
77
+ // ignore cleanup errors
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Spawn the opencode process and stream output to terminal while capturing.
84
+ *
85
+ * @param {Array<string>} args
86
+ * @param {boolean} verbose
87
+ * @returns {Promise<{stdout: string, exitCode: number}>}
88
+ */
89
+ function _spawnOpenCode(args, verbose) {
90
+ return new Promise((resolve, reject) => {
91
+ const child = spawn('opencode', args, {
92
+ stdio: ['inherit', 'pipe', 'pipe'],
93
+ env: process.env,
94
+ });
95
+
96
+ let stdout = '';
97
+ let stderr = '';
98
+
99
+ child.stdout.on('data', (chunk) => {
100
+ const text = chunk.toString();
101
+ stdout += text;
102
+ process.stdout.write(chunk);
103
+ });
104
+
105
+ child.stderr.on('data', (chunk) => {
106
+ const text = chunk.toString();
107
+ stderr += text;
108
+ process.stderr.write(chunk);
109
+ });
110
+
111
+ child.on('error', (err) => {
112
+ if (err.code === 'ENOENT') {
113
+ reject(new Error('mini-ralph invoker: opencode CLI not found. Please install opencode: npm install -g opencode-ai'));
114
+ } else {
115
+ reject(new Error(`mini-ralph invoker: failed to start opencode: ${err.message}`));
116
+ }
117
+ });
118
+
119
+ child.on('close', (code) => {
120
+ resolve({ stdout, stderr, exitCode: code || 0 });
121
+ });
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Extract a compact tool usage summary from OpenCode output.
127
+ * Returns an array of { tool, count } objects.
128
+ *
129
+ * @param {string} text
130
+ * @returns {Array<{tool: string, count: number}>}
131
+ */
132
+ function _extractToolUsage(text) {
133
+ if (!text) return [];
134
+
135
+ // Heuristic: count occurrences of common tool call patterns in output
136
+ const toolPatterns = [
137
+ 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'Task',
138
+ 'TodoWrite', 'WebFetch',
139
+ ];
140
+
141
+ const usage = [];
142
+ for (const tool of toolPatterns) {
143
+ const regex = new RegExp(`\\b${tool}\\b`, 'g');
144
+ const matches = text.match(regex);
145
+ if (matches && matches.length > 0) {
146
+ usage.push({ tool, count: matches.length });
147
+ }
148
+ }
149
+
150
+ return usage;
151
+ }
152
+
153
+ /**
154
+ * Take a snapshot of modified/untracked files via git status.
155
+ * Returns a Set of file paths relative to the repo root.
156
+ * Returns an empty Set if git is unavailable or not in a repo.
157
+ *
158
+ * @returns {Set<string>}
159
+ */
160
+ function _gitSnapshot() {
161
+ try {
162
+ // git status --porcelain outputs modified, added, deleted, untracked files
163
+ const output = execFileSync('git', ['status', '--porcelain'], {
164
+ encoding: 'utf8',
165
+ stdio: ['pipe', 'pipe', 'pipe'],
166
+ });
167
+ const files = new Set();
168
+ for (const line of output.split('\n')) {
169
+ const trimmed = line.trim();
170
+ if (!trimmed) continue;
171
+ // Format: XY filename (first two chars are status, then space, then path)
172
+ const filePath = line.slice(3).trim();
173
+ if (filePath) files.add(filePath);
174
+ }
175
+ return files;
176
+ } catch {
177
+ return new Set();
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Return an array of files that appear in postSnapshot but not preSnapshot,
183
+ * or whose presence changed between the two snapshots.
184
+ *
185
+ * @param {Set<string>} preSnapshot
186
+ * @param {Set<string>} postSnapshot
187
+ * @returns {Array<string>}
188
+ */
189
+ function _diffSnapshots(preSnapshot, postSnapshot) {
190
+ const changed = [];
191
+ for (const file of postSnapshot) {
192
+ if (!preSnapshot.has(file)) {
193
+ changed.push(file);
194
+ }
195
+ }
196
+ // Also capture files that were in pre but are gone (e.g., deleted)
197
+ for (const file of preSnapshot) {
198
+ if (!postSnapshot.has(file)) {
199
+ changed.push(file);
200
+ }
201
+ }
202
+ return changed;
203
+ }
204
+
205
+ module.exports = { invoke, _spawnOpenCode, _extractToolUsage, _gitSnapshot, _diffSnapshots };
@@ -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 };