quest-loop 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## [0.3.5] — 2026-07-08
10
+
11
+ ### Added
12
+ - `quest codex doctor --fix` and `quest claude doctor --fix` now repair
13
+ Quest-owned native-agent templates and provider plugin install/version state,
14
+ then rerun the same doctor checks.
15
+ - `quest codex open` and `quest claude open` now run the provider health gate
16
+ before launching the interactive provider from the project root.
17
+
18
+ ### Changed
19
+ - Stale Quest agent templates are replaced without `--force`; unrelated custom
20
+ files still require explicit replacement.
21
+
22
+ ## [0.3.4] — 2026-07-08
23
+
24
+ ### Changed
25
+ - `quest codex install-agents` and `quest claude install-agents` now allow
26
+ `--force` to write through symlinked agent template files, while continuing to
27
+ refuse symlinked provider agent directories.
28
+
29
+ ### Added
30
+ - Added a Quest plan-exit reminder hook that nudges accepted `$quest:plan`
31
+ handoffs toward `$quest:orchestrate` rather than direct implementation.
32
+
9
33
  ## [0.3.3] — 2026-07-08
10
34
 
11
35
  ### Changed
package/README.md CHANGED
@@ -57,8 +57,8 @@ Install Quest's project-scoped Claude agent templates and verify the local
57
57
  Claude setup with:
58
58
 
59
59
  ```bash
60
- quest claude install-agents --scope project
61
- quest claude doctor
60
+ quest claude doctor --fix
61
+ quest claude open
62
62
  ```
63
63
 
64
64
  For local development against a checkout, point Claude Code at the repo directly
@@ -84,16 +84,19 @@ Verify the install with:
84
84
 
85
85
  ```bash
86
86
  codex plugin list --marketplace quest
87
- quest codex install-agents --scope project
88
- quest codex doctor
87
+ quest codex doctor --fix
88
+ quest codex open
89
89
  codex debug prompt-input "noop"
90
90
  ```
91
91
 
92
- `quest codex doctor` checks the installed plugin version, Codex `multi_agent`
93
- support, hook parser, neutral skill roots, and installed native-agent templates.
94
- `codex debug prompt-input "noop"` should not print any hook parse warnings.
92
+ `quest codex doctor --fix` and `quest claude doctor --fix` install or refresh
93
+ Quest-owned native-agent templates and repair the provider plugin install/version
94
+ when the provider CLI supports it, then rerun doctor. `quest codex open` and
95
+ `quest claude open` run the same health gate before launching the interactive
96
+ provider. `codex debug prompt-input "noop"` should not print any hook parse
97
+ warnings.
95
98
 
96
- #### Updating the Codex plugin
99
+ #### Updating provider plugins
97
100
 
98
101
  Codex installs plugins from a marketplace snapshot. Pulling this Git repo or
99
102
  publishing a new tag is not enough to update the already-installed plugin cache.
@@ -103,10 +106,18 @@ Refresh the marketplace snapshot, then reinstall the plugin from it:
103
106
  codex plugin marketplace upgrade quest
104
107
  codex plugin add quest@quest
105
108
  codex plugin list --marketplace quest
106
- quest codex doctor
109
+ quest codex doctor --fix
107
110
  ```
108
111
 
109
- Then start a new Codex thread. If the update contains hook changes, re-run:
112
+ Claude updates through its plugin manager:
113
+
114
+ ```bash
115
+ claude plugin update quest@quest
116
+ quest claude doctor --fix
117
+ ```
118
+
119
+ Then start a new provider session. If the Codex update contains hook changes,
120
+ re-run:
110
121
 
111
122
  ```bash
112
123
  codex debug prompt-input "noop"
@@ -145,12 +156,17 @@ both providers:
145
156
  - `.claude/agents/quest-executor.md`
146
157
  - `.claude/agents/quest-reviewer.md`
147
158
 
148
- Use `quest init --no-agents` when you only want the `.quests/` store. If an
149
- existing project agent template would be replaced, init fails before creating
159
+ Use `quest init --no-agents` when you only want the `.quests/` store. Stale
160
+ Quest-owned agent templates are replaced automatically. If an existing file is
161
+ not recognizable as a Quest agent template, init fails before creating
150
162
  `.quests/`; inspect the conflicting files, run the explicit provider install
151
163
  command with `--force` only if you intend to replace them, then rerun
152
164
  `quest init`.
153
165
 
166
+ Agent template files may be symlinks; when you use `--force`, the install writes
167
+ through those file symlinks. Keep `.codex/agents` and `.claude/agents` as real
168
+ directories rather than symlinking the whole directory.
169
+
154
170
  Project-scoped agent templates install at the Git repository root. For a nested
155
171
  quest store, run `quest init --no-agents` in the nested directory and set
156
172
  `QUEST_DIR` for agents launched from elsewhere, or initialize from the repo root.
@@ -234,8 +250,8 @@ full base protocol lives in
234
250
  | `quest amend` | Append a numbered protocol amendment (retro output) |
235
251
  | `quest protocol` | Print the loop protocol + this store's local amendments |
236
252
  | `quest runs` | Show headless runner activity (from `.quests/runs.ndjson`) |
237
- | `quest codex` | Validate Codex-native setup and install Codex native agent templates |
238
- | `quest claude` | Validate Claude-native setup and install Claude native agent templates |
253
+ | `quest codex` | Validate, repair, install, or open Codex-native setup |
254
+ | `quest claude` | Validate, repair, install, or open Claude-native setup |
239
255
 
240
256
  ## quest-run (headless runner)
241
257
 
package/hooks/hooks.json CHANGED
@@ -62,6 +62,33 @@
62
62
  }
63
63
  ]
64
64
  }
65
+ ],
66
+ "PostToolUse": [
67
+ {
68
+ "matcher": "ExitPlanMode",
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": "root=\"${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}\"; [ -n \"$root\" ] && node \"$root/hooks/plan-exit-reminder.mjs\"",
73
+ "commandWindows": "if defined CODEX_PLUGIN_ROOT (node \"%CODEX_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\") else if defined CLAUDE_PLUGIN_ROOT (node \"%CLAUDE_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\")",
74
+ "timeout": 10,
75
+ "statusMessage": "Preparing quest orchestration reminder"
76
+ }
77
+ ]
78
+ }
79
+ ],
80
+ "Stop": [
81
+ {
82
+ "hooks": [
83
+ {
84
+ "type": "command",
85
+ "command": "root=\"${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}\"; [ -n \"$root\" ] && node \"$root/hooks/plan-exit-reminder.mjs\"",
86
+ "commandWindows": "if defined CODEX_PLUGIN_ROOT (node \"%CODEX_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\") else if defined CLAUDE_PLUGIN_ROOT (node \"%CLAUDE_PLUGIN_ROOT%\\hooks\\plan-exit-reminder.mjs\")",
87
+ "timeout": 10,
88
+ "statusMessage": "Checking quest plan handoff"
89
+ }
90
+ ]
91
+ }
65
92
  ]
66
93
  }
67
94
  }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // Plan-exit reminder hook. When a Quest-generated plan handoff is accepted,
3
+ // remind the parent session that the next run is orchestration, not direct
4
+ // implementation. Anything uncertain is a silent no-op.
5
+
6
+ import { writeSync } from "node:fs";
7
+
8
+ const REMINDER = [
9
+ "Quest plan handoff accepted.",
10
+ "This run is about orchestration, not direct implementation:",
11
+ "create or confirm the quest records, run `quest lint`, then switch to `$quest:orchestrate` to dispatch workers, verify checkpoints, and close the wave.",
12
+ ].join(" ");
13
+
14
+ async function readStdin() {
15
+ const chunks = [];
16
+ for await (const chunk of process.stdin) chunks.push(chunk);
17
+ return Buffer.concat(chunks).toString("utf8");
18
+ }
19
+
20
+ function hookEventName(payload) {
21
+ return payload?.hook_event_name ?? payload?.hookEventName ?? "";
22
+ }
23
+
24
+ function toolName(payload) {
25
+ return payload?.tool_name ?? payload?.toolName ?? payload?.tool?.name ?? "";
26
+ }
27
+
28
+ function lastAssistantMessage(payload) {
29
+ const value = payload?.last_assistant_message ?? payload?.lastAssistantMessage;
30
+ if (typeof value === "string") return value;
31
+ if (value && typeof value === "object") return JSON.stringify(value);
32
+ return "";
33
+ }
34
+
35
+ function questPlanHandoff(text) {
36
+ if (!text) return false;
37
+ const hasQuestPlan = /(?:\$?quest:plan\b|\bquest create\b|\bquest lint\b|<proposed_plan>)/i.test(text);
38
+ const hasOrchestrate = /(?:\$?quest:orchestrate\b|\borchestration\b|\borchestrator\b)/i.test(text);
39
+ return hasQuestPlan && hasOrchestrate;
40
+ }
41
+
42
+ function postToolUseText(payload) {
43
+ const parts = [];
44
+ for (const key of ["tool_input", "toolInput", "tool_response", "toolResponse"]) {
45
+ const value = payload?.[key];
46
+ if (typeof value === "string") parts.push(value);
47
+ else if (value && typeof value === "object") parts.push(JSON.stringify(value));
48
+ }
49
+ return parts.join("\n");
50
+ }
51
+
52
+ function shouldRemind(payload) {
53
+ const event = hookEventName(payload);
54
+ if (event === "PostToolUse" && toolName(payload) === "ExitPlanMode") {
55
+ return questPlanHandoff(postToolUseText(payload));
56
+ }
57
+ if (event === "Stop") {
58
+ return questPlanHandoff(lastAssistantMessage(payload));
59
+ }
60
+ return false;
61
+ }
62
+
63
+ try {
64
+ const raw = await readStdin();
65
+ let payload = {};
66
+ try { payload = raw.trim() ? JSON.parse(raw) : {}; } catch { payload = {}; }
67
+ if (shouldRemind(payload)) writeSync(1, JSON.stringify({ systemMessage: REMINDER }) + "\n");
68
+ process.exit(0);
69
+ } catch {
70
+ process.exit(0);
71
+ }
package/lib/cli.mjs CHANGED
@@ -5,13 +5,14 @@ import { parseArgs } from "node:util";
5
5
  import { readFileSync, existsSync, writeFileSync, appendFileSync } from "node:fs";
6
6
  import { join, dirname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "node:child_process";
8
9
  import { ConfigError, findStoreDir, loadConfig } from "./config.mjs";
9
10
  import { ContractError, lintRecord } from "./contract.mjs";
10
11
  import * as local from "./store-local.mjs";
11
12
  import * as github from "./store-github.mjs";
12
13
  import { openStore } from "./store.mjs";
13
14
  import { COMMANDS, renderCommandHelp, renderGeneralHelp, renderInitNextSteps, renderNoStore, renderStatusOverview } from "./help.mjs";
14
- import { claudeDoctor, doctor as codexDoctor, installAgents as installCodexAgents, installClaudeAgents, versionInfo } from "./codex-native.mjs";
15
+ import { claudeDoctor, claudeDoctorFix, codexDoctorFix, doctor as codexDoctor, installAgents as installCodexAgents, installClaudeAgents, nativeProjectRoot, versionInfo } from "./codex-native.mjs";
15
16
 
16
17
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
17
18
 
@@ -92,28 +93,87 @@ function preflightInitAgentInstall(cwd, env) {
92
93
  return plans;
93
94
  }
94
95
 
95
- async function runNativeSetupCommand(command, args, { out, cwd, env }, { label, doctor, install }) {
96
+ function printDoctorResult(command, result, out) {
97
+ if (result.repairs?.length) {
98
+ out(`${command} doctor --fix: repairs`);
99
+ for (const r of result.repairs) out(` ${r.ok ? "OK " : "ERR"} ${r.name}: ${r.command} (${r.detail})`);
100
+ }
101
+ for (const c of result.checks) out(`${c.ok ? "OK " : "ERR"} ${c.name}: ${c.detail}`);
102
+ out(result.ok ? `${command} doctor: OK` : `${command} doctor: problems found`);
103
+ }
104
+
105
+ function parseOpenArgs(args) {
106
+ const forwarded = [];
107
+ let dryRun = false;
108
+ for (let i = 0; i < args.length; i += 1) {
109
+ const arg = args[i];
110
+ if (arg === "--") {
111
+ forwarded.push(...args.slice(i + 1));
112
+ break;
113
+ }
114
+ if (arg === "--dry-run") {
115
+ dryRun = true;
116
+ continue;
117
+ }
118
+ if (arg === "--help" || arg === "-h") return { help: true };
119
+ forwarded.push(arg);
120
+ }
121
+ return { dryRun, forwarded };
122
+ }
123
+
124
+ function shellQuote(arg) {
125
+ return /^[A-Za-z0-9_./:=@+-]+$/.test(arg) ? arg : `'${String(arg).replaceAll("'", "'\\''")}'`;
126
+ }
127
+
128
+ function openInvocation(command, cwd, env, forwarded) {
129
+ const root = nativeProjectRoot(cwd, env);
130
+ if (command === "codex") return { cmd: "codex", args: ["-C", root, ...forwarded], cwd };
131
+ if (command === "claude") return { cmd: "claude", args: forwarded, cwd: root };
132
+ throw new Error(`unknown provider "${command}"`);
133
+ }
134
+
135
+ async function runNativeSetupCommand(command, args, { out, cwd, env }, { label, doctor, doctorFix, install }) {
96
136
  const [subcommand, ...rest] = args;
97
137
  if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
98
138
  out(renderCommandHelp(command));
99
139
  return 0;
100
140
  }
101
141
  if (subcommand === "doctor") {
102
- const p = parse(command, rest, {}, { positionals: 0 });
142
+ const p = parse(command, rest, { fix: { type: "boolean" } }, { positionals: 0 });
103
143
  if (p.help) {
104
144
  out(renderCommandHelp(command));
105
145
  return 0;
106
146
  }
107
- const result = doctor({ cwd, env });
147
+ const result = p.values.fix ? doctorFix({ cwd, env }) : doctor({ cwd, env });
108
148
  if (p.values.json) out(JSON.stringify(result));
109
- else {
110
- for (const c of result.checks) out(`${c.ok ? "OK " : "ERR"} ${c.name}: ${c.detail}`);
111
- out(result.ok ? `${command} doctor: OK` : `${command} doctor: problems found`);
112
- }
149
+ else printDoctorResult(command, result, out);
113
150
  // 1 (generic diagnostic failure), not 5 — exit 5 is reserved for
114
151
  // ContractError (a quest contract violation), which a doctor finding is not.
115
152
  return result.ok ? 0 : 1;
116
153
  }
154
+ if (subcommand === "open") {
155
+ const p = parseOpenArgs(rest);
156
+ if (p.help) {
157
+ out(renderCommandHelp(command));
158
+ return 0;
159
+ }
160
+ const result = doctorFix({ cwd, env });
161
+ if (!result.ok) {
162
+ printDoctorResult(command, result, out);
163
+ out(`${command} open: not launching because doctor still reports problems`);
164
+ return 1;
165
+ }
166
+ const invocation = openInvocation(command, cwd, env, p.forwarded);
167
+ const rendered = [invocation.cmd, ...invocation.args].map(shellQuote).join(" ");
168
+ if (p.dryRun) {
169
+ out(`${command} open: health OK`);
170
+ out(`Would run: ${rendered}`);
171
+ return 0;
172
+ }
173
+ out(`${command} open: health OK; launching ${rendered}`);
174
+ const res = spawnSync(invocation.cmd, invocation.args, { cwd: invocation.cwd, env, stdio: "inherit" });
175
+ return typeof res.status === "number" ? res.status : 1;
176
+ }
117
177
  if (subcommand === "install-agents") {
118
178
  const p = parse(command, rest, {
119
179
  scope: { type: "string", default: "project" },
@@ -136,9 +196,9 @@ async function runNativeSetupCommand(command, args, { out, cwd, env }, { label,
136
196
  // A real run refuses to overwrite; a --dry-run PREVIEWS the conflict
137
197
  // (its whole purpose) rather than erroring before showing the plan.
138
198
  if (!result.ok && !isDry) {
139
- const symlinkConflicts = result.conflicts.filter((c) => c.reason === "symlink");
140
- if (symlinkConflicts.length) {
141
- throw new UsageError(`refusing to write agent templates through symlinked path: ${symlinkConflicts.map((c) => c.path).join(", ")}`, { command });
199
+ const symlinkDirConflicts = result.conflicts.filter((c) => c.reason === "symlink-directory");
200
+ if (symlinkDirConflicts.length) {
201
+ throw new UsageError(`refusing to write agent templates through symlinked directory: ${symlinkDirConflicts.map((c) => c.path).join(", ")}`, { command });
142
202
  }
143
203
  const conflicts = result.conflicts.map((c) => c.path).join(", ");
144
204
  throw new UsageError(`agent file already exists; pass --force to replace: ${conflicts}`, { command });
@@ -147,7 +207,12 @@ async function runNativeSetupCommand(command, args, { out, cwd, env }, { label,
147
207
  else {
148
208
  out(`${isDry ? "Would install" : "Installed"} ${label} agents to ${result.target}`);
149
209
  for (const a of result.actions) out(` ${a.action.padEnd(9)} ${a.path}`);
150
- if (!result.ok) out(` conflicts present — pass --force to replace: ${result.conflicts.map((c) => c.path).join(", ")}`);
210
+ if (!result.ok) {
211
+ const symlinkDirConflicts = result.conflicts.filter((c) => c.reason === "symlink-directory");
212
+ if (symlinkDirConflicts.length) out(` symlinked directories must be real directories: ${symlinkDirConflicts.map((c) => c.path).join(", ")}`);
213
+ const fileConflicts = result.conflicts.filter((c) => c.reason !== "symlink-directory");
214
+ if (fileConflicts.length) out(` conflicts present — pass --force to replace: ${fileConflicts.map((c) => c.path).join(", ")}`);
215
+ }
151
216
  }
152
217
  return 0;
153
218
  }
@@ -531,10 +596,10 @@ const HANDLERS = {
531
596
  },
532
597
 
533
598
  async codex(args, { out, cwd, env }) {
534
- return runNativeSetupCommand("codex", args, { out, cwd, env }, { label: "Codex", doctor: codexDoctor, install: installCodexAgents });
599
+ return runNativeSetupCommand("codex", args, { out, cwd, env }, { label: "Codex", doctor: codexDoctor, doctorFix: codexDoctorFix, install: installCodexAgents });
535
600
  },
536
601
 
537
602
  async claude(args, { out, cwd, env }) {
538
- return runNativeSetupCommand("claude", args, { out, cwd, env }, { label: "Claude", doctor: claudeDoctor, install: installClaudeAgents });
603
+ return runNativeSetupCommand("claude", args, { out, cwd, env }, { label: "Claude", doctor: claudeDoctor, doctorFix: claudeDoctorFix, install: installClaudeAgents });
539
604
  },
540
605
  };
@@ -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",
@@ -82,7 +83,6 @@ function installedAgentDirs(provider, cwd, env) {
82
83
 
83
84
  function sameFile(path, text) {
84
85
  try {
85
- if (lstatSync(path).isSymbolicLink()) return false;
86
86
  return readFileSync(path, "utf8") === text;
87
87
  } catch {
88
88
  return false;
@@ -161,13 +161,25 @@ export function versionInfo() {
161
161
  };
162
162
  }
163
163
 
164
- function installProviderAgents(provider, { scope = "project", dryRun = false, force = false, cwd = process.cwd(), env = process.env } = {}) {
164
+ function existingAgentMatchesName(provider, name, path) {
165
+ let text;
166
+ try {
167
+ text = readFileSync(path, "utf8");
168
+ } catch {
169
+ return false;
170
+ }
171
+ if (provider === "codex") return new RegExp(`^\\s*name\\s*=\\s*"${name}"\\s*$`, "m").test(text);
172
+ if (provider === "claude") return new RegExp(`^\\s*name:\\s*${name}\\s*$`, "m").test(text);
173
+ return false;
174
+ }
175
+
176
+ function installProviderAgents(provider, { scope = "project", dryRun = false, force = false, replaceStale = true, cwd = process.cwd(), env = process.env } = {}) {
165
177
  const cfg = providerConfig(provider);
166
178
  const dir = targetDir(provider, scope, cwd, env);
167
179
  const dirConflicts = [dirname(dir), dir]
168
180
  .filter((path, index, all) => all.indexOf(path) === index)
169
- .filter((path) => existsSync(path) && isSymlink(path))
170
- .map((path) => ({ name: null, path, reason: "symlink" }));
181
+ .filter((path) => isSymlink(path))
182
+ .map((path) => ({ name: null, path, reason: "symlink-directory" }));
171
183
 
172
184
  // Plan every agent first; only touch disk once the whole set is known to be
173
185
  // conflict-free. A mixed create+conflict run must not leave a partial install.
@@ -175,29 +187,26 @@ function installProviderAgents(provider, { scope = "project", dryRun = false, fo
175
187
  const src = join(PLUGIN_ROOT, "agents", `${name}.${cfg.extension}`);
176
188
  const dest = join(dir, `${name}.${cfg.extension}`);
177
189
  const text = readFileSync(src, "utf8");
178
- const exists = existsSync(dest);
179
- const symlink = exists && isSymlink(dest);
190
+ const exists = existsSync(dest) || isSymlink(dest);
191
+ const staleQuestAgent = exists && !sameFile(dest, text) && existingAgentMatchesName(provider, name, dest);
180
192
  let action;
181
- if (symlink) action = "conflict";
182
- else if (exists && sameFile(dest, text)) action = "unchanged";
183
- else if (exists && !force) action = "conflict";
193
+ if (exists && sameFile(dest, text)) action = "unchanged";
194
+ else if (exists && !force && !(replaceStale && staleQuestAgent)) action = "conflict";
184
195
  else action = exists ? "replace" : "create";
185
- return { name, path: dest, action, text, reason: symlink ? "symlink" : undefined };
196
+ return { name, path: dest, action, text };
186
197
  });
187
198
 
188
199
  const conflicts = [
189
200
  ...dirConflicts,
190
- ...plans.filter((p) => p.action === "conflict").map(({ name, path, reason }) => ({ name, path, ...(reason ? { reason } : {}) })),
201
+ ...plans.filter((p) => p.action === "conflict").map(({ name, path }) => ({ name, path })),
191
202
  ];
192
203
  const actions = plans.map(({ name, path, action }) => ({ name, path, action }));
193
204
  const ok = conflicts.length === 0;
194
205
 
195
206
  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
207
  for (const p of plans) {
198
208
  if (p.action === "unchanged") continue;
199
209
  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
210
  writeFileSync(p.path, p.text);
202
211
  }
203
212
  }
@@ -213,6 +222,10 @@ export function installClaudeAgents(options = {}) {
213
222
  return installProviderAgents("claude", options);
214
223
  }
215
224
 
225
+ export function nativeProjectRoot(cwd = process.cwd(), env = process.env) {
226
+ return projectRoot(cwd, env);
227
+ }
228
+
216
229
  function questCliPathCheck(cwd, env, versions) {
217
230
  const pathQuestVersion = runCmd("quest", ["--version"], { cwd, env });
218
231
  const pathVersion = pathQuestVersion.status === 0 ? pathQuestVersion.stdout.trim() : null;
@@ -248,7 +261,7 @@ function nativeAgentsCheck(provider, cwd, env) {
248
261
  }
249
262
  const agentDetail = [
250
263
  missing.length ? `missing: ${missing.join(", ")}` : null,
251
- stale.length ? `stale (run install-agents --force): ${stale.join(", ")}` : null,
264
+ stale.length ? `stale (run doctor --fix or install-agents): ${stale.join(", ")}` : null,
252
265
  ].filter(Boolean).join("; ") || `installed ${cfg.label} templates current: ${Object.values(found).join(", ")}`;
253
266
  return check(
254
267
  "native-agents",
@@ -423,3 +436,113 @@ export function claudeDoctor({ cwd = process.cwd(), env = process.env } = {}) {
423
436
 
424
437
  return { ok: checks.every((c) => c.ok), checks };
425
438
  }
439
+
440
+ function checkByName(result, name) {
441
+ return result.checks.find((c) => c.name === name);
442
+ }
443
+
444
+ function cmdDetail(res) {
445
+ const text = [res.stdout, res.stderr, res.error?.message].filter(Boolean).join("\n").trim();
446
+ return text || `exit ${res.status ?? "unknown"}`;
447
+ }
448
+
449
+ function repairRecord(name, command, res, extra = {}) {
450
+ return {
451
+ name,
452
+ command: command.join(" "),
453
+ ok: res.status === 0,
454
+ detail: cmdDetail(res),
455
+ ...extra,
456
+ };
457
+ }
458
+
459
+ function runRepairCommand(cmd, args, { cwd, env }) {
460
+ return runCmd(cmd, args, { cwd, env });
461
+ }
462
+
463
+ function codexMarketplacePresent(cwd, env) {
464
+ const res = runCmd("codex", ["plugin", "marketplace", "list", "--json"], { cwd, env });
465
+ if (res.status !== 0) return { ok: false, present: false, res };
466
+ try {
467
+ const parsed = JSON.parse(res.stdout || "{}");
468
+ const marketplaces = Array.isArray(parsed.marketplaces) ? parsed.marketplaces : [];
469
+ return { ok: true, present: marketplaces.some((m) => m.name === "quest" || m.root?.includes("/quest")) };
470
+ } catch {
471
+ return { ok: false, present: false, res };
472
+ }
473
+ }
474
+
475
+ function providerPluginNeedsRepair(provider, result) {
476
+ const names = provider === "codex"
477
+ ? ["plugin-installed", "plugin-version", "hook-parser", "single-neutral-skill-root"]
478
+ : ["plugin-installed", "plugin-version"];
479
+ return names.some((name) => checkByName(result, name)?.ok === false);
480
+ }
481
+
482
+ function repairProviderPlugin(provider, before, { cwd, env }) {
483
+ const cliName = provider === "codex" ? "codex-cli" : "claude-cli";
484
+ if (!checkByName(before, cliName)?.ok || !providerPluginNeedsRepair(provider, before)) return [];
485
+
486
+ const repairs = [];
487
+ if (provider === "codex") {
488
+ const marketplace = codexMarketplacePresent(cwd, env);
489
+ if (!marketplace.ok) {
490
+ repairs.push(repairRecord("plugin-marketplace-list", ["codex", "plugin", "marketplace", "list", "--json"], marketplace.res));
491
+ return repairs;
492
+ }
493
+ if (!marketplace.present) {
494
+ const addMarketplaceArgs = ["plugin", "marketplace", "add", QUEST_CODEX_MARKETPLACE_SOURCE];
495
+ const addMarketplace = runRepairCommand("codex", addMarketplaceArgs, { cwd, env });
496
+ repairs.push(repairRecord("plugin-marketplace-add", ["codex", ...addMarketplaceArgs], addMarketplace));
497
+ if (addMarketplace.status !== 0) return repairs;
498
+ }
499
+ const upgrade = runRepairCommand("codex", ["plugin", "marketplace", "upgrade", "quest"], { cwd, env });
500
+ repairs.push(repairRecord("plugin-marketplace-upgrade", ["codex", "plugin", "marketplace", "upgrade", "quest"], upgrade));
501
+ if (upgrade.status !== 0) return repairs;
502
+ const addPlugin = runRepairCommand("codex", ["plugin", "add", "quest@quest"], { cwd, env });
503
+ repairs.push(repairRecord("plugin-add", ["codex", "plugin", "add", "quest@quest"], addPlugin));
504
+ return repairs;
505
+ }
506
+
507
+ const installed = checkByName(before, "plugin-installed")?.installed;
508
+ const subcommand = installed ? "update" : "install";
509
+ const res = runRepairCommand("claude", ["plugin", subcommand, "quest@quest"], { cwd, env });
510
+ repairs.push(repairRecord(`plugin-${subcommand}`, ["claude", "plugin", subcommand, "quest@quest"], res));
511
+ return repairs;
512
+ }
513
+
514
+ function repairProviderAgents(provider, before, { cwd, env }) {
515
+ if (checkByName(before, "native-agents")?.ok !== false) return [];
516
+ const result = installProviderAgents(provider, { scope: "project", force: false, replaceStale: true, cwd, env });
517
+ return [{
518
+ name: "native-agents",
519
+ command: `quest ${provider} install-agents --scope project`,
520
+ ok: result.ok,
521
+ detail: result.ok ? `${result.actions.map((a) => `${a.action} ${a.path}`).join("; ")}` : `conflicts: ${result.conflicts.map((c) => c.path).join(", ")}`,
522
+ result,
523
+ }];
524
+ }
525
+
526
+ function doctorForProvider(provider, options) {
527
+ if (provider === "codex") return doctor(options);
528
+ if (provider === "claude") return claudeDoctor(options);
529
+ throw new Error(`unknown provider "${provider}"`);
530
+ }
531
+
532
+ function doctorWithFix(provider, { cwd = process.cwd(), env = process.env } = {}) {
533
+ const before = doctorForProvider(provider, { cwd, env });
534
+ const repairs = [
535
+ ...repairProviderAgents(provider, before, { cwd, env }),
536
+ ...repairProviderPlugin(provider, before, { cwd, env }),
537
+ ];
538
+ const after = doctorForProvider(provider, { cwd, env });
539
+ return { ok: after.ok, checks: after.checks, repairs, before };
540
+ }
541
+
542
+ export function codexDoctorFix(options = {}) {
543
+ return doctorWithFix("codex", options);
544
+ }
545
+
546
+ export function claudeDoctorFix(options = {}) {
547
+ return doctorWithFix("claude", options);
548
+ }
package/lib/help.mjs CHANGED
@@ -138,30 +138,34 @@ export const COMMANDS = {
138
138
  example: "quest runs --active",
139
139
  },
140
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]",
141
+ purpose: "Inspect, repair, install, or open Quest's native Codex integration",
142
+ usage: "quest codex doctor [--fix] [--json] | quest codex open [--dry-run] [-- <codex args>] | quest codex install-agents [--scope project|user] [--dry-run] [--force] [--json]",
143
143
  flags: [
144
144
  ["doctor", "check Codex CLI, multi-agent support, plugin install/version, hooks, skill roots, and native-agent templates"],
145
+ ["--fix", "repair missing/stale Quest agents and Quest plugin install/version when possible, then rerun doctor"],
146
+ ["open", "repair/check setup, then launch interactive Codex from the project root"],
145
147
  ["install-agents", "install quest-executor and quest-reviewer as native Codex custom agents"],
146
148
  ["--scope <s>", "project (default: .codex/agents at repo root) or user (~/.codex/agents)"],
147
149
  ["--dry-run", "show intended agent writes without changing files"],
148
- ["--force", "replace existing agent files"],
150
+ ["--force", "replace existing non-Quest agent files"],
149
151
  ["--json", "machine-readable output"],
150
152
  ],
151
- example: "quest codex install-agents --scope project && quest codex doctor",
153
+ example: "quest codex doctor --fix && quest codex open",
152
154
  },
153
155
  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
+ purpose: "Inspect, repair, install, or open Quest's native Claude Code integration",
157
+ usage: "quest claude doctor [--fix] [--json] | quest claude open [--dry-run] [-- <claude args>] | quest claude install-agents [--scope project|user] [--dry-run] [--force] [--json]",
156
158
  flags: [
157
159
  ["doctor", "check Claude CLI, plugin install/version, and native-agent templates"],
160
+ ["--fix", "repair missing/stale Quest agents and Quest plugin install/version when possible, then rerun doctor"],
161
+ ["open", "repair/check setup, then launch interactive Claude Code from the project root"],
158
162
  ["install-agents", "install quest-executor and quest-reviewer as native Claude Code custom agents"],
159
163
  ["--scope <s>", "project (default: .claude/agents at repo root) or user (~/.claude/agents)"],
160
164
  ["--dry-run", "show intended agent writes without changing files"],
161
- ["--force", "replace existing agent files"],
165
+ ["--force", "replace existing non-Quest agent files"],
162
166
  ["--json", "machine-readable output"],
163
167
  ],
164
- example: "quest claude install-agents --scope project && quest claude doctor",
168
+ example: "quest claude doctor --fix && quest claude open",
165
169
  },
166
170
  };
167
171
 
@@ -221,9 +225,11 @@ export function renderInitNextSteps(backend, { agentsInstalled = true } = {}) {
221
225
  "",
222
226
  "Store created. Next steps:",
223
227
  ...(agentsInstalled ? [" 0. Restart agent sessions so newly installed project templates are loaded"] : []),
224
- " 1. Author a quest: quest create --help (or $quest:plan in your agent session)",
225
- " 2. Check it: quest lint --all",
226
- " 3. Work it: $quest:work <id> in-session, or quest-run <id> headless",
228
+ ...(agentsInstalled ? [" 1. Repair/check providers: quest codex doctor --fix (or quest claude doctor --fix)"] : []),
229
+ ...(agentsInstalled ? [" 2. Open provider: quest codex open (or quest claude open)"] : []),
230
+ ` ${agentsInstalled ? "3" : "1"}. Author a quest: quest create --help (or $quest:plan in your agent session)`,
231
+ ` ${agentsInstalled ? "4" : "2"}. Check it: quest lint --all`,
232
+ ` ${agentsInstalled ? "5" : "3"}. Work it: $quest:work <id> in-session, or quest-run <id> headless`,
227
233
  ...(agentsInstalled ? [] : [" Agent templates skipped; install later with `quest codex install-agents --scope project` and `quest claude install-agents --scope project`."]),
228
234
  ...(backend === "github" ? ["", "Records live as GitHub issues; config and amendments stay local in .quests/."] : []),
229
235
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quest-loop",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Goal-loop engineering for coding agents: quest contracts, iterative execution with evidence checkpoints, Claude + Codex workers. Ships the `quest` store CLI and the `quest-run` headless runner.",
5
5
  "type": "module",
6
6
  "engines": { "node": ">=20" },
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: setup
3
- description: Use when setting up, validating, updating, or troubleshooting Quest's native Codex or Claude plugin integration.
4
- argument-hint: "[doctor|install-agents|init]"
3
+ description: Use when setting up, validating, updating, opening, or troubleshooting Quest's native Codex or Claude plugin integration.
4
+ argument-hint: "[doctor|fix|open|install-agents|init]"
5
5
  ---
6
6
 
7
7
  # Set up Quest native agents
@@ -19,9 +19,10 @@ agent templates for both providers:
19
19
  - `.claude/agents/quest-reviewer.md`
20
20
 
21
21
  Use `quest init --no-agents` when you only want the quest store. If an existing
22
- project template conflicts, init fails before creating `.quests/`; inspect the
23
- file, run the explicit provider install command with `--force` only when you
24
- intend replacement, then rerun `quest init`.
22
+ project template is stale and still identifies itself as Quest's agent, init
23
+ replaces it. If another file conflicts, init fails before creating `.quests/`;
24
+ inspect the file, run the explicit provider install command with `--force` only
25
+ when you intend replacement, then rerun `quest init`.
25
26
 
26
27
  Project-scoped agent templates install at the Git repository root. For a nested
27
28
  quest store, use `quest init --no-agents` in the nested directory and set
@@ -32,7 +33,7 @@ quest store, use `quest init --no-agents` in the nested directory and set
32
33
  Check the installed Codex-facing state from the actual Codex surfaces:
33
34
 
34
35
  ```bash
35
- quest codex doctor
36
+ quest codex doctor --fix
36
37
  ```
37
38
 
38
39
  This verifies the Codex CLI, `quest` binary on PATH, `multi_agent` and `goals`
@@ -45,7 +46,14 @@ use `quest-run --codex-goal-mode require` only as the headless fallback.
45
46
  Check the installed Claude-facing state from the actual Claude surfaces:
46
47
 
47
48
  ```bash
48
- quest claude doctor
49
+ quest claude doctor --fix
50
+ ```
51
+
52
+ Open an interactive provider only after the same repair/check gate passes:
53
+
54
+ ```bash
55
+ quest codex open
56
+ quest claude open
49
57
  ```
50
58
 
51
59
  ## Install Native Agents
@@ -70,8 +78,10 @@ quest claude install-agents --scope user
70
78
  ```
71
79
 
72
80
  If an existing file conflicts, inspect it first. Use `--force` only when you
73
- intend to replace that custom agent with Quest's bundled definition. Symlinked
74
- agent directories or files are refused rather than overwritten.
81
+ intend to replace that custom agent with Quest's bundled definition. Stale
82
+ Quest-owned templates are replaced without `--force`. Symlinked agent template
83
+ files can be written through with `--force`; keep the provider agent directories
84
+ themselves as real directories.
75
85
 
76
86
  ## Update Installed Plugin
77
87
 
@@ -81,7 +91,14 @@ refresh and reinstall, then start a new Codex thread:
81
91
  ```bash
82
92
  codex plugin marketplace upgrade quest
83
93
  codex plugin add quest@quest
84
- quest codex doctor
94
+ quest codex doctor --fix
95
+ ```
96
+
97
+ Claude updates through its plugin manager. After updating, restart Claude Code:
98
+
99
+ ```bash
100
+ claude plugin update quest@quest
101
+ quest claude doctor --fix
85
102
  ```
86
103
 
87
104
  If hooks changed, review/trust the updated hook definitions in Codex when