scrumrun 4.0.0 → 4.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 +26 -0
- package/README.md +45 -1
- package/SPEC.md +31 -0
- package/bin/scrumrun.js +89 -11
- 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/commands/repair.js +24 -3
- 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 +13 -1
- 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,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
|
|
7
|
+
function sha256(content) {
|
|
8
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function insideRoot(projectRoot, target) {
|
|
12
|
+
const absoluteRoot = path.resolve(projectRoot);
|
|
13
|
+
const absoluteTarget = path.resolve(projectRoot, target);
|
|
14
|
+
return absoluteTarget === absoluteRoot || absoluteTarget.startsWith(`${absoluteRoot}${path.sep}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireInside(projectRoot, relative, label) {
|
|
18
|
+
if (!relative || typeof relative !== "string") throw new Error(`${label}: path must be a non-empty string`);
|
|
19
|
+
if (path.isAbsolute(relative)) throw new Error(`${label}: absolute paths are not allowed`);
|
|
20
|
+
if (!insideRoot(projectRoot, relative)) throw new Error(`${label}: path escapes the project root`);
|
|
21
|
+
return path.resolve(projectRoot, relative);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OPS = Object.freeze({
|
|
25
|
+
rename_path(projectRoot, step, journal) {
|
|
26
|
+
const from = requireInside(projectRoot, step.from, "rename_path.from");
|
|
27
|
+
const to = requireInside(projectRoot, step.to, "rename_path.to");
|
|
28
|
+
if (!fs.existsSync(from)) {
|
|
29
|
+
if (step.if_missing === "skip") return { skipped: true };
|
|
30
|
+
throw new Error(`rename_path: source does not exist: ${step.from}`);
|
|
31
|
+
}
|
|
32
|
+
if (fs.existsSync(to)) throw new Error(`rename_path: destination already exists: ${step.to}`);
|
|
33
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
34
|
+
fs.renameSync(from, to);
|
|
35
|
+
journal.push({ op: "rename_path", from: step.from, to: step.to });
|
|
36
|
+
return { renamed: true };
|
|
37
|
+
},
|
|
38
|
+
bump_schema(projectRoot, step, journal) {
|
|
39
|
+
const file = requireInside(projectRoot, step.file || "method.json", "bump_schema.file");
|
|
40
|
+
if (!fs.existsSync(file)) throw new Error(`bump_schema: method file missing: ${step.file || "method.json"}`);
|
|
41
|
+
const before = fs.readFileSync(file, "utf8");
|
|
42
|
+
const method = JSON.parse(before);
|
|
43
|
+
const previous = method.schemas && method.schemas[step.field];
|
|
44
|
+
if (step.from !== undefined && previous !== step.from) {
|
|
45
|
+
throw new Error(`bump_schema: expected ${step.field} to be ${step.from}, found ${previous}`);
|
|
46
|
+
}
|
|
47
|
+
method.schemas = method.schemas || {};
|
|
48
|
+
method.schemas[step.field] = step.to;
|
|
49
|
+
fs.writeFileSync(file, `${JSON.stringify(method, null, 2)}\n`);
|
|
50
|
+
journal.push({ op: "bump_schema", file: step.file || "method.json", field: step.field, from: previous, to: step.to });
|
|
51
|
+
return { previous, next: step.to };
|
|
52
|
+
},
|
|
53
|
+
assert_hash(projectRoot, step) {
|
|
54
|
+
const file = requireInside(projectRoot, step.file, "assert_hash.file");
|
|
55
|
+
if (!fs.existsSync(file)) throw new Error(`assert_hash: missing file ${step.file}`);
|
|
56
|
+
const actual = sha256(fs.readFileSync(file));
|
|
57
|
+
if (actual !== step.sha256) throw new Error(`assert_hash: ${step.file} hash mismatch (expected ${step.sha256}, got ${actual})`);
|
|
58
|
+
return { hash: actual };
|
|
59
|
+
},
|
|
60
|
+
create_backup(projectRoot, step, journal) {
|
|
61
|
+
const source = requireInside(projectRoot, step.file, "create_backup.file");
|
|
62
|
+
const backupRoot = requireInside(projectRoot, step.backup_dir || ".backup", "create_backup.backup_dir");
|
|
63
|
+
if (!fs.existsSync(source)) throw new Error(`create_backup: missing source ${step.file}`);
|
|
64
|
+
fs.mkdirSync(backupRoot, { recursive: true });
|
|
65
|
+
const destination = path.join(backupRoot, path.basename(step.file));
|
|
66
|
+
fs.copyFileSync(source, destination);
|
|
67
|
+
journal.push({ op: "create_backup", file: step.file, backup: path.relative(projectRoot, destination) });
|
|
68
|
+
return { backup: path.relative(projectRoot, destination) };
|
|
69
|
+
},
|
|
70
|
+
move_frontmatter_field(projectRoot, step, journal) {
|
|
71
|
+
const file = requireInside(projectRoot, step.file, "move_frontmatter_field.file");
|
|
72
|
+
if (!fs.existsSync(file)) throw new Error(`move_frontmatter_field: missing ${step.file}`);
|
|
73
|
+
const source = fs.readFileSync(file, "utf8");
|
|
74
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/.exec(source);
|
|
75
|
+
if (!match) throw new Error(`move_frontmatter_field: no frontmatter in ${step.file}`);
|
|
76
|
+
const lines = match[1].split(/\r?\n/);
|
|
77
|
+
const fromRe = new RegExp(`^${step.from}:\\s*(.*)$`);
|
|
78
|
+
const idx = lines.findIndex((line) => fromRe.test(line));
|
|
79
|
+
if (idx === -1) {
|
|
80
|
+
if (step.if_missing === "skip") return { skipped: true };
|
|
81
|
+
throw new Error(`move_frontmatter_field: field ${step.from} not found in ${step.file}`);
|
|
82
|
+
}
|
|
83
|
+
const value = lines[idx].replace(fromRe, "$1");
|
|
84
|
+
lines[idx] = `${step.to}: ${value}`;
|
|
85
|
+
const next = `---\n${lines.join("\n")}\n---${match[2]}`;
|
|
86
|
+
fs.writeFileSync(file, next);
|
|
87
|
+
journal.push({ op: "move_frontmatter_field", file: step.file, from: step.from, to: step.to, value });
|
|
88
|
+
return { renamed: true };
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
module.exports = { OPS, sha256, requireInside };
|
|
@@ -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([
|
|
@@ -71,7 +72,9 @@ function parseGuardrails(content) {
|
|
|
71
72
|
const rule = capped(fields.rule || proseRule(block) || title);
|
|
72
73
|
const status = normalized(fields.status || "active").replace(/[._-]+$/, "");
|
|
73
74
|
const enforcement = normalized(fields.enforcement || inferEnforcement(title, rule));
|
|
74
|
-
const
|
|
75
|
+
const rawScope = String(fields.scope || "all").replace(/\([^)]*\)/g, "");
|
|
76
|
+
const scope = rawScope.split(/\s*[,+]\s*/).map((value) => normalized(value)).filter(Boolean);
|
|
77
|
+
const declarative = enforcementBlock(block, heading[1]);
|
|
75
78
|
return {
|
|
76
79
|
id: heading[1],
|
|
77
80
|
title,
|
|
@@ -80,6 +83,8 @@ function parseGuardrails(content) {
|
|
|
80
83
|
enforcement,
|
|
81
84
|
scope,
|
|
82
85
|
source: fields.source || null,
|
|
86
|
+
enforcement_rule: declarative.value,
|
|
87
|
+
enforcement_errors: declarative.errors,
|
|
83
88
|
explicit: {
|
|
84
89
|
status: Object.prototype.hasOwnProperty.call(fields, "status"),
|
|
85
90
|
enforcement: Object.prototype.hasOwnProperty.call(fields, "enforcement"),
|
|
@@ -94,6 +99,11 @@ function configFields(content) {
|
|
|
94
99
|
return fieldMap(String(content || ""));
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
function declarativeGuardrailConfig(content) {
|
|
103
|
+
// A project preference can promote warnings to blocks, never demote blocks.
|
|
104
|
+
return { on_violation: normalized(configFields(content).guardrail_on_violation || "warn") === "block" ? "block" : "warn" };
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
function agentIdentity(scrumDir) {
|
|
98
108
|
if (process.env.SCRUMRUN_AGENT) {
|
|
99
109
|
const env = String(process.env.SCRUMRUN_AGENT).trim();
|
|
@@ -249,6 +259,7 @@ function validateGuardrailDocument(content) {
|
|
|
249
259
|
if (!ENFORCEMENTS.has(record.enforcement)) errors.push(`${record.id} has unknown enforcement: ${record.enforcement}`);
|
|
250
260
|
if (!record.scope.length) errors.push(`${record.id} has no scope`);
|
|
251
261
|
for (const scope of record.scope) if (!SCOPES.has(scope)) errors.push(`${record.id} has unknown scope: ${scope}`);
|
|
262
|
+
for (const error of record.enforcement_errors || []) errors.push(error);
|
|
252
263
|
}
|
|
253
264
|
return { records, errors };
|
|
254
265
|
}
|
|
@@ -278,6 +289,7 @@ function normalizeGuardrailDocument(content) {
|
|
|
278
289
|
module.exports = {
|
|
279
290
|
agentIdentity,
|
|
280
291
|
configWeakeningAttempts,
|
|
292
|
+
declarativeGuardrailConfig,
|
|
281
293
|
evaluatePolicy,
|
|
282
294
|
inferEnforcement,
|
|
283
295
|
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
|
+
};
|