scrumrun 1.5.2 → 2.0.0

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CORE.md +224 -311
  3. package/DECISIONS.md +95 -11
  4. package/LICENSE +21 -0
  5. package/MIGRATION-1-to-2.md +109 -0
  6. package/README.md +137 -78
  7. package/SPEC.md +248 -257
  8. package/bin/claude-install.js +18 -132
  9. package/bin/scrumrun.js +1259 -174
  10. package/docs/COMMANDS.md +69 -0
  11. package/docs/ENTITY-MODEL.md +38 -0
  12. package/docs/RELEASE.md +44 -0
  13. package/docs/SCHEMA.md +80 -0
  14. package/docs/SEMANTIC-MEMORY.md +68 -0
  15. package/docs/TROUBLESHOOTING.md +66 -0
  16. package/lib/code-intel/adapter.js +20 -0
  17. package/lib/code-intel/javascript.js +199 -0
  18. package/lib/code-intel/learning.js +66 -0
  19. package/lib/code-intel/scanner.js +126 -0
  20. package/lib/commands/manifest.js +109 -0
  21. package/lib/commands/render.js +75 -0
  22. package/lib/memory/index.js +600 -0
  23. package/lib/memory/markdown.js +51 -0
  24. package/lib/memory/service.js +300 -0
  25. package/lib/runtime/budgets.js +19 -0
  26. package/lib/runtime/context.js +141 -0
  27. package/lib/runtime/orchestrator.js +270 -0
  28. package/lib/runtime/request-engine.js +128 -0
  29. package/lib/security/secrets.js +23 -0
  30. package/lib/v2/artifacts.js +343 -0
  31. package/lib/v2/conformance.js +167 -0
  32. package/lib/v2/migration.js +1161 -0
  33. package/lib/v2/project-store.js +44 -0
  34. package/lib/v2/schema.js +128 -0
  35. package/package.json +16 -6
  36. package/scripts/generate-contract-docs.js +113 -0
  37. package/templates/project/.scrumrun/config.md +4 -7
  38. package/templates/project/.scrumrun/guardrails.md +23 -0
  39. package/templates/project/.scrumrun/map.md +4 -16
  40. package/templates/project/.scrumrun/memory/decisions/.gitkeep +1 -0
  41. package/templates/project/.scrumrun/memory/dossiers/.gitkeep +1 -0
  42. package/templates/project/.scrumrun/memory/insights/.gitkeep +1 -0
  43. package/templates/project/.scrumrun/memory/knowledge/.gitkeep +1 -0
  44. package/templates/project/.scrumrun/method.json +4 -0
  45. package/templates/project/.scrumrun/project.md +6 -12
  46. package/templates/project/.scrumrun/runs/.gitkeep +1 -0
  47. package/templates/project/.scrumrun/sprints/.gitkeep +1 -0
  48. package/templates/project/.scrumrun/state.md +21 -0
  49. package/templates/project/.scrumrun/tasks/.gitkeep +1 -0
  50. package/templates/project/AGENTS.md +16 -49
  51. package/templates/project-lean/AGENTS.md +18 -0
  52. package/templates/shared/skills/scrumrun/SKILL.md +201 -0
  53. package/templates/codex/prompts/sc-agent.md +0 -14
  54. package/templates/codex/prompts/sc-backlog.md +0 -13
  55. package/templates/codex/prompts/sc-challenge.md +0 -57
  56. package/templates/codex/prompts/sc-config.md +0 -18
  57. package/templates/codex/prompts/sc-context.md +0 -24
  58. package/templates/codex/prompts/sc-decisions.md +0 -8
  59. package/templates/codex/prompts/sc-feature.md +0 -16
  60. package/templates/codex/prompts/sc-fix.md +0 -21
  61. package/templates/codex/prompts/sc-goal.md +0 -14
  62. package/templates/codex/prompts/sc-golden.md +0 -14
  63. package/templates/codex/prompts/sc-help.md +0 -12
  64. package/templates/codex/prompts/sc-init.md +0 -14
  65. package/templates/codex/prompts/sc-intake.md +0 -22
  66. package/templates/codex/prompts/sc-know.md +0 -75
  67. package/templates/codex/prompts/sc-map.md +0 -13
  68. package/templates/codex/prompts/sc-review.md +0 -13
  69. package/templates/codex/prompts/sc-sprint.md +0 -28
  70. package/templates/codex/prompts/sc-study.md +0 -23
  71. package/templates/codex/prompts/sc-uninstall.md +0 -14
  72. package/templates/codex/prompts/sc-update.md +0 -8
  73. package/templates/codex/prompts/sc-vault.md +0 -27
  74. package/templates/codex/skills/scrumrun/SKILL.md +0 -412
  75. package/templates/opencode/commands/sc-agent.md +0 -14
  76. package/templates/opencode/commands/sc-backlog.md +0 -13
  77. package/templates/opencode/commands/sc-challenge.md +0 -57
  78. package/templates/opencode/commands/sc-config.md +0 -18
  79. package/templates/opencode/commands/sc-context.md +0 -24
  80. package/templates/opencode/commands/sc-decisions.md +0 -8
  81. package/templates/opencode/commands/sc-feature.md +0 -16
  82. package/templates/opencode/commands/sc-fix.md +0 -21
  83. package/templates/opencode/commands/sc-goal.md +0 -14
  84. package/templates/opencode/commands/sc-golden.md +0 -14
  85. package/templates/opencode/commands/sc-help.md +0 -12
  86. package/templates/opencode/commands/sc-init.md +0 -14
  87. package/templates/opencode/commands/sc-intake.md +0 -22
  88. package/templates/opencode/commands/sc-know.md +0 -75
  89. package/templates/opencode/commands/sc-map.md +0 -13
  90. package/templates/opencode/commands/sc-review.md +0 -13
  91. package/templates/opencode/commands/sc-sprint.md +0 -28
  92. package/templates/opencode/commands/sc-study.md +0 -23
  93. package/templates/opencode/commands/sc-uninstall.md +0 -14
  94. package/templates/opencode/commands/sc-update.md +0 -8
  95. package/templates/opencode/commands/sc-vault.md +0 -27
  96. package/templates/opencode/skills/scrumrun/SKILL.md +0 -412
  97. package/templates/project/.scrumrun/agents.md +0 -36
  98. package/templates/project/.scrumrun/backlog.md +0 -7
  99. package/templates/project/.scrumrun/context.md +0 -61
  100. package/templates/project/.scrumrun/goals/main/decisions.md +0 -9
  101. package/templates/project/.scrumrun/goals/main/history.md +0 -51
  102. package/templates/project/.scrumrun/goals/main/sprint.md +0 -54
  103. package/templates/project/.scrumrun/golden-rules.md +0 -9
  104. package/templates/project/.scrumrun/knowledge.md +0 -15
  105. package/templates/project/.scrumrun/runbook.md +0 -101
  106. package/templates/project/.scrumrun/token-policy.md +0 -43
@@ -0,0 +1,1161 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { extractEvidence } = require("../memory/markdown");
6
+ const { containsSecret } = require("../security/secrets");
7
+ const {
8
+ ARTIFACT_TYPES,
9
+ ArtifactRepository,
10
+ METHOD_VERSION,
11
+ assertNoSymlinkPath,
12
+ atomicWrite,
13
+ parseArtifact,
14
+ relativeArtifactPath,
15
+ serializeArtifact,
16
+ sha256,
17
+ validateArtifact
18
+ } = require("./artifacts");
19
+
20
+ const MIGRATION_NAME = "v1-to-v2";
21
+ const MANIFEST_PATH = path.join(".migration", MIGRATION_NAME, "manifest.json");
22
+ const REPORT_PATH = path.join(".migration", MIGRATION_NAME, "report.md");
23
+ const BACKUP_PATH = path.join(".migration", MIGRATION_NAME, "backup");
24
+ const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
25
+ const LEGACY_ROOT_FILES = new Set([
26
+ ".DS_Store",
27
+ "agents.md",
28
+ "backlog.md",
29
+ "context.md",
30
+ "fixes.md",
31
+ "golden-rules.md",
32
+ "knowledge.md",
33
+ "runbook.md",
34
+ "token-policy.md"
35
+ ]);
36
+
37
+ function posix(relative) {
38
+ return relative.split(path.sep).join("/");
39
+ }
40
+
41
+ function backupRelative(relative) {
42
+ return posix(path.join(BACKUP_PATH, relative));
43
+ }
44
+
45
+ function isLegacyActivePath(relative) {
46
+ const normalized = posix(relative);
47
+ if (LEGACY_ROOT_FILES.has(normalized) || normalized.startsWith("goals/") || normalized.startsWith("knowledge/")) return true;
48
+ if (/^features\/[^/]+\//.test(normalized)) return true;
49
+ if (/^reviews\/(?!REV-\d{3,}\.md$).+\.md$/i.test(normalized)) return true;
50
+ return false;
51
+ }
52
+
53
+ function removeLegacyActivePaths(stage, archivedPaths) {
54
+ for (const relative of [...archivedPaths].sort((a, b) => b.length - a.length)) {
55
+ const target = path.join(stage, relative);
56
+ if (fs.existsSync(target) && fs.lstatSync(target).isFile()) fs.rmSync(target, { force: true });
57
+ }
58
+ for (const relative of ["goals", "knowledge"]) {
59
+ const target = path.join(stage, relative);
60
+ if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
61
+ }
62
+ for (const directory of ["features", "reviews"]) {
63
+ const target = path.join(stage, directory);
64
+ if (!fs.existsSync(target)) continue;
65
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
66
+ if (directory === "features" && entry.isDirectory()) fs.rmSync(path.join(target, entry.name), { recursive: true, force: true });
67
+ if (directory === "reviews" && entry.isFile() && !/^REV-\d{3,}\.md$/.test(entry.name)) fs.rmSync(path.join(target, entry.name), { force: true });
68
+ }
69
+ }
70
+ }
71
+
72
+ function today() {
73
+ return new Date().toISOString().slice(0, 10);
74
+ }
75
+
76
+ function walkTree(root) {
77
+ const files = [];
78
+ const directories = [];
79
+ const symlinks = [];
80
+ if (!fs.existsSync(root)) return { files, directories, symlinks };
81
+
82
+ function visit(directory, relative = "") {
83
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
84
+ const rel = relative ? path.join(relative, entry.name) : entry.name;
85
+ const full = path.join(directory, entry.name);
86
+ const stat = fs.lstatSync(full);
87
+ if (stat.isSymbolicLink()) {
88
+ symlinks.push({ path: posix(rel), target: fs.readlinkSync(full) });
89
+ } else if (stat.isDirectory()) {
90
+ directories.push({ path: posix(rel), mode: stat.mode & 0o777 });
91
+ visit(full, rel);
92
+ } else if (stat.isFile()) {
93
+ const content = fs.readFileSync(full);
94
+ files.push({
95
+ path: posix(rel),
96
+ size: content.length,
97
+ mode: stat.mode & 0o777,
98
+ sha256: sha256(content)
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ visit(root);
105
+ return { files, directories, symlinks };
106
+ }
107
+
108
+ function inventoryTree(root) {
109
+ const tree = walkTree(root);
110
+ const fingerprintInput = [
111
+ ...tree.directories.map((entry) => `d\0${entry.path}\0${entry.mode}`),
112
+ ...tree.files.map((entry) => `f\0${entry.path}\0${entry.size}\0${entry.mode}\0${entry.sha256}`),
113
+ ...tree.symlinks.map((entry) => `l\0${entry.path}\0${entry.target}`)
114
+ ].sort().join("\n");
115
+ return { ...tree, rootSha256: sha256(fingerprintInput) };
116
+ }
117
+
118
+ function readText(scrumDir, relative) {
119
+ const file = path.join(scrumDir, relative);
120
+ return fs.existsSync(file) && fs.lstatSync(file).isFile() ? fs.readFileSync(file, "utf8") : "";
121
+ }
122
+
123
+ function packagedFile(relative, variables = {}) {
124
+ let content = fs.readFileSync(path.join(PACKAGE_ROOT, relative), "utf8");
125
+ for (const [name, value] of Object.entries(variables)) content = content.split(`{{${name}}}`).join(value);
126
+ return content.endsWith("\n") ? content : `${content}\n`;
127
+ }
128
+
129
+ function v2Config(content, projectName) {
130
+ if (!content.trim()) {
131
+ return packagedFile(path.join("templates", "project", ".scrumrun", "config.md"), { PROJECT_NAME: projectName });
132
+ }
133
+ let next = content.trimEnd();
134
+ const values = [
135
+ ["Method Version", METHOD_VERSION],
136
+ ["CLI Target", METHOD_VERSION],
137
+ ["Language", "English"],
138
+ ["Interaction Mode", "guided"],
139
+ ["Execution Approval", "always"],
140
+ ["Quick Tasks", "ask"]
141
+ ];
142
+ for (const [field, fallback] of values) {
143
+ const matcher = new RegExp(`^${field}:\\s*.*$`, "m");
144
+ if (field === "Method Version" || field === "CLI Target") {
145
+ next = matcher.test(next) ? next.replace(matcher, `${field}: ${METHOD_VERSION}`) : `${next}\n${field}: ${METHOD_VERSION}`;
146
+ } else if (!matcher.test(next)) {
147
+ next = `${next}\n${field}: ${fallback}`;
148
+ }
149
+ }
150
+ if (!/cannot weaken .*guardrails\.md/i.test(next)) {
151
+ next += "\n\nThese are operating preferences. They can never weaken `.scrumrun/guardrails.md`.";
152
+ }
153
+ return `${next}\n`;
154
+ }
155
+
156
+ function section(content, title) {
157
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
158
+ const match = new RegExp(`^## ${escaped}[ \\t]*$`, "m").exec(content);
159
+ if (!match) return null;
160
+ const bodyStart = match.index + match[0].length;
161
+ const tail = content.slice(bodyStart);
162
+ const next = /^## [^\n]+$/m.exec(tail);
163
+ const end = next ? bodyStart + next.index : content.length;
164
+ return { body: content.slice(bodyStart, end), bodyStart, end };
165
+ }
166
+
167
+ function headingBlocks(content, level) {
168
+ const headings = [...content.matchAll(/^(#{1,6})[ \t]+([^\r\n]+)[ \t]*$/gm)];
169
+ const blocks = [];
170
+ for (let index = 0; index < headings.length; index++) {
171
+ const heading = headings[index];
172
+ if (heading[1].length !== level) continue;
173
+ let end = content.length;
174
+ for (let next = index + 1; next < headings.length; next++) {
175
+ if (headings[next][1].length <= level) {
176
+ end = headings[next].index;
177
+ break;
178
+ }
179
+ }
180
+ blocks.push({
181
+ heading: heading[2].trim(),
182
+ raw: content.slice(heading.index, end).trimEnd(),
183
+ start: heading.index,
184
+ end
185
+ });
186
+ }
187
+ return blocks;
188
+ }
189
+
190
+ function titleFromDocument(content, fallback) {
191
+ const match = content.match(/^# ([^\r\n]+)$/m);
192
+ return match ? match[1].replace(/^(Feature|Project|Review)\s*[-:]\s*/i, "").trim() : fallback;
193
+ }
194
+
195
+ function legacyStatus(raw, fallback = "backlog") {
196
+ const match = raw.match(/^(?:\*\*)?Status(?:\*\*)?:\s*\**([a-z_-]+)/im);
197
+ return match ? match[1].toLowerCase().replace(/[._-]+$/, "") : fallback;
198
+ }
199
+
200
+ function taskStatus(raw) {
201
+ const status = legacyStatus(raw);
202
+ return ["backlog", "proposed", "running", "validating", "learning", "partial", "completed", "failed", "blocked", "cancelled"].includes(status)
203
+ ? status
204
+ : "backlog";
205
+ }
206
+
207
+ function runStatus(raw) {
208
+ const status = legacyStatus(raw, "partial");
209
+ if (status === "running") return "executing";
210
+ if (["completed", "failed", "blocked", "partial"].includes(status)) return status;
211
+ return "partial";
212
+ }
213
+
214
+ function sprintStatus(raw) {
215
+ const status = legacyStatus(raw, "proposed");
216
+ if (["proposed", "running", "partial", "completed", "blocked", "cancelled"].includes(status)) return status;
217
+ return status === "failed" ? "blocked" : "proposed";
218
+ }
219
+
220
+ function slugTitle(value) {
221
+ return value.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).trim();
222
+ }
223
+
224
+ function sourceBody(title, source, anchor, raw) {
225
+ const indented = raw.split(/\r?\n/).map((line) => ` ${line}`).join("\n");
226
+ return `# ${title}\n\n## Migration Provenance\n\n- Source: \`.scrumrun/${backupRelative(source)}\`\n- Anchor: ${anchor}\n- Source block SHA-256: \`${sha256(raw)}\`\n\n## Imported v1 Content\n\n${indented}`;
227
+ }
228
+
229
+ class IdAllocator {
230
+ constructor(scrumDir) {
231
+ this.used = new Map();
232
+ for (const [kind, type] of Object.entries(ARTIFACT_TYPES)) {
233
+ const ids = new Set();
234
+ const directory = path.join(scrumDir, type.directory);
235
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isDirectory() && !fs.lstatSync(directory).isSymbolicLink()) {
236
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
237
+ const match = entry.isFile() && entry.name.match(new RegExp(`^(${type.prefix}-\\d{3,})\\.md$`));
238
+ if (match) ids.add(match[1]);
239
+ }
240
+ }
241
+ this.used.set(kind, ids);
242
+ }
243
+ }
244
+
245
+ claim(kind, preferred = null) {
246
+ const type = ARTIFACT_TYPES[kind];
247
+ const used = this.used.get(kind);
248
+ if (preferred && new RegExp(`^${type.prefix}-\\d{3,}$`).test(preferred) && !used.has(preferred)) {
249
+ used.add(preferred);
250
+ return preferred;
251
+ }
252
+ let number = 1;
253
+ while (used.has(`${type.prefix}-${String(number).padStart(3, "0")}`)) number++;
254
+ const id = `${type.prefix}-${String(number).padStart(3, "0")}`;
255
+ used.add(id);
256
+ return id;
257
+ }
258
+ }
259
+
260
+ function migrationPlan(projectRoot) {
261
+ const scrumDir = path.join(projectRoot, ".scrumrun");
262
+ if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) {
263
+ throw new Error("Not a ScrumRun project: .scrumrun/ is missing.");
264
+ }
265
+
266
+ const appliedManifest = path.join(scrumDir, MANIFEST_PATH);
267
+ if (fs.existsSync(appliedManifest)) {
268
+ const manifest = JSON.parse(fs.readFileSync(appliedManifest, "utf8"));
269
+ return { alreadyApplied: true, manifest, scrumDir };
270
+ }
271
+ const methodFile = path.join(scrumDir, "method.json");
272
+ if (fs.existsSync(methodFile)) {
273
+ try {
274
+ const marker = JSON.parse(fs.readFileSync(methodFile, "utf8"));
275
+ if (marker.method === METHOD_VERSION) {
276
+ return { alreadyApplied: true, manifest: { to: METHOD_VERSION, sourceInventory: null }, scrumDir };
277
+ }
278
+ } catch {
279
+ throw new Error("Existing .scrumrun/method.json is malformed; migration refused.");
280
+ }
281
+ throw new Error("Existing .scrumrun/method.json is not a recognized ScrumRun 2.0 marker; migration refused.");
282
+ }
283
+
284
+ const inventory = inventoryTree(scrumDir);
285
+ const generated = new Map();
286
+ const artifacts = [];
287
+ const archivedPaths = inventory.files.filter((entry) => isLegacyActivePath(entry.path)).map((entry) => entry.path);
288
+ const archivedSet = new Set(archivedPaths);
289
+ const mappings = inventory.files.map((entry) => ({
290
+ source: entry.path,
291
+ sourceSha256: entry.sha256,
292
+ outcome: archivedSet.has(entry.path) ? "archived-in-backup" : "preserved",
293
+ destination: archivedSet.has(entry.path) ? backupRelative(entry.path) : entry.path
294
+ }));
295
+ const warnings = [];
296
+ const errors = inventory.symlinks.map((entry) => `Symbolic link is not migrated automatically: ${entry.path}`);
297
+ for (const entry of inventory.files) {
298
+ if (/(^|\/)vault(?:\.local)?\.md$/i.test(entry.path) || !/\.(?:md|json|txt)$/i.test(entry.path)) continue;
299
+ const content = fs.readFileSync(path.join(scrumDir, entry.path), "utf8");
300
+ if (containsSecret(content)) errors.push(`Secret-like content detected outside the local vault: ${entry.path}`);
301
+ }
302
+ const allocator = new IdAllocator(scrumDir);
303
+ const existingArtifacts = Object.fromEntries(Object.keys(ARTIFACT_TYPES).map((kind) => [kind, []]));
304
+ const existingIds = new Set();
305
+ try {
306
+ const repository = new ArtifactRepository(scrumDir);
307
+ for (const kind of Object.keys(ARTIFACT_TYPES)) {
308
+ for (const artifact of repository.list(kind)) {
309
+ if (!artifact.record) {
310
+ errors.push(`${path.relative(scrumDir, artifact.file)}: ${artifact.errors.join("; ")}`);
311
+ continue;
312
+ }
313
+ existingArtifacts[kind].push(artifact);
314
+ existingIds.add(artifact.record.id);
315
+ }
316
+ }
317
+ } catch (error) {
318
+ errors.push(`Existing canonical artifact scan failed: ${error.message}`);
319
+ }
320
+ const taskIndex = new Map();
321
+ const runAttempts = new Map();
322
+ const created = today();
323
+
324
+ function addArtifact(record, body, source = null) {
325
+ const validation = validateArtifact(record);
326
+ if (validation.length) {
327
+ errors.push(`${record.id || "unknown"}: ${validation.join("; ")}`);
328
+ return null;
329
+ }
330
+ const relative = posix(relativeArtifactPath(record));
331
+ const content = serializeArtifact(record, body);
332
+ if (generated.has(relative) && generated.get(relative) !== content) {
333
+ errors.push(`Generated artifact collision: ${relative}`);
334
+ return null;
335
+ }
336
+ generated.set(relative, content);
337
+ artifacts.push({ id: record.id, kind: record.kind, status: record.status, relative, record });
338
+ if (source) {
339
+ mappings.push({
340
+ source: source.path,
341
+ anchor: source.anchor,
342
+ sourceSha256: sha256(source.raw),
343
+ outcome: "transformed",
344
+ destination: relative,
345
+ id: record.id
346
+ });
347
+ }
348
+ return { record, relative };
349
+ }
350
+
351
+ function normalizeExistingArtifacts() {
352
+ const aliases = {
353
+ decision: { confirmed: "resolved" },
354
+ knowledge: { confirmed: "approved" }
355
+ };
356
+ const activeTruth = new Set(["approved", "confirmed", "resolved", "active"]);
357
+ for (const [kind, entries] of Object.entries(existingArtifacts)) {
358
+ for (const artifact of entries) {
359
+ const permittedErrors = artifact.errors.filter((error) => {
360
+ const alias = aliases[kind] && aliases[kind][artifact.record.status];
361
+ return alias && error === `invalid ${kind} status: ${artifact.record.status}`;
362
+ });
363
+ if (artifact.errors.length !== permittedErrors.length) {
364
+ errors.push(`${path.relative(scrumDir, artifact.file)}: ${artifact.errors.join("; ")}`);
365
+ continue;
366
+ }
367
+ const record = { ...artifact.record };
368
+ let body = artifact.body;
369
+ let changed = false;
370
+ const alias = aliases[kind] && aliases[kind][record.status];
371
+ if (alias) {
372
+ record.status = alias;
373
+ changed = true;
374
+ warnings.push(`${record.id}: normalized legacy ${kind} status ${artifact.record.status} → ${alias}.`);
375
+ }
376
+ if (["knowledge", "decision", "insight", "dossier"].includes(kind)
377
+ && activeTruth.has(record.status)
378
+ && !extractEvidence(body).length) {
379
+ const sourceId = [record.source_id, record.source].find((value) => typeof value === "string" && existingIds.has(value));
380
+ const legacySource = typeof record.legacy_source === "string" && fs.existsSync(path.join(scrumDir, record.legacy_source))
381
+ ? `.scrumrun/${backupRelative(record.legacy_source)}`
382
+ : null;
383
+ const evidence = sourceId || legacySource;
384
+ if (!evidence) {
385
+ errors.push(`${record.id}: active memory has no resolvable evidence and cannot be normalized safely.`);
386
+ } else {
387
+ body = `${body.trimEnd()}\n\n## Evidence · normalized by migration\n\n- ${evidence}\n`;
388
+ changed = true;
389
+ warnings.push(`${record.id}: promoted its existing source ${evidence} to structured evidence.`);
390
+ }
391
+ }
392
+ const validation = validateArtifact(record, kind);
393
+ if (validation.length) {
394
+ errors.push(`${record.id}: ${validation.join("; ")}`);
395
+ continue;
396
+ }
397
+ artifact.record = record;
398
+ if (!changed) continue;
399
+ const relative = posix(path.relative(scrumDir, artifact.file));
400
+ generated.set(relative, serializeArtifact(record, body));
401
+ const mapping = mappings.find((item) => item.source === relative && !item.anchor);
402
+ if (mapping) {
403
+ mapping.outcome = "normalized-v2-artifact";
404
+ mapping.destination = relative;
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ normalizeExistingArtifacts();
411
+
412
+ function migrateSprintPlan(relative, { feature = null, lane = "main" } = {}) {
413
+ const content = readText(scrumDir, relative);
414
+ if (!content) return;
415
+ for (const block of headingBlocks(content, 2)) {
416
+ const sprint = block.heading.match(/^Sprint\s+([0-9]+(?:\.[0-9]+)?)\s*(?:[-—:]\s*)?(.*)$/i);
417
+ if (!sprint) continue;
418
+ const legacyId = `Sprint ${sprint[1]}`;
419
+ const title = sprint[2] ? `${legacyId} - ${sprint[2]}` : legacyId;
420
+ const canonicalSprintId = /^\d+$/.test(sprint[1]) ? `SPRINT-${String(Number(sprint[1])).padStart(3, "0")}` : null;
421
+ const existingSprint = canonicalSprintId
422
+ ? existingArtifacts.sprint.find((artifact) => artifact.record.id === canonicalSprintId)
423
+ : null;
424
+ const linkedTasks = existingSprint
425
+ ? existingArtifacts.task.filter((artifact) => artifact.record.sprint === canonicalSprintId)
426
+ : [];
427
+ if (existingSprint && linkedTasks.length) {
428
+ const primary = linkedTasks.length === 1 ? linkedTasks[0].record : null;
429
+ mappings.push({
430
+ source: relative,
431
+ anchor: block.heading,
432
+ sourceSha256: sha256(block.raw),
433
+ outcome: "represented-by-existing-v2",
434
+ destination: primary ? posix(relativeArtifactPath(primary)) : posix(relativeArtifactPath(existingSprint.record)),
435
+ id: primary ? primary.id : existingSprint.record.id
436
+ });
437
+ if (primary) {
438
+ taskIndex.set(`${lane}:${legacyId.toLowerCase()}`, primary);
439
+ taskIndex.set(`${lane}:${title.toLowerCase()}`, primary);
440
+ } else {
441
+ warnings.push(`${relative}#${block.heading}: ${canonicalSprintId} already groups multiple Tasks; no duplicate Task was invented.`);
442
+ }
443
+ continue;
444
+ }
445
+ const taskId = allocator.claim("task");
446
+ const task = {
447
+ id: taskId,
448
+ kind: "task",
449
+ type: "task",
450
+ status: taskStatus(block.raw),
451
+ created,
452
+ updated: created,
453
+ method: METHOD_VERSION,
454
+ feature,
455
+ sprint: existingSprint ? canonicalSprintId : null,
456
+ legacy_ref: legacyId,
457
+ legacy_source: relative
458
+ };
459
+
460
+ const hasTimebox = /^Timebox:\s*.+$/im.test(block.raw)
461
+ || (/^Start:\s*.+$/im.test(block.raw) && /^End:\s*.+$/im.test(block.raw));
462
+ if (hasTimebox && !existingSprint) {
463
+ const sprintId = allocator.claim("sprint");
464
+ task.sprint = sprintId;
465
+ addArtifact({
466
+ id: sprintId,
467
+ kind: "sprint",
468
+ status: sprintStatus(block.raw),
469
+ created,
470
+ updated: created,
471
+ method: METHOD_VERSION,
472
+ legacy_ref: legacyId,
473
+ legacy_source: relative
474
+ }, `${sourceBody(title, relative, block.heading, block.raw)}\n\n## Tasks\n\n- ${taskId}`, { path: relative, anchor: block.heading, raw: block.raw });
475
+ }
476
+ addArtifact(task, sourceBody(title, relative, block.heading, block.raw), { path: relative, anchor: block.heading, raw: block.raw });
477
+ taskIndex.set(`${lane}:${legacyId.toLowerCase()}`, task);
478
+ taskIndex.set(`${lane}:${title.toLowerCase()}`, task);
479
+ }
480
+ }
481
+
482
+ function findHistoryTask(lane, heading) {
483
+ const lowered = heading.toLowerCase();
484
+ const candidates = [...taskIndex.entries()]
485
+ .filter(([key]) => key.startsWith(`${lane}:`) && lowered.includes(key.slice(lane.length + 1)))
486
+ .sort((a, b) => b[0].length - a[0].length);
487
+ return candidates.length ? candidates[0][1] : null;
488
+ }
489
+
490
+ function migrateHistory(relative, { lane = "main" } = {}) {
491
+ const content = readText(scrumDir, relative);
492
+ if (!content) return;
493
+ const blocks = headingBlocks(content, 3);
494
+ for (const block of blocks) {
495
+ const task = findHistoryTask(lane, block.heading);
496
+ if (!task) {
497
+ warnings.push(`${relative}#${block.heading}: preserved but no Task link was inferred.`);
498
+ continue;
499
+ }
500
+ const attempt = (runAttempts.get(task.id) || 0) + 1;
501
+ runAttempts.set(task.id, attempt);
502
+ const existingRun = existingArtifacts.run.find((artifact) => artifact.record.task === task.id && artifact.record.attempt === attempt);
503
+ if (existingRun) {
504
+ mappings.push({
505
+ source: relative,
506
+ anchor: block.heading,
507
+ sourceSha256: sha256(block.raw),
508
+ outcome: "represented-by-existing-v2",
509
+ destination: posix(relativeArtifactPath(existingRun.record)),
510
+ id: existingRun.record.id
511
+ });
512
+ continue;
513
+ }
514
+ const id = allocator.claim("run");
515
+ addArtifact({
516
+ id,
517
+ kind: "run",
518
+ status: runStatus(block.raw),
519
+ created,
520
+ updated: created,
521
+ method: METHOD_VERSION,
522
+ task: task.id,
523
+ sprint: task.sprint,
524
+ attempt,
525
+ legacy_source: relative
526
+ }, sourceBody(block.heading, relative, block.heading, block.raw), { path: relative, anchor: block.heading, raw: block.raw });
527
+ }
528
+ }
529
+
530
+ function migrateFeatures() {
531
+ const featuresDir = path.join(scrumDir, "features");
532
+ if (!fs.existsSync(featuresDir)) return;
533
+ for (const entry of fs.readdirSync(featuresDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
534
+ if (!entry.isDirectory()) continue;
535
+ const base = path.join("features", entry.name);
536
+ const featureSource = path.join(base, "feature.md");
537
+ const featureContent = readText(scrumDir, featureSource);
538
+ const title = titleFromDocument(featureContent, slugTitle(entry.name));
539
+ const id = allocator.claim("feature");
540
+ addArtifact({
541
+ id,
542
+ kind: "feature",
543
+ status: "active",
544
+ created,
545
+ updated: created,
546
+ method: METHOD_VERSION,
547
+ legacy_ref: entry.name,
548
+ legacy_source: featureSource
549
+ }, sourceBody(title, featureSource, title, featureContent || `Feature lane: ${entry.name}`), {
550
+ path: featureSource,
551
+ anchor: title,
552
+ raw: featureContent || `Feature lane: ${entry.name}`
553
+ });
554
+ migrateSprintPlan(path.join(base, "sprint.md"), { feature: id, lane: `feature:${entry.name}` });
555
+ migrateHistory(path.join(base, "history.md"), { lane: `feature:${entry.name}` });
556
+ migrateDecisionFile(path.join(base, "decisions.md"), { feature: id });
557
+ }
558
+ }
559
+
560
+ function migrateBacklog() {
561
+ const relative = "backlog.md";
562
+ const content = readText(scrumDir, relative);
563
+ if (!content) return;
564
+ for (const match of content.matchAll(/^- \[([ xX])\]\s+(.+)$/gm)) {
565
+ const raw = match[0];
566
+ const title = match[2].trim();
567
+ const id = allocator.claim("task");
568
+ addArtifact({
569
+ id,
570
+ kind: "task",
571
+ type: "task",
572
+ status: match[1].toLowerCase() === "x" ? "completed" : "backlog",
573
+ created,
574
+ updated: created,
575
+ method: METHOD_VERSION,
576
+ feature: null,
577
+ sprint: null,
578
+ legacy_source: relative
579
+ }, sourceBody(title, relative, title, raw), { path: relative, anchor: title, raw });
580
+ }
581
+ }
582
+
583
+ function migrateFixes() {
584
+ const relative = "fixes.md";
585
+ const content = readText(scrumDir, relative);
586
+ if (!content) return;
587
+ let blocks = headingBlocks(content, 2);
588
+ if (!blocks.length) blocks = headingBlocks(content, 3);
589
+ for (const block of blocks) {
590
+ if (!/\b(?:F-\d{3}|fix)\b/i.test(block.heading)) continue;
591
+ const taskId = allocator.claim("task");
592
+ const status = taskStatus(block.raw);
593
+ addArtifact({
594
+ id: taskId,
595
+ kind: "task",
596
+ type: "fix",
597
+ status,
598
+ created,
599
+ updated: created,
600
+ method: METHOD_VERSION,
601
+ feature: null,
602
+ sprint: null,
603
+ legacy_ref: (block.heading.match(/F-\d{3}/i) || [])[0] || null,
604
+ legacy_source: relative
605
+ }, sourceBody(block.heading, relative, block.heading, block.raw), { path: relative, anchor: block.heading, raw: block.raw });
606
+ if (status !== "backlog" && status !== "proposed") {
607
+ const runId = allocator.claim("run");
608
+ addArtifact({
609
+ id: runId,
610
+ kind: "run",
611
+ status: runStatus(block.raw),
612
+ created,
613
+ updated: created,
614
+ method: METHOD_VERSION,
615
+ task: taskId,
616
+ sprint: null,
617
+ attempt: 1,
618
+ legacy_source: relative
619
+ }, sourceBody(block.heading, relative, block.heading, block.raw), { path: relative, anchor: block.heading, raw: block.raw });
620
+ }
621
+ }
622
+ }
623
+
624
+ function migrateKnowledge() {
625
+ const relative = "knowledge.md";
626
+ const content = readText(scrumDir, relative);
627
+ if (!content) return;
628
+ const sectionStatuses = [
629
+ ["Approved Knowledge", "approved"],
630
+ ["Pending Proposals", "candidate"],
631
+ ["Rejected Proposals", "rejected"]
632
+ ];
633
+ for (const [sectionTitle, status] of sectionStatuses) {
634
+ const bounds = section(content, sectionTitle);
635
+ if (!bounds) continue;
636
+ for (const block of headingBlocks(bounds.body, 3)) {
637
+ const match = block.heading.match(/^(K-\d{3,})\s*[-—:]\s*(.+)$/i);
638
+ if (!match) continue;
639
+ const preferred = match[1].toUpperCase();
640
+ const id = allocator.claim("knowledge", preferred);
641
+ const source = { path: relative, anchor: block.heading, raw: block.raw };
642
+ addArtifact({
643
+ id,
644
+ kind: "knowledge",
645
+ status,
646
+ created,
647
+ updated: created,
648
+ method: METHOD_VERSION,
649
+ legacy_ref: preferred,
650
+ legacy_source: relative
651
+ }, sourceBody(match[2], relative, block.heading, block.raw), source);
652
+
653
+ const insightSection = block.raw.match(/^#### Reusable Insights[ \t]*\r?\n([\s\S]*?)(?=^#### |^### |(?![\s\S]))/m);
654
+ if (insightSection) {
655
+ for (const insight of insightSection[1].matchAll(/^- (.+)$/gm)) {
656
+ const insightId = allocator.claim("insight");
657
+ addArtifact({
658
+ id: insightId,
659
+ kind: "insight",
660
+ status: "candidate",
661
+ created,
662
+ updated: created,
663
+ method: METHOD_VERSION,
664
+ subject: id,
665
+ source_type: "knowledge",
666
+ source_id: id,
667
+ legacy_source: relative
668
+ }, sourceBody(insight[1], relative, block.heading, insight[0]), { path: relative, anchor: `${block.heading} / Reusable Insights`, raw: insight[0] });
669
+ }
670
+ }
671
+ }
672
+ }
673
+ }
674
+
675
+ function migrateDecisionFile(relative, { feature = null } = {}) {
676
+ const content = readText(scrumDir, relative);
677
+ if (!content) return;
678
+ for (const [sectionTitle, status] of [["Open Decisions", "open"], ["Resolved Decisions", "resolved"]]) {
679
+ const bounds = section(content, sectionTitle);
680
+ if (!bounds) continue;
681
+ const structured = headingBlocks(bounds.body, 3);
682
+ const masked = [...bounds.body];
683
+ for (const block of structured) {
684
+ for (let index = block.start; index < block.end; index++) {
685
+ if (masked[index] !== "\n" && masked[index] !== "\r") masked[index] = " ";
686
+ }
687
+ const match = block.heading.match(/^(D-\d{3,})\s*[-—:]\s*(.+)$/i);
688
+ if (!match) continue;
689
+ const id = allocator.claim("decision");
690
+ addArtifact({
691
+ id,
692
+ kind: "decision",
693
+ status,
694
+ created,
695
+ updated: created,
696
+ method: METHOD_VERSION,
697
+ feature,
698
+ legacy_ref: match[1].toUpperCase(),
699
+ legacy_source: relative
700
+ }, sourceBody(match[2], relative, block.heading, block.raw), { path: relative, anchor: block.heading, raw: block.raw });
701
+ }
702
+ for (const bullet of masked.join("").matchAll(/^- (?!Pending: none\.)(.+)$/gm)) {
703
+ const title = bullet[1].trim();
704
+ const id = allocator.claim("decision");
705
+ addArtifact({
706
+ id,
707
+ kind: "decision",
708
+ status,
709
+ created,
710
+ updated: created,
711
+ method: METHOD_VERSION,
712
+ feature,
713
+ legacy_ref: null,
714
+ legacy_source: relative
715
+ }, sourceBody(title, relative, title, bullet[0]), { path: relative, anchor: title, raw: bullet[0] });
716
+ }
717
+ }
718
+ }
719
+
720
+ function migrateDossiers() {
721
+ const legacyDir = path.join(scrumDir, "knowledge", "dossiers");
722
+ if (!fs.existsSync(legacyDir)) return;
723
+ for (const entry of fs.readdirSync(legacyDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
724
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
725
+ const relative = path.join("knowledge", "dossiers", entry.name);
726
+ const content = readText(scrumDir, relative);
727
+ const title = titleFromDocument(content, slugTitle(path.basename(entry.name, ".md")));
728
+ const id = allocator.claim("dossier");
729
+ addArtifact({
730
+ id,
731
+ kind: "dossier",
732
+ status: legacyStatus(content, "active") === "stale" ? "stale" : "active",
733
+ created,
734
+ updated: created,
735
+ method: METHOD_VERSION,
736
+ legacy_source: relative
737
+ }, sourceBody(title, relative, title, content), { path: relative, anchor: title, raw: content });
738
+ }
739
+ }
740
+
741
+ function migrateReviews() {
742
+ const reviewsDir = path.join(scrumDir, "reviews");
743
+ if (!fs.existsSync(reviewsDir)) return;
744
+ for (const entry of fs.readdirSync(reviewsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
745
+ if (!entry.isFile() || !entry.name.endsWith(".md") || /^REV-\d{3,}\.md$/.test(entry.name)) continue;
746
+ const relative = path.join("reviews", entry.name);
747
+ const content = readText(scrumDir, relative);
748
+ const title = titleFromDocument(content, slugTitle(path.basename(entry.name, ".md")));
749
+ const id = allocator.claim("review");
750
+ addArtifact({
751
+ id,
752
+ kind: "review",
753
+ status: "archived",
754
+ created,
755
+ updated: created,
756
+ method: METHOD_VERSION,
757
+ legacy_source: relative
758
+ }, sourceBody(title, relative, title, content), { path: relative, anchor: title, raw: content });
759
+ }
760
+ }
761
+
762
+ function migrateGuardrails() {
763
+ const golden = readText(scrumDir, "golden-rules.md");
764
+ const current = readText(scrumDir, "guardrails.md");
765
+ if (/^# ScrumRun Project Guardrails\b/m.test(current) && /^#{2,6} GR-\d{3,}\b/m.test(current)) {
766
+ const canonical = current.replace(/^#{3,6}(?= GR-\d{3,}\b)/gm, "##");
767
+ if (canonical !== current) {
768
+ generated.set("guardrails.md", canonical.endsWith("\n") ? canonical : `${canonical}\n`);
769
+ const mapping = mappings.find((item) => item.source === "guardrails.md" && !item.anchor);
770
+ if (mapping) {
771
+ mapping.outcome = "normalized-v2-artifact";
772
+ mapping.destination = "guardrails.md";
773
+ }
774
+ warnings.push("guardrails.md: normalized stable GR headings to canonical level 2 without changing rule content.");
775
+ }
776
+ return;
777
+ }
778
+ const goldenRules = [...golden.matchAll(/^\s*(\d+)\.\s+(.+)$/gm)].map((match) => match[2].trim());
779
+ const currentRules = [...current.matchAll(/^Rule:\s+(.+)$/gm)].map((match) => match[1].trim());
780
+ const defaults = [
781
+ "Never commit or print real secrets.",
782
+ "Never overwrite unrelated or pre-existing owner changes.",
783
+ "Never modify a path marked read-only by the owner or project configuration.",
784
+ "Intake remains read-only; execution requires explicit valid approval."
785
+ ];
786
+ const canonicalRules = goldenRules.length ? goldenRules : currentRules.length ? currentRules : defaults;
787
+ const source = goldenRules.length ? "golden-rules.md" : currentRules.length ? "guardrails.md" : "ScrumRun v2 safe defaults";
788
+ const body = [
789
+ "# ScrumRun Project Guardrails",
790
+ "",
791
+ "Canonical project policy for ScrumRun method 2.0.0. Universal invariants remain in `core.md`.",
792
+ "",
793
+ ...canonicalRules.flatMap((rule, index) => [
794
+ `## GR-${String(index + 1).padStart(3, "0")} - ${rule}`,
795
+ "",
796
+ "Status: active",
797
+ `Source: \`${source}\``,
798
+ ""
799
+ ]),
800
+ "## Migration",
801
+ "",
802
+ "The byte-exact v1 policy remains in `golden-rules.md` and in the migration backup."
803
+ ].join("\n");
804
+ generated.set("guardrails.md", `${body.trim()}\n`);
805
+ if (current) {
806
+ mappings.push({
807
+ source: "guardrails.md",
808
+ anchor: "complete document",
809
+ sourceSha256: sha256(current),
810
+ outcome: "superseded-and-backed-up",
811
+ destination: "guardrails.md"
812
+ });
813
+ }
814
+ if (golden) {
815
+ mappings.push({
816
+ source: "golden-rules.md",
817
+ anchor: "complete document",
818
+ sourceSha256: sha256(golden),
819
+ outcome: "transformed-and-preserved",
820
+ destination: "guardrails.md"
821
+ });
822
+ }
823
+ }
824
+
825
+ migrateSprintPlan(path.join("goals", "main", "sprint.md"));
826
+ migrateHistory(path.join("goals", "main", "history.md"));
827
+ migrateFeatures();
828
+ migrateBacklog();
829
+ migrateFixes();
830
+ migrateKnowledge();
831
+ migrateDecisionFile(path.join("goals", "main", "decisions.md"));
832
+ migrateDossiers();
833
+ migrateReviews();
834
+ migrateGuardrails();
835
+
836
+ function generateInfrastructure(relative, content) {
837
+ const current = readText(scrumDir, relative);
838
+ generated.set(relative, content);
839
+ if (current && current !== content) {
840
+ const mapping = mappings.find((item) => item.source === relative && !item.anchor);
841
+ if (mapping) {
842
+ mapping.outcome = "superseded-and-backed-up";
843
+ mapping.destination = relative;
844
+ }
845
+ }
846
+ }
847
+
848
+ const projectName = path.basename(projectRoot);
849
+ generateInfrastructure("core.md", packagedFile("CORE.md"));
850
+ generateInfrastructure("config.md", v2Config(readText(scrumDir, "config.md"), projectName));
851
+ if (!readText(scrumDir, "project.md")) {
852
+ generateInfrastructure("project.md", packagedFile(path.join("templates", "project", ".scrumrun", "project.md"), {
853
+ PROJECT_NAME: projectName,
854
+ DATE: created
855
+ }));
856
+ }
857
+
858
+ const existingIgnore = readText(scrumDir, ".gitignore");
859
+ const ignoreLines = existingIgnore.split(/\r?\n/).filter(Boolean);
860
+ for (const ignored of [".cache/", ".backup/", ".migration/", "vault.local.md"]) {
861
+ if (!ignoreLines.includes(ignored)) ignoreLines.push(ignored);
862
+ }
863
+ generated.set(".gitignore", `${ignoreLines.join("\n")}\n`);
864
+ const ignoreMapping = mappings.find((mapping) => mapping.source === ".gitignore" && !mapping.anchor);
865
+ if (ignoreMapping) {
866
+ ignoreMapping.outcome = "intentionally-merged";
867
+ ignoreMapping.destination = ".gitignore";
868
+ }
869
+
870
+ const sourceLayout = existingIds.size ? "hybrid-v1-v2" : "1.x";
871
+ generated.set("method.json", `${JSON.stringify({ method: METHOD_VERSION, layout: "v2", migrated_from: sourceLayout, migration: MIGRATION_NAME }, null, 2)}\n`);
872
+
873
+ const counts = {};
874
+ for (const artifact of artifacts) counts[artifact.kind] = (counts[artifact.kind] || 0) + 1;
875
+ return {
876
+ alreadyApplied: false,
877
+ projectRoot,
878
+ scrumDir,
879
+ inventory,
880
+ artifacts,
881
+ generated,
882
+ mappings,
883
+ warnings,
884
+ errors,
885
+ counts,
886
+ created,
887
+ sourceLayout,
888
+ archivedPaths
889
+ };
890
+ }
891
+
892
+ function reportFor(plan, status = "dry-run") {
893
+ const countLines = Object.entries(plan.counts).sort().map(([kind, count]) => `- ${kind}: ${count}`);
894
+ const warningLines = plan.warnings.length ? plan.warnings.map((warning) => `- ${warning}`) : ["- None."];
895
+ const transformed = plan.mappings.filter((mapping) => mapping.outcome !== "preserved");
896
+ const mappingLines = transformed.length
897
+ ? transformed.map((mapping) => `- \`${mapping.source}\`${mapping.anchor ? ` → ${mapping.anchor}` : ""} => \`${mapping.destination}\`${mapping.id ? ` (${mapping.id})` : ""} [${mapping.outcome}]`)
898
+ : ["- No semantic records were inferred; all source files remain preserved."];
899
+ return `# ScrumRun v1 to v2 Migration Report\n\nStatus: ${status}\nSource layout: ${plan.sourceLayout}\nSource fingerprint: \`${plan.inventory.rootSha256}\`\nSource files: ${plan.inventory.files.length}\nGenerated artifacts: ${plan.artifacts.length}\nArchived legacy paths: ${plan.archivedPaths.length}\n\n## Artifact Counts\n\n${countLines.length ? countLines.join("\n") : "- None."}\n\n## Preservation\n\n- ${plan.inventory.files.length} original files are covered by the hashed backup inventory.\n- Active project paths contain only the v2 kernel after apply; legacy-only paths remain byte-exact under the ignored migration backup.\n- Files superseded or normalized by a v2 canonical artifact remain byte-exact in the local migration backup.\n- Vault contents are preserved locally and are never rendered in this report.\n- Generated relations carry the SHA-256 of their source block.\n\n## Source-to-Destination Mapping\n\n${mappingLines.join("\n")}\n\n## Warnings\n\n${warningLines.join("\n")}\n`;
900
+ }
901
+
902
+ function manifestFor(plan) {
903
+ const generated = [...plan.generated.entries()].map(([relative, content]) => ({
904
+ path: relative,
905
+ size: Buffer.byteLength(content),
906
+ sha256: sha256(content)
907
+ }));
908
+ return {
909
+ schema: 1,
910
+ migration: MIGRATION_NAME,
911
+ status: "applied",
912
+ from: plan.sourceLayout,
913
+ to: METHOD_VERSION,
914
+ created: plan.created,
915
+ sourceInventory: plan.inventory,
916
+ generated,
917
+ archivedPaths: plan.archivedPaths,
918
+ mappings: plan.mappings,
919
+ warnings: plan.warnings
920
+ };
921
+ }
922
+
923
+ function validateStage(stage, plan) {
924
+ const errors = [];
925
+ const records = new Map();
926
+ const allArtifacts = [];
927
+ const repository = new ArtifactRepository(stage);
928
+ for (const kind of Object.keys(ARTIFACT_TYPES)) {
929
+ for (const artifact of repository.list(kind)) {
930
+ allArtifacts.push(artifact);
931
+ errors.push(...artifact.errors.map((error) => `${path.relative(stage, artifact.file)}: ${error}`));
932
+ if (!artifact.record || artifact.errors.length) continue;
933
+ if (records.has(artifact.record.id)) errors.push(`Duplicate artifact id: ${artifact.record.id}`);
934
+ records.set(artifact.record.id, artifact.record);
935
+ }
936
+ }
937
+ for (const artifact of plan.artifacts) {
938
+ const file = path.join(stage, artifact.relative);
939
+ if (!fs.existsSync(file)) {
940
+ errors.push(`Missing staged artifact: ${artifact.relative}`);
941
+ continue;
942
+ }
943
+ const parsed = parseArtifact(fs.readFileSync(file, "utf8"));
944
+ errors.push(...parsed.errors.map((error) => `${artifact.relative}: ${error}`));
945
+ errors.push(...validateArtifact(parsed.record, artifact.kind).map((error) => `${artifact.relative}: ${error}`));
946
+ }
947
+ for (const artifact of allArtifacts) {
948
+ if (!artifact.record || artifact.errors.length) continue;
949
+ const record = artifact.record;
950
+ const requiredRelations = record.kind === "run"
951
+ ? [["task", "task"], ["sprint", "sprint"]]
952
+ : record.kind === "task"
953
+ ? [["feature", "feature"], ["sprint", "sprint"]]
954
+ : [];
955
+ for (const [field, expectedKind] of requiredRelations) {
956
+ if (!record[field]) continue;
957
+ const target = records.get(record[field]);
958
+ if (!target || target.kind !== expectedKind) {
959
+ errors.push(`${record.id}.${field} has unresolved ${expectedKind} reference: ${record[field]}`);
960
+ }
961
+ }
962
+ }
963
+ const activeTruth = new Set(["approved", "confirmed", "resolved", "active"]);
964
+ for (const artifact of allArtifacts) {
965
+ const record = artifact.record;
966
+ if (!record || !["knowledge", "decision", "insight", "dossier"].includes(record.kind) || !activeTruth.has(record.status)) continue;
967
+ const evidence = extractEvidence(artifact.body);
968
+ if (!evidence.length) {
969
+ errors.push(`${record.id}: active memory has no structured evidence.`);
970
+ continue;
971
+ }
972
+ for (const reference of evidence) {
973
+ if (records.has(reference)) continue;
974
+ const relative = String(reference).replace(/^path:\s*/i, "").replace(/^\.scrumrun\//, "");
975
+ if (path.isAbsolute(relative) || relative.split(/[\\/]+/).includes("..")) {
976
+ errors.push(`${record.id}: unsafe evidence reference ${reference}`);
977
+ continue;
978
+ }
979
+ const target = String(reference).startsWith(".scrumrun/")
980
+ ? path.join(stage, relative)
981
+ : path.join(path.dirname(stage), relative);
982
+ if (!fs.existsSync(target)) {
983
+ errors.push(`${record.id}: unresolved evidence reference ${reference}`);
984
+ continue;
985
+ }
986
+ try {
987
+ const evidenceRoot = String(reference).startsWith(".scrumrun/") ? stage : path.dirname(stage);
988
+ assertNoSymlinkPath(evidenceRoot, target);
989
+ } catch (error) {
990
+ errors.push(`${record.id}: unsafe evidence reference ${reference}: ${error.message}`);
991
+ }
992
+ }
993
+ }
994
+ const backupInventory = inventoryTree(path.join(stage, BACKUP_PATH));
995
+ if (backupInventory.rootSha256 !== plan.inventory.rootSha256) errors.push("Staged backup fingerprint differs from source inventory.");
996
+ return errors;
997
+ }
998
+
999
+ function ensureCanonicalDirectories(stage) {
1000
+ const directories = new Set(Object.values(ARTIFACT_TYPES).map((type) => type.directory));
1001
+ for (const directory of directories) fs.mkdirSync(path.join(stage, directory), { recursive: true });
1002
+ }
1003
+
1004
+ function copyTree(source, destination) {
1005
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
1006
+ fs.cpSync(source, destination, { recursive: true, force: false, preserveTimestamps: true, errorOnExist: true });
1007
+ }
1008
+
1009
+ function cleanup(target) {
1010
+ if (target && fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
1011
+ }
1012
+
1013
+ function atomicDirectorySwap(current, staged, failurePoint = null) {
1014
+ const parent = path.dirname(current);
1015
+ const previous = path.join(parent, `.scrumrun-v1-swap-${process.pid}-${Date.now()}`);
1016
+ let state = "ready";
1017
+ try {
1018
+ fs.renameSync(current, previous);
1019
+ state = "previous-moved";
1020
+ if (failurePoint === "after-source-move") throw new Error("Injected migration failure after source move.");
1021
+ fs.renameSync(staged, current);
1022
+ state = "new-active";
1023
+ if (failurePoint === "after-target-move") throw new Error("Injected migration failure after target move.");
1024
+ cleanup(previous);
1025
+ state = "complete";
1026
+ } catch (error) {
1027
+ if (state === "new-active" && fs.existsSync(current)) fs.renameSync(current, staged);
1028
+ if ((state === "new-active" || state === "previous-moved") && fs.existsSync(previous)) fs.renameSync(previous, current);
1029
+ throw error;
1030
+ } finally {
1031
+ if (state === "complete") cleanup(staged);
1032
+ }
1033
+ }
1034
+
1035
+ function applyMigration(projectRoot, { failurePoint = null, plan: verifiedPlan = null } = {}) {
1036
+ const plan = verifiedPlan || migrationPlan(projectRoot);
1037
+ if (path.resolve(plan.projectRoot || projectRoot) !== path.resolve(projectRoot)) {
1038
+ throw new Error("Verified migration plan belongs to a different project.");
1039
+ }
1040
+ if (plan.alreadyApplied) return { status: "already-applied", manifest: plan.manifest };
1041
+ if (plan.errors.length) throw new Error(`Migration blocked:\n- ${plan.errors.join("\n- ")}`);
1042
+
1043
+ const parent = path.dirname(plan.scrumDir);
1044
+ const stage = fs.mkdtempSync(path.join(parent, ".scrumrun-v2-stage-"));
1045
+ try {
1046
+ copyTree(plan.scrumDir, stage);
1047
+ copyTree(plan.scrumDir, path.join(stage, BACKUP_PATH));
1048
+ removeLegacyActivePaths(stage, plan.archivedPaths);
1049
+ ensureCanonicalDirectories(stage);
1050
+ for (const [relative, content] of plan.generated) {
1051
+ atomicWrite(path.join(stage, relative), content);
1052
+ }
1053
+ atomicWrite(path.join(stage, ".migration", ".gitignore"), "*\n!.gitignore\n");
1054
+ require("../runtime/orchestrator").refreshState(stage);
1055
+ const report = reportFor(plan, "applied");
1056
+ const manifest = manifestFor(plan);
1057
+ atomicWrite(path.join(stage, REPORT_PATH), report);
1058
+ atomicWrite(path.join(stage, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`);
1059
+ const validation = validateStage(stage, plan);
1060
+ if (validation.length) throw new Error(`Migration validation failed:\n- ${validation.join("\n- ")}`);
1061
+ const currentInventory = inventoryTree(plan.scrumDir);
1062
+ if (currentInventory.rootSha256 !== plan.inventory.rootSha256) {
1063
+ throw new Error("ScrumRun source changed while migration was being staged; no files were applied.");
1064
+ }
1065
+ if (failurePoint === "after-staging") throw new Error("Injected migration failure after staging.");
1066
+ atomicDirectorySwap(plan.scrumDir, stage, failurePoint);
1067
+ return { status: "applied", manifest, report, warnings: plan.warnings };
1068
+ } catch (error) {
1069
+ cleanup(stage);
1070
+ throw error;
1071
+ }
1072
+ }
1073
+
1074
+ function rollbackSafetyErrors(scrumDir, manifest) {
1075
+ const errors = [];
1076
+ const source = new Map(manifest.sourceInventory.files.map((entry) => [entry.path, entry]));
1077
+ const generated = new Map(manifest.generated.map((entry) => [entry.path, entry]));
1078
+ const archived = new Set(manifest.archivedPaths || []);
1079
+ const current = inventoryTree(scrumDir);
1080
+ const currentFiles = new Map(current.files.map((entry) => [entry.path, entry]));
1081
+
1082
+ for (const [relative, expected] of source) {
1083
+ if (archived.has(relative)) {
1084
+ if (currentFiles.has(relative)) errors.push(`archived legacy path was recreated after migration: ${relative}`);
1085
+ continue;
1086
+ }
1087
+ if (generated.has(relative) || relative === ".DS_Store" || relative === "state.md" || relative === "map.md" || relative.startsWith(".cache/")) continue;
1088
+ const actual = currentFiles.get(relative);
1089
+ if (!actual) {
1090
+ errors.push(`legacy source was removed after migration: ${relative}`);
1091
+ } else if (actual.sha256 !== expected.sha256 || actual.mode !== expected.mode) {
1092
+ errors.push(`legacy source changed after migration: ${relative}`);
1093
+ }
1094
+ }
1095
+ for (const [relative, expected] of generated) {
1096
+ const actual = currentFiles.get(relative);
1097
+ if (!actual) {
1098
+ errors.push(`generated v2 artifact was removed after migration: ${relative}`);
1099
+ } else if (actual.sha256 !== expected.sha256) {
1100
+ errors.push(`generated v2 artifact changed after migration: ${relative}`);
1101
+ }
1102
+ }
1103
+ for (const relative of currentFiles.keys()) {
1104
+ if (relative.startsWith(".migration/") || relative.startsWith(".cache/")) continue;
1105
+ if (["map.md", "state.md"].includes(relative) && !source.has(relative) && !generated.has(relative)) continue;
1106
+ if (!source.has(relative) && !generated.has(relative)) errors.push(`new post-migration file would be lost: ${relative}`);
1107
+ }
1108
+ return errors;
1109
+ }
1110
+
1111
+ function rollbackMigration(projectRoot, { failurePoint = null } = {}) {
1112
+ const scrumDir = path.join(projectRoot, ".scrumrun");
1113
+ const manifestFile = path.join(scrumDir, MANIFEST_PATH);
1114
+ if (!fs.existsSync(manifestFile)) throw new Error("No applied v1-to-v2 migration was found.");
1115
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
1116
+ const backup = path.join(scrumDir, BACKUP_PATH);
1117
+ if (!fs.existsSync(backup)) throw new Error("Migration backup is missing; rollback refused.");
1118
+ const backupInventory = inventoryTree(backup);
1119
+ if (backupInventory.rootSha256 !== manifest.sourceInventory.rootSha256) {
1120
+ throw new Error("Migration backup hash mismatch; rollback refused.");
1121
+ }
1122
+ if (backupInventory.symlinks.length) throw new Error("Migration backup contains unsupported symbolic links; rollback refused.");
1123
+ const unsafe = rollbackSafetyErrors(scrumDir, manifest);
1124
+ if (unsafe.length) throw new Error(`Rollback refused to avoid losing post-migration changes:\n- ${unsafe.join("\n- ")}`);
1125
+
1126
+ const parent = path.dirname(scrumDir);
1127
+ const stage = fs.mkdtempSync(path.join(parent, ".scrumrun-v1-restore-"));
1128
+ try {
1129
+ cleanup(stage);
1130
+ copyTree(backup, stage);
1131
+ if (failurePoint === "after-staging") throw new Error("Injected rollback failure after staging.");
1132
+ atomicDirectorySwap(scrumDir, stage, failurePoint);
1133
+ const restored = inventoryTree(scrumDir);
1134
+ if (restored.rootSha256 !== manifest.sourceInventory.rootSha256) {
1135
+ throw new Error("Rollback completed but restored fingerprint does not match the source inventory.");
1136
+ }
1137
+ return { status: "rolled-back", rootSha256: restored.rootSha256 };
1138
+ } catch (error) {
1139
+ cleanup(stage);
1140
+ throw error;
1141
+ }
1142
+ }
1143
+
1144
+ function dryRunMigration(projectRoot) {
1145
+ const plan = migrationPlan(projectRoot);
1146
+ if (plan.alreadyApplied) return { status: "already-applied", manifest: plan.manifest, output: "ScrumRun project is already migrated to method 2.0.0.\n" };
1147
+ const output = reportFor(plan, "dry-run");
1148
+ return { status: plan.errors.length ? "blocked" : "ready", plan, output };
1149
+ }
1150
+
1151
+ module.exports = {
1152
+ BACKUP_PATH,
1153
+ MANIFEST_PATH,
1154
+ MIGRATION_NAME,
1155
+ REPORT_PATH,
1156
+ applyMigration,
1157
+ dryRunMigration,
1158
+ inventoryTree,
1159
+ migrationPlan,
1160
+ rollbackMigration
1161
+ };