quest-loop 0.3.5 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -4,7 +4,28 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [SemVer](https://semver.org/).
6
6
 
7
- ## Unreleased
7
+ ## [0.3.6] — 2026-07-09
8
+
9
+ ### Added
10
+ - `quest list --queue` now exposes worker-ready quests, inline-close-ready epics,
11
+ and blocked queue reasons as separate orchestration queues.
12
+ - `quest lint --all` now reports missing parent and `depends_on` references
13
+ across local and GitHub-backed stores.
14
+ - Provider `work --dry-run` commands now print native subagent handoffs or the
15
+ `quest-run` fallback command after running setup health checks.
16
+
17
+ ### Changed
18
+ - Session-start status output and command help are more concise and focused on
19
+ the next operator action.
20
+ - `$quest:plan` and `$quest:orchestrate` now document the v2 queue workflow and
21
+ explicit provider/model/effort handoffs more precisely.
22
+
23
+ ### Fixed
24
+ - Malformed hand-edited `depends_on` metadata now stays a normal contract or
25
+ queue-blocking problem instead of crashing graph lint or entering
26
+ `worker_ready`.
27
+ - Headless Codex fallback handoff commands now quote Quest binary paths that
28
+ contain spaces.
8
29
 
9
30
  ## [0.3.5] — 2026-07-08
10
31
 
package/README.md CHANGED
@@ -128,7 +128,8 @@ you drive it from Claude Code, Codex, or a plain shell.
128
128
 
129
129
  ## Quickstart
130
130
 
131
- Create a store in your project, author a quest, check it, and work it:
131
+ Create a store in your project, author a quest, check it, then use the compact
132
+ dashboard to pick the next action:
132
133
 
133
134
  ```bash
134
135
  # 1. Create a quest store (.quests/) here
@@ -141,11 +142,14 @@ quest create --title "Add dark mode" \
141
142
  --done-when "\`npm test\` passes including new theme tests" \
142
143
  --validation "npm test"
143
144
 
144
- # 3. Check it against the contract spec
145
- quest lint --all
145
+ # 3. Check the new quest against the contract spec
146
+ quest lint 1
146
147
 
147
- # 4. See what's ready to work (dependencies met)
148
- quest list --ready
148
+ # 4. Show counts, active work, ready work, and the next command
149
+ quest
150
+
151
+ # 5. Read the quest before working it
152
+ quest show 1 --json
149
153
  ```
150
154
 
151
155
  By default, `quest init` also installs project-scoped native agent templates for
@@ -237,6 +241,7 @@ full base protocol lives in
237
241
 
238
242
  | Command | Purpose |
239
243
  |---|---|
244
+ | bare `quest` | Show a compact dashboard with counts, active work, ready work, active runs, and the next command |
240
245
  | `quest init` | Create a quest store (`.quests/`) and install project-scoped Codex/Claude agent templates by default |
241
246
  | `quest create` | Create a new quest (the only way records are born) |
242
247
  | `quest list` | List quests (filter by status, parent, or readiness) |
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // SessionStart hook (startup / clear / compact). When a `.quests/` store exists
3
3
  // at or above the session's cwd, write a short one-paragraph summary of in-flight
4
- // quests and active runs to stdout — Claude Code injects stdout as session
5
- // context. No store, a non-local backend, an empty store, or ANY error → a silent
6
- // no-op (exit 0). Reads only; no network and no child processes, so it stays fast.
4
+ // local quests and active runs to stdout — Claude Code injects stdout as session
5
+ // context. GitHub-backed stores get a no-network hint instead of live issue
6
+ // counts. No store or ANY error → a silent no-op (exit 0). Reads only; no network
7
+ // and no child processes, so it stays fast.
7
8
 
8
9
  import { writeSync } from "node:fs";
9
10
  import { findStoreDir, loadConfig } from "../lib/config.mjs";
@@ -24,25 +25,31 @@ function activeRunCount(storeDir) {
24
25
  }
25
26
 
26
27
  // One paragraph (<= ~3 lines): counts by status, up to 3 in-flight quests, the
27
- // active-run count, and the ready-list hint. Returns null when there is nothing
28
- // worth injecting (empty store).
28
+ // active-run count, and one exact next command.
29
29
  function buildContext(storeDir) {
30
30
  const all = listQuests(storeDir);
31
- if (!all.length) return null;
31
+ if (!all.length) return "Quest store (.quests): local backend, no quests.\nNext: run `quest create --help`.";
32
32
  const counts = {};
33
33
  for (const q of all) counts[q.status] = (counts[q.status] ?? 0) + 1;
34
34
  const countStr = STATUS_ORDER.filter((s) => counts[s]).map((s) => `${counts[s]} ${s}`).join(", ");
35
- const inFlight = all
35
+ const inFlightQuests = all
36
36
  .filter((q) => q.status === "in_progress" || q.status === "blocked")
37
37
  .sort((a, b) => (a.status === b.status ? a.id - b.id : a.status === "in_progress" ? -1 : 1))
38
- .slice(0, 3)
39
- .map((q) => `#${q.id} ${q.title} [${q.status}]`);
38
+ .slice(0, 3);
39
+ const inFlight = inFlightQuests.map((q) => `#${q.id} ${q.title} [${q.status}]`);
40
40
  const lines = [`Quest store (.quests): ${all.length} quest${all.length === 1 ? "" : "s"} — ${countStr}.`];
41
41
  if (inFlight.length) lines.push(`In flight: ${inFlight.join("; ")}.`);
42
- lines.push(`Active runs: ${activeRunCount(storeDir)}. Ready to work next: \`quest list --ready\`.`);
42
+ const next = inFlightQuests[0] ? `quest show ${inFlightQuests[0].id} --json` : "quest list --ready";
43
+ lines.push(`Active runs: ${activeRunCount(storeDir)}. Next: run \`${next}\`.`);
43
44
  return lines.join("\n");
44
45
  }
45
46
 
47
+ function githubContext(config) {
48
+ const repo = config.github?.repo;
49
+ const scope = repo ? ` for ${repo}` : "";
50
+ return `Quest store (.quests): github backend${scope}. No GitHub request was made by this hook.\nNext: run \`quest list --ready\` when you need live issue state.`;
51
+ }
52
+
46
53
  try {
47
54
  const raw = await readStdin();
48
55
  let payload = {};
@@ -50,7 +57,12 @@ try {
50
57
  const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
51
58
  const storeDir = findStoreDir(cwd, process.env);
52
59
  if (!storeDir) process.exit(0); // no store here — silent no-op, NOT an error
53
- if (loadConfig(storeDir, process.env).backend !== "local") process.exit(0); // only local is readable today
60
+ const config = loadConfig(storeDir, process.env);
61
+ if (config.backend === "github") {
62
+ writeSync(1, githubContext(config) + "\n");
63
+ process.exit(0);
64
+ }
65
+ if (config.backend !== "local") process.exit(0);
54
66
  const context = buildContext(storeDir);
55
67
  if (context) writeSync(1, context + "\n");
56
68
  process.exit(0);
@@ -6,25 +6,29 @@
6
6
  // A subagent counts as a quest-executor iff one of its transcript entries records
7
7
  // an actual *mutating* quest invocation — `quest start <id>` or `quest checkpoint
8
8
  // <id>` (under any binary prefix: `quest`, `./bin/quest`, `node bin/quest`) — as a
9
- // tool_use block whose shell command carries the marker. Read-only verbs
10
- // (`quest show <id> --json`, `list`, `protocol`, `runs`) are how reviewers and
11
- // orchestrators inspect a quest without owning it, so they must NEVER key
9
+ // tool_use block whose shell command carries the marker, OR the native subagent
10
+ // stop payload itself names the Quest-owned `quest-executor` agent and carries a
11
+ // launch prompt in the orchestrator's narrow `work quest <id>` shape. Read-only
12
+ // verbs (`quest show <id> --json`, `list`, `protocol`, `runs`) are how reviewers
13
+ // and orchestrators inspect a quest without owning it, so they must NEVER key
12
14
  // detection: an agent whose transcript holds only read verbs is allowed silently.
13
- // The executor id comes from that first mutating invocation (deterministic,
14
- // transcript order) — including `quest checkpoint <id>`, because an executor
15
- // resuming an already-in_progress quest skips `quest start` and its first mutating
16
- // verb is the checkpoint. We parse the JSONL per-entry and inspect only tool_use
17
- // command inputs; prose, quoted skill text, examples, and echoed file contents live
18
- // in text blocks and tool_result content (never in a tool_use command), so
19
- // `quest start 12` / `quest checkpoint 12` examples in the skills can no longer key
20
- // detection. No mutating invocation not our concern, allow silently. We then
15
+ // The executor id comes from the first mutating invocation (deterministic,
16
+ // transcript order) when present — including `quest checkpoint <id>`, because an
17
+ // executor resuming an already-in_progress quest skips `quest start` and its first
18
+ // mutating verb is the checkpoint. If no mutating command has run yet, a native
19
+ // `quest-executor` launch prompt with an explicit quest id can still identify the
20
+ // owner. We parse the JSONL per-entry and inspect only command inputs; prose,
21
+ // quoted skill text, examples, and echoed file contents live in text blocks and
22
+ // tool_result content (never in a command), so `quest start 12` / `quest checkpoint
23
+ // 12` examples in the skills can no longer key detection. No mutating invocation
24
+ // and no native executor launch id → not our concern, allow silently. We then
21
25
  // compare the quest's latest checkpoint against the subagent's start time (the
22
26
  // transcript's first timestamp): a checkpoint newer than start clears the stop; so
23
27
  // does a terminal store status (complete/blocked/cancelled) reached during the run.
24
28
  //
25
- // Conservative by construction: any missing/unreadable input or parse failure →
26
- // allow (exit 0), with a one-line stderr diagnostic. We never false-positive-block
27
- // unrelated subagents.
29
+ // Conservative by construction: any missing input, unknown store/quest,
30
+ // unreadable input, or parse failure → allow (exit 0). Expected allow paths stay
31
+ // silent so unrelated or partially-shaped subagents do not surface hook noise.
28
32
  //
29
33
  // Block contract (Claude Code command hook): print {"decision":"block","reason"}
30
34
  // to stdout and exit 0. (Note: the {"ok":false,...} shape is for prompt/agent
@@ -41,6 +45,8 @@ import { loadQuest } from "../lib/store-local.mjs";
41
45
  // `git commit -m "quest checkpoint 1"`) does not falsely key executor detection.
42
46
  // Read verbs (show/list/protocol/runs) are deliberately absent.
43
47
  const MARKER = /^(?:node\s+)?(?:[.\w/@-]*\/)?quest\s+(start|checkpoint)\s+(\d+)/;
48
+ const NATIVE_EXECUTOR_AGENT = "quest-executor";
49
+ const NATIVE_WORK_PROMPT = /\bwork\s+quest\s+(\d+)\b/i;
44
50
 
45
51
  // Split a shell command into top-level segments on `&&`, `||`, `;`, `|`, and
46
52
  // newline, IGNORING any separator that sits inside single or double quotes. This
@@ -143,6 +149,20 @@ function commandOf(input) {
143
149
  return input && typeof input === "object" && typeof input.command === "string" ? input.command : null;
144
150
  }
145
151
 
152
+ function firstString(...values) {
153
+ return values.find((v) => typeof v === "string" && v.trim());
154
+ }
155
+
156
+ function nativeExecutorQuestId(payload) {
157
+ if (!payload || typeof payload !== "object") return null;
158
+ const agentType = firstString(payload.agent_type, payload.agentType, payload.agent_name, payload.agentName, payload.name);
159
+ if (agentType !== NATIVE_EXECUTOR_AGENT) return null;
160
+ const prompt = firstString(payload.prompt, payload.task_prompt, payload.taskPrompt, payload.agent_prompt, payload.agentPrompt);
161
+ if (!prompt) return null;
162
+ const m = NATIVE_WORK_PROMPT.exec(prompt);
163
+ return m ? Number(m[1]) : null;
164
+ }
165
+
146
166
  // The mutating-verb marker id from one transcript entry, considering only tool_use
147
167
  // command invocations. Assistant messages carry an array of content blocks; string
148
168
  // content (plain prose) and tool_result blocks (echoed output/file contents) are
@@ -194,29 +214,29 @@ try {
194
214
  try { payload = raw.trim() ? JSON.parse(raw) : {}; } catch { payload = {}; }
195
215
 
196
216
  const transcriptPath = payload.transcript_path;
197
- if (typeof transcriptPath !== "string" || !transcriptPath) { diag("no transcript_path in payload; allowing"); allow(); }
217
+ if (typeof transcriptPath !== "string" || !transcriptPath) allow();
198
218
 
199
219
  let transcript;
200
220
  try { transcript = readFileSync(transcriptPath, "utf8"); }
201
221
  catch (err) { diag(`cannot read transcript (${err.code || err.message}); allowing`); allow(); }
202
222
 
203
- const id = executorQuestId(transcript);
223
+ const id = executorQuestId(transcript) ?? nativeExecutorQuestId(payload);
204
224
  if (id == null) allow(); // no mutating quest invocation (read-only agent) — leave it alone, silently
205
225
 
206
226
  // Prefer an explicit start field if a future payload carries one; else the
207
227
  // transcript's first timestamp.
208
228
  const start = toMs(payload.start_time) ?? toMs(payload.started_at) ?? firstTimestamp(transcript);
209
- if (start == null) { diag(`quest ${id}: could not determine subagent start time; allowing`); allow(); }
229
+ if (start == null) allow();
210
230
 
211
231
  const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
212
232
  let storeDir;
213
233
  try { storeDir = findStoreDir(cwd, process.env); }
214
- catch (err) { diag(`quest ${id}: ${err.message}; allowing`); allow(); }
215
- if (!storeDir) { diag(`quest ${id}: no store found from ${cwd}; allowing`); allow(); }
234
+ catch { allow(); }
235
+ if (!storeDir) allow();
216
236
 
217
237
  let quest;
218
238
  try { quest = loadQuest(storeDir, id); }
219
- catch (err) { diag(`quest ${id}: ${err.message}; allowing`); allow(); }
239
+ catch { allow(); }
220
240
 
221
241
  // A checkpoint recorded after the subagent started clears the stop.
222
242
  const latestCp = quest.checkpoints.reduce((max, cp) => {
package/lib/cli.mjs CHANGED
@@ -99,6 +99,10 @@ function printDoctorResult(command, result, out) {
99
99
  for (const r of result.repairs) out(` ${r.ok ? "OK " : "ERR"} ${r.name}: ${r.command} (${r.detail})`);
100
100
  }
101
101
  for (const c of result.checks) out(`${c.ok ? "OK " : "ERR"} ${c.name}: ${c.detail}`);
102
+ if (result.recommended_path) {
103
+ const r = result.recommended_path;
104
+ out(`Recommendation: ${r.label} — ${r.command}`);
105
+ }
102
106
  out(result.ok ? `${command} doctor: OK` : `${command} doctor: problems found`);
103
107
  }
104
108
 
@@ -132,6 +136,54 @@ function openInvocation(command, cwd, env, forwarded) {
132
136
  throw new Error(`unknown provider "${command}"`);
133
137
  }
134
138
 
139
+ function questCommandPrelude(cwd, env) {
140
+ const root = nativeProjectRoot(cwd, env);
141
+ return [
142
+ `cd ${shellQuote(root)}`,
143
+ `export PATH=${shellQuote(join(PLUGIN_ROOT, "bin"))}:$PATH`,
144
+ "quest --version",
145
+ "git status --short --branch",
146
+ ];
147
+ }
148
+
149
+ function codexNativePrompt(id) {
150
+ return "First call create_goal with: quest " + id +
151
+ " has a new checkpoint whose quest_status is complete or blocked in `quest show " + id +
152
+ " --json`; verify with get_goal; work quest " + id +
153
+ " per $quest:work; only call update_goal(status=\"complete\") after the checkpoint exists.";
154
+ }
155
+
156
+ function claudeNativePrompt(id) {
157
+ return "/goal quest " + id +
158
+ " has a new checkpoint whose quest_status is complete or blocked in `quest show " + id +
159
+ " --json`\nWork quest " + id + " per $quest:work.";
160
+ }
161
+
162
+ function renderWorkHandoff(command, id, recommendation, cwd, env) {
163
+ const lines = [
164
+ `${command} work: recommended path: ${recommendation.label}`,
165
+ `Reason: ${recommendation.reason}`,
166
+ "",
167
+ "Quest command prelude:",
168
+ ...questCommandPrelude(cwd, env).map((line) => ` ${line}`),
169
+ "",
170
+ ];
171
+ if (recommendation.key === "native-subagents") {
172
+ lines.push(
173
+ command === "codex" ? "Codex native subagent handoff:" : "Claude native subagent handoff:",
174
+ " agent_type: quest-executor",
175
+ " prompt:",
176
+ ...String(command === "codex" ? codexNativePrompt(id) : claudeNativePrompt(id)).split("\n").map((line) => ` ${line}`),
177
+ );
178
+ } else {
179
+ lines.push(
180
+ "Headless fallback command:",
181
+ ` ${recommendation.command.replace("<id>", String(id))}`,
182
+ );
183
+ }
184
+ return lines.join("\n");
185
+ }
186
+
135
187
  async function runNativeSetupCommand(command, args, { out, cwd, env }, { label, doctor, doctorFix, install }) {
136
188
  const [subcommand, ...rest] = args;
137
189
  if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
@@ -174,6 +226,28 @@ async function runNativeSetupCommand(command, args, { out, cwd, env }, { label,
174
226
  const res = spawnSync(invocation.cmd, invocation.args, { cwd: invocation.cwd, env, stdio: "inherit" });
175
227
  return typeof res.status === "number" ? res.status : 1;
176
228
  }
229
+ if (subcommand === "work") {
230
+ const p = parse(command, rest, { "dry-run": { type: "boolean" } }, { positionals: 1 });
231
+ if (p.help) {
232
+ out(renderCommandHelp(command));
233
+ return 0;
234
+ }
235
+ const rawId = p.positionals[0];
236
+ if (!rawId || !/^\d+$/.test(rawId)) throw new UsageError("a numeric quest id is required", { command });
237
+ if (!p.values["dry-run"]) {
238
+ throw new UsageError(`${command} work only supports --dry-run; launching provider sessions is out of scope`, { command });
239
+ }
240
+ const result = doctor({ cwd, env });
241
+ if (!result.recommended_path?.available) {
242
+ printDoctorResult(command, result, out);
243
+ out(`${command} work: not printing a handoff because doctor still reports setup problems`);
244
+ return 1;
245
+ }
246
+ if (!result.ok) printDoctorResult(command, result, out);
247
+ else out(`${command} work: health OK`);
248
+ out(renderWorkHandoff(command, rawId, result.recommended_path, cwd, env));
249
+ return 0;
250
+ }
177
251
  if (subcommand === "install-agents") {
178
252
  const p = parse(command, rest, {
179
253
  scope: { type: "string", default: "project" },
@@ -243,7 +317,8 @@ async function dispatch(argv, ctx) {
243
317
  const runs = local.readRuns(storeDir);
244
318
  const ended = new Set(runs.filter((r) => r.event === "run_ended").map((r) => r.run_id));
245
319
  const active = runs.filter((r) => r.event === "run_started" && !ended.has(r.run_id)).length;
246
- out(renderStatusOverview({ counts, ready: store.readyQuests(), activeRuns: active, backend: config.backend }));
320
+ const inFlightOrBlocked = all.filter((q) => q.status === "in_progress" || q.status === "blocked");
321
+ out(renderStatusOverview({ counts, ready: store.readyQuests(), activeRuns: active, backend: config.backend, inFlightOrBlocked }));
247
322
  return 0;
248
323
  }
249
324
 
@@ -368,8 +443,8 @@ const HANDLERS = {
368
443
  if (p.values.json) out(JSON.stringify({ ok: true, id: created.id, path: created.path }));
369
444
  else {
370
445
  out(`Created quest ${created.id}: ${v.title}`);
371
- out(` record: ${created.path}`);
372
- out(` next: quest lint ${created.id} && quest show ${created.id}`);
446
+ out(`Record: ${created.path}`);
447
+ out(`Next: \`quest lint ${created.id}\``);
373
448
  }
374
449
  return 0;
375
450
  },
@@ -379,10 +454,41 @@ const HANDLERS = {
379
454
  status: { type: "string" },
380
455
  parent: { type: "string" },
381
456
  ready: { type: "boolean" },
457
+ queue: { type: "boolean" },
382
458
  });
383
459
  if (p.help) return void out(renderCommandHelp("list")) ?? 0;
460
+ if (p.values.ready && p.values.queue) throw new UsageError("--ready and --queue are mutually exclusive", { command: "list" });
461
+ if (p.values.queue && (p.values.status || p.values.parent)) throw new UsageError("--queue cannot be combined with --status or --parent", { command: "list" });
384
462
  const config = getStore(cwd, env);
385
463
  const store = openStore(config, { env });
464
+ if (p.values.queue) {
465
+ const q = store.queueState();
466
+ const payload = {
467
+ worker_ready: q.workerReady,
468
+ inline_close_ready_epics: q.inlineCloseReadyEpics,
469
+ blocked: q.blocked.map((b) => ({ quest: b.quest, reasons: b.reasons })),
470
+ };
471
+ if (p.values.json) {
472
+ out(JSON.stringify(payload));
473
+ } else {
474
+ const lines = [];
475
+ const pushQuest = (quest) => lines.push(`${String(quest.id).padStart(3)} [${quest.priority}] ${quest.status.padEnd(11)} ${quest.title}`);
476
+ lines.push("Worker-ready quests:");
477
+ if (payload.worker_ready.length) payload.worker_ready.forEach(pushQuest);
478
+ else lines.push(" (none)");
479
+ lines.push("", "Inline-close-ready epics:");
480
+ if (payload.inline_close_ready_epics.length) payload.inline_close_ready_epics.forEach(pushQuest);
481
+ else lines.push(" (none)");
482
+ lines.push("", "Blocked from queue:");
483
+ if (payload.blocked.length) {
484
+ for (const b of payload.blocked) lines.push(`${String(b.quest.id).padStart(3)} ${b.quest.title} — ${b.reasons.join("; ")}`);
485
+ } else {
486
+ lines.push(" (none)");
487
+ }
488
+ for (const line of lines) out(line);
489
+ }
490
+ return 0;
491
+ }
386
492
  let quests = p.values.ready ? store.readyQuests() : store.listQuests();
387
493
  if (p.values.status) quests = quests.filter((q) => q.status === p.values.status);
388
494
  if (p.values.parent) quests = quests.filter((q) => q.parent === Number(p.values.parent));
@@ -226,6 +226,88 @@ export function nativeProjectRoot(cwd = process.cwd(), env = process.env) {
226
226
  return projectRoot(cwd, env);
227
227
  }
228
228
 
229
+ function questBin() {
230
+ return join(PLUGIN_ROOT, "bin", "quest");
231
+ }
232
+
233
+ function questRunBin() {
234
+ return join(PLUGIN_ROOT, "bin", "quest-run");
235
+ }
236
+
237
+ function shellQuote(arg) {
238
+ return /^[A-Za-z0-9_./:=@+-]+$/.test(arg) ? arg : `'${String(arg).replaceAll("'", "'\\''")}'`;
239
+ }
240
+
241
+ function providerRecommendation(provider, checks) {
242
+ const bad = (name) => checkByName({ checks }, name)?.ok === false;
243
+ const commandId = "<id>";
244
+ if (provider === "codex") {
245
+ const setupCheckNames = [
246
+ "version-sync",
247
+ "quest-cli-path",
248
+ "codex-cli",
249
+ "plugin-installed",
250
+ "plugin-version",
251
+ "hook-parser",
252
+ "single-neutral-skill-root",
253
+ "native-agents",
254
+ ];
255
+ const setupReady = setupCheckNames.every((name) => checkByName({ checks }, name)?.ok === true);
256
+ if (!setupReady) {
257
+ return {
258
+ key: "repair-first",
259
+ label: "repair Codex setup first",
260
+ available: false,
261
+ command: `${shellQuote(questBin())} codex doctor --fix`,
262
+ reason: "Codex setup checks must be green before Quest can print a trustworthy work handoff",
263
+ };
264
+ }
265
+ if (!bad("multi-agent-feature") && !bad("goals-feature")) {
266
+ return {
267
+ key: "native-subagents",
268
+ label: "native Codex quest-executor subagent",
269
+ available: true,
270
+ command: `${shellQuote(questBin())} codex work ${commandId} --dry-run`,
271
+ reason: "multi_agent, goals, Quest plugin, skill root, and native-agent template checks are green",
272
+ };
273
+ }
274
+ if (!bad("goals-feature")) {
275
+ return {
276
+ key: "quest-run-goal-required",
277
+ label: "headless Codex runner fallback with required goal tools",
278
+ available: true,
279
+ command: `${shellQuote(questRunBin())} ${commandId} --worker codex --codex-goal-mode require`,
280
+ reason: "Codex goal tools are available, but native multi-agent dispatch is not",
281
+ };
282
+ }
283
+ return {
284
+ key: "quest-run-goal-auto",
285
+ label: "headless Codex runner fallback with auto goal mode",
286
+ available: true,
287
+ command: `${shellQuote(questRunBin())} ${commandId} --worker codex --codex-goal-mode auto`,
288
+ reason: "Codex native goal mode is unavailable, so the runner must not require goal tools",
289
+ };
290
+ }
291
+
292
+ const setupReady = checks.every((c) => c.ok);
293
+ if (!setupReady) {
294
+ return {
295
+ key: "repair-first",
296
+ label: "repair Claude setup first",
297
+ available: false,
298
+ command: `${shellQuote(questBin())} claude doctor --fix`,
299
+ reason: "Claude setup checks must be green before Quest can print a trustworthy work handoff",
300
+ };
301
+ }
302
+ return {
303
+ key: "native-subagents",
304
+ label: "native Claude quest-executor subagent",
305
+ available: true,
306
+ command: `${shellQuote(questBin())} claude work ${commandId} --dry-run`,
307
+ reason: "Claude CLI, Quest plugin, and native-agent template checks are green",
308
+ };
309
+ }
310
+
229
311
  function questCliPathCheck(cwd, env, versions) {
230
312
  const pathQuestVersion = runCmd("quest", ["--version"], { cwd, env });
231
313
  const pathVersion = pathQuestVersion.status === 0 ? pathQuestVersion.stdout.trim() : null;
@@ -378,7 +460,7 @@ export function doctor({ cwd = process.cwd(), env = process.env } = {}) {
378
460
 
379
461
  checks.push(nativeAgentsCheck("codex", cwd, env));
380
462
 
381
- return { ok: checks.every((c) => c.ok), checks };
463
+ return { ok: checks.every((c) => c.ok), checks, recommended_path: providerRecommendation("codex", checks) };
382
464
  }
383
465
 
384
466
  export function claudeDoctor({ cwd = process.cwd(), env = process.env } = {}) {
@@ -434,7 +516,7 @@ export function claudeDoctor({ cwd = process.cwd(), env = process.env } = {}) {
434
516
 
435
517
  checks.push(nativeAgentsCheck("claude", cwd, env));
436
518
 
437
- return { ok: checks.every((c) => c.ok), checks };
519
+ return { ok: checks.every((c) => c.ok), checks, recommended_path: providerRecommendation("claude", checks) };
438
520
  }
439
521
 
440
522
  function checkByName(result, name) {
@@ -536,7 +618,7 @@ function doctorWithFix(provider, { cwd = process.cwd(), env = process.env } = {}
536
618
  ...repairProviderPlugin(provider, before, { cwd, env }),
537
619
  ];
538
620
  const after = doctorForProvider(provider, { cwd, env });
539
- return { ok: after.ok, checks: after.checks, repairs, before };
621
+ return { ok: after.ok, checks: after.checks, recommended_path: after.recommended_path, repairs, before };
540
622
  }
541
623
 
542
624
  export function codexDoctorFix(options = {}) {
package/lib/graph.mjs ADDED
@@ -0,0 +1,82 @@
1
+ const TERMINAL = new Set(["complete", "cancelled"]);
2
+
3
+ function byId(quests) {
4
+ return new Map(quests.map((q) => [q.id, q]));
5
+ }
6
+
7
+ function prioritySort(a, b) {
8
+ return a.priority.localeCompare(b.priority) || a.id - b.id;
9
+ }
10
+
11
+ function dependencyIds(q) {
12
+ return Array.isArray(q.depends_on) && q.depends_on.every(Number.isInteger) ? q.depends_on : [];
13
+ }
14
+
15
+ function hasMalformedDependsOn(q) {
16
+ return "depends_on" in q && (!Array.isArray(q.depends_on) || q.depends_on.some((d) => !Number.isInteger(d)));
17
+ }
18
+
19
+ function openChildIds(quests, parentId) {
20
+ return quests
21
+ .filter((q) => q.parent === parentId && !TERMINAL.has(q.status))
22
+ .map((q) => q.id)
23
+ .sort((a, b) => a - b);
24
+ }
25
+
26
+ function queueBlockers(q, known, quests, epicIds) {
27
+ const reasons = [];
28
+ if (q.parent !== undefined && !known.has(q.parent)) reasons.push(`missing parent: #${q.parent}`);
29
+ if (hasMalformedDependsOn(q)) reasons.push("malformed depends_on: must be a list of quest ids");
30
+
31
+ const deps = dependencyIds(q);
32
+ const missingDeps = deps.filter((id) => !known.has(id));
33
+ if (missingDeps.length) reasons.push(`missing depends_on: ${missingDeps.map((id) => `#${id}`).join(", ")}`);
34
+
35
+ const incompleteDeps = deps.filter((id) => known.has(id) && known.get(id).status !== "complete");
36
+ if (incompleteDeps.length) reasons.push(`incomplete depends_on: ${incompleteDeps.map((id) => `#${id}`).join(", ")}`);
37
+
38
+ if (epicIds.has(q.id)) {
39
+ const open = openChildIds(quests, q.id);
40
+ if (open.length) reasons.push(`open children: ${open.map((id) => `#${id}`).join(", ")}`);
41
+ }
42
+ return reasons;
43
+ }
44
+
45
+ export function computeQueue(quests) {
46
+ const all = [...quests];
47
+ const known = byId(all);
48
+ const epicIds = new Set(all.filter((q) => q.parent !== undefined).map((q) => q.parent));
49
+ const workerReady = [];
50
+ const inlineCloseReadyEpics = [];
51
+ const blocked = [];
52
+
53
+ for (const q of all) {
54
+ if (q.status !== "todo") continue;
55
+ const reasons = queueBlockers(q, known, all, epicIds);
56
+ if (reasons.length) {
57
+ blocked.push({ quest: q, reasons });
58
+ continue;
59
+ }
60
+ if (epicIds.has(q.id)) inlineCloseReadyEpics.push(q);
61
+ else workerReady.push(q);
62
+ }
63
+
64
+ workerReady.sort(prioritySort);
65
+ inlineCloseReadyEpics.sort(prioritySort);
66
+ blocked.sort((a, b) => prioritySort(a.quest, b.quest));
67
+ return { workerReady, inlineCloseReadyEpics, blocked };
68
+ }
69
+
70
+ export function lintGraphReferences(quests) {
71
+ const known = byId(quests);
72
+ const problems = new Map(quests.map((q) => [q.id, []]));
73
+ for (const q of quests) {
74
+ if (q.parent !== undefined && !known.has(q.parent)) {
75
+ problems.get(q.id).push(`parent references unknown quest ${q.parent}`);
76
+ }
77
+ for (const dep of dependencyIds(q)) {
78
+ if (!known.has(dep)) problems.get(q.id).push(`depends_on references unknown quest ${dep}`);
79
+ }
80
+ }
81
+ return problems;
82
+ }
package/lib/help.mjs CHANGED
@@ -48,11 +48,12 @@ export const COMMANDS = {
48
48
  },
49
49
  list: {
50
50
  purpose: "List quests (filter by status, parent, or readiness)",
51
- usage: "quest list [--status <s>] [--parent <id>] [--ready] [--json]",
51
+ usage: "quest list [--status <s>] [--parent <id>] [--ready|--queue] [--json]",
52
52
  flags: [
53
53
  ["--status <s>", "todo | in_progress | blocked | complete | cancelled"],
54
54
  ["--parent <id>", "children of an epic"],
55
- ["--ready", "todo quests whose depends_on are all complete, excluding epics with open children (dispatch order)"],
55
+ ["--ready", "worker-dispatchable todo quests whose depends_on are complete, excluding epics"],
56
+ ["--queue", "worker-ready quests, inline-close-ready epics, and blocked queue reasons"],
56
57
  ["--json", "machine-readable output"],
57
58
  ],
58
59
  example: "quest list --ready",
@@ -138,37 +139,46 @@ export const COMMANDS = {
138
139
  example: "quest runs --active",
139
140
  },
140
141
  codex: {
141
- purpose: "Inspect, repair, install, or open Quest's native Codex integration",
142
- usage: "quest codex doctor [--fix] [--json] | quest codex open [--dry-run] [-- <codex args>] | quest codex install-agents [--scope project|user] [--dry-run] [--force] [--json]",
142
+ purpose: "Inspect, repair, install, open, or hand off Quest's native Codex integration",
143
+ usage: "quest codex doctor [--fix] [--json] | quest codex work <id> --dry-run | quest codex open [--dry-run] [-- <codex args>] | quest codex install-agents [--scope project|user] [--dry-run] [--force] [--json]",
143
144
  flags: [
144
145
  ["doctor", "check Codex CLI, multi-agent support, plugin install/version, hooks, skill roots, and native-agent templates"],
145
146
  ["--fix", "repair missing/stale Quest agents and Quest plugin install/version when possible, then rerun doctor"],
147
+ ["work <id>", "run the health gate and print the native subagent or runner-fallback handoff"],
146
148
  ["open", "repair/check setup, then launch interactive Codex from the project root"],
147
149
  ["install-agents", "install quest-executor and quest-reviewer as native Codex custom agents"],
148
150
  ["--scope <s>", "project (default: .codex/agents at repo root) or user (~/.codex/agents)"],
149
- ["--dry-run", "show intended agent writes without changing files"],
151
+ ["--dry-run", "install-agents: preview writes; work/open: print the handoff or launch command without starting provider work"],
150
152
  ["--force", "replace existing non-Quest agent files"],
151
153
  ["--json", "machine-readable output"],
152
154
  ],
153
- example: "quest codex doctor --fix && quest codex open",
155
+ example: "quest codex doctor --fix && quest codex work 12 --dry-run",
154
156
  },
155
157
  claude: {
156
- purpose: "Inspect, repair, install, or open Quest's native Claude Code integration",
157
- usage: "quest claude doctor [--fix] [--json] | quest claude open [--dry-run] [-- <claude args>] | quest claude install-agents [--scope project|user] [--dry-run] [--force] [--json]",
158
+ purpose: "Inspect, repair, install, open, or hand off Quest's native Claude Code integration",
159
+ usage: "quest claude doctor [--fix] [--json] | quest claude work <id> --dry-run | quest claude open [--dry-run] [-- <claude args>] | quest claude install-agents [--scope project|user] [--dry-run] [--force] [--json]",
158
160
  flags: [
159
161
  ["doctor", "check Claude CLI, plugin install/version, and native-agent templates"],
160
162
  ["--fix", "repair missing/stale Quest agents and Quest plugin install/version when possible, then rerun doctor"],
163
+ ["work <id>", "run the health gate and print the native subagent handoff"],
161
164
  ["open", "repair/check setup, then launch interactive Claude Code from the project root"],
162
165
  ["install-agents", "install quest-executor and quest-reviewer as native Claude Code custom agents"],
163
166
  ["--scope <s>", "project (default: .claude/agents at repo root) or user (~/.claude/agents)"],
164
- ["--dry-run", "show intended agent writes without changing files"],
167
+ ["--dry-run", "install-agents: preview writes; work/open: print the handoff or launch command without starting provider work"],
165
168
  ["--force", "replace existing non-Quest agent files"],
166
169
  ["--json", "machine-readable output"],
167
170
  ],
168
- example: "quest claude doctor --fix && quest claude open",
171
+ example: "quest claude doctor --fix && quest claude work 12 --dry-run",
169
172
  },
170
173
  };
171
174
 
175
+ const COMMAND_GROUPS = [
176
+ ["Setup and authoring", ["init", "create", "edit", "lint", "protocol"]],
177
+ ["Queue and work", ["list", "show", "start", "checkpoint", "runs"]],
178
+ ["Lifecycle", ["cancel", "reopen", "amend"]],
179
+ ["Provider handoff", ["codex", "claude"]],
180
+ ];
181
+
172
182
  export function renderCommandHelp(name) {
173
183
  const c = COMMANDS[name];
174
184
  const lines = [c.purpose, "", `Usage: ${c.usage}`];
@@ -182,55 +192,84 @@ export function renderCommandHelp(name) {
182
192
  }
183
193
 
184
194
  export function renderGeneralHelp() {
185
- const lines = [INTRO, "", "Commands:"];
195
+ const lines = [INTRO, "", "Commands by workflow:"];
186
196
  const w = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
187
- for (const [name, c] of Object.entries(COMMANDS)) lines.push(` quest ${name.padEnd(w)} ${c.purpose}`);
197
+ const seen = new Set();
198
+ for (const [group, names] of COMMAND_GROUPS) {
199
+ lines.push("", `${group}:`);
200
+ for (const name of names) {
201
+ const c = COMMANDS[name];
202
+ seen.add(name);
203
+ lines.push(` quest ${name.padEnd(w)} ${c.purpose}`);
204
+ }
205
+ }
206
+ const ungrouped = Object.keys(COMMANDS).filter((name) => !seen.has(name));
207
+ if (ungrouped.length) {
208
+ lines.push("", "Other:");
209
+ for (const name of ungrouped) lines.push(` quest ${name.padEnd(w)} ${COMMANDS[name].purpose}`);
210
+ }
188
211
  lines.push("", "Run `quest <command> --help` for flags and a copy-pasteable example.", "Headless workers: see `quest-run --help`.");
189
212
  return lines.join("\n");
190
213
  }
191
214
 
192
- export function renderStatusOverview({ counts, ready, activeRuns, backend }) {
215
+ function renderQuestSummary(q, { includeStatus = false } = {}) {
216
+ const status = includeStatus ? `[${q.status}] ` : "";
217
+ return ` ${q.id}. ${status}[${q.priority}] ${q.title}`;
218
+ }
219
+
220
+ export function renderStatusOverview({ counts, ready, activeRuns, backend, inFlightOrBlocked = [] }) {
193
221
  const lines = [`Quest store: ${backend} backend`];
194
222
  const parts = ["todo", "in_progress", "blocked", "complete", "cancelled"]
195
223
  .filter((s) => counts[s])
196
224
  .map((s) => `${counts[s]} ${s}`);
197
225
  lines.push(parts.length ? `Quests: ${parts.join(" · ")}` : "Quests: none yet");
226
+ lines.push(`Active runs: ${activeRuns}`);
227
+
228
+ lines.push("", "In flight / blocked:");
229
+ if (inFlightOrBlocked.length) {
230
+ for (const q of inFlightOrBlocked.slice(0, 3)) lines.push(renderQuestSummary(q, { includeStatus: true }));
231
+ } else {
232
+ lines.push(" none");
233
+ }
234
+
235
+ lines.push("", "Ready work:");
198
236
  if (ready.length) {
199
- lines.push("", "Ready to work (dependencies met):");
200
- for (const q of ready.slice(0, 3)) lines.push(` ${q.id}. [${q.priority}] ${q.title}`);
201
- lines.push("", "Next: `quest show <id>` to read one, or dispatch with `quest-run <id>`.");
237
+ for (const q of ready.slice(0, 3)) lines.push(renderQuestSummary(q));
238
+ } else {
239
+ lines.push(" none");
240
+ }
241
+
242
+ let next;
243
+ if (activeRuns > 0) {
244
+ next = "quest runs --active";
245
+ } else if (inFlightOrBlocked.length) {
246
+ next = `quest show ${inFlightOrBlocked[0].id} --json`;
247
+ } else if (ready.length) {
248
+ next = `quest show ${ready[0].id} --json`;
202
249
  } else if (!parts.length) {
203
- lines.push("", "Next: create your first quest — see `quest create --help` (or use the $quest:plan skill).");
250
+ next = "quest create --help";
204
251
  } else {
205
- lines.push("", "Nothing ready to start. `quest list` to see everything.");
252
+ next = "quest list --queue";
206
253
  }
207
- if (activeRuns > 0) lines.push("", `Active headless runs: ${activeRuns} — inspect with \`quest runs --active\`.`);
254
+ lines.push("", `Next: \`${next}\``);
208
255
  return lines.join("\n");
209
256
  }
210
257
 
211
258
  export function renderNoStore() {
212
259
  return [
213
- "No quest store found (searched for .quests/ from here upward).",
214
- "",
215
- "Get started:",
216
- " quest init create a local store here",
217
- " quest init --backend github --repo owner/name",
260
+ "No quest store found (.quests/ not found from here upward).",
218
261
  "",
219
- "Then: `quest create --help` shows how to author your first quest.",
262
+ "Next: `quest init`",
263
+ "GitHub-backed store: `quest init --backend github --repo owner/name`",
220
264
  ].join("\n");
221
265
  }
222
266
 
223
267
  export function renderInitNextSteps(backend, { agentsInstalled = true } = {}) {
224
268
  return [
225
269
  "",
226
- "Store created. Next steps:",
227
- ...(agentsInstalled ? [" 0. Restart agent sessions so newly installed project templates are loaded"] : []),
228
- ...(agentsInstalled ? [" 1. Repair/check providers: quest codex doctor --fix (or quest claude doctor --fix)"] : []),
229
- ...(agentsInstalled ? [" 2. Open provider: quest codex open (or quest claude open)"] : []),
230
- ` ${agentsInstalled ? "3" : "1"}. Author a quest: quest create --help (or $quest:plan in your agent session)`,
231
- ` ${agentsInstalled ? "4" : "2"}. Check it: quest lint --all`,
232
- ` ${agentsInstalled ? "5" : "3"}. Work it: $quest:work <id> in-session, or quest-run <id> headless`,
233
- ...(agentsInstalled ? [] : [" Agent templates skipped; install later with `quest codex install-agents --scope project` and `quest claude install-agents --scope project`."]),
234
- ...(backend === "github" ? ["", "Records live as GitHub issues; config and amendments stay local in .quests/."] : []),
270
+ `Store ready: ${backend} backend`,
271
+ ...(backend === "github" ? ["Records live as GitHub issues; config and amendments stay local in .quests/."] : []),
272
+ ...(agentsInstalled ? ["Agent templates installed. Restart agent sessions before using new project agents."] : ["Agent templates skipped."]),
273
+ `Next: \`${agentsInstalled ? "quest codex doctor --fix" : "quest create --help"}\``,
235
274
  ].join("\n");
236
275
  }
package/lib/runner.mjs CHANGED
@@ -184,6 +184,16 @@ async function recordBlocked(storeDir, env, id, summary, validation) {
184
184
  // Option resolution: flag → record frontmatter → config defaults
185
185
  // ---------------------------------------------------------------------------
186
186
 
187
+ function parseNumericFlag(flags, name, { integer = false, min = -Infinity, label = "number" } = {}) {
188
+ const raw = flags[name];
189
+ if (raw == null) return undefined;
190
+ const value = Number(raw);
191
+ if (typeof raw !== "string" || raw.trim() === "" || !Number.isFinite(value) || (integer && !Number.isInteger(value)) || value < min) {
192
+ throw new RunUsageError(`--${name} must be ${label} (got "${raw}")`);
193
+ }
194
+ return value;
195
+ }
196
+
187
197
  function resolveOptions(front, config, flags) {
188
198
  const worker = flags.worker ?? front.worker ?? config.defaults.worker;
189
199
  if (!["claude", "codex"].includes(worker)) throw new RunUsageError(`--worker must be claude or codex (got "${worker}")`);
@@ -191,12 +201,12 @@ function resolveOptions(front, config, flags) {
191
201
  const recordModel = front.model && front.model !== "inherit" ? front.model : undefined;
192
202
  const model = flags.model ?? recordModel ?? wd.model;
193
203
  const effort = flags.effort ?? front.effort ?? (worker === "codex" ? wd.reasoning_effort : wd.effort);
194
- const maxIterations = flags["max-iterations"] != null ? Number(flags["max-iterations"]) : front.max_iterations ?? config.defaults.max_iterations;
195
- const maxCost = flags["max-cost"] != null ? Number(flags["max-cost"]) : front.max_cost ?? undefined;
196
- const maxTokens = flags["max-tokens"] != null ? Number(flags["max-tokens"]) : undefined;
204
+ const maxIterations = parseNumericFlag(flags, "max-iterations", { integer: true, min: 1, label: "a positive integer" }) ?? front.max_iterations ?? config.defaults.max_iterations;
205
+ const maxCost = parseNumericFlag(flags, "max-cost", { min: 0, label: "a non-negative number" }) ?? front.max_cost ?? undefined;
206
+ const maxTokens = parseNumericFlag(flags, "max-tokens", { integer: true, min: 1, label: "a positive integer" });
197
207
  // Per-session wall-clock cap (seconds → ms). Flag > config default > 1800s.
198
- // A non-positive value disables the timeout.
199
- const sessionTimeoutSec = flags["session-timeout"] != null ? Number(flags["session-timeout"]) : config.defaults.session_timeout ?? 1800;
208
+ // A zero value disables the timeout.
209
+ const sessionTimeoutSec = parseNumericFlag(flags, "session-timeout", { min: 0, label: "a non-negative number" }) ?? config.defaults.session_timeout ?? 1800;
200
210
  const sessionTimeout = Number.isFinite(sessionTimeoutSec) && sessionTimeoutSec > 0 ? Math.round(sessionTimeoutSec * 1000) : 0;
201
211
  // Codex exec sandbox: flag → config defaults.codex.sandbox → workspace-write.
202
212
  // No silent escalation: the safe default stays workspace-write, and the value
@@ -563,7 +573,7 @@ function makeWorktree(repoRoot, id, title, io) {
563
573
  }
564
574
 
565
575
  async function runReady(storeDir, config, flags, io) {
566
- const parallel = Math.max(1, flags.parallel != null ? Number(flags.parallel) : 1);
576
+ const parallel = parseNumericFlag(flags, "parallel", { integer: true, min: 1, label: "a positive integer" }) ?? 1;
567
577
  const isolate = flags.isolate;
568
578
  if (isolate && isolate !== "worktree") throw new RunUsageError(`--isolate only supports "worktree" (got "${isolate}")`);
569
579
  const repoRoot = dirname(storeDir);
@@ -573,29 +583,17 @@ async function runReady(storeDir, config, flags, io) {
573
583
  const skippedEpics = new Set();
574
584
  const inFlight = new Map();
575
585
 
576
- const readyIds = async () => {
577
- const { stdout, code } = await questCli(storeDir, io.env, ["list", "--ready", "--json"]);
578
- if (code !== 0) return [];
579
- try {
580
- return JSON.parse(stdout).map((q) => q.id);
581
- } catch {
582
- return [];
583
- }
584
- };
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();
586
+ const queue = async () => {
587
+ const { stdout, code } = await questCli(storeDir, io.env, ["list", "--queue", "--json"]);
588
+ if (code !== 0) return { workerReady: [], inlineCloseReadyEpics: [] };
595
589
  try {
596
- return new Set(JSON.parse(stdout).filter((q) => q.parent !== undefined).map((q) => q.parent));
590
+ const parsed = JSON.parse(stdout);
591
+ return {
592
+ workerReady: (parsed.worker_ready ?? []).map((q) => q.id),
593
+ inlineCloseReadyEpics: (parsed.inline_close_ready_epics ?? []).map((q) => q.id),
594
+ };
597
595
  } catch {
598
- return new Set();
596
+ return { workerReady: [], inlineCloseReadyEpics: [] };
599
597
  }
600
598
  };
601
599
 
@@ -619,14 +617,13 @@ async function runReady(storeDir, config, flags, io) {
619
617
  };
620
618
 
621
619
  for (;;) {
622
- const epics = await epicIds();
623
- const ready = (await readyIds()).filter((id) => !done.has(id) && !inFlight.has(id) && !skippedEpics.has(id));
620
+ const q = await queue();
621
+ for (const id of q.inlineCloseReadyEpics.filter((id) => !skippedEpics.has(id))) {
622
+ skippedEpics.add(id);
623
+ io.errOut(`quest-run: quest ${id} is an inline-close-ready epic — 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.`);
624
+ }
625
+ const ready = q.workerReady.filter((id) => !done.has(id) && !inFlight.has(id));
624
626
  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
- }
630
627
  if (inFlight.size >= parallel) break;
631
628
  startOne(id);
632
629
  }
@@ -31,6 +31,7 @@ import {
31
31
  serializeRecord,
32
32
  } from "./contract.mjs";
33
33
  import { parseFrontmatter, serializeFrontmatter } from "./frontmatter.mjs";
34
+ import { computeQueue, lintGraphReferences } from "./graph.mjs";
34
35
  import { NotFoundError } from "./store-local.mjs";
35
36
 
36
37
  export class GhError extends Error {
@@ -268,20 +269,15 @@ export function listQuests(repo, env) {
268
269
  });
269
270
  }
270
271
 
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.
272
+ // Same shared queue rule as store-local.readyQuests, over the remote list.
273
+ // readyQuests returns only worker-dispatchable quests; inline-close-ready epics
274
+ // are exposed separately by queueState.
276
275
  export function readyQuests(repo, env) {
277
- const all = listQuests(repo, env);
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
- );
282
- return all
283
- .filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
284
- .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
276
+ return queueState(repo, env).workerReady;
277
+ }
278
+
279
+ export function queueState(repo, env) {
280
+ return computeQueue(listQuests(repo, env));
285
281
  }
286
282
 
287
283
  export function createQuest(repo, defaults, fields, sections, env) {
@@ -433,14 +429,20 @@ export function editQuest(repo, id, { addDoneWhen = [], addMilestone = [], addCo
433
429
 
434
430
  export function lintAll(repo, env) {
435
431
  const results = [];
432
+ const valid = [];
436
433
  for (const issue of listIssues(repo, env)) {
437
434
  try {
438
435
  const rec = reconstruct(issue);
439
436
  results.push({ file: rec.file, id: rec.front.id, problems: lintRecord(rec, { filename: rec.file }) });
437
+ valid.push({ ...rec.front, file: rec.file });
440
438
  } catch (err) {
441
439
  results.push({ file: `#${issue.number}`, id: issue.number ?? null, problems: [err.message] });
442
440
  }
443
441
  }
442
+ const graphProblems = lintGraphReferences(valid);
443
+ for (const result of results) {
444
+ if (result.id !== null) result.problems.push(...(graphProblems.get(result.id) ?? []));
445
+ }
444
446
  return results;
445
447
  }
446
448
 
@@ -5,6 +5,7 @@
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, statSync, appendFileSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import { ContractError, appendToSection, appendUnderCheckpoints, assertReopen, assertTransition, lintRecord, makeCheckpoint, nowIso, parseRecord, recordFilename, renderBody, serializeRecord, CHECKPOINT_MARKER } from "./contract.mjs";
8
+ import { computeQueue, lintGraphReferences } from "./graph.mjs";
8
9
 
9
10
  export class NotFoundError extends Error {
10
11
  constructor(id) {
@@ -83,18 +84,11 @@ export function listQuests(storeDir) {
83
84
  }
84
85
 
85
86
  export function readyQuests(storeDir) {
86
- const all = listQuests(storeDir);
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
- );
95
- return all
96
- .filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
97
- .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
87
+ return queueState(storeDir).workerReady;
88
+ }
89
+
90
+ export function queueState(storeDir) {
91
+ return computeQueue(listQuests(storeDir));
98
92
  }
99
93
 
100
94
  export function createQuest(storeDir, defaults, fields, bodySections) {
@@ -238,16 +232,22 @@ export function readRuns(storeDir) {
238
232
 
239
233
  export function lintAll(storeDir) {
240
234
  const results = [];
235
+ const valid = [];
241
236
  for (const file of recordFiles(storeDir)) {
242
237
  const text = readFileSync(join(questsDir(storeDir), file), "utf8");
243
238
  try {
244
239
  const record = parseRecord(text);
245
240
  const problems = lintRecord(record, { filename: file });
246
241
  results.push({ file, id: record.front.id, problems });
242
+ valid.push({ ...record.front, file });
247
243
  } catch (err) {
248
244
  results.push({ file, id: null, problems: [err.message] });
249
245
  }
250
246
  }
247
+ const graphProblems = lintGraphReferences(valid);
248
+ for (const result of results) {
249
+ if (result.id !== null) result.problems.push(...(graphProblems.get(result.id) ?? []));
250
+ }
251
251
  return results;
252
252
  }
253
253
 
package/lib/store.mjs CHANGED
@@ -15,6 +15,7 @@ export function openStore(config, ctx = {}) {
15
15
  loadQuest: (id) => github.loadQuest(repo, id, env),
16
16
  listQuests: () => github.listQuests(repo, env),
17
17
  readyQuests: () => github.readyQuests(repo, env),
18
+ queueState: () => github.queueState(repo, env),
18
19
  startQuest: (id) => github.startQuest(repo, id, env),
19
20
  appendCheckpoint: (id, cp) => github.appendCheckpoint(repo, id, cp, env),
20
21
  cancelQuest: (id, reason) => github.cancelQuest(repo, id, reason, env),
@@ -29,6 +30,7 @@ export function openStore(config, ctx = {}) {
29
30
  loadQuest: (id) => local.loadQuest(dir, id),
30
31
  listQuests: () => local.listQuests(dir),
31
32
  readyQuests: () => local.readyQuests(dir),
33
+ queueState: () => local.queueState(dir),
32
34
  startQuest: (id) => local.startQuest(dir, id),
33
35
  appendCheckpoint: (id, cp) => local.appendCheckpoint(dir, id, cp),
34
36
  cancelQuest: (id, reason) => local.cancelQuest(dir, id, reason),
package/lib/workers.mjs CHANGED
@@ -353,7 +353,7 @@ export const codex = {
353
353
  if (!sawGoal && opts.codexGoalMode === "require") {
354
354
  correctiveResume = true;
355
355
  resumes += 1;
356
- const corr = this.buildResume(questRecord, config, opts, "--last", CODEX_CREATE_GOAL_CORRECTION(opts.id));
356
+ const corr = this.buildResume(questRecord, config, opts, sessionId || "--last", CODEX_CREATE_GOAL_CORRECTION(opts.id));
357
357
  const cres = await runSegment(corr);
358
358
  if (codexUsedCreateGoal(cres.events || [])) sawGoal = true;
359
359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quest-loop",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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" },
@@ -18,9 +18,16 @@ trails, and escalations only where a human ruling is genuinely needed.
18
18
 
19
19
  1. **Adopt state** (especially at session start):
20
20
  ```bash
21
- quest list --ready --json # the dispatch queue (deps met, priority order)
21
+ quest list --queue --json # orchestration state: worker_ready + inline_close_ready_epics
22
22
  quest runs --active # headless runners that outlived prior sessions
23
23
  ```
24
+ `worker_ready` is the worker dispatch queue (deps met, priority order);
25
+ `inline_close_ready_epics` is the set of epics you close yourself after
26
+ verifying children and the epic validation loop. `quest list --ready --json`
27
+ remains a dispatch-only shortcut for `worker_ready`.
28
+ When the checkout, plugin cache, and installed package may differ, run the
29
+ checkout binary (`./bin/quest`) or verify `PATH` with `quest --version`
30
+ before trusting queue or dispatch behavior.
24
31
  For an implementation accepted from `$quest:plan` in Codex Plan Mode, stay in
25
32
  this orchestrator role. Do not implement product code inline; create/lint the
26
33
  quest records if needed, then dispatch workers.
@@ -31,7 +38,7 @@ trails, and escalations only where a human ruling is genuinely needed.
31
38
  - In Claude Code, start the turn with `/goal` using the same condition.
32
39
  If native goal mode is unavailable, say so and keep the Quest checkpoint
33
40
  trail as the hard stop signal; do not pretend a goal was set.
34
- 3. **Dispatch** each ready quest per its record:
41
+ 3. **Dispatch** each `worker_ready` quest per its record:
35
42
  - In Codex, the default path is native subagents for both serial and
36
43
  parallel waves. If `spawn_agent` is not visible, call `tool_search` once
37
44
  for subagent tools before choosing any fallback. When available, spawn:
@@ -73,16 +80,17 @@ trails, and escalations only where a human ruling is genuinely needed.
73
80
  re-parent the original honestly.
74
81
  - **escalate-to-human** — surface human-only decisions verbatim. Never
75
82
  guess a ruling the human should make.
76
- 7. **Wave done?** When `quest list --ready` empties and nothing is in flight:
77
- run `$quest:retro` before starting the next wave.
83
+ 7. **Wave done?** When `quest list --queue --json` shows no `worker_ready` or
84
+ `inline_close_ready_epics`, and nothing is in flight, run `$quest:retro` (read skill `$quest:retro`) before starting the next wave.
78
85
 
79
86
  ## Closing an epic
80
87
 
81
88
  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
89
+ dispatched to a worker**: it does not belong in `worker_ready`, and
90
+ `quest-run --ready` refuses it even once every child is terminal (a direct
91
+ `quest-run <id>` on an epic still runs, but don't — it burns a worker on pure
92
+ verification). Once it is closeable, `quest list --queue --json` reports it in
93
+ `inline_close_ready_epics`; close it inline yourself, spending zero worker
86
94
  tokens:
87
95
 
88
96
  1. **Verify the children are genuinely done:**
@@ -119,7 +127,8 @@ quest reopen <id> --reason "review found npm audit criticals after completion"
119
127
  This flips `complete → in_progress` and appends an audited checkpoint carrying
120
128
  `reopen_reason`, so the loop keeps custody of the defect trail. Then dispatch the
121
129
  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`.
130
+ quests are `in_progress`, so they do **not** re-appear in `worker_ready` or
131
+ `quest list --ready`.
123
132
  Reopening a child of a **complete** parent epic is allowed (a stderr warning, not
124
133
  a block); you then rule whether the epic's completion verdict is falsified and,
125
134
  if so, reopen the epic too. `cancelled` is fully terminal — file a new quest.
@@ -139,7 +148,7 @@ genuinely done.
139
148
  ## Worked example
140
149
 
141
150
  ```bash
142
- quest list --ready --json # → [{"id":12,"worker":"claude"…},{"id":13,"worker":"codex"…}]
151
+ quest list --queue --json # → {"worker_ready":[{"id":12…},{"id":13…}],"inline_close_ready_epics":[]}
143
152
  # 12 → spawn quest-executor with a child /goal or create_goal prompt
144
153
  # 13 → spawn quest-executor too; use quest-run only if native subagents are unavailable
145
154
  # …executor stops →
@@ -27,14 +27,16 @@ extra context.
27
27
  In Codex Plan Mode, do **not** implement product code after the user accepts a
28
28
  plan. The same parent agent/session that processes `$quest:plan` owns the
29
29
  handoff: make the quest records real, then adopt `$quest:orchestrate` by
30
- default.
30
+ default when the user asks to implement the plan. The parent session never uses
31
+ `$quest:work <id>` after a Plan Mode handoff unless the user explicitly asks to.
31
32
 
32
33
  1. Create or confirm the quest records with `quest create` and `quest lint`.
33
34
  2. If the user accepted a plan and asked to implement it, the same parent
34
35
  session becomes `$quest:orchestrate`, not `$quest:work`. Do not start editing
35
- product code in the parent session.
36
- 3. In `$quest:orchestrate`, set the orchestrator goal for the wave, then spawn
37
- goal-mode workers.
36
+ product code in the parent session. Read skill `$quest:orchestrate` and follow its rules for dispatching workers and closing epics inline.
37
+ 3. In `$quest:orchestrate`, inspect `quest list --queue --json`, set the
38
+ orchestrator goal for the wave, then spawn goal-mode workers for
39
+ `worker_ready` quests.
38
40
  4. Stop after listing the ready quest ids and validation commands only when the
39
41
  user explicitly asked for create-only/no-dispatch behavior such as "only
40
42
  create quests", "do not dispatch", or "stop after planning".
@@ -46,8 +48,15 @@ unless the user explicitly asks to bypass orchestration for a genuinely small
46
48
  single quest.
47
49
 
48
50
  For epics: create the parent first, then children with `--parent <id>` and
49
- `--depends-on` expressing the real order. `quest list --ready` becomes the
50
- dispatch queue that is the whole wave mechanic.
51
+ `--depends-on` expressing the real order. `quest list --queue --json` shows the
52
+ wave order: `worker_ready` quests are dispatched, and
53
+ `inline_close_ready_epics` are closed inline by the orchestrator after children
54
+ finish. `quest list --ready` remains only the dispatch shortcut for
55
+ `worker_ready`.
56
+
57
+ When local checkout, plugin cache, and installed package versions can differ,
58
+ author and lint with the checkout binary (`./bin/quest`) or verify `PATH` with
59
+ `quest --version` before relying on queue semantics or generated records.
51
60
 
52
61
  Keep the **epic itself thin**. Its Done-when is **integration-level only**: it
53
62
  checks that the children compose into a working whole (the end-to-end behavior,
@@ -104,4 +113,4 @@ quest lint 12 # always, before dispatch
104
113
 
105
114
  **Next:** dispatch with `$quest:orchestrate`. Only work it yourself via
106
115
  `$quest:work <id>` for genuinely small inline work outside Plan Mode and outside
107
- an accepted Plan Mode handoff. Rules and vocabulary: `$quest:protocol`.
116
+ an accepted Plan Mode handoff, when user explicitly asked for it. Rules and vocabulary: `$quest:protocol`.