scrumrun 2.7.9 → 2.7.11
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 +10 -1
- package/lib/commands/repair.js +128 -2
- 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.7.
|
|
7
|
+
**Package:** `2.7.11` · **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
|
|
|
@@ -167,6 +167,15 @@ npx scrumrun@latest migrate --to 2 --rollback
|
|
|
167
167
|
|
|
168
168
|
Migration uses content hashes, a byte-exact ignored backup, staging validation, an atomic directory switch, a source-to-destination report, idempotent replay, and rollback protection. It also recognizes incomplete hybrid v1/v2 trees, reuses already-linked canonical work, and normalizes only deterministic schema aliases. Early v2 Run prose is preflighted and upgraded to the structured ledger by the same explicit `update --migrate` gate; ambiguous paths become evidenced snapshots instead of invented transitions. Legacy-only files leave the active tree but remain byte-exact in the ignored backup; vault contents remain local. Ambiguous history remains an explicit warning, and the migrator never invents a Sprint or Run.
|
|
169
169
|
|
|
170
|
+
For projects that already have v2 directories but contain pre-v2 Markdown records without YAML frontmatter, use the explicit repair gate:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
npx scrumrun@latest repair
|
|
174
|
+
npx scrumrun@latest repair --apply
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Repair converts deterministic `## Metadata` fields into v2 frontmatter, preserves the authored body, recovers IDs from canonical filenames, and stores byte-exact backups under `.scrumrun/.migration-backup/repair/`. It does not guess ambiguous relationships; validate afterward with `doctor --strict`.
|
|
178
|
+
|
|
170
179
|
After migration or an integration update, verify both installed assets and project state:
|
|
171
180
|
|
|
172
181
|
```bash
|
package/lib/commands/repair.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// complete→completed, in_progress→executing.
|
|
13
13
|
// 3. frontmatter method field: any value that isn't "2.0.0" → "2.0.0".
|
|
14
14
|
// 4. run.attempt missing → 1 (documented default).
|
|
15
|
+
// 5. Sprint task projections: add Task.sprint references absent from the
|
|
16
|
+
// Sprint's `## Tasks` list. Extra entries remain for human review.
|
|
15
17
|
//
|
|
16
18
|
// Categories intentionally NOT touched (need human judgment):
|
|
17
19
|
// - Secret-like content (SECRET_CANONICAL)
|
|
@@ -72,6 +74,7 @@ const RUN_TO_TASK_STATUS = {
|
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
const GUARDRAIL_SCOPES = new Set(["all", "intake", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
|
|
77
|
+
const CANONICAL_DIRS = ["tasks", "runs", "features", "sprints", "reviews", "memory/knowledge", "memory/decisions", "memory/insights", "memory/dossiers"];
|
|
75
78
|
|
|
76
79
|
function readIf(file) {
|
|
77
80
|
try {
|
|
@@ -131,6 +134,127 @@ function scanTaskIds(scrumDir) {
|
|
|
131
134
|
return ids;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
function planSprintMembership(scrumDir) {
|
|
138
|
+
const tasksBySprint = new Map();
|
|
139
|
+
for (const file of listDir(path.join(scrumDir, "tasks"), TASK_FILE)) {
|
|
140
|
+
const parsed = splitFrontmatter(readIf(file));
|
|
141
|
+
if (!parsed) continue;
|
|
142
|
+
const sprint = extractField(parsed.header, "sprint");
|
|
143
|
+
const id = extractField(parsed.header, "id") || path.basename(file, ".md");
|
|
144
|
+
if (!/^SPRINT-\d{3,}$/.test(sprint || "") || !TASK_REF.test(id)) continue;
|
|
145
|
+
if (!tasksBySprint.has(sprint)) tasksBySprint.set(sprint, []);
|
|
146
|
+
tasksBySprint.get(sprint).push(id);
|
|
147
|
+
}
|
|
148
|
+
const entries = [];
|
|
149
|
+
for (const file of listDir(path.join(scrumDir, "sprints"), SPRINT_FILE)) {
|
|
150
|
+
const original = readIf(file);
|
|
151
|
+
const parsed = splitFrontmatter(original);
|
|
152
|
+
if (!parsed) continue;
|
|
153
|
+
const sprintId = extractField(parsed.header, "id") || path.basename(file, ".md");
|
|
154
|
+
const expected = tasksBySprint.get(sprintId) || [];
|
|
155
|
+
if (!expected.length) continue;
|
|
156
|
+
const heading = /^## Tasks[ \t]*$/m.exec(parsed.body);
|
|
157
|
+
if (!heading) continue;
|
|
158
|
+
const tail = parsed.body.slice(heading.index + heading[0].length);
|
|
159
|
+
const nextHeading = /^## [^\r\n]+$/m.exec(tail);
|
|
160
|
+
const sectionEnd = nextHeading ? heading.index + heading[0].length + nextHeading.index : parsed.body.length;
|
|
161
|
+
const section = parsed.body.slice(heading.index + heading[0].length, sectionEnd);
|
|
162
|
+
const listed = new Set([...section.matchAll(/^-\s+(TASK-\d{3,})\s*$/gm)].map((match) => match[1]));
|
|
163
|
+
const missing = expected.filter((id) => !listed.has(id));
|
|
164
|
+
if (!missing.length) continue;
|
|
165
|
+
const before = parsed.body.slice(0, sectionEnd).replace(/\s*$/, "\n");
|
|
166
|
+
const after = parsed.body.slice(sectionEnd).replace(/^\n/, "");
|
|
167
|
+
const body = `${before}${missing.map((id) => `- ${id}`).join("\n")}\n${after}`;
|
|
168
|
+
entries.push({
|
|
169
|
+
file,
|
|
170
|
+
kind: "sprint-membership",
|
|
171
|
+
changes: [{ field: "sprint.tasks", from: "missing projection", to: missing.join(", ") }],
|
|
172
|
+
nextText: `---\n${parsed.header}\n---${parsed.sep}${body}`,
|
|
173
|
+
originalText: original
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function legacyMetadata(body) {
|
|
180
|
+
const metadata = {};
|
|
181
|
+
const section = String(body || "").match(/^## Metadata\s*$([\s\S]*?)(?=^##\s+|(?![\s\S]))/im);
|
|
182
|
+
if (!section) return metadata;
|
|
183
|
+
for (const line of section[1].split(/\r?\n/)) {
|
|
184
|
+
const match = line.match(/^\s*-\s*([a-z][a-z0-9_]*)\s*:\s*(.*?)\s*$/i);
|
|
185
|
+
if (match) metadata[match[1].toLowerCase()] = match[2];
|
|
186
|
+
}
|
|
187
|
+
return metadata;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function canonicalKind(relative) {
|
|
191
|
+
if (/^tasks\/TASK-\d{3,}\.md$/.test(relative)) return "task";
|
|
192
|
+
if (/^runs\/RUN-\d{3,}\.md$/.test(relative)) return "run";
|
|
193
|
+
if (/^features\/FEAT-\d{3,}\.md$/.test(relative)) return "feature";
|
|
194
|
+
if (/^sprints\/SPRINT-\d{3,}\.md$/.test(relative)) return "sprint";
|
|
195
|
+
if (/^reviews\/REV-\d{3,}\.md$/.test(relative)) return "review";
|
|
196
|
+
if (/^memory\/knowledge\/K-\d{3,}\.md$/.test(relative)) return "knowledge";
|
|
197
|
+
if (/^memory\/decisions\/DEC-\d{3,}\.md$/.test(relative)) return "decision";
|
|
198
|
+
if (/^memory\/insights\/INS-\d{3,}\.md$/.test(relative)) return "insight";
|
|
199
|
+
if (/^memory\/dossiers\/DOS-\d{3,}\.md$/.test(relative)) return "dossier";
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function legacyStatus(kind, value) {
|
|
204
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
205
|
+
const aliases = kind === "task" ? TASK_STATUS_ALIAS : kind === "run" ? RUN_STATUS_ALIAS : {};
|
|
206
|
+
const normalized = aliases[raw] || raw;
|
|
207
|
+
const valid = {
|
|
208
|
+
feature: ["backlog", "proposed", "active", "completed", "paused", "cancelled"],
|
|
209
|
+
task: [...VALID_TASK_STATUS],
|
|
210
|
+
sprint: ["proposed", "running", "partial", "completed", "blocked", "cancelled"],
|
|
211
|
+
run: [...VALID_RUN_STATUS],
|
|
212
|
+
review: ["proposed", "running", "passed", "failed", "archived"],
|
|
213
|
+
knowledge: ["candidate", "approved", "rejected", "deprecated", "invalidated"],
|
|
214
|
+
decision: ["open", "resolved", "deprecated", "invalidated"],
|
|
215
|
+
insight: ["candidate", "confirmed", "stale", "deprecated", "invalidated"],
|
|
216
|
+
dossier: ["active", "stale", "deprecated", "archived"]
|
|
217
|
+
}[kind] || [];
|
|
218
|
+
if (valid.includes(normalized)) return normalized;
|
|
219
|
+
return { feature: "proposed", task: "proposed", sprint: "proposed", run: "partial", review: "proposed", knowledge: "candidate", decision: "open", insight: "candidate", dossier: "active" }[kind];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function planMissingFrontmatter(scrumDir) {
|
|
223
|
+
const entries = [];
|
|
224
|
+
for (const directory of CANONICAL_DIRS) {
|
|
225
|
+
const absolute = path.join(scrumDir, directory);
|
|
226
|
+
if (!fs.existsSync(absolute) || !fs.lstatSync(absolute).isDirectory()) continue;
|
|
227
|
+
for (const file of fs.readdirSync(absolute).sort().map((name) => path.join(absolute, name))) {
|
|
228
|
+
if (!fs.lstatSync(file).isFile()) continue;
|
|
229
|
+
const relative = path.relative(scrumDir, file).split(path.sep).join("/");
|
|
230
|
+
const kind = canonicalKind(relative);
|
|
231
|
+
if (!kind) continue;
|
|
232
|
+
const original = readIf(file);
|
|
233
|
+
if (original === null || splitFrontmatter(original)) continue;
|
|
234
|
+
const metadata = legacyMetadata(original);
|
|
235
|
+
const id = path.basename(file, ".md");
|
|
236
|
+
const statDate = new Date(fs.statSync(file).mtimeMs).toISOString().slice(0, 10);
|
|
237
|
+
const created = ISO_DATE.test(metadata.created || "") ? metadata.created : statDate;
|
|
238
|
+
const updated = ISO_DATE.test(metadata.updated || "") ? metadata.updated : created;
|
|
239
|
+
const task = kind === "run" ? (TASK_REF.test(metadata.task || "") ? metadata.task : ((original.match(/\b(TASK-\d{3,})\b/) || [])[1] || null)) : null;
|
|
240
|
+
const featureValue = metadata.feature || metadata.scope || null;
|
|
241
|
+
const feature = FEAT_REF.test(featureValue || "") ? featureValue : null;
|
|
242
|
+
const sprint = /^SPRINT-\d{3,}$/.test(metadata.sprint || "") ? metadata.sprint : null;
|
|
243
|
+
const record = { id, kind, status: legacyStatus(kind, metadata.status), created, updated, method: "2.0.0" };
|
|
244
|
+
if (kind === "task") Object.assign(record, { type: metadata.type || "task", feature, sprint });
|
|
245
|
+
if (kind === "run") Object.assign(record, { task, sprint, attempt: Number(metadata.attempt) > 0 ? Number(metadata.attempt) : 1, ledger: 1 });
|
|
246
|
+
if (kind === "feature") record.type = "initiative";
|
|
247
|
+
if (kind === "decision" && feature) record.feature = feature;
|
|
248
|
+
const frontmatter = Object.entries(record).map(([key, value]) => `${key}: ${value === null ? "null" : value}`).join("\n");
|
|
249
|
+
const acceptance = kind === "task" && !/^## Acceptance Criteria\b/m.test(original)
|
|
250
|
+
? "\n\n## Acceptance Criteria\n\n- Preserved from legacy migration. Define what done means before re-approaching this work.\n"
|
|
251
|
+
: "";
|
|
252
|
+
entries.push({ file, kind: "legacy-frontmatter", changes: [{ field: "frontmatter", from: "missing", to: `${kind} schema` }], nextText: `---\n${frontmatter}\n---\n\n${original}${acceptance}`, originalText: original });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return entries;
|
|
256
|
+
}
|
|
257
|
+
|
|
134
258
|
function planStatusSync(scrumDir) {
|
|
135
259
|
// For each Task with Runs, align Task.status with the latest Run's status
|
|
136
260
|
// (by highest attempt number, then created date). Legacy Tasks hand-marked
|
|
@@ -537,7 +661,7 @@ function analyze(scrumDir) {
|
|
|
537
661
|
const featureIds = scanFeatureIds(scrumDir);
|
|
538
662
|
const taskIds = scanTaskIds(scrumDir);
|
|
539
663
|
const ctx = { featureIds };
|
|
540
|
-
const entries =
|
|
664
|
+
const entries = planMissingFrontmatter(scrumDir);
|
|
541
665
|
const orphanRuns = planOrphanRuns(scrumDir, taskIds);
|
|
542
666
|
|
|
543
667
|
const guardrailPlan = planGuardrails(scrumDir);
|
|
@@ -546,8 +670,10 @@ function analyze(scrumDir) {
|
|
|
546
670
|
// Extra passes: status sync, attempt resequence, acceptance criteria, secret redaction.
|
|
547
671
|
entries.push(...planStatusSync(scrumDir));
|
|
548
672
|
entries.push(...planAttemptResequence(scrumDir));
|
|
549
|
-
entries.
|
|
673
|
+
const missingFrontmatter = new Set(entries.filter((entry) => entry.kind === "legacy-frontmatter").map((entry) => entry.file));
|
|
674
|
+
entries.push(...planAcceptanceCriteria(scrumDir).filter((entry) => !missingFrontmatter.has(entry.file)));
|
|
550
675
|
entries.push(...planSecretRedaction(scrumDir));
|
|
676
|
+
entries.push(...planSprintMembership(scrumDir));
|
|
551
677
|
|
|
552
678
|
for (const file of listDir(path.join(scrumDir, "tasks"), TASK_FILE)) {
|
|
553
679
|
const plan = planFile(file, "task", ctx);
|