quest-loop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/runner.mjs ADDED
@@ -0,0 +1,682 @@
1
+ // The headless runner: `quest-run`. Drives Claude and Codex workers in native
2
+ // goal mode with deterministic budgets, stall enforcement, a runs journal, and
3
+ // notifications. It is a THIN loop — all quest state is read and written through
4
+ // the `quest` CLI (never by touching record files), and all worker-specific
5
+ // invocation/parse logic lives in lib/workers.mjs.
6
+ //
7
+ // Exit codes extend the `quest` CLI set (contract-spec):
8
+ // 0 success (complete) · 2 usage · 3 no store/config · 4 not found
9
+ // 10 ended blocked (worker-blocked OR stall) · 11 budget exhausted
10
+
11
+ import { parseArgs } from "node:util";
12
+ import { spawn as spawnChild, spawnSync } from "node:child_process";
13
+ import { existsSync, readdirSync, statSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { ConfigError, findStoreDir, loadConfig } from "./config.mjs";
17
+ import { nowIso } from "./contract.mjs";
18
+ import * as local from "./store-local.mjs";
19
+ import { getAdapter, CODEX_SANDBOX_MODES, DEFAULT_CODEX_SANDBOX } from "./workers.mjs";
20
+
21
+ const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
22
+ const QUEST_BIN = join(PLUGIN_ROOT, "bin", "quest");
23
+ const SCHEMA_PATH = join(PLUGIN_ROOT, "schemas", "final-report.schema.json");
24
+
25
+ class RunUsageError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.hint = "see `quest-run --help`";
29
+ }
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Help (same shape as the `quest` help layer: usage / flags / example)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const HELP = {
37
+ purpose: "Drive a headless Claude or Codex worker through a quest in native goal mode.",
38
+ usage: [
39
+ "quest-run <id> [--worker claude|codex] [--model M] [--effort E]",
40
+ " [--max-iterations N] [--max-cost USD] [--codex-sandbox MODE]",
41
+ " [--continue-session] [--notify '<cmd>'] [--dry-run] [--json]",
42
+ "quest-run --ready [--parallel N] [--isolate worktree] [--dry-run] [--json]",
43
+ ],
44
+ flags: [
45
+ ["<id>", "quest id to run (omit only with --ready)"],
46
+ ["--worker <w>", "claude | codex; default: record frontmatter, then config"],
47
+ ["--model <m>", "worker model override; default: record, then config default"],
48
+ ["--effort <e>", "reasoning effort override; default: record, then config"],
49
+ ["--max-iterations <n>", "session budget; exceeding it → blocked, exit 11"],
50
+ ["--max-cost <usd>", "USD cost cap (claude only; codex reports no USD so this never binds)"],
51
+ ["--max-tokens <n>", "token cap; governs codex spend since USD is unreported → blocked, exit 11"],
52
+ ["--session-timeout <s>", "per-session wall-clock cap (seconds); a hung worker is killed and the session counts as a stall (default 1800; config defaults.session_timeout)"],
53
+ ["--codex-sandbox <mode>", "codex exec sandbox: read-only | workspace-write | danger-full-access (default workspace-write; config defaults.codex.sandbox). workspace-write BLOCKS git commits — commit quests need danger-full-access"],
54
+ ["--continue-session", "resume the previous session each iteration instead of a fresh one"],
55
+ ["--notify <cmd>", "shell command run on run end (env: QUEST_ID, QUEST_TITLE, FINAL_STATUS, ITERATIONS, COST)"],
56
+ ["--dry-run", "print the exact worker invocation and exit without spawning"],
57
+ ["--json", "machine-readable run summary"],
58
+ ["--ready", "run every ready quest, promoting newly-ready ones as deps complete"],
59
+ ["--parallel <n>", "with --ready: run up to N quests concurrently (default 1)"],
60
+ ["--isolate <mode>", "with --ready: `worktree` gives each quest its own git worktree + branch"],
61
+ ],
62
+ notes: [
63
+ "Iterations are SESSIONS. By default each iteration is a fresh worker session;",
64
+ "2 consecutive sessions with no new checkpoint → runner-recorded blocked (exit 10).",
65
+ "A session exceeding --session-timeout is killed and counted as a no-checkpoint session.",
66
+ "Works against local and github-backed stores alike (record IO goes through the quest CLI).",
67
+ "Costs are never fabricated: codex is token-only, so --max-cost only binds for claude.",
68
+ "codex's default --codex-sandbox workspace-write write-protects .git, so a codex worker",
69
+ "cannot `git commit` under it; a commit-requiring quest must opt into danger-full-access —",
70
+ "an explicit tradeoff (full disk + network access), never escalated silently.",
71
+ "Without --isolate, parallel quests are assumed file-disjoint (no locking beyond the store's).",
72
+ "--isolate worktree leaves the worktree + quest/<id>-<slug> branch in place; the PR is the merge path.",
73
+ ],
74
+ example: "quest-run 12 --worker claude --max-iterations 6 --notify 'echo $QUEST_ID $FINAL_STATUS'",
75
+ };
76
+
77
+ function renderRunHelp() {
78
+ const lines = [HELP.purpose, "", "Usage:", ...HELP.usage.map((u) => ` ${u}`), "", "Flags:"];
79
+ const w = Math.max(...HELP.flags.map(([f]) => f.length));
80
+ for (const [flag, desc] of HELP.flags) lines.push(` ${flag.padEnd(w)} ${desc}`);
81
+ lines.push("", "Notes:", ...HELP.notes.map((n) => ` ${n}`), "", "Example:", ` ${HELP.example}`);
82
+ return lines.join("\n");
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Process helpers (async spawn so --parallel is genuinely concurrent)
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function resolveExecutable(cmd, env) {
90
+ if (cmd.includes("/")) return cmd;
91
+ const path = env.PATH || "";
92
+ for (const dir of path.split(":")) {
93
+ if (!dir) continue;
94
+ const candidate = join(dir, cmd);
95
+ try {
96
+ if (statSync(candidate).isFile()) return candidate;
97
+ } catch {
98
+ /* not here */
99
+ }
100
+ }
101
+ return cmd; // let spawn surface ENOENT
102
+ }
103
+
104
+ // A wall-clock `timeoutMs` (>0) arms a timer that SIGKILLs a hung child and
105
+ // resolves with `timedOut: true` — the runner treats that session as making no
106
+ // progress (never fabricated results). quest-CLI calls pass no timeout.
107
+ function spawnCapture(cmd, args, { cwd, env, timeoutMs, killSignal = "SIGKILL" } = {}) {
108
+ return new Promise((resolve) => {
109
+ let child;
110
+ try {
111
+ child = spawnChild(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
112
+ } catch (err) {
113
+ resolve({ stdout: "", stderr: String(err), code: 127 });
114
+ return;
115
+ }
116
+ let stdout = "";
117
+ let stderr = "";
118
+ let timedOut = false;
119
+ let timer;
120
+ const settle = (res) => {
121
+ if (timer) clearTimeout(timer);
122
+ resolve(res);
123
+ };
124
+ if (timeoutMs && timeoutMs > 0) {
125
+ timer = setTimeout(() => {
126
+ timedOut = true;
127
+ try {
128
+ child.kill(killSignal);
129
+ } catch {
130
+ /* already exited */
131
+ }
132
+ }, timeoutMs);
133
+ }
134
+ child.stdout.on("data", (d) => (stdout += d));
135
+ child.stderr.on("data", (d) => (stderr += d));
136
+ child.on("error", (err) => settle({ stdout, stderr: stderr + String(err), code: 127, timedOut }));
137
+ child.on("close", (code) => settle({ stdout, stderr, code: code ?? 0, timedOut }));
138
+ });
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Quest CLI seam — the ONLY path to read or mutate quest state
143
+ // ---------------------------------------------------------------------------
144
+
145
+ function questCli(storeDir, env, args) {
146
+ return spawnCapture(process.execPath, [QUEST_BIN, ...args], {
147
+ cwd: PLUGIN_ROOT,
148
+ env: { ...env, QUEST_DIR: storeDir },
149
+ });
150
+ }
151
+
152
+ async function readState(storeDir, env, id) {
153
+ const { stdout, code } = await questCli(storeDir, env, ["show", String(id), "--json"]);
154
+ if (code !== 0) return null;
155
+ try {
156
+ return JSON.parse(stdout.trim());
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ async function questStart(storeDir, env, id) {
163
+ return questCli(storeDir, env, ["start", String(id)]);
164
+ }
165
+
166
+ async function recordBlocked(storeDir, env, id, summary, validation) {
167
+ return questCli(storeDir, env, [
168
+ "checkpoint",
169
+ String(id),
170
+ "--status",
171
+ "blocked",
172
+ "--summary",
173
+ summary,
174
+ "--validation",
175
+ validation,
176
+ ]);
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Option resolution: flag → record frontmatter → config defaults
181
+ // ---------------------------------------------------------------------------
182
+
183
+ function resolveOptions(front, config, flags) {
184
+ const worker = flags.worker ?? front.worker ?? config.defaults.worker;
185
+ if (!["claude", "codex"].includes(worker)) throw new RunUsageError(`--worker must be claude or codex (got "${worker}")`);
186
+ const wd = worker === "codex" ? config.defaults.codex : config.defaults.claude;
187
+ const recordModel = front.model && front.model !== "inherit" ? front.model : undefined;
188
+ const model = flags.model ?? recordModel ?? wd.model;
189
+ const effort = flags.effort ?? front.effort ?? (worker === "codex" ? wd.reasoning_effort : wd.effort);
190
+ const maxIterations = flags["max-iterations"] != null ? Number(flags["max-iterations"]) : front.max_iterations ?? config.defaults.max_iterations;
191
+ const maxCost = flags["max-cost"] != null ? Number(flags["max-cost"]) : front.max_cost ?? undefined;
192
+ const maxTokens = flags["max-tokens"] != null ? Number(flags["max-tokens"]) : undefined;
193
+ // Per-session wall-clock cap (seconds → ms). Flag > config default > 1800s.
194
+ // A non-positive value disables the timeout.
195
+ const sessionTimeoutSec = flags["session-timeout"] != null ? Number(flags["session-timeout"]) : config.defaults.session_timeout ?? 1800;
196
+ const sessionTimeout = Number.isFinite(sessionTimeoutSec) && sessionTimeoutSec > 0 ? Math.round(sessionTimeoutSec * 1000) : 0;
197
+ // Codex exec sandbox: flag → config defaults.codex.sandbox → workspace-write.
198
+ // No silent escalation: the safe default stays workspace-write, and the value
199
+ // is validated (an illegal one is a usage error). Validated whenever the flag
200
+ // is explicit or the worker is codex, so a claude run never fails on a codex-
201
+ // only config typo.
202
+ const codexSandbox = flags["codex-sandbox"] ?? config.defaults.codex?.sandbox ?? DEFAULT_CODEX_SANDBOX;
203
+ if ((flags["codex-sandbox"] != null || worker === "codex") && !CODEX_SANDBOX_MODES.includes(codexSandbox)) {
204
+ throw new RunUsageError(`--codex-sandbox must be one of ${CODEX_SANDBOX_MODES.join(", ")} (got "${codexSandbox}")`);
205
+ }
206
+ return { worker, model, effort, maxIterations, maxCost, maxTokens, sessionTimeout, codexSandbox };
207
+ }
208
+
209
+ function lastSessionIdFor(storeDir, questId, worker) {
210
+ const events = local.readRuns(storeDir);
211
+ for (let i = events.length - 1; i >= 0; i--) {
212
+ const e = events[i];
213
+ if (e.event === "iteration_finished" && e.quest === questId && (!worker || e.worker === worker) && e.session_id) return e.session_id;
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Notify (fire on run end; failure is isolated from the runner's exit code)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function runNotify(command, vars, io) {
223
+ if (!command || !command.trim()) return;
224
+ const env = {
225
+ ...io.env,
226
+ QUEST_ID: String(vars.id),
227
+ QUEST_TITLE: vars.title ?? "",
228
+ FINAL_STATUS: vars.finalStatus ?? "",
229
+ ITERATIONS: String(vars.iterations ?? 0),
230
+ COST: vars.cost == null ? "" : String(vars.cost),
231
+ };
232
+ let res;
233
+ try {
234
+ res = spawnSync("sh", ["-c", command], { env, encoding: "utf8" });
235
+ } catch (err) {
236
+ io.errOut(`quest-run: notify command errored (${err.message}); continuing — this does not change the run's exit code`);
237
+ return;
238
+ }
239
+ if (res.error || res.status !== 0) {
240
+ const why = res.error ? res.error.message : `exit ${res.status}`;
241
+ io.errOut(`quest-run: notify command failed (${why}); continuing — this does not change the run's exit code`);
242
+ }
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Single-quest run
247
+ // ---------------------------------------------------------------------------
248
+
249
+ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
250
+ const env = io.env;
251
+ const workerCwd = cwd ?? io.cwd;
252
+
253
+ const initial = await readState(storeDir, env, id);
254
+ if (!initial) {
255
+ io.errOut(`quest-run: quest ${id} not found in ${storeDir}`);
256
+ return { exitCode: 4, finalStatus: "not_found", iterations: 0, cost: 0 };
257
+ }
258
+ const opts = resolveOptions(initial, config, flags);
259
+ const adapter = getAdapter(opts.worker);
260
+ const title = initial.title ?? `quest ${id}`;
261
+
262
+ // --dry-run: print the exact invocation, spawn nothing, journal nothing.
263
+ if (flags["dry-run"]) {
264
+ const runStartIso = nowIso();
265
+ const invOpts = {
266
+ id,
267
+ model: opts.model,
268
+ effort: opts.effort,
269
+ resumeSessionId: flags["continue-session"] ? lastSessionIdFor(storeDir, id, opts.worker) : undefined,
270
+ pluginRoot: PLUGIN_ROOT,
271
+ cwd: workerCwd,
272
+ env,
273
+ schemaPath: SCHEMA_PATH,
274
+ runStartIso,
275
+ codexSandbox: opts.codexSandbox,
276
+ };
277
+ const inv = adapter.buildInvocation(initial, config, invOpts);
278
+ if (flags.json) {
279
+ io.out(JSON.stringify({ dry_run: true, quest: id, worker: opts.worker, cmd: inv.cmd, args: inv.args, env: inv.env, prompt: inv.prompt }));
280
+ } else {
281
+ io.out(`# dry run — quest ${id} (${opts.worker}); nothing spawned`);
282
+ io.out(describeInvocation(inv));
283
+ }
284
+ return { exitCode: 0, finalStatus: "dry_run", iterations: 0, cost: 0 };
285
+ }
286
+
287
+ const runId = randomRunId();
288
+ const runStartIso = nowIso();
289
+ local.appendRunEvent(storeDir, { event: "run_started", run_id: runId, quest: id, worker: opts.worker, ts: runStartIso });
290
+
291
+ let finalStatus = "in_progress";
292
+ let exitCode = 0;
293
+ let sessionsRun = 0;
294
+ let totalCost = 0;
295
+ let totalTokens = 0;
296
+ let stall = 0;
297
+ let lastSessionId = flags["continue-session"] ? lastSessionIdFor(storeDir, id, opts.worker) : undefined;
298
+
299
+ try {
300
+ // Early exit on an already-terminal quest.
301
+ if (initial.status === "complete") {
302
+ finalStatus = "complete";
303
+ exitCode = 0;
304
+ } else if (initial.status === "cancelled") {
305
+ finalStatus = "cancelled";
306
+ exitCode = 0;
307
+ } else if (initial.status === "blocked") {
308
+ finalStatus = "blocked";
309
+ exitCode = 10;
310
+ } else {
311
+ // Starting a run puts the quest in_progress so runner-authored blocked
312
+ // checkpoints (stall/budget) are legal transitions.
313
+ if (initial.status === "todo") await questStart(storeDir, env, id);
314
+
315
+ for (;;) {
316
+ const state = await readState(storeDir, env, id);
317
+ if (!state) {
318
+ io.errOut(`quest-run: lost the quest ${id} record mid-run`);
319
+ finalStatus = "error";
320
+ exitCode = 4;
321
+ break;
322
+ }
323
+ if (state.status === "complete") {
324
+ finalStatus = "complete";
325
+ exitCode = 0;
326
+ break;
327
+ }
328
+ if (state.status === "blocked") {
329
+ finalStatus = "blocked";
330
+ exitCode = 10;
331
+ break;
332
+ }
333
+ if (state.status === "cancelled") {
334
+ finalStatus = "cancelled";
335
+ exitCode = 0;
336
+ break;
337
+ }
338
+
339
+ // Budget gates (deterministic). Iterations count sessions already run.
340
+ if (sessionsRun >= opts.maxIterations) {
341
+ await recordBlocked(
342
+ storeDir,
343
+ env,
344
+ id,
345
+ `runner: iteration budget exhausted (${sessionsRun}/${opts.maxIterations} sessions without completion)`,
346
+ "runner budget enforcement",
347
+ );
348
+ finalStatus = "blocked";
349
+ exitCode = 11;
350
+ break;
351
+ }
352
+ if (opts.maxCost != null && totalCost > opts.maxCost) {
353
+ await recordBlocked(
354
+ storeDir,
355
+ env,
356
+ id,
357
+ `runner: cost budget exhausted ($${totalCost.toFixed(4)} > $${opts.maxCost})`,
358
+ "runner budget enforcement",
359
+ );
360
+ finalStatus = "blocked";
361
+ exitCode = 11;
362
+ break;
363
+ }
364
+ if (opts.maxTokens != null && totalTokens > opts.maxTokens) {
365
+ await recordBlocked(
366
+ storeDir,
367
+ env,
368
+ id,
369
+ `runner: token budget exhausted (${totalTokens} > ${opts.maxTokens} tokens)`,
370
+ "runner budget enforcement",
371
+ );
372
+ finalStatus = "blocked";
373
+ exitCode = 11;
374
+ break;
375
+ }
376
+
377
+ const checkpointsBefore = state.checkpoints.length;
378
+ sessionsRun += 1;
379
+
380
+ // Any child killed by the session wall-clock timeout marks the whole
381
+ // session as timed-out (a no-progress session for stall accounting).
382
+ let sessionTimedOut = false;
383
+ const ctx = {
384
+ questRecord: state,
385
+ config,
386
+ opts: {
387
+ id,
388
+ model: opts.model,
389
+ effort: opts.effort,
390
+ resumeSessionId: flags["continue-session"] ? lastSessionId : undefined,
391
+ pluginRoot: PLUGIN_ROOT,
392
+ cwd: workerCwd,
393
+ env,
394
+ schemaPath: SCHEMA_PATH,
395
+ runStartIso,
396
+ codexSandbox: opts.codexSandbox,
397
+ },
398
+ runStartIso,
399
+ iteration: sessionsRun,
400
+ checkpointsBefore,
401
+ spawn: async (inv) => {
402
+ const res = await spawnCapture(resolveExecutable(inv.cmd, env), inv.args, {
403
+ cwd: workerCwd,
404
+ env: { ...env, QUEST_DIR: storeDir, ...inv.env },
405
+ timeoutMs: opts.sessionTimeout,
406
+ killSignal: "SIGKILL",
407
+ });
408
+ if (res.timedOut) sessionTimedOut = true;
409
+ return res;
410
+ },
411
+ readState: () => readState(storeDir, env, id),
412
+ };
413
+
414
+ let result;
415
+ try {
416
+ result = await adapter.runSession(ctx);
417
+ } catch (err) {
418
+ io.errOut(`quest-run: worker session ${sessionsRun} errored: ${err.message}`);
419
+ result = {};
420
+ }
421
+ totalCost += result.cost_usd ?? 0;
422
+ totalTokens += result.tokens ?? 0;
423
+ if (result.session_id) lastSessionId = result.session_id;
424
+
425
+ const after = await readState(storeDir, env, id);
426
+ const newCheckpoint = after && after.checkpoints.length > checkpointsBefore;
427
+ // A timed-out session never counts as progress, even if a checkpoint
428
+ // landed before the kill — a terminal status is still caught at the top
429
+ // of the next iteration, so completions are never lost.
430
+ const progressed = newCheckpoint && !sessionTimedOut;
431
+ local.appendRunEvent(storeDir, {
432
+ event: "iteration_finished",
433
+ run_id: runId,
434
+ quest: id,
435
+ worker: opts.worker,
436
+ ts: nowIso(),
437
+ session_id: result.session_id ?? null,
438
+ cost_usd: result.cost_usd ?? null,
439
+ tokens: result.tokens ?? null,
440
+ timed_out: sessionTimedOut,
441
+ status_after: after ? after.status : null,
442
+ });
443
+
444
+ if (progressed) {
445
+ stall = 0;
446
+ if (after.status === "complete") {
447
+ finalStatus = "complete";
448
+ exitCode = 0;
449
+ break;
450
+ }
451
+ if (after.status === "blocked") {
452
+ finalStatus = "blocked";
453
+ exitCode = 10;
454
+ break;
455
+ }
456
+ // in_progress → keep iterating
457
+ } else {
458
+ stall += 1;
459
+ if (stall >= 2) {
460
+ await recordBlocked(
461
+ storeDir,
462
+ env,
463
+ id,
464
+ "runner: 2 consecutive sessions ended without a checkpoint",
465
+ "runner stall enforcement",
466
+ );
467
+ finalStatus = "blocked";
468
+ exitCode = 10;
469
+ break;
470
+ }
471
+ }
472
+ }
473
+ }
474
+ } finally {
475
+ local.appendRunEvent(storeDir, {
476
+ event: "run_ended",
477
+ run_id: runId,
478
+ quest: id,
479
+ worker: opts.worker,
480
+ ts: nowIso(),
481
+ final_status: finalStatus,
482
+ iterations: sessionsRun,
483
+ cost_usd: totalCost,
484
+ tokens: totalTokens,
485
+ });
486
+ runNotify(flags.notify ?? config.notify?.command, { id, title, finalStatus, iterations: sessionsRun, cost: opts.worker === "codex" ? null : totalCost }, io);
487
+ }
488
+
489
+ if (!flags["dry-run"]) {
490
+ if (flags.json) {
491
+ io.out(JSON.stringify({ run_id: runId, quest: id, worker: opts.worker, final_status: finalStatus, iterations: sessionsRun, cost_usd: totalCost, tokens: totalTokens, exit_code: exitCode }));
492
+ } else {
493
+ io.out(`quest-run: quest ${id} ended ${finalStatus} after ${sessionsRun} session(s)${opts.worker === "codex" ? `, ${totalTokens} tokens` : `, $${totalCost.toFixed(4)}`} (run ${runId}).`);
494
+ }
495
+ }
496
+
497
+ return { exitCode, finalStatus, iterations: sessionsRun, cost: totalCost, runId };
498
+ }
499
+
500
+ function describeInvocation(inv) {
501
+ const lines = [];
502
+ for (const [k, v] of Object.entries(inv.env || {})) lines.push(`${k}=${v} \\`);
503
+ const q = (s) => (/[\s"'$`]/.test(s) ? JSON.stringify(s) : s);
504
+ lines.push([inv.cmd, ...inv.args.map(q)].join(" "));
505
+ return lines.join("\n");
506
+ }
507
+
508
+ function randomRunId() {
509
+ return Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-2);
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // --ready pool (with optional worktree isolation)
514
+ // ---------------------------------------------------------------------------
515
+
516
+ function slugForBranch(title) {
517
+ return String(title).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "quest";
518
+ }
519
+
520
+ function makeWorktree(repoRoot, id, title, io) {
521
+ const slug = slugForBranch(title);
522
+ const wtPath = join(repoRoot, ".quests-wt", `${id}-${slug}`);
523
+ const branch = `quest/${id}-${slug}`;
524
+ if (existsSync(wtPath)) return { wtPath, branch, created: false };
525
+ const res = spawnSync("git", ["worktree", "add", wtPath, "-b", branch], { cwd: repoRoot, encoding: "utf8" });
526
+ if (res.status !== 0) {
527
+ io.errOut(`quest-run: git worktree add failed for quest ${id}: ${(res.stderr || res.stdout || "").trim()}`);
528
+ return null;
529
+ }
530
+ return { wtPath, branch, created: true };
531
+ }
532
+
533
+ async function runReady(storeDir, config, flags, io) {
534
+ const parallel = Math.max(1, flags.parallel != null ? Number(flags.parallel) : 1);
535
+ const isolate = flags.isolate;
536
+ if (isolate && isolate !== "worktree") throw new RunUsageError(`--isolate only supports "worktree" (got "${isolate}")`);
537
+ const repoRoot = dirname(storeDir);
538
+
539
+ const summaries = [];
540
+ const done = new Set();
541
+ const inFlight = new Map();
542
+
543
+ const readyIds = async () => {
544
+ const { stdout, code } = await questCli(storeDir, io.env, ["list", "--ready", "--json"]);
545
+ if (code !== 0) return [];
546
+ try {
547
+ return JSON.parse(stdout).map((q) => q.id);
548
+ } catch {
549
+ return [];
550
+ }
551
+ };
552
+
553
+ const startOne = (id) => {
554
+ const promise = (async () => {
555
+ let cwd = io.cwd;
556
+ let worktreeNote;
557
+ if (isolate === "worktree" && !flags["dry-run"]) {
558
+ const state = await readState(storeDir, io.env, id);
559
+ const wt = makeWorktree(repoRoot, id, state?.title ?? `quest-${id}`, io);
560
+ if (wt) {
561
+ cwd = wt.wtPath;
562
+ worktreeNote = `worktree ${wt.wtPath} on branch ${wt.branch} (left in place; PR is the merge path)`;
563
+ io.out(`quest-run: quest ${id} isolated in ${worktreeNote}`);
564
+ }
565
+ }
566
+ const res = await runQuest(storeDir, config, id, flags, io, { cwd });
567
+ return { id, ...res, worktreeNote };
568
+ })();
569
+ inFlight.set(id, promise);
570
+ };
571
+
572
+ for (;;) {
573
+ const ready = (await readyIds()).filter((id) => !done.has(id) && !inFlight.has(id));
574
+ for (const id of ready) {
575
+ if (inFlight.size >= parallel) break;
576
+ startOne(id);
577
+ }
578
+ if (inFlight.size === 0) break; // nothing ready and nothing running → wave done
579
+ const settled = await Promise.race([...inFlight.values()]);
580
+ inFlight.delete(settled.id);
581
+ done.add(settled.id);
582
+ summaries.push(settled);
583
+ }
584
+
585
+ const worstExit = summaries.reduce((code, s) => (s.exitCode > code ? s.exitCode : code), 0);
586
+ if (flags.json) {
587
+ io.out(JSON.stringify({ ready_run: true, quests: summaries.map((s) => ({ quest: s.id, final_status: s.finalStatus, iterations: s.iterations, exit_code: s.exitCode })) }));
588
+ } else if (!summaries.length) {
589
+ io.out("quest-run: no ready quests to run.");
590
+ } else {
591
+ io.out(`quest-run: ready wave finished — ${summaries.length} quest(s) run.`);
592
+ for (const s of summaries) io.out(` quest ${s.id}: ${s.finalStatus} (exit ${s.exitCode})`);
593
+ }
594
+ return worstExit;
595
+ }
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // Entry
599
+ // ---------------------------------------------------------------------------
600
+
601
+ function getStore(cwd, env) {
602
+ const storeDir = findStoreDir(cwd, env);
603
+ if (!storeDir) throw new ConfigError("no quest store found (searched for .quests/ from here upward)", { hint: "run `quest init` to create one" });
604
+ const config = loadConfig(storeDir, env);
605
+ return { storeDir, config };
606
+ }
607
+
608
+ export async function run(argv, io = {}) {
609
+ const out = io.stdout ?? ((s) => process.stdout.write(s + "\n"));
610
+ const errOut = io.stderr ?? ((s) => process.stderr.write(s + "\n"));
611
+ const cwd = io.cwd ?? process.cwd();
612
+ const env = io.env ?? process.env;
613
+ const ioc = { out, errOut, cwd, env };
614
+
615
+ let parsed;
616
+ try {
617
+ parsed = parseArgs({
618
+ args: argv,
619
+ allowPositionals: true,
620
+ options: {
621
+ worker: { type: "string" },
622
+ model: { type: "string" },
623
+ effort: { type: "string" },
624
+ "max-iterations": { type: "string" },
625
+ "max-cost": { type: "string" },
626
+ "max-tokens": { type: "string" },
627
+ "session-timeout": { type: "string" },
628
+ "codex-sandbox": { type: "string" },
629
+ "continue-session": { type: "boolean" },
630
+ notify: { type: "string" },
631
+ "dry-run": { type: "boolean" },
632
+ json: { type: "boolean" },
633
+ ready: { type: "boolean" },
634
+ parallel: { type: "string" },
635
+ isolate: { type: "string" },
636
+ help: { type: "boolean" },
637
+ },
638
+ });
639
+ } catch (err) {
640
+ errOut(`quest-run: ${err.message}`);
641
+ errOut(" hint: see `quest-run --help`");
642
+ return 2;
643
+ }
644
+
645
+ if (parsed.values.help) {
646
+ out(renderRunHelp());
647
+ return 0;
648
+ }
649
+
650
+ const flags = parsed.values;
651
+ try {
652
+ const { storeDir, config } = getStore(cwd, env);
653
+ // Backend-agnostic: all record IO flows through the `quest` CLI, and the
654
+ // runs journal (readRuns/appendRunEvent) is always local per contract-spec,
655
+ // so github-backed stores are driven the same as local ones.
656
+
657
+ if (flags.ready) {
658
+ if (parsed.positionals.length) throw new RunUsageError("--ready takes no quest id");
659
+ return await runReady(storeDir, config, flags, ioc);
660
+ }
661
+
662
+ const raw = parsed.positionals[0];
663
+ if (!raw || !/^\d+$/.test(raw)) throw new RunUsageError("a numeric quest id is required (or use --ready)");
664
+ if (parsed.positionals.length > 1) throw new RunUsageError(`unexpected argument "${parsed.positionals[1]}"`);
665
+ const id = Number(raw);
666
+ const { exitCode } = await runQuest(storeDir, config, id, flags, ioc);
667
+ return exitCode;
668
+ } catch (err) {
669
+ if (err instanceof RunUsageError) {
670
+ errOut(`quest-run: ${err.message}`);
671
+ if (err.hint) errOut(` hint: ${err.hint}`);
672
+ return 2;
673
+ }
674
+ if (err instanceof ConfigError) {
675
+ errOut(`quest-run: ${err.message}`);
676
+ if (err.hint) errOut(` hint: ${err.hint}`);
677
+ return 3;
678
+ }
679
+ errOut(`quest-run: ${err.message}`);
680
+ return 1;
681
+ }
682
+ }