scrumrun 2.4.1 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes follow Semantic Versioning.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.5.1 - 2026-07-23
8
+
9
+ ### Fixed
10
+
11
+ - `doctor` no longer crashes on Runs with a null `task` reference. `conformance.js` now guards `repository.read("task", …)` and emits a specific `RUN_TASK_MISSING` finding when `record.task` is `null`, differentiating it from the "task exists but is invalid" case. Affects any project normalized with `sc plan run --normalize-legacy` where the original Run had an unparseable task ref.
12
+
13
+ ## 2.5.0 - 2026-07-23
14
+
15
+ ### Added
16
+
17
+ - `sc plan run --normalize-legacy [--dry-run]` collapses malformed Run ledgers (invalid `type`, unknown evidence `kind`, missing snapshot invariant, unparseable `task` reference, empty ledger) into a single valid `snapshot` event that preserves the fact of the Run without inventing transitions. Byte-exact originals are moved to `.scrumrun/.migration-backup/runs/RUN-NNN.md` (numbered suffix if a backup already exists). Coherent with ADR-021: ambiguous history becomes evidence, never invented state.
18
+ - Invariant **I-24**: canonical Runs must pass ledger validation. Hand-written Runs surface as a `high` `RUN_WRITE_BYPASS` finding in `doctor --strict`, with a pointer to the recovery command.
19
+ - New module `lib/commands/normalize-legacy` exposes `normalizeLegacyRuns`, `analyze`, `apply`, `renderReport`, `needsNormalization`, and `scanRuns` for library consumers.
20
+ - CORE and the shared SKILL now open with a hard instruction: **never write Run events by hand**. Only the CLI mutates `runs/*.md`. Direct writes produce invalid vocabulary and break conformance; if the CLI does not expose the shape you need, propose a spec change instead of inventing.
21
+
22
+ ### Changed
23
+
24
+ - Manifest declares `--normalize-legacy` and `--dry-run` on `sc plan run`.
25
+ - SPEC.md conformance range extended to `I-01 through I-24`.
26
+ - Test suite grew from 184 to 192 passing.
27
+
7
28
  ## 2.4.1 - 2026-07-23
8
29
 
9
30
  ### Added
package/CORE.md CHANGED
@@ -84,6 +84,8 @@ AGENTS.md
84
84
 
85
85
  **Before querying project state, read `.scrumrun/method.json`.** Its `paths` block is the authoritative index of every canonical location in this project. Navigate by that index; if a path is not declared there, it is not canonical truth. Directory listing and grep are fallbacks — never the first step. A ScrumRun-aware agent must never search for `goals/`, `backlog.md`, `sprint.md`, or any legacy layout: those are absent by design once migration completes and are surfaced only through `.scrumrun/.migration-backup/`.
86
86
 
87
+ **Never write Run events by hand.** Runs are only mutated through the CLI: `sc plan run --validate | --learn | --complete | --resume | --fail | --block | --satisfy-guardrail | --authorize-mutation | --record-mutation`. Editing `runs/RUN-NNN.md` directly bypasses schema validation, produces invalid ledger events (invalid `type`, unknown evidence `kind`, missing snapshot, wrong `from`), and breaks conformance for the entire project. If the CLI does not expose the shape you need, propose a spec change through an ADR — do not invent event vocabulary. Existing hand-written Runs can be recovered with `sc plan run --normalize-legacy` (byte-exact original preserved in `.scrumrun/.migration-backup/runs/`).
88
+
87
89
  Canonical truth is Markdown. SQLite/cache data stores only rebuildable indexes, symbol projections, relations, and bounded context packages. Deleting `.cache/` must never delete authored truth.
88
90
 
89
91
  `state.md` and the semantic index use two-tier freshness checks. Matching path/stat watch fingerprints avoid rereading unchanged sources; any metadata drift falls back to complete content hashing. A cache schema mismatch rebuilds the disposable index once. Watch metadata is only an optimization and never authority.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ScrumRun gives an agent a small command surface and a precise project memory: what should be done, how each attempt happened, which decisions constrain the code, and why the architecture exists in its current form.
6
6
 
7
- **Package:** `2.4.1` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
7
+ **Package:** `2.5.1` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
8
8
 
9
9
  **New here?** Read the [Quickstart](docs/QUICKSTART.md) — first Run in under 10 minutes, no `SPEC.md` reading required. Full docs map in [`docs/INDEX.md`](docs/INDEX.md).
10
10
 
package/SPEC.md CHANGED
@@ -310,6 +310,7 @@ Legacy sprint entries become Tasks. History entries become Runs only with an evi
310
310
  - **I-21** Material mutations are policy-bound, path-scoped, hash-verified, append-only, and fail closed on bypass; unresolved Guardrail obligations block completion.
311
311
  - **I-22** The semantic index's declared search backend must match the runtime capabilities of the current Node.js SQLite build; conformance flags a mismatch instead of relying on lazy runtime fallback.
312
312
  - **I-23** `method.json` declares the canonical path index for every artifact family so agents navigate by declaration, not by search. Missing or drifted paths fail conformance; grep and directory scans are fallbacks, never the first step.
313
+ - **I-24** Runs are only mutated through the CLI. A canonical Run whose ledger fails validation is a bypass — conformance reports `RUN_WRITE_BYPASS` with a pointer to the recovery command `sc plan run --normalize-legacy`, which collapses the bad ledger into a single `snapshot` event and preserves the byte-exact original under `.scrumrun/.migration-backup/runs/`.
313
314
 
314
315
  ## 11. Command grammar
315
316
 
@@ -327,7 +328,7 @@ Unknown syntax fails deterministically and never guesses a mutation.
327
328
 
328
329
  An implementation may claim ScrumRun method 2.0.0 only when it:
329
330
 
330
- 1. passes positive and negative tests for I-01 through I-23;
331
+ 1. passes positive and negative tests for I-01 through I-24;
331
332
  2. enforces every exposed state machine and schema;
332
333
  3. proves read-only intake and dry-run migration through full-tree fingerprints;
333
334
  4. proves migration failure recovery, rollback safety, and vault exclusion;
package/bin/scrumrun.js CHANGED
@@ -1386,6 +1386,27 @@ function executeRootRoute(route) {
1386
1386
  }
1387
1387
  return;
1388
1388
  }
1389
+ if (noun === "plan" && subject === "run" && routeArgs[0] === "--normalize-legacy") {
1390
+ const { normalizeLegacyRuns, renderReport } = require(path.join(root, "lib", "commands", "normalize-legacy"));
1391
+ const dryRun = routeArgs.includes("--dry-run");
1392
+ const scrumDir = path.join(process.cwd(), ".scrumrun");
1393
+ try {
1394
+ const result = normalizeLegacyRuns(scrumDir, { dryRun });
1395
+ console.log(renderReport(result.plan, result.applied));
1396
+ if (!dryRun && result.applied && result.applied.length) {
1397
+ refreshState(scrumDir);
1398
+ console.log(`\nNormalized ${result.applied.length} Run(s). Byte-exact originals live under .scrumrun/.migration-backup/runs/.`);
1399
+ } else if (dryRun && result.plan.malformed) {
1400
+ console.log(`\nDry-run only. Re-run without --dry-run to apply the plan.`);
1401
+ } else if (!result.plan.malformed) {
1402
+ console.log(`\nEvery Run already conforms to the current ledger schema. Nothing to normalize.`);
1403
+ }
1404
+ } catch (error) {
1405
+ console.error(error.message);
1406
+ process.exitCode = 1;
1407
+ }
1408
+ return;
1409
+ }
1389
1410
  if (noun === "plan" && subject === "run" && routeArgs[0] === "--stats") {
1390
1411
  const { computeStats, renderStats } = require(path.join(root, "lib", "commands", "run-stats"));
1391
1412
  const filters = {
@@ -14,6 +14,7 @@ const nouns = Object.freeze({
14
14
  "--show",
15
15
  "--render <RUN-NNN>",
16
16
  "--stats [--task <TASK-NNN>] [--feature <FEAT-NNN>] [--sprint <SPRINT-NNN>] [--json]",
17
+ "--normalize-legacy [--dry-run]",
17
18
  "--authorize-mutation <RUN-NNN> --path <relative-path>",
18
19
  "--record-mutation <RUN-NNN> --permit <MUT-id> [--note] [--actor]",
19
20
  "--satisfy-guardrail <RUN-NNN> --guardrail <GR-NNN> [--note] [--evidence] [--review] [--migration] [--actor]",
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+
3
+ // `sc plan run --normalize-legacy [--dry-run]`
4
+ //
5
+ // Reads every Run under `.scrumrun/runs/` and validates its ledger
6
+ // against the current schema. When a Run fails validation (invalid
7
+ // event type, unknown evidence kind, missing snapshot, bad transition,
8
+ // empty ledger, etc.), the whole ledger is collapsed into a single
9
+ // snapshot event that preserves the fact of the Run without inventing
10
+ // transitions. The original body is preserved byte-exact under
11
+ // `.scrumrun/.migration-backup/runs/RUN-NNN.md` (append `.NN` when a
12
+ // backup already exists so history is never overwritten).
13
+ //
14
+ // This is coherent with ADR-021: ambiguous history becomes evidence,
15
+ // never invented transitions.
16
+
17
+ const fs = require("node:fs");
18
+ const path = require("node:path");
19
+ const crypto = require("node:crypto");
20
+
21
+ const { parseRunLedger, validateRunLedger, renderRunEvent } = require("../runtime/run-ledger");
22
+ const { RUN_LEDGER_VERSION } = require("../v2/schema");
23
+
24
+ const RUN_FILENAME_PATTERN = /^RUN-\d{3,}\.md$/;
25
+ const TASK_REF_PATTERN = /^TASK-\d{3,}$/;
26
+
27
+ function readFrontmatterAndBody(text) {
28
+ const match = String(text || "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
29
+ if (!match) return { frontmatter: {}, header: "", body: String(text || "") };
30
+ const header = match[1];
31
+ const body = match[2] || "";
32
+ const frontmatter = {};
33
+ for (const line of header.split(/\r?\n/)) {
34
+ const kv = line.match(/^([a-z_][a-z0-9_]*):\s*(.*)$/i);
35
+ if (!kv) continue;
36
+ let value = kv[2].trim();
37
+ if (value === "null" || value === "") value = null;
38
+ else if (/^-?\d+$/.test(value)) value = Number(value);
39
+ frontmatter[kv[1]] = value;
40
+ }
41
+ return { frontmatter, header, body };
42
+ }
43
+
44
+ function extractTitle(body, fallback) {
45
+ const match = String(body || "").match(/^# ([^\r\n]+)$/m);
46
+ return match ? match[1] : fallback;
47
+ }
48
+
49
+ function serializeFrontmatter(frontmatter) {
50
+ const lines = Object.entries(frontmatter).map(([key, value]) => {
51
+ if (value === null || value === undefined) return `${key}: null`;
52
+ return `${key}: ${value}`;
53
+ });
54
+ return `---\n${lines.join("\n")}\n---`;
55
+ }
56
+
57
+ function ensureDir(dir) {
58
+ fs.mkdirSync(dir, { recursive: true });
59
+ }
60
+
61
+ function reserveBackupPath(backupDir, runId) {
62
+ ensureDir(backupDir);
63
+ const base = path.join(backupDir, `${runId}.md`);
64
+ if (!fs.existsSync(base)) return base;
65
+ for (let n = 1; n <= 99; n++) {
66
+ const candidate = path.join(backupDir, `${runId}.md.${String(n).padStart(2, "0")}`);
67
+ if (!fs.existsSync(candidate)) return candidate;
68
+ }
69
+ throw new Error(`Cannot reserve backup slot for ${runId}: 100 backups already exist.`);
70
+ }
71
+
72
+ function needsNormalization(record, body) {
73
+ if (!record) return { needs: true, reason: "frontmatter is malformed or missing" };
74
+ if (typeof record.task === "string" && record.task && !TASK_REF_PATTERN.test(record.task)) {
75
+ return { needs: true, reason: `task field does not match TASK-NNN (was ${JSON.stringify(record.task)})` };
76
+ }
77
+ const result = validateRunLedger(record, body);
78
+ const errors = result.errors || [];
79
+ if (errors.length) return { needs: true, reason: `ledger validation failed (${errors.length} error${errors.length === 1 ? "" : "s"})`, errors };
80
+ return { needs: false };
81
+ }
82
+
83
+ function buildNormalizedBody({ record, originalBody, backupRelative, reason, errors }) {
84
+ const title = extractTitle(originalBody, `Run ${record.id}`);
85
+ const sha = crypto.createHash("sha256").update(originalBody).digest("hex");
86
+ const normalizedRecord = {
87
+ id: record.id,
88
+ kind: "run",
89
+ status: record.status || "partial",
90
+ created: record.created,
91
+ updated: new Date().toISOString().slice(0, 10),
92
+ method: "2.0.0",
93
+ task: TASK_REF_PATTERN.test(record.task || "") ? record.task : null,
94
+ sprint: record.sprint || null,
95
+ attempt: record.attempt || 1,
96
+ ledger: RUN_LEDGER_VERSION
97
+ };
98
+ const evidence = [
99
+ { kind: "legacy", ref: backupRelative, summary: `byte-exact original preserved (sha256:${sha.slice(0, 12)}…)` },
100
+ { kind: "note", summary: reason }
101
+ ];
102
+ if (errors && errors.length) {
103
+ const sampled = errors.slice(0, 5).map((error) => `- ${error}`).join("; ");
104
+ evidence.push({ kind: "note", summary: `Pre-normalization validation errors: ${sampled}${errors.length > 5 ? `; +${errors.length - 5} more` : ""}` });
105
+ }
106
+ const event = {
107
+ schema: RUN_LEDGER_VERSION,
108
+ id: `${record.id}-EVT-001`,
109
+ sequence: 1,
110
+ type: "snapshot",
111
+ occurred_at: `${normalizedRecord.created || normalizedRecord.updated}T00:00:00.000Z`,
112
+ timestamp_precision: "date",
113
+ actor: "migration",
114
+ from: null,
115
+ to: normalizedRecord.status,
116
+ reason: `Legacy Run ledger normalized to preserve history without inventing canonical transitions. See ${backupRelative}.`,
117
+ evidence
118
+ };
119
+ const front = serializeFrontmatter(normalizedRecord);
120
+ const bodyText = `# ${title}\n\n## Events\n\n${renderRunEvent(event)}\n`;
121
+ return `${front}\n\n${bodyText}`;
122
+ }
123
+
124
+ function scanRuns(scrumDir) {
125
+ const runsDir = path.join(scrumDir, "runs");
126
+ if (!fs.existsSync(runsDir) || !fs.lstatSync(runsDir).isDirectory()) return [];
127
+ return fs
128
+ .readdirSync(runsDir)
129
+ .filter((name) => RUN_FILENAME_PATTERN.test(name))
130
+ .sort()
131
+ .map((name) => path.join(runsDir, name));
132
+ }
133
+
134
+ function analyze(scrumDir) {
135
+ const plan = { runs: [], safe: 0, malformed: 0 };
136
+ for (const file of scanRuns(scrumDir)) {
137
+ const raw = fs.readFileSync(file, "utf8");
138
+ const { frontmatter } = readFrontmatterAndBody(raw);
139
+ const check = needsNormalization(frontmatter, raw);
140
+ plan.runs.push({ id: frontmatter.id || path.basename(file, ".md"), file, needs: check.needs, reason: check.reason || "ledger validates", errors: check.errors || [] });
141
+ if (check.needs) plan.malformed += 1;
142
+ else plan.safe += 1;
143
+ }
144
+ return plan;
145
+ }
146
+
147
+ function apply(scrumDir, plan) {
148
+ const backupDir = path.join(scrumDir, ".migration-backup", "runs");
149
+ const applied = [];
150
+ for (const entry of plan.runs) {
151
+ if (!entry.needs) continue;
152
+ const raw = fs.readFileSync(entry.file, "utf8");
153
+ const { frontmatter } = readFrontmatterAndBody(raw);
154
+ const backupPath = reserveBackupPath(backupDir, entry.id);
155
+ fs.writeFileSync(backupPath, raw);
156
+ const backupRelative = path.relative(scrumDir, backupPath);
157
+ const normalized = buildNormalizedBody({
158
+ record: frontmatter,
159
+ originalBody: raw,
160
+ backupRelative,
161
+ reason: entry.reason,
162
+ errors: entry.errors
163
+ });
164
+ fs.writeFileSync(entry.file, normalized);
165
+ applied.push({ id: entry.id, backup: backupRelative, reason: entry.reason });
166
+ }
167
+ return applied;
168
+ }
169
+
170
+ function renderReport(plan, applied) {
171
+ const lines = [];
172
+ lines.push("## Run ledger normalization plan");
173
+ lines.push("");
174
+ lines.push(`Runs scanned: ${plan.runs.length}`);
175
+ lines.push(`Already valid: ${plan.safe}`);
176
+ lines.push(`Needs normalize: ${plan.malformed}`);
177
+ if (plan.malformed) {
178
+ lines.push("");
179
+ lines.push("Malformed Runs:");
180
+ for (const entry of plan.runs) {
181
+ if (!entry.needs) continue;
182
+ lines.push(` - ${entry.id}: ${entry.reason}`);
183
+ }
184
+ }
185
+ if (applied) {
186
+ lines.push("");
187
+ lines.push("Applied:");
188
+ if (!applied.length) lines.push(" (no changes)");
189
+ for (const entry of applied) {
190
+ lines.push(` - ${entry.id} → normalized (backup ${entry.backup})`);
191
+ }
192
+ }
193
+ return lines.join("\n");
194
+ }
195
+
196
+ function normalizeLegacyRuns(scrumDir, { dryRun = false } = {}) {
197
+ if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) {
198
+ throw new Error(".scrumrun/ not found; run this inside a ScrumRun project.");
199
+ }
200
+ const plan = analyze(scrumDir);
201
+ if (dryRun) return { plan, applied: null };
202
+ const applied = apply(scrumDir, plan);
203
+ return { plan, applied };
204
+ }
205
+
206
+ module.exports = {
207
+ normalizeLegacyRuns,
208
+ analyze,
209
+ apply,
210
+ renderReport,
211
+ buildNormalizedBody,
212
+ needsNormalization,
213
+ scanRuns
214
+ };
@@ -38,7 +38,8 @@ const INVARIANTS = Object.freeze([
38
38
  { id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] },
39
39
  { id: "I-21", summary: "material mutations are scoped, policy-bound, and fail closed", tests: ["Mutation Gateway rejects bypass and out-of-scope writes", "Run completion rejects unresolved Guardrail obligations"] },
40
40
  { id: "I-22", summary: "declared search backend matches observed runtime capabilities", tests: ["conformance detects a semantic index that declares fts5 when the runtime does not provide it"] },
41
- { id: "I-23", summary: "method.json declares canonical paths so agents navigate by index, not by search", tests: ["method.json path index is present, well-formed, and matches the canonical layout"] }
41
+ { id: "I-23", summary: "method.json declares canonical paths so agents navigate by index, not by search", tests: ["method.json path index is present, well-formed, and matches the canonical layout"] },
42
+ { id: "I-24", summary: "every canonical Run passes ledger validation; hand-written events surface as bypass findings", tests: ["conformance flags Run write bypass when the ledger uses invalid vocabulary or misses the snapshot invariant"] }
42
43
  ]);
43
44
 
44
45
  function finding(severity, code, message, file = null) {
@@ -132,14 +133,28 @@ function auditProject(projectRoot) {
132
133
  }
133
134
  }
134
135
  for (const run of records.run || []) {
135
- const task = run.record ? repository.read("task", run.record.task) : null;
136
- if (run.record && (!task || task.errors.length)) findings.push(finding("high", "RUN_TASK_MISSING", `${run.record.id} references missing or invalid ${run.record.task}.`, run.file));
136
+ let task = null;
137
+ if (run.record && run.record.task) {
138
+ try {
139
+ task = repository.read("task", run.record.task);
140
+ } catch (error) {
141
+ task = null;
142
+ }
143
+ }
144
+ if (run.record && !run.record.task) {
145
+ findings.push(finding("high", "RUN_TASK_MISSING", `${run.record.id} has no task reference (record.task is null).`, run.file));
146
+ } else if (run.record && (!task || task.errors.length)) {
147
+ findings.push(finding("high", "RUN_TASK_MISSING", `${run.record.id} references missing or invalid ${run.record.task}.`, run.file));
148
+ }
137
149
  if (run.record && task && !task.errors.length && (run.record.sprint || null) !== (task.record.sprint || null)) {
138
150
  findings.push(finding("high", "RUN_SPRINT_MISMATCH", `${run.record.id}.sprint must match ${task.record.id}.sprint.`, run.file));
139
151
  }
140
152
  if (run.record && run.record.ledger === RUN_LEDGER_VERSION) {
141
153
  const ledger = validateRunLedger(run.record, run.body);
142
- for (const error of ledger.errors) findings.push(finding("high", "RUN_LEDGER_INVALID", `${run.record.id}: ${error}`, run.file));
154
+ if (ledger.errors.length) {
155
+ findings.push(finding("high", "RUN_WRITE_BYPASS", `${run.record.id}: ledger has ${ledger.errors.length} validation error(s); this Run was likely written without going through the CLI. Recover with \`sc plan run --normalize-legacy --dry-run\` then re-run without --dry-run.`, run.file));
156
+ for (const error of ledger.errors) findings.push(finding("high", "RUN_LEDGER_INVALID", `${run.record.id}: ${error}`, run.file));
157
+ }
143
158
  } else if (run.record) {
144
159
  const declared = methodMarker && methodMarker.schemas && methodMarker.schemas.run_ledger === RUN_LEDGER_VERSION;
145
160
  findings.push(finding(declared ? "high" : "warning", "RUN_LEDGER_LEGACY", `${run.record.id} requires the Run ledger ${RUN_LEDGER_VERSION} migration.`, run.file));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "2.4.1",
3
+ "version": "2.5.1",
4
4
  "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",
@@ -36,6 +36,8 @@ Normal hot path:
36
36
  5. follow the ids/pointers to only the relevant canonical artifacts;
37
37
  6. load `.scrumrun/core.md` when the method contract or an exceptional transition is needed.
38
38
 
39
+ **Never write Run events by hand.** Only the CLI mutates `runs/RUN-NNN.md`: `sc plan run --validate | --learn | --complete | --resume | --fail | --block | --satisfy-guardrail | --authorize-mutation | --record-mutation`. Direct edits produce invalid ledger events (unknown `type` like `execution`/`validation`/`learning`, unknown evidence `kind` like `guardrail-check`/`build`/`task-status`, missing snapshot invariant) and break project conformance. If the CLI does not expose the shape you need, propose a spec change instead of inventing vocabulary. Recover hand-written Runs via `sc plan run --normalize-legacy` — originals are preserved byte-exact under `.scrumrun/.migration-backup/runs/`.
40
+
39
41
  Lean mode is a read policy, not an incomplete store. Generated files and `.scrumrun/.cache/` are never authoritative.
40
42
 
41
43
  Generated state and semantic indexes use a metadata-watch fast path with a full content-hash fallback. Treat cache-schema mismatch as a request to rebuild the disposable projection, never as permission to rewrite canonical Markdown.