quest-loop 0.1.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/lib/cli.mjs ADDED
@@ -0,0 +1,401 @@
1
+ // Command dispatch for `quest`. Exit codes (contract-spec): 0 ok · 2 usage ·
2
+ // 3 no store/config · 4 not found · 5 contract violation · 6 backend unavailable.
3
+
4
+ import { parseArgs } from "node:util";
5
+ import { readFileSync, existsSync, writeFileSync, appendFileSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { ConfigError, findStoreDir, loadConfig } from "./config.mjs";
9
+ import { ContractError, lintRecord } from "./contract.mjs";
10
+ import * as local from "./store-local.mjs";
11
+ import * as github from "./store-github.mjs";
12
+ import { openStore } from "./store.mjs";
13
+ import { COMMANDS, renderCommandHelp, renderGeneralHelp, renderInitNextSteps, renderNoStore, renderStatusOverview } from "./help.mjs";
14
+
15
+ const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
16
+
17
+ class UsageError extends Error {
18
+ constructor(message, { command } = {}) {
19
+ super(message);
20
+ this.hint = command ? `see \`quest ${command} --help\`` : "see `quest help`";
21
+ }
22
+ }
23
+
24
+ function exitCodeFor(err) {
25
+ if (err instanceof UsageError) return 2;
26
+ if (err instanceof ConfigError) return 3;
27
+ if (err instanceof local.NotFoundError) return 4;
28
+ if (err instanceof ContractError) return 5;
29
+ if (err instanceof github.GhError) return 6; // backend unavailable: gh missing/unauthenticated/failed
30
+ return 1;
31
+ }
32
+
33
+ export async function run(argv, io = {}) {
34
+ const out = io.stdout ?? ((s) => process.stdout.write(s + "\n"));
35
+ const errOut = io.stderr ?? ((s) => process.stderr.write(s + "\n"));
36
+ const cwd = io.cwd ?? process.cwd();
37
+ const env = io.env ?? process.env;
38
+
39
+ try {
40
+ return await dispatch(argv, { out, errOut, cwd, env });
41
+ } catch (err) {
42
+ const code = exitCodeFor(err);
43
+ errOut(`quest: ${err.message}`);
44
+ if (err.hint) errOut(` hint: ${err.hint}`);
45
+ if (code === 1) errOut(err.stack.split("\n").slice(1, 4).join("\n"));
46
+ return code;
47
+ }
48
+ }
49
+
50
+ function parse(command, args, options, { positionals = 0 } = {}) {
51
+ let parsed;
52
+ try {
53
+ parsed = parseArgs({ args, options: { ...options, help: { type: "boolean" }, json: { type: "boolean" } }, allowPositionals: true });
54
+ } catch (err) {
55
+ throw new UsageError(err.message, { command });
56
+ }
57
+ if (parsed.values.help) return { help: true };
58
+ if (parsed.positionals.length > positionals) {
59
+ throw new UsageError(`unexpected argument "${parsed.positionals[positionals]}"`, { command });
60
+ }
61
+ return parsed;
62
+ }
63
+
64
+ function requireId(parsed, command) {
65
+ const raw = parsed.positionals[0];
66
+ if (!raw || !/^\d+$/.test(raw)) throw new UsageError("a numeric quest id is required", { command });
67
+ return Number(raw);
68
+ }
69
+
70
+ function getStore(cwd, env) {
71
+ const storeDir = findStoreDir(cwd, env);
72
+ if (!storeDir) throw new ConfigError("no quest store found (searched for .quests/ from here upward)", { hint: "run `quest init` to create one" });
73
+ return loadConfig(storeDir, env);
74
+ }
75
+
76
+ async function dispatch(argv, ctx) {
77
+ const [command, ...rest] = argv;
78
+ const { out, errOut, cwd, env } = ctx;
79
+
80
+ if (!command) {
81
+ const storeDir = findStoreDir(cwd, env);
82
+ if (!storeDir) {
83
+ out(renderNoStore());
84
+ return 0;
85
+ }
86
+ const config = loadConfig(storeDir, env);
87
+ const store = openStore(config, { env });
88
+ const all = store.listQuests();
89
+ const counts = {};
90
+ for (const q of all) counts[q.status] = (counts[q.status] ?? 0) + 1;
91
+ const runs = local.readRuns(storeDir);
92
+ const ended = new Set(runs.filter((r) => r.event === "run_ended").map((r) => r.run_id));
93
+ const active = runs.filter((r) => r.event === "run_started" && !ended.has(r.run_id)).length;
94
+ out(renderStatusOverview({ counts, ready: store.readyQuests(), activeRuns: active, backend: config.backend }));
95
+ return 0;
96
+ }
97
+
98
+ if (command === "help" || command === "--help" || command === "-h") {
99
+ const topic = rest[0];
100
+ if (topic && COMMANDS[topic]) out(renderCommandHelp(topic));
101
+ else out(renderGeneralHelp());
102
+ return 0;
103
+ }
104
+
105
+ if (!(command in HANDLERS)) {
106
+ throw new UsageError(`unknown command "${command}"`);
107
+ }
108
+ return HANDLERS[command](rest, ctx);
109
+ }
110
+
111
+ const HANDLERS = {
112
+ async init(args, { out, cwd, env }) {
113
+ const p = parse("init", args, {
114
+ backend: { type: "string", default: "local" },
115
+ repo: { type: "string" },
116
+ "agents-md": { type: "boolean" },
117
+ });
118
+ if (p.help) return void out(renderCommandHelp("init")) ?? 0;
119
+ const backend = p.values.backend;
120
+ if (!["local", "github"].includes(backend)) throw new UsageError(`--backend must be local or github (got "${backend}")`, { command: "init" });
121
+ if (backend === "github" && !p.values.repo) throw new UsageError("--repo owner/name is required with --backend github", { command: "init" });
122
+ if (backend === "github") {
123
+ github.assertAuth(env); // green `gh auth status` required
124
+ github.ensureLabels(p.values.repo, env); // idempotent label creation
125
+ }
126
+ const storeDir = local.initLocalStore(cwd);
127
+ const config = {
128
+ backend,
129
+ ...(p.values.repo ? { github: { repo: p.values.repo } } : {}),
130
+ defaults: { worker: "claude", claude: { model: "opus", effort: "xhigh" }, codex: { model: "gpt-5.5", reasoning_effort: "medium" }, max_iterations: 8, priority: "p2" },
131
+ notify: { command: "" },
132
+ };
133
+ writeFileSync(join(storeDir, "config.json"), JSON.stringify(config, null, 2) + "\n");
134
+ writeFileSync(join(storeDir, "amendments.md"), "# Protocol amendments\n\n(none yet)\n");
135
+ if (p.values["agents-md"]) {
136
+ const section = [
137
+ "",
138
+ "## Quest goal-loop",
139
+ "",
140
+ "Work in this repo can be tracked as quests (goal contracts) in `.quests/`.",
141
+ "Orient with `quest protocol` and `quest show <id> --json`; record progress",
142
+ "only via `quest checkpoint`. A quest is complete only when every Done-when",
143
+ "item is enumerated with evidence.",
144
+ "",
145
+ ].join("\n");
146
+ appendFileSync(join(cwd, "AGENTS.md"), section);
147
+ }
148
+ out(`Initialized ${backend} quest store at ${storeDir}`);
149
+ out(renderInitNextSteps(backend));
150
+ return 0;
151
+ },
152
+
153
+ async create(args, { out, cwd, env }) {
154
+ const p = parse("create", args, {
155
+ title: { type: "string" },
156
+ objective: { type: "string" },
157
+ "done-when": { type: "string", multiple: true },
158
+ validation: { type: "string" },
159
+ constraint: { type: "string", multiple: true },
160
+ milestone: { type: "string", multiple: true },
161
+ context: { type: "string" },
162
+ "out-of-scope": { type: "string", multiple: true },
163
+ parent: { type: "string" },
164
+ "depends-on": { type: "string" },
165
+ worker: { type: "string" },
166
+ model: { type: "string" },
167
+ effort: { type: "string" },
168
+ "max-iterations": { type: "string" },
169
+ "max-cost": { type: "string" },
170
+ priority: { type: "string" },
171
+ });
172
+ if (p.help) return void out(renderCommandHelp("create")) ?? 0;
173
+ const v = p.values;
174
+ for (const [flag, val] of [["--title", v.title], ["--objective", v.objective], ["--validation", v.validation]]) {
175
+ if (!val || !val.trim()) throw new UsageError(`${flag} is required`, { command: "create" });
176
+ }
177
+ if (!v["done-when"]?.length) throw new UsageError("at least one --done-when is required", { command: "create" });
178
+ const config = getStore(cwd, env);
179
+ const store = openStore(config, { env });
180
+ const num = (flag, s) => {
181
+ if (s === undefined) return undefined;
182
+ if (!/^\d+(\.\d+)?$/.test(s)) throw new UsageError(`${flag} must be a number (got "${s}")`, { command: "create" });
183
+ return Number(s);
184
+ };
185
+ const created = store.createQuest(config.defaults, {
186
+ title: v.title,
187
+ priority: v.priority,
188
+ worker: v.worker,
189
+ model: v.model,
190
+ effort: v.effort,
191
+ max_iterations: num("--max-iterations", v["max-iterations"]),
192
+ max_cost: num("--max-cost", v["max-cost"]),
193
+ parent: num("--parent", v.parent),
194
+ depends_on: v["depends-on"] ? v["depends-on"].split(",").map((s) => num("--depends-on", s.trim())) : undefined,
195
+ }, {
196
+ objective: v.objective,
197
+ doneWhen: v["done-when"],
198
+ validation: v.validation,
199
+ constraints: v.constraint,
200
+ milestones: v.milestone,
201
+ context: v.context,
202
+ outOfScope: v["out-of-scope"],
203
+ });
204
+ if (p.values.json) out(JSON.stringify({ ok: true, id: created.id, path: created.path }));
205
+ else {
206
+ out(`Created quest ${created.id}: ${v.title}`);
207
+ out(` record: ${created.path}`);
208
+ out(` next: quest lint ${created.id} && quest show ${created.id}`);
209
+ }
210
+ return 0;
211
+ },
212
+
213
+ async list(args, { out, cwd, env }) {
214
+ const p = parse("list", args, {
215
+ status: { type: "string" },
216
+ parent: { type: "string" },
217
+ ready: { type: "boolean" },
218
+ });
219
+ if (p.help) return void out(renderCommandHelp("list")) ?? 0;
220
+ const config = getStore(cwd, env);
221
+ const store = openStore(config, { env });
222
+ let quests = p.values.ready ? store.readyQuests() : store.listQuests();
223
+ if (p.values.status) quests = quests.filter((q) => q.status === p.values.status);
224
+ if (p.values.parent) quests = quests.filter((q) => q.parent === Number(p.values.parent));
225
+ if (p.values.json) {
226
+ out(JSON.stringify(quests));
227
+ } else if (!quests.length) {
228
+ out(p.values.ready ? "No quests are ready (check `quest list` for blockers)." : "No quests match.");
229
+ } else {
230
+ for (const q of quests) {
231
+ const deps = q.depends_on?.length ? ` deps:[${q.depends_on.join(",")}]` : "";
232
+ out(`${String(q.id).padStart(3)} [${q.priority}] ${q.status.padEnd(11)} ${q.title}${deps}`);
233
+ }
234
+ }
235
+ return 0;
236
+ },
237
+
238
+ async show(args, { out, cwd, env }) {
239
+ const p = parse("show", args, {}, { positionals: 1 });
240
+ if (p.help) return void out(renderCommandHelp("show")) ?? 0;
241
+ const id = requireId(p, "show");
242
+ const config = getStore(cwd, env);
243
+ const q = openStore(config, { env }).loadQuest(id);
244
+ if (p.values.json) out(JSON.stringify({ ...q.front, body: q.body, checkpoints: q.checkpoints, path: q.path }));
245
+ else out(q.text.trimEnd());
246
+ return 0;
247
+ },
248
+
249
+ async start(args, { out, cwd, env }) {
250
+ const p = parse("start", args, {}, { positionals: 1 });
251
+ if (p.help) return void out(renderCommandHelp("start")) ?? 0;
252
+ const id = requireId(p, "start");
253
+ const config = getStore(cwd, env);
254
+ const q = openStore(config, { env }).startQuest(id);
255
+ if (p.values.json) out(JSON.stringify({ ok: true, id, status: q.front.status }));
256
+ else out(`Quest ${id} is now in_progress. Work it per \`quest protocol\`; record evidence with \`quest checkpoint ${id}\`.`);
257
+ return 0;
258
+ },
259
+
260
+ async checkpoint(args, { out, cwd, env }) {
261
+ const p = parse("checkpoint", args, {
262
+ status: { type: "string" },
263
+ summary: { type: "string" },
264
+ validation: { type: "string" },
265
+ iteration: { type: "string" },
266
+ pr: { type: "string" },
267
+ sha: { type: "string" },
268
+ failed: { type: "string" },
269
+ expansion: { type: "string" },
270
+ note: { type: "string" },
271
+ }, { positionals: 1 });
272
+ if (p.help) return void out(renderCommandHelp("checkpoint")) ?? 0;
273
+ const id = requireId(p, "checkpoint");
274
+ const v = p.values;
275
+ for (const [flag, val] of [["--status", v.status], ["--summary", v.summary], ["--validation", v.validation]]) {
276
+ if (!val || !val.trim()) throw new UsageError(`${flag} is required`, { command: "checkpoint" });
277
+ }
278
+ const config = getStore(cwd, env);
279
+ const store = openStore(config, { env });
280
+ const existing = store.loadQuest(id);
281
+ const q = store.appendCheckpoint(id, {
282
+ quest_status: v.status,
283
+ iteration: v.iteration ?? existing.checkpoints.length + 1,
284
+ changed: v.summary,
285
+ validation_summary: v.validation,
286
+ pr: v.pr,
287
+ head_sha: v.sha,
288
+ failed_approaches: v.failed,
289
+ compatible_expansion: v.expansion,
290
+ note: v.note,
291
+ });
292
+ if (p.values.json) out(JSON.stringify({ ok: true, id, status: q.front.status, checkpoints: q.checkpoints.length }));
293
+ else {
294
+ out(`Checkpoint recorded — quest ${id} is ${q.front.status}.`);
295
+ if (q.front.status === "complete") out("Remember: the final checkpoint should enumerate every Done-when item as Done/Blocked/Cancelled with evidence.");
296
+ if (q.front.status === "blocked") out("Blocked is a valid stop. State the exact blocker so a fresh session (or human) can rule.");
297
+ }
298
+ return 0;
299
+ },
300
+
301
+ async cancel(args, { out, cwd, env }) {
302
+ const p = parse("cancel", args, { reason: { type: "string" } }, { positionals: 1 });
303
+ if (p.help) return void out(renderCommandHelp("cancel")) ?? 0;
304
+ const id = requireId(p, "cancel");
305
+ const config = getStore(cwd, env);
306
+ openStore(config, { env }).cancelQuest(id, p.values.reason);
307
+ if (p.values.json) out(JSON.stringify({ ok: true, id, status: "cancelled" }));
308
+ else out(`Quest ${id} cancelled.`);
309
+ return 0;
310
+ },
311
+
312
+ async edit(args, { out, cwd, env }) {
313
+ const p = parse("edit", args, {
314
+ "add-done-when": { type: "string", multiple: true },
315
+ "add-milestone": { type: "string", multiple: true },
316
+ "add-context": { type: "string" },
317
+ rationale: { type: "string" },
318
+ }, { positionals: 1 });
319
+ if (p.help) return void out(renderCommandHelp("edit")) ?? 0;
320
+ const id = requireId(p, "edit");
321
+ const config = getStore(cwd, env);
322
+ openStore(config, { env }).editQuest(id, {
323
+ addDoneWhen: p.values["add-done-when"] ?? [],
324
+ addMilestone: p.values["add-milestone"] ?? [],
325
+ addContext: p.values["add-context"],
326
+ rationale: p.values.rationale,
327
+ });
328
+ if (p.values.json) out(JSON.stringify({ ok: true, id }));
329
+ else out(`Quest ${id} expanded (rationale recorded).`);
330
+ return 0;
331
+ },
332
+
333
+ async lint(args, { out, errOut, cwd, env }) {
334
+ const p = parse("lint", args, { all: { type: "boolean" } }, { positionals: 1 });
335
+ if (p.help) return void out(renderCommandHelp("lint")) ?? 0;
336
+ const config = getStore(cwd, env);
337
+ const store = openStore(config, { env });
338
+ let results;
339
+ if (p.values.all) {
340
+ results = store.lintAll();
341
+ } else {
342
+ const id = requireId(p, "lint");
343
+ const q = store.loadQuest(id);
344
+ results = [{ file: q.file, id, problems: lintRecord(q, { filename: q.file }) }];
345
+ }
346
+ const bad = results.filter((r) => r.problems.length);
347
+ if (p.values.json) out(JSON.stringify({ ok: bad.length === 0, results }));
348
+ else if (!bad.length) out(`lint: OK (${results.length} record${results.length === 1 ? "" : "s"})`);
349
+ else {
350
+ for (const r of bad) for (const prob of r.problems) errOut(`${r.file}: ${prob}`);
351
+ }
352
+ if (bad.length) throw new ContractError(`${bad.length} record(s) fail the contract`, { hint: "records are written by `quest` commands; hand-edits must follow contract-spec.md exactly" });
353
+ return 0;
354
+ },
355
+
356
+ async amend(args, { out, cwd, env }) {
357
+ const p = parse("amend", args, { text: { type: "string" } });
358
+ if (p.help) return void out(renderCommandHelp("amend")) ?? 0;
359
+ if (!p.values.text?.trim()) throw new UsageError("--text is required", { command: "amend" });
360
+ const config = getStore(cwd, env);
361
+ const { number } = local.appendAmendment(config.storeDir, p.values.text);
362
+ if (p.values.json) out(JSON.stringify({ ok: true, amendment: number }));
363
+ else out(`Amendment ${number} recorded — future sessions read it via \`quest protocol\`.`);
364
+ return 0;
365
+ },
366
+
367
+ async protocol(args, { out, cwd, env }) {
368
+ const p = parse("protocol", args, {});
369
+ if (p.help) return void out(renderCommandHelp("protocol")) ?? 0;
370
+ const config = getStore(cwd, env);
371
+ const base = readFileSync(join(PLUGIN_ROOT, "skills", "protocol", "references", "protocol.md"), "utf8");
372
+ out(base.trimEnd());
373
+ const amendmentsPath = join(config.storeDir, "amendments.md");
374
+ if (existsSync(amendmentsPath)) {
375
+ out("\n---\n");
376
+ out(readFileSync(amendmentsPath, "utf8").trimEnd());
377
+ }
378
+ return 0;
379
+ },
380
+
381
+ async runs(args, { out, cwd, env }) {
382
+ const p = parse("runs", args, { active: { type: "boolean" } });
383
+ if (p.help) return void out(renderCommandHelp("runs")) ?? 0;
384
+ const config = getStore(cwd, env);
385
+ const events = local.readRuns(config.storeDir);
386
+ const byRun = new Map();
387
+ for (const e of events) {
388
+ if (!byRun.has(e.run_id)) byRun.set(e.run_id, { run_id: e.run_id, quest: e.quest, worker: e.worker, started: null, ended: null, final_status: null, iterations: 0, cost_usd: 0 });
389
+ const r = byRun.get(e.run_id);
390
+ if (e.event === "run_started") r.started = e.ts;
391
+ if (e.event === "iteration_finished") { r.iterations += 1; r.cost_usd += e.cost_usd ?? 0; }
392
+ if (e.event === "run_ended") { r.ended = e.ts; r.final_status = e.final_status; }
393
+ }
394
+ let runs = [...byRun.values()];
395
+ if (p.values.active) runs = runs.filter((r) => !r.ended);
396
+ if (p.values.json) out(JSON.stringify(runs));
397
+ else if (!runs.length) out(p.values.active ? "No active runs." : "No recorded runs.");
398
+ else for (const r of runs) out(`${r.run_id} quest ${r.quest} ${r.worker} ${r.ended ? `ended ${r.final_status}` : "ACTIVE"} iters:${r.iterations} $${r.cost_usd.toFixed(2)}`);
399
+ return 0;
400
+ },
401
+ };
package/lib/config.mjs ADDED
@@ -0,0 +1,64 @@
1
+ // Store discovery and config resolution.
2
+ // Order: QUEST_DIR env (points at a .quests dir) → nearest `.quests/` walking
3
+ // up from cwd. QUEST_BACKEND env overrides the backend only. No store → the
4
+ // caller exits 3 (every command except `init`).
5
+
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ export class ConfigError extends Error {
10
+ constructor(message, { hint } = {}) {
11
+ super(message);
12
+ this.hint = hint;
13
+ }
14
+ }
15
+
16
+ export const BUILTIN_DEFAULTS = {
17
+ worker: "claude",
18
+ claude: { model: "opus", effort: "xhigh" },
19
+ codex: { model: "gpt-5.5", reasoning_effort: "medium" },
20
+ max_iterations: 8,
21
+ priority: "p2",
22
+ };
23
+
24
+ export function findStoreDir(cwd = process.cwd(), env = process.env) {
25
+ if (env.QUEST_DIR) {
26
+ const dir = resolve(env.QUEST_DIR);
27
+ if (!existsSync(join(dir, "config.json"))) {
28
+ throw new ConfigError(`QUEST_DIR points at "${dir}" but no config.json exists there`, { hint: "unset QUEST_DIR or run `quest init` in that directory's parent" });
29
+ }
30
+ return dir;
31
+ }
32
+ let dir = resolve(cwd);
33
+ for (;;) {
34
+ const candidate = join(dir, ".quests");
35
+ if (existsSync(join(candidate, "config.json")) && statSync(candidate).isDirectory()) return candidate;
36
+ const parent = dirname(dir);
37
+ if (parent === dir) return null;
38
+ dir = parent;
39
+ }
40
+ }
41
+
42
+ export function loadConfig(storeDir, env = process.env) {
43
+ const path = join(storeDir, "config.json");
44
+ let raw;
45
+ try {
46
+ raw = JSON.parse(readFileSync(path, "utf8"));
47
+ } catch (err) {
48
+ throw new ConfigError(`invalid ${path}: ${err.message}`, { hint: "fix the JSON by hand or re-run `quest init`" });
49
+ }
50
+ const backend = env.QUEST_BACKEND || raw.backend;
51
+ if (!["local", "github"].includes(backend)) {
52
+ throw new ConfigError(`backend must be "local" or "github" (got "${backend}")`, { hint: `edit ${path}` });
53
+ }
54
+ if (backend === "github" && !raw.github?.repo) {
55
+ throw new ConfigError('backend is "github" but github.repo is not set', { hint: `add {"github": {"repo": "owner/name"}} to ${path}` });
56
+ }
57
+ const defaults = {
58
+ ...BUILTIN_DEFAULTS,
59
+ ...raw.defaults,
60
+ claude: { ...BUILTIN_DEFAULTS.claude, ...raw.defaults?.claude },
61
+ codex: { ...BUILTIN_DEFAULTS.codex, ...raw.defaults?.codex },
62
+ };
63
+ return { storeDir, path, backend, github: raw.github ?? {}, defaults, notify: raw.notify ?? { command: "" } };
64
+ }