scrumrun 2.6.6 → 2.6.8

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/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.6.6` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
7
+ **Package:** `2.6.8` · **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
 
@@ -83,12 +83,13 @@ function needsNormalization(record, body) {
83
83
  function buildNormalizedBody({ record, originalBody, backupRelative, reason, errors }) {
84
84
  const title = extractTitle(originalBody, `Run ${record.id}`);
85
85
  const sha = crypto.createHash("sha256").update(originalBody).digest("hex");
86
+ const snapshotDate = record.created || new Date().toISOString().slice(0, 10);
86
87
  const normalizedRecord = {
87
88
  id: record.id,
88
89
  kind: "run",
89
90
  status: record.status || "partial",
90
91
  created: record.created,
91
- updated: new Date().toISOString().slice(0, 10),
92
+ updated: snapshotDate,
92
93
  method: "2.0.0",
93
94
  task: TASK_REF_PATTERN.test(record.task || "") ? record.task : null,
94
95
  sprint: record.sprint || null,
@@ -108,7 +109,7 @@ function buildNormalizedBody({ record, originalBody, backupRelative, reason, err
108
109
  id: `${record.id}-EVT-001`,
109
110
  sequence: 1,
110
111
  type: "snapshot",
111
- occurred_at: `${normalizedRecord.created || normalizedRecord.updated}T00:00:00.000Z`,
112
+ occurred_at: `${snapshotDate}T00:00:00.000Z`,
112
113
  timestamp_precision: "date",
113
114
  actor: "migration",
114
115
  from: null,
@@ -26,6 +26,8 @@
26
26
  const fs = require("node:fs");
27
27
  const path = require("node:path");
28
28
 
29
+ const { normalizeLegacyRuns } = require("./normalize-legacy");
30
+
29
31
  const TASK_FILE = /^TASK-\d{3,}\.md$/;
30
32
  const RUN_FILE = /^RUN-\d{3,}\.md$/;
31
33
  const FEAT_FILE = /^FEAT-\d{3,}\.md$/;
@@ -49,6 +51,8 @@ const RUN_STATUS_ALIAS = {
49
51
  done: "completed"
50
52
  };
51
53
 
54
+ const GUARDRAIL_SCOPES = new Set(["all", "intake", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
55
+
52
56
  function readIf(file) {
53
57
  try {
54
58
  return fs.readFileSync(file, "utf8");
@@ -98,6 +102,29 @@ function scanFeatureIds(scrumDir) {
98
102
  return ids;
99
103
  }
100
104
 
105
+ function scanTaskIds(scrumDir) {
106
+ const dir = path.join(scrumDir, "tasks");
107
+ const ids = new Set();
108
+ for (const file of listDir(dir, TASK_FILE)) {
109
+ ids.add(path.basename(file, ".md"));
110
+ }
111
+ return ids;
112
+ }
113
+
114
+ function planOrphanRuns(scrumDir, taskIds) {
115
+ const orphans = [];
116
+ for (const file of listDir(path.join(scrumDir, "runs"), RUN_FILE)) {
117
+ const raw = readIf(file);
118
+ if (raw === null) continue;
119
+ const split = splitFrontmatter(raw);
120
+ if (!split) continue;
121
+ const taskRef = extractField(split.header, "task");
122
+ if (taskRef && taskRef !== "null" && TASK_REF.test(taskRef) && taskIds.has(taskRef)) continue;
123
+ orphans.push({ file, reason: taskRef ? `task ${taskRef} is null or missing from .scrumrun/tasks/` : "task field missing", originalText: raw });
124
+ }
125
+ return orphans;
126
+ }
127
+
101
128
  function planFile(file, kind, ctx) {
102
129
  const original = readIf(file);
103
130
  if (original === null) return null;
@@ -161,7 +188,15 @@ function planFile(file, kind, ctx) {
161
188
 
162
189
  if (kind === "run") {
163
190
  const taskRef = extractField(header, "task");
164
- if (taskRef !== undefined && taskRef !== "null" && taskRef !== null && taskRef !== "" && !TASK_REF.test(taskRef)) {
191
+ if (taskRef === undefined) {
192
+ if (hasField(header, "id")) {
193
+ const next = insertFieldAfter(header, "id", "task", "null");
194
+ if (next !== header) {
195
+ changes.push({ field: "task", from: "(missing)", to: "null" });
196
+ header = next;
197
+ }
198
+ }
199
+ } else if (taskRef !== "null" && taskRef !== null && taskRef !== "" && !TASK_REF.test(taskRef)) {
165
200
  const next = replaceField(header, "task", "null");
166
201
  if (next !== header) {
167
202
  changes.push({ field: "task", from: taskRef, to: "null" });
@@ -191,10 +226,33 @@ function planFile(file, kind, ctx) {
191
226
  return { file, kind, changes, nextText, originalText: original };
192
227
  }
193
228
 
229
+ function planGuardrails(scrumDir) {
230
+ const file = path.join(scrumDir, "guardrails.md");
231
+ const original = readIf(file);
232
+ if (original === null) return null;
233
+ const changes = [];
234
+ let text = original;
235
+ const scopeRe = /^(\s*scope:\s*)([^\n\r]+)$/gim;
236
+ text = text.replace(scopeRe, (match, prefix, value) => {
237
+ const trimmed = value.trim();
238
+ if (!trimmed || trimmed === "null") return match;
239
+ if (GUARDRAIL_SCOPES.has(trimmed)) return match;
240
+ changes.push({ field: "scope", from: trimmed, to: "all" });
241
+ return `${prefix}all`;
242
+ });
243
+ if (!changes.length) return null;
244
+ return { file, kind: "guardrails", changes, nextText: text, originalText: original };
245
+ }
246
+
194
247
  function analyze(scrumDir) {
195
248
  const featureIds = scanFeatureIds(scrumDir);
249
+ const taskIds = scanTaskIds(scrumDir);
196
250
  const ctx = { featureIds };
197
251
  const entries = [];
252
+ const orphanRuns = planOrphanRuns(scrumDir, taskIds);
253
+
254
+ const guardrailPlan = planGuardrails(scrumDir);
255
+ if (guardrailPlan) entries.push(guardrailPlan);
198
256
 
199
257
  for (const file of listDir(path.join(scrumDir, "tasks"), TASK_FILE)) {
200
258
  const plan = planFile(file, "task", ctx);
@@ -225,7 +283,7 @@ function analyze(scrumDir) {
225
283
  totals.byField[change.field] = (totals.byField[change.field] || 0) + 1;
226
284
  }
227
285
  }
228
- return { entries, totals };
286
+ return { entries, totals, orphanRuns };
229
287
  }
230
288
 
231
289
  function apply(scrumDir, plan) {
@@ -240,13 +298,33 @@ function apply(scrumDir, plan) {
240
298
  fs.writeFileSync(entry.file, entry.nextText);
241
299
  applied.push({ file: relative, backup: path.relative(scrumDir, backupPath), changes: entry.changes });
242
300
  }
243
- return applied;
301
+ // Also normalize legacy Run ledgers (empty/broken) — same safety guarantee: byte-exact backup.
302
+ let ledgerResult = null;
303
+ try {
304
+ ledgerResult = normalizeLegacyRuns(scrumDir, { dryRun: false });
305
+ } catch {
306
+ ledgerResult = { plan: { malformed: 0 }, applied: [] };
307
+ }
308
+ // Quarantine orphan Runs (Task ref missing/invalid — schema requires TASK-NNN). Move byte-exact
309
+ // to backup so nothing is deleted; the orphan can be manually re-linked or discarded later.
310
+ const orphanBackup = path.join(scrumDir, ".migration-backup", "repair", "orphan-runs");
311
+ const quarantined = [];
312
+ for (const orphan of plan.orphanRuns || []) {
313
+ fs.mkdirSync(orphanBackup, { recursive: true });
314
+ const target = path.join(orphanBackup, path.basename(orphan.file));
315
+ if (!fs.existsSync(target)) fs.writeFileSync(target, orphan.originalText);
316
+ fs.rmSync(orphan.file, { force: true });
317
+ quarantined.push({ id: path.basename(orphan.file, ".md"), reason: orphan.reason, backup: path.relative(scrumDir, target) });
318
+ }
319
+ return { applied, ledger: ledgerResult, quarantined };
244
320
  }
245
321
 
246
322
  function renderReport(plan, applied) {
247
323
  const lines = [];
248
324
  lines.push("## Repair plan\n");
249
- lines.push(`Files needing repair: ${plan.entries.length}`);
325
+ lines.push(`Files needing frontmatter repair: ${plan.entries.length}`);
326
+ if (plan.ledgerMalformed) lines.push(`Run ledgers to normalize: ${plan.ledgerMalformed}`);
327
+ if (plan.orphanRuns && plan.orphanRuns.length) lines.push(`Orphan Runs to quarantine: ${plan.orphanRuns.length}`);
250
328
  const byField = plan.totals.byField;
251
329
  if (Object.keys(byField).length) {
252
330
  lines.push("\nBy field:");
@@ -264,7 +342,17 @@ function renderReport(plan, applied) {
264
342
  }
265
343
  if (applied) {
266
344
  lines.push("");
267
- lines.push(applied.length ? `Applied: ${applied.length} file(s) rewritten. Backups under .scrumrun/.migration-backup/repair/.` : "Applied: no changes.");
345
+ const frontmatterCount = applied.applied ? applied.applied.length : 0;
346
+ const ledgerCount = applied.ledger && applied.ledger.applied ? applied.ledger.applied.length : 0;
347
+ const quarantinedCount = applied.quarantined ? applied.quarantined.length : 0;
348
+ if (frontmatterCount || ledgerCount || quarantinedCount) {
349
+ if (frontmatterCount) lines.push(`Applied: ${frontmatterCount} frontmatter file(s) rewritten.`);
350
+ if (ledgerCount) lines.push(`Normalized: ${ledgerCount} Run ledger(s) collapsed to snapshot events.`);
351
+ if (quarantinedCount) lines.push(`Quarantined: ${quarantinedCount} orphan Run(s) moved to .scrumrun/.migration-backup/repair/orphan-runs/.`);
352
+ lines.push("Backups under .scrumrun/.migration-backup/repair/ and .scrumrun/.migration-backup/runs/.");
353
+ } else {
354
+ lines.push("Applied: no changes.");
355
+ }
268
356
  } else {
269
357
  lines.push("");
270
358
  lines.push("The project remains unchanged. Apply with: scrumrun repair --apply");
@@ -277,8 +365,16 @@ function repair(scrumDir, { apply: doApply = false } = {}) {
277
365
  throw new Error(".scrumrun/ not found; run this inside a ScrumRun project.");
278
366
  }
279
367
  const plan = analyze(scrumDir);
368
+ let ledgerPreview = null;
369
+ try {
370
+ ledgerPreview = normalizeLegacyRuns(scrumDir, { dryRun: true }).plan;
371
+ } catch {
372
+ ledgerPreview = null;
373
+ }
374
+ plan.ledgerMalformed = ledgerPreview ? ledgerPreview.malformed : 0;
280
375
  if (!doApply) return { plan, applied: null, report: renderReport(plan, null) };
281
376
  const applied = apply(scrumDir, plan);
377
+ plan.ledgerNormalized = applied.ledger && applied.ledger.applied ? applied.ledger.applied.length : 0;
282
378
  return { plan, applied, report: renderReport(plan, applied) };
283
379
  }
284
380
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "2.6.6",
3
+ "version": "2.6.8",
4
4
  "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",