spec-and-loop 3.3.2 → 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.
@@ -0,0 +1,1319 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * supervisor.js - Supervisor-loop helpers for mini-ralph.
5
+ *
6
+ * This initial slice freezes the prompt-template variable list and the
7
+ * response-parser contract before orchestration and patch application land.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const childProcess = require('child_process');
13
+ const crypto = require('crypto');
14
+
15
+ const tasks = require('./tasks');
16
+ const state = require('./state');
17
+ const invoker = require('./invoker');
18
+ const { _renderTemplate } = require('./prompt');
19
+ const rules = require('./supervisor-rules');
20
+ const stateHelpers = require('./supervisor-state');
21
+
22
+ const _SUPERVISOR_TMP_SUFFIX = '.supervisor-tmp';
23
+ const _SUPERVISOR_ORIG_SUFFIX = '.supervisor-orig';
24
+
25
+ const _loadRuleSources = rules._loadRuleSources;
26
+ const _resetRuleSourceCache = rules._resetRuleSourceCache;
27
+ const _summarizeDownstreamTasks = rules._summarizeDownstreamTasks;
28
+ const _extractDesignSections = rules._extractDesignSections;
29
+ const _extractProposalSections = rules._extractProposalSections;
30
+ const _distillRalphBP = rules._distillRalphBP;
31
+ const _isEnabled = rules._isEnabled;
32
+ const _escapeRegExp = rules._escapeRegExp;
33
+ const _resolveOpenspecRootFromTasks = rules._resolveOpenspecRootFromTasks;
34
+
35
+ const _computeBlockerHash = stateHelpers._computeBlockerHash;
36
+ const _readSupervisorState = stateHelpers._readSupervisorState;
37
+ const _writeSupervisorState = stateHelpers._writeSupervisorState;
38
+ const _nonNegativeInteger = stateHelpers._nonNegativeInteger;
39
+ const _decideBoundedBudget = stateHelpers._decideBoundedBudget;
40
+ const _consumeBoundedBudget = stateHelpers._consumeBoundedBudget;
41
+ const _normalizeInvestigationHints = stateHelpers._normalizeInvestigationHints;
42
+ const _formatPreviousSupervisorAttempts = stateHelpers._formatPreviousSupervisorAttempts;
43
+ const _joinSummaryParts = stateHelpers._joinSummaryParts;
44
+ const _firstNonEmptyText = stateHelpers._firstNonEmptyText;
45
+ const _buildSupervisorReturn = stateHelpers._buildSupervisorReturn;
46
+
47
+ const _SUPERVISOR_TEMPLATE_VARIABLES = Object.freeze([
48
+ 'blocker_note',
49
+ 'current_task_number',
50
+ 'current_task_body',
51
+ 'downstream_tasks',
52
+ 'handoff_history',
53
+ 'recent_iterations',
54
+ 'try_index',
55
+ 'previous_supervisor_attempts',
56
+ 'openspec_config_rules',
57
+ 'ralph_authoring_rules',
58
+ 'change_proposal',
59
+ 'change_design',
60
+ 'run_stdout_log_path',
61
+ 'run_stderr_log_path',
62
+ ]);
63
+
64
+ /**
65
+ * Parse the supervisor's fenced JSON response.
66
+ *
67
+ * Missing fences and malformed JSON are infrastructure failures. Unknown task
68
+ * numbers are structural rejections when a tasks file is available via
69
+ * `RALPH_TASKS_FILE`.
70
+ *
71
+ * @param {string} stdout
72
+ * @returns {object}
73
+ */
74
+ function _parseSupervisorResponse(stdout) {
75
+ const match = String(stdout || '').match(/```supervisor-response\s*([\s\S]*?)```/);
76
+ if (!match) {
77
+ return { kind: 'infra_failure', reason: 'missing_fence' };
78
+ }
79
+
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(match[1].trim());
83
+ } catch {
84
+ return { kind: 'infra_failure', reason: 'malformed_json' };
85
+ }
86
+
87
+ const unknownTaskNumber = _findUnknownTaskNumber(parsed);
88
+ if (unknownTaskNumber) {
89
+ return {
90
+ kind: 'structural_rejection',
91
+ reason: 'unknown_task_number',
92
+ taskNumber: unknownTaskNumber,
93
+ };
94
+ }
95
+
96
+ return {
97
+ current_task_patch: Object.prototype.hasOwnProperty.call(parsed, 'current_task_patch')
98
+ ? parsed.current_task_patch
99
+ : null,
100
+ downstream_patches: Array.isArray(parsed.downstream_patches) ? parsed.downstream_patches : [],
101
+ investigation_hints: Array.isArray(parsed.investigation_hints) ? parsed.investigation_hints : [],
102
+ summary: typeof parsed.summary === 'string' ? parsed.summary : '',
103
+ downstream_rationale: typeof parsed.downstream_rationale === 'string'
104
+ ? parsed.downstream_rationale
105
+ : '',
106
+ };
107
+ }
108
+
109
+ function _findUnknownTaskNumber(parsed) {
110
+ const knownTaskNumbers = _readKnownTaskNumbers();
111
+ if (!knownTaskNumbers || knownTaskNumbers.size === 0) {
112
+ return '';
113
+ }
114
+
115
+ const candidateNumbers = [];
116
+ if (parsed && parsed.current_task_patch && typeof parsed.current_task_patch === 'object') {
117
+ candidateNumbers.push(parsed.current_task_patch.task_number);
118
+ }
119
+
120
+ if (parsed && Array.isArray(parsed.downstream_patches)) {
121
+ for (const patch of parsed.downstream_patches) {
122
+ if (!patch || typeof patch !== 'object') continue;
123
+ candidateNumbers.push(patch.task_number);
124
+ candidateNumbers.push(patch.anchor_task_number);
125
+ }
126
+ }
127
+
128
+ for (const taskNumber of candidateNumbers) {
129
+ if (typeof taskNumber === 'string' && taskNumber.trim() && !knownTaskNumbers.has(taskNumber.trim())) {
130
+ return taskNumber.trim();
131
+ }
132
+ }
133
+
134
+ return '';
135
+ }
136
+
137
+ function _readKnownTaskNumbers() {
138
+ const tasksFile = process.env.RALPH_TASKS_FILE;
139
+ if (!tasksFile) {
140
+ return null;
141
+ }
142
+
143
+ return new Set(
144
+ tasks.parseTasks(tasksFile)
145
+ .map((task) => task.number)
146
+ .filter(Boolean)
147
+ );
148
+ }
149
+
150
+ function _renderSupervisorPrompt(options = {}) {
151
+ const templatePath = options.templatePath
152
+ ? path.resolve(options.templatePath)
153
+ : path.join(path.resolve(__dirname, '..', '..'), 'scripts', 'supervisor-prompt.md');
154
+ if (!fs.existsSync(templatePath)) {
155
+ throw new Error(`mini-ralph supervisor: template file not found: ${templatePath}`);
156
+ }
157
+
158
+ const template = fs.readFileSync(templatePath, 'utf8');
159
+ if (!template.trim()) {
160
+ throw new Error(`mini-ralph supervisor: template file is empty: ${templatePath}`);
161
+ }
162
+
163
+ const renderedVars = _buildSupervisorTemplateVars(options);
164
+ return _renderTemplate(template, renderedVars)
165
+ .replace(/\{\{tasks_md_path\}\}/g, '')
166
+ .replace(/\{\{blocker_hash\}\}/g, '');
167
+ }
168
+
169
+ function _buildSupervisorTemplateVars(options) {
170
+ const requiredInputs = [
171
+ 'blockerNote',
172
+ 'currentTaskNumber',
173
+ 'currentTaskBody',
174
+ 'downstreamTasks',
175
+ 'handoffHistory',
176
+ 'recentIterations',
177
+ 'tryIndex',
178
+ 'previousSupervisorAttempts',
179
+ ];
180
+
181
+ for (const key of requiredInputs) {
182
+ if (options[key] === undefined || options[key] === null) {
183
+ throw new Error(`mini-ralph supervisor: missing renderer input for ${key}`);
184
+ }
185
+ }
186
+
187
+ const tryIndex = Number(options.tryIndex);
188
+ if (!Number.isInteger(tryIndex) || tryIndex < 1) {
189
+ throw new Error('mini-ralph supervisor: tryIndex must be a positive integer');
190
+ }
191
+
192
+ const ruleSources = _loadRuleSources(options);
193
+ const retrySuppressed = tryIndex >= 2;
194
+ const suppressDownstream = retrySuppressed && !_isEnabled(options.keepDownstreamOnRetry, 'RALPH_SELF_HEAL_KEEP_DOWNSTREAM_ON_RETRY');
195
+ const suppressHandoffHistory = retrySuppressed && !_isEnabled(options.keepHandoffHistoryOnRetry, 'RALPH_SELF_HEAL_KEEP_HANDOFF_HISTORY_ON_RETRY');
196
+
197
+ return {
198
+ blocker_note: String(options.blockerNote),
199
+ current_task_number: String(options.currentTaskNumber),
200
+ current_task_body: String(options.currentTaskBody),
201
+ downstream_tasks: suppressDownstream
202
+ ? '[suppressed on retry; see try 1]'
203
+ : _summarizeDownstreamTasks(options.downstreamTasks, options),
204
+ handoff_history: suppressHandoffHistory
205
+ ? '[suppressed on retry; see try 1]'
206
+ : String(options.handoffHistory),
207
+ recent_iterations: String(options.recentIterations),
208
+ try_index: String(tryIndex),
209
+ previous_supervisor_attempts: String(options.previousSupervisorAttempts),
210
+ openspec_config_rules: ruleSources.openspec_config_rules.content,
211
+ ralph_authoring_rules: _distillRalphBP(ruleSources.ralph_authoring_rules.content, options),
212
+ change_proposal: _extractProposalSections(ruleSources.change_proposal.content, options),
213
+ change_design: _extractDesignSections(ruleSources.change_design.content, options),
214
+ run_stdout_log_path: String(options.runStdoutLogPath || ''),
215
+ run_stderr_log_path: String(options.runStderrLogPath || ''),
216
+ };
217
+ }
218
+
219
+ function _detectSizingProfile(ralphAuthoringRules) {
220
+ const defaultProfile = {
221
+ name: 'medium',
222
+ minDoneWhen: 3,
223
+ maxDoneWhen: 7,
224
+ source: 'default_medium',
225
+ };
226
+ const input = String(ralphAuthoringRules || '');
227
+
228
+ const lightweightRange = _extractSizingRange(input, 'Lightweight');
229
+ if (lightweightRange) {
230
+ return {
231
+ name: 'lightweight',
232
+ minDoneWhen: lightweightRange.min,
233
+ maxDoneWhen: lightweightRange.max,
234
+ source: 'bp_lightweight',
235
+ };
236
+ }
237
+
238
+ const mediumRange = _extractSizingRange(input, 'Medium');
239
+ if (mediumRange) {
240
+ return {
241
+ name: 'medium',
242
+ minDoneWhen: mediumRange.min,
243
+ maxDoneWhen: mediumRange.max,
244
+ source: 'bp_medium',
245
+ };
246
+ }
247
+
248
+ return defaultProfile;
249
+ }
250
+
251
+ function _extractSizingRange(input, profileName) {
252
+ const lines = String(input || '').split('\n');
253
+ const heading = `**${profileName} profile**`;
254
+
255
+ for (const line of lines) {
256
+ if (!line.includes(heading)) {
257
+ continue;
258
+ }
259
+
260
+ const match = line.match(/(\d+)\s*[–-]\s*(\d+)\s+`?Done when`?\s+bullets/i);
261
+ if (!match) {
262
+ continue;
263
+ }
264
+
265
+ return {
266
+ min: Number(match[1]),
267
+ max: Number(match[2]),
268
+ };
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ function _validateTaskStructure(taskBody, options = {}) {
275
+ const body = String(taskBody || '').replace(/\r\n/g, '\n');
276
+ const lines = body.split('\n');
277
+ const errors = [];
278
+ const warnings = [];
279
+ const sizingProfile = options.sizingProfile || _detectSizingProfile(options.ralphAuthoringRules || '');
280
+ const titleLine = lines.find((line) => line.trim()) || '';
281
+
282
+ if (!/^\s*-\s+\[ \]\s+(?:\d+(?:\.\d+)*\s+)?\*\*.+\*\*/.test(titleLine)) {
283
+ errors.push('bold_title_missing: task body must start with a pending checkbox line containing a bold title');
284
+ }
285
+
286
+ const scopeIndex = _findTaskBulletIndex(lines, 'Scope:');
287
+ if (scopeIndex === -1) {
288
+ errors.push('scope_missing: task body must include a `Scope:` bullet');
289
+ }
290
+
291
+ const changeIndex = _findTaskBulletIndex(lines, 'Change:');
292
+ if (changeIndex === -1) {
293
+ errors.push('change_missing: task body must include a `Change:` bullet');
294
+ }
295
+
296
+ const doneWhenIndex = _findTaskBulletIndex(lines, 'Done when:');
297
+ const doneWhenBullets = doneWhenIndex === -1 ? [] : _collectNestedBulletLines(lines, doneWhenIndex);
298
+ if (doneWhenIndex === -1) {
299
+ errors.push('done_when_missing: task body must include a `Done when:` bullet');
300
+ } else if (doneWhenBullets.length < sizingProfile.minDoneWhen) {
301
+ errors.push(
302
+ `done_when_count_under_spec: expected ${sizingProfile.minDoneWhen}-${sizingProfile.maxDoneWhen} nested bullets under \`Done when:\`, found ${doneWhenBullets.length}`
303
+ );
304
+ } else if (doneWhenBullets.length > sizingProfile.maxDoneWhen) {
305
+ errors.push(
306
+ `done_when_count_over_spec: expected ${sizingProfile.minDoneWhen}-${sizingProfile.maxDoneWhen} nested bullets under \`Done when:\`, found ${doneWhenBullets.length}`
307
+ );
308
+ }
309
+
310
+ const stopIndex = _findTaskBulletIndex(lines, 'Stop and hand off if:');
311
+ const stopBullets = stopIndex === -1 ? [] : _collectNestedBulletLines(lines, stopIndex);
312
+ if (stopIndex === -1) {
313
+ errors.push('stop_and_hand_off_missing: task body must include a `Stop and hand off if:` bullet');
314
+ } else if (stopBullets.length < 1) {
315
+ errors.push('stop_and_hand_off_subbullet_missing: `Stop and hand off if:` must include at least one nested sub-bullet');
316
+ }
317
+
318
+ if (!/<!--\s*supervised-edit:/i.test(body)) {
319
+ errors.push('audit_comment_missing: task body must include a `<!-- supervised-edit: ... -->` audit comment');
320
+ }
321
+
322
+ for (const line of doneWhenBullets) {
323
+ const trimmed = line.trim();
324
+ if (!/\b(ensure|support|validate|keep|maintain)\b/i.test(trimmed)) {
325
+ continue;
326
+ }
327
+ if (/`[^`]+`/.test(trimmed)) {
328
+ continue;
329
+ }
330
+ warnings.push(`soft_verb_without_verifier: ${trimmed}`);
331
+ }
332
+
333
+ return {
334
+ ok: errors.length === 0,
335
+ errors,
336
+ warnings,
337
+ sizingProfile,
338
+ };
339
+ }
340
+
341
+ function _findTaskBulletIndex(lines, label) {
342
+ return lines.findIndex((line) => new RegExp(`^\\s+-\\s+${_escapeRegExp(label)}`).test(line));
343
+ }
344
+
345
+ function _collectNestedBulletLines(lines, parentIndex) {
346
+ if (parentIndex < 0 || parentIndex >= lines.length) {
347
+ return [];
348
+ }
349
+
350
+ const parentMatch = lines[parentIndex].match(/^(\s*)-\s+/);
351
+ const parentIndent = parentMatch ? parentMatch[1].length : 0;
352
+ const nested = [];
353
+
354
+ for (let index = parentIndex + 1; index < lines.length; index += 1) {
355
+ const line = lines[index];
356
+ const bulletMatch = line.match(/^(\s*)-\s+/);
357
+ if (bulletMatch) {
358
+ const indent = bulletMatch[1].length;
359
+ if (indent <= parentIndent) {
360
+ break;
361
+ }
362
+ nested.push(line);
363
+ continue;
364
+ }
365
+
366
+ if (line.trim() && line.match(/^\s*[^-]/) && line.search(/\S/) <= parentIndent) {
367
+ break;
368
+ }
369
+ }
370
+
371
+ return nested;
372
+ }
373
+
374
+ function _applyTaskPatch(options = {}) {
375
+ const tasksFile = options.tasksFile ? path.resolve(options.tasksFile) : '';
376
+ const patchedContent = String(options.patchedContent || '');
377
+ const timeout = Number.isInteger(options.validationTimeoutMs) && options.validationTimeoutMs > 0
378
+ ? options.validationTimeoutMs
379
+ : 30000;
380
+
381
+ if (!tasksFile) {
382
+ throw new Error('mini-ralph supervisor: tasksFile is required for patch application');
383
+ }
384
+ if (!fs.existsSync(tasksFile)) {
385
+ throw new Error(`mini-ralph supervisor: tasks file not found: ${tasksFile}`);
386
+ }
387
+
388
+ const tmpPath = `${tasksFile}${_SUPERVISOR_TMP_SUFFIX}`;
389
+ const origPath = `${tasksFile}${_SUPERVISOR_ORIG_SUFFIX}`;
390
+ const changeDir = path.dirname(tasksFile);
391
+ const activeChangeId = path.basename(changeDir);
392
+ const cwd = options.cwd
393
+ ? path.resolve(options.cwd)
394
+ : _resolveWorkspaceRootFromTasks(tasksFile);
395
+
396
+ if (!cwd) {
397
+ throw new Error(`mini-ralph supervisor: unable to resolve workspace root from tasks file: ${tasksFile}`);
398
+ }
399
+
400
+ fs.writeFileSync(tmpPath, patchedContent, 'utf8');
401
+ fs.renameSync(tasksFile, origPath);
402
+ fs.renameSync(tmpPath, tasksFile);
403
+
404
+ try {
405
+ childProcess.execFileSync('npx', ['openspec', 'validate', activeChangeId, '--strict'], {
406
+ cwd,
407
+ timeout,
408
+ });
409
+ fs.rmSync(origPath, { force: true });
410
+ fs.rmSync(tmpPath, { force: true });
411
+ return {
412
+ ok: true,
413
+ activeChangeId,
414
+ };
415
+ } catch (error) {
416
+ _restoreOriginalTasksFile(tasksFile, origPath, tmpPath);
417
+ return {
418
+ ok: false,
419
+ reason: 'validation_failed',
420
+ activeChangeId,
421
+ stderr: _readExecErrorStream(error, 'stderr'),
422
+ stdout: _readExecErrorStream(error, 'stdout'),
423
+ error,
424
+ };
425
+ }
426
+ }
427
+
428
+ function _recoverSupervisorTmpFiles(options = {}) {
429
+ const tasksFile = options.tasksFile
430
+ ? path.resolve(options.tasksFile)
431
+ : (options.changeDir ? path.join(path.resolve(options.changeDir), 'tasks.md') : '');
432
+
433
+ if (!tasksFile) {
434
+ throw new Error('mini-ralph supervisor: tasksFile or changeDir is required for recovery');
435
+ }
436
+
437
+ const tmpPath = `${tasksFile}${_SUPERVISOR_TMP_SUFFIX}`;
438
+ const origPath = `${tasksFile}${_SUPERVISOR_ORIG_SUFFIX}`;
439
+ const actions = [];
440
+
441
+ if (fs.existsSync(origPath)) {
442
+ if (fs.existsSync(tasksFile)) {
443
+ fs.rmSync(tasksFile, { force: true });
444
+ actions.push(`removed stale tasks file: ${tasksFile}`);
445
+ }
446
+ fs.renameSync(origPath, tasksFile);
447
+ actions.push(`restored tasks file from rollback: ${origPath} -> ${tasksFile}`);
448
+ if (fs.existsSync(tmpPath)) {
449
+ fs.rmSync(tmpPath, { force: true });
450
+ actions.push(`discarded staged supervisor tmp: ${tmpPath}`);
451
+ }
452
+ return {
453
+ recovered: true,
454
+ actions,
455
+ };
456
+ }
457
+
458
+ if (fs.existsSync(tmpPath)) {
459
+ if (!fs.existsSync(tasksFile)) {
460
+ fs.renameSync(tmpPath, tasksFile);
461
+ actions.push(`restored tasks file from staged supervisor tmp: ${tmpPath} -> ${tasksFile}`);
462
+ } else {
463
+ fs.rmSync(tmpPath, { force: true });
464
+ actions.push(`discarded orphaned supervisor tmp: ${tmpPath}`);
465
+ }
466
+ }
467
+
468
+ return {
469
+ recovered: actions.length > 0,
470
+ actions,
471
+ };
472
+ }
473
+
474
+ function _resolveWorkspaceRootFromTasks(tasksFile) {
475
+ const openspecRoot = _resolveOpenspecRootFromTasks(tasksFile);
476
+ if (!openspecRoot) {
477
+ return '';
478
+ }
479
+ return path.dirname(openspecRoot);
480
+ }
481
+
482
+ function _resolveRunLogPaths(options = {}) {
483
+ if (process.env.RALPH_SELF_HEAL_LOG_ACCESS === '0') {
484
+ return { stdoutLog: '', stderrLog: '' };
485
+ }
486
+
487
+ const ralphDir = options.ralphDir ? path.resolve(options.ralphDir) : '';
488
+ if (!ralphDir) {
489
+ return { stdoutLog: '', stderrLog: '' };
490
+ }
491
+
492
+ const outputDirFile = path.join(ralphDir, '.output_dir');
493
+ if (!fs.existsSync(outputDirFile)) {
494
+ return { stdoutLog: '', stderrLog: '' };
495
+ }
496
+
497
+ let outputDir = '';
498
+ try {
499
+ outputDir = String(fs.readFileSync(outputDirFile, 'utf8') || '').trim();
500
+ } catch {
501
+ return { stdoutLog: '', stderrLog: '' };
502
+ }
503
+
504
+ if (!outputDir) {
505
+ return { stdoutLog: '', stderrLog: '' };
506
+ }
507
+
508
+ const resolvedOutputDir = path.isAbsolute(outputDir)
509
+ ? path.resolve(outputDir)
510
+ : path.resolve(ralphDir, outputDir);
511
+
512
+ return {
513
+ stdoutLog: _resolveExistingLogPath(path.join(resolvedOutputDir, 'ralph-stdout.log')),
514
+ stderrLog: _resolveExistingLogPath(path.join(resolvedOutputDir, 'ralph-stderr.log')),
515
+ };
516
+ }
517
+
518
+ function _resolveExistingLogPath(filePath) {
519
+ if (!filePath) {
520
+ return '';
521
+ }
522
+
523
+ const resolved = path.resolve(filePath);
524
+ try {
525
+ return fs.statSync(resolved).isFile() ? resolved : '';
526
+ } catch {
527
+ return '';
528
+ }
529
+ }
530
+
531
+ function _detectSupervisorLogReads(options = {}) {
532
+ const result = options.result || {};
533
+ const stdoutLog = String(options.stdoutLog || '');
534
+ const stderrLog = String(options.stderrLog || '');
535
+ const toolUsage = Array.isArray(result.toolUsage) ? result.toolUsage : null;
536
+
537
+ if (!toolUsage || toolUsage.length === 0) {
538
+ return {
539
+ supervisorReadLogs: null,
540
+ supervisorReadLogsBytes: null,
541
+ };
542
+ }
543
+
544
+ const targetPaths = [stdoutLog, stderrLog].filter(Boolean).map((value) => path.resolve(value));
545
+ let sawDetailedUsage = false;
546
+ let matchedRead = false;
547
+ let bytesRead = 0;
548
+
549
+ for (const entry of toolUsage) {
550
+ const analysis = _scanToolUsageEntry(entry, targetPaths);
551
+ if (!analysis.hasDetails) {
552
+ continue;
553
+ }
554
+
555
+ sawDetailedUsage = true;
556
+ if (analysis.matched) {
557
+ matchedRead = true;
558
+ bytesRead += analysis.bytes;
559
+ }
560
+ }
561
+
562
+ if (!sawDetailedUsage) {
563
+ return {
564
+ supervisorReadLogs: null,
565
+ supervisorReadLogsBytes: null,
566
+ };
567
+ }
568
+
569
+ return {
570
+ supervisorReadLogs: matchedRead,
571
+ supervisorReadLogsBytes: matchedRead ? bytesRead : 0,
572
+ };
573
+ }
574
+
575
+ function _scanToolUsageEntry(value, targetPaths) {
576
+ if (Array.isArray(value)) {
577
+ return value.reduce((acc, item) => _mergeToolUsageAnalysis(acc, _scanToolUsageEntry(item, targetPaths)), {
578
+ hasDetails: false,
579
+ matched: false,
580
+ bytes: 0,
581
+ });
582
+ }
583
+
584
+ if (!value || typeof value !== 'object') {
585
+ return { hasDetails: false, matched: false, bytes: 0 };
586
+ }
587
+
588
+ const keys = Object.keys(value);
589
+ const hasDetails = keys.some((key) => !['tool', 'count'].includes(key));
590
+ let matched = false;
591
+ let bytes = 0;
592
+
593
+ for (const [key, child] of Object.entries(value)) {
594
+ if (typeof child === 'string' && _matchesLogPath(child, targetPaths)) {
595
+ matched = true;
596
+ const inferredBytes = _inferReadBytes(value, key);
597
+ if (inferredBytes > 0) {
598
+ bytes += inferredBytes;
599
+ }
600
+ }
601
+
602
+ if (Array.isArray(child) || (child && typeof child === 'object')) {
603
+ const nested = _scanToolUsageEntry(child, targetPaths);
604
+ matched = matched || nested.matched;
605
+ bytes += nested.bytes;
606
+ }
607
+ }
608
+
609
+ if (matched && bytes === 0) {
610
+ bytes = _inferReadBytesDeep(value);
611
+ }
612
+
613
+ return { hasDetails, matched, bytes };
614
+ }
615
+
616
+ function _mergeToolUsageAnalysis(current, next) {
617
+ return {
618
+ hasDetails: current.hasDetails || next.hasDetails,
619
+ matched: current.matched || next.matched,
620
+ bytes: current.bytes + next.bytes,
621
+ };
622
+ }
623
+
624
+ function _matchesLogPath(candidate, targetPaths) {
625
+ const text = String(candidate || '').trim();
626
+ if (!text || targetPaths.length === 0) {
627
+ return false;
628
+ }
629
+
630
+ const normalized = path.isAbsolute(text) ? path.resolve(text) : '';
631
+ if (normalized && targetPaths.includes(normalized)) {
632
+ return true;
633
+ }
634
+
635
+ return targetPaths.some((targetPath) => text.includes(targetPath));
636
+ }
637
+
638
+ function _inferReadBytes(source, matchedKey) {
639
+ const directKeys = [
640
+ 'bytes',
641
+ 'byteCount',
642
+ 'bytesRead',
643
+ 'readBytes',
644
+ 'size',
645
+ 'length',
646
+ ];
647
+ for (const key of directKeys) {
648
+ const value = _toFiniteNonNegativeNumber(source[key]);
649
+ if (value !== null) {
650
+ return value;
651
+ }
652
+ }
653
+
654
+ if (typeof source.content === 'string') {
655
+ return Buffer.byteLength(source.content, 'utf8');
656
+ }
657
+
658
+ if (typeof source.preview === 'string') {
659
+ return Buffer.byteLength(source.preview, 'utf8');
660
+ }
661
+
662
+ if (typeof source.text === 'string' && matchedKey !== 'text') {
663
+ return Buffer.byteLength(source.text, 'utf8');
664
+ }
665
+
666
+ return 0;
667
+ }
668
+
669
+ function _inferReadBytesDeep(value) {
670
+ if (!value || typeof value !== 'object') {
671
+ return 0;
672
+ }
673
+
674
+ const direct = _inferReadBytes(value, '');
675
+ if (direct > 0) {
676
+ return direct;
677
+ }
678
+
679
+ for (const child of Object.values(value)) {
680
+ if (Array.isArray(child)) {
681
+ for (const item of child) {
682
+ const nested = _inferReadBytesDeep(item);
683
+ if (nested > 0) {
684
+ return nested;
685
+ }
686
+ }
687
+ continue;
688
+ }
689
+
690
+ if (child && typeof child === 'object') {
691
+ const nested = _inferReadBytesDeep(child);
692
+ if (nested > 0) {
693
+ return nested;
694
+ }
695
+ }
696
+ }
697
+
698
+ return 0;
699
+ }
700
+
701
+ function _toFiniteNonNegativeNumber(value) {
702
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
703
+ return value;
704
+ }
705
+ return null;
706
+ }
707
+
708
+ function _restoreOriginalTasksFile(tasksFile, origPath, tmpPath) {
709
+ if (fs.existsSync(origPath)) {
710
+ if (fs.existsSync(tasksFile)) {
711
+ fs.rmSync(tasksFile, { force: true });
712
+ }
713
+ fs.renameSync(origPath, tasksFile);
714
+ }
715
+ if (fs.existsSync(tmpPath)) {
716
+ fs.rmSync(tmpPath, { force: true });
717
+ }
718
+ }
719
+
720
+ function _readExecErrorStream(error, streamName) {
721
+ if (!error || !error[streamName]) {
722
+ return '';
723
+ }
724
+
725
+ const value = error[streamName];
726
+ if (Buffer.isBuffer(value)) {
727
+ return value.toString('utf8');
728
+ }
729
+ return String(value);
730
+ }
731
+
732
+ async function runSupervisor(options = {}) {
733
+ const blockerNote = String(options.blockerNote || '');
734
+ const ralphDir = options.ralphDir ? path.resolve(options.ralphDir) : '';
735
+ const changeDir = options.changeDir ? path.resolve(options.changeDir) : '';
736
+ const openspecRoot = options.openspecRoot ? path.resolve(options.openspecRoot) : '';
737
+ const config = Object.assign({
738
+ selfHealMaxTries: 3,
739
+ selfHealDownstream: true,
740
+ selfHealHints: true,
741
+ selfHealLogAccess: true,
742
+ selfHealVerbose: false,
743
+ validationTimeoutMs: 30000,
744
+ }, options.config || {});
745
+ const tasksFile = options.tasksFile
746
+ ? path.resolve(options.tasksFile)
747
+ : path.join(changeDir, 'tasks.md');
748
+
749
+ if (!ralphDir) {
750
+ throw new Error('mini-ralph supervisor: ralphDir is required');
751
+ }
752
+ if (!changeDir) {
753
+ throw new Error('mini-ralph supervisor: changeDir is required');
754
+ }
755
+ if (!fs.existsSync(tasksFile)) {
756
+ throw new Error(`mini-ralph supervisor: tasks file not found: ${tasksFile}`);
757
+ }
758
+
759
+ const invokeSupervisor = options.invoke || invoker.invoke;
760
+ const renderSupervisorPrompt = options.renderPrompt || _renderSupervisorPrompt;
761
+ const parseSupervisorResponse = options.parseResponse || _parseSupervisorResponse;
762
+ const validateTaskStructure = options.validateTaskStructure || _validateTaskStructure;
763
+ const applyTaskPatch = options.applyTaskPatch || _applyTaskPatch;
764
+ const resolveRunLogPaths = options.resolveRunLogPaths || _resolveRunLogPaths;
765
+ const detectSupervisorLogReads = options.detectSupervisorLogReads || _detectSupervisorLogReads;
766
+
767
+ const blockerHash = _computeBlockerHash(blockerNote);
768
+ const supervisorState = _readSupervisorState(ralphDir);
769
+ const sameEvent = supervisorState.currentBlockerHash === blockerHash;
770
+ let triesUsed = sameEvent ? _nonNegativeInteger(supervisorState.triesUsedForCurrentBlocker) : 0;
771
+ let totalAttempts = sameEvent ? _nonNegativeInteger(supervisorState.totalAttemptsForCurrentBlocker) : 0;
772
+ let lastOutcome = sameEvent ? String(supervisorState.lastOutcome || '') : '';
773
+ const maxTries = Number.isInteger(config.selfHealMaxTries) && config.selfHealMaxTries > 0
774
+ ? config.selfHealMaxTries
775
+ : 3;
776
+ const hardAttemptCap = 5;
777
+ const previousSupervisorAttempts = [];
778
+ const supervisorAttempts = [];
779
+
780
+ if (sameEvent && lastOutcome === 'patch_applied') {
781
+ _writeSupervisorState(ralphDir, {
782
+ currentBlockerHash: blockerHash,
783
+ triesUsedForCurrentBlocker: triesUsed,
784
+ totalAttemptsForCurrentBlocker: totalAttempts,
785
+ lastOutcome: 'oscillation',
786
+ });
787
+ return _buildSupervisorReturn({
788
+ outcome: 'blocked_handoff',
789
+ summary: 'Supervisor stopped after the same blocker hash reappeared for the current blocker event.',
790
+ blockerHash,
791
+ readLogs: false,
792
+ readLogsBytes: 0,
793
+ });
794
+ }
795
+
796
+ while (triesUsed < maxTries && totalAttempts < hardAttemptCap) {
797
+ const taskSnapshot = _readTaskSnapshot(tasksFile);
798
+ const currentTask = taskSnapshot.currentTask;
799
+ if (!currentTask) {
800
+ _writeSupervisorState(ralphDir, {
801
+ currentBlockerHash: blockerHash,
802
+ triesUsedForCurrentBlocker: triesUsed,
803
+ totalAttemptsForCurrentBlocker: totalAttempts,
804
+ lastOutcome: 'no_pending_task',
805
+ });
806
+ return _buildSupervisorReturn({
807
+ outcome: 'blocked_handoff',
808
+ summary: 'Supervisor could not find a pending task to patch.',
809
+ blockerHash,
810
+ readLogs: false,
811
+ readLogsBytes: 0,
812
+ });
813
+ }
814
+
815
+ const tryIndex = triesUsed + 1;
816
+ const logPaths = config.selfHealLogAccess
817
+ ? resolveRunLogPaths({ ralphDir, changeDir })
818
+ : { stdoutLog: '', stderrLog: '' };
819
+ const prompt = renderSupervisorPrompt({
820
+ blockerNote,
821
+ changeDir,
822
+ openspecRoot,
823
+ tasksFile,
824
+ currentTaskNumber: currentTask.number,
825
+ currentTaskBody: currentTask.body,
826
+ downstreamTasks: taskSnapshot.downstreamTasks,
827
+ handoffHistory: '',
828
+ recentIterations: '',
829
+ tryIndex,
830
+ previousSupervisorAttempts: _formatPreviousSupervisorAttempts(previousSupervisorAttempts),
831
+ runStdoutLogPath: logPaths.stdoutLog || '',
832
+ runStderrLogPath: logPaths.stderrLog || '',
833
+ });
834
+
835
+ let result;
836
+ try {
837
+ result = await invokeSupervisor({
838
+ prompt,
839
+ model: options.model,
840
+ noCommit: true,
841
+ verbose: config.selfHealVerbose,
842
+ ralphDir,
843
+ });
844
+ } catch (error) {
845
+ totalAttempts += 1;
846
+ lastOutcome = 'infra_failure';
847
+ previousSupervisorAttempts.push(`attempt ${totalAttempts}: infra_failure ${error.message}`);
848
+ _writeSupervisorState(ralphDir, {
849
+ currentBlockerHash: blockerHash,
850
+ triesUsedForCurrentBlocker: triesUsed,
851
+ totalAttemptsForCurrentBlocker: totalAttempts,
852
+ lastOutcome,
853
+ });
854
+ continue;
855
+ }
856
+
857
+ totalAttempts += 1;
858
+ const logAudit = detectSupervisorLogReads({
859
+ result,
860
+ stdoutLog: logPaths.stdoutLog || '',
861
+ stderrLog: logPaths.stderrLog || '',
862
+ }) || {};
863
+ const readLogs = Object.prototype.hasOwnProperty.call(logAudit, 'supervisorReadLogs')
864
+ ? logAudit.supervisorReadLogs
865
+ : null;
866
+ const readLogsBytes = Object.prototype.hasOwnProperty.call(logAudit, 'supervisorReadLogsBytes')
867
+ ? logAudit.supervisorReadLogsBytes
868
+ : null;
869
+
870
+ const parsed = parseSupervisorResponse(result.stdout || '');
871
+ if (parsed && parsed.kind === 'infra_failure') {
872
+ lastOutcome = 'infra_failure';
873
+ previousSupervisorAttempts.push(`attempt ${totalAttempts}: infra_failure ${parsed.reason}`);
874
+ _writeSupervisorState(ralphDir, {
875
+ currentBlockerHash: blockerHash,
876
+ triesUsedForCurrentBlocker: triesUsed,
877
+ totalAttemptsForCurrentBlocker: totalAttempts,
878
+ lastOutcome,
879
+ });
880
+ continue;
881
+ }
882
+
883
+ if (parsed && parsed.kind === 'structural_rejection') {
884
+ triesUsed += 1;
885
+ lastOutcome = 'patch_rejected_structural';
886
+ const attemptLine = `try ${triesUsed}: patch_rejected_structural ${parsed.reason}`;
887
+ previousSupervisorAttempts.push(attemptLine);
888
+ supervisorAttempts.push(attemptLine);
889
+ _writeSupervisorState(ralphDir, {
890
+ currentBlockerHash: blockerHash,
891
+ triesUsedForCurrentBlocker: triesUsed,
892
+ totalAttemptsForCurrentBlocker: totalAttempts,
893
+ lastOutcome,
894
+ });
895
+ continue;
896
+ }
897
+
898
+ if (!parsed || typeof parsed !== 'object') {
899
+ lastOutcome = 'infra_failure';
900
+ previousSupervisorAttempts.push(`attempt ${totalAttempts}: infra_failure invalid response object`);
901
+ _writeSupervisorState(ralphDir, {
902
+ currentBlockerHash: blockerHash,
903
+ triesUsedForCurrentBlocker: triesUsed,
904
+ totalAttemptsForCurrentBlocker: totalAttempts,
905
+ lastOutcome,
906
+ });
907
+ continue;
908
+ }
909
+
910
+ const normalizedHints = config.selfHealHints
911
+ ? _normalizeInvestigationHints(parsed.investigation_hints, { openspecRoot, changeDir, ralphDir })
912
+ : { hints: [], hintsDropped: [] };
913
+
914
+ if (parsed.current_task_patch === null) {
915
+ triesUsed += 1;
916
+ lastOutcome = 'declined';
917
+ supervisorAttempts.push(`try ${triesUsed}: declined ${parsed.summary || 'Supervisor declined to patch the current task.'}`);
918
+ _writeSupervisorState(ralphDir, {
919
+ currentBlockerHash: blockerHash,
920
+ triesUsedForCurrentBlocker: triesUsed,
921
+ totalAttemptsForCurrentBlocker: totalAttempts,
922
+ lastOutcome,
923
+ });
924
+ return _buildSupervisorReturn({
925
+ outcome: 'blocked_handoff',
926
+ summary: parsed.summary || 'Supervisor declined to patch the current task.',
927
+ blockerHash,
928
+ hints: normalizedHints.hints,
929
+ hintsDropped: normalizedHints.hintsDropped,
930
+ attempts: supervisorAttempts,
931
+ readLogs,
932
+ readLogsBytes,
933
+ });
934
+ }
935
+
936
+ if (!parsed.current_task_patch || typeof parsed.current_task_patch !== 'object') {
937
+ triesUsed += 1;
938
+ lastOutcome = 'patch_rejected_structural';
939
+ const attemptLine = `try ${triesUsed}: patch_rejected_structural missing current_task_patch`;
940
+ previousSupervisorAttempts.push(attemptLine);
941
+ supervisorAttempts.push(attemptLine);
942
+ _writeSupervisorState(ralphDir, {
943
+ currentBlockerHash: blockerHash,
944
+ triesUsedForCurrentBlocker: triesUsed,
945
+ totalAttemptsForCurrentBlocker: totalAttempts,
946
+ lastOutcome,
947
+ });
948
+ continue;
949
+ }
950
+
951
+ if (String(parsed.current_task_patch.task_number || '').trim() !== currentTask.number) {
952
+ triesUsed += 1;
953
+ lastOutcome = 'patch_rejected_structural';
954
+ const attemptLine = `try ${triesUsed}: patch_rejected_structural current task mismatch (${parsed.current_task_patch.task_number || 'missing'})`;
955
+ previousSupervisorAttempts.push(attemptLine);
956
+ supervisorAttempts.push(attemptLine);
957
+ _writeSupervisorState(ralphDir, {
958
+ currentBlockerHash: blockerHash,
959
+ triesUsedForCurrentBlocker: triesUsed,
960
+ totalAttemptsForCurrentBlocker: totalAttempts,
961
+ lastOutcome,
962
+ });
963
+ continue;
964
+ }
965
+
966
+ const currentPatchResult = _attemptStructuredTaskPatch({
967
+ content: taskSnapshot.content,
968
+ taskSnapshot,
969
+ patch: parsed.current_task_patch,
970
+ blockerHash,
971
+ iteration: options.iteration,
972
+ tasksFile,
973
+ applyTaskPatch,
974
+ validateTaskStructure,
975
+ validationTimeoutMs: config.validationTimeoutMs,
976
+ currentTaskNumber: currentTask.number,
977
+ });
978
+ if (!currentPatchResult.ok) {
979
+ triesUsed += 1;
980
+ lastOutcome = currentPatchResult.reason;
981
+ const attemptLine = `try ${triesUsed}: ${currentPatchResult.reason} ${currentPatchResult.detail}`.trim();
982
+ previousSupervisorAttempts.push(attemptLine);
983
+ supervisorAttempts.push(attemptLine);
984
+ _writeSupervisorState(ralphDir, {
985
+ currentBlockerHash: blockerHash,
986
+ triesUsedForCurrentBlocker: triesUsed,
987
+ totalAttemptsForCurrentBlocker: totalAttempts,
988
+ lastOutcome,
989
+ });
990
+ continue;
991
+ }
992
+
993
+ const patchedTasks = [currentTask.number];
994
+ const softWarnings = currentPatchResult.warnings.slice();
995
+ const downstreamPatches = config.selfHealDownstream && Array.isArray(parsed.downstream_patches)
996
+ ? parsed.downstream_patches
997
+ : [];
998
+ const downstreamFailures = [];
999
+ for (const downstreamPatch of downstreamPatches) {
1000
+ const downstreamResult = _attemptStructuredTaskPatch({
1001
+ content: fs.readFileSync(tasksFile, 'utf8'),
1002
+ taskSnapshot: _readTaskSnapshot(tasksFile),
1003
+ patch: downstreamPatch,
1004
+ blockerHash,
1005
+ iteration: options.iteration,
1006
+ tasksFile,
1007
+ applyTaskPatch,
1008
+ validateTaskStructure,
1009
+ validationTimeoutMs: config.validationTimeoutMs,
1010
+ currentTaskNumber: currentTask.number,
1011
+ allowInsert: true,
1012
+ });
1013
+ if (downstreamResult.ok) {
1014
+ patchedTasks.push(downstreamResult.taskNumber);
1015
+ softWarnings.push(...downstreamResult.warnings);
1016
+ } else {
1017
+ downstreamFailures.push(`${downstreamResult.taskNumber || 'unknown'}:${downstreamResult.reason}`);
1018
+ }
1019
+ }
1020
+
1021
+ lastOutcome = 'patch_applied';
1022
+ _writeSupervisorState(ralphDir, {
1023
+ currentBlockerHash: blockerHash,
1024
+ triesUsedForCurrentBlocker: triesUsed,
1025
+ totalAttemptsForCurrentBlocker: totalAttempts,
1026
+ lastOutcome,
1027
+ });
1028
+ return _buildSupervisorReturn({
1029
+ outcome: 'patch_applied',
1030
+ patchedTasks,
1031
+ hints: normalizedHints.hints,
1032
+ hintsDropped: normalizedHints.hintsDropped,
1033
+ attempts: supervisorAttempts,
1034
+ readLogs,
1035
+ readLogsBytes,
1036
+ softWarnings,
1037
+ summary: _joinSummaryParts(parsed.summary, downstreamFailures),
1038
+ blockerHash,
1039
+ });
1040
+ }
1041
+
1042
+ _writeSupervisorState(ralphDir, {
1043
+ currentBlockerHash: blockerHash,
1044
+ triesUsedForCurrentBlocker: triesUsed,
1045
+ totalAttemptsForCurrentBlocker: totalAttempts,
1046
+ lastOutcome: lastOutcome || (triesUsed >= maxTries ? 'budget_exhausted' : 'infra_failure'),
1047
+ });
1048
+ return _buildSupervisorReturn({
1049
+ outcome: 'blocked_handoff',
1050
+ summary: triesUsed >= maxTries
1051
+ ? 'Supervisor exhausted the configured self-heal try budget.'
1052
+ : 'Supervisor exhausted the infrastructure-attempt cap before producing a valid patch.',
1053
+ blockerHash,
1054
+ hints: [],
1055
+ hintsDropped: [],
1056
+ attempts: supervisorAttempts,
1057
+ attemptsExhausted: triesUsed >= maxTries,
1058
+ readLogs: false,
1059
+ readLogsBytes: 0,
1060
+ });
1061
+ }
1062
+
1063
+ function _readTaskSnapshot(tasksFile) {
1064
+ const content = fs.readFileSync(tasksFile, 'utf8');
1065
+ const taskBlocks = _listTaskBlocks(content);
1066
+ const currentTask = taskBlocks.find((task) => task.status === 'incomplete') || null;
1067
+ const downstreamTasks = currentTask
1068
+ ? taskBlocks
1069
+ .filter((task) => task.status === 'incomplete' && task.index > currentTask.index)
1070
+ .map((task) => task.body)
1071
+ .join('\n\n')
1072
+ : '';
1073
+ return {
1074
+ content,
1075
+ taskBlocks,
1076
+ currentTask,
1077
+ downstreamTasks,
1078
+ };
1079
+ }
1080
+
1081
+ function _listTaskBlocks(content) {
1082
+ const lines = String(content || '').replace(/\r\n/g, '\n').split('\n');
1083
+ const blocks = [];
1084
+ let current = null;
1085
+
1086
+ for (let index = 0; index < lines.length; index += 1) {
1087
+ const line = lines[index];
1088
+ const startMatch = line.match(/^-\s+\[([ x/])\]\s+(.+)$/);
1089
+ if (startMatch) {
1090
+ if (current) {
1091
+ current.endLine = index;
1092
+ current.body = lines.slice(current.startLine, current.endLine).join('\n').trimEnd();
1093
+ blocks.push(current);
1094
+ }
1095
+ const description = startMatch[2].trim();
1096
+ const numberMatch = description.match(/^(\d+(?:\.\d+)*)\b/);
1097
+ current = {
1098
+ index: blocks.length,
1099
+ number: numberMatch ? numberMatch[1] : '',
1100
+ status: _taskStatusFromCheck(startMatch[1]),
1101
+ startLine: index,
1102
+ endLine: lines.length,
1103
+ body: '',
1104
+ };
1105
+ continue;
1106
+ }
1107
+
1108
+ if (current && /^##\s+/.test(line)) {
1109
+ current.endLine = index;
1110
+ current.body = lines.slice(current.startLine, current.endLine).join('\n').trimEnd();
1111
+ blocks.push(current);
1112
+ current = null;
1113
+ }
1114
+ }
1115
+
1116
+ if (current) {
1117
+ current.endLine = lines.length;
1118
+ current.body = lines.slice(current.startLine, current.endLine).join('\n').trimEnd();
1119
+ blocks.push(current);
1120
+ }
1121
+
1122
+ return blocks;
1123
+ }
1124
+
1125
+ function _taskStatusFromCheck(checkChar) {
1126
+ if (checkChar === 'x') return 'completed';
1127
+ if (checkChar === '/') return 'in_progress';
1128
+ return 'incomplete';
1129
+ }
1130
+
1131
+ function _attemptStructuredTaskPatch(options = {}) {
1132
+ const patch = options.patch;
1133
+ const patchTaskNumber = patch && typeof patch.task_number === 'string'
1134
+ ? patch.task_number.trim()
1135
+ : '';
1136
+ const nextContent = _applyPatchInstructionToTasksContent(options.content, patch, {
1137
+ iteration: options.iteration,
1138
+ blockerHash: options.blockerHash,
1139
+ });
1140
+ if (!nextContent.ok) {
1141
+ return nextContent;
1142
+ }
1143
+
1144
+ const taskSnapshot = _readTaskSnapshotFromContent(nextContent.content);
1145
+ const patchedTask = taskSnapshot.taskBlocks.find((task) => task.number === nextContent.taskNumber);
1146
+ if (!patchedTask) {
1147
+ return {
1148
+ ok: false,
1149
+ reason: 'patch_rejected_structural',
1150
+ detail: 'patched task not found after rewrite',
1151
+ taskNumber: patchTaskNumber,
1152
+ };
1153
+ }
1154
+ if (patchedTask.status !== 'incomplete') {
1155
+ return {
1156
+ ok: false,
1157
+ reason: 'patch_rejected_structural',
1158
+ detail: 'supervisor may only patch incomplete tasks',
1159
+ taskNumber: patchedTask.number,
1160
+ };
1161
+ }
1162
+
1163
+ const validation = options.validateTaskStructure(patchedTask.body, {
1164
+ ralphAuthoringRules: _loadRuleSources({ tasksFile: options.tasksFile }).ralph_authoring_rules.content,
1165
+ });
1166
+ if (!validation.ok) {
1167
+ return {
1168
+ ok: false,
1169
+ reason: 'patch_rejected_structural',
1170
+ detail: validation.errors.join('; '),
1171
+ warnings: validation.warnings || [],
1172
+ taskNumber: patchedTask.number,
1173
+ };
1174
+ }
1175
+
1176
+ const applyResult = options.applyTaskPatch({
1177
+ tasksFile: options.tasksFile,
1178
+ patchedContent: nextContent.content,
1179
+ validationTimeoutMs: options.validationTimeoutMs,
1180
+ });
1181
+ if (!applyResult.ok) {
1182
+ return {
1183
+ ok: false,
1184
+ reason: 'patch_rejected_validation',
1185
+ detail: _firstNonEmptyText(applyResult.stderr, applyResult.stdout, applyResult.reason),
1186
+ warnings: validation.warnings || [],
1187
+ taskNumber: patchedTask.number,
1188
+ };
1189
+ }
1190
+
1191
+ return {
1192
+ ok: true,
1193
+ taskNumber: patchedTask.number,
1194
+ warnings: validation.warnings || [],
1195
+ };
1196
+ }
1197
+
1198
+ function _readTaskSnapshotFromContent(content) {
1199
+ const taskBlocks = _listTaskBlocks(content);
1200
+ return { content, taskBlocks };
1201
+ }
1202
+
1203
+ function _applyPatchInstructionToTasksContent(content, patch, auditOptions = {}) {
1204
+ if (!patch || typeof patch !== 'object') {
1205
+ return {
1206
+ ok: false,
1207
+ reason: 'patch_rejected_structural',
1208
+ detail: 'patch must be an object',
1209
+ taskNumber: '',
1210
+ };
1211
+ }
1212
+
1213
+ const taskBlocks = _listTaskBlocks(content);
1214
+ const operation = String(patch.operation || 'modify');
1215
+ const newBody = _ensureAuditComment(String(patch.new_body || ''), {
1216
+ iteration: auditOptions.iteration,
1217
+ blockerHash: auditOptions.blockerHash,
1218
+ rationale: typeof patch.rationale === 'string' ? patch.rationale : '',
1219
+ });
1220
+ const lines = String(content || '').replace(/\r\n/g, '\n').split('\n');
1221
+
1222
+ if (operation === 'modify') {
1223
+ const target = taskBlocks.find((task) => task.number === String(patch.task_number || '').trim());
1224
+ if (!target) {
1225
+ return {
1226
+ ok: false,
1227
+ reason: 'patch_rejected_structural',
1228
+ detail: 'target task not found',
1229
+ taskNumber: String(patch.task_number || '').trim(),
1230
+ };
1231
+ }
1232
+ const replacedLines = _replaceLineRange(lines, target.startLine, target.endLine, newBody.split('\n'));
1233
+ return {
1234
+ ok: true,
1235
+ content: replacedLines.join('\n'),
1236
+ taskNumber: target.number,
1237
+ };
1238
+ }
1239
+
1240
+ if (operation === 'insert_before' || operation === 'insert_after') {
1241
+ const anchor = taskBlocks.find((task) => task.number === String(patch.anchor_task_number || '').trim());
1242
+ if (!anchor) {
1243
+ return {
1244
+ ok: false,
1245
+ reason: 'patch_rejected_structural',
1246
+ detail: 'anchor task not found',
1247
+ taskNumber: String(patch.task_number || '').trim(),
1248
+ };
1249
+ }
1250
+ const insertAt = operation === 'insert_before' ? anchor.startLine : anchor.endLine;
1251
+ const insertion = newBody.split('\n');
1252
+ const normalizedInsertion = _withTaskSpacing(lines, insertAt, insertion);
1253
+ const insertedLines = _replaceLineRange(lines, insertAt, insertAt, normalizedInsertion);
1254
+ return {
1255
+ ok: true,
1256
+ content: insertedLines.join('\n'),
1257
+ taskNumber: String(patch.task_number || '').trim(),
1258
+ };
1259
+ }
1260
+
1261
+ return {
1262
+ ok: false,
1263
+ reason: 'patch_rejected_structural',
1264
+ detail: `unsupported downstream patch operation: ${operation}`,
1265
+ taskNumber: String(patch.task_number || '').trim(),
1266
+ };
1267
+ }
1268
+
1269
+ function _replaceLineRange(lines, start, end, replacementLines) {
1270
+ return [
1271
+ ...lines.slice(0, start),
1272
+ ...replacementLines,
1273
+ ...lines.slice(end),
1274
+ ];
1275
+ }
1276
+
1277
+ function _withTaskSpacing(lines, insertAt, insertionLines) {
1278
+ const normalized = insertionLines.slice();
1279
+ if (insertAt > 0 && lines[insertAt - 1] && lines[insertAt - 1].trim() !== '') {
1280
+ normalized.unshift('');
1281
+ }
1282
+ if (insertAt < lines.length && lines[insertAt] && lines[insertAt].trim() !== '') {
1283
+ normalized.push('');
1284
+ }
1285
+ return normalized;
1286
+ }
1287
+
1288
+ function _ensureAuditComment(body, options = {}) {
1289
+ if (/<!--\s*supervised-edit:/i.test(body)) {
1290
+ return body;
1291
+ }
1292
+ const rationale = String(options.rationale || '').replace(/"/g, '\'').trim() || 'supervisor patch';
1293
+ const hash = String(options.blockerHash || '').slice(0, 8) || '00000000';
1294
+ const iteration = Number.isInteger(options.iteration) ? options.iteration : 0;
1295
+ const comment = `<!-- supervised-edit: iter=${iteration} reason="${rationale.slice(0, 120)}" hash=${hash} -->`;
1296
+ return `${String(body || '').trimEnd()}\n ${comment}`;
1297
+ }
1298
+
1299
+ module.exports = {
1300
+ _applyTaskPatch,
1301
+ _consumeBoundedBudget,
1302
+ _decideBoundedBudget,
1303
+ _detectSupervisorLogReads,
1304
+ _detectSizingProfile,
1305
+ _distillRalphBP,
1306
+ _extractDesignSections,
1307
+ _extractProposalSections,
1308
+ _loadRuleSources,
1309
+ _normalizeInvestigationHints,
1310
+ _parseSupervisorResponse,
1311
+ _recoverSupervisorTmpFiles,
1312
+ _renderSupervisorPrompt,
1313
+ _resolveRunLogPaths,
1314
+ _SUPERVISOR_TEMPLATE_VARIABLES,
1315
+ _validateTaskStructure,
1316
+ _resetRuleSourceCache,
1317
+ _summarizeDownstreamTasks,
1318
+ runSupervisor,
1319
+ };