spec-and-loop 3.3.3 → 3.3.4

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.
@@ -13,6 +13,12 @@ const path = require('path');
13
13
 
14
14
  const STATE_FILE = 'ralph-loop.state.json';
15
15
  const LOCK_FILE = 'ralph-loop.lock.json';
16
+ const SUPERVISOR_STATE_FIELDS = [
17
+ 'currentBlockerHash',
18
+ 'triesUsedForCurrentBlocker',
19
+ 'totalAttemptsForCurrentBlocker',
20
+ 'lastOutcome',
21
+ ];
16
22
 
17
23
  /**
18
24
  * Return the absolute path to the state file.
@@ -43,7 +49,7 @@ function lockPath(ralphDir) {
43
49
  */
44
50
  function init(ralphDir, data) {
45
51
  _ensureDir(ralphDir);
46
- _write(ralphDir, data);
52
+ _write(ralphDir, _normalizeStateData(data));
47
53
  }
48
54
 
49
55
  /**
@@ -67,7 +73,7 @@ function read(ralphDir) {
67
73
  if (attempt === 0) continue;
68
74
  return null;
69
75
  }
70
- return JSON.parse(raw);
76
+ return _normalizeStateData(JSON.parse(raw));
71
77
  } catch (err) {
72
78
  if (attempt === 0 && (err.code === 'ENOENT' || err instanceof SyntaxError)) {
73
79
  continue;
@@ -87,7 +93,7 @@ function read(ralphDir) {
87
93
  */
88
94
  function update(ralphDir, updates) {
89
95
  const current = read(ralphDir) || {};
90
- _write(ralphDir, Object.assign({}, current, updates));
96
+ _write(ralphDir, _normalizeStateData(Object.assign({}, current, updates)));
91
97
  }
92
98
 
93
99
  /**
@@ -196,6 +202,32 @@ function _ensureDir(ralphDir) {
196
202
  }
197
203
  }
198
204
 
205
+ function _normalizeStateData(data) {
206
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
207
+ return data;
208
+ }
209
+
210
+ const normalized = Object.assign({}, data);
211
+ if (Object.prototype.hasOwnProperty.call(normalized, 'supervisor')) {
212
+ normalized.supervisor = _normalizeSupervisorState(normalized.supervisor);
213
+ }
214
+ return normalized;
215
+ }
216
+
217
+ function _normalizeSupervisorState(supervisor) {
218
+ if (!supervisor || typeof supervisor !== 'object' || Array.isArray(supervisor)) {
219
+ return supervisor;
220
+ }
221
+
222
+ const normalized = Object.assign({}, supervisor);
223
+ for (const field of SUPERVISOR_STATE_FIELDS) {
224
+ if (!Object.prototype.hasOwnProperty.call(supervisor, field)) {
225
+ normalized[field] = null;
226
+ }
227
+ }
228
+ return normalized;
229
+ }
230
+
199
231
  function _write(ralphDir, data) {
200
232
  _ensureDir(ralphDir);
201
233
  const target = statePath(ralphDir);
@@ -23,6 +23,7 @@ const errors = require('./errors');
23
23
  */
24
24
  function render(ralphDir, tasksFile) {
25
25
  const lines = [];
26
+ const allHistory = history.read(ralphDir);
26
27
 
27
28
  const loopState = state.read(ralphDir);
28
29
  if (!loopState) {
@@ -60,6 +61,17 @@ function render(ralphDir, tasksFile) {
60
61
  lines.push(`Exit reason: ${loopState.exitReason}`);
61
62
  }
62
63
 
64
+ const supervisorStatus = _supervisorStatus(loopState, allHistory);
65
+ if (supervisorStatus) {
66
+ lines.push('');
67
+ lines.push('--- Supervisor ---');
68
+ lines.push(` Blocker hash: ${supervisorStatus.currentBlockerHash}`);
69
+ lines.push(` Tries used: ${supervisorStatus.triesUsedForCurrentBlocker}`);
70
+ lines.push(` Last outcome: ${supervisorStatus.lastOutcome}`);
71
+ lines.push(` Supervisor edits: ${supervisorStatus.editCount}`);
72
+ lines.push('-'.repeat(50));
73
+ }
74
+
63
75
  const pendingDirtyPaths = _pendingDirtyPaths(loopState);
64
76
  if (pendingDirtyPaths) {
65
77
  lines.push('');
@@ -128,7 +140,7 @@ function render(ralphDir, tasksFile) {
128
140
  }
129
141
 
130
142
  // Recent history
131
- const recentHistory = history.recent(ralphDir, 5);
143
+ const recentHistory = allHistory.slice(-5);
132
144
  if (recentHistory.length > 0) {
133
145
  lines.push('');
134
146
  lines.push('--- Recent History ---');
@@ -171,6 +183,29 @@ function render(ralphDir, tasksFile) {
171
183
  return lines.join('\n');
172
184
  }
173
185
 
186
+ function _supervisorStatus(loopState, historyEntries) {
187
+ const supervisor = loopState && loopState.supervisor;
188
+ if (!supervisor || typeof supervisor !== 'object') {
189
+ return null;
190
+ }
191
+
192
+ const editCount = Array.isArray(historyEntries)
193
+ ? historyEntries.filter((entry) => entry && entry.type === 'supervisorEdit').length
194
+ : 0;
195
+ if (editCount === 0) {
196
+ return null;
197
+ }
198
+
199
+ return {
200
+ currentBlockerHash: supervisor.currentBlockerHash || '',
201
+ triesUsedForCurrentBlocker: Number.isInteger(supervisor.triesUsedForCurrentBlocker)
202
+ ? supervisor.triesUsedForCurrentBlocker
203
+ : 0,
204
+ lastOutcome: supervisor.lastOutcome || '',
205
+ editCount,
206
+ };
207
+ }
208
+
174
209
  // ---------------------------------------------------------------------------
175
210
  // Internal helpers
176
211
  // ---------------------------------------------------------------------------
@@ -402,4 +437,5 @@ module.exports = {
402
437
  _latestCommitAnomaly,
403
438
  _formatHistoryFailure,
404
439
  _isFailedHistoryEntry,
440
+ _supervisorStatus,
405
441
  };
@@ -0,0 +1,379 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * supervisor-rules.js — Rule-source loading and content distillation split
5
+ * out of supervisor.js.
6
+ *
7
+ * Layer-α rule sources (config.yaml, OPENSPEC-RALPH-BP.md, proposal.md,
8
+ * design.md) are read with an mtime+size cache so reusing the same template
9
+ * across attempts is cheap. Each source is then either passed through (when
10
+ * the relevant `RALPH_SELF_HEAL_FULL_*` env knob is set) or distilled to its
11
+ * task-relevant sections so the supervisor prompt fits within Anthropic's
12
+ * 200K-token budget on long-running changes.
13
+ *
14
+ * All helpers are pure / filesystem-only; no internal supervisor state.
15
+ * Moved verbatim from supervisor.js so existing tests keep passing.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const _RULE_SOURCE_MAX_BYTES = 32 * 1024;
22
+ const _SECTION_FALLBACK_MAX_BYTES = 4 * 1024;
23
+ const _ruleSourceCache = new Map();
24
+
25
+ /**
26
+ * Load the four Layer alpha rule sources used by the supervisor prompt.
27
+ *
28
+ * @param {{ tasksFile?: string, changeDir?: string, openspecRoot?: string }} [options]
29
+ * @returns {object}
30
+ */
31
+ function _loadRuleSources(options = {}) {
32
+ const tasksFile = options.tasksFile ? path.resolve(options.tasksFile) : '';
33
+ const changeDir = options.changeDir
34
+ ? path.resolve(options.changeDir)
35
+ : (tasksFile ? path.dirname(tasksFile) : '');
36
+ const openspecRoot = options.openspecRoot
37
+ ? path.resolve(options.openspecRoot)
38
+ : _resolveOpenspecRootFromTasks(tasksFile);
39
+
40
+ if (!openspecRoot) {
41
+ throw new Error('mini-ralph supervisor: unable to resolve openspec root from tasks.md');
42
+ }
43
+
44
+ const cacheEnabled = process.env.RALPH_SELF_HEAL_RULE_CACHE !== '0';
45
+ const rulePaths = {
46
+ openspec_config_rules: path.join(openspecRoot, 'config.yaml'),
47
+ ralph_authoring_rules: path.join(openspecRoot, 'OPENSPEC-RALPH-BP.md'),
48
+ change_proposal: path.join(changeDir, 'proposal.md'),
49
+ change_design: path.join(changeDir, 'design.md'),
50
+ };
51
+
52
+ const loaded = {};
53
+ for (const [name, filePath] of Object.entries(rulePaths)) {
54
+ loaded[name] = {
55
+ path: filePath,
56
+ content: _readRuleSource(filePath, { cacheEnabled }),
57
+ };
58
+ }
59
+
60
+ return loaded;
61
+ }
62
+
63
+ function _readRuleSource(filePath, options = {}) {
64
+ const cacheEnabled = options.cacheEnabled !== false;
65
+
66
+ if (!fs.existsSync(filePath)) {
67
+ _ruleSourceCache.delete(filePath);
68
+ return '';
69
+ }
70
+
71
+ const stat = fs.statSync(filePath);
72
+ const cached = cacheEnabled ? _ruleSourceCache.get(filePath) : null;
73
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
74
+ return cached.content;
75
+ }
76
+
77
+ const content = _truncateRuleSource(fs.readFileSync(filePath, 'utf8'), filePath);
78
+ if (cacheEnabled) {
79
+ if (cached) {
80
+ process.stderr.write(`[mini-ralph] warning: supervisor rule cache refreshed for ${filePath}\n`);
81
+ }
82
+ _ruleSourceCache.set(filePath, {
83
+ mtimeMs: stat.mtimeMs,
84
+ size: stat.size,
85
+ content,
86
+ });
87
+ }
88
+
89
+ return content;
90
+ }
91
+
92
+ function _truncateRuleSource(content, filePath) {
93
+ const buffer = Buffer.from(String(content || ''), 'utf8');
94
+ if (buffer.byteLength <= _RULE_SOURCE_MAX_BYTES) {
95
+ return String(content || '');
96
+ }
97
+
98
+ const sentinel = `... [truncated, ${buffer.byteLength} total]`;
99
+ const keepBytes = Math.max(0, _RULE_SOURCE_MAX_BYTES - Buffer.byteLength(sentinel));
100
+ process.stderr.write(`[mini-ralph] warning: supervisor rule source truncated: ${filePath}\n`);
101
+ return buffer.subarray(0, keepBytes).toString('utf8') + sentinel;
102
+ }
103
+
104
+ function _resolveOpenspecRootFromTasks(tasksFile) {
105
+ let current = tasksFile ? path.resolve(path.dirname(tasksFile)) : '';
106
+ while (current) {
107
+ if (path.basename(current) === 'openspec') {
108
+ return current;
109
+ }
110
+
111
+ const parent = path.dirname(current);
112
+ if (parent === current) {
113
+ break;
114
+ }
115
+ current = parent;
116
+ }
117
+
118
+ return '';
119
+ }
120
+
121
+ function _resetRuleSourceCache() {
122
+ _ruleSourceCache.clear();
123
+ }
124
+
125
+ function _summarizeDownstreamTasks(rawInput, config = {}) {
126
+ const input = String(rawInput || '');
127
+ if (_isEnabled(config.fullDownstream, 'RALPH_SELF_HEAL_FULL_DOWNSTREAM')) {
128
+ return input;
129
+ }
130
+
131
+ const taskBlocks = _splitTaskBlocks(input);
132
+ const summaries = [];
133
+ for (const block of taskBlocks) {
134
+ const lines = block.split('\n');
135
+ const firstLine = lines[0] || '';
136
+ const match = firstLine.match(/^-\s+\[(?: |\/)\]\s+(.+)$/);
137
+ if (!match) {
138
+ continue;
139
+ }
140
+
141
+ const scopeLine = lines.find((line) => /^\s+-\s+Scope:/.test(line));
142
+ const summaryLines = [`- ${match[1].trim()}`];
143
+ if (scopeLine) {
144
+ summaryLines.push(scopeLine);
145
+ }
146
+ summaries.push(summaryLines.join('\n'));
147
+ }
148
+
149
+ return summaries.join('\n\n');
150
+ }
151
+
152
+ function _extractDesignSections(rawInput, config = {}) {
153
+ const input = String(rawInput || '');
154
+ if (_isEnabled(config.fullDesign, 'RALPH_SELF_HEAL_FULL_DESIGN')) {
155
+ return input;
156
+ }
157
+
158
+ return _extractNamedSections(input, [
159
+ 'Scope',
160
+ 'Non-goals',
161
+ 'Policy decisions',
162
+ 'Policy choices',
163
+ 'Decisions',
164
+ ]);
165
+ }
166
+
167
+ function _extractProposalSections(rawInput, config = {}) {
168
+ const input = String(rawInput || '');
169
+ if (_isEnabled(config.fullProposal, 'RALPH_SELF_HEAL_FULL_PROPOSAL')) {
170
+ return input;
171
+ }
172
+
173
+ return _extractNamedSections(input, [
174
+ 'Why',
175
+ 'What Changes',
176
+ 'Goals',
177
+ 'Non-goals',
178
+ ]);
179
+ }
180
+
181
+ function _distillRalphBP(rawInput, config = {}) {
182
+ const input = String(rawInput || '');
183
+ if (_isEnabled(config.fullBpContext, 'RALPH_SELF_HEAL_FULL_BP_CONTEXT')) {
184
+ return input;
185
+ }
186
+
187
+ const parts = [];
188
+ const taskTemplateFence = _extractFirstFenceFromSection(input, 'Task template');
189
+ if (taskTemplateFence) {
190
+ parts.push(['## Task template', '', taskTemplateFence].join('\n'));
191
+ }
192
+
193
+ const sizingLines = input
194
+ .split('\n')
195
+ .filter((line) => /^\*\*(?:Medium|Lightweight) profile\*\*/.test(line.trim()));
196
+ if (sizingLines.length > 0) {
197
+ parts.push(['## Sizing profiles', '', ...sizingLines].join('\n'));
198
+ }
199
+
200
+ const verifierRules = _extractBulletLinesFromSection(input, 'Surgical validation', [
201
+ /Start every task with the cheapest verifier/i,
202
+ /Verify command routing before writing it into `tasks\.md`/i,
203
+ /Use broad gates .* only when/i,
204
+ /Prefer one focused verifier per task/i,
205
+ ]);
206
+ if (verifierRules.length > 0) {
207
+ parts.push(['## Verifier cluster rule', '', ...verifierRules].join('\n'));
208
+ }
209
+
210
+ const distilled = parts.filter(Boolean).join('\n\n').trim();
211
+ if (!distilled) {
212
+ return _fallbackSectionContent(input);
213
+ }
214
+
215
+ if (Buffer.byteLength(distilled, 'utf8') <= _SECTION_FALLBACK_MAX_BYTES) {
216
+ return distilled;
217
+ }
218
+
219
+ const trimmed = [];
220
+ if (taskTemplateFence) {
221
+ trimmed.push(['## Task template', '', taskTemplateFence].join('\n'));
222
+ }
223
+ if (sizingLines.length > 0) {
224
+ trimmed.push(['## Sizing profiles', '', ...sizingLines].join('\n'));
225
+ }
226
+ const minimal = trimmed.filter(Boolean).join('\n\n').trim();
227
+ if (minimal && Buffer.byteLength(minimal, 'utf8') <= _SECTION_FALLBACK_MAX_BYTES) {
228
+ return minimal;
229
+ }
230
+
231
+ return _truncateBytes(minimal || distilled, _SECTION_FALLBACK_MAX_BYTES);
232
+ }
233
+
234
+ function _extractNamedSections(input, headings) {
235
+ const matchedSections = [];
236
+ for (const heading of headings) {
237
+ const section = _extractSectionByHeading(input, heading);
238
+ if (section) {
239
+ matchedSections.push(section);
240
+ }
241
+ }
242
+
243
+ if (matchedSections.length === 0) {
244
+ return _fallbackSectionContent(input);
245
+ }
246
+
247
+ return matchedSections.join('\n\n').trim();
248
+ }
249
+
250
+ function _extractSectionByHeading(input, heading) {
251
+ const lines = String(input || '').split('\n');
252
+ const headingRegex = new RegExp(`^##\\s+${_escapeRegExp(heading)}\\s*$`, 'i');
253
+ let startIndex = -1;
254
+
255
+ for (let index = 0; index < lines.length; index += 1) {
256
+ if (headingRegex.test(lines[index])) {
257
+ startIndex = index;
258
+ break;
259
+ }
260
+ }
261
+
262
+ if (startIndex === -1) {
263
+ return '';
264
+ }
265
+
266
+ let endIndex = lines.length;
267
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
268
+ if (/^##\s+/.test(lines[index])) {
269
+ endIndex = index;
270
+ break;
271
+ }
272
+ }
273
+
274
+ return lines.slice(startIndex, endIndex).join('\n').trim();
275
+ }
276
+
277
+ function _extractFirstFenceFromSection(input, heading) {
278
+ const section = _extractSectionByHeading(input, heading);
279
+ if (!section) {
280
+ return '';
281
+ }
282
+
283
+ const match = section.match(/```[\s\S]*?```/);
284
+ return match ? match[0] : '';
285
+ }
286
+
287
+ function _extractBulletLinesFromSection(input, heading, patterns) {
288
+ const section = _extractSectionByHeading(input, heading);
289
+ if (!section) {
290
+ return [];
291
+ }
292
+
293
+ return section
294
+ .split('\n')
295
+ .filter((line) => {
296
+ const trimmed = line.trim();
297
+ if (!trimmed.startsWith('- ')) {
298
+ return false;
299
+ }
300
+ return patterns.some((pattern) => pattern.test(trimmed));
301
+ });
302
+ }
303
+
304
+ function _splitTaskBlocks(input) {
305
+ const lines = String(input || '').split('\n');
306
+ const blocks = [];
307
+ let current = [];
308
+
309
+ for (const line of lines) {
310
+ if (/^-\s+\[(?: |\/)\]\s+/.test(line)) {
311
+ if (current.length > 0) {
312
+ blocks.push(current.join('\n').trimEnd());
313
+ }
314
+ current = [line];
315
+ continue;
316
+ }
317
+
318
+ if (current.length > 0) {
319
+ current.push(line);
320
+ }
321
+ }
322
+
323
+ if (current.length > 0) {
324
+ blocks.push(current.join('\n').trimEnd());
325
+ }
326
+
327
+ return blocks;
328
+ }
329
+
330
+ function _fallbackSectionContent(input) {
331
+ const sentinel = '[fallback: first 4KB; no recognized sections found]';
332
+ const truncated = _truncateBytes(String(input || ''), _SECTION_FALLBACK_MAX_BYTES, sentinel);
333
+ return truncated.includes(sentinel) ? truncated : `${sentinel}\n${truncated}`.trim();
334
+ }
335
+
336
+ function _truncateBytes(content, maxBytes, sentinel = '') {
337
+ const buffer = Buffer.from(String(content || ''), 'utf8');
338
+ if (buffer.byteLength <= maxBytes) {
339
+ return String(content || '');
340
+ }
341
+
342
+ const suffix = sentinel ? `\n${sentinel}` : '';
343
+ const keepBytes = Math.max(0, maxBytes - Buffer.byteLength(suffix, 'utf8'));
344
+ return buffer.subarray(0, keepBytes).toString('utf8') + suffix;
345
+ }
346
+
347
+ function _isEnabled(configValue, envName) {
348
+ if (configValue === true || configValue === false) {
349
+ return configValue;
350
+ }
351
+ return process.env[envName] === '1';
352
+ }
353
+
354
+ function _escapeRegExp(value) {
355
+ return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
356
+ }
357
+
358
+ module.exports = {
359
+ _RULE_SOURCE_MAX_BYTES,
360
+ _SECTION_FALLBACK_MAX_BYTES,
361
+ _loadRuleSources,
362
+ _readRuleSource,
363
+ _truncateRuleSource,
364
+ _resolveOpenspecRootFromTasks,
365
+ _resetRuleSourceCache,
366
+ _summarizeDownstreamTasks,
367
+ _extractDesignSections,
368
+ _extractProposalSections,
369
+ _distillRalphBP,
370
+ _extractNamedSections,
371
+ _extractSectionByHeading,
372
+ _extractFirstFenceFromSection,
373
+ _extractBulletLinesFromSection,
374
+ _splitTaskBlocks,
375
+ _fallbackSectionContent,
376
+ _truncateBytes,
377
+ _isEnabled,
378
+ _escapeRegExp,
379
+ };