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.
@@ -0,0 +1,415 @@
1
+ // GitHub Issues backend: quest records live as issues, driven entirely through
2
+ // the `gh` CLI. The mapping (see skills/protocol/references/contract-spec.md,
3
+ // "GitHub backend mapping") is:
4
+ // record → issue (body = the same body sections, minus ## Checkpoints)
5
+ // frontmatter → an <!-- quest:meta --> HTML block at the top of the body
6
+ // (status/priority live in labels, not meta)
7
+ // id → issue number
8
+ // status → labels quest:todo|in-progress|blocked|complete|cancelled
9
+ // (+ marker label `quest`); issue state mirrored
10
+ // priority → labels quest-p0|p1|p2
11
+ // checkpoint → issue comment, byte-identical to the local checkpoint block
12
+ // parent/child→ child meta `parent:` + epic body ## Children task list
13
+ // Config, amendments and the runs journal stay LOCAL in .quests/ — only the
14
+ // records live remotely. Every gh failure surfaces gh's own stderr and exits 6;
15
+ // there is never a fallback to the local backend.
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import {
19
+ CHECKPOINT_MARKER,
20
+ ContractError,
21
+ appendToSection,
22
+ appendUnderCheckpoints,
23
+ assertTransition,
24
+ lintRecord,
25
+ makeCheckpoint,
26
+ nowIso,
27
+ parseCheckpoints,
28
+ recordFilename,
29
+ renderBody,
30
+ serializeRecord,
31
+ } from "./contract.mjs";
32
+ import { parseFrontmatter, serializeFrontmatter } from "./frontmatter.mjs";
33
+ import { NotFoundError } from "./store-local.mjs";
34
+
35
+ export class GhError extends Error {
36
+ constructor(message, { hint, stderr } = {}) {
37
+ super(message);
38
+ this.hint = hint;
39
+ this.stderr = stderr;
40
+ }
41
+ }
42
+
43
+ const MARKER_LABEL = "quest";
44
+ // Note the hyphen in the in-progress *label* vs the underscore in the *status*.
45
+ const STATUS_LABEL = { todo: "quest:todo", in_progress: "quest:in-progress", blocked: "quest:blocked", complete: "quest:complete", cancelled: "quest:cancelled" };
46
+ const LABEL_STATUS = { todo: "todo", "in-progress": "in_progress", blocked: "blocked", complete: "complete", cancelled: "cancelled" };
47
+ const PRIORITY_LABEL = { p0: "quest-p0", p1: "quest-p1", p2: "quest-p2" };
48
+
49
+ function gh(args, { env = process.env, input } = {}) {
50
+ try {
51
+ return execFileSync("gh", args, { encoding: "utf8", input, maxBuffer: 32 * 1024 * 1024, env });
52
+ } catch (err) {
53
+ if (err.code === "ENOENT") {
54
+ throw new GhError("the `gh` CLI was not found on PATH", { hint: "install GitHub CLI — https://cli.github.com — then `gh auth login`" });
55
+ }
56
+ const stderr = (err.stderr ? err.stderr.toString() : "").trim();
57
+ throw new GhError(`gh ${args.join(" ")} failed: ${stderr || err.message}`, {
58
+ hint: "check `gh auth status`, the repo name, and your access",
59
+ stderr,
60
+ });
61
+ }
62
+ }
63
+
64
+ // --- init helpers ---------------------------------------------------------
65
+
66
+ export function assertAuth(env) {
67
+ try {
68
+ // Scope to github.com: `gh auth status` (no host) also probes any configured
69
+ // enterprise hosts, and one of those timing out would wrongly fail the check
70
+ // even when github.com auth is green. `--repo owner/name` resolves to
71
+ // github.com, so that is the host that matters here.
72
+ gh(["auth", "status", "--hostname", "github.com"], { env });
73
+ } catch (err) {
74
+ if (err instanceof GhError) {
75
+ throw new GhError(`GitHub CLI is not ready: ${err.stderr || err.message}`, {
76
+ hint: "run `gh auth login` (install gh first if missing: https://cli.github.com)",
77
+ stderr: err.stderr,
78
+ });
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ export function ensureLabels(repo, env) {
85
+ const labels = [
86
+ [MARKER_LABEL, "5319e7", "quest record marker"],
87
+ [STATUS_LABEL.todo, "ededed", "quest status: todo"],
88
+ [STATUS_LABEL.in_progress, "1d76db", "quest status: in progress"],
89
+ [STATUS_LABEL.blocked, "b60205", "quest status: blocked"],
90
+ [STATUS_LABEL.complete, "0e8a16", "quest status: complete"],
91
+ [STATUS_LABEL.cancelled, "6a737d", "quest status: cancelled"],
92
+ [PRIORITY_LABEL.p0, "d93f0b", "quest priority p0"],
93
+ [PRIORITY_LABEL.p1, "fbca04", "quest priority p1"],
94
+ [PRIORITY_LABEL.p2, "c2e0c6", "quest priority p2"],
95
+ ];
96
+ for (const [name, color, description] of labels) {
97
+ gh(["label", "create", name, "--repo", repo, "--color", color, "--description", description, "--force"], { env });
98
+ }
99
+ }
100
+
101
+ // --- meta block <-> object ------------------------------------------------
102
+
103
+ function serializeMeta(meta) {
104
+ const inner = serializeFrontmatter(meta).split("\n").slice(1, -1).join("\n"); // drop the `---` fences
105
+ return `<!-- quest:meta\n${inner}\n-->`;
106
+ }
107
+
108
+ function parseMeta(inner) {
109
+ return parseFrontmatter(`---\n${inner}\n---\n`).front; // reuse the strict frontmatter parser
110
+ }
111
+
112
+ function splitStoredBody(body) {
113
+ const m = String(body).match(/<!-- quest:meta\n([\s\S]*?)\n-->/);
114
+ if (!m) throw new ContractError("quest issue is missing its <!-- quest:meta --> block", { hint: "records are written by `quest`; this issue was not created by quest or was hand-edited" });
115
+ const meta = parseMeta(m[1]);
116
+ const storedSections = body.slice(m.index + m[0].length).replace(/^\n+/, "").replace(/\n+$/, "");
117
+ return { meta, storedSections };
118
+ }
119
+
120
+ // --- label helpers --------------------------------------------------------
121
+
122
+ function statusFromLabels(labels) {
123
+ for (const l of labels ?? []) {
124
+ const m = l.name.match(/^quest:(.+)$/);
125
+ if (m && LABEL_STATUS[m[1]]) return LABEL_STATUS[m[1]];
126
+ }
127
+ throw new ContractError("quest issue has no quest:<status> label");
128
+ }
129
+
130
+ function priorityFromLabels(labels) {
131
+ for (const l of labels ?? []) {
132
+ const m = l.name.match(/^quest-(p[012])$/);
133
+ if (m) return m[1];
134
+ }
135
+ throw new ContractError("quest issue has no quest-p<n> priority label");
136
+ }
137
+
138
+ function buildFront(issue, meta) {
139
+ const front = {
140
+ id: issue.number,
141
+ title: meta.title,
142
+ status: statusFromLabels(issue.labels),
143
+ priority: priorityFromLabels(issue.labels),
144
+ worker: meta.worker,
145
+ model: meta.model,
146
+ ...(meta.effort !== undefined ? { effort: meta.effort } : {}),
147
+ max_iterations: meta.max_iterations,
148
+ ...(meta.max_cost !== undefined ? { max_cost: meta.max_cost } : {}),
149
+ ...(meta.parent !== undefined ? { parent: meta.parent } : {}),
150
+ ...(meta.depends_on !== undefined ? { depends_on: meta.depends_on } : {}),
151
+ created: meta.created,
152
+ updated: meta.updated,
153
+ };
154
+ return front;
155
+ }
156
+
157
+ function normalizeComment(raw) {
158
+ return String(raw).replace(/\r\n/g, "\n").replace(/\n+$/, "");
159
+ }
160
+
161
+ // Rebuild the in-memory record so it is byte-identical to what the local
162
+ // backend would hold: body = stored sections + a ## Checkpoints section into
163
+ // which every comment (checkpoints and cancel/expand notes alike) is folded in
164
+ // chronological order, exactly as store-local appends them.
165
+ function reconstruct(issue) {
166
+ const { meta, storedSections } = splitStoredBody(issue.body);
167
+ const front = buildFront(issue, meta);
168
+ const comments = (issue.comments ?? []).map((c) => normalizeComment(c.body));
169
+ let body = storedSections;
170
+ if (comments.length === 0) body = `${storedSections}\n\n## Checkpoints\n`;
171
+ else for (const block of comments) body = appendUnderCheckpoints(body, block);
172
+ // store-local parses records back out of the frontmatter fence, which leaves a
173
+ // single leading newline before `# title`; mirror it so `show --json` bodies
174
+ // are byte-identical across backends.
175
+ body = `\n${body}`;
176
+ const checkpoints = parseCheckpoints(body);
177
+ return {
178
+ path: issue.url ?? `#${front.id}`,
179
+ file: recordFilename(front.id, front.title),
180
+ text: serializeRecord(front, body),
181
+ front,
182
+ body,
183
+ checkpoints,
184
+ meta,
185
+ storedSections,
186
+ state: issue.state,
187
+ labels: issue.labels ?? [],
188
+ };
189
+ }
190
+
191
+ // --- gh reads -------------------------------------------------------------
192
+
193
+ const VIEW_FIELDS = "number,title,body,state,stateReason,labels,comments,url";
194
+ const LIST_FIELDS = "number,title,body,labels,comments,url";
195
+
196
+ function viewIssue(repo, id, env) {
197
+ let out;
198
+ try {
199
+ out = gh(["issue", "view", String(id), "--repo", repo, "--json", VIEW_FIELDS], { env });
200
+ } catch (err) {
201
+ if (err instanceof GhError && /could not resolve|not found|no issue/i.test(err.stderr ?? err.message)) {
202
+ throw new NotFoundError(id);
203
+ }
204
+ throw err;
205
+ }
206
+ const issue = JSON.parse(out);
207
+ if (!(issue.labels ?? []).some((l) => l.name === MARKER_LABEL)) throw new NotFoundError(id);
208
+ return issue;
209
+ }
210
+
211
+ function listIssues(repo, env) {
212
+ const out = gh(["issue", "list", "--repo", repo, "--label", MARKER_LABEL, "--state", "all", "--json", LIST_FIELDS, "--limit", "500"], { env });
213
+ return JSON.parse(out);
214
+ }
215
+
216
+ // --- gh writes (issue body / labels / state) ------------------------------
217
+
218
+ function writeStoredBody(repo, id, meta, storedSections, env) {
219
+ const body = `${serializeMeta(meta)}\n\n${storedSections.replace(/\n+$/, "")}\n`;
220
+ gh(["issue", "edit", String(id), "--repo", repo, "--body-file", "-"], { env, input: body });
221
+ }
222
+
223
+ // Swap the status label and mirror the transition onto the issue's open/closed
224
+ // state: complete → closed (completed), cancelled → closed (not planned),
225
+ // in_progress/blocked → reopen if the issue was closed.
226
+ function applyStatus(repo, id, currentLabels, currentState, newStatus, env) {
227
+ const oldLabel = (currentLabels ?? []).map((l) => l.name).find((n) => /^quest:/.test(n));
228
+ const newLabel = STATUS_LABEL[newStatus];
229
+ if (newLabel && oldLabel !== newLabel) {
230
+ const args = ["issue", "edit", String(id), "--repo", repo];
231
+ if (oldLabel) args.push("--remove-label", oldLabel);
232
+ args.push("--add-label", newLabel);
233
+ gh(args, { env });
234
+ }
235
+ if (newStatus === "complete") gh(["issue", "close", String(id), "--repo", repo, "--reason", "completed"], { env });
236
+ else if (newStatus === "cancelled") gh(["issue", "close", String(id), "--repo", repo, "--reason", "not planned"], { env });
237
+ else if ((newStatus === "in_progress" || newStatus === "blocked") && currentState === "CLOSED") gh(["issue", "reopen", String(id), "--repo", repo], { env });
238
+ }
239
+
240
+ function appendChild(storedSections, childNum) {
241
+ const line = `- [ ] #${childNum}`;
242
+ const lines = storedSections.split("\n");
243
+ const idx = lines.findIndex((l) => l.trim() === "## Children");
244
+ if (idx === -1) return `${storedSections.replace(/\n+$/, "")}\n\n## Children\n${line}`;
245
+ let end = idx + 1;
246
+ while (end < lines.length && !lines[end].startsWith("## ")) end++;
247
+ while (end > idx + 1 && lines[end - 1].trim() === "") end--;
248
+ lines.splice(end, 0, line);
249
+ return lines.join("\n");
250
+ }
251
+
252
+ // --- operational surface (mirrors store-local) ----------------------------
253
+
254
+ export function loadQuest(repo, id, env) {
255
+ return reconstruct(viewIssue(repo, id, env));
256
+ }
257
+
258
+ export function listQuests(repo, env) {
259
+ return listIssues(repo, env).map((issue) => {
260
+ const { meta } = splitStoredBody(issue.body);
261
+ const front = buildFront(issue, meta);
262
+ const comments = issue.comments;
263
+ const checkpoints = Array.isArray(comments)
264
+ ? comments.filter((c) => (c.body || "").includes(CHECKPOINT_MARKER)).length
265
+ : Number(comments) || 0;
266
+ return { ...front, checkpoints, file: recordFilename(front.id, front.title) };
267
+ });
268
+ }
269
+
270
+ // Same readiness rule as store-local.readyQuests, over the remote list.
271
+ export function readyQuests(repo, env) {
272
+ const all = listQuests(repo, env);
273
+ const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
274
+ return all
275
+ .filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
276
+ .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
277
+ }
278
+
279
+ export function createQuest(repo, defaults, fields, sections, env) {
280
+ const ts = nowIso();
281
+ const priority = fields.priority ?? defaults.priority;
282
+ const worker = fields.worker ?? defaults.worker;
283
+ const model = fields.model ?? "inherit";
284
+ const max_iterations = fields.max_iterations ?? defaults.max_iterations;
285
+
286
+ // Validate references before mutating anything.
287
+ let parentIssue;
288
+ if (fields.parent !== undefined) parentIssue = viewIssue(repo, fields.parent, env); // throws NotFound if missing/non-quest
289
+ if (fields.depends_on?.length) {
290
+ const known = new Set(listIssues(repo, env).map((i) => i.number));
291
+ for (const dep of fields.depends_on) {
292
+ if (!known.has(dep)) throw new ContractError(`depends_on references unknown quest ${dep}`, { hint: "create dependencies first, or fix the id" });
293
+ }
294
+ }
295
+
296
+ const meta = { title: fields.title, worker, model };
297
+ if (fields.effort) meta.effort = fields.effort;
298
+ meta.max_iterations = max_iterations;
299
+ if (fields.max_cost !== undefined) meta.max_cost = fields.max_cost;
300
+ if (fields.parent !== undefined) meta.parent = fields.parent;
301
+ if (fields.depends_on?.length) meta.depends_on = fields.depends_on;
302
+ meta.created = ts;
303
+ meta.updated = ts;
304
+
305
+ // Lint an equivalent local record (id assigned by GitHub later) before any gh
306
+ // mutation, so a malformed quest fails 5 without creating an issue.
307
+ const fullBody = renderBody(fields.title, sections);
308
+ const front0 = {
309
+ id: 1,
310
+ title: fields.title,
311
+ status: "todo",
312
+ priority,
313
+ worker,
314
+ model,
315
+ ...(fields.effort ? { effort: fields.effort } : {}),
316
+ max_iterations,
317
+ ...(fields.max_cost !== undefined ? { max_cost: fields.max_cost } : {}),
318
+ ...(fields.parent !== undefined ? { parent: fields.parent } : {}),
319
+ ...(fields.depends_on?.length ? { depends_on: fields.depends_on } : {}),
320
+ created: ts,
321
+ updated: ts,
322
+ };
323
+ const problems = lintRecord({ front: front0, body: fullBody, checkpoints: [] });
324
+ if (problems.length) throw new ContractError(`new quest fails lint:\n - ${problems.join("\n - ")}`, { hint: "see `quest create --help` for required fields" });
325
+
326
+ const storedSections = fullBody.replace(/\n*## Checkpoints\s*$/, "");
327
+ const issueBody = `${serializeMeta(meta)}\n\n${storedSections}\n`;
328
+ const args = ["issue", "create", "--repo", repo, "--title", fields.title, "--body-file", "-"];
329
+ for (const label of [MARKER_LABEL, STATUS_LABEL.todo, PRIORITY_LABEL[priority]]) args.push("--label", label);
330
+ const out = gh(args, { env, input: issueBody });
331
+ const m = out.match(/\/issues\/(\d+)/);
332
+ if (!m) throw new GhError(`could not parse the new issue number from gh output: ${out.trim()}`, { hint: "is gh >= 2.0?" });
333
+ const id = Number(m[1]);
334
+
335
+ if (parentIssue) {
336
+ const prec = reconstruct(parentIssue);
337
+ writeStoredBody(repo, fields.parent, { ...prec.meta, updated: nowIso() }, appendChild(prec.storedSections, id), env);
338
+ }
339
+
340
+ return { id, path: `https://github.com/${repo}/issues/${id}`, front: { ...front0, id } };
341
+ }
342
+
343
+ export function startQuest(repo, id, env) {
344
+ const rec = reconstruct(viewIssue(repo, id, env));
345
+ assertTransition(rec.front.status, "in_progress");
346
+ const updated = nowIso();
347
+ writeStoredBody(repo, id, { ...rec.meta, updated }, rec.storedSections, env);
348
+ applyStatus(repo, id, rec.labels, rec.state, "in_progress", env);
349
+ return { front: { ...rec.front, status: "in_progress", updated } };
350
+ }
351
+
352
+ export function appendCheckpoint(repo, id, cp, env) {
353
+ const rec = reconstruct(viewIssue(repo, id, env));
354
+ assertTransition(rec.front.status, cp.quest_status);
355
+ const block = makeCheckpoint({ ...cp, timestamp: nowIso() });
356
+ gh(["issue", "comment", String(id), "--repo", repo, "--body-file", "-"], { env, input: block });
357
+ const updated = nowIso();
358
+ writeStoredBody(repo, id, { ...rec.meta, updated }, rec.storedSections, env);
359
+ applyStatus(repo, id, rec.labels, rec.state, cp.quest_status, env);
360
+ return { front: { ...rec.front, status: cp.quest_status, updated }, checkpoints: [...rec.checkpoints, ...parseCheckpoints(block)] };
361
+ }
362
+
363
+ export function cancelQuest(repo, id, reason, env) {
364
+ if (!reason || !reason.trim()) throw new ContractError("cancellation requires --reason", { hint: 'example: quest cancel 4 --reason "superseded by quest 7"' });
365
+ const rec = reconstruct(viewIssue(repo, id, env));
366
+ assertTransition(rec.front.status, "cancelled");
367
+ const note = `**Cancelled ${nowIso()}** — ${reason.trim()}`;
368
+ gh(["issue", "comment", String(id), "--repo", repo, "--body-file", "-"], { env, input: note });
369
+ const updated = nowIso();
370
+ writeStoredBody(repo, id, { ...rec.meta, updated }, rec.storedSections, env);
371
+ applyStatus(repo, id, rec.labels, rec.state, "cancelled", env);
372
+ return { front: { ...rec.front, status: "cancelled", updated } };
373
+ }
374
+
375
+ export function editQuest(repo, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }, env) {
376
+ 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
+ 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
+ const rec = reconstruct(viewIssue(repo, id, env));
379
+ let sections = rec.storedSections;
380
+ for (const item of addDoneWhen) sections = appendToSection(sections, "## Done when", `- [ ] ${item}`);
381
+ for (const item of addMilestone) sections = appendToSection(sections, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
382
+ if (addContext) sections = appendToSection(sections, "## Context", addContext, { createAfter: "## Milestones" });
383
+ const updated = nowIso();
384
+ writeStoredBody(repo, id, { ...rec.meta, updated }, sections, env);
385
+ let comment;
386
+ if (["in_progress", "blocked"].includes(rec.front.status)) {
387
+ comment = makeCheckpoint({
388
+ timestamp: updated,
389
+ quest_status: rec.front.status,
390
+ iteration: rec.checkpoints.length + 1,
391
+ changed: `compatible expansion: ${[...addDoneWhen, ...addMilestone, addContext].filter(Boolean).join("; ").slice(0, 200)}`,
392
+ validation_summary: "contract expansion only; no execution this entry",
393
+ compatible_expansion: rationale.trim(),
394
+ });
395
+ } else {
396
+ comment = `**Expanded ${updated}** — ${rationale.trim()}`;
397
+ }
398
+ gh(["issue", "comment", String(id), "--repo", repo, "--body-file", "-"], { env, input: comment });
399
+ return { front: rec.front };
400
+ }
401
+
402
+ export function lintAll(repo, env) {
403
+ const results = [];
404
+ for (const issue of listIssues(repo, env)) {
405
+ try {
406
+ const rec = reconstruct(issue);
407
+ results.push({ file: rec.file, id: rec.front.id, problems: lintRecord(rec, { filename: rec.file }) });
408
+ } catch (err) {
409
+ results.push({ file: `#${issue.number}`, id: issue.number ?? null, problems: [err.message] });
410
+ }
411
+ }
412
+ return results;
413
+ }
414
+
415
+ export { CHECKPOINT_MARKER };
@@ -0,0 +1,224 @@
1
+ // Local backend: quest records as markdown files under .quests/quests/.
2
+ // This module (via the CLI) is the single write path for records — nothing
3
+ // else writes them. Read-modify-write is guarded by a mkdir lock.
4
+
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, statSync, appendFileSync } from "node:fs";
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";
8
+
9
+ export class NotFoundError extends Error {
10
+ constructor(id) {
11
+ super(`quest ${id} not found`);
12
+ this.hint = "run `quest list` to see known quests";
13
+ }
14
+ }
15
+
16
+ const LOCK_STALE_MS = 10_000;
17
+
18
+ function withLock(storeDir, fn) {
19
+ const lockDir = join(storeDir, ".lock");
20
+ const deadline = Date.now() + 5_000;
21
+ for (;;) {
22
+ try {
23
+ mkdirSync(lockDir);
24
+ break;
25
+ } catch {
26
+ let stale = false;
27
+ try {
28
+ stale = Date.now() - statSync(lockDir).mtimeMs > LOCK_STALE_MS;
29
+ } catch {
30
+ continue; // lock vanished between attempts — retry immediately
31
+ }
32
+ if (stale) {
33
+ try { rmdirSync(lockDir); } catch { /* racing unlocker — retry */ }
34
+ continue;
35
+ }
36
+ if (Date.now() > deadline) throw new ContractError("store is locked by another quest process", { hint: `if no other process is running, remove ${lockDir}` });
37
+ const wait = 25 + Math.random() * 50;
38
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait);
39
+ }
40
+ }
41
+ try {
42
+ return fn();
43
+ } finally {
44
+ try { rmdirSync(lockDir); } catch { /* already released */ }
45
+ }
46
+ }
47
+
48
+ function questsDir(storeDir) {
49
+ return join(storeDir, "quests");
50
+ }
51
+
52
+ function recordFiles(storeDir) {
53
+ const dir = questsDir(storeDir);
54
+ if (!existsSync(dir)) return [];
55
+ return readdirSync(dir).filter((f) => /^\d{3}-.*\.md$/.test(f)).sort();
56
+ }
57
+
58
+ export function initLocalStore(rootDir) {
59
+ const storeDir = join(rootDir, ".quests");
60
+ if (existsSync(join(storeDir, "config.json"))) {
61
+ throw new ContractError(`a quest store already exists at ${storeDir}`, { hint: "use the existing store, or delete it first if you really mean to start over" });
62
+ }
63
+ mkdirSync(questsDir(storeDir), { recursive: true });
64
+ return storeDir;
65
+ }
66
+
67
+ export function loadQuest(storeDir, id) {
68
+ const prefix = String(id).padStart(3, "0") + "-";
69
+ const file = recordFiles(storeDir).find((f) => f.startsWith(prefix));
70
+ if (!file) throw new NotFoundError(id);
71
+ const path = join(questsDir(storeDir), file);
72
+ const text = readFileSync(path, "utf8");
73
+ const record = parseRecord(text);
74
+ if (record.front.id !== Number(id)) throw new ContractError(`file ${file} claims id ${record.front.id}, expected ${id}`);
75
+ return { path, file, text, ...record };
76
+ }
77
+
78
+ export function listQuests(storeDir) {
79
+ return recordFiles(storeDir).map((file) => {
80
+ const { front, checkpoints } = parseRecord(readFileSync(join(questsDir(storeDir), file), "utf8"));
81
+ return { ...front, checkpoints: checkpoints.length, file };
82
+ });
83
+ }
84
+
85
+ export function readyQuests(storeDir) {
86
+ const all = listQuests(storeDir);
87
+ const done = new Set(all.filter((q) => q.status === "complete").map((q) => q.id));
88
+ return all
89
+ .filter((q) => q.status === "todo" && (q.depends_on ?? []).every((d) => done.has(d)))
90
+ .sort((a, b) => a.priority.localeCompare(b.priority) || a.id - b.id);
91
+ }
92
+
93
+ export function createQuest(storeDir, defaults, fields, bodySections) {
94
+ return withLock(storeDir, () => {
95
+ const existing = listQuests(storeDir);
96
+ const id = existing.reduce((max, q) => Math.max(max, q.id), 0) + 1;
97
+ const ts = nowIso();
98
+ const front = {
99
+ id,
100
+ title: fields.title,
101
+ status: "todo",
102
+ priority: fields.priority ?? defaults.priority,
103
+ worker: fields.worker ?? defaults.worker,
104
+ model: fields.model ?? "inherit",
105
+ ...(fields.effort ? { effort: fields.effort } : {}),
106
+ max_iterations: fields.max_iterations ?? defaults.max_iterations,
107
+ ...(fields.max_cost !== undefined ? { max_cost: fields.max_cost } : {}),
108
+ ...(fields.parent !== undefined ? { parent: fields.parent } : {}),
109
+ ...(fields.depends_on?.length ? { depends_on: fields.depends_on } : {}),
110
+ created: ts,
111
+ updated: ts,
112
+ };
113
+ if (fields.parent !== undefined && !existing.some((q) => q.id === fields.parent)) throw new NotFoundError(fields.parent);
114
+ for (const dep of front.depends_on ?? []) {
115
+ if (!existing.some((q) => q.id === dep)) throw new ContractError(`depends_on references unknown quest ${dep}`, { hint: "create dependencies first, or fix the id" });
116
+ }
117
+ const body = renderBody(fields.title, bodySections);
118
+ const record = { front, body, checkpoints: [] };
119
+ const problems = lintRecord(record);
120
+ if (problems.length) throw new ContractError(`new quest fails lint:\n - ${problems.join("\n - ")}`, { hint: "see `quest create --help` for required fields" });
121
+ const path = join(questsDir(storeDir), recordFilename(id, fields.title));
122
+ writeFileSync(path, serializeRecord(front, body), { flag: "wx" });
123
+ return { id, path, front };
124
+ });
125
+ }
126
+
127
+ function updateQuest(storeDir, id, mutate) {
128
+ return withLock(storeDir, () => {
129
+ const q = loadQuest(storeDir, id);
130
+ const next = mutate(q);
131
+ next.front.updated = nowIso();
132
+ const newText = serializeRecord(next.front, next.body);
133
+ const canonical = recordFilename(next.front.id, next.front.title);
134
+ writeFileSync(q.path, newText);
135
+ if (basename(q.path) !== canonical) renameSync(q.path, join(questsDir(storeDir), canonical));
136
+ return { path: q.path, file: q.file, text: newText, ...parseRecord(newText) };
137
+ });
138
+ }
139
+
140
+ export function startQuest(storeDir, id) {
141
+ return updateQuest(storeDir, id, (q) => {
142
+ assertTransition(q.front.status, "in_progress");
143
+ return { front: { ...q.front, status: "in_progress" }, body: q.body };
144
+ });
145
+ }
146
+
147
+ export function appendCheckpoint(storeDir, id, cp) {
148
+ return updateQuest(storeDir, id, (q) => {
149
+ assertTransition(q.front.status, cp.quest_status);
150
+ const block = makeCheckpoint({ ...cp, timestamp: nowIso() });
151
+ const body = appendUnderCheckpoints(q.body, block);
152
+ return { front: { ...q.front, status: cp.quest_status }, body };
153
+ });
154
+ }
155
+
156
+ export function cancelQuest(storeDir, id, reason) {
157
+ if (!reason || !reason.trim()) throw new ContractError("cancellation requires --reason", { hint: 'example: quest cancel 4 --reason "superseded by quest 7"' });
158
+ return updateQuest(storeDir, id, (q) => {
159
+ assertTransition(q.front.status, "cancelled");
160
+ const body = appendUnderCheckpoints(q.body, `**Cancelled ${nowIso()}** — ${reason.trim()}`);
161
+ return { front: { ...q.front, status: "cancelled" }, body };
162
+ });
163
+ }
164
+
165
+ export function editQuest(storeDir, id, { addDoneWhen = [], addMilestone = [], addContext, rationale }) {
166
+ 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
+ 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
+ return updateQuest(storeDir, id, (q) => {
169
+ let body = q.body;
170
+ for (const item of addDoneWhen) body = appendToSection(body, "## Done when", `- [ ] ${item}`);
171
+ for (const item of addMilestone) body = appendToSection(body, "## Milestones", `- [ ] ${item}`, { createAfter: "## Validation loop" });
172
+ if (addContext) body = appendToSection(body, "## Context", addContext, { createAfter: "## Milestones" });
173
+ if (["in_progress", "blocked"].includes(q.front.status)) {
174
+ const block = makeCheckpoint({
175
+ timestamp: nowIso(),
176
+ quest_status: q.front.status,
177
+ iteration: q.checkpoints.length + 1,
178
+ changed: `compatible expansion: ${[...addDoneWhen, ...addMilestone, addContext].filter(Boolean).join("; ").slice(0, 200)}`,
179
+ validation_summary: "contract expansion only; no execution this entry",
180
+ compatible_expansion: rationale.trim(),
181
+ });
182
+ body = appendUnderCheckpoints(body, block);
183
+ } else {
184
+ body = appendUnderCheckpoints(body, `**Expanded ${nowIso()}** — ${rationale.trim()}`);
185
+ }
186
+ return { front: q.front, body };
187
+ });
188
+ }
189
+
190
+ export function appendAmendment(storeDir, text) {
191
+ const path = join(storeDir, "amendments.md");
192
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "# Protocol amendments\n";
193
+ const n = (existing.match(/^## Amendment /gm) ?? []).length + 1;
194
+ const cleaned = existing.replace(/\n\(none yet\)\n?/, "\n");
195
+ writeFileSync(path, `${cleaned.replace(/\n+$/, "\n")}\n## Amendment ${n} — ${nowIso()}\n\n${text.trim()}\n`);
196
+ return { number: n, path };
197
+ }
198
+
199
+ export function appendRunEvent(storeDir, event) {
200
+ appendFileSync(join(storeDir, "runs.ndjson"), JSON.stringify(event) + "\n");
201
+ }
202
+
203
+ export function readRuns(storeDir) {
204
+ const path = join(storeDir, "runs.ndjson");
205
+ if (!existsSync(path)) return [];
206
+ return readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
207
+ }
208
+
209
+ export function lintAll(storeDir) {
210
+ const results = [];
211
+ for (const file of recordFiles(storeDir)) {
212
+ const text = readFileSync(join(questsDir(storeDir), file), "utf8");
213
+ try {
214
+ const record = parseRecord(text);
215
+ const problems = lintRecord(record, { filename: file });
216
+ results.push({ file, id: record.front.id, problems });
217
+ } catch (err) {
218
+ results.push({ file, id: null, problems: [err.message] });
219
+ }
220
+ }
221
+ return results;
222
+ }
223
+
224
+ export { CHECKPOINT_MARKER };
package/lib/store.mjs ADDED
@@ -0,0 +1,37 @@
1
+ // Backend seam. Given a loaded config it returns one uniform record-store
2
+ // interface, so cli.mjs handlers are backend-agnostic. Only quest *records*
3
+ // are backend-specific; config, amendments and the runs journal always stay
4
+ // local in .quests/ (the handlers for those keep calling store-local directly).
5
+
6
+ import * as local from "./store-local.mjs";
7
+ import * as github from "./store-github.mjs";
8
+
9
+ export function openStore(config, ctx = {}) {
10
+ if (config.backend === "github") {
11
+ const repo = config.github.repo;
12
+ const env = ctx.env ?? process.env;
13
+ return {
14
+ createQuest: (defaults, fields, sections) => github.createQuest(repo, defaults, fields, sections, env),
15
+ loadQuest: (id) => github.loadQuest(repo, id, env),
16
+ listQuests: () => github.listQuests(repo, env),
17
+ readyQuests: () => github.readyQuests(repo, env),
18
+ startQuest: (id) => github.startQuest(repo, id, env),
19
+ appendCheckpoint: (id, cp) => github.appendCheckpoint(repo, id, cp, env),
20
+ cancelQuest: (id, reason) => github.cancelQuest(repo, id, reason, env),
21
+ editQuest: (id, changes) => github.editQuest(repo, id, changes, env),
22
+ lintAll: () => github.lintAll(repo, env),
23
+ };
24
+ }
25
+ const dir = config.storeDir;
26
+ return {
27
+ createQuest: (defaults, fields, sections) => local.createQuest(dir, defaults, fields, sections),
28
+ loadQuest: (id) => local.loadQuest(dir, id),
29
+ listQuests: () => local.listQuests(dir),
30
+ readyQuests: () => local.readyQuests(dir),
31
+ startQuest: (id) => local.startQuest(dir, id),
32
+ appendCheckpoint: (id, cp) => local.appendCheckpoint(dir, id, cp),
33
+ cancelQuest: (id, reason) => local.cancelQuest(dir, id, reason),
34
+ editQuest: (id, changes) => local.editQuest(dir, id, changes),
35
+ lintAll: () => local.lintAll(dir),
36
+ };
37
+ }