@enderfga/claw-orchestrator 3.4.2 → 3.5.5

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.
Files changed (46) hide show
  1. package/README.md +9 -9
  2. package/configs/autoloop-coder-prompt.md +59 -0
  3. package/configs/autoloop-planner-prompt.md +136 -0
  4. package/configs/autoloop-reviewer-prompt.md +76 -0
  5. package/dist/src/autoloop/agent-tools.d.ts +44 -0
  6. package/dist/src/autoloop/agent-tools.js +71 -0
  7. package/dist/src/autoloop/agent-tools.js.map +1 -0
  8. package/dist/src/autoloop/dispatcher.d.ts +137 -0
  9. package/dist/src/autoloop/dispatcher.js +580 -0
  10. package/dist/src/autoloop/dispatcher.js.map +1 -0
  11. package/dist/src/autoloop/messages.d.ts +114 -0
  12. package/dist/src/autoloop/messages.js +92 -0
  13. package/dist/src/autoloop/messages.js.map +1 -0
  14. package/dist/src/autoloop/notify.d.ts +36 -0
  15. package/dist/src/autoloop/notify.js +166 -0
  16. package/dist/src/autoloop/notify.js.map +1 -0
  17. package/dist/src/autoloop/planner-tools.d.ts +81 -0
  18. package/dist/src/autoloop/planner-tools.js +140 -0
  19. package/dist/src/autoloop/planner-tools.js.map +1 -0
  20. package/dist/src/autoloop/runner.d.ts +48 -0
  21. package/dist/src/autoloop/runner.js +214 -0
  22. package/dist/src/autoloop/runner.js.map +1 -0
  23. package/dist/src/autoloop/types.d.ts +69 -0
  24. package/dist/src/autoloop/types.js +16 -0
  25. package/dist/src/autoloop/types.js.map +1 -0
  26. package/dist/src/dashboard/index.html +910 -0
  27. package/dist/src/embedded-server.js +173 -32
  28. package/dist/src/embedded-server.js.map +1 -1
  29. package/dist/src/index.d.ts +5 -2
  30. package/dist/src/index.js +67 -105
  31. package/dist/src/index.js.map +1 -1
  32. package/dist/src/session-manager.d.ts +47 -14
  33. package/dist/src/session-manager.js +135 -47
  34. package/dist/src/session-manager.js.map +1 -1
  35. package/package.json +2 -2
  36. package/skills/references/autoloop.md +176 -193
  37. package/configs/autoloop-bootstrap-prompt.md +0 -30
  38. package/configs/autoloop-compress-prompt.md +0 -50
  39. package/configs/autoloop-propose-prompt.md +0 -57
  40. package/configs/autoloop-ratchet-prompt.md +0 -56
  41. package/dist/src/autoloop-types.d.ts +0 -218
  42. package/dist/src/autoloop-types.js +0 -130
  43. package/dist/src/autoloop-types.js.map +0 -1
  44. package/dist/src/autoloop.d.ts +0 -70
  45. package/dist/src/autoloop.js +0 -910
  46. package/dist/src/autoloop.js.map +0 -1
@@ -1,910 +0,0 @@
1
- /**
2
- * Autoloop driver — phase machine for autonomous workspace iteration.
3
- *
4
- * Contract: tasks/autoloop.md. This file implements the runner; phase prompts
5
- * live in configs/autoloop-*-prompt.md.
6
- *
7
- * Lifecycle (one runner per task):
8
- * start() → BOOTSTRAP → loop { PROPOSE → EXECUTE → MEASURE → RATCHET → maybe COMPRESS } → TERMINATED
9
- *
10
- * Termination triggers: scalar target hit, max_iters, max_cost_usd, hard error,
11
- * explicit stop(). Plateau pushes the user but does NOT halt the loop (per
12
- * design constraint C9 — proactivity).
13
- */
14
- import { EventEmitter } from 'node:events';
15
- import * as fs from 'node:fs';
16
- import * as path from 'node:path';
17
- import * as os from 'node:os';
18
- import { spawn, execSync } from 'node:child_process';
19
- import { fileURLToPath } from 'node:url';
20
- import { nullLogger } from './logger.js';
21
- import { deriveMetric, isImprovement, isTargetReached, validateGoalSpec } from './autoloop-types.js';
22
- // ─── Module-local config helpers ───────────────────────────────────────────
23
- function resolveConfigPath(filename) {
24
- const filePath = fileURLToPath(import.meta.url);
25
- const dir = path.dirname(filePath);
26
- const candidates = [path.join(dir, '..', 'configs', filename), path.join(dir, '..', '..', 'configs', filename)];
27
- for (const p of candidates) {
28
- if (fs.existsSync(p))
29
- return p;
30
- }
31
- return candidates[0];
32
- }
33
- function loadPrompt(name, vars) {
34
- const tpl = fs.readFileSync(resolveConfigPath(name), 'utf-8');
35
- return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k] ?? `{{${k}}}`));
36
- }
37
- // ─── Atomic state I/O ──────────────────────────────────────────────────────
38
- function writeJsonAtomic(filePath, data) {
39
- const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
40
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
41
- fs.renameSync(tmp, filePath);
42
- }
43
- function appendJsonArray(filePath, entry) {
44
- let arr = [];
45
- if (fs.existsSync(filePath)) {
46
- try {
47
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
48
- if (Array.isArray(parsed))
49
- arr = parsed;
50
- }
51
- catch {
52
- // Treat malformed history as empty rather than crashing the loop.
53
- }
54
- }
55
- arr.push(entry);
56
- writeJsonAtomic(filePath, arr);
57
- }
58
- /**
59
- * Run a shell command with a hard wall-clock cap. The command is spawned in
60
- * its own process group so we can SIGKILL the entire tree on timeout — the
61
- * default exec/spawn behaviour only kills the shell wrapper, leaving children
62
- * orphaned (see CLAUDE.md recovery-path notes).
63
- */
64
- async function runShell(cmd, opts) {
65
- const start = Date.now();
66
- return new Promise((resolve) => {
67
- const child = spawn('/bin/sh', ['-c', cmd], {
68
- cwd: opts.cwd,
69
- detached: true,
70
- stdio: ['ignore', 'pipe', 'pipe'],
71
- });
72
- let stdout = '';
73
- let stderr = '';
74
- let timedOut = false;
75
- child.stdout?.on('data', (b) => {
76
- stdout += b.toString();
77
- // Cap collected output to avoid OOM on chatty commands.
78
- if (stdout.length > 1024 * 1024)
79
- stdout = stdout.slice(-1024 * 1024);
80
- });
81
- child.stderr?.on('data', (b) => {
82
- stderr += b.toString();
83
- if (stderr.length > 256 * 1024)
84
- stderr = stderr.slice(-256 * 1024);
85
- });
86
- const killTimer = setTimeout(() => {
87
- timedOut = true;
88
- try {
89
- if (child.pid != null)
90
- process.kill(-child.pid, 'SIGKILL');
91
- }
92
- catch {
93
- // Already dead.
94
- }
95
- }, opts.timeout_ms);
96
- child.on('close', (code) => {
97
- clearTimeout(killTimer);
98
- resolve({
99
- exit_code: code == null ? -1 : code,
100
- stdout,
101
- stderr,
102
- duration_ms: Date.now() - start,
103
- timed_out: timedOut,
104
- });
105
- });
106
- });
107
- }
108
- // ─── Runner ────────────────────────────────────────────────────────────────
109
- export class AutoloopRunner extends EventEmitter {
110
- id;
111
- config;
112
- taskDir;
113
- branch;
114
- state;
115
- goal;
116
- stopRequested = false;
117
- startedAt = '';
118
- endedAt;
119
- status = 'starting';
120
- errorMsg;
121
- manager;
122
- logger;
123
- constructor(manager, config, logger = nullLogger) {
124
- super();
125
- this.manager = manager;
126
- this.config = config;
127
- this.id = config.task_id || `autoloop-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
128
- this.taskDir = path.join(config.workspace, 'tasks', this.id);
129
- this.branch = `autoloop/${this.id}`;
130
- this.logger = logger;
131
- }
132
- // ─── Public API ────────────────────────────────────────────────────────
133
- /** Start the loop. Resolves after BOOTSTRAP; iteration runs in the background. */
134
- async start() {
135
- this.startedAt = new Date().toISOString();
136
- this.emit('starting');
137
- try {
138
- this.prepareTaskDir();
139
- this.goal = this.loadAndValidateGoal();
140
- this.state = this.initialState();
141
- this.persistState();
142
- this.ensureBranch();
143
- }
144
- catch (err) {
145
- this.fail(`init failed: ${err instanceof Error ? err.message : String(err)}`);
146
- throw err;
147
- }
148
- // BOOTSTRAP synchronously — failure aborts before iteration starts.
149
- const ok = await this.runBootstrap();
150
- if (!ok) {
151
- this.fail('bootstrap failed');
152
- return;
153
- }
154
- this.status = 'running';
155
- this.persistState();
156
- // Run the loop in the background; let start() return.
157
- void this.runLoop().catch((err) => {
158
- this.fail(`loop crashed: ${err instanceof Error ? err.stack || err.message : String(err)}`);
159
- });
160
- }
161
- /**
162
- * Resume an interrupted autoloop from on-disk state. Skips BOOTSTRAP and
163
- * jumps straight to the iteration loop. Resets the workspace to the last
164
- * known best (or the BOOTSTRAP baseline if no best yet) so any half-finished
165
- * PROPOSE that crashed mid-flight is discarded.
166
- */
167
- async resume() {
168
- this.startedAt = new Date().toISOString();
169
- this.emit('starting');
170
- if (!fs.existsSync(this.taskDir)) {
171
- throw new Error(`autoloop ${this.id}: task dir not found at ${this.taskDir}`);
172
- }
173
- const statePath = path.join(this.taskDir, 'state.json');
174
- if (!fs.existsSync(statePath)) {
175
- throw new Error(`autoloop ${this.id}: no state.json — was BOOTSTRAP completed?`);
176
- }
177
- this.goal = this.loadAndValidateGoal();
178
- this.state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
179
- if (this.state.termination.fired) {
180
- throw new Error(`autoloop ${this.id} already terminated: ${this.state.termination.reason}`);
181
- }
182
- if (this.state.iter >= this.goal.termination.max_iters) {
183
- throw new Error(`autoloop ${this.id} already at max_iters (${this.state.iter})`);
184
- }
185
- if (this.state.cost_usd_so_far >= this.goal.termination.max_cost_usd) {
186
- throw new Error(`autoloop ${this.id} already at max_cost_usd ($${this.state.cost_usd_so_far.toFixed(2)})`);
187
- }
188
- // Verify branch exists; switch to it.
189
- const branchCheck = await runShell(`git rev-parse --verify ${this.branch}`, {
190
- cwd: this.config.workspace,
191
- timeout_ms: 10_000,
192
- });
193
- if (branchCheck.exit_code !== 0) {
194
- throw new Error(`autoloop ${this.id}: branch ${this.branch} not found in workspace`);
195
- }
196
- await runShell(`git checkout ${this.branch}`, { cwd: this.config.workspace, timeout_ms: 10_000 });
197
- // Roll back to last known-good state — discard any crashed in-flight commits.
198
- const resetTarget = this.state.best?.git_sha ?? this.state.bootstrap_sha;
199
- if (resetTarget) {
200
- await runShell(`git reset --hard ${resetTarget}`, {
201
- cwd: this.config.workspace,
202
- timeout_ms: 10_000,
203
- });
204
- }
205
- // Reset transient flags; persist; jump into loop.
206
- this.stopRequested = false;
207
- this.status = 'running';
208
- this.state.phase = 'IDLE';
209
- this.state.decision = null;
210
- this.state.decision_reason = null;
211
- this.persistState();
212
- void this.runLoop().catch((err) => {
213
- this.fail(`resumed loop crashed: ${err instanceof Error ? err.stack || err.message : String(err)}`);
214
- });
215
- }
216
- /** Ask the loop to halt at the next phase boundary. Resolves when actually stopped. */
217
- async stop() {
218
- this.stopRequested = true;
219
- // Spin briefly to wait for the loop to exit. Worst case the runner is mid-LLM-call.
220
- for (let i = 0; i < 60; i++) {
221
- if (this.status !== 'running')
222
- return;
223
- await new Promise((r) => setTimeout(r, 1000));
224
- }
225
- // Force mark stopped; the in-flight phase will discover stopRequested on next check.
226
- if (this.status === 'running') {
227
- this.status = 'stopped';
228
- this.endedAt = new Date().toISOString();
229
- this.persistState();
230
- }
231
- }
232
- /** Inject a hint that the next PROPOSE will read. */
233
- inject(text) {
234
- const inbox = path.join(this.taskDir, 'inbox.md');
235
- const ts = new Date().toISOString();
236
- fs.appendFileSync(inbox, `\n---\n## ${ts} (injection)\n\n${text}\n`);
237
- this.emit('inject', { ts, text });
238
- }
239
- /** Cheap status snapshot from in-memory state. */
240
- handle() {
241
- return {
242
- id: this.id,
243
- status: this.status,
244
- task_dir: this.taskDir,
245
- started_at: this.startedAt,
246
- ended_at: this.endedAt,
247
- current_phase: this.state?.phase,
248
- current_iter: this.state?.iter,
249
- best_metric: this.state?.best?.metric,
250
- error: this.errorMsg,
251
- };
252
- }
253
- // ─── Setup ─────────────────────────────────────────────────────────────
254
- prepareTaskDir() {
255
- if (!fs.existsSync(this.config.workspace)) {
256
- throw new Error(`workspace does not exist: ${this.config.workspace}`);
257
- }
258
- const gitDir = path.join(this.config.workspace, '.git');
259
- if (!fs.existsSync(gitDir)) {
260
- throw new Error(`workspace is not a git repo (no .git/ at ${this.config.workspace})`);
261
- }
262
- fs.mkdirSync(this.taskDir, { recursive: true });
263
- fs.mkdirSync(path.join(this.taskDir, 'iter'), { recursive: true });
264
- fs.copyFileSync(this.config.plan_path, path.join(this.taskDir, 'plan.md'));
265
- fs.copyFileSync(this.config.goal_path, path.join(this.taskDir, 'goal.json'));
266
- }
267
- loadAndValidateGoal() {
268
- const raw = JSON.parse(fs.readFileSync(path.join(this.taskDir, 'goal.json'), 'utf-8'));
269
- return validateGoalSpec(raw);
270
- }
271
- initialState() {
272
- return {
273
- task_id: this.id,
274
- branch: this.branch,
275
- phase: 'BOOTSTRAP',
276
- status: 'starting',
277
- iter: 0,
278
- started_at: this.startedAt,
279
- best: null,
280
- last_metric: null,
281
- plateau_count: 0,
282
- decision: null,
283
- decision_reason: null,
284
- bootstrap_sha: null,
285
- tree: { parent_iter: null, children_iters: [] },
286
- termination: { fired: false, reason: null },
287
- cost_usd_so_far: 0,
288
- pending_aspirational_count: this.goal.aspirational_gates?.length ?? 0,
289
- };
290
- }
291
- ensureBranch() {
292
- // Create branch if not already on it. Fast-forward if exists.
293
- const cwd = this.config.workspace;
294
- runShell(`git rev-parse --verify ${this.branch} >/dev/null 2>&1 || git checkout -b ${this.branch}`, {
295
- cwd,
296
- timeout_ms: 10_000,
297
- }).then(() => {
298
- runShell(`git checkout ${this.branch}`, { cwd, timeout_ms: 10_000 });
299
- });
300
- }
301
- // ─── Phase: BOOTSTRAP ──────────────────────────────────────────────────
302
- async runBootstrap() {
303
- this.state.phase = 'BOOTSTRAP';
304
- this.persistState();
305
- this.emit('phase', { phase: 'BOOTSTRAP', iter: 0 });
306
- const prompt = loadPrompt('autoloop-bootstrap-prompt.md', {
307
- task_id: this.id,
308
- max_aspirational: this.goal.termination.max_pending_aspirational,
309
- });
310
- const sessionName = `autoloop-${this.id}-bootstrap`;
311
- let costUsd = 0;
312
- try {
313
- await this.manager.startSession({
314
- name: sessionName,
315
- cwd: this.config.workspace,
316
- engine: this.config.propose_engine || 'claude',
317
- model: this.config.propose_model || 'opus',
318
- permissionMode: 'bypassPermissions',
319
- systemPrompt: prompt,
320
- maxTurns: 30,
321
- });
322
- const result = await this.manager.sendMessage(sessionName, 'Begin BOOTSTRAP for this task.', {
323
- timeout: this.config.per_iter_timeout_ms ?? 600_000,
324
- });
325
- costUsd = readCostUsd(this.manager, sessionName, result);
326
- }
327
- catch (err) {
328
- this.logger.error(`[autoloop ${this.id}] bootstrap session failed:`, err);
329
- return false;
330
- }
331
- finally {
332
- try {
333
- await this.manager.stopSession(sessionName);
334
- }
335
- catch {
336
- // Ignore stop errors.
337
- }
338
- }
339
- this.state.cost_usd_so_far += costUsd;
340
- // Capture bootstrap commit sha as the rollback floor for the rest of the run.
341
- this.state.bootstrap_sha = this.gitSha();
342
- // Did bootstrap leave a failure marker?
343
- const failPath = path.join(this.taskDir, 'bootstrap-failure.md');
344
- if (fs.existsSync(failPath)) {
345
- const reason = fs.readFileSync(failPath, 'utf-8').slice(0, 1000);
346
- await this.push({
347
- kind: 'hard_error',
348
- text: `BOOTSTRAP failed for ${this.id}:\n${reason}`,
349
- task_id: this.id,
350
- iter: 0,
351
- ts: new Date().toISOString(),
352
- });
353
- return false;
354
- }
355
- // If aspirational gates were proposed, push to user.
356
- const goal = this.loadAndValidateGoal();
357
- const newAspirational = (goal.aspirational_gates?.length ?? 0) - (this.goal.aspirational_gates?.length ?? 0);
358
- if (newAspirational > 0) {
359
- this.goal = goal;
360
- this.state.pending_aspirational_count = goal.aspirational_gates?.length ?? 0;
361
- const list = goal.aspirational_gates.map((g, i) => `${i + 1}. ${g.name}: ${g.cmd}`).join('\n');
362
- await this.push({
363
- kind: 'bootstrap_aspirational',
364
- text: `Autoloop ${this.id} bootstrap done. ${newAspirational} aspirational gates proposed:\n${list}\n\nReply 'lock 1,3,4' or 'reject 2' or paste edits to lock or rotate.`,
365
- task_id: this.id,
366
- iter: 0,
367
- ts: new Date().toISOString(),
368
- });
369
- }
370
- return true;
371
- }
372
- // ─── Loop Driver ───────────────────────────────────────────────────────
373
- async runLoop() {
374
- while (!this.stopRequested) {
375
- // Termination checks (cheap, do every iter top).
376
- if (this.state.iter >= this.goal.termination.max_iters) {
377
- this.terminate('max_iters reached');
378
- return;
379
- }
380
- if (this.state.cost_usd_so_far >= this.goal.termination.max_cost_usd) {
381
- this.terminate(`max_cost_usd reached ($${this.state.cost_usd_so_far.toFixed(2)})`);
382
- return;
383
- }
384
- this.state.iter += 1;
385
- const iter = this.state.iter;
386
- const iterDir = path.join(this.taskDir, 'iter', String(iter));
387
- fs.mkdirSync(iterDir, { recursive: true });
388
- try {
389
- await this.runPropose(iter);
390
- if (this.stopRequested)
391
- break;
392
- const evalOut = await this.runExecute(iter);
393
- if (this.stopRequested)
394
- break;
395
- this.runMeasure(iter, evalOut);
396
- if (this.stopRequested)
397
- break;
398
- await this.runRatchet(iter, evalOut);
399
- }
400
- catch (err) {
401
- // One iter failure → reset and continue (soft fail). Hard errors still throw.
402
- this.logger.error(`[autoloop ${this.id}] iter ${iter} crashed:`, err);
403
- await this.gitReset();
404
- await this.push({
405
- kind: 'hard_error',
406
- text: `iter ${iter} crashed: ${err instanceof Error ? err.message : String(err)}. Continuing.`,
407
- task_id: this.id,
408
- iter,
409
- ts: new Date().toISOString(),
410
- });
411
- }
412
- // Termination from RATCHET decisions (target hit).
413
- if (this.state.termination.fired)
414
- return;
415
- // COMPRESS check.
416
- const k = this.config.compress_every_k ?? 10;
417
- if (iter > 0 && iter % k === 0) {
418
- await this.runCompress(iter - k + 1, iter);
419
- }
420
- }
421
- if (this.stopRequested) {
422
- this.status = 'stopped';
423
- this.endedAt = new Date().toISOString();
424
- this.state.phase = 'TERMINATED';
425
- this.persistState();
426
- this.emit('stopped');
427
- }
428
- }
429
- // ─── Phase: PROPOSE ────────────────────────────────────────────────────
430
- async runPropose(iter) {
431
- this.state.phase = 'PROPOSE';
432
- this.persistState();
433
- this.emit('phase', { phase: 'PROPOSE', iter });
434
- const prompt = loadPrompt('autoloop-propose-prompt.md', {
435
- task_id: this.id,
436
- iter,
437
- workspace: this.config.workspace,
438
- });
439
- const sessionName = `autoloop-${this.id}-propose-${iter}`;
440
- try {
441
- await this.manager.startSession({
442
- name: sessionName,
443
- cwd: this.config.workspace,
444
- engine: this.config.propose_engine || 'claude',
445
- model: this.config.propose_model || 'opus',
446
- permissionMode: 'bypassPermissions',
447
- systemPrompt: prompt,
448
- maxTurns: 40,
449
- });
450
- const result = await this.manager.sendMessage(sessionName, `Run PROPOSE for iter ${iter}.`, {
451
- timeout: this.config.per_iter_timeout_ms ?? 600_000,
452
- });
453
- this.state.cost_usd_so_far += readCostUsd(this.manager, sessionName, result);
454
- }
455
- finally {
456
- try {
457
- await this.manager.stopSession(sessionName);
458
- }
459
- catch {
460
- // Ignore.
461
- }
462
- }
463
- // Re-read goal in case PROPOSE added an aspirational gate.
464
- const fresh = this.loadAndValidateGoal();
465
- this.state.pending_aspirational_count = fresh.aspirational_gates?.length ?? 0;
466
- if ((fresh.aspirational_gates?.length ?? 0) > (this.goal.aspirational_gates?.length ?? 0)) {
467
- const added = fresh.aspirational_gates[fresh.aspirational_gates.length - 1];
468
- this.goal = fresh;
469
- await this.push({
470
- kind: 'aspirational_proposed',
471
- text: `Autoloop ${this.id} iter ${iter} proposed aspirational gate: ${added.name} (cmd: ${added.cmd}). Reply 'lock' to promote.`,
472
- task_id: this.id,
473
- iter,
474
- ts: new Date().toISOString(),
475
- });
476
- }
477
- this.persistState();
478
- }
479
- // ─── Phase: EXECUTE ────────────────────────────────────────────────────
480
- async runExecute(iter) {
481
- this.state.phase = 'EXECUTE';
482
- this.persistState();
483
- this.emit('phase', { phase: 'EXECUTE', iter });
484
- const cwd = this.config.workspace;
485
- const gateResults = [];
486
- for (const gate of this.goal.gates) {
487
- const res = await runShell(gate.cmd, {
488
- cwd,
489
- timeout_ms: (gate.timeout_sec ?? 300) * 1000,
490
- });
491
- gateResults.push({
492
- name: gate.name,
493
- passed: res.exit_code === 0 && !res.timed_out,
494
- exit_code: res.exit_code,
495
- duration_ms: res.duration_ms,
496
- output_tail: tail(res.stderr || res.stdout, 500),
497
- });
498
- }
499
- let scalar = null;
500
- if (this.goal.scalar) {
501
- const res = await runShell(this.goal.scalar.extract_cmd, {
502
- cwd,
503
- timeout_ms: (this.goal.scalar.extract_timeout_sec ?? 600) * 1000,
504
- });
505
- if (res.exit_code === 0 && !res.timed_out) {
506
- const parsed = parseFloat(res.stdout.trim().split(/\s+/).pop() ?? '');
507
- if (Number.isFinite(parsed))
508
- scalar = parsed;
509
- }
510
- }
511
- const passed = gateResults.filter((g) => g.passed).length;
512
- const total = gateResults.length;
513
- const gate_completion = total === 0 ? 1 : passed / total;
514
- const all_gates_passed = total === 0 ? true : passed === total;
515
- const evalOut = {
516
- iter,
517
- ts: new Date().toISOString(),
518
- gates: gateResults,
519
- scalar,
520
- gate_completion,
521
- all_gates_passed,
522
- };
523
- writeJsonAtomic(path.join(this.taskDir, 'iter', String(iter), 'eval.json'), evalOut);
524
- return evalOut;
525
- }
526
- // ─── Phase: MEASURE ────────────────────────────────────────────────────
527
- runMeasure(iter, evalOut) {
528
- this.state.phase = 'MEASURE';
529
- this.persistState();
530
- this.emit('phase', { phase: 'MEASURE', iter });
531
- const metric = deriveMetric(evalOut, this.goal);
532
- const entry = {
533
- iter,
534
- ts: evalOut.ts,
535
- metric,
536
- gate_completion: evalOut.gate_completion,
537
- phase_at_record: 'MEASURE',
538
- git_sha_pre: this.state.best?.git_sha,
539
- git_sha_post: this.gitSha(),
540
- };
541
- appendJsonArray(path.join(this.taskDir, 'metric.json'), entry);
542
- this.state.last_metric = { iter, metric, gate_completion: evalOut.gate_completion };
543
- this.persistState();
544
- }
545
- // ─── Phase: RATCHET ────────────────────────────────────────────────────
546
- async runRatchet(iter, evalOut) {
547
- this.state.phase = 'RATCHET';
548
- this.persistState();
549
- this.emit('phase', { phase: 'RATCHET', iter });
550
- const sandbox = path.join(os.tmpdir(), `autoloop-ratchet-${this.id}-${iter}`);
551
- fs.mkdirSync(sandbox, { recursive: true });
552
- const prompt = loadPrompt('autoloop-ratchet-prompt.md', { task_id: this.id, iter });
553
- const lastRatchet = this.readLastRatchet(iter - 1);
554
- const currentMd = this.tryRead(path.join(this.taskDir, 'current.md'));
555
- const inputBlob = JSON.stringify({
556
- goal: this.goal,
557
- eval: evalOut,
558
- metric_history: this.readMetricHistory(),
559
- current_md: currentMd,
560
- state_best: this.state.best,
561
- last_ratchet: lastRatchet,
562
- }, null, 2);
563
- const sessionName = `autoloop-${this.id}-ratchet-${iter}`;
564
- let raw = '';
565
- let costUsd = 0;
566
- try {
567
- await this.manager.startSession({
568
- name: sessionName,
569
- cwd: sandbox,
570
- engine: this.config.ratchet_engine || 'claude',
571
- model: this.config.ratchet_model || 'opus',
572
- permissionMode: 'default',
573
- systemPrompt: prompt,
574
- maxTurns: 1,
575
- });
576
- const result = await this.manager.sendMessage(sessionName, inputBlob, {
577
- timeout: 60_000,
578
- });
579
- raw = result.output || '';
580
- costUsd = readCostUsd(this.manager, sessionName, result);
581
- // Save raw output for forensics regardless of parse success.
582
- try {
583
- fs.writeFileSync(path.join(this.taskDir, 'iter', String(iter), 'ratchet-raw.txt'), raw);
584
- }
585
- catch {
586
- // Non-fatal.
587
- }
588
- }
589
- finally {
590
- try {
591
- await this.manager.stopSession(sessionName);
592
- }
593
- catch {
594
- // Ignore.
595
- }
596
- try {
597
- fs.rmSync(sandbox, { recursive: true, force: true });
598
- }
599
- catch {
600
- // Ignore.
601
- }
602
- }
603
- this.state.cost_usd_so_far += costUsd;
604
- const decision = parseRatchetJson(raw, iter);
605
- writeJsonAtomic(path.join(this.taskDir, 'iter', String(iter), 'ratchet.json'), decision);
606
- this.state.decision = decision.decision;
607
- this.state.decision_reason = decision.reason;
608
- const metric = deriveMetric(evalOut, this.goal);
609
- const newBest = isImprovement(metric, this.state.best?.metric ?? null, this.goal);
610
- if (decision.decision === 'commit' && newBest) {
611
- const sha = this.gitSha();
612
- this.state.best = { iter, metric, git_sha: sha, gate_completion: evalOut.gate_completion };
613
- this.state.plateau_count = 0;
614
- this.state.tree = { parent_iter: iter, children_iters: [iter] };
615
- // Push: new best.
616
- await this.push({
617
- kind: 'new_best',
618
- text: `Autoloop ${this.id} iter ${iter}: new best ${metric.toFixed(4)} (gate ${(evalOut.gate_completion * 100).toFixed(0)}%, sha ${sha.slice(0, 7)})`,
619
- task_id: this.id,
620
- iter,
621
- ts: new Date().toISOString(),
622
- });
623
- }
624
- else if (decision.decision === 'commit') {
625
- // Committed but not a new best (rare — RATCHET should reset). Treat as plateau.
626
- this.state.plateau_count += 1;
627
- }
628
- else {
629
- // Reset.
630
- await this.gitReset();
631
- this.state.plateau_count += 1;
632
- }
633
- // Plateau push (continues; does NOT halt).
634
- if (this.state.plateau_count >= this.goal.termination.plateau_iters &&
635
- this.state.plateau_count % this.goal.termination.plateau_iters === 0) {
636
- await this.push({
637
- kind: 'plateau',
638
- text: `Autoloop ${this.id} plateau ${this.state.plateau_count} iters; metric stuck at ${this.state.best?.metric?.toFixed(4) ?? 'n/a'}. Continuing. Reply 'stop' to halt or 'redirect: <hint>' to inject.`,
639
- task_id: this.id,
640
- iter,
641
- ts: new Date().toISOString(),
642
- });
643
- }
644
- // Custom push from RATCHET (e.g. unsure_no_metric).
645
- if (decision.push_user) {
646
- await this.push({
647
- kind: decision.push_user.kind,
648
- text: decision.push_user.text,
649
- task_id: this.id,
650
- iter,
651
- ts: new Date().toISOString(),
652
- });
653
- }
654
- // Target hit?
655
- if (this.state.best && isTargetReached(this.state.best.metric, this.state.best.gate_completion, this.goal)) {
656
- this.terminate(`scalar/gate target reached at iter ${iter}: metric=${this.state.best.metric}`);
657
- }
658
- this.persistState();
659
- }
660
- // ─── Phase: COMPRESS ───────────────────────────────────────────────────
661
- async runCompress(from, to) {
662
- this.state.phase = 'COMPRESS';
663
- this.persistState();
664
- this.emit('phase', { phase: 'COMPRESS', iter: to });
665
- const prompt = loadPrompt('autoloop-compress-prompt.md', {
666
- task_id: this.id,
667
- compress_every_k: this.config.compress_every_k ?? 10,
668
- compress_from: from,
669
- compress_to: to,
670
- compress_to_plus_1: to + 1,
671
- });
672
- const sessionName = `autoloop-${this.id}-compress-${to}`;
673
- try {
674
- await this.manager.startSession({
675
- name: sessionName,
676
- cwd: this.config.workspace,
677
- engine: this.config.propose_engine || 'claude',
678
- model: this.config.propose_model || 'opus',
679
- permissionMode: 'bypassPermissions',
680
- systemPrompt: prompt,
681
- maxTurns: 20,
682
- });
683
- const result = await this.manager.sendMessage(sessionName, `Run COMPRESS for iters ${from}-${to}.`, {
684
- timeout: 180_000,
685
- });
686
- this.state.cost_usd_so_far += readCostUsd(this.manager, sessionName, result);
687
- }
688
- finally {
689
- try {
690
- await this.manager.stopSession(sessionName);
691
- }
692
- catch {
693
- // Ignore.
694
- }
695
- }
696
- }
697
- // ─── Helpers ───────────────────────────────────────────────────────────
698
- persistState() {
699
- this.state.status = this.status;
700
- writeJsonAtomic(path.join(this.taskDir, 'state.json'), this.state);
701
- this.emit('state', this.state);
702
- }
703
- gitSha() {
704
- try {
705
- const out = execSync('git rev-parse HEAD', {
706
- cwd: this.config.workspace,
707
- encoding: 'utf-8',
708
- });
709
- return String(out).trim();
710
- }
711
- catch {
712
- return 'unknown';
713
- }
714
- }
715
- async gitReset() {
716
- // Prefer best, then bootstrap (stable floor), then HEAD~1 as a last resort.
717
- const target = this.state.best?.git_sha ?? this.state.bootstrap_sha ?? 'HEAD~1';
718
- await runShell(`git reset --hard ${target}`, {
719
- cwd: this.config.workspace,
720
- timeout_ms: 30_000,
721
- });
722
- }
723
- readMetricHistory() {
724
- const p = path.join(this.taskDir, 'metric.json');
725
- if (!fs.existsSync(p))
726
- return [];
727
- try {
728
- return JSON.parse(fs.readFileSync(p, 'utf-8'));
729
- }
730
- catch {
731
- return [];
732
- }
733
- }
734
- readLastRatchet(prevIter) {
735
- if (prevIter < 1)
736
- return null;
737
- const p = path.join(this.taskDir, 'iter', String(prevIter), 'ratchet.json');
738
- if (!fs.existsSync(p))
739
- return null;
740
- try {
741
- return JSON.parse(fs.readFileSync(p, 'utf-8'));
742
- }
743
- catch {
744
- return null;
745
- }
746
- }
747
- tryRead(p) {
748
- try {
749
- return fs.readFileSync(p, 'utf-8');
750
- }
751
- catch {
752
- return null;
753
- }
754
- }
755
- async push(event) {
756
- this.emit('push', event);
757
- // Append to inbox.md regardless of push command.
758
- const inbox = path.join(this.taskDir, 'inbox.md');
759
- fs.appendFileSync(inbox, `\n## ${event.ts} ${event.kind} (iter ${event.iter})\n\n${event.text}\n`);
760
- if (this.config.push_cmd === null)
761
- return;
762
- const cmd = this.config.push_cmd ?? 'openclaw message send';
763
- const escaped = event.text.replace(/'/g, `'\\''`);
764
- await runShell(`${cmd} '${escaped}'`, {
765
- cwd: this.config.workspace,
766
- timeout_ms: 30_000,
767
- }).catch(() => {
768
- // Push failure is non-fatal; inbox.md already has the record.
769
- });
770
- }
771
- terminate(reason) {
772
- this.state.termination = { fired: true, reason };
773
- this.state.phase = 'TERMINATED';
774
- this.status = 'completed';
775
- this.endedAt = new Date().toISOString();
776
- this.persistState();
777
- void this.push({
778
- kind: 'termination',
779
- text: `Autoloop ${this.id} done. ${reason}. Best ${this.state.best?.metric?.toFixed(4) ?? 'n/a'} at iter ${this.state.best?.iter ?? 0}. Cost $${this.state.cost_usd_so_far.toFixed(2)}.`,
780
- task_id: this.id,
781
- iter: this.state.iter,
782
- ts: this.endedAt,
783
- });
784
- this.emit('terminated', { reason });
785
- }
786
- fail(msg) {
787
- this.errorMsg = msg;
788
- this.status = 'error';
789
- this.endedAt = new Date().toISOString();
790
- if (this.state) {
791
- this.state.termination = { fired: true, reason: msg };
792
- this.state.phase = 'TERMINATED';
793
- this.persistState();
794
- }
795
- this.emit('error', new Error(msg));
796
- }
797
- }
798
- // ─── RATCHET output parsing ────────────────────────────────────────────────
799
- function parseRatchetJson(raw, iter) {
800
- // Try several extraction strategies. Order: most-specific to most-permissive.
801
- const candidates = [];
802
- // 1. Whole text trimmed.
803
- candidates.push(raw.trim());
804
- // 2. Strip code fences and try.
805
- candidates.push(raw.replace(/```(?:json)?\n?|```\n?/g, '').trim());
806
- // 3. Find every balanced top-level {...} block (greedy by matching braces),
807
- // try the last one first (RATCHET's prompt asks JSON to be at the end).
808
- const balanced = extractBalancedJsonObjects(raw);
809
- for (let i = balanced.length - 1; i >= 0; i--)
810
- candidates.push(balanced[i]);
811
- let payload = null;
812
- for (const c of candidates) {
813
- if (!c)
814
- continue;
815
- try {
816
- const p = JSON.parse(c);
817
- if (p && typeof p === 'object' && 'decision' in p) {
818
- payload = p;
819
- break;
820
- }
821
- }
822
- catch {
823
- // try next
824
- }
825
- }
826
- if (!payload || typeof payload !== 'object') {
827
- return {
828
- iter,
829
- decision: 'reset',
830
- reason: `malformed RATCHET output (could not parse JSON from ${raw.length} chars)`,
831
- };
832
- }
833
- const o = payload;
834
- const decision = o.decision === 'commit' ? 'commit' : 'reset';
835
- const reason = typeof o.reason === 'string' ? o.reason : 'no reason given';
836
- let push_user;
837
- if (o.push_user && typeof o.push_user === 'object') {
838
- const p = o.push_user;
839
- const validKinds = ['new_best', 'plateau', 'unsure_no_metric', 'aspirational_proposed'];
840
- if (typeof p.kind === 'string' && typeof p.text === 'string' && validKinds.includes(p.kind)) {
841
- push_user = { kind: p.kind, text: p.text };
842
- }
843
- }
844
- return { iter, decision, reason, push_user };
845
- }
846
- function tail(s, max) {
847
- if (s.length <= max)
848
- return s;
849
- return s.slice(-max);
850
- }
851
- /** Walk the string brace-by-brace and return every top-level {...} substring. */
852
- function extractBalancedJsonObjects(s) {
853
- const out = [];
854
- let depth = 0;
855
- let start = -1;
856
- let inString = false;
857
- let escape = false;
858
- for (let i = 0; i < s.length; i++) {
859
- const ch = s[i];
860
- if (inString) {
861
- if (escape)
862
- escape = false;
863
- else if (ch === '\\')
864
- escape = true;
865
- else if (ch === '"')
866
- inString = false;
867
- continue;
868
- }
869
- if (ch === '"') {
870
- inString = true;
871
- continue;
872
- }
873
- if (ch === '{') {
874
- if (depth === 0)
875
- start = i;
876
- depth++;
877
- }
878
- else if (ch === '}') {
879
- depth--;
880
- if (depth === 0 && start >= 0) {
881
- out.push(s.slice(start, i + 1));
882
- start = -1;
883
- }
884
- }
885
- }
886
- return out;
887
- }
888
- /**
889
- * Pull the cost USD for a session from the manager. Tries `getCost(name)`
890
- * (authoritative) first, then falls back to scanning events. We need to call
891
- * this BEFORE stopSession or the session lookup throws.
892
- */
893
- function readCostUsd(manager, sessionName, result) {
894
- try {
895
- const c = manager.getCost(sessionName);
896
- if (typeof c.totalUsd === 'number' && Number.isFinite(c.totalUsd))
897
- return c.totalUsd;
898
- }
899
- catch {
900
- // session may have been auto-stopped already; fall through
901
- }
902
- const events = result.events ?? [];
903
- for (let i = events.length - 1; i >= 0; i--) {
904
- const e = events[i];
905
- if (typeof e.total_cost_usd === 'number')
906
- return e.total_cost_usd;
907
- }
908
- return 0;
909
- }
910
- //# sourceMappingURL=autoloop.js.map