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,434 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const {
|
|
7
|
+
ArtifactRepository,
|
|
8
|
+
assertNoSymlinkPath,
|
|
9
|
+
atomicWrite,
|
|
10
|
+
serializeArtifact,
|
|
11
|
+
sha256,
|
|
12
|
+
withArtifactLock
|
|
13
|
+
} = require("../v2/artifacts");
|
|
14
|
+
const { pendingTransactionStatus, runKernelTransaction } = require("../v2/transaction");
|
|
15
|
+
const { configWeakeningAttempts, parseGuardrails, readOnlyPaths, validateGuardrailDocument } = require("./policy-engine");
|
|
16
|
+
const { appendGuardrailEvent, appendMutationEvent, guardrailState, parseRunLedger } = require("./run-ledger");
|
|
17
|
+
const { changesBetween, normalizedRelative, pathAuthorized, stable, workspaceState } = require("./workspace-state");
|
|
18
|
+
const { containsSecret } = require("../security/secrets");
|
|
19
|
+
|
|
20
|
+
const PERMIT_SCHEMA = 1;
|
|
21
|
+
const PERMIT_TTL_MS = 15 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
function readProjectMethod(projectRoot) {
|
|
24
|
+
const file = path.join(projectRoot, ".scrumrun", "method.json");
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw new Error(`Mutation Gateway requires a valid method.json: ${error.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function policyState(projectRoot) {
|
|
33
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
34
|
+
const file = assertNoSymlinkPath(scrumDir, path.join(scrumDir, "guardrails.md"));
|
|
35
|
+
if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) throw new Error("Canonical guardrails.md is missing or unsafe.");
|
|
36
|
+
const content = fs.readFileSync(file, "utf8");
|
|
37
|
+
const validation = validateGuardrailDocument(content);
|
|
38
|
+
const method = readProjectMethod(projectRoot);
|
|
39
|
+
const strict = method.schemas && method.schemas.guardrails === 1;
|
|
40
|
+
const errors = [...validation.errors];
|
|
41
|
+
if (strict) {
|
|
42
|
+
for (const record of validation.records) {
|
|
43
|
+
for (const field of ["status", "enforcement", "scope", "rule"]) {
|
|
44
|
+
if (!record.explicit[field]) errors.push(`${record.id} must explicitly declare ${field}.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!validation.records.some((record) => record.status === "active")) errors.push("At least one active Guardrail is required.");
|
|
49
|
+
const configFile = path.join(scrumDir, "config.md");
|
|
50
|
+
const config = fs.existsSync(configFile) ? fs.readFileSync(configFile, "utf8") : "";
|
|
51
|
+
errors.push(...configWeakeningAttempts(config));
|
|
52
|
+
if (errors.length) throw new Error(`Guardrail policy is not enforceable: ${errors.join("; ")}`);
|
|
53
|
+
return {
|
|
54
|
+
file,
|
|
55
|
+
content,
|
|
56
|
+
config,
|
|
57
|
+
fingerprint: sha256(stable({ schema: 1, guardrails: content, config, method: method.method, schemas: method.schemas || {} })),
|
|
58
|
+
guardrails: validation.records.filter((record) => record.status === "active")
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertCanonicalWrite(projectRoot, operation, contents = []) {
|
|
63
|
+
const method = readProjectMethod(projectRoot);
|
|
64
|
+
const guardrails = path.join(projectRoot, ".scrumrun", "guardrails.md");
|
|
65
|
+
const strict = method.schemas && method.schemas.guardrails === 1;
|
|
66
|
+
if (!strict && !fs.existsSync(guardrails)) {
|
|
67
|
+
if (contents.some((value) => containsSecret(value))) throw new Error(`SECRET_BOUNDARY: ${operation} contains secret-like canonical content.`);
|
|
68
|
+
return { operation, policy: "legacy-unbound" };
|
|
69
|
+
}
|
|
70
|
+
const policy = policyState(projectRoot);
|
|
71
|
+
if (contents.some((value) => containsSecret(value))) throw new Error(`SECRET_BOUNDARY: ${operation} contains secret-like canonical content.`);
|
|
72
|
+
const transactions = pendingTransactionStatus(path.join(projectRoot, ".scrumrun"));
|
|
73
|
+
if (transactions.error) throw new Error(`TRANSACTION_GATE: ${transactions.error}`);
|
|
74
|
+
if (transactions.pending.length) throw new Error(`TRANSACTION_GATE: pending canonical transaction ${transactions.pending.map((item) => item.id).join(", ")}.`);
|
|
75
|
+
return { operation, policy: policy.fingerprint };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function publicWorkspace(value) {
|
|
79
|
+
return {
|
|
80
|
+
schema: 1,
|
|
81
|
+
mode: value.mode,
|
|
82
|
+
head: value.head || null,
|
|
83
|
+
fingerprint: value.fingerprint,
|
|
84
|
+
files: value.files.map((item) => ({ path: item.path, sha256: item.sha256, kind: item.kind, mode: item.mode, scan: item.scan }))
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function initialEvent(runArtifact) {
|
|
89
|
+
const parsed = parseRunLedger(runArtifact.body);
|
|
90
|
+
if (parsed.errors.length || !parsed.events.length) throw new Error(`Run ledger is invalid: ${parsed.errors.join("; ") || "missing events"}`);
|
|
91
|
+
return parsed.events[0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function expectedWorkspace(runArtifact) {
|
|
95
|
+
const events = parseRunLedger(runArtifact.body).events;
|
|
96
|
+
const mutation = [...events].reverse().find((event) => event.type === "mutation");
|
|
97
|
+
return mutation ? mutation.workspace_after : events[0] && events[0].workspace_baseline;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertPolicyBound(runArtifact, policy) {
|
|
101
|
+
const event = initialEvent(runArtifact);
|
|
102
|
+
if (!event.policy_fingerprint) throw new Error(`${runArtifact.record.id} predates enforceable Guardrail binding; migrate or retry it before mutation.`);
|
|
103
|
+
if (event.policy_fingerprint !== policy.fingerprint) {
|
|
104
|
+
throw new Error(`POLICY_DRIFT: guardrails.md changed after ${runArtifact.record.id} approval; start a newly approved Run.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertWorkspaceExpected(runArtifact, actual) {
|
|
109
|
+
const expected = expectedWorkspace(runArtifact);
|
|
110
|
+
if (!expected) throw new Error(`${runArtifact.record.id} has no canonical workspace baseline; migrate or retry it before mutation.`);
|
|
111
|
+
if (expected.fingerprint !== actual.fingerprint) {
|
|
112
|
+
throw new Error(`BYPASS_DETECTED: workspace fingerprint differs from ${runArtifact.record.id}'s last authorized mutation.`);
|
|
113
|
+
}
|
|
114
|
+
return expected;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function permitsDirectory(scrumDir) {
|
|
118
|
+
const directory = assertNoSymlinkPath(scrumDir, path.join(scrumDir, ".cache", "mutation-permits"));
|
|
119
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
120
|
+
return directory;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mutationKey(scrumDir, { create = false } = {}) {
|
|
124
|
+
const file = assertNoSymlinkPath(scrumDir, path.join(scrumDir, ".cache", "mutation-gateway.key"));
|
|
125
|
+
if (!fs.existsSync(file)) {
|
|
126
|
+
if (!create) throw new Error("Mutation Gateway key is missing; outstanding permits are invalid.");
|
|
127
|
+
atomicWrite(file, crypto.randomBytes(32));
|
|
128
|
+
fs.chmodSync(file, 0o600);
|
|
129
|
+
}
|
|
130
|
+
const stat = fs.lstatSync(file);
|
|
131
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Mutation Gateway key path is unsafe.");
|
|
132
|
+
const key = fs.readFileSync(file);
|
|
133
|
+
if (key.length !== 32) throw new Error("Mutation Gateway key is malformed.");
|
|
134
|
+
return key;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function permitIntegrity(permit, key) {
|
|
138
|
+
const { integrity, ...payload } = permit;
|
|
139
|
+
return crypto.createHmac("sha256", key).update(stable(payload)).digest("hex");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function assertPermitIntegrity(permit, key) {
|
|
143
|
+
const expected = permitIntegrity(permit, key);
|
|
144
|
+
const actual = String(permit && permit.integrity || "");
|
|
145
|
+
if (!/^[a-f0-9]{64}$/.test(actual) || !crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected))) {
|
|
146
|
+
throw new Error(`Mutation permit integrity check failed: ${permit && permit.id || "unknown"}.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function permitFile(scrumDir, permitId) {
|
|
151
|
+
if (!/^MUT-[A-Za-z0-9-]{8,}$/.test(String(permitId || ""))) throw new Error("Mutation permit id is invalid.");
|
|
152
|
+
return assertNoSymlinkPath(scrumDir, path.join(permitsDirectory(scrumDir), `${permitId}.json`));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function activePermitFiles(scrumDir) {
|
|
156
|
+
const directory = permitsDirectory(scrumDir);
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
const active = [];
|
|
159
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
160
|
+
const key = entries.length ? mutationKey(scrumDir) : null;
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (!entry.isFile() || !/^MUT-[A-Za-z0-9-]{8,}\.json$/.test(entry.name)) throw new Error(`Unsafe Mutation Gateway cache entry: ${entry.name}`);
|
|
163
|
+
const file = path.join(directory, entry.name);
|
|
164
|
+
let permit;
|
|
165
|
+
try {
|
|
166
|
+
permit = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new Error(`Mutation permit is malformed: ${entry.name}: ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
assertPermitIntegrity(permit, key);
|
|
171
|
+
if (Date.parse(permit.expires_at) <= now) fs.rmSync(file, { force: true });
|
|
172
|
+
else active.push({ file, permit });
|
|
173
|
+
}
|
|
174
|
+
return active;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function validateAllowedPaths(projectRoot, policy, paths) {
|
|
178
|
+
const allowed = [...new Set((Array.isArray(paths) ? paths : [paths]).map((value) => normalizedRelative(projectRoot, value)))];
|
|
179
|
+
if (!allowed.length) throw new Error("Mutation Gateway requires at least one explicit --path.");
|
|
180
|
+
const readOnly = readOnlyPaths(policy.config).map((value) => normalizedRelative(projectRoot, value));
|
|
181
|
+
const forbidden = allowed.filter((entry) => readOnly.some((item) => entry === item || entry.startsWith(`${item}/`) || item.startsWith(`${entry}/`)));
|
|
182
|
+
if (forbidden.length) throw new Error(`READ_ONLY_PATH: mutation scope overlaps ${forbidden.join(", ")}.`);
|
|
183
|
+
return allowed.sort();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function authorizeMutationUnlocked(projectRoot, runId, paths, options = {}) {
|
|
187
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
188
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
189
|
+
const run = repository.read("run", runId);
|
|
190
|
+
if (!run || run.errors.length) throw new Error(`Mutation Run is missing or invalid: ${runId}`);
|
|
191
|
+
if (run.record.status !== "executing") throw new Error(`Mutation permits require an executing Run; ${runId} is ${run.record.status}.`);
|
|
192
|
+
if (run.record.workspace !== 1 || run.record.guardrails !== 1) throw new Error(`${runId} predates the secure Run schemas; migrate or retry it.`);
|
|
193
|
+
const policy = policyState(projectRoot);
|
|
194
|
+
assertPolicyBound(run, policy);
|
|
195
|
+
const actual = workspaceState(projectRoot);
|
|
196
|
+
assertWorkspaceExpected(run, actual);
|
|
197
|
+
const allowed = validateAllowedPaths(projectRoot, policy, paths);
|
|
198
|
+
const outstanding = activePermitFiles(scrumDir);
|
|
199
|
+
if (outstanding.length) throw new Error(`Another mutation permit is still active: ${outstanding.map((item) => item.permit.id).join(", ")}.`);
|
|
200
|
+
const created = new Date();
|
|
201
|
+
const permit = {
|
|
202
|
+
schema: PERMIT_SCHEMA,
|
|
203
|
+
id: `MUT-${crypto.randomBytes(12).toString("hex")}`,
|
|
204
|
+
run: runId,
|
|
205
|
+
created_at: created.toISOString(),
|
|
206
|
+
expires_at: new Date(created.getTime() + (options.ttlMs || PERMIT_TTL_MS)).toISOString(),
|
|
207
|
+
policy_fingerprint: policy.fingerprint,
|
|
208
|
+
workspace_before: actual,
|
|
209
|
+
allowed_paths: allowed
|
|
210
|
+
};
|
|
211
|
+
permit.integrity = permitIntegrity(permit, mutationKey(scrumDir, { create: true }));
|
|
212
|
+
const file = permitFile(scrumDir, permit.id);
|
|
213
|
+
atomicWrite(file, `${JSON.stringify(permit, null, 2)}\n`);
|
|
214
|
+
fs.chmodSync(file, 0o600);
|
|
215
|
+
return { permit: permit.id, run: runId, expiresAt: permit.expires_at, paths: allowed };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function authorizeMutation(projectRoot, runId, paths, options = {}) {
|
|
219
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
220
|
+
return withArtifactLock(scrumDir, "mutation-gateway", () => authorizeMutationUnlocked(projectRoot, runId, paths, options));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readPermit(scrumDir, permitId) {
|
|
224
|
+
const file = permitFile(scrumDir, permitId);
|
|
225
|
+
if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) throw new Error(`Mutation permit not found: ${permitId}`);
|
|
226
|
+
let permit;
|
|
227
|
+
try {
|
|
228
|
+
permit = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new Error(`Mutation permit is malformed: ${error.message}`);
|
|
231
|
+
}
|
|
232
|
+
if (permit.schema !== PERMIT_SCHEMA || permit.id !== permitId) throw new Error("Mutation permit identity or schema is invalid.");
|
|
233
|
+
assertPermitIntegrity(permit, mutationKey(scrumDir));
|
|
234
|
+
if (Date.parse(permit.expires_at) <= Date.now()) throw new Error(`Mutation permit expired: ${permitId}`);
|
|
235
|
+
return { file, permit };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function recordMutationUnlocked(projectRoot, runId, permitId, options = {}) {
|
|
239
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
240
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
241
|
+
const run = repository.read("run", runId);
|
|
242
|
+
if (!run || run.errors.length) throw new Error(`Mutation Run is missing or invalid: ${runId}`);
|
|
243
|
+
if (run.record.status !== "executing") throw new Error(`Mutation recording requires an executing Run; ${runId} is ${run.record.status}.`);
|
|
244
|
+
const { file: permitPath, permit } = readPermit(scrumDir, permitId);
|
|
245
|
+
if (permit.run !== runId) throw new Error(`Mutation permit belongs to ${permit.run}, not ${runId}.`);
|
|
246
|
+
const policy = policyState(projectRoot);
|
|
247
|
+
assertPolicyBound(run, policy);
|
|
248
|
+
if (permit.policy_fingerprint !== policy.fingerprint) throw new Error("POLICY_DRIFT: mutation permit policy is stale.");
|
|
249
|
+
const expected = expectedWorkspace(run);
|
|
250
|
+
if (!expected || expected.fingerprint !== permit.workspace_before.fingerprint) throw new Error("BYPASS_DETECTED: Run workspace advanced after this permit was issued.");
|
|
251
|
+
const after = workspaceState(projectRoot);
|
|
252
|
+
const changes = changesBetween(permit.workspace_before, after);
|
|
253
|
+
if (!changes.length) throw new Error("Mutation permit produced no workspace change.");
|
|
254
|
+
const outside = changes.filter((change) => !pathAuthorized(change.path, permit.allowed_paths));
|
|
255
|
+
if (outside.length) throw new Error(`OUT_OF_SCOPE_MUTATION: ${outside.map((item) => item.path).join(", ")}.`);
|
|
256
|
+
const unsafeKinds = changes.filter((change) => !["file", "missing", "clean"].includes(change.after_kind));
|
|
257
|
+
if (unsafeKinds.length) throw new Error(`UNSAFE_MUTATION_TYPE: ${unsafeKinds.map((item) => item.path).join(", ")}.`);
|
|
258
|
+
const unscannable = changes.filter((change) => change.after_kind === "file" && change.after_scan !== "text");
|
|
259
|
+
if (unscannable.length) throw new Error(`UNSCANNABLE_MUTATION: secret inspection was not possible for ${unscannable.map((item) => item.path).join(", ")}.`);
|
|
260
|
+
const leaked = changes.filter((change) => change.new_secret_fingerprints.length);
|
|
261
|
+
if (leaked.length) throw new Error(`SECRET_BOUNDARY: new secret-like content detected in ${leaked.map((item) => item.path).join(", ")}.`);
|
|
262
|
+
const mutation = {
|
|
263
|
+
mutation_id: permit.id,
|
|
264
|
+
paths: permit.allowed_paths,
|
|
265
|
+
changes: changes.map(({ new_secret_fingerprints, ...change }) => change),
|
|
266
|
+
workspace_before: permit.workspace_before.fingerprint,
|
|
267
|
+
workspace_after: publicWorkspace(after),
|
|
268
|
+
policy_fingerprint: policy.fingerprint
|
|
269
|
+
};
|
|
270
|
+
const previous = fs.readFileSync(run.file, "utf8");
|
|
271
|
+
const appended = appendMutationEvent(run.record, run.body, mutation, {
|
|
272
|
+
note: options.note || `Recorded ${changes.length} authorized workspace change(s).`,
|
|
273
|
+
actor: options.actor || "agent",
|
|
274
|
+
evidence: options.evidence && options.evidence.length ? options.evidence : [{ kind: "mutation", ref: permit.id, summary: `${changes.length} scoped path change(s) verified.` }]
|
|
275
|
+
});
|
|
276
|
+
runKernelTransaction(scrumDir, "record-workspace-mutation", [{
|
|
277
|
+
file: run.file,
|
|
278
|
+
previous,
|
|
279
|
+
next: serializeArtifact(appended.record, appended.body)
|
|
280
|
+
}]);
|
|
281
|
+
const verifiedAfter = workspaceState(projectRoot);
|
|
282
|
+
fs.rmSync(permitPath, { force: true });
|
|
283
|
+
if (verifiedAfter.fingerprint !== after.fingerprint) {
|
|
284
|
+
throw new Error("POST_RECORD_DRIFT: workspace changed while mutation evidence was being committed.");
|
|
285
|
+
}
|
|
286
|
+
return { run: appended.record, mutation: permit.id, changes: mutation.changes };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function recordMutation(projectRoot, runId, permitId, options = {}) {
|
|
290
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
291
|
+
return withArtifactLock(scrumDir, "mutation-gateway", () => recordMutationUnlocked(projectRoot, runId, permitId, options));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function verifyWorkspaceIntegrity(projectRoot, runArtifact) {
|
|
295
|
+
if (runArtifact.record.workspace !== 1) return { status: "legacy", fingerprint: null };
|
|
296
|
+
const actual = workspaceState(projectRoot);
|
|
297
|
+
assertWorkspaceExpected(runArtifact, actual);
|
|
298
|
+
const policy = policyState(projectRoot);
|
|
299
|
+
assertPolicyBound(runArtifact, policy);
|
|
300
|
+
return { status: "passed", fingerprint: actual.fingerprint, policy: policy.fingerprint };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function requiredEvidenceKind(enforcement) {
|
|
304
|
+
if (enforcement === "builtin:migration-integrity") return "migration";
|
|
305
|
+
if (["builtin:review-gate", "builtin:canonical-truth", "builtin:canonical-write", "manual"].includes(enforcement)) return "review";
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function assertGateEvidence(projectRoot, repository, kind, evidence, guardrailId) {
|
|
310
|
+
if (!kind) return;
|
|
311
|
+
const matching = evidence.filter((item) => item && item.kind === kind);
|
|
312
|
+
if (!matching.length) throw new Error(`${guardrailId} requires ${kind} evidence.`);
|
|
313
|
+
if (kind === "review") {
|
|
314
|
+
const passed = matching.some((item) => {
|
|
315
|
+
if (!/^REV-\d{3,}$/.test(item.ref || "")) return false;
|
|
316
|
+
const review = repository.read("review", item.ref);
|
|
317
|
+
return review && !review.errors.length && review.record.status === "passed" && /^## (?:Validation )?Evidence\s*$/m.test(review.body);
|
|
318
|
+
});
|
|
319
|
+
if (!passed) throw new Error(`${guardrailId} requires a canonical passed REV-NNN reference.`);
|
|
320
|
+
}
|
|
321
|
+
if (kind === "migration") {
|
|
322
|
+
const referenced = matching.some((item) => {
|
|
323
|
+
if (typeof item.ref !== "string" || !item.ref.trim()) return false;
|
|
324
|
+
try {
|
|
325
|
+
const relative = normalizedRelative(projectRoot, item.ref);
|
|
326
|
+
const target = assertNoSymlinkPath(projectRoot, path.join(projectRoot, relative));
|
|
327
|
+
return fs.existsSync(target) && fs.lstatSync(target).isFile();
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
if (!referenced) throw new Error(`${guardrailId} requires an existing, safe migration evidence file.`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function satisfyGuardrailUnlocked(projectRoot, runId, guardrailId, options = {}) {
|
|
337
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
338
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
339
|
+
const run = repository.read("run", runId);
|
|
340
|
+
if (!run || run.errors.length) throw new Error(`Guardrail Run is missing or invalid: ${runId}`);
|
|
341
|
+
if (!["executing", "validating", "learning"].includes(run.record.status)) throw new Error(`Cannot satisfy Guardrails for terminal ${runId}.`);
|
|
342
|
+
const policy = policyState(projectRoot);
|
|
343
|
+
assertPolicyBound(run, policy);
|
|
344
|
+
const obligation = guardrailState(run.record, run.body).find((item) => item.guardrail === guardrailId);
|
|
345
|
+
if (!obligation) throw new Error(`${guardrailId} is not an obligation of ${runId}.`);
|
|
346
|
+
const evidence = Array.isArray(options.evidence) ? options.evidence : [];
|
|
347
|
+
const kind = requiredEvidenceKind(obligation.enforcement);
|
|
348
|
+
assertGateEvidence(projectRoot, repository, kind, evidence, guardrailId);
|
|
349
|
+
if (["builtin:owner-work", "builtin:read-only-path", "builtin:secret-boundary"].includes(obligation.enforcement)) {
|
|
350
|
+
const verified = verifyWorkspaceIntegrity(projectRoot, run);
|
|
351
|
+
evidence.push({ kind: "mutation", summary: `Workspace chain verified at ${verified.fingerprint}.` });
|
|
352
|
+
}
|
|
353
|
+
if (!evidence.length) throw new Error(`${guardrailId} requires structured evidence.`);
|
|
354
|
+
const previous = fs.readFileSync(run.file, "utf8");
|
|
355
|
+
const appended = appendGuardrailEvent(run.record, run.body, obligation, "passed", {
|
|
356
|
+
note: options.note || `${guardrailId} passed its ${obligation.gate} gate.`,
|
|
357
|
+
actor: options.actor || "agent",
|
|
358
|
+
evidence
|
|
359
|
+
});
|
|
360
|
+
runKernelTransaction(scrumDir, "satisfy-run-guardrail", [{ file: run.file, previous, next: serializeArtifact(appended.record, appended.body) }]);
|
|
361
|
+
return { run: appended.record, guardrail: guardrailId, status: "passed" };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function satisfyGuardrail(projectRoot, runId, guardrailId, options = {}) {
|
|
365
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
366
|
+
return withArtifactLock(scrumDir, `run-${String(runId).toLowerCase()}`, () => satisfyGuardrailUnlocked(projectRoot, runId, guardrailId, options));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function assertCompletionAllowed(projectRoot, runArtifact) {
|
|
370
|
+
if (runArtifact.record.guardrails !== 1 || runArtifact.record.workspace !== 1) return { status: "legacy" };
|
|
371
|
+
const policy = policyState(projectRoot);
|
|
372
|
+
assertPolicyBound(runArtifact, policy);
|
|
373
|
+
verifyWorkspaceIntegrity(projectRoot, runArtifact);
|
|
374
|
+
const pending = guardrailState(runArtifact.record, runArtifact.body).filter((item) => item.status !== "passed");
|
|
375
|
+
if (pending.length) throw new Error(`GUARDRAILS_PENDING: ${pending.map((item) => `${item.guardrail}:${item.gate}`).join(", ")}.`);
|
|
376
|
+
const transactions = pendingTransactionStatus(path.join(projectRoot, ".scrumrun"));
|
|
377
|
+
if (transactions.error || transactions.pending.length) throw new Error(`TRANSACTION_GATE: ${transactions.error || "pending canonical transaction"}.`);
|
|
378
|
+
return { status: "passed", policy: policy.fingerprint };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function prepareCompletion(projectRoot, runArtifact, options = {}) {
|
|
382
|
+
if (runArtifact.record.guardrails !== 1 || runArtifact.record.workspace !== 1) {
|
|
383
|
+
return { record: runArtifact.record, body: runArtifact.body, resolved: [], status: "legacy" };
|
|
384
|
+
}
|
|
385
|
+
const policy = policyState(projectRoot);
|
|
386
|
+
assertPolicyBound(runArtifact, policy);
|
|
387
|
+
const verified = verifyWorkspaceIntegrity(projectRoot, runArtifact);
|
|
388
|
+
const automatic = new Set(["builtin:owner-work", "builtin:read-only-path", "builtin:secret-boundary"]);
|
|
389
|
+
let record = runArtifact.record;
|
|
390
|
+
let body = runArtifact.body;
|
|
391
|
+
const resolved = [];
|
|
392
|
+
for (const obligation of guardrailState(record, body).filter((item) => item.status !== "passed" && automatic.has(item.enforcement))) {
|
|
393
|
+
const appended = appendGuardrailEvent(record, body, obligation, "passed", {
|
|
394
|
+
note: `${obligation.guardrail} passed the automatic completion workspace check.`,
|
|
395
|
+
actor: "mutation-gateway",
|
|
396
|
+
occurredAt: options.occurredAt,
|
|
397
|
+
evidence: [{ kind: "mutation", summary: `Workspace chain verified at ${verified.fingerprint}.` }]
|
|
398
|
+
});
|
|
399
|
+
record = appended.record;
|
|
400
|
+
body = appended.body;
|
|
401
|
+
resolved.push(obligation.guardrail);
|
|
402
|
+
}
|
|
403
|
+
const pending = guardrailState(record, body).filter((item) => item.status !== "passed");
|
|
404
|
+
if (pending.length) throw new Error(`GUARDRAILS_PENDING: ${pending.map((item) => `${item.guardrail}:${item.gate}`).join(", ")}.`);
|
|
405
|
+
const transactions = pendingTransactionStatus(path.join(projectRoot, ".scrumrun"));
|
|
406
|
+
if (transactions.error || transactions.pending.length) throw new Error(`TRANSACTION_GATE: ${transactions.error || "pending canonical transaction"}.`);
|
|
407
|
+
return { record, body, resolved, status: "passed" };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function auditActiveWorkspace(projectRoot, runArtifact) {
|
|
411
|
+
try {
|
|
412
|
+
if (!runArtifact.record || runArtifact.record.workspace !== 1 || ["completed", "failed", "blocked"].includes(runArtifact.record.status)) return null;
|
|
413
|
+
verifyWorkspaceIntegrity(projectRoot, runArtifact);
|
|
414
|
+
return null;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return error.message;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
module.exports = {
|
|
421
|
+
PERMIT_SCHEMA,
|
|
422
|
+
assertCanonicalWrite,
|
|
423
|
+
assertCompletionAllowed,
|
|
424
|
+
auditActiveWorkspace,
|
|
425
|
+
authorizeMutation,
|
|
426
|
+
expectedWorkspace,
|
|
427
|
+
policyState,
|
|
428
|
+
prepareCompletion,
|
|
429
|
+
publicWorkspace,
|
|
430
|
+
recordMutation,
|
|
431
|
+
satisfyGuardrail,
|
|
432
|
+
verifyWorkspaceIntegrity,
|
|
433
|
+
workspaceState
|
|
434
|
+
};
|