quest-loop 0.1.0 → 0.3.0

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,246 @@
1
+ // Codex-native setup helpers. These are intentionally kept out of the quest
2
+ // store layer: they inspect/install Codex integration files, not quest records.
3
+
4
+ import { existsSync, 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
+
13
+ function readJson(rel) {
14
+ return JSON.parse(readFileSync(join(PLUGIN_ROOT, rel), "utf8"));
15
+ }
16
+
17
+ // Like readJson but returns null instead of throwing. The plugin manifests
18
+ // (.codex-plugin / .claude-plugin) are NOT part of the npm `files` allow-list,
19
+ // so they are absent from a pure `npm install -g quest-loop` install — reading
20
+ // them must never crash `quest --version` or `quest codex doctor`.
21
+ function readJsonSafe(rel) {
22
+ try {
23
+ return readJson(rel);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function runCmd(cmd, args, { cwd, env } = {}) {
30
+ return spawnSync(cmd, args, {
31
+ cwd,
32
+ env,
33
+ encoding: "utf8",
34
+ maxBuffer: 20 * 1024 * 1024,
35
+ });
36
+ }
37
+
38
+ function check(name, ok, detail, extra = {}) {
39
+ return { name, ok: Boolean(ok), detail, ...extra };
40
+ }
41
+
42
+ function projectRoot(cwd, env) {
43
+ const res = runCmd("git", ["rev-parse", "--show-toplevel"], { cwd, env });
44
+ if (res.status === 0 && res.stdout.trim()) return res.stdout.trim();
45
+ return resolve(cwd);
46
+ }
47
+
48
+ function targetDir(scope, cwd, env) {
49
+ if (scope === "user") return join(homedir(), ".codex", "agents");
50
+ if (scope === "project") return join(projectRoot(cwd, env), ".codex", "agents");
51
+ throw new Error(`--scope must be project or user (got "${scope}")`);
52
+ }
53
+
54
+ function installedAgentDirs(cwd, env) {
55
+ return [
56
+ join(projectRoot(cwd, env), ".codex", "agents"),
57
+ join(homedir(), ".codex", "agents"),
58
+ ];
59
+ }
60
+
61
+ function sameFile(path, text) {
62
+ try {
63
+ return readFileSync(path, "utf8") === text;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function extractTexts(promptInputJson) {
70
+ try {
71
+ const items = JSON.parse(promptInputJson);
72
+ const texts = [];
73
+ const walk = (v) => {
74
+ if (Array.isArray(v)) return v.forEach(walk);
75
+ if (!v || typeof v !== "object") return;
76
+ if (typeof v.text === "string") texts.push(v.text);
77
+ if (typeof v.content === "string") texts.push(v.content);
78
+ for (const child of Object.values(v)) walk(child);
79
+ };
80
+ walk(items);
81
+ return texts.join("\n");
82
+ } catch {
83
+ return promptInputJson;
84
+ }
85
+ }
86
+
87
+ function questSkillEntries(promptInputJson) {
88
+ const text = extractTexts(promptInputJson);
89
+ const entries = new Map();
90
+ const re = /- quest:(plan|work|orchestrate|retro|protocol|setup):[^\n]+\(file: ([^)]+)\)/g;
91
+ for (const m of text.matchAll(re)) entries.set(m[1], m[2]);
92
+ return [...entries.entries()].map(([name, path]) => ({ name, path })).sort((a, b) => a.name.localeCompare(b.name));
93
+ }
94
+
95
+ function pathRootForQuestSkill(path) {
96
+ // Match POSIX `/skills/` and Windows `\skills\` so the neutral-root check
97
+ // does not falsely split every skill path into its own root on Windows.
98
+ const i = path.search(/[/\\]skills[/\\]/);
99
+ return i === -1 ? path : path.slice(0, i);
100
+ }
101
+
102
+ export function versionInfo() {
103
+ // package.json is always shipped; the plugin manifests may be absent on a
104
+ // CLI-only npm install, so they are read tolerantly.
105
+ const pkg = readJson("package.json");
106
+ const codexManifest = readJsonSafe(".codex-plugin/plugin.json");
107
+ const claudeManifest = readJsonSafe(".claude-plugin/plugin.json");
108
+ return {
109
+ package: pkg.version,
110
+ codex: codexManifest?.version ?? null,
111
+ claude: claudeManifest?.version ?? null,
112
+ };
113
+ }
114
+
115
+ export function installAgents({ scope = "project", dryRun = false, force = false, cwd = process.cwd(), env = process.env } = {}) {
116
+ const dir = targetDir(scope, cwd, env);
117
+
118
+ // Plan every agent first; only touch disk once the whole set is known to be
119
+ // conflict-free. A mixed create+conflict run must not leave a partial install.
120
+ const plans = AGENTS.map((name) => {
121
+ const src = join(PLUGIN_ROOT, "agents", `${name}.toml`);
122
+ const dest = join(dir, `${name}.toml`);
123
+ const text = readFileSync(src, "utf8");
124
+ const exists = existsSync(dest);
125
+ let action;
126
+ if (exists && sameFile(dest, text)) action = "unchanged";
127
+ else if (exists && !force) action = "conflict";
128
+ else action = exists ? "replace" : "create";
129
+ return { name, path: dest, action, text };
130
+ });
131
+
132
+ const conflicts = plans.filter((p) => p.action === "conflict").map(({ name, path }) => ({ name, path }));
133
+ const actions = plans.map(({ name, path, action }) => ({ name, path, action }));
134
+ const ok = conflicts.length === 0;
135
+
136
+ if (ok && !dryRun) {
137
+ for (const p of plans) {
138
+ if (p.action === "unchanged") continue;
139
+ mkdirSync(dir, { recursive: true });
140
+ writeFileSync(p.path, p.text);
141
+ }
142
+ }
143
+
144
+ return { ok, scope, target: dir, dry_run: dryRun, actions, conflicts };
145
+ }
146
+
147
+ export function doctor({ cwd = process.cwd(), env = process.env } = {}) {
148
+ const checks = [];
149
+ const versions = versionInfo();
150
+ // package.json ships everywhere; the plugin manifests may be absent on a
151
+ // CLI-only npm install. Treat absent manifests as "not applicable" rather than
152
+ // a hard mismatch, and fall back to the package version as the expected plugin
153
+ // version so a legitimate CLI-only doctor run is not spuriously red.
154
+ const manifestVersion = versions.codex ?? versions.package;
155
+ const presentManifests = [["codex", versions.codex], ["claude", versions.claude]].filter(([, v]) => v != null);
156
+ const absent = ["codex", "claude"].filter((k) => versions[k] == null);
157
+
158
+ checks.push(check(
159
+ "version-sync",
160
+ presentManifests.every(([, v]) => v === versions.package),
161
+ `package=${versions.package}, codex=${versions.codex ?? "absent"}, claude=${versions.claude ?? "absent"}` +
162
+ (absent.length ? ` (${absent.join(", ")} manifest absent — CLI-only install)` : ""),
163
+ { versions },
164
+ ));
165
+
166
+ const codexVersion = runCmd("codex", ["--version"], { cwd, env });
167
+ checks.push(check(
168
+ "codex-cli",
169
+ codexVersion.status === 0,
170
+ codexVersion.status === 0 ? codexVersion.stdout.trim() : (codexVersion.stderr || codexVersion.error?.message || "codex not found").trim(),
171
+ ));
172
+
173
+ if (codexVersion.status === 0) {
174
+ const list = runCmd("codex", ["plugin", "list", "--json"], { cwd, env });
175
+ let installed;
176
+ try {
177
+ const parsed = JSON.parse(list.stdout || "{}");
178
+ installed = (parsed.installed || []).find((p) => p.pluginId === "quest@quest" || (p.name === "quest" && p.marketplaceName === "quest"));
179
+ } catch {
180
+ installed = null;
181
+ }
182
+ checks.push(check(
183
+ "plugin-installed",
184
+ list.status === 0 && installed?.enabled === true,
185
+ installed ? `quest@quest ${installed.version} enabled=${installed.enabled}` : (list.stderr || "quest@quest not installed").trim(),
186
+ { installed: installed ?? null },
187
+ ));
188
+ checks.push(check(
189
+ "plugin-version",
190
+ Boolean(installed && installed.version === manifestVersion),
191
+ installed ? `installed=${installed.version}, manifest=${manifestVersion}` : `manifest=${manifestVersion}`,
192
+ ));
193
+
194
+ const debug = runCmd("codex", ["debug", "prompt-input", "noop"], { cwd: tmpdir(), env });
195
+ // Require a hook-parse *problem*, not merely the word "hook" next to "warnings".
196
+ // Matches both orderings ("hook parse warning" / "failed to parse hook") while
197
+ // ignoring benign summaries like "loaded 4 hooks, 0 warnings".
198
+ 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 || "");
199
+ checks.push(check(
200
+ "hook-parser",
201
+ debug.status === 0 && !hookWarning,
202
+ debug.status === 0 ? (hookWarning ? debug.stderr.trim() : "no hook parse warnings") : (debug.stderr || "codex debug failed").trim(),
203
+ ));
204
+
205
+ const entries = debug.status === 0 ? questSkillEntries(debug.stdout) : [];
206
+ const requiredSkills = ["plan", "work", "orchestrate", "retro", "protocol"];
207
+ const missingSkills = requiredSkills.filter((name) => !entries.some((entry) => entry.name === name));
208
+ const paths = entries.map((entry) => entry.path);
209
+ const roots = [...new Set(paths.map(pathRootForQuestSkill))];
210
+ checks.push(check(
211
+ "single-neutral-skill-root",
212
+ missingSkills.length === 0 && roots.length === 1,
213
+ paths.length ? `${entries.length} quest skills across ${roots.length} root(s)` : "quest skills not found in neutral prompt-input",
214
+ { skills: entries, missing_skills: missingSkills, skill_paths: paths, skill_roots: roots },
215
+ ));
216
+ }
217
+
218
+ const agentDirs = installedAgentDirs(cwd, env);
219
+ const missing = [];
220
+ const stale = [];
221
+ const found = {};
222
+ for (const name of AGENTS) {
223
+ const bundled = readFileSync(join(PLUGIN_ROOT, "agents", `${name}.toml`), "utf8");
224
+ const path = agentDirs.map((dir) => join(dir, `${name}.toml`)).find((p) => existsSync(p));
225
+ if (!path) {
226
+ missing.push(name);
227
+ continue;
228
+ }
229
+ found[name] = path;
230
+ // Existence alone can mask a stale user-scope copy shadowing an out-of-date
231
+ // project install — compare against the bundled definition.
232
+ if (!sameFile(path, bundled)) stale.push(name);
233
+ }
234
+ const agentDetail = [
235
+ missing.length ? `missing: ${missing.join(", ")}` : null,
236
+ stale.length ? `stale (run install-agents --force): ${stale.join(", ")}` : null,
237
+ ].filter(Boolean).join("; ") || `found: ${Object.values(found).join(", ")}`;
238
+ checks.push(check(
239
+ "native-agents",
240
+ missing.length === 0 && stale.length === 0,
241
+ agentDetail,
242
+ { found, missing, stale },
243
+ ));
244
+
245
+ return { ok: checks.every((c) => c.ok), checks };
246
+ }
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
@@ -51,7 +51,7 @@ export const COMMANDS = {
51
51
  flags: [
52
52
  ["--status <s>", "todo | in_progress | blocked | complete | cancelled"],
53
53
  ["--parent <id>", "children of an epic"],
54
- ["--ready", "todo quests whose depends_on are all complete (dispatch order)"],
54
+ ["--ready", "todo quests whose depends_on are all complete, excluding epics with open children (dispatch order)"],
55
55
  ["--json", "machine-readable output"],
56
56
  ],
57
57
  example: "quest list --ready",
@@ -92,6 +92,12 @@ export const COMMANDS = {
92
92
  flags: [["--reason <why>", "required — recorded in the record"]],
93
93
  example: 'quest cancel 4 --reason "superseded by quest 7"',
94
94
  },
95
+ reopen: {
96
+ purpose: "Reopen a complete quest back into the loop (complete → in_progress)",
97
+ usage: "quest reopen <id> --reason <why>",
98
+ flags: [["--reason <why>", "required — recorded in an audited reopen checkpoint"]],
99
+ example: 'quest reopen 4 --reason "review found npm audit criticals after completion"',
100
+ },
95
101
  edit: {
96
102
  purpose: "Compatibly expand a quest (additions only; anchors are immutable)",
97
103
  usage: "quest edit <id> [--add-done-when D]… [--add-milestone M]… [--add-context C] --rationale <why>",
@@ -130,6 +136,19 @@ export const COMMANDS = {
130
136
  ],
131
137
  example: "quest runs --active",
132
138
  },
139
+ codex: {
140
+ purpose: "Inspect or install Quest's native Codex integration",
141
+ usage: "quest codex doctor [--json] | quest codex install-agents [--scope project|user] [--dry-run] [--force] [--json]",
142
+ flags: [
143
+ ["doctor", "check Codex CLI, plugin install/version, hooks, skill roots, and native agents"],
144
+ ["install-agents", "install quest-executor and quest-reviewer as native Codex custom agents"],
145
+ ["--scope <s>", "project (default: .codex/agents at repo root) or user (~/.codex/agents)"],
146
+ ["--dry-run", "show intended agent writes without changing files"],
147
+ ["--force", "replace existing agent files"],
148
+ ["--json", "machine-readable output"],
149
+ ],
150
+ example: "quest codex install-agents --scope project && quest codex doctor",
151
+ },
133
152
  };
134
153
 
135
154
  export function renderCommandHelp(name) {
@@ -163,7 +182,7 @@ export function renderStatusOverview({ counts, ready, activeRuns, backend }) {
163
182
  for (const q of ready.slice(0, 3)) lines.push(` ${q.id}. [${q.priority}] ${q.title}`);
164
183
  lines.push("", "Next: `quest show <id>` to read one, or dispatch with `quest-run <id>`.");
165
184
  } else if (!parts.length) {
166
- lines.push("", "Next: create your first quest — see `quest create --help` (or use the /quest:plan skill).");
185
+ lines.push("", "Next: create your first quest — see `quest create --help` (or use the $quest:plan skill).");
167
186
  } else {
168
187
  lines.push("", "Nothing ready to start. `quest list` to see everything.");
169
188
  }
@@ -187,9 +206,9 @@ export function renderInitNextSteps(backend) {
187
206
  return [
188
207
  "",
189
208
  "Store created. Next steps:",
190
- " 1. Author a quest: quest create --help (or /quest:plan in your agent session)",
209
+ " 1. Author a quest: quest create --help (or $quest:plan in your agent session)",
191
210
  " 2. Check it: quest lint --all",
192
- " 3. Work it: /quest:work <id> in-session, or quest-run <id> headless",
211
+ " 3. Work it: $quest:work <id> in-session, or quest-run <id> headless",
193
212
  ...(backend === "github" ? ["", "Records live as GitHub issues; config and amendments stay local in .quests/."] : []),
194
213
  ].join("\n");
195
214
  }
package/lib/runner.mjs CHANGED
@@ -34,10 +34,11 @@ class RunUsageError extends Error {
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
36
  const HELP = {
37
- purpose: "Drive a headless Claude or Codex worker through a quest in native goal mode.",
37
+ purpose: "Drive a headless Claude or Codex worker through a quest with checkpoint-based stop checks.",
38
38
  usage: [
39
39
  "quest-run <id> [--worker claude|codex] [--model M] [--effort E]",
40
40
  " [--max-iterations N] [--max-cost USD] [--codex-sandbox MODE]",
41
+ " [--codex-goal-mode auto|require|off]",
41
42
  " [--continue-session] [--notify '<cmd>'] [--dry-run] [--json]",
42
43
  "quest-run --ready [--parallel N] [--isolate worktree] [--dry-run] [--json]",
43
44
  ],
@@ -51,6 +52,7 @@ const HELP = {
51
52
  ["--max-tokens <n>", "token cap; governs codex spend since USD is unreported → blocked, exit 11"],
52
53
  ["--session-timeout <s>", "per-session wall-clock cap (seconds); a hung worker is killed and the session counts as a stall (default 1800; config defaults.session_timeout)"],
53
54
  ["--codex-sandbox <mode>", "codex exec sandbox: read-only | workspace-write | danger-full-access (default workspace-write; config defaults.codex.sandbox). workspace-write BLOCKS git commits — commit quests need danger-full-access"],
55
+ ["--codex-goal-mode <mode>", "codex goal-tool policy: auto | require | off (default auto; config defaults.codex.goal_mode)"],
54
56
  ["--continue-session", "resume the previous session each iteration instead of a fresh one"],
55
57
  ["--notify <cmd>", "shell command run on run end (env: QUEST_ID, QUEST_TITLE, FINAL_STATUS, ITERATIONS, COST)"],
56
58
  ["--dry-run", "print the exact worker invocation and exit without spawning"],
@@ -68,6 +70,8 @@ const HELP = {
68
70
  "codex's default --codex-sandbox workspace-write write-protects .git, so a codex worker",
69
71
  "cannot `git commit` under it; a commit-requiring quest must opt into danger-full-access —",
70
72
  "an explicit tradeoff (full disk + network access), never escalated silently.",
73
+ "codex goal tools are optional by default; --codex-goal-mode require blocks honestly if",
74
+ "the exec surface does not expose or invoke them.",
71
75
  "Without --isolate, parallel quests are assumed file-disjoint (no locking beyond the store's).",
72
76
  "--isolate worktree leaves the worktree + quest/<id>-<slug> branch in place; the PR is the merge path.",
73
77
  ],
@@ -203,7 +207,11 @@ function resolveOptions(front, config, flags) {
203
207
  if ((flags["codex-sandbox"] != null || worker === "codex") && !CODEX_SANDBOX_MODES.includes(codexSandbox)) {
204
208
  throw new RunUsageError(`--codex-sandbox must be one of ${CODEX_SANDBOX_MODES.join(", ")} (got "${codexSandbox}")`);
205
209
  }
206
- return { worker, model, effort, maxIterations, maxCost, maxTokens, sessionTimeout, codexSandbox };
210
+ const codexGoalMode = flags["codex-goal-mode"] ?? config.defaults.codex?.goal_mode ?? "auto";
211
+ if ((flags["codex-goal-mode"] != null || worker === "codex") && !["auto", "require", "off"].includes(codexGoalMode)) {
212
+ throw new RunUsageError(`--codex-goal-mode must be one of auto, require, off (got "${codexGoalMode}")`);
213
+ }
214
+ return { worker, model, effort, maxIterations, maxCost, maxTokens, sessionTimeout, codexSandbox, codexGoalMode };
207
215
  }
208
216
 
209
217
  function lastSessionIdFor(storeDir, questId, worker) {
@@ -273,6 +281,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
273
281
  schemaPath: SCHEMA_PATH,
274
282
  runStartIso,
275
283
  codexSandbox: opts.codexSandbox,
284
+ codexGoalMode: opts.codexGoalMode,
276
285
  };
277
286
  const inv = adapter.buildInvocation(initial, config, invOpts);
278
287
  if (flags.json) {
@@ -301,6 +310,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
301
310
  if (initial.status === "complete") {
302
311
  finalStatus = "complete";
303
312
  exitCode = 0;
313
+ io.errOut(`quest-run: quest ${id} is already complete — nothing to run. To legally re-enter the loop, run \`quest reopen ${id} --reason "<why>"\` first (never hand-edit the status line).`);
304
314
  } else if (initial.status === "cancelled") {
305
315
  finalStatus = "cancelled";
306
316
  exitCode = 0;
@@ -394,6 +404,7 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
394
404
  schemaPath: SCHEMA_PATH,
395
405
  runStartIso,
396
406
  codexSandbox: opts.codexSandbox,
407
+ codexGoalMode: opts.codexGoalMode,
397
408
  },
398
409
  runStartIso,
399
410
  iteration: sessionsRun,
@@ -428,6 +439,8 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
428
439
  // landed before the kill — a terminal status is still caught at the top
429
440
  // of the next iteration, so completions are never lost.
430
441
  const progressed = newCheckpoint && !sessionTimedOut;
442
+ // Journal the session BEFORE any terminal break so `quest runs` telemetry
443
+ // (iteration count, cost, tokens) is never dropped for the final session.
431
444
  local.appendRunEvent(storeDir, {
432
445
  event: "iteration_finished",
433
446
  run_id: runId,
@@ -441,6 +454,25 @@ async function runQuest(storeDir, config, id, flags, io, { cwd } = {}) {
441
454
  status_after: after ? after.status : null,
442
455
  });
443
456
 
457
+ // Goal-mode `require`: block whenever create_goal was required but never
458
+ // observed and the quest is still OPEN — a milestone checkpoint does not
459
+ // satisfy the requirement. A genuinely complete/blocked/cancelled quest is
460
+ // respected (its evidence trail stands on its own). A LOST record (`!after`)
461
+ // is not "open": let the missing-record path at the loop top surface it.
462
+ const questOpen = after && (after.status === "in_progress" || after.status === "todo");
463
+ if (opts.worker === "codex" && result.goalToolMissingRequired && questOpen) {
464
+ await recordBlocked(
465
+ storeDir,
466
+ env,
467
+ id,
468
+ "runner: codex goal tools were required but no create_goal tool call was observed",
469
+ "runner codex goal-mode enforcement",
470
+ );
471
+ finalStatus = "blocked";
472
+ exitCode = 10;
473
+ break;
474
+ }
475
+
444
476
  if (progressed) {
445
477
  stall = 0;
446
478
  if (after.status === "complete") {
@@ -538,6 +570,7 @@ async function runReady(storeDir, config, flags, io) {
538
570
 
539
571
  const summaries = [];
540
572
  const done = new Set();
573
+ const skippedEpics = new Set();
541
574
  const inFlight = new Map();
542
575
 
543
576
  const readyIds = async () => {
@@ -550,6 +583,22 @@ async function runReady(storeDir, config, flags, io) {
550
583
  }
551
584
  };
552
585
 
586
+ // Ids that at least one quest names as its parent — i.e. epics. Epics are
587
+ // closed by the orchestrator inline (verify children, run the epic validation
588
+ // loop, checkpoint), never dispatched to a worker. readyQuests already gates
589
+ // out epics with open children; this catches the fully-verified epic that has
590
+ // become "ready" so --ready still refuses to burn a worker run on it. A
591
+ // direct `quest-run <id>` on an epic stays allowed.
592
+ const epicIds = async () => {
593
+ const { stdout, code } = await questCli(storeDir, io.env, ["list", "--json"]);
594
+ if (code !== 0) return new Set();
595
+ try {
596
+ return new Set(JSON.parse(stdout).filter((q) => q.parent !== undefined).map((q) => q.parent));
597
+ } catch {
598
+ return new Set();
599
+ }
600
+ };
601
+
553
602
  const startOne = (id) => {
554
603
  const promise = (async () => {
555
604
  let cwd = io.cwd;
@@ -570,8 +619,14 @@ async function runReady(storeDir, config, flags, io) {
570
619
  };
571
620
 
572
621
  for (;;) {
573
- const ready = (await readyIds()).filter((id) => !done.has(id) && !inFlight.has(id));
622
+ const epics = await epicIds();
623
+ const ready = (await readyIds()).filter((id) => !done.has(id) && !inFlight.has(id) && !skippedEpics.has(id));
574
624
  for (const id of ready) {
625
+ if (epics.has(id)) {
626
+ skippedEpics.add(id);
627
+ io.errOut(`quest-run: quest ${id} is an epic (other quests name it as parent) — refusing to auto-dispatch it. Close it inline per $quest:orchestrate: verify its children, run the epic validation loop, then \`quest checkpoint ${id} --status complete\`. Never burn a worker run on an epic.`);
628
+ continue;
629
+ }
575
630
  if (inFlight.size >= parallel) break;
576
631
  startOne(id);
577
632
  }
@@ -626,6 +681,7 @@ export async function run(argv, io = {}) {
626
681
  "max-tokens": { type: "string" },
627
682
  "session-timeout": { type: "string" },
628
683
  "codex-sandbox": { type: "string" },
684
+ "codex-goal-mode": { type: "string" },
629
685
  "continue-session": { type: "boolean" },
630
686
  notify: { type: "string" },
631
687
  "dry-run": { type: "boolean" },
@@ -20,6 +20,7 @@ import {
20
20
  ContractError,
21
21
  appendToSection,
22
22
  appendUnderCheckpoints,
23
+ assertReopen,
23
24
  assertTransition,
24
25
  lintRecord,
25
26
  makeCheckpoint,
@@ -267,12 +268,19 @@ export function listQuests(repo, env) {
267
268
  });
268
269
  }
269
270
 
270
- // Same readiness rule as store-local.readyQuests, over the remote list.
271
+ // Same readiness rule as store-local.readyQuests, over the remote list
272
+ // including the epic gate: a quest with any non-terminal child (a quest whose
273
+ // parent points at it, not in complete/cancelled) is never ready. Epics are
274
+ // closed by the orchestrator inline, never auto-dispatched; a cancelled child
275
+ // is terminal so it cannot wedge the epic forever.
271
276
  export function readyQuests(repo, env) {
272
277
  const all = listQuests(repo, env);
273
278
  const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
279
+ const openParents = new Set(
280
+ all.filter((q) => q.parent !== undefined && q.status !== "complete" && q.status !== "cancelled").map((q) => q.parent),
281
+ );
274
282
  return all
275
- .filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
283
+ .filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
276
284
  .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
277
285
  }
278
286
 
@@ -372,10 +380,34 @@ export function cancelQuest(repo, id, reason, env) {
372
380
  return { front: { ...rec.front, status: "cancelled", updated } };
373
381
  }
374
382
 
383
+ // Comment-first, mirroring cancelQuest's operation order: post the audited
384
+ // reopen checkpoint, refresh the meta timestamp, then swap the label
385
+ // (quest:complete → quest:in-progress) and reopen the issue via applyStatus.
386
+ export function reopenQuest(repo, id, reason, env) {
387
+ if (!reason || !reason.trim()) throw new ContractError("reopen requires --reason", { hint: 'example: quest reopen 4 --reason "review found npm audit criticals"' });
388
+ const rec = reconstruct(viewIssue(repo, id, env));
389
+ assertReopen(rec.front.status);
390
+ const block = makeCheckpoint({
391
+ timestamp: nowIso(),
392
+ quest_status: "in_progress",
393
+ iteration: rec.checkpoints.length + 1,
394
+ changed: "reopened from complete",
395
+ validation_summary: "reopened for further work; no execution this entry",
396
+ reopen_reason: reason.trim(),
397
+ });
398
+ gh(["issue", "comment", String(id), "--repo", repo, "--body-file", "-"], { env, input: block });
399
+ const updated = nowIso();
400
+ writeStoredBody(repo, id, { ...rec.meta, updated }, rec.storedSections, env);
401
+ applyStatus(repo, id, rec.labels, rec.state, "in_progress", env);
402
+ return { front: { ...rec.front, status: "in_progress", updated }, checkpoints: [...rec.checkpoints, ...parseCheckpoints(block)] };
403
+ }
404
+
375
405
  export function editQuest(repo, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }, env) {
376
406
  if (!rationale || !rationale.trim()) throw new ContractError("edit requires --rationale (scope changes are recorded, per protocol)", { hint: "state why this is a compatible expansion of the Objective" });
377
407
  if (!addDoneWhen.length && !addMilestone.length && !addContext) throw new ContractError("nothing to add — pass --add-done-when, --add-milestone, or --add-context", { hint: "the Objective and existing Done-when items are immutable by design" });
378
408
  const rec = reconstruct(viewIssue(repo, id, env));
409
+ if (rec.front.status === "complete") throw new ContractError("cannot edit a complete quest", { hint: "reopen it first with `quest reopen <id> --reason`, then edit — or file a new quest" });
410
+ if (rec.front.status === "cancelled") throw new ContractError("cannot edit a cancelled quest", { hint: "cancelled is terminal — file a new quest instead" });
379
411
  let sections = rec.storedSections;
380
412
  for (const item of addDoneWhen) sections = appendToSection(sections, "## Done when", `- [ ] ${item}`);
381
413
  for (const item of addMilestone) sections = appendToSection(sections, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, statSync, appendFileSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
- import { ContractError, appendToSection, appendUnderCheckpoints, assertTransition, lintRecord, makeCheckpoint, nowIso, parseRecord, recordFilename, renderBody, serializeRecord, CHECKPOINT_MARKER } from "./contract.mjs";
7
+ import { ContractError, appendToSection, appendUnderCheckpoints, assertReopen, assertTransition, lintRecord, makeCheckpoint, nowIso, parseRecord, recordFilename, renderBody, serializeRecord, CHECKPOINT_MARKER } from "./contract.mjs";
8
8
 
9
9
  export class NotFoundError extends Error {
10
10
  constructor(id) {
@@ -85,8 +85,15 @@ export function listQuests(storeDir) {
85
85
  export function readyQuests(storeDir) {
86
86
  const all = listQuests(storeDir);
87
87
  const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
88
+ // Epic gate: a quest with any non-terminal child (a quest whose parent points
89
+ // at it, not in complete/cancelled) is never ready. Epics are closed by the
90
+ // orchestrator inline (see $quest:orchestrate), never auto-dispatched. A
91
+ // cancelled child is terminal so it cannot wedge the epic forever.
92
+ const openParents = new Set(
93
+ all.filter((q) => q.parent !== undefined && q.status !== "complete" && q.status !== "cancelled").map((q) => q.parent),
94
+ );
88
95
  return all
89
- .filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
96
+ .filter((q) => q.status === "todo" && !openParents.has(q.id) && (q.depends_on ?? []).every((d) => done.has(d)))
90
97
  .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
91
98
  }
92
99
 
@@ -162,10 +169,33 @@ export function cancelQuest(storeDir, id, reason) {
162
169
  });
163
170
  }
164
171
 
172
+ // The audited path from complete back into the loop (mirrors cancelQuest, but
173
+ // flips complete → in_progress and appends a real checkpoint carrying the
174
+ // reopen_reason). Uses assertReopen, never assertTransition — a checkpoint can
175
+ // never resurrect a complete quest; only this verb can.
176
+ export function reopenQuest(storeDir, id, reason) {
177
+ if (!reason || !reason.trim()) throw new ContractError("reopen requires --reason", { hint: 'example: quest reopen 4 --reason "review found npm audit criticals"' });
178
+ return updateQuest(storeDir, id, (q) => {
179
+ assertReopen(q.front.status);
180
+ const block = makeCheckpoint({
181
+ timestamp: nowIso(),
182
+ quest_status: "in_progress",
183
+ iteration: q.checkpoints.length + 1,
184
+ changed: "reopened from complete",
185
+ validation_summary: "reopened for further work; no execution this entry",
186
+ reopen_reason: reason.trim(),
187
+ });
188
+ const body = appendUnderCheckpoints(q.body, block);
189
+ return { front: { ...q.front, status: "in_progress" }, body };
190
+ });
191
+ }
192
+
165
193
  export function editQuest(storeDir, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }) {
166
194
  if (!rationale || !rationale.trim()) throw new ContractError("edit requires --rationale (scope changes are recorded, per protocol)", { hint: "state why this is a compatible expansion of the Objective" });
167
195
  if (!addDoneWhen.length && !addMilestone.length && !addContext) throw new ContractError("nothing to add — pass --add-done-when, --add-milestone, or --add-context", { hint: "the Objective and existing Done-when items are immutable by design" });
168
196
  return updateQuest(storeDir, id, (q) => {
197
+ if (q.front.status === "complete") throw new ContractError("cannot edit a complete quest", { hint: "reopen it first with `quest reopen <id> --reason`, then edit — or file a new quest" });
198
+ if (q.front.status === "cancelled") throw new ContractError("cannot edit a cancelled quest", { hint: "cancelled is terminal — file a new quest instead" });
169
199
  let body = q.body;
170
200
  for (const item of addDoneWhen) body = appendToSection(body, "## Done when", `- [ ] ${item}`);
171
201
  for (const item of addMilestone) body = appendToSection(body, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
package/lib/store.mjs CHANGED
@@ -18,6 +18,7 @@ export function openStore(config, ctx = {}) {
18
18
  startQuest: (id) => github.startQuest(repo, id, env),
19
19
  appendCheckpoint: (id, cp) => github.appendCheckpoint(repo, id, cp, env),
20
20
  cancelQuest: (id, reason) => github.cancelQuest(repo, id, reason, env),
21
+ reopenQuest: (id, reason) => github.reopenQuest(repo, id, reason, env),
21
22
  editQuest: (id, changes) => github.editQuest(repo, id, changes, env),
22
23
  lintAll: () => github.lintAll(repo, env),
23
24
  };
@@ -31,6 +32,7 @@ export function openStore(config, ctx = {}) {
31
32
  startQuest: (id) => local.startQuest(dir, id),
32
33
  appendCheckpoint: (id, cp) => local.appendCheckpoint(dir, id, cp),
33
34
  cancelQuest: (id, reason) => local.cancelQuest(dir, id, reason),
35
+ reopenQuest: (id, reason) => local.reopenQuest(dir, id, reason),
34
36
  editQuest: (id, changes) => local.editQuest(dir, id, changes),
35
37
  lintAll: () => local.lintAll(dir),
36
38
  };