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.
- package/CHANGELOG.md +90 -0
- package/README.md +67 -17
- package/agents/quest-executor.md +2 -2
- package/hooks/hooks.json +40 -5
- package/hooks/subagent-stop.mjs +140 -10
- package/lib/cli.mjs +98 -1
- package/lib/codex-native.mjs +246 -0
- package/lib/config.mjs +1 -1
- package/lib/contract.mjs +22 -2
- package/lib/help.mjs +23 -4
- 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 +64 -11
- package/skills/orchestrate/agents/openai.yaml +1 -1
- package/skills/plan/SKILL.md +11 -2
- 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 +56 -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,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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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" },
|
package/lib/store-github.mjs
CHANGED
|
@@ -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" });
|
package/lib/store-local.mjs
CHANGED
|
@@ -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
|
};
|