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 +1 -1
- package/lib/commands/normalize-legacy.js +3 -2
- package/lib/commands/repair.js +101 -5
- package/package.json +1 -1
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
|
+
**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:
|
|
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: `${
|
|
112
|
+
occurred_at: `${snapshotDate}T00:00:00.000Z`,
|
|
112
113
|
timestamp_precision: "date",
|
|
113
114
|
actor: "migration",
|
|
114
115
|
from: null,
|
package/lib/commands/repair.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|