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.
- package/CHANGELOG.md +137 -0
- package/README.md +119 -19
- package/agents/quest-executor.md +12 -2
- package/agents/quest-executor.toml +13 -6
- package/agents/quest-reviewer.md +9 -0
- package/agents/quest-reviewer.toml +11 -7
- package/hooks/hooks.json +40 -5
- package/hooks/subagent-stop.mjs +140 -10
- package/lib/cli.mjs +141 -2
- package/lib/codex-native.mjs +425 -0
- package/lib/config.mjs +1 -1
- package/lib/contract.mjs +22 -2
- package/lib/help.mjs +42 -7
- package/lib/runner.mjs +59 -3
- package/lib/store-github.mjs +34 -2
- package/lib/store-local.mjs +32 -2
- package/lib/store.mjs +2 -0
- package/lib/workers.mjs +26 -12
- package/package.json +3 -3
- package/skills/orchestrate/SKILL.md +100 -23
- package/skills/orchestrate/agents/openai.yaml +1 -1
- package/skills/plan/SKILL.md +42 -5
- package/skills/plan/agents/openai.yaml +1 -1
- package/skills/protocol/SKILL.md +2 -2
- package/skills/protocol/agents/openai.yaml +1 -1
- package/skills/protocol/references/contract-spec.md +14 -5
- package/skills/protocol/references/protocol.md +12 -4
- package/skills/retro/SKILL.md +2 -2
- package/skills/retro/agents/openai.yaml +1 -1
- package/skills/setup/SKILL.md +92 -0
- package/skills/setup/agents/openai.yaml +7 -0
- package/skills/work/SKILL.md +3 -3
- package/skills/work/agents/openai.yaml +1 -1
|
@@ -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:
|
|
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/)
|
|
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
|
|
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
|
-
"
|
|
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:
|
|
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
|
}
|