scrumrun 2.1.1 → 2.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.
@@ -0,0 +1,156 @@
1
+ # Quickstart
2
+
3
+ Get from "never heard of it" to your first approved Run in under 10 minutes.
4
+ No `SPEC.md` reading required.
5
+
6
+ ## The three-minute mental model
7
+
8
+ ScrumRun gives an AI coding agent a small vocabulary and a project memory.
9
+
10
+ - **Task** — one atomic piece of work.
11
+ - **Sprint** — a batch of Tasks grouped by time or theme. Optional.
12
+ - **Run** — one attempt at executing a Task. Retries create new Runs; the
13
+ previous one is never overwritten.
14
+ - **Feature** — a bigger initiative that groups Tasks and its own decisions.
15
+ - **Memory** — what the project learned: facts, decisions, insights, dossiers.
16
+
17
+ Everything lives as Markdown under `.scrumrun/`. Any Markdown-capable agent
18
+ can follow it.
19
+
20
+ ## Install
21
+
22
+ You need Node.js 22.13 or newer.
23
+
24
+ ```bash
25
+ npx scrumrun@latest install # installs the agent integration for your client
26
+ npx scrumrun@latest init # creates the .scrumrun/ tree in the current repo
27
+ ```
28
+
29
+ By default `.scrumrun/` is added to `.git/info/exclude` — the methodology
30
+ stays local to your machine. Add `--shared` to `init` if your team wants
31
+ to commit it.
32
+
33
+ ## Your first intake
34
+
35
+ You do not have to remember any commands. Just describe the problem in
36
+ natural language to your AI client. ScrumRun classifies it before writing
37
+ anything.
38
+
39
+ ```
40
+ Owner: The checkout charges twice when the page is refreshed.
41
+ ```
42
+
43
+ The agent responds with an intake summary:
44
+
45
+ ```
46
+ Classification: Task (fix)
47
+ Risk: high — financial path
48
+ Why: Payment behavior changed after completed work; corrective Task
49
+ linked to the original Run history.
50
+ Next: /sc plan intake "double charge on refresh"
51
+ Awaiting owner approval.
52
+ ```
53
+
54
+ Nothing has been written yet. The classification, risk, and plan are
55
+ proposals. Approving is one command:
56
+
57
+ ```bash
58
+ npx scrumrun@latest sc plan intake --approve <token>
59
+ ```
60
+
61
+ Only then does a Task and a Run get created.
62
+
63
+ ## Watching the Run
64
+
65
+ The agent executes inside the approved scope. Every step lands in an
66
+ append-only ledger under `.scrumrun/runs/RUN-NNN.md` with a stable event
67
+ id, timestamp, actor, reason, and typed evidence.
68
+
69
+ ```
70
+ RUN-001-EVT-001 planned by owner reason: "checkout double-charge fix"
71
+ RUN-001-EVT-002 executed command: npm test → 132 passed
72
+ RUN-001-EVT-003 validated (REV-001) guardrail checks: passed
73
+ RUN-001-EVT-004 learned (INS-001) "refresh triggers duplicate submit"
74
+ RUN-001-EVT-005 completed
75
+ ```
76
+
77
+ A retry does not overwrite `RUN-001`. It creates `RUN-002` beside it. The
78
+ old attempt stays as evidence.
79
+
80
+ You can render a Run's ledger as a human timeline instead of reading the
81
+ raw JSON:
82
+
83
+ ```bash
84
+ npx scrumrun@latest sc plan run --render RUN-001
85
+ ```
86
+
87
+ For aggregate signal across every Run in the project — status mix, p50
88
+ and p95 time in `VALIDATING`, retries per Task, guardrail check counts —
89
+ use `--stats`:
90
+
91
+ ```bash
92
+ npx scrumrun@latest sc plan run --stats
93
+ npx scrumrun@latest sc plan run --stats --task TASK-001
94
+ npx scrumrun@latest sc plan run --stats --json
95
+ ```
96
+
97
+ ## Reading the memory
98
+
99
+ As you work, the project accumulates:
100
+
101
+ - **Facts** (`K-NNN`) — reviewed truths about the code.
102
+ - **Decisions** (`DEC-NNN`) — normative choices that constrain future work.
103
+ - **Insights** (`INS-NNN`) — context, rationale, warnings, trade-offs.
104
+ - **Dossiers** (`DOS-NNN`) — curated topic or module deep-dives.
105
+
106
+ Nothing becomes canonical without confirmation. AI extraction creates
107
+ candidates; you promote them with evidence.
108
+
109
+ ```bash
110
+ npx scrumrun@latest sc knowledge insight --propose "..." --evidence src/foo.ts
111
+ npx scrumrun@latest sc knowledge insight --confirm INS-001
112
+ ```
113
+
114
+ Ask the agent things like *"why is calculateFinalPrice in checkout?"* or
115
+ *"which decision constrains the pricing module?"* — it answers by reading
116
+ the memory index, not by guessing.
117
+
118
+ ## What to read next
119
+
120
+ - [`docs/INDEX.md`](INDEX.md) — the full documentation map.
121
+ - [`CORE.md`](../CORE.md) — the operational guide agents follow at runtime.
122
+ - [`docs/SEMANTIC-MEMORY.md`](SEMANTIC-MEMORY.md) — how facts, decisions,
123
+ insights, and dossiers fit together.
124
+ - [`docs/TROUBLESHOOTING.md`](TROUBLESHOOTING.md) — for common install and
125
+ migration issues.
126
+ - [`SPEC.md`](../SPEC.md) — the formal specification, when you want the
127
+ invariants and state machines.
128
+
129
+ ## Common questions
130
+
131
+ **Do I have to type `/sc` commands?** No. Natural language is the primary
132
+ entry point. The `/sc` grammar exists for scripting and reproducibility.
133
+
134
+ **What if I already use v1?** Run `npx scrumrun@latest update` for a
135
+ read-only migration preflight, then `update --migrate` when you are
136
+ satisfied with the plan.
137
+
138
+ **Can I hide the `.scrumrun/` tree from Git?** It is hidden by default
139
+ (local mode). Use `--shared` if you want to commit it.
140
+
141
+ **Where do secrets live?** Never in canonical artifacts. Use
142
+ `.scrumrun/vault.local.md` for local-only plaintext that is never indexed
143
+ and never migrated.
144
+
145
+ **How do I add my own project rules?** Edit `.scrumrun/guardrails.md`.
146
+ Each rule has `Status`, `Enforcement`, `Scope`, and `Rule` fields and gets
147
+ a stable `GR-NNN` id.
148
+
149
+ **What if something goes wrong?** Every user-facing failure carries a
150
+ stable `SR-E-NNN` code. Grep it in [`docs/ERROR-CODES.md`](ERROR-CODES.md)
151
+ for the exact remediation. If a canonical transaction was interrupted,
152
+ preview the repair before running it:
153
+
154
+ ```bash
155
+ npx scrumrun@latest sc config doctor --recover --dry-run
156
+ ```
@@ -12,6 +12,8 @@ const nouns = Object.freeze({
12
12
  run: [
13
13
  "--list",
14
14
  "--show",
15
+ "--render <RUN-NNN>",
16
+ "--stats [--task <TASK-NNN>] [--feature <FEAT-NNN>] [--sprint <SPRINT-NNN>] [--json]",
15
17
  "--authorize-mutation <RUN-NNN> --path <relative-path>",
16
18
  "--record-mutation <RUN-NNN> --permit <MUT-id> [--note] [--actor]",
17
19
  "--satisfy-guardrail <RUN-NNN> --guardrail <GR-NNN> [--note] [--evidence] [--review] [--migration] [--actor]",
@@ -62,7 +64,7 @@ const nouns = Object.freeze({
62
64
  init: ["--local", "--shared", "--lean", "--no-agent-hint", "--force"],
63
65
  update: ["all [--migrate]", "codex [--migrate]", "opencode [--migrate]", "claude [--migrate]"],
64
66
  migrate: ["--to 2 --dry-run", "--to 2 --apply", "--to 2 --rollback"],
65
- doctor: ["all [--strict] [--recover]", "codex [--strict] [--recover]", "opencode [--strict] [--recover]", "claude [--strict] [--recover]"],
67
+ doctor: ["all [--strict] [--recover] [--dry-run]", "codex [--strict] [--recover] [--dry-run]", "opencode [--strict] [--recover] [--dry-run]", "claude [--strict] [--recover] [--dry-run]"],
66
68
  uninstall: ["--force"],
67
69
  help: ["<topic>"]
68
70
  }
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const { parseRunLedger } = require("../runtime/run-ledger");
7
+
8
+ const RULE = "─".repeat(72);
9
+
10
+ function formatInstant(iso) {
11
+ if (!iso) return " ";
12
+ const date = new Date(iso);
13
+ if (Number.isNaN(date.valueOf())) return String(iso);
14
+ const pad = (n) => String(n).padStart(2, "0");
15
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
16
+ }
17
+
18
+ function humanDuration(startIso, endIso) {
19
+ if (!startIso || !endIso) return null;
20
+ const delta = new Date(endIso).valueOf() - new Date(startIso).valueOf();
21
+ if (!Number.isFinite(delta) || delta < 0) return null;
22
+ const seconds = Math.round(delta / 1000);
23
+ if (seconds < 60) return `${seconds}s`;
24
+ const minutes = Math.floor(seconds / 60);
25
+ const rem = seconds % 60;
26
+ if (minutes < 60) return rem ? `${minutes}m ${rem}s` : `${minutes}m`;
27
+ const hours = Math.floor(minutes / 60);
28
+ const mrem = minutes % 60;
29
+ return mrem ? `${hours}h ${mrem}m` : `${hours}h`;
30
+ }
31
+
32
+ function readFrontmatter(body) {
33
+ const match = String(body || "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
34
+ if (!match) return {};
35
+ const result = {};
36
+ for (const line of match[1].split(/\r?\n/)) {
37
+ const kv = line.match(/^([a-z_][a-z0-9_]*):\s*(.*)$/i);
38
+ if (!kv) continue;
39
+ let value = kv[2].trim();
40
+ if (value === "null") value = null;
41
+ else if (/^-?\d+$/.test(value)) value = Number(value);
42
+ result[kv[1]] = value;
43
+ }
44
+ return result;
45
+ }
46
+
47
+ function readTitle(body) {
48
+ const match = String(body || "").match(/^#\s+([^\r\n]+)/m);
49
+ return match ? match[1].trim() : null;
50
+ }
51
+
52
+ function summarizeEvidence(evidence) {
53
+ if (!Array.isArray(evidence) || !evidence.length) return [];
54
+ return evidence.map((item) => {
55
+ if (!item || typeof item !== "object") return String(item);
56
+ const kind = item.kind || "evidence";
57
+ if (item.ref && item.summary) return `${kind}: ${item.ref} — ${item.summary}`;
58
+ if (item.ref) return `${kind}: ${item.ref}`;
59
+ if (item.command) return `${kind}: ${item.command}${item.result ? ` → ${item.result}` : ""}`;
60
+ if (item.test) return `${kind}: ${item.test}${item.result ? ` → ${item.result}` : ""}`;
61
+ if (item.summary) return `${kind}: ${item.summary}`;
62
+ return kind;
63
+ });
64
+ }
65
+
66
+ function headline(event) {
67
+ const type = event.type || "event";
68
+ if (type === "transition") {
69
+ const from = event.from || "?";
70
+ const to = event.to || "?";
71
+ return `${from} → ${to}`;
72
+ }
73
+ if (type === "snapshot") return "snapshot (baseline)";
74
+ if (type === "guardrail") {
75
+ const status = event.status || event.result || "recorded";
76
+ return `guardrail ${event.guardrail || event.code || ""} ${status}`.trim();
77
+ }
78
+ if (type === "mutation") {
79
+ const permit = event.mutation || event.permit || "";
80
+ return `mutation ${permit}`.trim();
81
+ }
82
+ return type;
83
+ }
84
+
85
+ function renderEvent(event, indent = " ") {
86
+ const lines = [];
87
+ const time = formatInstant(event.occurred_at);
88
+ const actor = String(event.actor || "?").padEnd(8, " ");
89
+ lines.push(`${time} ${actor}${event.id || ""} ${headline(event)}`);
90
+ if (event.reason) {
91
+ lines.push(`${indent}reason ${event.reason}`);
92
+ }
93
+ const evidence = summarizeEvidence(event.evidence);
94
+ if (evidence.length) {
95
+ lines.push(`${indent}evidence ${evidence[0]}`);
96
+ for (let i = 1; i < evidence.length; i++) {
97
+ lines.push(`${indent} ${evidence[i]}`);
98
+ }
99
+ }
100
+ if (event.type === "mutation" && Array.isArray(event.paths) && event.paths.length) {
101
+ lines.push(`${indent}paths ${event.paths.join(", ")}`);
102
+ }
103
+ if (event.type === "guardrail" && event.gate) {
104
+ lines.push(`${indent}gate ${event.gate}`);
105
+ }
106
+ return lines.join("\n");
107
+ }
108
+
109
+ function renderRun({ id, file, body }) {
110
+ const frontmatter = readFrontmatter(body);
111
+ const title = readTitle(body) || id;
112
+ const { events, errors } = parseRunLedger(body);
113
+
114
+ const header = [
115
+ `${id} ${title}`,
116
+ `status ${frontmatter.status || "unknown"} task ${frontmatter.task || "-"} attempt ${frontmatter.attempt ?? "-"} sprint ${frontmatter.sprint || "-"}`,
117
+ file ? `file ${path.relative(process.cwd(), file)}` : null,
118
+ RULE
119
+ ].filter(Boolean);
120
+
121
+ const body_lines = [];
122
+ if (!events.length) {
123
+ body_lines.push("(no ledger events found)");
124
+ } else {
125
+ for (let i = 0; i < events.length; i++) {
126
+ body_lines.push(renderEvent(events[i]));
127
+ if (i < events.length - 1) body_lines.push("");
128
+ }
129
+ }
130
+
131
+ const first = events[0]?.occurred_at;
132
+ const last = events[events.length - 1]?.occurred_at;
133
+ const duration = humanDuration(first, last);
134
+
135
+ const footer = [
136
+ RULE,
137
+ `${events.length} event${events.length === 1 ? "" : "s"}${duration ? ` span ${duration}` : ""}`
138
+ ];
139
+
140
+ if (errors.length) {
141
+ footer.push("");
142
+ footer.push("Ledger validation warnings:");
143
+ for (const err of errors) footer.push(` - ${err}`);
144
+ }
145
+
146
+ return [...header, ...body_lines, ...footer].join("\n");
147
+ }
148
+
149
+ function locateRun(cwd, runId) {
150
+ const file = path.join(cwd, ".scrumrun", "runs", `${runId}.md`);
151
+ if (!fs.existsSync(file)) return null;
152
+ return { id: runId, file, body: fs.readFileSync(file, "utf8") };
153
+ }
154
+
155
+ function renderRunFromDisk(cwd, runId) {
156
+ const run = locateRun(cwd, runId);
157
+ if (!run) throw new Error(`Run not found: ${runId}`);
158
+ return renderRun(run);
159
+ }
160
+
161
+ module.exports = { renderRun, renderRunFromDisk, formatInstant, humanDuration, summarizeEvidence };
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const { parseRunLedger } = require("../runtime/run-ledger");
7
+
8
+ function readFrontmatter(body) {
9
+ const match = String(body || "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
10
+ if (!match) return {};
11
+ const result = {};
12
+ for (const line of match[1].split(/\r?\n/)) {
13
+ const kv = line.match(/^([a-z_][a-z0-9_]*):\s*(.*)$/i);
14
+ if (!kv) continue;
15
+ let value = kv[2].trim();
16
+ if (value === "null" || value === "") value = null;
17
+ else if (/^-?\d+$/.test(value)) value = Number(value);
18
+ result[kv[1]] = value;
19
+ }
20
+ return result;
21
+ }
22
+
23
+ function loadRuns(cwd) {
24
+ const dir = path.join(cwd, ".scrumrun", "runs");
25
+ if (!fs.existsSync(dir)) return [];
26
+ return fs
27
+ .readdirSync(dir)
28
+ .filter((name) => /^RUN-\d{3,}\.md$/.test(name))
29
+ .sort()
30
+ .map((name) => {
31
+ const file = path.join(dir, name);
32
+ const body = fs.readFileSync(file, "utf8");
33
+ return { id: name.replace(/\.md$/, ""), file, body, frontmatter: readFrontmatter(body) };
34
+ });
35
+ }
36
+
37
+ function transitions(events) {
38
+ return events.filter((event) => event && event.type === "transition");
39
+ }
40
+
41
+ function firstEntryInto(events, status) {
42
+ for (const event of transitions(events)) {
43
+ if (event.to === status) return event.occurred_at;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function lastExitFrom(events, status) {
49
+ let last = null;
50
+ for (const event of transitions(events)) {
51
+ if (event.from === status) last = event.occurred_at;
52
+ }
53
+ return last;
54
+ }
55
+
56
+ function durationMs(startIso, endIso) {
57
+ if (!startIso || !endIso) return null;
58
+ const delta = new Date(endIso).valueOf() - new Date(startIso).valueOf();
59
+ return Number.isFinite(delta) && delta >= 0 ? delta : null;
60
+ }
61
+
62
+ function percentile(sortedMs, p) {
63
+ if (!sortedMs.length) return null;
64
+ const rank = Math.min(sortedMs.length - 1, Math.max(0, Math.floor((p / 100) * sortedMs.length)));
65
+ return sortedMs[rank];
66
+ }
67
+
68
+ function humanMs(ms) {
69
+ if (ms === null || ms === undefined || !Number.isFinite(ms)) return "-";
70
+ const seconds = Math.round(ms / 1000);
71
+ if (seconds < 60) return `${seconds}s`;
72
+ const minutes = Math.floor(seconds / 60);
73
+ const rem = seconds % 60;
74
+ if (minutes < 60) return rem ? `${minutes}m ${rem}s` : `${minutes}m`;
75
+ const hours = Math.floor(minutes / 60);
76
+ const mrem = minutes % 60;
77
+ return mrem ? `${hours}h ${mrem}m` : `${hours}h`;
78
+ }
79
+
80
+ function computeStats(cwd, filters = {}) {
81
+ const runs = loadRuns(cwd).filter((run) => {
82
+ if (filters.task && run.frontmatter.task !== filters.task) return false;
83
+ if (filters.feature && run.frontmatter.feature !== filters.feature) return false;
84
+ if (filters.sprint && run.frontmatter.sprint !== filters.sprint) return false;
85
+ return true;
86
+ });
87
+
88
+ const summary = {
89
+ total: runs.length,
90
+ byStatus: {},
91
+ validating: { count: 0, samples: [], p50: null, p95: null, max: null },
92
+ executing: { count: 0, samples: [], p50: null, p95: null, max: null },
93
+ attempts: { max: 0, byTask: {} },
94
+ guardrails: { passed: 0, deferred: 0, blocked: 0 },
95
+ mutations: 0,
96
+ completionsWithFailurePredecessor: 0
97
+ };
98
+
99
+ const failedTaskIds = new Set();
100
+
101
+ for (const run of runs) {
102
+ const status = run.frontmatter.status || "unknown";
103
+ summary.byStatus[status] = (summary.byStatus[status] || 0) + 1;
104
+
105
+ const attempt = Number(run.frontmatter.attempt) || 1;
106
+ if (attempt > summary.attempts.max) summary.attempts.max = attempt;
107
+ if (run.frontmatter.task) {
108
+ summary.attempts.byTask[run.frontmatter.task] = Math.max(summary.attempts.byTask[run.frontmatter.task] || 0, attempt);
109
+ }
110
+
111
+ if (status === "failed" && run.frontmatter.task) failedTaskIds.add(run.frontmatter.task);
112
+ if (status === "completed" && run.frontmatter.task && failedTaskIds.has(run.frontmatter.task)) {
113
+ summary.completionsWithFailurePredecessor += 1;
114
+ }
115
+
116
+ const { events } = parseRunLedger(run.body);
117
+ const enterValidating = firstEntryInto(events, "validating");
118
+ const exitValidating = lastExitFrom(events, "validating");
119
+ const validatingMs = durationMs(enterValidating, exitValidating);
120
+ if (validatingMs !== null) {
121
+ summary.validating.count += 1;
122
+ summary.validating.samples.push(validatingMs);
123
+ }
124
+
125
+ const enterExecuting = firstEntryInto(events, "executing");
126
+ const exitExecuting = lastExitFrom(events, "executing");
127
+ const executingMs = durationMs(enterExecuting, exitExecuting);
128
+ if (executingMs !== null) {
129
+ summary.executing.count += 1;
130
+ summary.executing.samples.push(executingMs);
131
+ }
132
+
133
+ for (const event of events) {
134
+ if (event.type === "guardrail") {
135
+ const state = String(event.status || event.result || "").toLowerCase();
136
+ if (state === "passed") summary.guardrails.passed += 1;
137
+ else if (state === "deferred") summary.guardrails.deferred += 1;
138
+ else if (state === "blocked") summary.guardrails.blocked += 1;
139
+ } else if (event.type === "mutation") {
140
+ summary.mutations += 1;
141
+ }
142
+ }
143
+ }
144
+
145
+ for (const key of ["validating", "executing"]) {
146
+ const samples = summary[key].samples.slice().sort((a, b) => a - b);
147
+ summary[key].p50 = percentile(samples, 50);
148
+ summary[key].p95 = percentile(samples, 95);
149
+ summary[key].max = samples[samples.length - 1] ?? null;
150
+ delete summary[key].samples;
151
+ }
152
+
153
+ return summary;
154
+ }
155
+
156
+ function renderStats(summary, filters = {}) {
157
+ const lines = [];
158
+ const scope = [];
159
+ if (filters.task) scope.push(`task=${filters.task}`);
160
+ if (filters.feature) scope.push(`feature=${filters.feature}`);
161
+ if (filters.sprint) scope.push(`sprint=${filters.sprint}`);
162
+ lines.push(`Run statistics${scope.length ? ` (${scope.join(", ")})` : ""}`);
163
+ lines.push("─".repeat(72));
164
+ lines.push(`total runs ${summary.total}`);
165
+
166
+ const statusEntries = Object.entries(summary.byStatus).sort((a, b) => b[1] - a[1]);
167
+ if (statusEntries.length) {
168
+ lines.push("");
169
+ lines.push("by status");
170
+ for (const [status, count] of statusEntries) {
171
+ lines.push(` ${status.padEnd(30, " ")} ${count}`);
172
+ }
173
+ }
174
+
175
+ lines.push("");
176
+ lines.push("time in VALIDATING");
177
+ lines.push(` runs sampled ${summary.validating.count}`);
178
+ lines.push(` p50 ${humanMs(summary.validating.p50)}`);
179
+ lines.push(` p95 ${humanMs(summary.validating.p95)}`);
180
+ lines.push(` max ${humanMs(summary.validating.max)}`);
181
+
182
+ lines.push("");
183
+ lines.push("time in EXECUTING");
184
+ lines.push(` runs sampled ${summary.executing.count}`);
185
+ lines.push(` p50 ${humanMs(summary.executing.p50)}`);
186
+ lines.push(` p95 ${humanMs(summary.executing.p95)}`);
187
+ lines.push(` max ${humanMs(summary.executing.max)}`);
188
+
189
+ lines.push("");
190
+ lines.push("retries and recovery");
191
+ lines.push(` highest attempt observed ${summary.attempts.max}`);
192
+ const retriedTasks = Object.entries(summary.attempts.byTask).filter(([, n]) => n > 1);
193
+ lines.push(` tasks that retried ${retriedTasks.length}`);
194
+ lines.push(` completions after prior fail ${summary.completionsWithFailurePredecessor}`);
195
+
196
+ lines.push("");
197
+ lines.push("guardrails and edit permits");
198
+ lines.push(` guardrail events passed ${summary.guardrails.passed}`);
199
+ lines.push(` guardrail events deferred ${summary.guardrails.deferred}`);
200
+ lines.push(` guardrail events blocked ${summary.guardrails.blocked}`);
201
+ lines.push(` recorded mutation events ${summary.mutations}`);
202
+
203
+ return lines.join("\n");
204
+ }
205
+
206
+ module.exports = { computeStats, renderStats, loadRuns };
package/lib/errors.js ADDED
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ // Stable ScrumRun error catalog.
4
+ //
5
+ // Codes are permanent once assigned. Never reuse a retired code. When
6
+ // removing an error site, keep its entry with { retired: true } instead
7
+ // of deleting it so external logs stay resolvable.
8
+ //
9
+ // Ranges (soft, for readability):
10
+ // SR-E-001..049 request/intake and approval
11
+ // SR-E-050..099 approvals, tokens, and secrets
12
+ // SR-E-100..149 runs, task/run transitions, ledger
13
+ // SR-E-150..199 guardrails, policy, obligations
14
+ // SR-E-200..249 edit permits (Mutation Gateway)
15
+ // SR-E-250..299 transactions and recovery
16
+ // SR-E-300..349 memory (facts, decisions, insights, dossiers, vault)
17
+ // SR-E-350..399 semantic index, code intelligence
18
+ // SR-E-400..449 migration and update
19
+ // SR-E-450..499 conformance and doctor
20
+ // SR-E-500..549 configuration and installation
21
+
22
+ const CATALOG = Object.freeze({
23
+ // Request / intake
24
+ "SR-E-001": { summary: "Intake request is missing.", remediation: "Provide a natural-language request or use --request \"...\"." },
25
+ "SR-E-002": { summary: "Intake payload contains secret-like content.", remediation: "Remove the secret from the request or reference a vault entry by path." },
26
+ "SR-E-003": { summary: "Intake classification could not be produced.", remediation: "Rephrase the request in one sentence describing the observable problem." },
27
+
28
+ // Approvals
29
+ "SR-E-050": { summary: "Approval token is malformed.", remediation: "Re-run the intake and copy the token exactly as printed after 'Approval:'." },
30
+ "SR-E-051": { summary: "Approval token signature does not match this repository.", remediation: "Tokens are bound to the project fingerprint. Re-run intake from the repo the change targets." },
31
+ "SR-E-052": { summary: "Approval token expired.", remediation: "Re-run intake to obtain a fresh token before approving." },
32
+
33
+ // Runs and ledger
34
+ "SR-E-100": { summary: "Run not found.", remediation: "Check the RUN-NNN id with `sc plan run --list`." },
35
+ "SR-E-101": { summary: "Run ledger contains an invalid event.", remediation: "Inspect the reported event id; use `sc plan run --render` for a human view or restore from git history." },
36
+ "SR-E-102": { summary: "Run transition rejected: missing validation or learning evidence.", remediation: "Complete `--validate` and `--learn` with typed evidence before `--complete`." },
37
+ "SR-E-103": { summary: "Retry rejected because the previous Run is still active.", remediation: "Complete, fail, or block the current Run before creating a retry." },
38
+
39
+ // Guardrails
40
+ "SR-E-150": { summary: "Guardrail check blocked the operation.", remediation: "Read the reported GR-NNN, satisfy or retire it explicitly; guardrails never bypass silently." },
41
+ "SR-E-151": { summary: "Guardrail obligation is still pending.", remediation: "Resolve each `pending guardrail` via `sc plan run --satisfy-guardrail` before completing the Run." },
42
+ "SR-E-152": { summary: "Guardrail declaration is malformed.", remediation: "Every active guardrail requires Status, Enforcement, Scope, and Rule fields; check .scrumrun/guardrails.md." },
43
+
44
+ // Edit permits (Mutation Gateway)
45
+ "SR-E-200": { summary: "No edit permit for this path.", remediation: "Request one with `sc plan run --authorize-mutation RUN-NNN --path <path>` before editing canonical or source files." },
46
+ "SR-E-201": { summary: "Edit permit expired.", remediation: "Permits last 15 minutes. Authorize a new one and record the change immediately." },
47
+ "SR-E-202": { summary: "Edit permit path scope mismatch.", remediation: "The permit does not cover the modified path. Request a new permit that lists it." },
48
+ "SR-E-203": { summary: "File hash changed unexpectedly since the permit was issued.", remediation: "Someone else modified the file. Re-plan the change and request a fresh permit." },
49
+
50
+ // Transactions and recovery
51
+ "SR-E-250": { summary: "Pending kernel transaction cannot be recovered automatically.", remediation: "Run `sc config doctor --recover --dry-run` to preview; if it shows 'would overwrite owner changes', reconcile the file manually before applying." },
52
+ "SR-E-251": { summary: "Journal fails integrity check.", remediation: "Inspect .scrumrun/.transactions/pending. Do not delete; contact support or restore from backup." },
53
+
54
+ // Memory
55
+ "SR-E-300": { summary: "Memory candidate rejected: missing resolvable evidence.", remediation: "Attach at least one --evidence path or `sc knowledge <subject> --propose` before `--confirm`." },
56
+ "SR-E-301": { summary: "Attempt to write into vault via canonical channel.", remediation: "vault.local.md is local-only and never indexed. Edit the file directly." },
57
+
58
+ // Semantic index / code intel
59
+ "SR-E-350": { summary: "Semantic index is stale.", remediation: "Rebuild with `sc knowledge map --build`. Cache is disposable; canonical memory is unaffected." },
60
+ "SR-E-351": { summary: "Search backend advertised in the cache does not match this runtime.", remediation: "Delete .scrumrun/.cache/semantic-index.sqlite and re-run any `sc knowledge` query to rebuild against the current runtime." },
61
+
62
+ // Migration
63
+ "SR-E-400": { summary: "Migration preflight failed.", remediation: "Run `npx scrumrun@latest update` (dry) to see blockers, resolve them, then apply with `--migrate`." },
64
+ "SR-E-401": { summary: "Migration rollback requested but no backup was found.", remediation: "Rollback needs the ignored byte-exact backup created during --migrate. Restore from version control if the backup is gone." },
65
+
66
+ // Conformance / doctor
67
+ "SR-E-450": { summary: "Conformance check failed.", remediation: "The reported invariant identifies the exact violation; the message includes the file and expected shape." },
68
+ "SR-E-451": { summary: "Installed client asset is stale.", remediation: "Re-run `npx scrumrun@latest update` for the specific client. `doctor --strict` shows which files diverge." },
69
+
70
+ // Configuration / install
71
+ "SR-E-500": { summary: "ScrumRun project not initialized.", remediation: "Run `npx scrumrun@latest init` in the repository root." },
72
+ "SR-E-501": { summary: "Unsupported Node.js runtime.", remediation: "ScrumRun requires Node.js >=22.13.0 for native SQLite. Upgrade Node and retry." }
73
+ });
74
+
75
+ class ScrumRunError extends Error {
76
+ constructor(code, message, options = {}) {
77
+ const entry = CATALOG[code];
78
+ if (!entry) throw new Error(`Unknown ScrumRun error code: ${code}`);
79
+ const composed = message || entry.summary;
80
+ super(`${code} ${composed}`);
81
+ this.name = "ScrumRunError";
82
+ this.code = code;
83
+ this.summary = entry.summary;
84
+ this.remediation = entry.remediation;
85
+ if (options.cause) this.cause = options.cause;
86
+ if (options.details) this.details = options.details;
87
+ }
88
+ }
89
+
90
+ function describe(code) {
91
+ return CATALOG[code] || null;
92
+ }
93
+
94
+ function codes() {
95
+ return Object.keys(CATALOG).sort();
96
+ }
97
+
98
+ module.exports = { ScrumRunError, describe, codes, CATALOG };
@@ -499,7 +499,7 @@ function indexStatus(projectRoot) {
499
499
  return { exists: true, stale: true, file, stored, check: "schema", reason: `semantic index schema ${INDEX_SCHEMA_VERSION} rebuild required` };
500
500
  }
501
501
  if (searchBackend === "fts5" && !runtimeSupportsFts5(database)) {
502
- return { exists: true, stale: true, file, stored, searchBackend, check: "schema", reason: "semantic index requires unavailable FTS5; fallback rebuild required" };
502
+ return { exists: true, stale: true, file, stored, searchBackend, check: "backend", backendMismatch: true, reason: "declared search backend fts5 is not available in this Node.js runtime; disposable rebuild required" };
503
503
  }
504
504
  const watch = sourceWatchSnapshot(projectRoot);
505
505
  if (metadata.get("source_watch_fingerprint") === watch.fingerprint) {