scrumrun 2.6.7 → 2.6.9

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.7` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
7
+ **Package:** `2.6.9` · **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,
@@ -102,6 +102,29 @@ function scanFeatureIds(scrumDir) {
102
102
  return ids;
103
103
  }
104
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
+
105
128
  function planFile(file, kind, ctx) {
106
129
  const original = readIf(file);
107
130
  if (original === null) return null;
@@ -164,6 +187,23 @@ function planFile(file, kind, ctx) {
164
187
  }
165
188
 
166
189
  if (kind === "run") {
190
+ // Reconcile `updated` with the last event's occurred_at date. Fixes Runs
191
+ // normalized by an older CLI that stamped `updated: today` instead of
192
+ // matching the snapshot event, which produced the "Run updated date
193
+ // disagrees with final event timestamp" migration blocker.
194
+ const currentUpdated = extractField(header, "updated");
195
+ const eventDates = [...split.body.matchAll(/"occurred_at":\s*"(\d{4}-\d{2}-\d{2})/g)].map((m) => m[1]);
196
+ if (eventDates.length && currentUpdated && ISO_DATE.test(currentUpdated)) {
197
+ const lastEventDate = eventDates[eventDates.length - 1];
198
+ if (currentUpdated !== lastEventDate) {
199
+ const next = replaceField(header, "updated", lastEventDate);
200
+ if (next !== header) {
201
+ changes.push({ field: "updated", from: currentUpdated, to: lastEventDate });
202
+ header = next;
203
+ }
204
+ }
205
+ }
206
+
167
207
  const taskRef = extractField(header, "task");
168
208
  if (taskRef === undefined) {
169
209
  if (hasField(header, "id")) {
@@ -223,8 +263,10 @@ function planGuardrails(scrumDir) {
223
263
 
224
264
  function analyze(scrumDir) {
225
265
  const featureIds = scanFeatureIds(scrumDir);
266
+ const taskIds = scanTaskIds(scrumDir);
226
267
  const ctx = { featureIds };
227
268
  const entries = [];
269
+ const orphanRuns = planOrphanRuns(scrumDir, taskIds);
228
270
 
229
271
  const guardrailPlan = planGuardrails(scrumDir);
230
272
  if (guardrailPlan) entries.push(guardrailPlan);
@@ -258,7 +300,7 @@ function analyze(scrumDir) {
258
300
  totals.byField[change.field] = (totals.byField[change.field] || 0) + 1;
259
301
  }
260
302
  }
261
- return { entries, totals };
303
+ return { entries, totals, orphanRuns };
262
304
  }
263
305
 
264
306
  function apply(scrumDir, plan) {
@@ -280,7 +322,18 @@ function apply(scrumDir, plan) {
280
322
  } catch {
281
323
  ledgerResult = { plan: { malformed: 0 }, applied: [] };
282
324
  }
283
- return { applied, ledger: ledgerResult };
325
+ // Quarantine orphan Runs (Task ref missing/invalid — schema requires TASK-NNN). Move byte-exact
326
+ // to backup so nothing is deleted; the orphan can be manually re-linked or discarded later.
327
+ const orphanBackup = path.join(scrumDir, ".migration-backup", "repair", "orphan-runs");
328
+ const quarantined = [];
329
+ for (const orphan of plan.orphanRuns || []) {
330
+ fs.mkdirSync(orphanBackup, { recursive: true });
331
+ const target = path.join(orphanBackup, path.basename(orphan.file));
332
+ if (!fs.existsSync(target)) fs.writeFileSync(target, orphan.originalText);
333
+ fs.rmSync(orphan.file, { force: true });
334
+ quarantined.push({ id: path.basename(orphan.file, ".md"), reason: orphan.reason, backup: path.relative(scrumDir, target) });
335
+ }
336
+ return { applied, ledger: ledgerResult, quarantined };
284
337
  }
285
338
 
286
339
  function renderReport(plan, applied) {
@@ -288,6 +341,7 @@ function renderReport(plan, applied) {
288
341
  lines.push("## Repair plan\n");
289
342
  lines.push(`Files needing frontmatter repair: ${plan.entries.length}`);
290
343
  if (plan.ledgerMalformed) lines.push(`Run ledgers to normalize: ${plan.ledgerMalformed}`);
344
+ if (plan.orphanRuns && plan.orphanRuns.length) lines.push(`Orphan Runs to quarantine: ${plan.orphanRuns.length}`);
291
345
  const byField = plan.totals.byField;
292
346
  if (Object.keys(byField).length) {
293
347
  lines.push("\nBy field:");
@@ -307,9 +361,11 @@ function renderReport(plan, applied) {
307
361
  lines.push("");
308
362
  const frontmatterCount = applied.applied ? applied.applied.length : 0;
309
363
  const ledgerCount = applied.ledger && applied.ledger.applied ? applied.ledger.applied.length : 0;
310
- if (frontmatterCount || ledgerCount) {
364
+ const quarantinedCount = applied.quarantined ? applied.quarantined.length : 0;
365
+ if (frontmatterCount || ledgerCount || quarantinedCount) {
311
366
  if (frontmatterCount) lines.push(`Applied: ${frontmatterCount} frontmatter file(s) rewritten.`);
312
367
  if (ledgerCount) lines.push(`Normalized: ${ledgerCount} Run ledger(s) collapsed to snapshot events.`);
368
+ if (quarantinedCount) lines.push(`Quarantined: ${quarantinedCount} orphan Run(s) moved to .scrumrun/.migration-backup/repair/orphan-runs/.`);
313
369
  lines.push("Backups under .scrumrun/.migration-backup/repair/ and .scrumrun/.migration-backup/runs/.");
314
370
  } else {
315
371
  lines.push("Applied: no changes.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "2.6.7",
3
+ "version": "2.6.9",
4
4
  "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",