scrumrun 4.0.0 → 4.1.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.
- package/CHANGELOG.md +15 -0
- package/README.md +45 -1
- package/SPEC.md +31 -0
- package/bin/scrumrun.js +73 -7
- package/docs/COMMANDS.md +12 -2
- package/docs/ERROR-CODES.md +4 -0
- package/docs/SCHEMA.md +4 -0
- package/docs/SEMANTIC-MEMORY.md +28 -0
- package/lib/actions/index.js +81 -0
- package/lib/commands/manifest.js +3 -2
- package/lib/errors.js +4 -0
- package/lib/git/context.js +30 -0
- package/lib/guardrails/changeset.js +45 -0
- package/lib/guardrails/evaluate.js +175 -0
- package/lib/memory/compaction.js +289 -0
- package/lib/memory/index.js +62 -2
- package/lib/migrate/ops.js +92 -0
- package/lib/migrate/run.js +108 -0
- package/lib/runtime/context.js +3 -1
- package/lib/runtime/policy-engine.js +11 -0
- package/lib/runtime/watcher.js +185 -0
- package/lib/v2/conformance.js +27 -2
- package/lib/v2/runs-jsonl.js +134 -0
- package/lib/v2/task-schema.js +133 -0
- package/package.json +1 -1
- package/scripts/generate-contract-docs.js +4 -0
- package/templates/project/.scrumrun/config.md +9 -0
- package/templates/shared/hooks/pre-commit +16 -0
- package/templates/shared/view.html +281 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { OPS } = require("./ops");
|
|
6
|
+
|
|
7
|
+
function extractYamlBlocks(markdown) {
|
|
8
|
+
const blocks = {};
|
|
9
|
+
const regex = /```yaml\s+([a-z_0-9]+)\r?\n([\s\S]*?)```/g;
|
|
10
|
+
let match;
|
|
11
|
+
while ((match = regex.exec(markdown)) !== null) {
|
|
12
|
+
blocks[match[1]] = match[2];
|
|
13
|
+
}
|
|
14
|
+
return blocks;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseYamlList(yaml) {
|
|
18
|
+
if (!yaml || !yaml.trim()) return [];
|
|
19
|
+
const items = [];
|
|
20
|
+
let current = null;
|
|
21
|
+
for (const rawLine of yaml.split(/\r?\n/)) {
|
|
22
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
23
|
+
if (!line.trim()) continue;
|
|
24
|
+
const listMatch = /^(\s*)-\s+([a-z_0-9]+):\s*(.*)$/.exec(line);
|
|
25
|
+
const kvMatch = /^(\s+)([a-z_0-9]+):\s*(.*)$/.exec(line);
|
|
26
|
+
if (listMatch) {
|
|
27
|
+
current = {};
|
|
28
|
+
current[listMatch[2]] = coerce(listMatch[3]);
|
|
29
|
+
items.push(current);
|
|
30
|
+
} else if (kvMatch && current) {
|
|
31
|
+
current[kvMatch[2]] = coerce(kvMatch[3]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return items;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function coerce(raw) {
|
|
38
|
+
const text = raw.trim();
|
|
39
|
+
if (text === "") return "";
|
|
40
|
+
if (text === "true") return true;
|
|
41
|
+
if (text === "false") return false;
|
|
42
|
+
if (text === "null") return null;
|
|
43
|
+
if (/^-?\d+$/.test(text)) return Number(text);
|
|
44
|
+
if (text.startsWith('"') && text.endsWith('"')) return text.slice(1, -1);
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseMigration(source) {
|
|
49
|
+
const blocks = extractYamlBlocks(source);
|
|
50
|
+
const errors = [];
|
|
51
|
+
const steps = parseYamlList(blocks.steps);
|
|
52
|
+
const verify = parseYamlList(blocks.verify);
|
|
53
|
+
const rollback = parseYamlList(blocks.rollback);
|
|
54
|
+
if (!steps.length) errors.push("missing or empty ```yaml steps``` block");
|
|
55
|
+
if (!verify.length) errors.push("missing or empty ```yaml verify``` block");
|
|
56
|
+
for (const step of steps) {
|
|
57
|
+
if (!step.op || !OPS[step.op]) errors.push(`unknown or missing op in step: ${JSON.stringify(step)}`);
|
|
58
|
+
}
|
|
59
|
+
for (const step of verify) {
|
|
60
|
+
if (!step.op || !OPS[step.op]) errors.push(`unknown or missing verify op: ${JSON.stringify(step)}`);
|
|
61
|
+
}
|
|
62
|
+
return { steps, verify, rollback, errors };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadMigration(migrationFile) {
|
|
66
|
+
if (!fs.existsSync(migrationFile)) throw new Error(`Migration file not found: ${migrationFile}`);
|
|
67
|
+
const source = fs.readFileSync(migrationFile, "utf8");
|
|
68
|
+
const parsed = parseMigration(source);
|
|
69
|
+
if (parsed.errors.length) throw new Error(`Malformed migration ${path.basename(migrationFile)}: ${parsed.errors.join("; ")}`);
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runMigration(projectRoot, migrationFile, { dryRun = false } = {}) {
|
|
74
|
+
const migration = loadMigration(migrationFile);
|
|
75
|
+
if (dryRun) {
|
|
76
|
+
return {
|
|
77
|
+
status: "dry-run",
|
|
78
|
+
planned_steps: migration.steps.length,
|
|
79
|
+
planned_verify: migration.verify.length,
|
|
80
|
+
planned_rollback: migration.rollback.length
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const journal = [];
|
|
84
|
+
const applied = [];
|
|
85
|
+
try {
|
|
86
|
+
for (const step of migration.steps) {
|
|
87
|
+
const result = OPS[step.op](projectRoot, step, journal);
|
|
88
|
+
applied.push({ op: step.op, result });
|
|
89
|
+
}
|
|
90
|
+
for (const step of migration.verify) {
|
|
91
|
+
OPS[step.op](projectRoot, step, journal);
|
|
92
|
+
}
|
|
93
|
+
return { status: "applied", steps: applied, journal };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { status: "failed", steps: applied, error: error.message, journal };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function listMigrations(projectRoot) {
|
|
100
|
+
const dir = path.join(projectRoot, "migrations");
|
|
101
|
+
if (!fs.existsSync(dir)) return [];
|
|
102
|
+
return fs.readdirSync(dir)
|
|
103
|
+
.filter((name) => /\.md$/.test(name))
|
|
104
|
+
.sort()
|
|
105
|
+
.map((name) => path.join(dir, name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { parseMigration, loadMigration, runMigration, listMigrations, extractYamlBlocks };
|
package/lib/runtime/context.js
CHANGED
|
@@ -167,7 +167,9 @@ function buildContextPackage(projectRoot, request) {
|
|
|
167
167
|
return capped(content, limit);
|
|
168
168
|
};
|
|
169
169
|
const guardrailsContent = safeControl("guardrails.md", 10000);
|
|
170
|
-
|
|
170
|
+
// Policy fields must never be truncated: a late Read-Only Paths declaration
|
|
171
|
+
// is still mandatory, regardless of explanatory prose above it.
|
|
172
|
+
const config = safeControl("config.md", 10000);
|
|
171
173
|
const project = safeControl("project.md", 1400);
|
|
172
174
|
const artifacts = model.source === "canonical-v2" ? artifactSnapshot(scrumDir) : { records: {}, hashes: [], warnings: [] };
|
|
173
175
|
const openDecisions = (artifacts.records.decision || []).filter((record) => record.status === "open").slice(-10);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const { containsSecret } = require("../security/secrets");
|
|
6
|
+
const { enforcementBlock } = require("../guardrails/evaluate");
|
|
6
7
|
|
|
7
8
|
const GUARDRAIL_STATUSES = new Set(["active", "retired", "superseded"]);
|
|
8
9
|
const ENFORCEMENTS = new Set([
|
|
@@ -72,6 +73,7 @@ function parseGuardrails(content) {
|
|
|
72
73
|
const status = normalized(fields.status || "active").replace(/[._-]+$/, "");
|
|
73
74
|
const enforcement = normalized(fields.enforcement || inferEnforcement(title, rule));
|
|
74
75
|
const scope = (fields.scope || "all").split(/\s*,\s*/).map((value) => normalized(value)).filter(Boolean);
|
|
76
|
+
const declarative = enforcementBlock(block, heading[1]);
|
|
75
77
|
return {
|
|
76
78
|
id: heading[1],
|
|
77
79
|
title,
|
|
@@ -80,6 +82,8 @@ function parseGuardrails(content) {
|
|
|
80
82
|
enforcement,
|
|
81
83
|
scope,
|
|
82
84
|
source: fields.source || null,
|
|
85
|
+
enforcement_rule: declarative.value,
|
|
86
|
+
enforcement_errors: declarative.errors,
|
|
83
87
|
explicit: {
|
|
84
88
|
status: Object.prototype.hasOwnProperty.call(fields, "status"),
|
|
85
89
|
enforcement: Object.prototype.hasOwnProperty.call(fields, "enforcement"),
|
|
@@ -94,6 +98,11 @@ function configFields(content) {
|
|
|
94
98
|
return fieldMap(String(content || ""));
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
function declarativeGuardrailConfig(content) {
|
|
102
|
+
// A project preference can promote warnings to blocks, never demote blocks.
|
|
103
|
+
return { on_violation: normalized(configFields(content).guardrail_on_violation || "warn") === "block" ? "block" : "warn" };
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
function agentIdentity(scrumDir) {
|
|
98
107
|
if (process.env.SCRUMRUN_AGENT) {
|
|
99
108
|
const env = String(process.env.SCRUMRUN_AGENT).trim();
|
|
@@ -249,6 +258,7 @@ function validateGuardrailDocument(content) {
|
|
|
249
258
|
if (!ENFORCEMENTS.has(record.enforcement)) errors.push(`${record.id} has unknown enforcement: ${record.enforcement}`);
|
|
250
259
|
if (!record.scope.length) errors.push(`${record.id} has no scope`);
|
|
251
260
|
for (const scope of record.scope) if (!SCOPES.has(scope)) errors.push(`${record.id} has unknown scope: ${scope}`);
|
|
261
|
+
for (const error of record.enforcement_errors || []) errors.push(error);
|
|
252
262
|
}
|
|
253
263
|
return { records, errors };
|
|
254
264
|
}
|
|
@@ -278,6 +288,7 @@ function normalizeGuardrailDocument(content) {
|
|
|
278
288
|
module.exports = {
|
|
279
289
|
agentIdentity,
|
|
280
290
|
configWeakeningAttempts,
|
|
291
|
+
declarativeGuardrailConfig,
|
|
281
292
|
evaluatePolicy,
|
|
282
293
|
inferEnforcement,
|
|
283
294
|
normalizeGuardrailDocument,
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawn } = require("node:child_process");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { refreshState } = require("./orchestrator");
|
|
7
|
+
const { incrementalArtifact, rebuildIndex, writeMap } = require("../memory/index");
|
|
8
|
+
const { sourceWatchSnapshot } = require("../memory/index");
|
|
9
|
+
|
|
10
|
+
const PID_FILE = path.join(".scrumrun", ".cache", "watcher.pid");
|
|
11
|
+
const DEFAULT_DEBOUNCE_MS = 250;
|
|
12
|
+
const DEFAULT_POLL_MS = 1500;
|
|
13
|
+
const GENERATED_OUTPUTS = Object.freeze([".scrumrun/state.md", ".scrumrun/map.md", ".scrumrun/.cache/"]);
|
|
14
|
+
const EXECUTABLE_MARKDOWN = Object.freeze(["CORE.md", "SPEC.md", "DECISIONS.md"]);
|
|
15
|
+
|
|
16
|
+
function number(value, fallback, minimum, maximum) {
|
|
17
|
+
const parsed = Number(value);
|
|
18
|
+
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function watcherConfig(projectRoot) {
|
|
22
|
+
const file = path.join(projectRoot, ".scrumrun", "config.md");
|
|
23
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
24
|
+
const field = (name) => {
|
|
25
|
+
const matches = [...content.matchAll(new RegExp(`^${name.replace(/\./g, "\\.")}:\\s*(.+)$`, "gmi"))];
|
|
26
|
+
return matches.length ? matches[matches.length - 1][1] : "";
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
enabled: /^(true|yes|on|1)$/i.test(field("watcher.enabled").trim()),
|
|
30
|
+
debounceMs: number(field("watcher.debounce_ms"), DEFAULT_DEBOUNCE_MS, 25, 60_000),
|
|
31
|
+
pollMs: number(field("watcher.poll_interval_ms"), DEFAULT_POLL_MS, 250, 300_000)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pidFile(projectRoot) { return path.join(projectRoot, PID_FILE); }
|
|
36
|
+
|
|
37
|
+
function readStatus(projectRoot) {
|
|
38
|
+
const file = pidFile(projectRoot);
|
|
39
|
+
try {
|
|
40
|
+
const pid = Number(fs.readFileSync(file, "utf8").trim());
|
|
41
|
+
if (!Number.isInteger(pid) || pid < 1) throw new Error("invalid pid");
|
|
42
|
+
try { process.kill(pid, 0); return { running: true, pid, file }; }
|
|
43
|
+
catch { return { running: false, pid, file }; }
|
|
44
|
+
} catch { return { running: false, pid: null, file }; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeStatus(projectRoot, value) {
|
|
48
|
+
const file = pidFile(projectRoot);
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
fs.writeFileSync(file, `${value.pid}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRelevant(relative) {
|
|
54
|
+
const item = String(relative || "").split(path.sep).join("/").replace(/^\.\//, "");
|
|
55
|
+
if (!item || item === ".") return true;
|
|
56
|
+
if (item === ".scrumrun/vault.local.md" || item.startsWith(".scrumrun/.cache/contexts/") || item.startsWith(".scrumrun/.cache/") || item === ".scrumrun/state.md" || item === ".scrumrun/map.md") return false;
|
|
57
|
+
if (item.startsWith(".git/") || item.startsWith("node_modules/") || item.startsWith("dist/") || item.startsWith("build/") || item.startsWith("coverage/")) return false;
|
|
58
|
+
return item.startsWith(".scrumrun/") || /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/i.test(item);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function generatedOutputOnly(relative) {
|
|
62
|
+
const item = String(relative || "").split(path.sep).join("/").replace(/^\.\//, "");
|
|
63
|
+
return GENERATED_OUTPUTS.some((output) => output.endsWith("/") ? item.startsWith(output) : item === output);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertGeneratedOutput(relative) {
|
|
67
|
+
if (!generatedOutputOnly(relative)) throw new Error(`Watcher output is restricted to generated projections, not ${relative}.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function refreshDerived(projectRoot, changed = []) {
|
|
71
|
+
// state.md is a bounded incremental projection; the SQLite graph falls back
|
|
72
|
+
// to its complete deterministic rebuild whenever a changed file could alter
|
|
73
|
+
// graph-wide lexical relations. This favours correctness over a partial edge
|
|
74
|
+
// update and keeps the incremental seam explicit for future adapters.
|
|
75
|
+
assertGeneratedOutput(".scrumrun/state.md");
|
|
76
|
+
assertGeneratedOutput(".scrumrun/.cache/semantic-index.sqlite");
|
|
77
|
+
assertGeneratedOutput(".scrumrun/map.md");
|
|
78
|
+
const state = refreshState(path.join(projectRoot, ".scrumrun"));
|
|
79
|
+
const incremental = incrementalArtifact(projectRoot, changed);
|
|
80
|
+
const index = incremental || rebuildIndex(projectRoot, { changedPaths: changed });
|
|
81
|
+
const map = writeMap(projectRoot);
|
|
82
|
+
return { mode: incremental ? "incremental-artifact" : changed.length === 1 ? "incremental-state+full-index-fallback" : "coalesced-full-index", changed, state, index, map };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createWatcher(projectRoot, options = {}) {
|
|
86
|
+
const config = { ...watcherConfig(projectRoot), ...options };
|
|
87
|
+
let closed = false;
|
|
88
|
+
let pending = new Set();
|
|
89
|
+
let timer = null;
|
|
90
|
+
let poll = null;
|
|
91
|
+
let native = null;
|
|
92
|
+
let mode = "polling";
|
|
93
|
+
let snapshot = sourceWatchSnapshot(projectRoot).fingerprint;
|
|
94
|
+
let rebuilds = 0;
|
|
95
|
+
let lastResult = null;
|
|
96
|
+
|
|
97
|
+
const flush = () => {
|
|
98
|
+
timer = null;
|
|
99
|
+
if (closed || !pending.size) return;
|
|
100
|
+
const changed = [...pending].sort();
|
|
101
|
+
pending.clear();
|
|
102
|
+
lastResult = refreshDerived(projectRoot, changed);
|
|
103
|
+
snapshot = sourceWatchSnapshot(projectRoot).fingerprint;
|
|
104
|
+
rebuilds++;
|
|
105
|
+
};
|
|
106
|
+
const schedule = (relative) => {
|
|
107
|
+
if (!isRelevant(relative)) return;
|
|
108
|
+
pending.add(relative || ".");
|
|
109
|
+
if (timer) clearTimeout(timer);
|
|
110
|
+
timer = setTimeout(flush, config.debounceMs);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const watch = options.watchImpl || fs.watch;
|
|
114
|
+
const startPolling = () => {
|
|
115
|
+
if (poll) return;
|
|
116
|
+
mode = "polling";
|
|
117
|
+
poll = setInterval(() => {
|
|
118
|
+
if (closed) return;
|
|
119
|
+
const next = sourceWatchSnapshot(projectRoot).fingerprint;
|
|
120
|
+
if (next !== snapshot) schedule(".");
|
|
121
|
+
}, config.pollMs);
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
native = watch(projectRoot, { recursive: true }, (_event, filename) => schedule(filename ? String(filename) : "."));
|
|
125
|
+
mode = "fs.watch";
|
|
126
|
+
native.once("error", () => {
|
|
127
|
+
if (native) native.close();
|
|
128
|
+
native = null;
|
|
129
|
+
startPolling();
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
// Linux and some Windows volumes do not support recursive fs.watch.
|
|
133
|
+
startPolling();
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
get mode() { return mode; },
|
|
137
|
+
get rebuilds() { return rebuilds; },
|
|
138
|
+
get lastResult() { return lastResult; },
|
|
139
|
+
schedule,
|
|
140
|
+
close() {
|
|
141
|
+
closed = true;
|
|
142
|
+
if (timer) clearTimeout(timer);
|
|
143
|
+
if (poll) clearInterval(poll);
|
|
144
|
+
if (native) native.close();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runDaemon(projectRoot) {
|
|
150
|
+
const config = watcherConfig(projectRoot);
|
|
151
|
+
if (!config.enabled) throw new Error("Watcher is disabled. Set watcher.enabled: true in .scrumrun/config.md before starting it.");
|
|
152
|
+
refreshDerived(projectRoot, ["startup"]);
|
|
153
|
+
const watcher = createWatcher(projectRoot, config);
|
|
154
|
+
writeStatus(projectRoot, { pid: process.pid });
|
|
155
|
+
const stop = () => {
|
|
156
|
+
watcher.close();
|
|
157
|
+
try { fs.rmSync(pidFile(projectRoot), { force: true }); } catch { /* best effort generated state cleanup */ }
|
|
158
|
+
process.exit(0);
|
|
159
|
+
};
|
|
160
|
+
process.on("SIGTERM", stop);
|
|
161
|
+
process.on("SIGINT", stop);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function startWatcher(projectRoot) {
|
|
165
|
+
const config = watcherConfig(projectRoot);
|
|
166
|
+
if (!config.enabled) return { status: "disabled", config };
|
|
167
|
+
const status = readStatus(projectRoot);
|
|
168
|
+
if (status.running) return { status: "already-running", ...status };
|
|
169
|
+
const child = spawn(process.execPath, [__filename, "--daemon", projectRoot], { detached: true, stdio: "ignore" });
|
|
170
|
+
child.unref();
|
|
171
|
+
return { status: "started", pid: child.pid, config };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stopWatcher(projectRoot) {
|
|
175
|
+
const status = readStatus(projectRoot);
|
|
176
|
+
if (status.running) {
|
|
177
|
+
try { process.kill(status.pid, "SIGTERM"); } catch { /* status is reconciled below */ }
|
|
178
|
+
}
|
|
179
|
+
try { fs.rmSync(pidFile(projectRoot), { force: true }); } catch { /* generated state cleanup */ }
|
|
180
|
+
return { status: status.running ? "stopped" : "not-running", pid: status.pid };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (require.main === module && process.argv[2] === "--daemon") runDaemon(path.resolve(process.argv[3] || process.cwd()));
|
|
184
|
+
|
|
185
|
+
module.exports = { EXECUTABLE_MARKDOWN, GENERATED_OUTPUTS, assertGeneratedOutput, createWatcher, generatedOutputOnly, isRelevant, refreshDerived, readStatus, startWatcher, stopWatcher, watcherConfig };
|
package/lib/v2/conformance.js
CHANGED
|
@@ -11,10 +11,14 @@ const { extractEvidence } = require("../memory/markdown");
|
|
|
11
11
|
const { resolveEvidence } = require("../memory/service");
|
|
12
12
|
const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = require("../security/secrets");
|
|
13
13
|
const { pendingTransactionStatus } = require("./transaction");
|
|
14
|
-
const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
14
|
+
const { configWeakeningAttempts, declarativeGuardrailConfig, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
15
15
|
const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
|
|
16
16
|
const { canonicalPaths, PATHS_SCHEMA_VERSION } = require("./paths");
|
|
17
17
|
const { auditPolicyIntegrity } = require("../runtime/policy-integrity");
|
|
18
|
+
const { evaluate } = require("../guardrails/evaluate");
|
|
19
|
+
const { collectChangeSet } = require("../guardrails/changeset");
|
|
20
|
+
const { validateArtifacts: validateTaskSchema } = require("./task-schema");
|
|
21
|
+
const { readGitContext } = require("../git/context");
|
|
18
22
|
|
|
19
23
|
const INVARIANTS = Object.freeze([
|
|
20
24
|
{ id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
|
|
@@ -65,7 +69,7 @@ function diffPathIndex(expected, actual, prefix = "") {
|
|
|
65
69
|
return drift;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
function auditProject(projectRoot) {
|
|
72
|
+
function auditProject(projectRoot, options = {}) {
|
|
69
73
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
70
74
|
const findings = [];
|
|
71
75
|
if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) {
|
|
@@ -113,6 +117,16 @@ function auditProject(projectRoot) {
|
|
|
113
117
|
const configFile = path.join(scrumDir, "config.md");
|
|
114
118
|
const config = fs.existsSync(configFile) && fs.lstatSync(configFile).isFile() ? fs.readFileSync(configFile, "utf8") : "";
|
|
115
119
|
for (const error of configWeakeningAttempts(config)) findings.push(finding("high", "CONFIG_WEAKENS_POLICY", error, configFile));
|
|
120
|
+
const declarativeRules = guardrailValidation.records.filter((record) => record.status === "active" && record.enforcement_rule);
|
|
121
|
+
const guardrailEvaluation = evaluate(declarativeRules, options.changeSet || collectChangeSet(projectRoot, { staged: options.staged === true }), {
|
|
122
|
+
config: declarativeGuardrailConfig(config)
|
|
123
|
+
});
|
|
124
|
+
for (const item of guardrailEvaluation.blocked) {
|
|
125
|
+
findings.push(finding("high", "DECLARATIVE_GUARDRAIL_BLOCK", `${item.guardrail}: ${item.message} Evidence: ${item.evidence.join(", ")}.`, guardrailsFile));
|
|
126
|
+
}
|
|
127
|
+
for (const item of guardrailEvaluation.warnings) {
|
|
128
|
+
findings.push(finding("warning", "DECLARATIVE_GUARDRAIL_WARN", `${item.guardrail}: ${item.message} Evidence: ${item.evidence.join(", ")}.`, guardrailsFile));
|
|
129
|
+
}
|
|
116
130
|
|
|
117
131
|
const repository = new ArtifactRepository(scrumDir);
|
|
118
132
|
const counts = {};
|
|
@@ -288,6 +302,17 @@ function auditProject(projectRoot) {
|
|
|
288
302
|
} catch (error) {
|
|
289
303
|
findings.push(finding("high", "INDEX_UNSAFE", `semantic index cannot inspect canonical sources safely: ${error.message}`));
|
|
290
304
|
}
|
|
305
|
+
const allArtifacts = Object.values(records).flat();
|
|
306
|
+
const gitContext = options.gitContext || readGitContext(projectRoot);
|
|
307
|
+
const taskSchemaReport = validateTaskSchema(allArtifacts, { gitContext });
|
|
308
|
+
const schemaErrorSeverity = options.strict ? "high" : "warning";
|
|
309
|
+
for (const error of taskSchemaReport.errors) {
|
|
310
|
+
findings.push(finding(schemaErrorSeverity, error.code, error.message, error.file));
|
|
311
|
+
}
|
|
312
|
+
for (const warning of taskSchemaReport.warnings) {
|
|
313
|
+
findings.push(finding("warning", warning.code, warning.message, warning.file));
|
|
314
|
+
}
|
|
315
|
+
|
|
291
316
|
const transactions = pendingTransactionStatus(scrumDir);
|
|
292
317
|
if (transactions.error) {
|
|
293
318
|
findings.push(finding("high", "TRANSACTION_JOURNAL_UNSAFE", transactions.error));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
|
|
7
|
+
const RUNS_JSONL_SCHEMA = 1;
|
|
8
|
+
const ALLOWED_EVENTS = Object.freeze(["created", "started", "transition", "note", "evidence", "guardrail", "mutation", "completed", "failed", "blocked"]);
|
|
9
|
+
const GENESIS_HASH = "0".repeat(64);
|
|
10
|
+
|
|
11
|
+
function sha256Hex(input) {
|
|
12
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function jsonlPath(scrumDir, taskId) {
|
|
16
|
+
return path.join(scrumDir, "runs", `${taskId}.jsonl`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertTaskId(taskId) {
|
|
20
|
+
if (!/^TASK-\d{3,}$/.test(String(taskId || ""))) throw new Error(`Invalid TASK id for runs.jsonl: ${taskId}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function canonicalizePayload(payload) {
|
|
24
|
+
if (payload === null || payload === undefined) return {};
|
|
25
|
+
if (typeof payload !== "object" || Array.isArray(payload)) throw new Error("payload must be a plain object");
|
|
26
|
+
const sorted = {};
|
|
27
|
+
for (const key of Object.keys(payload).sort()) sorted[key] = payload[key];
|
|
28
|
+
return sorted;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function computeHash(previousHash, entry) {
|
|
32
|
+
const material = JSON.stringify({ prev: previousHash, run_id: entry.run_id, ts: entry.ts, event: entry.event, payload: entry.payload });
|
|
33
|
+
return sha256Hex(material);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readEntries(scrumDir, taskId) {
|
|
37
|
+
assertTaskId(taskId);
|
|
38
|
+
const file = jsonlPath(scrumDir, taskId);
|
|
39
|
+
if (!fs.existsSync(file)) return [];
|
|
40
|
+
const contents = fs.readFileSync(file, "utf8");
|
|
41
|
+
if (!contents) return [];
|
|
42
|
+
const entries = [];
|
|
43
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
44
|
+
const line = rawLine.trim();
|
|
45
|
+
if (!line) continue;
|
|
46
|
+
entries.push(JSON.parse(line));
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateEntries(entries) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
let previous = GENESIS_HASH;
|
|
54
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
55
|
+
const entry = entries[index];
|
|
56
|
+
if (!entry || typeof entry !== "object") { errors.push(`entry ${index}: not an object`); continue; }
|
|
57
|
+
for (const field of ["run_id", "ts", "event", "payload", "prev", "hash"]) {
|
|
58
|
+
if (!(field in entry)) errors.push(`entry ${index}: missing ${field}`);
|
|
59
|
+
}
|
|
60
|
+
if (entry.prev !== previous) errors.push(`entry ${index}: prev ${entry.prev} != expected ${previous}`);
|
|
61
|
+
if (!ALLOWED_EVENTS.includes(entry.event)) errors.push(`entry ${index}: unknown event ${entry.event}`);
|
|
62
|
+
const expectedHash = computeHash(previous, entry);
|
|
63
|
+
if (entry.hash !== expectedHash) errors.push(`entry ${index}: hash ${entry.hash} != expected ${expectedHash}`);
|
|
64
|
+
previous = entry.hash || previous;
|
|
65
|
+
}
|
|
66
|
+
return { errors, headHash: previous };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function appendEntry(scrumDir, taskId, { run_id, ts, event, payload }) {
|
|
70
|
+
assertTaskId(taskId);
|
|
71
|
+
if (!run_id || !/^RUN-\d{3,}$/.test(String(run_id))) throw new Error(`Invalid run_id: ${run_id}`);
|
|
72
|
+
if (!ts || Number.isNaN(Date.parse(ts))) throw new Error(`Invalid ts (ISO-8601 required): ${ts}`);
|
|
73
|
+
if (!ALLOWED_EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`);
|
|
74
|
+
const canonicalPayload = canonicalizePayload(payload);
|
|
75
|
+
|
|
76
|
+
const file = jsonlPath(scrumDir, taskId);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
|
|
79
|
+
// Concurrency: exclusive-create a lockfile; retry with jittered backoff.
|
|
80
|
+
const lock = `${file}.lock`;
|
|
81
|
+
const deadline = Date.now() + 5000;
|
|
82
|
+
let handle = null;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
try {
|
|
85
|
+
handle = fs.openSync(lock, "wx");
|
|
86
|
+
break;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== "EEXIST") throw error;
|
|
89
|
+
const delay = 5 + Math.floor(Math.random() * 15);
|
|
90
|
+
const busyUntil = Date.now() + delay;
|
|
91
|
+
while (Date.now() < busyUntil) { /* spin briefly */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (handle === null) throw new Error(`Could not acquire lock on ${lock} within 5s`);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const entries = readEntries(scrumDir, taskId);
|
|
98
|
+
const previous = entries.length ? entries[entries.length - 1].hash : GENESIS_HASH;
|
|
99
|
+
const record = { run_id, ts, event, payload: canonicalPayload, prev: previous };
|
|
100
|
+
record.hash = computeHash(previous, record);
|
|
101
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
102
|
+
fs.appendFileSync(file, line);
|
|
103
|
+
return record;
|
|
104
|
+
} finally {
|
|
105
|
+
try { fs.closeSync(handle); } catch { /* ignore */ }
|
|
106
|
+
try { fs.unlinkSync(lock); } catch { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function listRunIds(scrumDir, taskId) {
|
|
111
|
+
const entries = readEntries(scrumDir, taskId);
|
|
112
|
+
return [...new Set(entries.map((entry) => entry.run_id))];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function listAllTaskLedgers(scrumDir) {
|
|
116
|
+
const dir = path.join(scrumDir, "runs");
|
|
117
|
+
if (!fs.existsSync(dir)) return [];
|
|
118
|
+
return fs.readdirSync(dir)
|
|
119
|
+
.filter((name) => /^TASK-\d{3,}\.jsonl$/.test(name))
|
|
120
|
+
.map((name) => name.replace(/\.jsonl$/, ""));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
RUNS_JSONL_SCHEMA,
|
|
125
|
+
ALLOWED_EVENTS,
|
|
126
|
+
GENESIS_HASH,
|
|
127
|
+
jsonlPath,
|
|
128
|
+
readEntries,
|
|
129
|
+
validateEntries,
|
|
130
|
+
appendEntry,
|
|
131
|
+
listRunIds,
|
|
132
|
+
listAllTaskLedgers,
|
|
133
|
+
computeHash
|
|
134
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const REQUIRED_SECTIONS = Object.freeze({
|
|
4
|
+
task: ["## Request", "## Done when"],
|
|
5
|
+
feature: [],
|
|
6
|
+
sprint: [],
|
|
7
|
+
run: []
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const COMPLETION_MIN_LENGTH = 24;
|
|
11
|
+
|
|
12
|
+
function findSectionLine(lines, heading) {
|
|
13
|
+
const target = heading.trim().toLowerCase();
|
|
14
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
15
|
+
if (lines[index].trim().toLowerCase() === target) return index + 1;
|
|
16
|
+
}
|
|
17
|
+
return -1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sectionBody(lines, heading) {
|
|
21
|
+
const start = findSectionLine(lines, heading);
|
|
22
|
+
if (start === -1) return null;
|
|
23
|
+
const body = [];
|
|
24
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
25
|
+
const line = lines[index];
|
|
26
|
+
if (/^## /.test(line) && index !== start - 1) break;
|
|
27
|
+
if (index !== start - 1) body.push(line);
|
|
28
|
+
}
|
|
29
|
+
return body.join("\n").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bodyLines(bodyOrSource) {
|
|
33
|
+
const text = String(bodyOrSource || "");
|
|
34
|
+
const lines = text.split(/\r?\n/);
|
|
35
|
+
if (lines[0] !== "---") return lines;
|
|
36
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
37
|
+
if (lines[index] === "---") return lines.slice(index + 1);
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function completionSatisfied(taskBody, runs = []) {
|
|
43
|
+
const body = sectionBody(bodyLines(taskBody), "## Completion");
|
|
44
|
+
if (body && body.replace(/\s+/g, " ").length >= COMPLETION_MIN_LENGTH) return { ok: true, via: "section" };
|
|
45
|
+
for (const run of runs) {
|
|
46
|
+
const runBody = String(run.body || "");
|
|
47
|
+
const summary = sectionBody(bodyLines(runBody), "## Technical Summary");
|
|
48
|
+
if (summary && summary.replace(/\s+/g, " ").length >= COMPLETION_MIN_LENGTH) {
|
|
49
|
+
return { ok: true, via: `run:${run.id || "unknown"}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { ok: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateTaskArtifact(artifact, options = {}) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
const warnings = [];
|
|
58
|
+
if (!artifact || !artifact.record) return { errors, warnings };
|
|
59
|
+
const record = artifact.record;
|
|
60
|
+
const source = artifact.body != null ? artifact.body : (artifact.source || "");
|
|
61
|
+
const lines = bodyLines(source);
|
|
62
|
+
const required = REQUIRED_SECTIONS[record.kind] || [];
|
|
63
|
+
const schemaOptIn = Number(record.task_schema || 0) >= 1;
|
|
64
|
+
if (schemaOptIn) {
|
|
65
|
+
for (const heading of required) {
|
|
66
|
+
if (findSectionLine(lines, heading) === -1) {
|
|
67
|
+
errors.push({
|
|
68
|
+
code: "SR-E-452",
|
|
69
|
+
message: `${record.id}: missing required section "${heading}".`,
|
|
70
|
+
file: artifact.file || null,
|
|
71
|
+
line: null
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (schemaOptIn && record.kind === "task" && record.status === "completed") {
|
|
77
|
+
const runs = options.runsByTask ? options.runsByTask[record.id] || [] : [];
|
|
78
|
+
const result = completionSatisfied(source, runs);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
const line = findSectionLine(lines, "## Completion");
|
|
81
|
+
errors.push({
|
|
82
|
+
code: "SR-E-453",
|
|
83
|
+
message: `${record.id} is completed but lacks a non-empty "## Completion" (min ${COMPLETION_MIN_LENGTH} chars) and no associated Run carries a "## Technical Summary".`,
|
|
84
|
+
file: artifact.file || null,
|
|
85
|
+
line: line === -1 ? null : line
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (schemaOptIn && record.kind === "task" && ["running", "in_progress", "validating", "learning"].includes(record.status)) {
|
|
90
|
+
const git = options.gitContext;
|
|
91
|
+
if (git && git.isRepo) {
|
|
92
|
+
const branch = record.branch || (record.git && record.git.branch);
|
|
93
|
+
if (!branch) {
|
|
94
|
+
warnings.push({
|
|
95
|
+
code: "SR-E-454",
|
|
96
|
+
message: `${record.id} is ${record.status} inside a git repository but has no git.branch recorded in frontmatter. Reconcile with \`scrumrun repair --apply\` or add \`git: { branch, base_sha }\`.`,
|
|
97
|
+
file: artifact.file || null
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { errors, warnings };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateArtifacts(artifacts, options = {}) {
|
|
106
|
+
const runsByTask = {};
|
|
107
|
+
for (const artifact of artifacts) {
|
|
108
|
+
if (!artifact || !artifact.record) continue;
|
|
109
|
+
if (artifact.record.kind !== "run") continue;
|
|
110
|
+
const taskId = artifact.record.task;
|
|
111
|
+
if (!taskId) continue;
|
|
112
|
+
if (!runsByTask[taskId]) runsByTask[taskId] = [];
|
|
113
|
+
runsByTask[taskId].push({ id: artifact.record.id, body: (artifact.body != null ? artifact.body : (artifact.source || "")) });
|
|
114
|
+
}
|
|
115
|
+
const errors = [];
|
|
116
|
+
const warnings = [];
|
|
117
|
+
for (const artifact of artifacts) {
|
|
118
|
+
const result = validateTaskArtifact(artifact, { ...options, runsByTask });
|
|
119
|
+
errors.push(...result.errors);
|
|
120
|
+
warnings.push(...result.warnings);
|
|
121
|
+
}
|
|
122
|
+
return { errors, warnings };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
REQUIRED_SECTIONS,
|
|
127
|
+
COMPLETION_MIN_LENGTH,
|
|
128
|
+
validateTaskArtifact,
|
|
129
|
+
validateArtifacts,
|
|
130
|
+
findSectionLine,
|
|
131
|
+
sectionBody,
|
|
132
|
+
bodyLines
|
|
133
|
+
};
|