scrumrun 2.6.2 → 2.6.3
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/bin/scrumrun.js +13 -0
- package/lib/commands/repair.js +228 -0
- 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.3` · **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/bin/scrumrun.js
CHANGED
|
@@ -61,6 +61,7 @@ Usage:
|
|
|
61
61
|
scrumrun migrate --to 2 --apply
|
|
62
62
|
scrumrun migrate --to 2 --rollback
|
|
63
63
|
scrumrun doctor [all|codex|opencode|claude] [--strict] [--recover]
|
|
64
|
+
scrumrun repair [--apply]
|
|
64
65
|
scrumrun uninstall [--force]
|
|
65
66
|
|
|
66
67
|
Install:
|
|
@@ -2512,6 +2513,18 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
2512
2513
|
} else {
|
|
2513
2514
|
initProject({ force, mode: shared ? "shared" : "local", agentHint: shared || !noAgentHint, lean });
|
|
2514
2515
|
}
|
|
2516
|
+
} else if (command === "repair") {
|
|
2517
|
+
const scrumDir = path.join(process.cwd(), ".scrumrun");
|
|
2518
|
+
try {
|
|
2519
|
+
const { repair } = require(path.join(root, "lib", "commands", "repair"));
|
|
2520
|
+
const doApply = args.includes("--apply");
|
|
2521
|
+
const result = repair(scrumDir, { apply: doApply });
|
|
2522
|
+
console.log(result.report);
|
|
2523
|
+
if (!doApply && result.plan.entries.length) process.exitCode = 0;
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
console.error(`repair failed: ${error.message}`);
|
|
2526
|
+
process.exitCode = 1;
|
|
2527
|
+
}
|
|
2515
2528
|
} else if (command === "uninstall") {
|
|
2516
2529
|
uninstallProject(force);
|
|
2517
2530
|
} else if (command === "status") {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// `scrumrun repair [--apply|--dry-run]`
|
|
4
|
+
//
|
|
5
|
+
// Applies safe, mechanical normalizations to a v2 project that has drifted
|
|
6
|
+
// through hand-writing outside the CLI. Only touches categories where the
|
|
7
|
+
// fix is provably deterministic and does not invent data:
|
|
8
|
+
//
|
|
9
|
+
// 1. task.feature: kebab-case slug that does not resolve to a FEAT-NNN → null.
|
|
10
|
+
// (broken reference; safer to nullify than to fabricate.)
|
|
11
|
+
// 2. task.status alias mapping: done→completed, todo→backlog,
|
|
12
|
+
// complete→completed, in_progress→executing.
|
|
13
|
+
// 3. frontmatter method field: any value that isn't "2.0.0" → "2.0.0".
|
|
14
|
+
// 4. run.attempt missing → 1 (documented default).
|
|
15
|
+
//
|
|
16
|
+
// Categories intentionally NOT touched (need human judgment):
|
|
17
|
+
// - Secret-like content (SECRET_CANONICAL)
|
|
18
|
+
// - Missing Acceptance Criteria sections
|
|
19
|
+
// - Guardrail scope/enforcement (authored semantics)
|
|
20
|
+
// - Task/Run status disagreements (requires domain knowledge)
|
|
21
|
+
// - Empty Run ledgers (use `sc plan run --normalize-legacy`)
|
|
22
|
+
//
|
|
23
|
+
// Every mutated file is backed up byte-exact under
|
|
24
|
+
// `.scrumrun/.migration-backup/repair/<relative-path>` before being rewritten.
|
|
25
|
+
|
|
26
|
+
const fs = require("node:fs");
|
|
27
|
+
const path = require("node:path");
|
|
28
|
+
|
|
29
|
+
const TASK_FILE = /^TASK-\d{3,}\.md$/;
|
|
30
|
+
const RUN_FILE = /^RUN-\d{3,}\.md$/;
|
|
31
|
+
const FEAT_FILE = /^FEAT-\d{3,}\.md$/;
|
|
32
|
+
const FEAT_REF = /^FEAT-\d{3,}$/;
|
|
33
|
+
const SLUG_REF = /^[a-z0-9][a-z0-9-]*$/;
|
|
34
|
+
|
|
35
|
+
const STATUS_ALIAS = {
|
|
36
|
+
done: "completed",
|
|
37
|
+
todo: "backlog",
|
|
38
|
+
complete: "completed",
|
|
39
|
+
in_progress: "executing"
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function readIf(file) {
|
|
43
|
+
try {
|
|
44
|
+
return fs.readFileSync(file, "utf8");
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function listDir(dir, pattern) {
|
|
51
|
+
if (!fs.existsSync(dir) || !fs.lstatSync(dir).isDirectory()) return [];
|
|
52
|
+
return fs.readdirSync(dir).filter((name) => pattern.test(name)).sort().map((name) => path.join(dir, name));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function splitFrontmatter(text) {
|
|
56
|
+
const match = String(text || "").match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n)?([\s\S]*)$/);
|
|
57
|
+
if (!match) return null;
|
|
58
|
+
return { header: match[1], sep: match[2] || "\n", body: match[3] || "" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractField(header, field) {
|
|
62
|
+
const re = new RegExp(`^${field}:\\s*(.*)$`, "im");
|
|
63
|
+
const match = header.match(re);
|
|
64
|
+
return match ? match[1].trim() : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function replaceField(header, field, newValue) {
|
|
68
|
+
const re = new RegExp(`^(${field}:\\s*)(.*)$`, "im");
|
|
69
|
+
return header.replace(re, `$1${newValue}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasField(header, field) {
|
|
73
|
+
const re = new RegExp(`^${field}:\\s`, "im");
|
|
74
|
+
return re.test(header);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function insertFieldAfter(header, afterField, field, value) {
|
|
78
|
+
const re = new RegExp(`^(${afterField}:.*)$`, "im");
|
|
79
|
+
return header.replace(re, `$1\n${field}: ${value}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scanFeatureIds(scrumDir) {
|
|
83
|
+
const dir = path.join(scrumDir, "features");
|
|
84
|
+
const ids = new Set();
|
|
85
|
+
for (const file of listDir(dir, FEAT_FILE)) {
|
|
86
|
+
ids.add(path.basename(file, ".md"));
|
|
87
|
+
}
|
|
88
|
+
return ids;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function planFile(file, kind, ctx) {
|
|
92
|
+
const original = readIf(file);
|
|
93
|
+
if (original === null) return null;
|
|
94
|
+
const split = splitFrontmatter(original);
|
|
95
|
+
if (!split) return null;
|
|
96
|
+
|
|
97
|
+
let header = split.header;
|
|
98
|
+
const changes = [];
|
|
99
|
+
|
|
100
|
+
const method = extractField(header, "method");
|
|
101
|
+
if (method !== undefined && method !== "2.0.0" && method !== "null") {
|
|
102
|
+
const next = replaceField(header, "method", "2.0.0");
|
|
103
|
+
if (next !== header) {
|
|
104
|
+
changes.push({ field: "method", from: method, to: "2.0.0" });
|
|
105
|
+
header = next;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (kind === "task") {
|
|
110
|
+
const feature = extractField(header, "feature");
|
|
111
|
+
if (feature !== undefined && feature !== "null" && feature !== null && feature !== "") {
|
|
112
|
+
if (!FEAT_REF.test(feature) && SLUG_REF.test(feature) && !ctx.featureIds.has(feature)) {
|
|
113
|
+
const next = replaceField(header, "feature", "null");
|
|
114
|
+
if (next !== header) {
|
|
115
|
+
changes.push({ field: "feature", from: feature, to: "null" });
|
|
116
|
+
header = next;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const status = extractField(header, "status");
|
|
122
|
+
if (status && STATUS_ALIAS[status]) {
|
|
123
|
+
const next = replaceField(header, "status", STATUS_ALIAS[status]);
|
|
124
|
+
if (next !== header) {
|
|
125
|
+
changes.push({ field: "status", from: status, to: STATUS_ALIAS[status] });
|
|
126
|
+
header = next;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (kind === "run") {
|
|
132
|
+
if (!hasField(header, "attempt") && hasField(header, "task")) {
|
|
133
|
+
const next = insertFieldAfter(header, "task", "attempt", "1");
|
|
134
|
+
if (next !== header) {
|
|
135
|
+
changes.push({ field: "attempt", from: "(missing)", to: "1" });
|
|
136
|
+
header = next;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!changes.length) return { file, kind, changes: [], nextText: original };
|
|
142
|
+
|
|
143
|
+
const nextText = `---\n${header}\n---${split.sep}${split.body}`;
|
|
144
|
+
return { file, kind, changes, nextText, originalText: original };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function analyze(scrumDir) {
|
|
148
|
+
const featureIds = scanFeatureIds(scrumDir);
|
|
149
|
+
const ctx = { featureIds };
|
|
150
|
+
const entries = [];
|
|
151
|
+
|
|
152
|
+
for (const file of listDir(path.join(scrumDir, "tasks"), TASK_FILE)) {
|
|
153
|
+
const plan = planFile(file, "task", ctx);
|
|
154
|
+
if (plan && plan.changes.length) entries.push(plan);
|
|
155
|
+
}
|
|
156
|
+
for (const file of listDir(path.join(scrumDir, "runs"), RUN_FILE)) {
|
|
157
|
+
const plan = planFile(file, "run", ctx);
|
|
158
|
+
if (plan && plan.changes.length) entries.push(plan);
|
|
159
|
+
}
|
|
160
|
+
for (const file of listDir(path.join(scrumDir, "features"), FEAT_FILE)) {
|
|
161
|
+
const plan = planFile(file, "feature", ctx);
|
|
162
|
+
if (plan && plan.changes.length) entries.push(plan);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const totals = { files: entries.length, byField: {} };
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
for (const change of entry.changes) {
|
|
168
|
+
totals.byField[change.field] = (totals.byField[change.field] || 0) + 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { entries, totals };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function apply(scrumDir, plan) {
|
|
175
|
+
const backupRoot = path.join(scrumDir, ".migration-backup", "repair");
|
|
176
|
+
fs.mkdirSync(backupRoot, { recursive: true });
|
|
177
|
+
const applied = [];
|
|
178
|
+
for (const entry of plan.entries) {
|
|
179
|
+
const relative = path.relative(scrumDir, entry.file);
|
|
180
|
+
const backupPath = path.join(backupRoot, relative);
|
|
181
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
182
|
+
if (!fs.existsSync(backupPath)) fs.writeFileSync(backupPath, entry.originalText);
|
|
183
|
+
fs.writeFileSync(entry.file, entry.nextText);
|
|
184
|
+
applied.push({ file: relative, backup: path.relative(scrumDir, backupPath), changes: entry.changes });
|
|
185
|
+
}
|
|
186
|
+
return applied;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderReport(plan, applied) {
|
|
190
|
+
const lines = [];
|
|
191
|
+
lines.push("## Repair plan\n");
|
|
192
|
+
lines.push(`Files needing repair: ${plan.entries.length}`);
|
|
193
|
+
const byField = plan.totals.byField;
|
|
194
|
+
if (Object.keys(byField).length) {
|
|
195
|
+
lines.push("\nBy field:");
|
|
196
|
+
for (const [field, count] of Object.entries(byField)) {
|
|
197
|
+
lines.push(` - ${field}: ${count}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (plan.entries.length) {
|
|
201
|
+
lines.push("\nDetails (up to 30 files shown):");
|
|
202
|
+
for (const entry of plan.entries.slice(0, 30)) {
|
|
203
|
+
const summary = entry.changes.map((c) => `${c.field}: ${c.from} → ${c.to}`).join("; ");
|
|
204
|
+
lines.push(` - ${path.basename(entry.file)}: ${summary}`);
|
|
205
|
+
}
|
|
206
|
+
if (plan.entries.length > 30) lines.push(` … and ${plan.entries.length - 30} more`);
|
|
207
|
+
}
|
|
208
|
+
if (applied) {
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(applied.length ? `Applied: ${applied.length} file(s) rewritten. Backups under .scrumrun/.migration-backup/repair/.` : "Applied: no changes.");
|
|
211
|
+
} else {
|
|
212
|
+
lines.push("");
|
|
213
|
+
lines.push("The project remains unchanged. Apply with: scrumrun repair --apply");
|
|
214
|
+
}
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function repair(scrumDir, { apply: doApply = false } = {}) {
|
|
219
|
+
if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) {
|
|
220
|
+
throw new Error(".scrumrun/ not found; run this inside a ScrumRun project.");
|
|
221
|
+
}
|
|
222
|
+
const plan = analyze(scrumDir);
|
|
223
|
+
if (!doApply) return { plan, applied: null, report: renderReport(plan, null) };
|
|
224
|
+
const applied = apply(scrumDir, plan);
|
|
225
|
+
return { plan, applied, report: renderReport(plan, applied) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { repair, analyze, apply, renderReport };
|