quest-loop 0.1.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ // Native setup helpers. These are intentionally kept out of the quest store
2
+ // layer: they inspect/install agent integration files, not quest records.
3
+
4
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
11
+ const AGENTS = ["quest-executor", "quest-reviewer"];
12
+ const PROVIDERS = {
13
+ codex: {
14
+ label: "Codex",
15
+ extension: "toml",
16
+ projectDir: ".codex",
17
+ userDir: join(homedir(), ".codex", "agents"),
18
+ },
19
+ claude: {
20
+ label: "Claude",
21
+ extension: "md",
22
+ projectDir: ".claude",
23
+ userDir: join(homedir(), ".claude", "agents"),
24
+ },
25
+ };
26
+
27
+ function readJson(rel) {
28
+ return JSON.parse(readFileSync(join(PLUGIN_ROOT, rel), "utf8"));
29
+ }
30
+
31
+ // Like readJson but returns null instead of throwing. The plugin manifests
32
+ // (.codex-plugin / .claude-plugin) are NOT part of the npm `files` allow-list,
33
+ // so they are absent from a pure `npm install -g quest-loop` install — reading
34
+ // them must never crash `quest --version` or `quest codex doctor`.
35
+ function readJsonSafe(rel) {
36
+ try {
37
+ return readJson(rel);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function runCmd(cmd, args, { cwd, env } = {}) {
44
+ return spawnSync(cmd, args, {
45
+ cwd,
46
+ env,
47
+ encoding: "utf8",
48
+ maxBuffer: 20 * 1024 * 1024,
49
+ });
50
+ }
51
+
52
+ function check(name, ok, detail, extra = {}) {
53
+ return { name, ok: Boolean(ok), detail, ...extra };
54
+ }
55
+
56
+ function projectRoot(cwd, env) {
57
+ const res = runCmd("git", ["rev-parse", "--show-toplevel"], { cwd, env });
58
+ if (res.status === 0 && res.stdout.trim()) return res.stdout.trim();
59
+ return resolve(cwd);
60
+ }
61
+
62
+ function providerConfig(provider) {
63
+ const cfg = PROVIDERS[provider];
64
+ if (!cfg) throw new Error(`unknown native agent provider "${provider}"`);
65
+ return cfg;
66
+ }
67
+
68
+ function targetDir(provider, scope, cwd, env) {
69
+ const cfg = providerConfig(provider);
70
+ if (scope === "user") return cfg.userDir;
71
+ if (scope === "project") return join(projectRoot(cwd, env), cfg.projectDir, "agents");
72
+ throw new Error(`--scope must be project or user (got "${scope}")`);
73
+ }
74
+
75
+ function installedAgentDirs(provider, cwd, env) {
76
+ const cfg = providerConfig(provider);
77
+ return [
78
+ join(projectRoot(cwd, env), cfg.projectDir, "agents"),
79
+ cfg.userDir,
80
+ ];
81
+ }
82
+
83
+ function sameFile(path, text) {
84
+ try {
85
+ if (lstatSync(path).isSymbolicLink()) return false;
86
+ return readFileSync(path, "utf8") === text;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function isSymlink(path) {
93
+ try {
94
+ return lstatSync(path).isSymbolicLink();
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function extractTexts(promptInputJson) {
101
+ try {
102
+ const items = JSON.parse(promptInputJson);
103
+ const texts = [];
104
+ const walk = (v) => {
105
+ if (Array.isArray(v)) return v.forEach(walk);
106
+ if (!v || typeof v !== "object") return;
107
+ if (typeof v.text === "string") texts.push(v.text);
108
+ if (typeof v.content === "string") texts.push(v.content);
109
+ for (const child of Object.values(v)) walk(child);
110
+ };
111
+ walk(items);
112
+ return texts.join("\n");
113
+ } catch {
114
+ return promptInputJson;
115
+ }
116
+ }
117
+
118
+ function questSkillEntries(promptInputJson) {
119
+ const text = extractTexts(promptInputJson);
120
+ const entries = [];
121
+ const re = /- quest:(plan|work|orchestrate|retro|protocol|setup):[^\n]+\(file: ([^)]+)\)/g;
122
+ for (const m of text.matchAll(re)) entries.push({ name: m[1], path: m[2] });
123
+ return entries.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
124
+ }
125
+
126
+ function pathRootForQuestSkill(path) {
127
+ // Match POSIX `/skills/` and Windows `\skills\` so the neutral-root check
128
+ // does not falsely split every skill path into its own root on Windows.
129
+ const i = path.search(/[/\\]skills[/\\]/);
130
+ return i === -1 ? path : path.slice(0, i);
131
+ }
132
+
133
+ function featureEnabled(output, name) {
134
+ for (const line of String(output || "").split("\n")) {
135
+ const parts = line.trim().split(/\s+/);
136
+ if (parts[0] === name) return parts.at(-1) === "true";
137
+ }
138
+ return false;
139
+ }
140
+
141
+ function upgradeHint() {
142
+ return "run `codex plugin marketplace upgrade quest`, `codex plugin add quest@quest`, then start a new Codex thread";
143
+ }
144
+
145
+ function duplicateSkillNames(entries) {
146
+ const counts = new Map();
147
+ for (const entry of entries) counts.set(entry.name, (counts.get(entry.name) || 0) + 1);
148
+ return [...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name).sort();
149
+ }
150
+
151
+ export function versionInfo() {
152
+ // package.json is always shipped; the plugin manifests may be absent on a
153
+ // CLI-only npm install, so they are read tolerantly.
154
+ const pkg = readJson("package.json");
155
+ const codexManifest = readJsonSafe(".codex-plugin/plugin.json");
156
+ const claudeManifest = readJsonSafe(".claude-plugin/plugin.json");
157
+ return {
158
+ package: pkg.version,
159
+ codex: codexManifest?.version ?? null,
160
+ claude: claudeManifest?.version ?? null,
161
+ };
162
+ }
163
+
164
+ function installProviderAgents(provider, { scope = "project", dryRun = false, force = false, cwd = process.cwd(), env = process.env } = {}) {
165
+ const cfg = providerConfig(provider);
166
+ const dir = targetDir(provider, scope, cwd, env);
167
+ const dirConflicts = [dirname(dir), dir]
168
+ .filter((path, index, all) => all.indexOf(path) === index)
169
+ .filter((path) => existsSync(path) && isSymlink(path))
170
+ .map((path) => ({ name: null, path, reason: "symlink" }));
171
+
172
+ // Plan every agent first; only touch disk once the whole set is known to be
173
+ // conflict-free. A mixed create+conflict run must not leave a partial install.
174
+ const plans = AGENTS.map((name) => {
175
+ const src = join(PLUGIN_ROOT, "agents", `${name}.${cfg.extension}`);
176
+ const dest = join(dir, `${name}.${cfg.extension}`);
177
+ const text = readFileSync(src, "utf8");
178
+ const exists = existsSync(dest);
179
+ const symlink = exists && isSymlink(dest);
180
+ let action;
181
+ if (symlink) action = "conflict";
182
+ else if (exists && sameFile(dest, text)) action = "unchanged";
183
+ else if (exists && !force) action = "conflict";
184
+ else action = exists ? "replace" : "create";
185
+ return { name, path: dest, action, text, reason: symlink ? "symlink" : undefined };
186
+ });
187
+
188
+ const conflicts = [
189
+ ...dirConflicts,
190
+ ...plans.filter((p) => p.action === "conflict").map(({ name, path, reason }) => ({ name, path, ...(reason ? { reason } : {}) })),
191
+ ];
192
+ const actions = plans.map(({ name, path, action }) => ({ name, path, action }));
193
+ const ok = conflicts.length === 0;
194
+
195
+ if (ok && !dryRun) {
196
+ if (dirConflicts.length) throw new Error(`refusing to write agent templates through symlinked path: ${dirConflicts.map((c) => c.path).join(", ")}`);
197
+ for (const p of plans) {
198
+ if (p.action === "unchanged") continue;
199
+ mkdirSync(dir, { recursive: true });
200
+ if (isSymlink(dirname(p.path)) || isSymlink(p.path)) throw new Error(`refusing to write agent template through symlinked path: ${p.path}`);
201
+ writeFileSync(p.path, p.text);
202
+ }
203
+ }
204
+
205
+ return { ok, provider, scope, target: dir, dry_run: dryRun, actions, conflicts };
206
+ }
207
+
208
+ export function installAgents(options = {}) {
209
+ return installProviderAgents("codex", options);
210
+ }
211
+
212
+ export function installClaudeAgents(options = {}) {
213
+ return installProviderAgents("claude", options);
214
+ }
215
+
216
+ function questCliPathCheck(cwd, env, versions) {
217
+ const pathQuestVersion = runCmd("quest", ["--version"], { cwd, env });
218
+ const pathVersion = pathQuestVersion.status === 0 ? pathQuestVersion.stdout.trim() : null;
219
+ return check(
220
+ "quest-cli-path",
221
+ pathQuestVersion.status !== 0 || pathVersion === versions.package,
222
+ pathQuestVersion.status === 0
223
+ ? (pathVersion === versions.package
224
+ ? `quest on PATH=${pathVersion}`
225
+ : `quest on PATH=${pathVersion}, package=${versions.package}; update with \`npm install -g quest-loop@${versions.package}\` or run the checkout binary explicitly`)
226
+ : `quest not found on PATH (${(pathQuestVersion.stderr || pathQuestVersion.error?.message || "not found").trim()}); current package=${versions.package}`,
227
+ { path_version: pathVersion, expected_version: versions.package },
228
+ );
229
+ }
230
+
231
+ function nativeAgentsCheck(provider, cwd, env) {
232
+ const cfg = providerConfig(provider);
233
+ const agentDirs = installedAgentDirs(provider, cwd, env);
234
+ const missing = [];
235
+ const stale = [];
236
+ const found = {};
237
+ for (const name of AGENTS) {
238
+ const bundled = readFileSync(join(PLUGIN_ROOT, "agents", `${name}.${cfg.extension}`), "utf8");
239
+ const path = agentDirs.map((dir) => join(dir, `${name}.${cfg.extension}`)).find((p) => existsSync(p));
240
+ if (!path) {
241
+ missing.push(name);
242
+ continue;
243
+ }
244
+ found[name] = path;
245
+ // Existence alone can mask a stale user-scope copy shadowing an out-of-date
246
+ // project install — compare against the bundled definition.
247
+ if (!sameFile(path, bundled)) stale.push(name);
248
+ }
249
+ const agentDetail = [
250
+ missing.length ? `missing: ${missing.join(", ")}` : null,
251
+ stale.length ? `stale (run install-agents --force): ${stale.join(", ")}` : null,
252
+ ].filter(Boolean).join("; ") || `installed ${cfg.label} templates current: ${Object.values(found).join(", ")}`;
253
+ return check(
254
+ "native-agents",
255
+ missing.length === 0 && stale.length === 0,
256
+ agentDetail,
257
+ { found, missing, stale, provider },
258
+ );
259
+ }
260
+
261
+ export function doctor({ cwd = process.cwd(), env = process.env } = {}) {
262
+ const checks = [];
263
+ const versions = versionInfo();
264
+ // package.json ships everywhere; the plugin manifests may be absent on a
265
+ // CLI-only npm install. Treat absent manifests as "not applicable" rather than
266
+ // a hard mismatch, and fall back to the package version as the expected plugin
267
+ // version so a legitimate CLI-only doctor run is not spuriously red.
268
+ const manifestVersion = versions.codex ?? versions.package;
269
+ const presentManifests = [["codex", versions.codex], ["claude", versions.claude]].filter(([, v]) => v != null);
270
+ const absent = ["codex", "claude"].filter((k) => versions[k] == null);
271
+
272
+ checks.push(check(
273
+ "version-sync",
274
+ presentManifests.every(([, v]) => v === versions.package),
275
+ `package=${versions.package}, codex=${versions.codex ?? "absent"}, claude=${versions.claude ?? "absent"}` +
276
+ (absent.length ? ` (${absent.join(", ")} manifest absent — CLI-only install)` : ""),
277
+ { versions },
278
+ ));
279
+
280
+ checks.push(questCliPathCheck(cwd, env, versions));
281
+
282
+ const codexVersion = runCmd("codex", ["--version"], { cwd, env });
283
+ checks.push(check(
284
+ "codex-cli",
285
+ codexVersion.status === 0,
286
+ codexVersion.status === 0 ? codexVersion.stdout.trim() : (codexVersion.stderr || codexVersion.error?.message || "codex not found").trim(),
287
+ ));
288
+
289
+ if (codexVersion.status === 0) {
290
+ const list = runCmd("codex", ["plugin", "list", "--json"], { cwd, env });
291
+ let installed;
292
+ try {
293
+ const parsed = JSON.parse(list.stdout || "{}");
294
+ installed = (parsed.installed || []).find((p) => p.pluginId === "quest@quest" || (p.name === "quest" && p.marketplaceName === "quest"));
295
+ } catch {
296
+ installed = null;
297
+ }
298
+ checks.push(check(
299
+ "plugin-installed",
300
+ list.status === 0 && installed?.enabled === true,
301
+ installed ? `quest@quest ${installed.version} enabled=${installed.enabled}` : (list.stderr || "quest@quest not installed").trim(),
302
+ { installed: installed ?? null },
303
+ ));
304
+ checks.push(check(
305
+ "plugin-version",
306
+ Boolean(installed && installed.version === manifestVersion),
307
+ installed
308
+ ? (installed.version === manifestVersion
309
+ ? `installed=${installed.version}, manifest=${manifestVersion}`
310
+ : `installed=${installed.version}, manifest=${manifestVersion}; ${upgradeHint()}`)
311
+ : `manifest=${manifestVersion}; ${upgradeHint()}`,
312
+ ));
313
+
314
+ const features = runCmd("codex", ["features", "list"], { cwd, env });
315
+ const multiAgent = features.status === 0 && featureEnabled(features.stdout, "multi_agent");
316
+ const goals = features.status === 0 && featureEnabled(features.stdout, "goals");
317
+ checks.push(check(
318
+ "multi-agent-feature",
319
+ multiAgent,
320
+ features.status === 0
321
+ ? (multiAgent
322
+ ? "multi_agent enabled; native Codex quest-executor dispatch available"
323
+ : "multi_agent is not enabled; use quest-run fallback")
324
+ : ((features.stderr || features.error?.message || "could not inspect Codex feature flags").trim() + "; use quest-run fallback"),
325
+ { feature: "multi_agent" },
326
+ ));
327
+ checks.push(check(
328
+ "goals-feature",
329
+ goals,
330
+ features.status === 0
331
+ ? (goals
332
+ ? "goals enabled; native create_goal/get_goal dispatch available"
333
+ : "goals is not enabled; native goal-mode dispatch requires create_goal/get_goal")
334
+ : ((features.stderr || features.error?.message || "could not inspect Codex feature flags").trim() + "; native goal-mode dispatch unavailable"),
335
+ { feature: "goals" },
336
+ ));
337
+
338
+ const debug = runCmd("codex", ["debug", "prompt-input", "noop"], { cwd: tmpdir(), env });
339
+ // Require a hook-parse *problem*, not merely the word "hook" next to "warnings".
340
+ // Matches both orderings ("hook parse warning" / "failed to parse hook") while
341
+ // ignoring benign summaries like "loaded 4 hooks, 0 warnings".
342
+ const hookWarning = /(?:parse|parsing)[^\n]*\bhook\b|\bhook\b[^\n]*(?:parse|parsing|invalid|malformed)|failed to (?:parse|load)[^\n]*\bhook\b/i.test(debug.stderr || "");
343
+ checks.push(check(
344
+ "hook-parser",
345
+ debug.status === 0 && !hookWarning,
346
+ debug.status === 0 ? (hookWarning ? debug.stderr.trim() : "no hook parse warnings") : (debug.stderr || "codex debug failed").trim(),
347
+ ));
348
+
349
+ const entries = debug.status === 0 ? questSkillEntries(debug.stdout) : [];
350
+ const requiredSkills = ["plan", "work", "orchestrate", "retro", "protocol"];
351
+ const missingSkills = requiredSkills.filter((name) => !entries.some((entry) => entry.name === name));
352
+ const paths = entries.map((entry) => entry.path);
353
+ const roots = [...new Set(paths.map(pathRootForQuestSkill))];
354
+ const duplicateSkills = duplicateSkillNames(entries);
355
+ checks.push(check(
356
+ "single-neutral-skill-root",
357
+ missingSkills.length === 0 && roots.length === 1 && duplicateSkills.length === 0,
358
+ paths.length
359
+ ? `${entries.length} quest skill entries across ${roots.length} root(s)` +
360
+ (duplicateSkills.length ? `; duplicate skill names: ${duplicateSkills.join(", ")}; ${upgradeHint()}` : "")
361
+ : "quest skills not found in neutral prompt-input",
362
+ { skills: entries, missing_skills: missingSkills, duplicate_skills: duplicateSkills, skill_paths: paths, skill_roots: roots },
363
+ ));
364
+ }
365
+
366
+ checks.push(nativeAgentsCheck("codex", cwd, env));
367
+
368
+ return { ok: checks.every((c) => c.ok), checks };
369
+ }
370
+
371
+ export function claudeDoctor({ cwd = process.cwd(), env = process.env } = {}) {
372
+ const checks = [];
373
+ const versions = versionInfo();
374
+ const manifestVersion = versions.claude ?? versions.package;
375
+ const presentManifests = [["codex", versions.codex], ["claude", versions.claude]].filter(([, v]) => v != null);
376
+ const absent = ["codex", "claude"].filter((k) => versions[k] == null);
377
+
378
+ checks.push(check(
379
+ "version-sync",
380
+ presentManifests.every(([, v]) => v === versions.package),
381
+ `package=${versions.package}, codex=${versions.codex ?? "absent"}, claude=${versions.claude ?? "absent"}` +
382
+ (absent.length ? ` (${absent.join(", ")} manifest absent — CLI-only install)` : ""),
383
+ { versions },
384
+ ));
385
+
386
+ checks.push(questCliPathCheck(cwd, env, versions));
387
+
388
+ const claudeVersion = runCmd("claude", ["--version"], { cwd, env });
389
+ checks.push(check(
390
+ "claude-cli",
391
+ claudeVersion.status === 0,
392
+ claudeVersion.status === 0 ? claudeVersion.stdout.trim() : (claudeVersion.stderr || claudeVersion.error?.message || "claude not found").trim(),
393
+ ));
394
+
395
+ if (claudeVersion.status === 0) {
396
+ const list = runCmd("claude", ["plugin", "list", "--json"], { cwd, env });
397
+ let installed;
398
+ try {
399
+ const parsed = JSON.parse(list.stdout || "[]");
400
+ const plugins = Array.isArray(parsed) ? parsed : (parsed.installed || []);
401
+ installed = plugins.find((p) => p.id === "quest@quest" || p.pluginId === "quest@quest" || (p.name === "quest" && p.marketplaceName === "quest"));
402
+ } catch {
403
+ installed = null;
404
+ }
405
+ checks.push(check(
406
+ "plugin-installed",
407
+ list.status === 0 && installed?.enabled === true,
408
+ installed ? `quest@quest ${installed.version} enabled=${installed.enabled}` : (list.stderr || "quest@quest not installed").trim(),
409
+ { installed: installed ?? null },
410
+ ));
411
+ checks.push(check(
412
+ "plugin-version",
413
+ Boolean(installed && installed.version === manifestVersion),
414
+ installed
415
+ ? (installed.version === manifestVersion
416
+ ? `installed=${installed.version}, manifest=${manifestVersion}`
417
+ : `installed=${installed.version}, manifest=${manifestVersion}; run \`claude plugin update quest@quest\`, then restart Claude Code`)
418
+ : `manifest=${manifestVersion}; run \`claude plugin install quest@quest\`, then restart Claude Code`,
419
+ ));
420
+ }
421
+
422
+ checks.push(nativeAgentsCheck("claude", cwd, env));
423
+
424
+ return { ok: checks.every((c) => c.ok), checks };
425
+ }
package/lib/config.mjs CHANGED
@@ -16,7 +16,7 @@ export class ConfigError extends Error {
16
16
  export const BUILTIN_DEFAULTS = {
17
17
  worker: "claude",
18
18
  claude: { model: "opus", effort: "xhigh" },
19
- codex: { model: "gpt-5.5", reasoning_effort: "medium" },
19
+ codex: { model: "gpt-5.5", reasoning_effort: "medium", goal_mode: "auto" },
20
20
  max_iterations: 8,
21
21
  priority: "p2",
22
22
  };
package/lib/contract.mjs CHANGED
@@ -79,7 +79,7 @@ export function parseCheckpoints(body) {
79
79
  return checkpoints;
80
80
  }
81
81
 
82
- export function makeCheckpoint({ timestamp, quest_status, iteration, changed, validation_summary, pr, head_sha, failed_approaches, compatible_expansion, note }) {
82
+ export function makeCheckpoint({ timestamp, quest_status, iteration, changed, validation_summary, pr, head_sha, failed_approaches, compatible_expansion, reopen_reason, note }) {
83
83
  if (!QUEST_STATUSES.includes(quest_status)) {
84
84
  throw new ContractError(`quest_status must be one of ${QUEST_STATUSES.join(" | ")} (got "${quest_status}")`, { hint: "store statuses like todo/cancelled are not checkpoint verdicts" });
85
85
  }
@@ -100,6 +100,7 @@ export function makeCheckpoint({ timestamp, quest_status, iteration, changed, va
100
100
  out.push(`- validation_summary: ${oneLine("validation_summary", validation_summary)}`);
101
101
  if (failed_approaches) out.push(`- failed_approaches: ${oneLine("failed_approaches", failed_approaches)}`);
102
102
  if (compatible_expansion) out.push(`- compatible_expansion: ${oneLine("compatible_expansion", compatible_expansion)}`);
103
+ if (reopen_reason) out.push(`- reopen_reason: ${oneLine("reopen_reason", reopen_reason)}`);
103
104
  if (note && String(note).trim() !== "") out.push("", String(note).trim());
104
105
  return out.join("\n");
105
106
  }
@@ -122,11 +123,30 @@ const TRANSITIONS = {
122
123
  export function assertTransition(from, to) {
123
124
  if (!(TRANSITIONS[from] ?? []).includes(to)) {
124
125
  throw new ContractError(`illegal status transition: ${from} → ${to}`, {
125
- hint: from === "complete" || from === "cancelled" ? `${from} is terminal — file a new quest instead` : `legal from "${from}": ${TRANSITIONS[from].join(", ") || "(none)"}`,
126
+ hint:
127
+ from === "complete"
128
+ ? "complete is terminal for checkpoints — use `quest reopen <id> --reason` to legally re-enter the loop"
129
+ : from === "cancelled"
130
+ ? "cancelled is terminal — file a new quest instead"
131
+ : `legal from "${from}": ${(TRANSITIONS[from] ?? []).join(", ") || "(none)"}`,
126
132
  });
127
133
  }
128
134
  }
129
135
 
136
+ // The single legal path from a terminal `complete` status back into the loop.
137
+ // Deliberately NOT part of TRANSITIONS.complete (which stays []): only the
138
+ // `quest reopen` verb calls this, so a stray checkpoint can never resurrect a
139
+ // complete quest. `cancelled` stays fully terminal — file a new quest instead.
140
+ export function assertReopen(from) {
141
+ if (from === "complete") return;
142
+ throw new ContractError(`cannot reopen a ${from} quest — only complete quests are reopenable`, {
143
+ hint:
144
+ from === "cancelled"
145
+ ? "cancelled is terminal — file a new quest instead"
146
+ : `${from} is not terminal; advance it with \`quest checkpoint\` (or \`quest start\` if todo)`,
147
+ });
148
+ }
149
+
130
150
  export function lintRecord({ front, body, checkpoints }, { filename } = {}) {
131
151
  const problems = [];
132
152
  for (const key of REQUIRED_FRONT) {
package/lib/help.mjs CHANGED
@@ -9,12 +9,13 @@ iterations that end in checkpoints; \`quest-run\` drives headless workers.`;
9
9
 
10
10
  export const COMMANDS = {
11
11
  init: {
12
- purpose: "Create a quest store (.quests/) in the current directory",
13
- usage: "quest init [--backend local|github] [--repo owner/name] [--agents-md]",
12
+ purpose: "Create a quest store (.quests/) and install project agent templates",
13
+ usage: "quest init [--backend local|github] [--repo owner/name] [--agents-md] [--no-agents]",
14
14
  flags: [
15
15
  ["--backend <b>", "record storage: local files (default) or github issues"],
16
16
  ["--repo <owner/name>", "required with --backend github"],
17
17
  ["--agents-md", "append a quest orientation section to ./AGENTS.md"],
18
+ ["--no-agents", "skip installing .codex/agents and .claude/agents templates"],
18
19
  ],
19
20
  example: "quest init",
20
21
  },
@@ -51,7 +52,7 @@ export const COMMANDS = {
51
52
  flags: [
52
53
  ["--status <s>", "todo | in_progress | blocked | complete | cancelled"],
53
54
  ["--parent <id>", "children of an epic"],
54
- ["--ready", "todo quests whose depends_on are all complete (dispatch order)"],
55
+ ["--ready", "todo quests whose depends_on are all complete, excluding epics with open children (dispatch order)"],
55
56
  ["--json", "machine-readable output"],
56
57
  ],
57
58
  example: "quest list --ready",
@@ -92,6 +93,12 @@ export const COMMANDS = {
92
93
  flags: [["--reason <why>", "required — recorded in the record"]],
93
94
  example: 'quest cancel 4 --reason "superseded by quest 7"',
94
95
  },
96
+ reopen: {
97
+ purpose: "Reopen a complete quest back into the loop (complete → in_progress)",
98
+ usage: "quest reopen <id> --reason <why>",
99
+ flags: [["--reason <why>", "required — recorded in an audited reopen checkpoint"]],
100
+ example: 'quest reopen 4 --reason "review found npm audit criticals after completion"',
101
+ },
95
102
  edit: {
96
103
  purpose: "Compatibly expand a quest (additions only; anchors are immutable)",
97
104
  usage: "quest edit <id> [--add-done-when D]… [--add-milestone M]… [--add-context C] --rationale <why>",
@@ -130,6 +137,32 @@ export const COMMANDS = {
130
137
  ],
131
138
  example: "quest runs --active",
132
139
  },
140
+ 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]",
143
+ flags: [
144
+ ["doctor", "check Codex CLI, multi-agent support, plugin install/version, hooks, skill roots, and native-agent templates"],
145
+ ["install-agents", "install quest-executor and quest-reviewer as native Codex custom agents"],
146
+ ["--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"],
149
+ ["--json", "machine-readable output"],
150
+ ],
151
+ example: "quest codex install-agents --scope project && quest codex doctor",
152
+ },
153
+ 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]",
156
+ flags: [
157
+ ["doctor", "check Claude CLI, plugin install/version, and native-agent templates"],
158
+ ["install-agents", "install quest-executor and quest-reviewer as native Claude Code custom agents"],
159
+ ["--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"],
162
+ ["--json", "machine-readable output"],
163
+ ],
164
+ example: "quest claude install-agents --scope project && quest claude doctor",
165
+ },
133
166
  };
134
167
 
135
168
  export function renderCommandHelp(name) {
@@ -163,7 +196,7 @@ export function renderStatusOverview({ counts, ready, activeRuns, backend }) {
163
196
  for (const q of ready.slice(0, 3)) lines.push(` ${q.id}. [${q.priority}] ${q.title}`);
164
197
  lines.push("", "Next: `quest show <id>` to read one, or dispatch with `quest-run <id>`.");
165
198
  } else if (!parts.length) {
166
- lines.push("", "Next: create your first quest — see `quest create --help` (or use the /quest:plan skill).");
199
+ lines.push("", "Next: create your first quest — see `quest create --help` (or use the $quest:plan skill).");
167
200
  } else {
168
201
  lines.push("", "Nothing ready to start. `quest list` to see everything.");
169
202
  }
@@ -183,13 +216,15 @@ export function renderNoStore() {
183
216
  ].join("\n");
184
217
  }
185
218
 
186
- export function renderInitNextSteps(backend) {
219
+ export function renderInitNextSteps(backend, { agentsInstalled = true } = {}) {
187
220
  return [
188
221
  "",
189
222
  "Store created. Next steps:",
190
- " 1. Author a quest: quest create --help (or /quest:plan in your agent session)",
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)",
191
225
  " 2. Check it: quest lint --all",
192
- " 3. Work it: /quest:work <id> in-session, or quest-run <id> headless",
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`."]),
193
228
  ...(backend === "github" ? ["", "Records live as GitHub issues; config and amendments stay local in .quests/."] : []),
194
229
  ].join("\n");
195
230
  }