scrumrun 2.0.0 → 2.1.1

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 (44) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CORE.md +17 -3
  3. package/DECISIONS.md +56 -0
  4. package/MIGRATION-1-to-2.md +11 -0
  5. package/README.md +23 -5
  6. package/SPEC.md +30 -10
  7. package/bin/scrumrun.js +175 -11
  8. package/docs/COMMANDS.md +10 -4
  9. package/docs/ENTITY-MODEL.md +1 -1
  10. package/docs/RELEASE-SCORECARD.md +43 -0
  11. package/docs/RELEASE.md +19 -12
  12. package/docs/SCHEMA.md +11 -0
  13. package/docs/SEMANTIC-MEMORY.md +1 -1
  14. package/docs/TROUBLESHOOTING.md +13 -1
  15. package/lib/commands/manifest.js +15 -3
  16. package/lib/commands/render.js +4 -0
  17. package/lib/memory/index.js +201 -41
  18. package/lib/memory/service.js +3 -0
  19. package/lib/runtime/budgets.js +4 -0
  20. package/lib/runtime/canonical-snapshot.js +110 -0
  21. package/lib/runtime/context.js +5 -45
  22. package/lib/runtime/mutation-gateway.js +434 -0
  23. package/lib/runtime/orchestrator.js +130 -65
  24. package/lib/runtime/policy-engine.js +267 -0
  25. package/lib/runtime/request-engine.js +32 -24
  26. package/lib/runtime/review-service.js +92 -0
  27. package/lib/runtime/run-ledger.js +546 -0
  28. package/lib/runtime/workspace-state.js +146 -0
  29. package/lib/security/secrets.js +15 -1
  30. package/lib/v2/artifacts.js +24 -1
  31. package/lib/v2/conformance.js +78 -12
  32. package/lib/v2/migration.js +74 -10
  33. package/lib/v2/run-ledger-migration.js +268 -0
  34. package/lib/v2/schema.js +28 -1
  35. package/lib/v2/transaction.js +254 -0
  36. package/package.json +1 -1
  37. package/scripts/generate-contract-docs.js +11 -0
  38. package/templates/project/.scrumrun/guardrails.md +8 -0
  39. package/templates/project/.scrumrun/map.md +4 -3
  40. package/templates/project/.scrumrun/method.json +7 -1
  41. package/templates/project/.scrumrun/state.md +7 -14
  42. package/templates/project/AGENTS.md +2 -1
  43. package/templates/project-lean/AGENTS.md +3 -1
  44. package/templates/shared/skills/scrumrun/SKILL.md +19 -5
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { execFileSync } = require("node:child_process");
6
+ const { assertNoSymlinkPath, sha256 } = require("../v2/artifacts");
7
+ const { secretFingerprints } = require("../security/secrets");
8
+
9
+ const SKIP_DIRECTORIES = new Set([".git", ".scrumrun", "node_modules"]);
10
+
11
+ function posix(value) {
12
+ return value.split(path.sep).join("/");
13
+ }
14
+
15
+ function stable(value) {
16
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
17
+ if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
18
+ return JSON.stringify(value);
19
+ }
20
+
21
+ function normalizedRelative(projectRoot, value) {
22
+ const root = path.resolve(projectRoot);
23
+ const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
24
+ if (!text || text === "." || path.posix.isAbsolute(text) || text.split("/").includes("..")) throw new Error(`Unsafe mutation path: ${value || "missing"}`);
25
+ if (text === ".git" || text.startsWith(".git/") || text === ".scrumrun" || text.startsWith(".scrumrun/")) {
26
+ throw new Error(`Mutation Gateway cannot authorize control path: ${text}`);
27
+ }
28
+ const target = path.resolve(root, text);
29
+ if (!target.startsWith(`${root}${path.sep}`)) throw new Error(`Mutation path escapes project: ${text}`);
30
+ assertNoSymlinkPath(root, target);
31
+ return posix(path.relative(root, target));
32
+ }
33
+
34
+ function fileValue(projectRoot, relative) {
35
+ const target = path.join(projectRoot, relative);
36
+ let stat;
37
+ try {
38
+ stat = fs.lstatSync(target);
39
+ } catch (error) {
40
+ if (error.code === "ENOENT") return { path: relative, sha256: null, kind: "missing", mode: null, scan: "not-applicable", secrets: [] };
41
+ throw error;
42
+ }
43
+ if (stat.isSymbolicLink()) {
44
+ const link = fs.readlinkSync(target);
45
+ return { path: relative, sha256: sha256(`symlink\0${link}`), kind: "symlink", mode: stat.mode & 0o777, scan: "not-applicable", secrets: [] };
46
+ }
47
+ if (!stat.isFile()) return { path: relative, sha256: sha256(`non-file\0${stat.mode}`), kind: "other", mode: stat.mode & 0o777, scan: "not-applicable", secrets: [] };
48
+ const content = fs.readFileSync(target);
49
+ const scan = content.length > 10 * 1024 * 1024 ? "too-large" : content.includes(0) ? "binary" : "text";
50
+ const secrets = scan === "text" ? secretFingerprints(content.toString("utf8")) : [];
51
+ return { path: relative, sha256: sha256(content), kind: "file", mode: stat.mode & 0o777, scan, secrets };
52
+ }
53
+
54
+ function gitOutput(projectRoot, args) {
55
+ return execFileSync("git", args, { cwd: projectRoot, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
56
+ }
57
+
58
+ function gitState(projectRoot) {
59
+ let top;
60
+ try {
61
+ top = gitOutput(projectRoot, ["rev-parse", "--show-toplevel"]).toString("utf8").trim();
62
+ } catch {
63
+ return null;
64
+ }
65
+ if (path.resolve(top) !== path.resolve(projectRoot)) return null;
66
+ const head = gitOutput(projectRoot, ["rev-parse", "--verify", "HEAD"]).toString("utf8").trim();
67
+ const flagged = gitOutput(projectRoot, ["ls-files", "-v", "-z", "--", "."]).toString("utf8").split("\0").filter(Boolean)
68
+ .filter((entry) => /^[a-zS] /.test(entry));
69
+ if (flagged.length) throw new Error(`Git workspace contains hidden index flags (assume-unchanged or skip-worktree): ${flagged.slice(0, 10).map((entry) => entry.slice(2)).join(", ")}.`);
70
+ const tracked = gitOutput(projectRoot, ["diff", "--name-only", "-z", "--no-renames", "HEAD", "--", "."]);
71
+ const untracked = gitOutput(projectRoot, ["ls-files", "--others", "--exclude-standard", "-z", "--", "."]);
72
+ const ignored = gitOutput(projectRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", "."]);
73
+ const paths = new Set(Buffer.concat([tracked, untracked, ignored]).toString("utf8").split("\0").filter(Boolean)
74
+ .map((entry) => entry.replace(/\\/g, "/"))
75
+ .filter((entry) => {
76
+ const segments = entry.split("/");
77
+ return entry !== ".scrumrun" && !entry.startsWith(".scrumrun/") && !segments.includes("node_modules") && !segments.includes(".git");
78
+ }));
79
+ if (paths.size > 20000) throw new Error("Git workspace exceeds the 20,000-file Mutation Gateway limit after ignored-file coverage.");
80
+ const files = [...paths].sort().map((relative) => fileValue(projectRoot, relative));
81
+ return { schema: 1, mode: "git", head, files };
82
+ }
83
+
84
+ function fileState(projectRoot) {
85
+ const files = [];
86
+ function visit(directory, relative = "") {
87
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
88
+ for (const entry of entries) {
89
+ if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) continue;
90
+ const next = relative ? `${relative}/${entry.name}` : entry.name;
91
+ const target = path.join(directory, entry.name);
92
+ if (entry.isDirectory()) visit(target, next);
93
+ else files.push(fileValue(projectRoot, next));
94
+ if (files.length > 20000) throw new Error("Non-git workspace exceeds the 20,000-file Mutation Gateway limit.");
95
+ }
96
+ }
97
+ visit(projectRoot);
98
+ return { schema: 1, mode: "files", head: null, files };
99
+ }
100
+
101
+ function workspaceState(projectRoot) {
102
+ const root = path.resolve(projectRoot);
103
+ const state = gitState(root) || fileState(root);
104
+ const canonical = {
105
+ schema: state.schema,
106
+ mode: state.mode,
107
+ head: state.head,
108
+ files: state.files.map(({ path: file, sha256: hash, kind, mode, scan }) => ({ path: file, sha256: hash, kind, mode, scan }))
109
+ };
110
+ return { ...state, fingerprint: sha256(stable(canonical)) };
111
+ }
112
+
113
+ function changesBetween(before, after) {
114
+ if (!before || !after || before.mode !== after.mode || before.head !== after.head) throw new Error("Workspace baseline mode or Git HEAD changed during the Run.");
115
+ const left = new Map(before.files.map((item) => [item.path, item]));
116
+ const right = new Map(after.files.map((item) => [item.path, item]));
117
+ return [...new Set([...left.keys(), ...right.keys()])].sort().flatMap((file) => {
118
+ const previous = left.get(file) || { sha256: null, kind: "clean", mode: null, scan: "not-applicable", secrets: [] };
119
+ const next = right.get(file) || { sha256: null, kind: "clean", mode: null, scan: "not-applicable", secrets: [] };
120
+ if (previous.sha256 === next.sha256 && previous.kind === next.kind && previous.mode === next.mode) return [];
121
+ return [{
122
+ path: file,
123
+ before_sha256: previous.sha256,
124
+ after_sha256: next.sha256,
125
+ before_kind: previous.kind,
126
+ after_kind: next.kind,
127
+ before_mode: previous.mode,
128
+ after_mode: next.mode,
129
+ before_scan: previous.scan,
130
+ after_scan: next.scan,
131
+ new_secret_fingerprints: (next.secrets || []).filter((value) => !(previous.secrets || []).includes(value))
132
+ }];
133
+ });
134
+ }
135
+
136
+ function pathAuthorized(relative, allowed) {
137
+ return allowed.some((entry) => relative === entry || relative.startsWith(`${entry}/`));
138
+ }
139
+
140
+ module.exports = {
141
+ changesBetween,
142
+ normalizedRelative,
143
+ pathAuthorized,
144
+ stable,
145
+ workspaceState
146
+ };
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("node:crypto");
4
+
3
5
  const SECRET_PATTERNS = Object.freeze([
4
6
  /\bsk-[A-Za-z0-9_-]{16,}\b/,
5
7
  /\bAKIA[0-9A-Z]{16}\b/,
@@ -20,4 +22,16 @@ function assertNoSecret(values, message = "Secret-like content is forbidden outs
20
22
  if (list.some(containsSecret)) throw new Error(message);
21
23
  }
22
24
 
23
- module.exports = { SECRET_PATTERNS, assertNoSecret, containsSecret };
25
+ function secretFingerprints(value) {
26
+ const text = String(value || "");
27
+ const found = new Set();
28
+ for (const pattern of SECRET_PATTERNS) {
29
+ const matcher = new RegExp(pattern.source, `${pattern.flags.replace(/g/g, "")}g`);
30
+ for (const match of text.matchAll(matcher)) {
31
+ found.add(crypto.createHash("sha256").update(match[0]).digest("hex"));
32
+ }
33
+ }
34
+ return [...found].sort();
35
+ }
36
+
37
+ module.exports = { SECRET_PATTERNS, assertNoSecret, containsSecret, secretFingerprints };
@@ -111,6 +111,12 @@ function validateArtifact(record, expectedKind = null) {
111
111
  if (field === "attempt" && (!Number.isInteger(record[field]) || record[field] < 1)) {
112
112
  errors.push(`${record.kind}.${field} must be a positive integer`);
113
113
  }
114
+ if (field === "ledger" && record[field] !== undefined && record[field] !== 1) {
115
+ errors.push(`${record.kind}.${field} must be 1`);
116
+ }
117
+ if (["guardrails", "workspace"].includes(field) && record[field] !== undefined && record[field] !== 1) {
118
+ errors.push(`${record.kind}.${field} must be 1`);
119
+ }
114
120
  }
115
121
  return errors;
116
122
  }
@@ -162,10 +168,27 @@ function assertNoSymlinkPath(root, target) {
162
168
  function atomicWrite(file, content) {
163
169
  fs.mkdirSync(path.dirname(file), { recursive: true });
164
170
  const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
171
+ let descriptor = null;
165
172
  try {
166
- fs.writeFileSync(temp, content);
173
+ const mode = fs.existsSync(file) ? fs.lstatSync(file).mode & 0o777 : 0o666;
174
+ descriptor = fs.openSync(temp, "wx", mode);
175
+ fs.writeFileSync(descriptor, content);
176
+ fs.fsyncSync(descriptor);
177
+ fs.closeSync(descriptor);
178
+ descriptor = null;
167
179
  fs.renameSync(temp, file);
180
+ try {
181
+ const directory = fs.openSync(path.dirname(file), "r");
182
+ try {
183
+ fs.fsyncSync(directory);
184
+ } finally {
185
+ fs.closeSync(directory);
186
+ }
187
+ } catch (error) {
188
+ if (!["EINVAL", "ENOTSUP", "EBADF"].includes(error.code)) throw error;
189
+ }
168
190
  } catch (error) {
191
+ if (descriptor !== null) fs.closeSync(descriptor);
169
192
  if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
170
193
  throw error;
171
194
  }
@@ -3,34 +3,39 @@
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
  const { ArtifactRepository } = require("./artifacts");
6
- const { ARTIFACT_TYPES, METHOD_VERSION } = require("./schema");
6
+ const { ARTIFACT_TYPES, METHOD_VERSION, RUN_LEDGER_VERSION } = require("./schema");
7
7
  const { stateIsStale } = require("../runtime/orchestrator");
8
- const { indexStatus } = require("../memory/index");
8
+ const { validateRunLedger } = require("../runtime/run-ledger");
9
+ const { indexStatus, mapStatus } = require("../memory/index");
9
10
  const { extractEvidence } = require("../memory/markdown");
10
11
  const { resolveEvidence } = require("../memory/service");
11
12
  const { containsSecret } = require("../security/secrets");
13
+ const { pendingTransactionStatus } = require("./transaction");
14
+ const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
15
+ const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
12
16
 
13
17
  const INVARIANTS = Object.freeze([
14
18
  { id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
15
19
  { id: "I-02", summary: "approval creates Task and Run atomically", tests: ["explicit approval creates exactly one linked Task and Run", "approval failure injection rolls back"] },
16
20
  { id: "I-03", summary: "Task is atomic and Sprint only groups evidenced batches", tests: ["migration apply creates linked v2 artifacts", "artifact repository builds explicit Feature Task Sprint Run graph"] },
17
21
  { id: "I-04", summary: "retry preserves prior Runs", tests: ["retry creates a new Run and preserves"] },
18
- { id: "I-05", summary: "state transitions are declared and recoverable", tests: ["run state machine follows", "paired transition failure restores"] },
19
- { id: "I-06", summary: "guardrails are canonical and cannot be weakened", tests: ["guardrails build preserves canonical v2 rules"] },
22
+ { id: "I-05", summary: "state transitions are declared, evidenced, ordered, and recoverable", tests: ["run state machine follows", "Run ledger uses stable event ids", "paired transition failure restores"] },
23
+ { id: "I-06", summary: "guardrails are canonical and cannot be weakened", tests: ["guardrails build preserves canonical v2 rules", "Policy Engine reports exact Guardrail ids"] },
20
24
  { id: "I-07", summary: "Markdown is canonical and indexes are disposable", tests: ["SQLite index is disposable"] },
21
25
  { id: "I-08", summary: "AI memory remains candidate", tests: ["AI insights stay candidate"] },
22
26
  { id: "I-09", summary: "confirmed claims require evidence", tests: ["confirmed facts reject missing"] },
23
27
  { id: "I-10", summary: "inactive memory is not active truth", tests: ["stale and expired memory are labeled"] },
24
28
  { id: "I-11", summary: "vault values never leave the vault boundary", tests: ["migration dry-run is read-only and never renders vault", "SQLite index is disposable"] },
25
29
  { id: "I-12", summary: "canonical schemas and identities are valid", tests: ["v2 artifact schemas round-trip", "malformed and mismatched artifacts are rejected"] },
26
- { id: "I-13", summary: "generated state exposes staleness", tests: ["generated state includes structured decisions", "approval rejects tampered or stale plans"] },
27
- { id: "I-14", summary: "context and retrieval are bounded", tests: ["lean context stays within twenty-five percent", "semantic query caps relation output"] },
30
+ { id: "I-13", summary: "generated state exposes staleness", tests: ["generated state includes structured decisions", "approval rejects tampered or stale plans", "state staleness uses metadata fast path", "generated map status refuses stale projection"] },
31
+ { id: "I-14", summary: "context and retrieval are bounded", tests: ["lean context stays within twenty-five percent", "semantic query caps relation output", "semantic index staleness uses metadata fast path"] },
28
32
  { id: "I-15", summary: "migration and ordinary update are read-only by default", tests: ["migration dry-run is read-only", "update preflights an ongoing v1 project"] },
29
- { id: "I-16", summary: "migration is hashed, idempotent, and reversible", tests: ["migration apply is idempotent and rollback restores"] },
33
+ { id: "I-16", summary: "migration is hashed, idempotent, and reversible", tests: ["migration apply is idempotent and rollback restores", "Run ledger migration failure and rollback restore"] },
30
34
  { id: "I-17", summary: "ambiguous migration is preserved and warned", tests: ["partial v1 layout is preserved"] },
31
- { id: "I-18", summary: "unsafe and partial writes fail safely", tests: ["canonical writes reject traversal and symlink paths", "repository refuses conflicting overwrite"] },
35
+ { id: "I-18", summary: "unsafe and partial writes fail safely", tests: ["canonical writes reject traversal and symlink paths", "repository refuses conflicting overwrite", "interrupted kernel transaction is recovered"] },
32
36
  { id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
33
- { id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] }
37
+ { id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] },
38
+ { id: "I-21", summary: "material mutations are scoped, policy-bound, and fail closed", tests: ["Mutation Gateway rejects bypass and out-of-scope writes", "Run completion rejects unresolved Guardrail obligations"] }
34
39
  ]);
35
40
 
36
41
  function finding(severity, code, message, file = null) {
@@ -45,10 +50,11 @@ function auditProject(projectRoot) {
45
50
  return { method: METHOD_VERSION, passed: false, findings, counts: {} };
46
51
  }
47
52
  const marker = path.join(scrumDir, "method.json");
53
+ let methodMarker = null;
48
54
  try {
49
55
  if (!fs.existsSync(marker) || !fs.lstatSync(marker).isFile()) throw new Error("marker is missing, not a regular file, or is a symbolic link");
50
- const value = JSON.parse(fs.readFileSync(marker, "utf8"));
51
- if (value.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
56
+ methodMarker = JSON.parse(fs.readFileSync(marker, "utf8"));
57
+ if (methodMarker.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
52
58
  } catch (error) {
53
59
  findings.push(finding("critical", "METHOD_MARKER", `method.json is missing or malformed: ${error.message}`, marker));
54
60
  }
@@ -58,7 +64,21 @@ function auditProject(projectRoot) {
58
64
  }
59
65
  const guardrailsFile = path.join(scrumDir, "guardrails.md");
60
66
  const guardrails = fs.existsSync(guardrailsFile) && fs.lstatSync(guardrailsFile).isFile() ? fs.readFileSync(guardrailsFile, "utf8") : "";
61
- if (!/^## GR-\d{3,}/m.test(guardrails)) findings.push(finding("high", "GUARDRAILS_EMPTY", "No stable-id Guardrail is defined."));
67
+ const guardrailValidation = validateGuardrailDocument(guardrails);
68
+ if (!guardrailValidation.records.length) findings.push(finding("high", "GUARDRAILS_EMPTY", "No stable-id Guardrail is defined."));
69
+ if (!guardrailValidation.records.some((record) => record.status === "active")) findings.push(finding("high", "GUARDRAILS_INACTIVE", "No active stable-id Guardrail is defined."));
70
+ for (const error of guardrailValidation.errors) findings.push(finding("high", "GUARDRAIL_INVALID", error, guardrailsFile));
71
+ const strictGuardrails = methodMarker && methodMarker.schemas && methodMarker.schemas.guardrails === 1;
72
+ if (strictGuardrails) {
73
+ for (const record of guardrailValidation.records) {
74
+ for (const field of ["status", "enforcement", "scope", "rule"]) {
75
+ if (!record.explicit[field]) findings.push(finding("high", "GUARDRAIL_FIELD_INFERRED", `${record.id} must explicitly declare ${field}.`, guardrailsFile));
76
+ }
77
+ }
78
+ }
79
+ const configFile = path.join(scrumDir, "config.md");
80
+ const config = fs.existsSync(configFile) && fs.lstatSync(configFile).isFile() ? fs.readFileSync(configFile, "utf8") : "";
81
+ for (const error of configWeakeningAttempts(config)) findings.push(finding("high", "CONFIG_WEAKENS_POLICY", error, configFile));
62
82
 
63
83
  const repository = new ArtifactRepository(scrumDir);
64
84
  const counts = {};
@@ -85,6 +105,22 @@ function auditProject(projectRoot) {
85
105
  if (run.record && task && !task.errors.length && (run.record.sprint || null) !== (task.record.sprint || null)) {
86
106
  findings.push(finding("high", "RUN_SPRINT_MISMATCH", `${run.record.id}.sprint must match ${task.record.id}.sprint.`, run.file));
87
107
  }
108
+ if (run.record && run.record.ledger === RUN_LEDGER_VERSION) {
109
+ const ledger = validateRunLedger(run.record, run.body);
110
+ for (const error of ledger.errors) findings.push(finding("high", "RUN_LEDGER_INVALID", `${run.record.id}: ${error}`, run.file));
111
+ } else if (run.record) {
112
+ const declared = methodMarker && methodMarker.schemas && methodMarker.schemas.run_ledger === RUN_LEDGER_VERSION;
113
+ findings.push(finding(declared ? "high" : "warning", "RUN_LEDGER_LEGACY", `${run.record.id} requires the Run ledger ${RUN_LEDGER_VERSION} migration.`, run.file));
114
+ }
115
+ const secureRuns = methodMarker && methodMarker.schemas && methodMarker.schemas.run_obligations === 1 && methodMarker.schemas.mutation_gateway === 1;
116
+ const active = run.record && !["completed", "failed", "blocked"].includes(run.record.status);
117
+ if (secureRuns && active && (run.record.guardrails !== 1 || run.record.workspace !== 1)) {
118
+ findings.push(finding("high", "RUN_SECURITY_SCHEMA", `${run.record.id} must be retried or migrated to Guardrail/workspace schema 1.`, run.file));
119
+ }
120
+ if (run.record && run.record.workspace === 1) {
121
+ const bypass = auditActiveWorkspace(projectRoot, run);
122
+ if (bypass) findings.push(finding("critical", "MUTATION_BYPASS", `${run.record.id}: ${bypass}`, run.file));
123
+ }
88
124
  }
89
125
  const byId = new Map(Object.values(records).flat().filter((artifact) => artifact.record).map((artifact) => [artifact.record.id, artifact]));
90
126
  for (const task of records.task || []) {
@@ -110,6 +146,26 @@ function auditProject(projectRoot) {
110
146
  findings.push(finding("high", "RUN_ATTEMPT_SEQUENCE", `${taskId} Run attempts must be unique and contiguous from 1; found ${ordered.join(", ")}.`));
111
147
  }
112
148
  }
149
+ for (const task of records.task || []) {
150
+ if (!task.record || task.errors.length) continue;
151
+ const attempts = (records.run || [])
152
+ .filter((run) => run.record && !run.errors.length && run.record.task === task.record.id)
153
+ .sort((left, right) => right.record.attempt - left.record.attempt);
154
+ if (!attempts.length) continue;
155
+ const latest = attempts[0].record;
156
+ const taskStatuses = {
157
+ executing: "running",
158
+ validating: "validating",
159
+ learning: "learning",
160
+ completed: "completed",
161
+ failed: "failed",
162
+ blocked: "blocked",
163
+ partial: "partial"
164
+ };
165
+ if (taskStatuses[latest.status] && task.record.status !== taskStatuses[latest.status]) {
166
+ findings.push(finding("high", "TASK_RUN_STATUS_MISMATCH", `${task.record.id}.status ${task.record.status} disagrees with latest ${latest.id}.status ${latest.status}.`, task.file));
167
+ }
168
+ }
113
169
  for (const sprint of records.sprint || []) {
114
170
  if (!sprint.record || sprint.errors.length) continue;
115
171
  const heading = /^## Tasks[ \t]*$/m.exec(sprint.body);
@@ -157,9 +213,19 @@ function auditProject(projectRoot) {
157
213
  try {
158
214
  const semantic = indexStatus(projectRoot);
159
215
  if (semantic.exists && semantic.stale) findings.push(finding("warning", "INDEX_STALE", "semantic-index.sqlite is stale and will be rebuilt on query."));
216
+ const map = mapStatus(projectRoot, { semanticStatus: semantic });
217
+ if (semantic.exists && map.stale) findings.push(finding("warning", "MAP_STALE", `map.md is stale: ${map.reason || map.error || "unknown reason"}.`));
160
218
  } catch (error) {
161
219
  findings.push(finding("high", "INDEX_UNSAFE", `semantic index cannot inspect canonical sources safely: ${error.message}`));
162
220
  }
221
+ const transactions = pendingTransactionStatus(scrumDir);
222
+ if (transactions.error) {
223
+ findings.push(finding("high", "TRANSACTION_JOURNAL_UNSAFE", transactions.error));
224
+ } else {
225
+ for (const transaction of transactions.pending) {
226
+ findings.push(finding("high", "TRANSACTION_PENDING", `${transaction.id} (${transaction.name}) is ${transaction.status}; run the approved operation again or recover explicitly before trusting canonical state.`));
227
+ }
228
+ }
163
229
  const blocking = findings.filter((item) => ["critical", "high"].includes(item.severity));
164
230
  return { method: METHOD_VERSION, passed: blocking.length === 0, findings, counts, invariants: INVARIANTS.length };
165
231
  }
@@ -16,6 +16,9 @@ const {
16
16
  sha256,
17
17
  validateArtifact
18
18
  } = require("./artifacts");
19
+ const { inferEnforcement, normalizeGuardrailDocument } = require("../runtime/policy-engine");
20
+ const { RUN_LEDGER_VERSION } = require("./schema");
21
+ const { migrateLegacyRun } = require("../runtime/run-ledger");
19
22
 
20
23
  const MIGRATION_NAME = "v1-to-v2";
21
24
  const MANIFEST_PATH = path.join(".migration", MIGRATION_NAME, "manifest.json");
@@ -322,19 +325,30 @@ function migrationPlan(projectRoot) {
322
325
  const created = today();
323
326
 
324
327
  function addArtifact(record, body, source = null) {
325
- const validation = validateArtifact(record);
328
+ let canonicalRecord = record;
329
+ let canonicalBody = body;
330
+ if (record.kind === "run" && record.ledger !== RUN_LEDGER_VERSION) {
331
+ const legacyHash = sha256(source ? source.raw : body);
332
+ const backupRef = source
333
+ ? `.scrumrun/${backupRelative(source.path)}`
334
+ : `.scrumrun/${backupRelative(relativeArtifactPath(record))}`;
335
+ const migrated = migrateLegacyRun(record, body, { legacyHash, backupRef });
336
+ canonicalRecord = migrated.record;
337
+ canonicalBody = migrated.body;
338
+ }
339
+ const validation = validateArtifact(canonicalRecord);
326
340
  if (validation.length) {
327
- errors.push(`${record.id || "unknown"}: ${validation.join("; ")}`);
341
+ errors.push(`${canonicalRecord.id || "unknown"}: ${validation.join("; ")}`);
328
342
  return null;
329
343
  }
330
- const relative = posix(relativeArtifactPath(record));
331
- const content = serializeArtifact(record, body);
344
+ const relative = posix(relativeArtifactPath(canonicalRecord));
345
+ const content = serializeArtifact(canonicalRecord, canonicalBody);
332
346
  if (generated.has(relative) && generated.get(relative) !== content) {
333
347
  errors.push(`Generated artifact collision: ${relative}`);
334
348
  return null;
335
349
  }
336
350
  generated.set(relative, content);
337
- artifacts.push({ id: record.id, kind: record.kind, status: record.status, relative, record });
351
+ artifacts.push({ id: canonicalRecord.id, kind: canonicalRecord.kind, status: canonicalRecord.status, relative, record: canonicalRecord });
338
352
  if (source) {
339
353
  mappings.push({
340
354
  source: source.path,
@@ -342,10 +356,10 @@ function migrationPlan(projectRoot) {
342
356
  sourceSha256: sha256(source.raw),
343
357
  outcome: "transformed",
344
358
  destination: relative,
345
- id: record.id
359
+ id: canonicalRecord.id
346
360
  });
347
361
  }
348
- return { record, relative };
362
+ return { record: canonicalRecord, relative };
349
363
  }
350
364
 
351
365
  function normalizeExistingArtifacts() {
@@ -367,6 +381,18 @@ function migrationPlan(projectRoot) {
367
381
  const record = { ...artifact.record };
368
382
  let body = artifact.body;
369
383
  let changed = false;
384
+ if (kind === "run" && record.ledger !== RUN_LEDGER_VERSION) {
385
+ const relative = posix(path.relative(scrumDir, artifact.file));
386
+ const legacyContent = fs.readFileSync(artifact.file, "utf8");
387
+ const migrated = migrateLegacyRun(record, body, {
388
+ legacyHash: sha256(legacyContent),
389
+ backupRef: `.scrumrun/${backupRelative(relative)}`
390
+ });
391
+ Object.assign(record, migrated.record);
392
+ body = migrated.body;
393
+ changed = true;
394
+ warnings.push(`${record.id}: migrated the existing Run to ledger ${RUN_LEDGER_VERSION} using ${migrated.mode}.`);
395
+ }
370
396
  const alias = aliases[kind] && aliases[kind][record.status];
371
397
  if (alias) {
372
398
  record.status = alias;
@@ -487,6 +513,33 @@ function migrationPlan(projectRoot) {
487
513
  return candidates.length ? candidates[0][1] : null;
488
514
  }
489
515
 
516
+ function synchronizeGeneratedTask(task, status) {
517
+ const mapped = {
518
+ executing: "running",
519
+ validating: "validating",
520
+ learning: "learning",
521
+ completed: "completed",
522
+ failed: "failed",
523
+ blocked: "blocked",
524
+ partial: "partial"
525
+ }[status];
526
+ if (!mapped || task.status === mapped) return;
527
+ const relative = posix(relativeArtifactPath(task));
528
+ const content = generated.get(relative);
529
+ if (!content) return;
530
+ const parsed = parseArtifact(content);
531
+ if (!parsed.record || parsed.errors.length) return;
532
+ task.status = mapped;
533
+ task.updated = created;
534
+ generated.set(relative, serializeArtifact({ ...parsed.record, status: mapped, updated: created }, parsed.body));
535
+ const entry = artifacts.find((artifact) => artifact.id === task.id);
536
+ if (entry) {
537
+ entry.status = mapped;
538
+ entry.record.status = mapped;
539
+ entry.record.updated = created;
540
+ }
541
+ }
542
+
490
543
  function migrateHistory(relative, { lane = "main" } = {}) {
491
544
  const content = readText(scrumDir, relative);
492
545
  if (!content) return;
@@ -512,10 +565,11 @@ function migrationPlan(projectRoot) {
512
565
  continue;
513
566
  }
514
567
  const id = allocator.claim("run");
568
+ const status = runStatus(block.raw);
515
569
  addArtifact({
516
570
  id,
517
571
  kind: "run",
518
- status: runStatus(block.raw),
572
+ status,
519
573
  created,
520
574
  updated: created,
521
575
  method: METHOD_VERSION,
@@ -524,6 +578,7 @@ function migrationPlan(projectRoot) {
524
578
  attempt,
525
579
  legacy_source: relative
526
580
  }, sourceBody(block.heading, relative, block.heading, block.raw), { path: relative, anchor: block.heading, raw: block.raw });
581
+ synchronizeGeneratedTask(task, status);
527
582
  }
528
583
  }
529
584
 
@@ -763,7 +818,7 @@ function migrationPlan(projectRoot) {
763
818
  const golden = readText(scrumDir, "golden-rules.md");
764
819
  const current = readText(scrumDir, "guardrails.md");
765
820
  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, "##");
821
+ const canonical = normalizeGuardrailDocument(current.replace(/^#{3,6}(?= GR-\d{3,}\b)/gm, "##"));
767
822
  if (canonical !== current) {
768
823
  generated.set("guardrails.md", canonical.endsWith("\n") ? canonical : `${canonical}\n`);
769
824
  const mapping = mappings.find((item) => item.source === "guardrails.md" && !item.anchor);
@@ -794,6 +849,9 @@ function migrationPlan(projectRoot) {
794
849
  `## GR-${String(index + 1).padStart(3, "0")} - ${rule}`,
795
850
  "",
796
851
  "Status: active",
852
+ `Enforcement: ${inferEnforcement(rule, rule)}`,
853
+ "Scope: all",
854
+ `Rule: ${rule}`,
797
855
  `Source: \`${source}\``,
798
856
  ""
799
857
  ]),
@@ -868,7 +926,13 @@ function migrationPlan(projectRoot) {
868
926
  }
869
927
 
870
928
  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`);
929
+ generated.set("method.json", `${JSON.stringify({
930
+ method: METHOD_VERSION,
931
+ layout: "v2",
932
+ schemas: { run_ledger: RUN_LEDGER_VERSION, guardrails: 1, run_obligations: 1, mutation_gateway: 1 },
933
+ migrated_from: sourceLayout,
934
+ migration: MIGRATION_NAME
935
+ }, null, 2)}\n`);
872
936
 
873
937
  const counts = {};
874
938
  for (const artifact of artifacts) counts[artifact.kind] = (counts[artifact.kind] || 0) + 1;