scrumrun 2.1.0 → 2.2.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.
@@ -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,9 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+
3
7
  const SECRET_PATTERNS = Object.freeze([
4
8
  /\bsk-[A-Za-z0-9_-]{16,}\b/,
5
9
  /\bAKIA[0-9A-Z]{16}\b/,
@@ -20,4 +24,85 @@ function assertNoSecret(values, message = "Secret-like content is forbidden outs
20
24
  if (list.some(containsSecret)) throw new Error(message);
21
25
  }
22
26
 
23
- module.exports = { SECRET_PATTERNS, assertNoSecret, containsSecret };
27
+ function secretFingerprints(value) {
28
+ const text = String(value || "");
29
+ const found = new Set();
30
+ for (const pattern of SECRET_PATTERNS) {
31
+ const matcher = new RegExp(pattern.source, `${pattern.flags.replace(/g/g, "")}g`);
32
+ for (const match of text.matchAll(matcher)) {
33
+ found.add(crypto.createHash("sha256").update(match[0]).digest("hex"));
34
+ }
35
+ }
36
+ return [...found].sort();
37
+ }
38
+
39
+ function parseFrontmatter(text) {
40
+ const match = String(text || "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
41
+ if (!match) return { explicit: {}, raw: "" };
42
+ const body = match[1];
43
+ const explicit = {};
44
+ for (const line of body.split(/\r?\n/)) {
45
+ if (!line.trim() || line.trim().startsWith("#")) continue;
46
+ const kv = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
47
+ if (!kv) continue;
48
+ const key = kv[1].trim();
49
+ let value = kv[2].trim();
50
+ if (value.startsWith("[") && value.endsWith("]")) {
51
+ value = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
52
+ } else if (/^['"].*['"]$/.test(value)) {
53
+ value = value.slice(1, -1);
54
+ } else if (value === "true" || value === "false") {
55
+ value = value === "true";
56
+ }
57
+ explicit[key] = value;
58
+ }
59
+ return { explicit, raw: body };
60
+ }
61
+
62
+ function loadSecretAllowlist(scrumDir) {
63
+ if (!scrumDir || !fs.existsSync(scrumDir)) return new Set();
64
+ const configFile = path.join(scrumDir, "config.md");
65
+ if (!fs.existsSync(configFile) || !fs.lstatSync(configFile).isFile()) return new Set();
66
+ let text;
67
+ try {
68
+ text = fs.readFileSync(configFile, "utf8");
69
+ } catch {
70
+ return new Set();
71
+ }
72
+ const { explicit } = parseFrontmatter(text);
73
+ const raw = explicit.allow_secrets_in;
74
+ if (!raw) return new Set();
75
+ const list = Array.isArray(raw) ? raw : [String(raw)];
76
+ return new Set(list.map((p) => String(p).replace(/^\.\//, "").trim()).filter(Boolean));
77
+ }
78
+
79
+ function isSecretAllowed(relativePath, allowlist) {
80
+ if (!allowlist || !allowlist.size) return false;
81
+ if (!relativePath) return false;
82
+ const normalized = String(relativePath).replace(/^\.\//, "");
83
+ if (allowlist.has(normalized)) return true;
84
+ for (const entry of allowlist) {
85
+ if (entry.endsWith("/") && normalized.startsWith(entry)) return true;
86
+ if (entry.includes("*")) {
87
+ const re = new RegExp("^" + entry.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
88
+ if (re.test(normalized)) return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function containsSecretWithAllowlist(value, relativePath, allowlist) {
95
+ if (isSecretAllowed(relativePath, allowlist)) return false;
96
+ return containsSecret(value);
97
+ }
98
+
99
+ module.exports = {
100
+ SECRET_PATTERNS,
101
+ assertNoSecret,
102
+ containsSecret,
103
+ containsSecretWithAllowlist,
104
+ isSecretAllowed,
105
+ loadSecretAllowlist,
106
+ parseFrontmatter,
107
+ secretFingerprints
108
+ };
@@ -114,6 +114,9 @@ function validateArtifact(record, expectedKind = null) {
114
114
  if (field === "ledger" && record[field] !== undefined && record[field] !== 1) {
115
115
  errors.push(`${record.kind}.${field} must be 1`);
116
116
  }
117
+ if (["guardrails", "workspace"].includes(field) && record[field] !== undefined && record[field] !== 1) {
118
+ errors.push(`${record.kind}.${field} must be 1`);
119
+ }
117
120
  }
118
121
  return errors;
119
122
  }
@@ -9,9 +9,10 @@ const { validateRunLedger } = require("../runtime/run-ledger");
9
9
  const { indexStatus, mapStatus } = require("../memory/index");
10
10
  const { extractEvidence } = require("../memory/markdown");
11
11
  const { resolveEvidence } = require("../memory/service");
12
- const { containsSecret } = require("../security/secrets");
12
+ const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = require("../security/secrets");
13
13
  const { pendingTransactionStatus } = require("./transaction");
14
14
  const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
15
+ const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
15
16
 
16
17
  const INVARIANTS = Object.freeze([
17
18
  { id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
@@ -33,7 +34,8 @@ const INVARIANTS = Object.freeze([
33
34
  { id: "I-17", summary: "ambiguous migration is preserved and warned", tests: ["partial v1 layout is preserved"] },
34
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"] },
35
36
  { id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
36
- { 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"] }
37
39
  ]);
38
40
 
39
41
  function finding(severity, code, message, file = null) {
@@ -66,6 +68,14 @@ function auditProject(projectRoot) {
66
68
  if (!guardrailValidation.records.length) findings.push(finding("high", "GUARDRAILS_EMPTY", "No stable-id Guardrail is defined."));
67
69
  if (!guardrailValidation.records.some((record) => record.status === "active")) findings.push(finding("high", "GUARDRAILS_INACTIVE", "No active stable-id Guardrail is defined."));
68
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
+ }
69
79
  const configFile = path.join(scrumDir, "config.md");
70
80
  const config = fs.existsSync(configFile) && fs.lstatSync(configFile).isFile() ? fs.readFileSync(configFile, "utf8") : "";
71
81
  for (const error of configWeakeningAttempts(config)) findings.push(finding("high", "CONFIG_WEAKENS_POLICY", error, configFile));
@@ -81,7 +91,8 @@ function auditProject(projectRoot) {
81
91
  counts[kind] = artifacts.length;
82
92
  for (const artifact of artifacts) {
83
93
  for (const error of artifact.errors) findings.push(finding("high", "ARTIFACT_INVALID", `${path.basename(artifact.file)}: ${error}`, artifact.file));
84
- if (containsSecret(fs.readFileSync(artifact.file, "utf8"))) findings.push(finding("critical", "SECRET_CANONICAL", `Secret-like content detected in ${path.relative(scrumDir, artifact.file)}.`, artifact.file));
94
+ const relativeArtifact = path.relative(scrumDir, artifact.file);
95
+ if (containsSecret(fs.readFileSync(artifact.file, "utf8"))) findings.push(finding("critical", "SECRET_CANONICAL", `Secret-like content detected in ${relativeArtifact}.`, artifact.file));
85
96
  if (artifact.record && ids.has(artifact.record.id)) findings.push(finding("critical", "ID_DUPLICATE", `Duplicate artifact id: ${artifact.record.id}`));
86
97
  if (artifact.record) ids.add(artifact.record.id);
87
98
  }
@@ -102,6 +113,15 @@ function auditProject(projectRoot) {
102
113
  const declared = methodMarker && methodMarker.schemas && methodMarker.schemas.run_ledger === RUN_LEDGER_VERSION;
103
114
  findings.push(finding(declared ? "high" : "warning", "RUN_LEDGER_LEGACY", `${run.record.id} requires the Run ledger ${RUN_LEDGER_VERSION} migration.`, run.file));
104
115
  }
116
+ const secureRuns = methodMarker && methodMarker.schemas && methodMarker.schemas.run_obligations === 1 && methodMarker.schemas.mutation_gateway === 1;
117
+ const active = run.record && !["completed", "failed", "blocked"].includes(run.record.status);
118
+ if (secureRuns && active && (run.record.guardrails !== 1 || run.record.workspace !== 1)) {
119
+ findings.push(finding("high", "RUN_SECURITY_SCHEMA", `${run.record.id} must be retried or migrated to Guardrail/workspace schema 1.`, run.file));
120
+ }
121
+ if (run.record && run.record.workspace === 1) {
122
+ const bypass = auditActiveWorkspace(projectRoot, run);
123
+ if (bypass) findings.push(finding("critical", "MUTATION_BYPASS", `${run.record.id}: ${bypass}`, run.file));
124
+ }
105
125
  }
106
126
  const byId = new Map(Object.values(records).flat().filter((artifact) => artifact.record).map((artifact) => [artifact.record.id, artifact]));
107
127
  for (const task of records.task || []) {
@@ -3,7 +3,7 @@
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
  const { extractEvidence } = require("../memory/markdown");
6
- const { containsSecret } = require("../security/secrets");
6
+ const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = require("../security/secrets");
7
7
  const {
8
8
  ARTIFACT_TYPES,
9
9
  ArtifactRepository,
@@ -16,6 +16,7 @@ const {
16
16
  sha256,
17
17
  validateArtifact
18
18
  } = require("./artifacts");
19
+ const { inferEnforcement, normalizeGuardrailDocument } = require("../runtime/policy-engine");
19
20
  const { RUN_LEDGER_VERSION } = require("./schema");
20
21
  const { migrateLegacyRun } = require("../runtime/run-ledger");
21
22
 
@@ -296,10 +297,11 @@ function migrationPlan(projectRoot) {
296
297
  }));
297
298
  const warnings = [];
298
299
  const errors = inventory.symlinks.map((entry) => `Symbolic link is not migrated automatically: ${entry.path}`);
300
+ const secretAllowlist = loadSecretAllowlist(scrumDir);
299
301
  for (const entry of inventory.files) {
300
302
  if (/(^|\/)vault(?:\.local)?\.md$/i.test(entry.path) || !/\.(?:md|json|txt)$/i.test(entry.path)) continue;
301
303
  const content = fs.readFileSync(path.join(scrumDir, entry.path), "utf8");
302
- if (containsSecret(content)) errors.push(`Secret-like content detected outside the local vault: ${entry.path}`);
304
+ if (containsSecretWithAllowlist(content, entry.path, secretAllowlist)) errors.push(`Secret-like content detected outside the local vault: ${entry.path}`);
303
305
  }
304
306
  const allocator = new IdAllocator(scrumDir);
305
307
  const existingArtifacts = Object.fromEntries(Object.keys(ARTIFACT_TYPES).map((kind) => [kind, []]));
@@ -817,7 +819,7 @@ function migrationPlan(projectRoot) {
817
819
  const golden = readText(scrumDir, "golden-rules.md");
818
820
  const current = readText(scrumDir, "guardrails.md");
819
821
  if (/^# ScrumRun Project Guardrails\b/m.test(current) && /^#{2,6} GR-\d{3,}\b/m.test(current)) {
820
- const canonical = current.replace(/^#{3,6}(?= GR-\d{3,}\b)/gm, "##");
822
+ const canonical = normalizeGuardrailDocument(current.replace(/^#{3,6}(?= GR-\d{3,}\b)/gm, "##"));
821
823
  if (canonical !== current) {
822
824
  generated.set("guardrails.md", canonical.endsWith("\n") ? canonical : `${canonical}\n`);
823
825
  const mapping = mappings.find((item) => item.source === "guardrails.md" && !item.anchor);
@@ -848,6 +850,9 @@ function migrationPlan(projectRoot) {
848
850
  `## GR-${String(index + 1).padStart(3, "0")} - ${rule}`,
849
851
  "",
850
852
  "Status: active",
853
+ `Enforcement: ${inferEnforcement(rule, rule)}`,
854
+ "Scope: all",
855
+ `Rule: ${rule}`,
851
856
  `Source: \`${source}\``,
852
857
  ""
853
858
  ]),
@@ -925,7 +930,7 @@ function migrationPlan(projectRoot) {
925
930
  generated.set("method.json", `${JSON.stringify({
926
931
  method: METHOD_VERSION,
927
932
  layout: "v2",
928
- schemas: { run_ledger: RUN_LEDGER_VERSION },
933
+ schemas: { run_ledger: RUN_LEDGER_VERSION, guardrails: 1, run_obligations: 1, mutation_gateway: 1 },
929
934
  migrated_from: sourceLayout,
930
935
  migration: MIGRATION_NAME
931
936
  }, null, 2)}\n`);
@@ -15,6 +15,7 @@ const {
15
15
  } = require("./artifacts");
16
16
  const { RUN_LEDGER_VERSION } = require("./schema");
17
17
  const { migrateLegacyRun, validateRunLedger } = require("../runtime/run-ledger");
18
+ const { normalizeGuardrailDocument, validateGuardrailDocument } = require("../runtime/policy-engine");
18
19
 
19
20
  const MIGRATION_NAME = "run-ledger-v1";
20
21
  const MIGRATION_DIR = path.join(".migration", MIGRATION_NAME);
@@ -49,6 +50,27 @@ function planRunLedgerMigration(projectRoot) {
49
50
  const warnings = [];
50
51
  const errors = [];
51
52
 
53
+ const guardrailsFile = assertNoSymlinkPath(scrumDir, path.join(scrumDir, "guardrails.md"));
54
+ if (fs.existsSync(guardrailsFile) && fs.lstatSync(guardrailsFile).isFile()) {
55
+ const before = fs.readFileSync(guardrailsFile, "utf8");
56
+ const after = normalizeGuardrailDocument(before);
57
+ const validation = validateGuardrailDocument(after);
58
+ if (validation.errors.length) errors.push(`guardrails.md: ${validation.errors.join("; ")}`);
59
+ else if (before !== after) {
60
+ changes.push({
61
+ relative: "guardrails.md",
62
+ before,
63
+ after,
64
+ beforeSha256: sha256(before),
65
+ afterSha256: sha256(after),
66
+ mode: "guardrail-schema"
67
+ });
68
+ warnings.push("guardrails.md: inferred legacy policy fields were made explicit without deleting source prose.");
69
+ }
70
+ } else {
71
+ errors.push("guardrails.md is missing or unsafe.");
72
+ }
73
+
52
74
  for (const artifact of repository.list("run")) {
53
75
  if (!artifact.record || artifact.errors.length) {
54
76
  errors.push(`${path.relative(scrumDir, artifact.file)}: ${artifact.errors.join("; ")}`);
@@ -79,7 +101,13 @@ function planRunLedgerMigration(projectRoot) {
79
101
 
80
102
  const markerValue = {
81
103
  ...marker.value,
82
- schemas: { ...(marker.value.schemas || {}), run_ledger: RUN_LEDGER_VERSION }
104
+ schemas: {
105
+ ...(marker.value.schemas || {}),
106
+ run_ledger: RUN_LEDGER_VERSION,
107
+ guardrails: 1,
108
+ run_obligations: 1,
109
+ mutation_gateway: 1
110
+ }
83
111
  };
84
112
  const markerAfter = `${JSON.stringify(markerValue, null, 2)}\n`;
85
113
  if (marker.content !== markerAfter) {
@@ -112,7 +140,7 @@ function reportRunLedgerMigration(plan, status = plan.status) {
112
140
  : "- No changes required.";
113
141
  const warnings = plan.warnings.length ? plan.warnings.map((warning) => `- ${warning}`).join("\n") : "- None.";
114
142
  const errors = plan.errors.length ? plan.errors.map((error) => `- ${error}`).join("\n") : "- None.";
115
- return `# ScrumRun Run Ledger Migration\n\nStatus: ${status}\nSchema: ${RUN_LEDGER_VERSION}\nSource fingerprint: \`${plan.fingerprint}\`\nChanged files: ${plan.changes.length}\n\n## Mappings\n\n${mappings}\n\n## Warnings\n\n${warnings}\n\n## Blockers\n\n${errors}\n`;
143
+ return `# ScrumRun Kernel Schema Migration\n\nStatus: ${status}\nRun ledger schema: ${RUN_LEDGER_VERSION}\nGuardrail schema: 1\nMutation Gateway schema: 1\nSource fingerprint: \`${plan.fingerprint}\`\nChanged files: ${plan.changes.length}\n\n## Mappings\n\n${mappings}\n\n## Warnings\n\n${warnings}\n\n## Blockers\n\n${errors}\n`;
116
144
  }
117
145
 
118
146
  function manifestFile(scrumDir) {
package/lib/v2/schema.js CHANGED
@@ -9,7 +9,7 @@ function deepFreeze(value) {
9
9
  const METHOD_VERSION = "2.0.0";
10
10
  const RUN_LEDGER_VERSION = 1;
11
11
 
12
- const RUN_EVENT_TYPES = deepFreeze(["transition", "snapshot"]);
12
+ const RUN_EVENT_TYPES = deepFreeze(["transition", "snapshot", "guardrail", "mutation"]);
13
13
  const RUN_EVIDENCE_KINDS = deepFreeze([
14
14
  "approval",
15
15
  "command",
@@ -21,9 +21,13 @@ const RUN_EVIDENCE_KINDS = deepFreeze([
21
21
  "risk",
22
22
  "note",
23
23
  "migration",
24
- "legacy"
24
+ "legacy",
25
+ "guardrail",
26
+ "mutation"
25
27
  ]);
26
28
 
29
+ const GUARDRAIL_RESULTS = deepFreeze(["pending", "passed", "blocked"]);
30
+
27
31
  const ARTIFACT_TYPES = deepFreeze({
28
32
  feature: {
29
33
  prefix: "FEAT",
@@ -109,7 +113,9 @@ const STRUCTURAL_RELATIONS = deepFreeze({
109
113
 
110
114
  const SCALAR_FIELDS = deepFreeze({
111
115
  attempt: { kinds: ["run"], required: true, type: "positive integer", meaning: "monotonic execution-attempt number within one Task" },
112
- ledger: { kinds: ["run"], required: false, type: `integer ${RUN_LEDGER_VERSION}`, meaning: "canonical Run event-ledger schema; required for newly authored Runs" }
116
+ ledger: { kinds: ["run"], required: false, type: `integer ${RUN_LEDGER_VERSION}`, meaning: "canonical Run event-ledger schema; required for newly authored Runs" },
117
+ guardrails: { kinds: ["run"], required: false, type: "integer 1", meaning: "append-only Guardrail obligation schema" },
118
+ workspace: { kinds: ["run"], required: false, type: "integer 1", meaning: "workspace mutation-gateway schema" }
113
119
  });
114
120
 
115
121
  const TRUTH_OWNERSHIP = deepFreeze({
@@ -137,6 +143,7 @@ module.exports = {
137
143
  ARTIFACT_TRANSITIONS,
138
144
  ARTIFACT_TYPES,
139
145
  AUTHORITY,
146
+ GUARDRAIL_RESULTS,
140
147
  METHOD_VERSION,
141
148
  RUN_EVENT_TYPES,
142
149
  RUN_EVIDENCE_KINDS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",
@@ -55,4 +55,4 @@
55
55
  "engines": {
56
56
  "node": ">=22.13.0"
57
57
  }
58
- }
58
+ }
@@ -2,6 +2,9 @@
2
2
  "method": "2.0.0",
3
3
  "layout": "v2",
4
4
  "schemas": {
5
- "run_ledger": 1
5
+ "run_ledger": 1,
6
+ "guardrails": 1,
7
+ "run_obligations": 1,
8
+ "mutation_gateway": 1
6
9
  }
7
10
  }
@@ -20,7 +20,8 @@ After approval:
20
20
  - Run is one execution attempt and follows `executing → validating → learning → completed|failed|blocked`;
21
21
  - a retry creates a new Run and preserves the old one;
22
22
  - learning proposes evidence-backed Knowledge, Decisions, or candidate Insights.
23
+ - every application/source edit requires a path-scoped Mutation Gateway permit, immediate hash recording, and resolution of the Run's Guardrail obligations before completion.
23
24
 
24
- Never bypass guardrails, overwrite owner work, treat generated state/cache as truth, auto-confirm AI knowledge, auto-migrate a v1 project, or print vault values.
25
+ Never bypass guardrails or edit around the Mutation Gateway, overwrite owner work, treat generated state/cache as truth, auto-confirm AI knowledge, auto-migrate a v1 project, or print vault values.
25
26
 
26
27
  If `/sc` is unavailable, follow the equivalent workflow in `.scrumrun/core.md` manually.
@@ -13,6 +13,8 @@ Do not scan every Task, Run, Sprint, Feature, or Memory file by default. Generat
13
13
 
14
14
  Natural-language product work begins as read-only intake. Explicit approval creates/updates a Task and creates one Run. A Sprint exists only for a real batch/timebox. Run state is `executing → validating → learning → completed|failed|blocked`; retries preserve prior Runs.
15
15
 
16
- `.scrumrun/guardrails.md` is canonical policy. Never bypass it, overwrite owner work, auto-confirm AI knowledge, auto-migrate v1 state, or print vault values.
16
+ Every application/source edit requires a short-lived path-scoped Mutation Gateway permit and immediate hash recording in the active Run. Resolve all persisted Guardrail obligations before completion; policy/workspace drift fails closed.
17
+
18
+ `.scrumrun/guardrails.md` is canonical policy. Never bypass it or the Mutation Gateway, overwrite owner work, auto-confirm AI knowledge, auto-migrate v1 state, or print vault values.
17
19
 
18
20
  Use `/sc <noun> <subject> <action> [args]`; if `/sc` is unavailable, follow `.scrumrun/core.md` manually.
@@ -80,6 +80,8 @@ Evaluate every active Guardrail into a structured `passed`, `blocked`, or `defer
80
80
 
81
81
  Do not create canonical artifacts, change status, edit application code, or treat ambiguous acknowledgement as approval. Temporary context may exist only in ignored disposable cache.
82
82
 
83
+ The approval token binds both canonical context and a complete workspace fingerprint. Any canonical or source change after planning invalidates it and requires a new intake.
84
+
83
85
  ## Approved execution
84
86
 
85
87
  Explicit approval creates/updates the Task and creates a Run. The Run follows:
@@ -93,7 +95,7 @@ During execution:
93
95
 
94
96
  1. keep the change inside the approved Task scope;
95
97
  2. preserve existing owner work and unrelated dirty files;
96
- 3. enforce guardrails before every material mutation;
98
+ 3. enforce guardrails before every material mutation: obtain a short-lived path-scoped Mutation Gateway permit before editing and record its verified before/after hashes immediately afterward;
97
99
  4. validate in proportion to risk;
98
100
  5. record exactly one structured `RUN-NNN-EVT-NNN` event per state transition, with RFC3339 time, actor, reason, and typed evidence;
99
101
  6. run configured reviewers;
@@ -102,6 +104,8 @@ During execution:
102
104
 
103
105
  Never overwrite a prior attempt. Never mark work complete because time/token budget ended.
104
106
 
107
+ Every deferred policy result is an append-only Run obligation. Unrecorded workspace drift, policy drift, an expired/missing permit, out-of-scope changes, unsafe symlinks, newly introduced secret-like content, or an unresolved obligation blocks validation/completion. The ignored permit cache is disposable; deleting it invalidates outstanding permits and never creates authority.
108
+
105
109
  Run is the sole operational-history authority. Task synchronizes current status without copying Run events. Validation, learning, completion, failure, block, and resume require a reason or structured evidence; completion also requires evidenced validation and learning. Early v2 prose Runs are migrated explicitly, with deterministic chains recovered and uncertain history represented as an evidenced snapshot.
106
110
 
107
111
  Linked canonical writes use the ignored durable transaction journal. An interrupted prepared mutation rolls back before the next approved mutation; a committed journal is verified and finalized. Audit remains read-only and reports pending recovery. Use `doctor --recover` only when explicitly requested, and never overwrite bytes changed after interruption.
@@ -155,7 +159,7 @@ Dry-run must not write project data. Apply requires a hashed inventory, byte-exa
155
159
  - `task`: add/list/show/run/audit/cancel/retry atomic work; use `type: fix` for fixes and `status: backlog` for parked work.
156
160
  - `sprint`: add/list/show/start/complete/block a real Task batch/timebox.
157
161
  - `feature`: add/list/show/activate/complete long-lived initiatives.
158
- - `run`: list/show/validate/learn/complete/resume/fail/block concrete Task attempts.
162
+ - `run`: list/show/authorize-mutation/record-mutation/satisfy-guardrail/validate/learn/complete/resume/fail/block concrete Task attempts.
159
163
  - `intake <request>`: execute the read-only request pipeline.
160
164
  - `challenge <question>`: deep read-only analysis with evidence, risks, options, and recommendation.
161
165