quest-loop 0.3.4 → 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.
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
 
10
10
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
11
11
  const AGENTS = ["quest-executor", "quest-reviewer"];
12
+ const QUEST_CODEX_MARKETPLACE_SOURCE = ["roberts", "reberski/quest"].join("");
12
13
  const PROVIDERS = {
13
14
  codex: {
14
15
  label: "Codex",
@@ -160,7 +161,19 @@ export function versionInfo() {
160
161
  };
161
162
  }
162
163
 
163
- function installProviderAgents(provider, { scope = "project", dryRun = false, force = false, cwd = process.cwd(), env = process.env } = {}) {
164
+ function existingAgentMatchesName(provider, name, path) {
165
+ let text;
166
+ try {
167
+ text = readFileSync(path, "utf8");
168
+ } catch {
169
+ return false;
170
+ }
171
+ if (provider === "codex") return new RegExp(`^\\s*name\\s*=\\s*"${name}"\\s*$`, "m").test(text);
172
+ if (provider === "claude") return new RegExp(`^\\s*name:\\s*${name}\\s*$`, "m").test(text);
173
+ return false;
174
+ }
175
+
176
+ function installProviderAgents(provider, { scope = "project", dryRun = false, force = false, replaceStale = true, cwd = process.cwd(), env = process.env } = {}) {
164
177
  const cfg = providerConfig(provider);
165
178
  const dir = targetDir(provider, scope, cwd, env);
166
179
  const dirConflicts = [dirname(dir), dir]
@@ -175,9 +188,10 @@ function installProviderAgents(provider, { scope = "project", dryRun = false, fo
175
188
  const dest = join(dir, `${name}.${cfg.extension}`);
176
189
  const text = readFileSync(src, "utf8");
177
190
  const exists = existsSync(dest) || isSymlink(dest);
191
+ const staleQuestAgent = exists && !sameFile(dest, text) && existingAgentMatchesName(provider, name, dest);
178
192
  let action;
179
193
  if (exists && sameFile(dest, text)) action = "unchanged";
180
- else if (exists && !force) action = "conflict";
194
+ else if (exists && !force && !(replaceStale && staleQuestAgent)) action = "conflict";
181
195
  else action = exists ? "replace" : "create";
182
196
  return { name, path: dest, action, text };
183
197
  });
@@ -208,6 +222,92 @@ export function installClaudeAgents(options = {}) {
208
222
  return installProviderAgents("claude", options);
209
223
  }
210
224
 
225
+ export function nativeProjectRoot(cwd = process.cwd(), env = process.env) {
226
+ return projectRoot(cwd, env);
227
+ }
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
+
211
311
  function questCliPathCheck(cwd, env, versions) {
212
312
  const pathQuestVersion = runCmd("quest", ["--version"], { cwd, env });
213
313
  const pathVersion = pathQuestVersion.status === 0 ? pathQuestVersion.stdout.trim() : null;
@@ -243,7 +343,7 @@ function nativeAgentsCheck(provider, cwd, env) {
243
343
  }
244
344
  const agentDetail = [
245
345
  missing.length ? `missing: ${missing.join(", ")}` : null,
246
- stale.length ? `stale (run install-agents --force): ${stale.join(", ")}` : null,
346
+ stale.length ? `stale (run doctor --fix or install-agents): ${stale.join(", ")}` : null,
247
347
  ].filter(Boolean).join("; ") || `installed ${cfg.label} templates current: ${Object.values(found).join(", ")}`;
248
348
  return check(
249
349
  "native-agents",
@@ -360,7 +460,7 @@ export function doctor({ cwd = process.cwd(), env = process.env } = {}) {
360
460
 
361
461
  checks.push(nativeAgentsCheck("codex", cwd, env));
362
462
 
363
- return { ok: checks.every((c) => c.ok), checks };
463
+ return { ok: checks.every((c) => c.ok), checks, recommended_path: providerRecommendation("codex", checks) };
364
464
  }
365
465
 
366
466
  export function claudeDoctor({ cwd = process.cwd(), env = process.env } = {}) {
@@ -416,5 +516,115 @@ export function claudeDoctor({ cwd = process.cwd(), env = process.env } = {}) {
416
516
 
417
517
  checks.push(nativeAgentsCheck("claude", cwd, env));
418
518
 
419
- return { ok: checks.every((c) => c.ok), checks };
519
+ return { ok: checks.every((c) => c.ok), checks, recommended_path: providerRecommendation("claude", checks) };
520
+ }
521
+
522
+ function checkByName(result, name) {
523
+ return result.checks.find((c) => c.name === name);
524
+ }
525
+
526
+ function cmdDetail(res) {
527
+ const text = [res.stdout, res.stderr, res.error?.message].filter(Boolean).join("\n").trim();
528
+ return text || `exit ${res.status ?? "unknown"}`;
529
+ }
530
+
531
+ function repairRecord(name, command, res, extra = {}) {
532
+ return {
533
+ name,
534
+ command: command.join(" "),
535
+ ok: res.status === 0,
536
+ detail: cmdDetail(res),
537
+ ...extra,
538
+ };
539
+ }
540
+
541
+ function runRepairCommand(cmd, args, { cwd, env }) {
542
+ return runCmd(cmd, args, { cwd, env });
543
+ }
544
+
545
+ function codexMarketplacePresent(cwd, env) {
546
+ const res = runCmd("codex", ["plugin", "marketplace", "list", "--json"], { cwd, env });
547
+ if (res.status !== 0) return { ok: false, present: false, res };
548
+ try {
549
+ const parsed = JSON.parse(res.stdout || "{}");
550
+ const marketplaces = Array.isArray(parsed.marketplaces) ? parsed.marketplaces : [];
551
+ return { ok: true, present: marketplaces.some((m) => m.name === "quest" || m.root?.includes("/quest")) };
552
+ } catch {
553
+ return { ok: false, present: false, res };
554
+ }
555
+ }
556
+
557
+ function providerPluginNeedsRepair(provider, result) {
558
+ const names = provider === "codex"
559
+ ? ["plugin-installed", "plugin-version", "hook-parser", "single-neutral-skill-root"]
560
+ : ["plugin-installed", "plugin-version"];
561
+ return names.some((name) => checkByName(result, name)?.ok === false);
562
+ }
563
+
564
+ function repairProviderPlugin(provider, before, { cwd, env }) {
565
+ const cliName = provider === "codex" ? "codex-cli" : "claude-cli";
566
+ if (!checkByName(before, cliName)?.ok || !providerPluginNeedsRepair(provider, before)) return [];
567
+
568
+ const repairs = [];
569
+ if (provider === "codex") {
570
+ const marketplace = codexMarketplacePresent(cwd, env);
571
+ if (!marketplace.ok) {
572
+ repairs.push(repairRecord("plugin-marketplace-list", ["codex", "plugin", "marketplace", "list", "--json"], marketplace.res));
573
+ return repairs;
574
+ }
575
+ if (!marketplace.present) {
576
+ const addMarketplaceArgs = ["plugin", "marketplace", "add", QUEST_CODEX_MARKETPLACE_SOURCE];
577
+ const addMarketplace = runRepairCommand("codex", addMarketplaceArgs, { cwd, env });
578
+ repairs.push(repairRecord("plugin-marketplace-add", ["codex", ...addMarketplaceArgs], addMarketplace));
579
+ if (addMarketplace.status !== 0) return repairs;
580
+ }
581
+ const upgrade = runRepairCommand("codex", ["plugin", "marketplace", "upgrade", "quest"], { cwd, env });
582
+ repairs.push(repairRecord("plugin-marketplace-upgrade", ["codex", "plugin", "marketplace", "upgrade", "quest"], upgrade));
583
+ if (upgrade.status !== 0) return repairs;
584
+ const addPlugin = runRepairCommand("codex", ["plugin", "add", "quest@quest"], { cwd, env });
585
+ repairs.push(repairRecord("plugin-add", ["codex", "plugin", "add", "quest@quest"], addPlugin));
586
+ return repairs;
587
+ }
588
+
589
+ const installed = checkByName(before, "plugin-installed")?.installed;
590
+ const subcommand = installed ? "update" : "install";
591
+ const res = runRepairCommand("claude", ["plugin", subcommand, "quest@quest"], { cwd, env });
592
+ repairs.push(repairRecord(`plugin-${subcommand}`, ["claude", "plugin", subcommand, "quest@quest"], res));
593
+ return repairs;
594
+ }
595
+
596
+ function repairProviderAgents(provider, before, { cwd, env }) {
597
+ if (checkByName(before, "native-agents")?.ok !== false) return [];
598
+ const result = installProviderAgents(provider, { scope: "project", force: false, replaceStale: true, cwd, env });
599
+ return [{
600
+ name: "native-agents",
601
+ command: `quest ${provider} install-agents --scope project`,
602
+ ok: result.ok,
603
+ detail: result.ok ? `${result.actions.map((a) => `${a.action} ${a.path}`).join("; ")}` : `conflicts: ${result.conflicts.map((c) => c.path).join(", ")}`,
604
+ result,
605
+ }];
606
+ }
607
+
608
+ function doctorForProvider(provider, options) {
609
+ if (provider === "codex") return doctor(options);
610
+ if (provider === "claude") return claudeDoctor(options);
611
+ throw new Error(`unknown provider "${provider}"`);
612
+ }
613
+
614
+ function doctorWithFix(provider, { cwd = process.cwd(), env = process.env } = {}) {
615
+ const before = doctorForProvider(provider, { cwd, env });
616
+ const repairs = [
617
+ ...repairProviderAgents(provider, before, { cwd, env }),
618
+ ...repairProviderPlugin(provider, before, { cwd, env }),
619
+ ];
620
+ const after = doctorForProvider(provider, { cwd, env });
621
+ return { ok: after.ok, checks: after.checks, recommended_path: after.recommended_path, repairs, before };
622
+ }
623
+
624
+ export function codexDoctorFix(options = {}) {
625
+ return doctorWithFix("codex", options);
626
+ }
627
+
628
+ export function claudeDoctorFix(options = {}) {
629
+ return doctorWithFix("claude", options);
420
630
  }
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,33 +139,46 @@ export const COMMANDS = {
138
139
  example: "quest runs --active",
139
140
  },
140
141
  codex: {
141
- purpose: "Inspect or install Quest's native Codex integration",
142
- usage: "quest codex doctor [--json] | 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"],
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"],
148
+ ["open", "repair/check setup, then launch interactive Codex from the project root"],
145
149
  ["install-agents", "install quest-executor and quest-reviewer as native Codex custom agents"],
146
150
  ["--scope <s>", "project (default: .codex/agents at repo root) or user (~/.codex/agents)"],
147
- ["--dry-run", "show intended agent writes without changing files"],
148
- ["--force", "replace existing agent files"],
151
+ ["--dry-run", "install-agents: preview writes; work/open: print the handoff or launch command without starting provider work"],
152
+ ["--force", "replace existing non-Quest agent files"],
149
153
  ["--json", "machine-readable output"],
150
154
  ],
151
- example: "quest codex install-agents --scope project && quest codex doctor",
155
+ example: "quest codex doctor --fix && quest codex work 12 --dry-run",
152
156
  },
153
157
  claude: {
154
- purpose: "Inspect or install Quest's native Claude Code integration",
155
- usage: "quest claude doctor [--json] | 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]",
156
160
  flags: [
157
161
  ["doctor", "check Claude CLI, plugin install/version, and native-agent templates"],
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"],
164
+ ["open", "repair/check setup, then launch interactive Claude Code from the project root"],
158
165
  ["install-agents", "install quest-executor and quest-reviewer as native Claude Code custom agents"],
159
166
  ["--scope <s>", "project (default: .claude/agents at repo root) or user (~/.claude/agents)"],
160
- ["--dry-run", "show intended agent writes without changing files"],
161
- ["--force", "replace existing agent files"],
167
+ ["--dry-run", "install-agents: preview writes; work/open: print the handoff or launch command without starting provider work"],
168
+ ["--force", "replace existing non-Quest agent files"],
162
169
  ["--json", "machine-readable output"],
163
170
  ],
164
- example: "quest claude install-agents --scope project && quest claude doctor",
171
+ example: "quest claude doctor --fix && quest claude work 12 --dry-run",
165
172
  },
166
173
  };
167
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
+
168
182
  export function renderCommandHelp(name) {
169
183
  const c = COMMANDS[name];
170
184
  const lines = [c.purpose, "", `Usage: ${c.usage}`];
@@ -178,53 +192,84 @@ export function renderCommandHelp(name) {
178
192
  }
179
193
 
180
194
  export function renderGeneralHelp() {
181
- const lines = [INTRO, "", "Commands:"];
195
+ const lines = [INTRO, "", "Commands by workflow:"];
182
196
  const w = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
183
- 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
+ }
184
211
  lines.push("", "Run `quest <command> --help` for flags and a copy-pasteable example.", "Headless workers: see `quest-run --help`.");
185
212
  return lines.join("\n");
186
213
  }
187
214
 
188
- 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 = [] }) {
189
221
  const lines = [`Quest store: ${backend} backend`];
190
222
  const parts = ["todo", "in_progress", "blocked", "complete", "cancelled"]
191
223
  .filter((s) => counts[s])
192
224
  .map((s) => `${counts[s]} ${s}`);
193
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:");
194
236
  if (ready.length) {
195
- lines.push("", "Ready to work (dependencies met):");
196
- for (const q of ready.slice(0, 3)) lines.push(` ${q.id}. [${q.priority}] ${q.title}`);
197
- 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`;
198
249
  } else if (!parts.length) {
199
- lines.push("", "Next: create your first quest — see `quest create --help` (or use the $quest:plan skill).");
250
+ next = "quest create --help";
200
251
  } else {
201
- lines.push("", "Nothing ready to start. `quest list` to see everything.");
252
+ next = "quest list --queue";
202
253
  }
203
- if (activeRuns > 0) lines.push("", `Active headless runs: ${activeRuns} — inspect with \`quest runs --active\`.`);
254
+ lines.push("", `Next: \`${next}\``);
204
255
  return lines.join("\n");
205
256
  }
206
257
 
207
258
  export function renderNoStore() {
208
259
  return [
209
- "No quest store found (searched for .quests/ from here upward).",
210
- "",
211
- "Get started:",
212
- " quest init create a local store here",
213
- " quest init --backend github --repo owner/name",
260
+ "No quest store found (.quests/ not found from here upward).",
214
261
  "",
215
- "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`",
216
264
  ].join("\n");
217
265
  }
218
266
 
219
267
  export function renderInitNextSteps(backend, { agentsInstalled = true } = {}) {
220
268
  return [
221
269
  "",
222
- "Store created. Next steps:",
223
- ...(agentsInstalled ? [" 0. Restart agent sessions so newly installed project templates are loaded"] : []),
224
- " 1. Author a quest: quest create --help (or $quest:plan in your agent session)",
225
- " 2. Check it: quest lint --all",
226
- " 3. Work it: $quest:work <id> in-session, or quest-run <id> headless",
227
- ...(agentsInstalled ? [] : [" Agent templates skipped; install later with `quest codex install-agents --scope project` and `quest claude install-agents --scope project`."]),
228
- ...(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"}\``,
229
274
  ].join("\n");
230
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
  }