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.
- package/CHANGELOG.md +45 -0
- package/CORE.md +17 -3
- package/DECISIONS.md +56 -0
- package/MIGRATION-1-to-2.md +11 -0
- package/README.md +23 -5
- package/SPEC.md +30 -10
- package/bin/scrumrun.js +175 -11
- package/docs/COMMANDS.md +10 -4
- package/docs/ENTITY-MODEL.md +1 -1
- package/docs/RELEASE-SCORECARD.md +43 -0
- package/docs/RELEASE.md +19 -12
- package/docs/SCHEMA.md +11 -0
- package/docs/SEMANTIC-MEMORY.md +1 -1
- package/docs/TROUBLESHOOTING.md +13 -1
- package/lib/commands/manifest.js +15 -3
- package/lib/commands/render.js +4 -0
- package/lib/memory/index.js +201 -41
- package/lib/memory/service.js +3 -0
- package/lib/runtime/budgets.js +4 -0
- package/lib/runtime/canonical-snapshot.js +110 -0
- package/lib/runtime/context.js +5 -45
- package/lib/runtime/mutation-gateway.js +434 -0
- package/lib/runtime/orchestrator.js +130 -65
- package/lib/runtime/policy-engine.js +267 -0
- package/lib/runtime/request-engine.js +32 -24
- package/lib/runtime/review-service.js +92 -0
- package/lib/runtime/run-ledger.js +546 -0
- package/lib/runtime/workspace-state.js +146 -0
- package/lib/security/secrets.js +15 -1
- package/lib/v2/artifacts.js +24 -1
- package/lib/v2/conformance.js +78 -12
- package/lib/v2/migration.js +74 -10
- package/lib/v2/run-ledger-migration.js +268 -0
- package/lib/v2/schema.js +28 -1
- package/lib/v2/transaction.js +254 -0
- package/package.json +1 -1
- package/scripts/generate-contract-docs.js +11 -0
- package/templates/project/.scrumrun/guardrails.md +8 -0
- package/templates/project/.scrumrun/map.md +4 -3
- package/templates/project/.scrumrun/method.json +7 -1
- package/templates/project/.scrumrun/state.md +7 -14
- package/templates/project/AGENTS.md +2 -1
- package/templates/project-lean/AGENTS.md +3 -1
- 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
|
+
};
|
package/lib/security/secrets.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/lib/v2/artifacts.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/lib/v2/conformance.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
51
|
-
if (
|
|
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
|
-
|
|
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
|
}
|
package/lib/v2/migration.js
CHANGED
|
@@ -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
|
-
|
|
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(`${
|
|
341
|
+
errors.push(`${canonicalRecord.id || "unknown"}: ${validation.join("; ")}`);
|
|
328
342
|
return null;
|
|
329
343
|
}
|
|
330
|
-
const relative = posix(relativeArtifactPath(
|
|
331
|
-
const content = serializeArtifact(
|
|
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:
|
|
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:
|
|
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
|
|
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({
|
|
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;
|