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,225 @@
1
+ // The quest record contract: field validation, canonical serialization,
2
+ // checkpoint format, lint rules, status transitions. Byte-level spec:
3
+ // skills/protocol/references/contract-spec.md — this module IS that spec in code.
4
+
5
+ import { parseFrontmatter, serializeFrontmatter, FrontmatterError } from "./frontmatter.mjs";
6
+
7
+ export const STATUSES = ["todo", "in_progress", "blocked", "complete", "cancelled"];
8
+ export const QUEST_STATUSES = ["in_progress", "complete", "blocked"];
9
+ export const PRIORITIES = ["p0", "p1", "p2"];
10
+ export const WORKERS = ["claude", "codex"];
11
+ export const CHECKPOINT_MARKER = "<!-- quest:checkpoint -->";
12
+
13
+ const REQUIRED_FRONT = ["id", "title", "status", "priority", "worker", "model", "max_iterations", "created", "updated"];
14
+ const OPTIONAL_FRONT = ["effort", "max_cost", "parent", "depends_on"];
15
+ const FRONT_ORDER = ["id", "title", "status", "priority", "worker", "model", "effort", "max_iterations", "max_cost", "parent", "depends_on", "created", "updated"];
16
+ const REQUIRED_SECTIONS = ["## Objective", "## Done when", "## Validation loop"];
17
+ const SECTION_ORDER = ["## Objective", "## Done when", "## Validation loop", "## Constraints", "## Milestones", "## Context", "## Out of scope", "## Checkpoints"];
18
+
19
+ export class ContractError extends Error {
20
+ constructor(message, { hint } = {}) {
21
+ super(message);
22
+ this.hint = hint;
23
+ }
24
+ }
25
+
26
+ export function nowIso() {
27
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
28
+ }
29
+
30
+ export function slugify(title) {
31
+ let slug = String(title)
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, "-")
34
+ .replace(/^-+|-+$/g, "");
35
+ if (slug.length > 40) {
36
+ const cut = slug.slice(0, 41).lastIndexOf("-");
37
+ slug = slug.slice(0, cut > 0 ? cut : 40);
38
+ }
39
+ return slug || "quest";
40
+ }
41
+
42
+ export function recordFilename(id, title) {
43
+ return `${String(id).padStart(3, "0")}-${slugify(title)}.md`;
44
+ }
45
+
46
+ export function parseRecord(text) {
47
+ let front, body;
48
+ try {
49
+ ({ front, body } = parseFrontmatter(text));
50
+ } catch (err) {
51
+ if (err instanceof FrontmatterError) throw new ContractError(err.message, { hint: "records are written by `quest` commands; see contract-spec.md for the exact format" });
52
+ throw err;
53
+ }
54
+ return { front, body, checkpoints: parseCheckpoints(body) };
55
+ }
56
+
57
+ export function parseCheckpoints(body) {
58
+ const checkpoints = [];
59
+ const lines = body.split("\n");
60
+ for (let i = 0; i < lines.length; i++) {
61
+ if (lines[i].trim() !== CHECKPOINT_MARKER) continue;
62
+ const header = lines[i + 1] ?? "";
63
+ const hm = header.match(/^### (\S+) — quest_status: (\S+)$/);
64
+ if (!hm) throw new ContractError(`malformed checkpoint header after marker at line ${i + 1}: "${header}"`);
65
+ const cp = { timestamp: hm[1], quest_status: hm[2], fields: {}, note: "" };
66
+ let j = i + 2;
67
+ for (; j < lines.length; j++) {
68
+ const fm = lines[j].match(/^- ([a-z_]+): (.*)$/);
69
+ if (!fm) break;
70
+ cp.fields[fm[1]] = fm[2];
71
+ }
72
+ const noteLines = [];
73
+ for (; j < lines.length && lines[j].trim() !== CHECKPOINT_MARKER && !lines[j].startsWith("### "); j++) {
74
+ noteLines.push(lines[j]);
75
+ }
76
+ cp.note = noteLines.join("\n").trim();
77
+ checkpoints.push(cp);
78
+ }
79
+ return checkpoints;
80
+ }
81
+
82
+ export function makeCheckpoint({ timestamp, quest_status, iteration, changed, validation_summary, pr, head_sha, failed_approaches, compatible_expansion, note }) {
83
+ if (!QUEST_STATUSES.includes(quest_status)) {
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
+ }
86
+ for (const [name, v] of [["iteration", iteration], ["changed", changed], ["validation_summary", validation_summary]]) {
87
+ if (v === undefined || String(v).trim() === "") throw new ContractError(`checkpoint field "${name}" is required`, { hint: "see `quest checkpoint --help` for the full field list" });
88
+ }
89
+ if (quest_status === "complete" && !/`[^`]+`/.test(validation_summary)) {
90
+ throw new ContractError("a `complete` checkpoint's validation_summary must cite at least one backticked command — commands, not adjectives", { hint: 'example: --validation "`npm test` → 42 passed"' });
91
+ }
92
+ const oneLine = (name, v) => {
93
+ if (/\n/.test(String(v))) throw new ContractError(`checkpoint field "${name}" must be a single line (use the trailing note for prose)`);
94
+ return String(v);
95
+ };
96
+ const out = [CHECKPOINT_MARKER, `### ${timestamp} — quest_status: ${quest_status}`, `- iteration: ${oneLine("iteration", iteration)}`];
97
+ if (pr) out.push(`- pr: ${oneLine("pr", pr)}`);
98
+ if (head_sha) out.push(`- head_sha: ${oneLine("head_sha", head_sha)}`);
99
+ out.push(`- changed: ${oneLine("changed", changed)}`);
100
+ out.push(`- validation_summary: ${oneLine("validation_summary", validation_summary)}`);
101
+ if (failed_approaches) out.push(`- failed_approaches: ${oneLine("failed_approaches", failed_approaches)}`);
102
+ if (compatible_expansion) out.push(`- compatible_expansion: ${oneLine("compatible_expansion", compatible_expansion)}`);
103
+ if (note && String(note).trim() !== "") out.push("", String(note).trim());
104
+ return out.join("\n");
105
+ }
106
+
107
+ export function serializeRecord(front, body) {
108
+ const ordered = {};
109
+ for (const key of FRONT_ORDER) if (key in front) ordered[key] = front[key];
110
+ for (const key of Object.keys(front)) if (!(key in ordered)) ordered[key] = front[key];
111
+ return `${serializeFrontmatter(ordered)}\n\n${body.replace(/^\n+/, "")}`;
112
+ }
113
+
114
+ const TRANSITIONS = {
115
+ todo: ["in_progress", "cancelled"],
116
+ in_progress: ["in_progress", "complete", "blocked", "cancelled"],
117
+ blocked: ["in_progress", "blocked", "cancelled"],
118
+ complete: [],
119
+ cancelled: [],
120
+ };
121
+
122
+ export function assertTransition(from, to) {
123
+ if (!(TRANSITIONS[from] ?? []).includes(to)) {
124
+ throw new ContractError(`illegal status transition: ${from} → ${to}`, {
125
+ hint: from === "complete" || from === "cancelled" ? `${from} is terminal — file a new quest instead` : `legal from "${from}": ${TRANSITIONS[from].join(", ") || "(none)"}`,
126
+ });
127
+ }
128
+ }
129
+
130
+ export function lintRecord({ front, body, checkpoints }, { filename } = {}) {
131
+ const problems = [];
132
+ for (const key of REQUIRED_FRONT) {
133
+ if (!(key in front)) problems.push(`missing frontmatter key "${key}"`);
134
+ }
135
+ for (const key of Object.keys(front)) {
136
+ if (!REQUIRED_FRONT.includes(key) && !OPTIONAL_FRONT.includes(key)) problems.push(`unknown frontmatter key "${key}"`);
137
+ }
138
+ if ("id" in front && (!Number.isInteger(front.id) || front.id < 1)) problems.push(`id must be a positive integer (got ${JSON.stringify(front.id)})`);
139
+ if ("status" in front && !STATUSES.includes(front.status)) problems.push(`status must be one of ${STATUSES.join(" | ")} (got "${front.status}")`);
140
+ if ("priority" in front && !PRIORITIES.includes(front.priority)) problems.push(`priority must be one of ${PRIORITIES.join(" | ")} (got "${front.priority}")`);
141
+ if ("worker" in front && !WORKERS.includes(front.worker)) problems.push(`worker must be one of ${WORKERS.join(" | ")} (got "${front.worker}")`);
142
+ if ("max_iterations" in front && (!Number.isInteger(front.max_iterations) || front.max_iterations < 1)) problems.push("max_iterations must be a positive integer");
143
+ if ("depends_on" in front && (!Array.isArray(front.depends_on) || front.depends_on.some((d) => !Number.isInteger(d)))) problems.push("depends_on must be a list of quest ids");
144
+ if ("parent" in front && !Number.isInteger(front.parent)) problems.push("parent must be a quest id");
145
+ for (const ts of ["created", "updated"]) {
146
+ if (ts in front && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(String(front[ts]))) problems.push(`${ts} must be UTC ISO-8601 ending in Z`);
147
+ }
148
+ for (const section of REQUIRED_SECTIONS) {
149
+ if (!body.split("\n").some((l) => l.trim() === section)) problems.push(`missing required section "${section}"`);
150
+ }
151
+ const present = SECTION_ORDER.filter((s) => body.split("\n").some((l) => l.trim() === s));
152
+ const actual = body.split("\n").map((l) => l.trim()).filter((l) => SECTION_ORDER.includes(l));
153
+ if (JSON.stringify(present) !== JSON.stringify(actual)) problems.push(`sections out of canonical order (expected ${present.join(" → ")})`);
154
+ if (filename && "id" in front && "title" in front) {
155
+ const expected = recordFilename(front.id, front.title);
156
+ if (filename !== expected) problems.push(`filename "${filename}" does not match canonical "${expected}"`);
157
+ }
158
+ for (const [i, cp] of checkpoints.entries()) {
159
+ if (!QUEST_STATUSES.includes(cp.quest_status)) problems.push(`checkpoint ${i + 1}: quest_status "${cp.quest_status}" not in ${QUEST_STATUSES.join(" | ")}`);
160
+ for (const req of ["iteration", "changed", "validation_summary"]) {
161
+ if (!cp.fields[req]) problems.push(`checkpoint ${i + 1}: missing required field "${req}"`);
162
+ }
163
+ if (cp.quest_status === "complete" && cp.fields.validation_summary && !/`[^`]+`/.test(cp.fields.validation_summary)) {
164
+ problems.push(`checkpoint ${i + 1}: complete requires a backticked command in validation_summary`);
165
+ }
166
+ }
167
+ if (front.status === "complete") {
168
+ const last = checkpoints.at(-1);
169
+ if (!last || last.quest_status !== "complete") problems.push('status is "complete" but the last checkpoint is not quest_status: complete');
170
+ }
171
+ return problems;
172
+ }
173
+
174
+ // Body rendering + section editing. Shared by every backend so the body bytes
175
+ // are identical whether a record lives on disk (store-local) or as a GitHub
176
+ // issue (store-github).
177
+
178
+ export function renderBody(title, s) {
179
+ const lines = [`# ${title}`, "", "## Objective", s.objective.trim(), "", "## Done when"];
180
+ for (const item of s.doneWhen) lines.push(`- [ ] ${item}`);
181
+ lines.push("", "## Validation loop", "```bash", s.validation.trim(), "```");
182
+ if (s.constraints?.length) {
183
+ lines.push("", "## Constraints");
184
+ for (const c of s.constraints) lines.push(`- ${c}`);
185
+ }
186
+ if (s.milestones?.length) {
187
+ lines.push("", "## Milestones");
188
+ for (const [i, m] of s.milestones.entries()) lines.push(`- [ ] M${i + 1} — ${m}`);
189
+ }
190
+ if (s.context) lines.push("", "## Context", s.context.trim());
191
+ if (s.outOfScope?.length) {
192
+ lines.push("", "## Out of scope");
193
+ for (const o of s.outOfScope) lines.push(`- ${o}`);
194
+ }
195
+ lines.push("", "## Checkpoints", "");
196
+ return lines.join("\n");
197
+ }
198
+
199
+ export function appendUnderCheckpoints(body, block) {
200
+ const hasSection = body.split("\n").some((l) => l.trim() === "## Checkpoints");
201
+ const trimmed = body.replace(/\n+$/, "");
202
+ return hasSection ? `${trimmed}\n\n${block}\n` : `${trimmed}\n\n## Checkpoints\n\n${block}\n`;
203
+ }
204
+
205
+ export function appendToSection(body, section, line, { createAfter } = {}) {
206
+ const lines = body.split("\n");
207
+ const start = lines.findIndex((l) => l.trim() === section);
208
+ if (start === -1) {
209
+ if (!createAfter) throw new ContractError(`section "${section}" not found`);
210
+ const anchorEnd = sectionEnd(lines, createAfter);
211
+ lines.splice(anchorEnd, 0, "", section, line);
212
+ return lines.join("\n");
213
+ }
214
+ lines.splice(sectionEnd(lines, section), 0, line);
215
+ return lines.join("\n");
216
+ }
217
+
218
+ function sectionEnd(lines, section) {
219
+ const start = lines.findIndex((l) => l.trim() === section);
220
+ if (start === -1) throw new ContractError(`section "${section}" not found`);
221
+ let end = start + 1;
222
+ while (end < lines.length && !lines[end].startsWith("## ")) end++;
223
+ while (end > start + 1 && lines[end - 1].trim() === "") end--;
224
+ return end;
225
+ }
@@ -0,0 +1,61 @@
1
+ // Strict YAML-subset frontmatter: only `key: scalar` and `key: [a, b]` lines.
2
+ // No nesting, no multi-line values, no quoting. Reject anything else with a
3
+ // precise error — records are written only by this tool, so canonical form is
4
+ // guaranteed and hand-edits that stray from it must fail loudly.
5
+
6
+ export class FrontmatterError extends Error {}
7
+
8
+ const KEY_RE = /^([a-z][a-z0-9_]*):(?: (.*))?$/;
9
+
10
+ function parseScalar(raw) {
11
+ const s = raw.trim();
12
+ if (/^-?\d+$/.test(s)) return Number(s);
13
+ if (/^-?\d+\.\d+$/.test(s)) return Number(s);
14
+ return s;
15
+ }
16
+
17
+ export function parseFrontmatter(text) {
18
+ const lines = text.split("\n");
19
+ if (lines[0] !== "---") throw new FrontmatterError("record must start with `---` frontmatter fence");
20
+ const end = lines.indexOf("---", 1);
21
+ if (end === -1) throw new FrontmatterError("unterminated frontmatter: closing `---` fence not found");
22
+ const front = {};
23
+ for (let i = 1; i < end; i++) {
24
+ const line = lines[i];
25
+ if (line.trim() === "") continue;
26
+ const m = line.match(KEY_RE);
27
+ if (!m) {
28
+ throw new FrontmatterError(
29
+ `frontmatter line ${i + 1} is not \`key: value\`: "${line}" (nested/multi-line YAML is not supported; see contract-spec.md)`,
30
+ );
31
+ }
32
+ const [, key, rawValue] = m;
33
+ if (key in front) throw new FrontmatterError(`duplicate frontmatter key "${key}"`);
34
+ if (rawValue === undefined || rawValue.trim() === "") {
35
+ throw new FrontmatterError(`frontmatter key "${key}" has no value (omit optional keys entirely)`);
36
+ }
37
+ const v = rawValue.trim();
38
+ if (v.startsWith("[")) {
39
+ if (!v.endsWith("]")) throw new FrontmatterError(`frontmatter key "${key}": inline list must close with ]`);
40
+ const inner = v.slice(1, -1).trim();
41
+ front[key] = inner === "" ? [] : inner.split(",").map((x) => parseScalar(x));
42
+ } else {
43
+ front[key] = parseScalar(v);
44
+ }
45
+ }
46
+ return { front, body: lines.slice(end + 1).join("\n") };
47
+ }
48
+
49
+ export function serializeFrontmatter(front) {
50
+ const out = ["---"];
51
+ for (const [key, value] of Object.entries(front)) {
52
+ if (value === undefined || value === null) continue;
53
+ if (Array.isArray(value)) {
54
+ out.push(`${key}: [${value.join(", ")}]`);
55
+ } else {
56
+ out.push(`${key}: ${value}`);
57
+ }
58
+ }
59
+ out.push("---");
60
+ return out.join("\n");
61
+ }
package/lib/help.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // All human-facing guidance lives here so it can be snapshot-tested and can't
2
+ // drift silently from the contract. Keep texts deterministic (no timestamps).
3
+
4
+ export const INTRO = `quest — goal-loop engineering for coding agents
5
+
6
+ Work is planned as quests: contracts with an Objective, evidence-checkable
7
+ "Done when" items, and a Validation loop. Agents (or you) work them in
8
+ iterations that end in checkpoints; \`quest-run\` drives headless workers.`;
9
+
10
+ export const COMMANDS = {
11
+ init: {
12
+ purpose: "Create a quest store (.quests/) in the current directory",
13
+ usage: "quest init [--backend local|github] [--repo owner/name] [--agents-md]",
14
+ flags: [
15
+ ["--backend <b>", "record storage: local files (default) or github issues"],
16
+ ["--repo <owner/name>", "required with --backend github"],
17
+ ["--agents-md", "append a quest orientation section to ./AGENTS.md"],
18
+ ],
19
+ example: "quest init",
20
+ },
21
+ create: {
22
+ purpose: "Create a new quest (the only way records are born)",
23
+ usage: "quest create --title T --objective O --done-when D --validation V [more flags]",
24
+ flags: [
25
+ ["--title <t>", "quest title (required)"],
26
+ ["--objective <o>", "one concrete outcome, ≤3 sentences (required)"],
27
+ ["--done-when <d>", "evidence-checkable condition; repeatable (≥1 required)"],
28
+ ["--validation <cmds>", "exact commands run each iteration (required)"],
29
+ ["--constraint <c>", "hard guardrail; repeatable"],
30
+ ["--milestone <m>", "discrete testable unit; repeatable"],
31
+ ["--context <text>", "pointers: files + symbols, related quests"],
32
+ ["--out-of-scope <o>", "explicit exclusion; repeatable"],
33
+ ["--parent <id>", "epic this quest belongs to"],
34
+ ["--depends-on <ids>", "comma-separated quest ids that must complete first"],
35
+ ["--worker <w>", "claude (default) or codex"],
36
+ ["--model <m>", "worker model; default inherit (store config)"],
37
+ ["--effort <e>", "worker reasoning effort (e.g. xhigh)"],
38
+ ["--max-iterations <n>", "session budget (default from config)"],
39
+ ["--max-cost <usd>", "cost budget for headless runs"],
40
+ ["--priority <p>", "p0 | p1 | p2 (default from config)"],
41
+ ],
42
+ example: `quest create --title "Add dark mode" \\
43
+ --objective "Settings page offers a dark theme that persists." \\
44
+ --done-when "toggling theme updates the UI and survives reload" \\
45
+ --done-when "\`npm test\` passes including new theme tests" \\
46
+ --validation "npm test"`,
47
+ },
48
+ list: {
49
+ purpose: "List quests (filter by status, parent, or readiness)",
50
+ usage: "quest list [--status <s>] [--parent <id>] [--ready] [--json]",
51
+ flags: [
52
+ ["--status <s>", "todo | in_progress | blocked | complete | cancelled"],
53
+ ["--parent <id>", "children of an epic"],
54
+ ["--ready", "todo quests whose depends_on are all complete (dispatch order)"],
55
+ ["--json", "machine-readable output"],
56
+ ],
57
+ example: "quest list --ready",
58
+ },
59
+ show: {
60
+ purpose: "Show a quest record in full",
61
+ usage: "quest show <id> [--json]",
62
+ flags: [["--json", "parsed record: frontmatter, body, checkpoints[]"]],
63
+ example: "quest show 3 --json",
64
+ },
65
+ start: {
66
+ purpose: "Mark a quest in_progress (todo → in_progress)",
67
+ usage: "quest start <id>",
68
+ flags: [],
69
+ example: "quest start 3",
70
+ },
71
+ checkpoint: {
72
+ purpose: "Record iteration evidence and drive the quest's status",
73
+ usage: "quest checkpoint <id> --status <s> --summary <what> --validation <evidence> [flags]",
74
+ flags: [
75
+ ["--status <s>", "in_progress | complete | blocked (required)"],
76
+ ["--summary <text>", "what changed, one line per milestone touched (required)"],
77
+ ["--validation <text>", "commands + observed results; complete requires backticked commands (required)"],
78
+ ["--iteration <n>", "iteration number (default: previous + 1)"],
79
+ ["--pr <url>", "pull request for this quest's work"],
80
+ ["--sha <sha>", "HEAD sha the evidence was gathered at"],
81
+ ["--failed <text>", "approaches that failed, with reasons"],
82
+ ["--expansion <text>", "rationale when scope compatibly expanded"],
83
+ ["--note <text>", "free-form trailing note"],
84
+ ],
85
+ example: `quest checkpoint 3 --status complete \\
86
+ --summary "M2 done — theme persistence via localStorage" \\
87
+ --validation "\`npm test\` → 42 passed, 0 failed"`,
88
+ },
89
+ cancel: {
90
+ purpose: "Cancel a quest (terminal; reason is recorded)",
91
+ usage: "quest cancel <id> --reason <why>",
92
+ flags: [["--reason <why>", "required — recorded in the record"]],
93
+ example: 'quest cancel 4 --reason "superseded by quest 7"',
94
+ },
95
+ edit: {
96
+ purpose: "Compatibly expand a quest (additions only; anchors are immutable)",
97
+ usage: "quest edit <id> [--add-done-when D]… [--add-milestone M]… [--add-context C] --rationale <why>",
98
+ flags: [
99
+ ["--add-done-when <d>", "append a Done-when item; repeatable"],
100
+ ["--add-milestone <m>", "append a milestone; repeatable"],
101
+ ["--add-context <text>", "append to Context"],
102
+ ["--rationale <why>", "required — why this is a compatible expansion"],
103
+ ],
104
+ example: 'quest edit 3 --add-done-when "works in Safari" --rationale "same objective, missed browser"',
105
+ },
106
+ lint: {
107
+ purpose: "Check records against the contract spec",
108
+ usage: "quest lint [<id> | --all]",
109
+ flags: [["--all", "lint every record in the store"]],
110
+ example: "quest lint --all",
111
+ },
112
+ amend: {
113
+ purpose: "Append a numbered protocol amendment (retro output)",
114
+ usage: "quest amend --text <amendment>",
115
+ flags: [["--text <t>", "the amendment, imperative, citing its evidence (required)"]],
116
+ example: 'quest amend --text "Always re-verify cited symbols at orient time — quest 12 lost an iteration to a stale reference."',
117
+ },
118
+ protocol: {
119
+ purpose: "Print the loop protocol + this store's local amendments",
120
+ usage: "quest protocol",
121
+ flags: [],
122
+ example: "quest protocol",
123
+ },
124
+ runs: {
125
+ purpose: "Show headless runner activity (from .quests/runs.ndjson)",
126
+ usage: "quest runs [--active] [--json]",
127
+ flags: [
128
+ ["--active", "only runs that started and have not ended"],
129
+ ["--json", "machine-readable output"],
130
+ ],
131
+ example: "quest runs --active",
132
+ },
133
+ };
134
+
135
+ export function renderCommandHelp(name) {
136
+ const c = COMMANDS[name];
137
+ const lines = [c.purpose, "", `Usage: ${c.usage}`];
138
+ if (c.flags.length) {
139
+ lines.push("", "Flags:");
140
+ const w = Math.max(...c.flags.map(([f]) => f.length));
141
+ for (const [flag, desc] of c.flags) lines.push(` ${flag.padEnd(w)} ${desc}`);
142
+ }
143
+ lines.push("", "Example:", ...c.example.split("\n").map((l) => ` ${l}`));
144
+ return lines.join("\n");
145
+ }
146
+
147
+ export function renderGeneralHelp() {
148
+ const lines = [INTRO, "", "Commands:"];
149
+ const w = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
150
+ for (const [name, c] of Object.entries(COMMANDS)) lines.push(` quest ${name.padEnd(w)} ${c.purpose}`);
151
+ lines.push("", "Run `quest <command> --help` for flags and a copy-pasteable example.", "Headless workers: see `quest-run --help`.");
152
+ return lines.join("\n");
153
+ }
154
+
155
+ export function renderStatusOverview({ counts, ready, activeRuns, backend }) {
156
+ const lines = [`Quest store: ${backend} backend`];
157
+ const parts = ["todo", "in_progress", "blocked", "complete", "cancelled"]
158
+ .filter((s) => counts[s])
159
+ .map((s) => `${counts[s]} ${s}`);
160
+ lines.push(parts.length ? `Quests: ${parts.join(" · ")}` : "Quests: none yet");
161
+ if (ready.length) {
162
+ lines.push("", "Ready to work (dependencies met):");
163
+ for (const q of ready.slice(0, 3)) lines.push(` ${q.id}. [${q.priority}] ${q.title}`);
164
+ lines.push("", "Next: `quest show <id>` to read one, or dispatch with `quest-run <id>`.");
165
+ } else if (!parts.length) {
166
+ lines.push("", "Next: create your first quest — see `quest create --help` (or use the /quest:plan skill).");
167
+ } else {
168
+ lines.push("", "Nothing ready to start. `quest list` to see everything.");
169
+ }
170
+ if (activeRuns > 0) lines.push("", `Active headless runs: ${activeRuns} — inspect with \`quest runs --active\`.`);
171
+ return lines.join("\n");
172
+ }
173
+
174
+ export function renderNoStore() {
175
+ return [
176
+ "No quest store found (searched for .quests/ from here upward).",
177
+ "",
178
+ "Get started:",
179
+ " quest init create a local store here",
180
+ " quest init --backend github --repo owner/name",
181
+ "",
182
+ "Then: `quest create --help` shows how to author your first quest.",
183
+ ].join("\n");
184
+ }
185
+
186
+ export function renderInitNextSteps(backend) {
187
+ return [
188
+ "",
189
+ "Store created. Next steps:",
190
+ " 1. Author a quest: quest create --help (or /quest:plan in your agent session)",
191
+ " 2. Check it: quest lint --all",
192
+ " 3. Work it: /quest:work <id> in-session, or quest-run <id> headless",
193
+ ...(backend === "github" ? ["", "Records live as GitHub issues; config and amendments stay local in .quests/."] : []),
194
+ ].join("\n");
195
+ }