spec-and-loop 1.0.7 → 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.
@@ -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 };