quest-loop 0.1.0 → 0.3.2

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 CHANGED
@@ -34,10 +34,11 @@ class RunUsageError extends Error {
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
36
  const HELP = {
37
- purpose: "Drive a headless Claude or Codex worker through a quest in native goal mode.",
37
+ purpose: "Drive a headless Claude or Codex worker through a quest with checkpoint-based stop checks.",
38
38
  usage: [
39
39
  "quest-run <id> [--worker claude|codex] [--model M] [--effort E]",
40
40
  " [--max-iterations N] [--max-cost USD] [--codex-sandbox MODE]",
41
+ " [--codex-goal-mode auto|require|off]",
41
42
  " [--continue-session] [--notify '<cmd>'] [--dry-run] [--json]",
42
43
  "quest-run --ready [--parallel N] [--isolate worktree] [--dry-run] [--json]",
43
44
  ],
@@ -51,6 +52,7 @@ const HELP = {
51
52
  ["--max-tokens <n>", "token cap; governs codex spend since USD is unreported → blocked, exit 11"],
52
53
  ["--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
54
  ["--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"],
55
+ ["--codex-goal-mode <mode>", "codex goal-tool policy: auto | require | off (default auto; config defaults.codex.goal_mode)"],
54
56
  ["--continue-session", "resume the previous session each iteration instead of a fresh one"],
55
57
  ["--notify <cmd>", "shell command run on run end (env: QUEST_ID, QUEST_TITLE, FINAL_STATUS, ITERATIONS, COST)"],
56
58
  ["--dry-run", "print the exact worker invocation and exit without spawning"],
@@ -68,6 +70,8 @@ const HELP = {
68
70
  "codex's default --codex-sandbox workspace-write write-protects .git, so a codex worker",
69
71
  "cannot `git commit` under it; a commit-requiring quest must opt into danger-full-access —",
70
72
  "an explicit tradeoff (full disk + network access), never escalated silently.",
73
+ "codex goal tools are optional by default; --codex-goal-mode require blocks honestly if",
74
+ "the exec surface does not expose or invoke them.",
71
75
  "Without --isolate, parallel quests are assumed file-disjoint (no locking beyond the store's).",
72
76
  "--isolate worktree leaves the worktree + quest/<id>-<slug> branch in place; the PR is the merge path.",
73
77
  ],
@@ -203,7 +207,11 @@ function resolveOptions(front, config, flags) {
203
207
  if ((flags["codex-sandbox"] != null || worker === "codex") && !CODEX_SANDBOX_MODES.includes(codexSandbox)) {
204
208
  throw new RunUsageError(`--codex-sandbox must be one of ${CODEX_SANDBOX_MODES.join(", ")} (got "${codexSandbox}")`);
205
209
  }
206
- return { worker, model, effort, maxIterations, maxCost, maxTokens, sessionTimeout, codexSandbox };
210
+ const codexGoalMode = flags["codex-goal-mode"] ?? config.defaults.codex?.goal_mode ?? "auto";
211
+ if ((flags["codex-goal-mode"] != null || worker === "codex") && !["auto", "require", "off"].includes(codexGoalMode)) {
212
+ throw new RunUsageError(`--codex-goal-mode must be one of auto, require, off (got "${codexGoalMode}")`);
213
+ }
214
+ return { worker, model, effort, maxIterations, maxCost, maxTokens, sessionTimeout, codexSandbox, codexGoalMode };
207
215
  }
208
216
 
209
217
  function lastSessionIdFor(storeDir, questId, worker) {
@@ -273,6 +281,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
273
281
  schemaPath: SCHEMA_PATH,
274
282
  runStartIso,
275
283
  codexSandbox: opts.codexSandbox,
284
+ codexGoalMode: opts.codexGoalMode,
276
285
  };
277
286
  const inv = adapter.buildInvocation(initial, config, invOpts);
278
287
  if (flags.json) {
@@ -301,6 +310,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
301
310
  if (initial.status === "complete") {
302
311
  finalStatus = "complete";
303
312
  exitCode = 0;
313
+ io.errOut(`quest-run: quest ${id} is already complete — nothing to run. To legally re-enter the loop, run \`quest reopen ${id} --reason "<why>"\` first (never hand-edit the status line).`);
304
314
  } else if (initial.status === "cancelled") {
305
315
  finalStatus = "cancelled";
306
316
  exitCode = 0;
@@ -394,6 +404,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
394
404
  schemaPath: SCHEMA_PATH,
395
405
  runStartIso,
396
406
  codexSandbox: opts.codexSandbox,
407
+ codexGoalMode: opts.codexGoalMode,
397
408
  },
398
409
  runStartIso,
399
410
  iteration: sessionsRun,
@@ -428,6 +439,8 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
428
439
  // landed before the kill — a terminal status is still caught at the top
429
440
  // of the next iteration, so completions are never lost.
430
441
  const progressed = newCheckpoint && !sessionTimedOut;
442
+ // Journal the session BEFORE any terminal break so `quest runs` telemetry
443
+ // (iteration count, cost, tokens) is never dropped for the final session.
431
444
  local.appendRunEvent(storeDir, {
432
445
  event: "iteration_finished",
433
446
  run_id: runId,
@@ -441,6 +454,25 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
441
454
  status_after: after ? after.status : null,
442
455
  });
443
456
 
457
+ // Goal-mode `require`: block whenever create_goal was required but never
458
+ // observed and the quest is still OPEN — a milestone checkpoint does not
459
+ // satisfy the requirement. A genuinely complete/blocked/cancelled quest is
460
+ // respected (its evidence trail stands on its own). A LOST record (`!after`)
461
+ // is not "open": let the missing-record path at the loop top surface it.
462
+ const questOpen = after && (after.status === "in_progress" || after.status === "todo");
463
+ if (opts.worker === "codex" && result.goalToolMissingRequired && questOpen) {
464
+ await recordBlocked(
465
+ storeDir,
466
+ env,
467
+ id,
468
+ "runner: codex goal tools were required but no create_goal tool call was observed",
469
+ "runner codex goal-mode enforcement",
470
+ );
471
+ finalStatus = "blocked";
472
+ exitCode = 10;
473
+ break;
474
+ }
475
+
444
476
  if (progressed) {
445
477
  stall = 0;
446
478
  if (after.status === "complete") {
@@ -538,6 +570,7 @@ async function runReady(storeDir, config, flags, io) {
538
570
 
539
571
  const summaries = [];
540
572
  const done = new Set();
573
+ const skippedEpics = new Set();
541
574
  const inFlight = new Map();
542
575
 
543
576
  const readyIds = async () => {
@@ -550,6 +583,22 @@ async function runReady(storeDir, config, flags, io) {
550
583
  }
551
584
  };
552
585
 
586
+ // Ids that at least one quest names as its parent — i.e. epics. Epics are
587
+ // closed by the orchestrator inline (verify children, run the epic validation
588
+ // loop, checkpoint), never dispatched to a worker. readyQuests already gates
589
+ // out epics with open children; this catches the fully-verified epic that has
590
+ // become "ready" so --ready still refuses to burn a worker run on it. A
591
+ // direct `quest-run <id>` on an epic stays allowed.
592
+ const epicIds = async () => {
593
+ const { stdout, code } = await questCli(storeDir, io.env, ["list", "--json"]);
594
+ if (code !== 0) return new Set();
595
+ try {
596
+ return new Set(JSON.parse(stdout).filter((q) => q.parent !== undefined).map((q) => q.parent));
597
+ } catch {
598
+ return new Set();
599
+ }
600
+ };
601
+
553
602
  const startOne = (id) => {
554
603
  const promise = (async () => {
555
604
  let cwd = io.cwd;
@@ -570,8 +619,14 @@ async function runReady(storeDir, config, flags, io) {
570
619
  };
571
620
 
572
621
  for (;;) {
573
- const ready = (await readyIds()).filter((id) => !done.has(id) && !inFlight.has(id));
622
+ const epics = await epicIds();
623
+ const ready = (await readyIds()).filter((id) => !done.has(id) && !inFlight.has(id) && !skippedEpics.has(id));
574
624
  for (const id of ready) {
625
+ if (epics.has(id)) {
626
+ skippedEpics.add(id);
627
+ io.errOut(`quest-run: quest ${id} is an epic (other quests name it as parent) — refusing to auto-dispatch it. Close it inline per $quest:orchestrate: verify its children, run the epic validation loop, then \`quest checkpoint ${id} --status complete\`. Never burn a worker run on an epic.`);
628
+ continue;
629
+ }
575
630
  if (inFlight.size >= parallel) break;
576
631
  startOne(id);
577
632
  }
@@ -626,6 +681,7 @@ export async function run(argv, io = {}) {
626
681
  "max-tokens": { type: "string" },
627
682
  "session-timeout": { type: "string" },
628
683
  "codex-sandbox": { type: "string" },
684
+ "codex-goal-mode": { type: "string" },
629
685
  "continue-session": { type: "boolean" },
630
686
  notify: { type: "string" },
631
687
  "dry-run": { type: "boolean" },
@@ -20,6 +20,7 @@ import {
20
20
  ContractError,
21
21
  appendToSection,
22
22
  appendUnderCheckpoints,
23
+ assertReopen,
23
24
  assertTransition,
24
25
  lintRecord,
25
26
  makeCheckpoint,
@@ -267,12 +268,19 @@ export function listQuests(repo, env) {
267
268
  });
268
269
  }
269
270
 
270
- // Same readiness rule as store-local.readyQuests, over the remote list.
271
+ // Same readiness rule as store-local.readyQuests, over the remote list
272
+ // including the epic gate: a quest with any non-terminal child (a quest whose
273
+ // parent points at it, not in complete/cancelled) is never ready. Epics are
274
+ // closed by the orchestrator inline, never auto-dispatched; a cancelled child
275
+ // is terminal so it cannot wedge the epic forever.
271
276
  export function readyQuests(repo, env) {
272
277
  const all = listQuests(repo, env);
273
278
  const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
279
+ const openParents = new Set(
280
+ all.filter((q) => q.parent !== undefined && q.status !== "complete" && q.status !== "cancelled").map((q) => q.parent),
281
+ );
274
282
  return all
275
- .filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
283
+ .filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
276
284
  .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
277
285
  }
278
286
 
@@ -372,10 +380,34 @@ export function cancelQuest(repo, id, reason, env) {
372
380
  return { front: { ...rec.front, status: "cancelled", updated } };
373
381
  }
374
382
 
383
+ // Comment-first, mirroring cancelQuest's operation order: post the audited
384
+ // reopen checkpoint, refresh the meta timestamp, then swap the label
385
+ // (quest:complete → quest:in-progress) and reopen the issue via applyStatus.
386
+ export function reopenQuest(repo, id, reason, env) {
387
+ if (!reason || !reason.trim()) throw new ContractError("reopen requires --reason", { hint: 'example: quest reopen 4 --reason "review found npm audit criticals"' });
388
+ const rec = reconstruct(viewIssue(repo, id, env));
389
+ assertReopen(rec.front.status);
390
+ const block = makeCheckpoint({
391
+ timestamp: nowIso(),
392
+ quest_status: "in_progress",
393
+ iteration: rec.checkpoints.length + 1,
394
+ changed: "reopened from complete",
395
+ validation_summary: "reopened for further work; no execution this entry",
396
+ reopen_reason: reason.trim(),
397
+ });
398
+ gh(["issue", "comment", String(id), "--repo", repo, "--body-file", "-"], { env, input: block });
399
+ const updated = nowIso();
400
+ writeStoredBody(repo, id, { ...rec.meta, updated }, rec.storedSections, env);
401
+ applyStatus(repo, id, rec.labels, rec.state, "in_progress", env);
402
+ return { front: { ...rec.front, status: "in_progress", updated }, checkpoints: [...rec.checkpoints, ...parseCheckpoints(block)] };
403
+ }
404
+
375
405
  export function editQuest(repo, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }, env) {
376
406
  if (!rationale || !rationale.trim()) throw new ContractError("edit requires --rationale (scope changes are recorded, per protocol)", { hint: "state why this is a compatible expansion of the Objective" });
377
407
  if (!addDoneWhen.length && !addMilestone.length && !addContext) throw new ContractError("nothing to add — pass --add-done-when, --add-milestone, or --add-context", { hint: "the Objective and existing Done-when items are immutable by design" });
378
408
  const rec = reconstruct(viewIssue(repo, id, env));
409
+ if (rec.front.status === "complete") throw new ContractError("cannot edit a complete quest", { hint: "reopen it first with `quest reopen <id> --reason`, then edit — or file a new quest" });
410
+ if (rec.front.status === "cancelled") throw new ContractError("cannot edit a cancelled quest", { hint: "cancelled is terminal — file a new quest instead" });
379
411
  let sections = rec.storedSections;
380
412
  for (const item of addDoneWhen) sections = appendToSection(sections, "## Done when", `- [ ] ${item}`);
381
413
  for (const item of addMilestone) sections = appendToSection(sections, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, statSync, appendFileSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
- import { ContractError, appendToSection, appendUnderCheckpoints, assertTransition, lintRecord, makeCheckpoint, nowIso, parseRecord, recordFilename, renderBody, serializeRecord, CHECKPOINT_MARKER } from "./contract.mjs";
7
+ import { ContractError, appendToSection, appendUnderCheckpoints, assertReopen, assertTransition, lintRecord, makeCheckpoint, nowIso, parseRecord, recordFilename, renderBody, serializeRecord, CHECKPOINT_MARKER } from "./contract.mjs";
8
8
 
9
9
  export class NotFoundError extends Error {
10
10
  constructor(id) {
@@ -85,8 +85,15 @@ export function listQuests(storeDir) {
85
85
  export function readyQuests(storeDir) {
86
86
  const all = listQuests(storeDir);
87
87
  const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
88
+ // Epic gate: a quest with any non-terminal child (a quest whose parent points
89
+ // at it, not in complete/cancelled) is never ready. Epics are closed by the
90
+ // orchestrator inline (see $quest:orchestrate), never auto-dispatched. A
91
+ // cancelled child is terminal so it cannot wedge the epic forever.
92
+ const openParents = new Set(
93
+ all.filter((q) => q.parent !== undefined && q.status !== "complete" && q.status !== "cancelled").map((q) => q.parent),
94
+ );
88
95
  return all
89
- .filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
96
+ .filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
90
97
  .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
91
98
  }
92
99
 
@@ -162,10 +169,33 @@ export function cancelQuest(storeDir, id, reason) {
162
169
  });
163
170
  }
164
171
 
172
+ // The audited path from complete back into the loop (mirrors cancelQuest, but
173
+ // flips complete → in_progress and appends a real checkpoint carrying the
174
+ // reopen_reason). Uses assertReopen, never assertTransition — a checkpoint can
175
+ // never resurrect a complete quest; only this verb can.
176
+ export function reopenQuest(storeDir, id, reason) {
177
+ if (!reason || !reason.trim()) throw new ContractError("reopen requires --reason", { hint: 'example: quest reopen 4 --reason "review found npm audit criticals"' });
178
+ return updateQuest(storeDir, id, (q) => {
179
+ assertReopen(q.front.status);
180
+ const block = makeCheckpoint({
181
+ timestamp: nowIso(),
182
+ quest_status: "in_progress",
183
+ iteration: q.checkpoints.length + 1,
184
+ changed: "reopened from complete",
185
+ validation_summary: "reopened for further work; no execution this entry",
186
+ reopen_reason: reason.trim(),
187
+ });
188
+ const body = appendUnderCheckpoints(q.body, block);
189
+ return { front: { ...q.front, status: "in_progress" }, body };
190
+ });
191
+ }
192
+
165
193
  export function editQuest(storeDir, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }) {
166
194
  if (!rationale || !rationale.trim()) throw new ContractError("edit requires --rationale (scope changes are recorded, per protocol)", { hint: "state why this is a compatible expansion of the Objective" });
167
195
  if (!addDoneWhen.length && !addMilestone.length && !addContext) throw new ContractError("nothing to add — pass --add-done-when, --add-milestone, or --add-context", { hint: "the Objective and existing Done-when items are immutable by design" });
168
196
  return updateQuest(storeDir, id, (q) => {
197
+ if (q.front.status === "complete") throw new ContractError("cannot edit a complete quest", { hint: "reopen it first with `quest reopen <id> --reason`, then edit — or file a new quest" });
198
+ if (q.front.status === "cancelled") throw new ContractError("cannot edit a cancelled quest", { hint: "cancelled is terminal — file a new quest instead" });
169
199
  let body = q.body;
170
200
  for (const item of addDoneWhen) body = appendToSection(body, "## Done when", `- [ ] ${item}`);
171
201
  for (const item of addMilestone) body = appendToSection(body, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
package/lib/store.mjs CHANGED
@@ -18,6 +18,7 @@ export function openStore(config, ctx = {}) {
18
18
  startQuest: (id) => github.startQuest(repo, id, env),
19
19
  appendCheckpoint: (id, cp) => github.appendCheckpoint(repo, id, cp, env),
20
20
  cancelQuest: (id, reason) => github.cancelQuest(repo, id, reason, env),
21
+ reopenQuest: (id, reason) => github.reopenQuest(repo, id, reason, env),
21
22
  editQuest: (id, changes) => github.editQuest(repo, id, changes, env),
22
23
  lintAll: () => github.lintAll(repo, env),
23
24
  };
@@ -31,6 +32,7 @@ export function openStore(config, ctx = {}) {
31
32
  startQuest: (id) => local.startQuest(dir, id),
32
33
  appendCheckpoint: (id, cp) => local.appendCheckpoint(dir, id, cp),
33
34
  cancelQuest: (id, reason) => local.cancelQuest(dir, id, reason),
35
+ reopenQuest: (id, reason) => local.reopenQuest(dir, id, reason),
34
36
  editQuest: (id, changes) => local.editQuest(dir, id, changes),
35
37
  lintAll: () => local.lintAll(dir),
36
38
  };
package/lib/workers.mjs CHANGED
@@ -46,7 +46,7 @@ function workInstructions(id) {
46
46
  return (
47
47
  "Work quest " +
48
48
  id +
49
- " per the /quest:work skill. The quest store is in this directory. " +
49
+ " per the $quest:work skill. The quest store is in this directory. " +
50
50
  "End every iteration by running quest show " +
51
51
  id +
52
52
  "."
@@ -242,20 +242,28 @@ const CODEX_CREATE_GOAL_CORRECTION = (id) =>
242
242
  "You did not invoke the create_goal tool — you narrated it instead. Call the create_goal tool now " +
243
243
  "(a real tool call, not prose) with the stopping condition, verify with get_goal, then continue working quest " +
244
244
  id +
245
- " per /quest:work.";
245
+ " per $quest:work.";
246
246
 
247
247
  export const codex = {
248
248
  name: "codex",
249
249
 
250
250
  buildInvocation(questRecord, config, opts) {
251
251
  const { id } = opts;
252
- const prompt =
253
- "First step: Create a goal for this thread using the create_goal tool (not as prose) with this exact " +
254
- "stopping condition: " +
255
- stoppingCondition(id, opts.runStartIso) +
256
- '. Verify with get_goal. Only call update_goal(status="complete") AFTER `quest checkpoint` succeeded.' +
257
- "\n" +
258
- workInstructions(id);
252
+ const goalMode = opts.codexGoalMode ?? "auto";
253
+ const condition = stoppingCondition(id, opts.runStartIso);
254
+ // Each mode gets its own complete, self-terminating instruction. Every mode —
255
+ // including `off` — names the `quest checkpoint` command, which is the sole
256
+ // machine-verifiable stop signal a headless worker must emit.
257
+ const goalLine =
258
+ goalMode === "off"
259
+ ? `Do not rely on goal tools. Treat this as the run contract — stopping condition: ${condition}. ` +
260
+ 'Record progress with `quest checkpoint`; only report the quest complete AFTER `quest checkpoint` succeeded.'
261
+ : goalMode === "require"
262
+ ? `First step: Create a goal for this thread using the create_goal tool (not as prose) with this exact stopping condition: ${condition}. ` +
263
+ 'Verify with get_goal. Only call update_goal(status="complete") AFTER `quest checkpoint` succeeded.'
264
+ : `If goal tools are available in this exec surface, create a goal for this thread using the create_goal tool with this exact stopping condition: ${condition}. ` +
265
+ 'If you created a goal, verify with get_goal. Only call update_goal(status="complete") AFTER `quest checkpoint` succeeded.';
266
+ const prompt = goalLine + "\n" + workInstructions(id);
259
267
  const artifactsFile = codexArtifactsFile();
260
268
  const args = [
261
269
  "exec",
@@ -287,7 +295,7 @@ export const codex = {
287
295
  const args = ["exec", "resume"];
288
296
  if (sessionArg === "--last") args.push("--last");
289
297
  else args.push(sessionArg);
290
- args.push(text, "--json", "-C", opts.cwd, "--skip-git-repo-check", "-o", artifactsFile, "--output-schema", opts.schemaPath);
298
+ args.push(text, "--json", "--skip-git-repo-check", "-o", artifactsFile, "--output-schema", opts.schemaPath);
291
299
  if (opts.effort) args.push("-c", `model_reasoning_effort=${opts.effort}`);
292
300
  const basePath = opts.env?.PATH ?? "";
293
301
  const env = { PATH: `${join(opts.pluginRoot, "bin")}:${basePath}` };
@@ -339,8 +347,10 @@ export const codex = {
339
347
  const first = await runSegment(this.buildInvocation(questRecord, config, opts));
340
348
  let sawGoal = codexUsedCreateGoal(first.events || []);
341
349
 
342
- // Corrective resume: create_goal was narrated, not invoked.
343
- if (!sawGoal) {
350
+ // Corrective resume only when the caller explicitly requires goal tools.
351
+ // In auto/off modes the documented Codex exec JSONL + output-schema path is
352
+ // the contract; goal tools are useful but not assumed available.
353
+ if (!sawGoal && opts.codexGoalMode === "require") {
344
354
  correctiveResume = true;
345
355
  resumes += 1;
346
356
  const corr = this.buildResume(questRecord, config, opts, "--last", CODEX_CREATE_GOAL_CORRECTION(opts.id));
@@ -348,6 +358,10 @@ export const codex = {
348
358
  if (codexUsedCreateGoal(cres.events || [])) sawGoal = true;
349
359
  }
350
360
 
361
+ if (opts.codexGoalMode === "require" && !sawGoal) {
362
+ return { session_id: sessionId, cost_usd: cost, tokens, invocations, sawGoal, correctiveResume, resumes, goalToolMissingRequired: true };
363
+ }
364
+
351
365
  // Same-session continuation while the stopping condition is unmet.
352
366
  while (resumes < CODEX_MAX_RESUMES) {
353
367
  const state = await ctx.readState();
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "quest-loop",
3
- "version": "0.1.0",
3
+ "version": "0.3.2",
4
4
  "description": "Goal-loop engineering for coding agents: quest contracts, iterative execution with evidence checkpoints, Claude + Codex workers. Ships the `quest` store CLI and the `quest-run` headless runner.",
5
5
  "type": "module",
6
6
  "engines": { "node": ">=20" },
7
7
  "bin": {
8
- "quest": "./bin/quest",
9
- "quest-run": "./bin/quest-run"
8
+ "quest": "bin/quest",
9
+ "quest-run": "bin/quest-run"
10
10
  },
11
11
  "files": [
12
12
  "bin/",
@@ -21,54 +21,131 @@ trails, and escalations only where a human ruling is genuinely needed.
21
21
  quest list --ready --json # the dispatch queue (deps met, priority order)
22
22
  quest runs --active # headless runners that outlived prior sessions
23
23
  ```
24
- 2. **Dispatch** each ready quest per its record:
25
- - `worker: claude` spawn the `quest-executor` subagent with the record's
26
- `model`/`effort` as the dispatch override; prompt = "Work quest <id> per
27
- /quest:work."
28
- - `worker: codex`, parallel batches, or anything long-running → run
29
- `quest-run <id>` in **background Bash** and keep working; you'll be
30
- notified when it exits. Parallel file-disjoint quests:
31
- `quest-run --ready --parallel 3` (add `--isolate worktree` when they touch
32
- the same files).
33
- 3. **Verify before you believe:** when a worker stops, run
24
+ For an implementation accepted from `$quest:plan` in Codex Plan Mode, stay in
25
+ this orchestrator role. Do not implement product code inline; create/lint the
26
+ quest records if needed, then dispatch workers.
27
+ 2. **Pin the wave with native goal mode:**
28
+ - In Codex, call `create_goal` with this stopping condition:
29
+ "every quest in the scoped wave shows complete, blocked, or cancelled in
30
+ `quest list --json` output"; verify it with `get_goal`.
31
+ - In Claude Code, start the turn with `/goal` using the same condition.
32
+ If native goal mode is unavailable, say so and keep the Quest checkpoint
33
+ trail as the hard stop signal; do not pretend a goal was set.
34
+ 3. **Dispatch** each ready quest per its record:
35
+ - In Codex, the default path is native subagents for both serial and
36
+ parallel waves. If `spawn_agent` is not visible, call `tool_search` once
37
+ for subagent tools before choosing any fallback. When available, spawn:
38
+ `agent_type: "quest-executor"` and prompt =
39
+ `First call create_goal with: quest <id> has a new checkpoint whose
40
+ quest_status is complete or blocked in \`quest show <id> --json\`; verify
41
+ with get_goal; work quest <id> per $quest:work; only call
42
+ update_goal(status="complete") after the checkpoint exists.`
43
+ If the agent template is missing, run `quest codex install-agents --scope
44
+ project` (or `$quest:setup`) before dispatching.
45
+ - In Claude Code, spawn the `quest-executor` subagent with the record's
46
+ `model`/`effort` as the dispatch override and prompt =
47
+ `/goal quest <id> has a new checkpoint whose quest_status is complete or
48
+ blocked in \`quest show <id> --json\`\nWork quest <id> per $quest:work.`
49
+ - Use `quest-run` only when native subagents are still unavailable after
50
+ `tool_search`, or when the user explicitly asks for headless/background
51
+ execution. Codex fallback must require goal mode:
52
+ `quest-run <id> --worker codex --codex-goal-mode require`. Headless
53
+ file-disjoint waves can use `quest-run --ready --parallel 3
54
+ --codex-goal-mode require` (add `--isolate worktree` when they touch the
55
+ same files). Claude headless runs already enter native `/goal` mode.
56
+ 4. **Verify before you believe:** when a worker stops, run
34
57
  `quest show <id> --json`. A stop WITHOUT a new checkpoint is a protocol
35
58
  violation — redispatch with exactly that instruction. Never accept a chat
36
59
  summary in place of a recorded checkpoint.
37
- 4. **Review before accepting complete:** for non-trivial quests, spawn
38
- `quest-reviewer` on the diff + checkpoint evidence. Every finding gets a
39
- disposition: fixed / follow-up quest filed / rejected-with-reason.
40
- 5. **Rule** (quote evidence, never adjectives):
60
+ 5. **Review before accepting complete:** for non-trivial quests, spawn
61
+ `quest-reviewer` on the diff + checkpoint evidence in the same harness. Give
62
+ it a goal: return an `accept` or `iterate` verdict with evidence. In Codex,
63
+ ask it to call `create_goal`/`get_goal` and only `update_goal` after the
64
+ verdict exists; in Claude Code, prefix the reviewer prompt with `/goal`.
65
+ Every finding gets a disposition: fixed / follow-up quest filed /
66
+ rejected-with-reason.
67
+ 6. **Rule** (quote evidence, never adjectives):
41
68
  - **accept** — the validation_summary's commands actually discharge the
42
69
  Done-when items.
43
70
  - **iterate-with-feedback** — send the specific gap back (continue the
44
71
  subagent, or `quest-run <id> --continue-session`).
45
- - **split** — bigger than it looked → `/quest:plan` to decompose; cancel or
72
+ - **split** — bigger than it looked → `$quest:plan` to decompose; cancel or
46
73
  re-parent the original honestly.
47
74
  - **escalate-to-human** — surface human-only decisions verbatim. Never
48
75
  guess a ruling the human should make.
49
- 6. **Wave done?** When `quest list --ready` empties and nothing is in flight:
50
- run `/quest:retro` before starting the next wave.
76
+ 7. **Wave done?** When `quest list --ready` empties and nothing is in flight:
77
+ run `$quest:retro` before starting the next wave.
78
+
79
+ ## Closing an epic
80
+
81
+ An epic is an ordinary quest that other quests name as `parent`. It is **never
82
+ dispatched to a worker**: `quest list --ready` gates it out while any child is
83
+ non-terminal, and `quest-run --ready` refuses it even once every child is
84
+ terminal (a direct `quest-run <id>` on an epic still runs, but don't — it burns
85
+ a worker on pure verification). Close it inline yourself, spending zero worker
86
+ tokens:
87
+
88
+ 1. **Verify the children are genuinely done:**
89
+ ```bash
90
+ quest list --parent <id> --json # is every child complete or cancelled?
91
+ ```
92
+ A `cancelled` child is terminal too — account for why it was dropped; it does
93
+ not block the epic and you do not wait on it.
94
+ 2. **Run the epic's own validation loop** (the integration-level check in its
95
+ record) and read each child's completion evidence — this is the real work of
96
+ closing an epic.
97
+ 3. **Record the verdict on the epic itself:**
98
+ ```bash
99
+ quest start <id>
100
+ quest checkpoint <id> --status complete \
101
+ --summary "epic closed inline — children #a #b #c verified, integration loop green" \
102
+ --validation "<epic validation loop> → <observed result>"
103
+ ```
104
+ The checkpoint must **enumerate each child and each epic Done-when item** with
105
+ its evidence — the same bar every quest meets, just discharged by you inline
106
+ rather than by a dispatched worker.
107
+
108
+ ## Reopening completed work
109
+
110
+ When review (or reality) finds a defect in a quest you already marked
111
+ **complete**, never hand-edit the status line and never redispatch a worker onto
112
+ the terminal record — `quest-run` early-exits on a complete quest and journals a
113
+ 0-session no-op. Instead reopen it:
114
+
115
+ ```bash
116
+ quest reopen <id> --reason "review found npm audit criticals after completion"
117
+ ```
118
+
119
+ This flips `complete → in_progress` and appends an audited checkpoint carrying
120
+ `reopen_reason`, so the loop keeps custody of the defect trail. Then dispatch the
121
+ quest directly by id (`quest-run <id>` or a `quest-executor` subagent) — reopened
122
+ quests are `in_progress`, so they do **not** re-appear in `quest list --ready`.
123
+ Reopening a child of a **complete** parent epic is allowed (a stderr warning, not
124
+ a block); you then rule whether the epic's completion verdict is falsified and,
125
+ if so, reopen the epic too. `cancelled` is fully terminal — file a new quest.
51
126
 
52
127
  ## Autonomous waves
53
128
 
54
129
  For an unattended wave, pin your own session to the outcome with a native goal:
55
130
 
56
131
  ```
57
- /goal every quest in this wave shows complete or blocked in `quest list --json` output
132
+ every quest in this wave shows complete, blocked, or cancelled in `quest list --json` output
58
133
  ```
59
134
 
60
- The harness then keeps you cycling until the wave is genuinely done.
135
+ In Codex, use the native goal tool when available; in Claude Code, use
136
+ `/goal <condition>`. The harness then keeps you cycling until the wave is
137
+ genuinely done.
61
138
 
62
139
  ## Worked example
63
140
 
64
141
  ```bash
65
142
  quest list --ready --json # → [{"id":12,"worker":"claude"…},{"id":13,"worker":"codex"…}]
66
- # 12 → dispatch quest-executor subagent (model/effort from the record)
67
- # 13 → background Bash: quest-run 13
143
+ # 12 → spawn quest-executor with a child /goal or create_goal prompt
144
+ # 13 → spawn quest-executor too; use quest-run only if native subagents are unavailable
68
145
  # …executor stops →
69
146
  quest show 12 --json # new checkpoint? quest_status? evidence?
70
147
  # reviewer on 12's diff → findings dispositioned → accept
71
148
  ```
72
149
 
73
- **Next:** contracts weak? `/quest:plan` to fix them first. Wave finished?
74
- `/quest:retro`. Vocabulary and stop rules: `/quest:protocol`.
150
+ **Next:** contracts weak? `$quest:plan` to fix them first. Wave finished?
151
+ `$quest:retro`. Vocabulary and stop rules: `$quest:protocol`.
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Orchestrate Quests"
3
3
  short_description: "Dispatch workers on ready quests, verify checkpoints, rule on evidence"
4
- default_prompt: "Use $orchestrate to drive the ready quests to completion."
4
+ default_prompt: "Use $quest:orchestrate to drive the ready quests to completion."
@@ -22,10 +22,41 @@ extra context.
22
22
  | Medium | 1 quest, dispatch an executor | needs iterations, fits one Objective |
23
23
  | Large | epic parent + child quests in waves | multiple objectives; order via `depends_on` |
24
24
 
25
+ ## Plan Mode handoff
26
+
27
+ In Codex Plan Mode, do **not** implement product code after the user accepts a
28
+ plan. Your role is to make the quest records real, then ask before orchestration:
29
+
30
+ 1. Create or confirm the quest records with `quest create` and `quest lint`.
31
+ 2. If the user accepted a plan and asked to implement it, your next role is
32
+ `$quest:orchestrate`, not `$quest:work`. Do not start editing product code in
33
+ the parent session.
34
+ 3. If the user only asked to create quests, ask whether to enter
35
+ `$quest:orchestrate` and dispatch the ready quests now. Do not silently
36
+ switch modes.
37
+ 4. In `$quest:orchestrate`, set the orchestrator goal for the wave, then spawn
38
+ goal-mode workers. If the user declines orchestration, stop after listing the
39
+ ready quest ids and validation commands.
40
+
41
+ The parent session owns dispatch, checkpoint verification, reviewer rulings, and
42
+ epic closure. The spawned executor owns implementation for exactly one quest.
43
+ The parent session never uses `$quest:work <id>` after a Plan Mode handoff
44
+ unless the user explicitly asks to bypass orchestration for a genuinely small
45
+ single quest.
46
+
25
47
  For epics: create the parent first, then children with `--parent <id>` and
26
48
  `--depends-on` expressing the real order. `quest list --ready` becomes the
27
49
  dispatch queue — that is the whole wave mechanic.
28
50
 
51
+ Keep the **epic itself thin**. Its Done-when is **integration-level only**: it
52
+ checks that the children compose into a working whole (the end-to-end behavior,
53
+ the parity gate, the shipped docs), never a restatement of each child's
54
+ Done-when. Its milestones must **not mirror the children 1:1** — the children
55
+ *are* the decomposition, so re-listing them in the epic body earns no worker and
56
+ wastes review. Give the epic an objective, integration Done-when, and the
57
+ validation loop the orchestrator runs to close it inline (it is never
58
+ dispatched — see `$quest:orchestrate` "Closing an epic").
59
+
29
60
  ## Author the contract
30
61
 
31
62
  Every field earns its place:
@@ -42,8 +73,12 @@ Every field earns its place:
42
73
  - **Context**: files + symbols (`resolveConfig` in `lib/config.mjs`), related
43
74
  quests. NEVER bare line numbers — they rot.
44
75
  - **Out of scope**: the adjacent work you are explicitly not doing.
45
- - `--worker` / `--model` / `--effort` / `--max-iterations`: match the tier to
46
- the difficulty; the defaults come from `.quests/config.json`.
76
+ - `--worker`, `--model`, and `--effort`: always specify all three explicitly in
77
+ every `quest create` command you author. Pick them deliberately from the task's
78
+ risk, ambiguity, context size, and validation cost so the worker starts with
79
+ an intentional execution profile instead of inheriting blindly from defaults.
80
+ - `--max-iterations`: match the loop budget to the difficulty; defaults in
81
+ `.quests/config.json` are fallback behavior, not a planning substitute.
47
82
 
48
83
  **Anti-patterns** (lint catches some, you catch the rest): adjective done-whens
49
84
  ("fast", "clean"); validation loops that are prose, not commands; objectives
@@ -52,7 +87,8 @@ hiding three objectives; context by line number; budgets so big they never bind.
52
87
  ## Worked example
53
88
 
54
89
  ```bash
55
- quest create --title "Add dark mode to settings" \
90
+ quest create --worker codex --model gpt-5.5 --effort medium --max-iterations 4 \
91
+ --title "Add dark mode to settings" \
56
92
  --objective "The settings page offers a dark theme that persists across reloads." \
57
93
  --done-when "toggling theme switches the UI and survives reload" \
58
94
  --done-when "\`npm test\` passes including new theme tests" \
@@ -65,5 +101,6 @@ quest create --title "Add dark mode to settings" \
65
101
  quest lint 12 # always, before dispatch
66
102
  ```
67
103
 
68
- **Next:** dispatch with `/quest:orchestrate` (or work it yourself via
69
- `/quest:work <id>`). Rules and vocabulary: `/quest:protocol`.
104
+ **Next:** dispatch with `$quest:orchestrate`. Only work it yourself via
105
+ `$quest:work <id>` for genuinely small inline work outside Plan Mode and outside
106
+ an accepted Plan Mode handoff. Rules and vocabulary: `$quest:protocol`.
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Plan Quests"
3
3
  short_description: "Turn an ask into evidence-checkable quest contracts"
4
- default_prompt: "Use $plan to decompose this ask into quest contracts via `quest create`."
4
+ default_prompt: "Use $quest:plan to decompose this ask into quest contracts via `quest create`."