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.
- package/CHANGELOG.md +35 -1
- package/README.md +36 -19
- package/hooks/session-start.mjs +23 -11
- package/hooks/subagent-stop.mjs +40 -20
- package/lib/cli.mjs +179 -13
- package/lib/codex-native.mjs +215 -5
- package/lib/graph.mjs +82 -0
- package/lib/help.mjs +79 -34
- package/lib/runner.mjs +31 -34
- package/lib/store-github.mjs +15 -13
- package/lib/store-local.mjs +12 -12
- package/lib/store.mjs +2 -0
- package/lib/workers.mjs +1 -1
- package/package.json +1 -1
- package/skills/orchestrate/SKILL.md +19 -10
- package/skills/plan/SKILL.md +16 -7
- package/skills/setup/SKILL.md +27 -11
package/lib/codex-native.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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", "
|
|
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
|
|
155
|
+
example: "quest codex doctor --fix && quest codex work 12 --dry-run",
|
|
152
156
|
},
|
|
153
157
|
claude: {
|
|
154
|
-
purpose: "Inspect
|
|
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", "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
lines.push(""
|
|
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
|
-
|
|
250
|
+
next = "quest create --help";
|
|
200
251
|
} else {
|
|
201
|
-
|
|
252
|
+
next = "quest list --queue";
|
|
202
253
|
}
|
|
203
|
-
|
|
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 (
|
|
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
|
-
"
|
|
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
|
-
|
|
223
|
-
...(
|
|
224
|
-
"
|
|
225
|
-
"
|
|
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
|
|
195
|
-
const maxCost = flags
|
|
196
|
-
const maxTokens = flags
|
|
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
|
|
199
|
-
const sessionTimeoutSec = flags
|
|
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 =
|
|
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
|
|
577
|
-
const { stdout, code } = await questCli(storeDir, io.env, ["list", "--
|
|
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
|
-
|
|
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
|
|
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
|
|
623
|
-
const
|
|
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
|
}
|