scrumrun 3.1.2 → 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 +29 -0
- package/CORE.md +13 -5
- package/README.md +73 -16
- package/SPEC.md +38 -3
- package/bin/scrumrun.js +102 -13
- package/docs/COMMANDS.md +17 -7
- package/docs/ENTITY-MODEL.md +17 -14
- package/docs/ERROR-CODES.md +4 -0
- package/docs/QUICKSTART.md +21 -19
- package/docs/SCHEMA.md +14 -10
- package/docs/SEMANTIC-MEMORY.md +28 -0
- package/lib/actions/index.js +81 -0
- package/lib/commands/manifest.js +4 -3
- package/lib/commands/render.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/briefing.js +29 -0
- package/lib/runtime/context.js +3 -1
- package/lib/runtime/orchestrator.js +4 -4
- package/lib/runtime/policy-engine.js +11 -0
- package/lib/runtime/policy-integrity.js +83 -0
- package/lib/runtime/watcher.js +185 -0
- package/lib/v2/artifacts.js +9 -0
- package/lib/v2/conformance.js +45 -18
- package/lib/v2/paths.js +2 -1
- package/lib/v2/runs-jsonl.js +134 -0
- package/lib/v2/schema.js +7 -7
- package/lib/v2/task-schema.js +133 -0
- package/package.json +2 -2
- package/scripts/generate-contract-docs.js +6 -2
- package/templates/project/.scrumrun/config.md +9 -0
- package/templates/project/.scrumrun/method.json +3 -0
- package/templates/project/AGENTS.md +10 -4
- package/templates/project-lean/AGENTS.md +6 -2
- package/templates/shared/hooks/pre-commit +16 -0
- package/templates/shared/skills/scrumrun/SKILL.md +11 -7
- package/templates/shared/view.html +281 -0
- package/types/index.d.ts +1 -1
|
@@ -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/briefing.js
CHANGED
|
@@ -29,6 +29,31 @@ function generateBriefing(scrumDir, repository) {
|
|
|
29
29
|
}))
|
|
30
30
|
.slice(0, 5);
|
|
31
31
|
|
|
32
|
+
// Blocked-by-dependency: Tasks with `depends_on: [ID, ...]` in frontmatter
|
|
33
|
+
// where at least one target artifact is missing or not terminal. This is a
|
|
34
|
+
// visibility signal, not a hard gate — the agent reads it and decides.
|
|
35
|
+
const allById = new Map();
|
|
36
|
+
for (const kind of Object.keys(snapshot.records)) {
|
|
37
|
+
for (const r of snapshot.records[kind] || []) {
|
|
38
|
+
if (r.id) allById.set(r.id, r);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const blockedLines = [];
|
|
42
|
+
for (const task of snapshot.records.task || []) {
|
|
43
|
+
if (!task.id || TERMINAL.has(task.status)) continue;
|
|
44
|
+
const deps = Array.isArray(task.depends_on) ? task.depends_on : [];
|
|
45
|
+
const blocking = [];
|
|
46
|
+
for (const depId of deps) {
|
|
47
|
+
if (typeof depId !== "string") continue;
|
|
48
|
+
const dep = allById.get(depId);
|
|
49
|
+
if (!dep) blocking.push(`${depId} (missing)`);
|
|
50
|
+
else if (!TERMINAL.has(dep.status)) blocking.push(`${depId} (${dep.status})`);
|
|
51
|
+
}
|
|
52
|
+
if (blocking.length) {
|
|
53
|
+
blockedLines.push(`- ${task.id} waits on ${blocking.join(", ")} — ${task.title || task.id}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
const completedRuns = (snapshot.records.run || [])
|
|
33
58
|
.filter((r) => r.status === "completed")
|
|
34
59
|
.slice(-5)
|
|
@@ -76,6 +101,10 @@ Progressive disclosure: read this first; go deeper only if the briefing lacks wh
|
|
|
76
101
|
|
|
77
102
|
${activeWork.length ? activeWork.join("\n") : "- No active canonical work."}
|
|
78
103
|
|
|
104
|
+
## Blocked by dependency
|
|
105
|
+
|
|
106
|
+
${blockedLines.length ? blockedLines.join("\n") : "- None. All active Tasks have their `depends_on` targets terminal (or none declared)."}
|
|
107
|
+
|
|
79
108
|
## Recent
|
|
80
109
|
|
|
81
110
|
${recentLines.length ? recentLines.join("\n") : "- No completed Runs."}
|
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);
|
|
@@ -57,7 +57,7 @@ function sprintBody(title, request, created, taskId) {
|
|
|
57
57
|
function buildTaskBody(request, classification, approvalId, fingerprint, risk, previewSection, links = {}) {
|
|
58
58
|
const featureSection = links.feature ? `\n## Feature\n\n- ${links.feature}\n` : "";
|
|
59
59
|
const sprintSection = links.sprint ? `\n## Sprint\n\n- ${links.sprint}\n` : "";
|
|
60
|
-
return `# ${titleFor(request)}\n\n## Request\n\n${request}\n\n##
|
|
60
|
+
return `# ${titleFor(request)}\n\n## Request\n\n${request}\n\n## Done when\n\n- [ ] _State the smallest observable delivery contract before execution._\n\n## Validation Scope\n\n- Required: validate the stated Done when contract and active Guardrails proportionately to risk.\n- Non-blocking: tests, reviews, or environments not explicitly required by the owner, Done when contract, or an active Guardrail. Record a material coverage gap as a follow-up only when it is outside the approved contract; do not fail the Task solely because that optional check was not run.\n\n## Completion\n\n- _Fill this once, after the delivery contract is satisfied._\n${previewSection}${featureSection}${sprintSection}\n## Classification\n\n- Type: ${classification.type}\n- Reason: ${classification.reason}\n- Risk: ${risk.level}\n\n## Approval\n\n- Explicit approval token: ${approvalId}\n- Context fingerprint: ${fingerprint}`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function stateFingerprint(repository) {
|
|
@@ -595,9 +595,9 @@ function planArtifactBody(kind, title, created) {
|
|
|
595
595
|
"",
|
|
596
596
|
title,
|
|
597
597
|
"",
|
|
598
|
-
"##
|
|
598
|
+
"## Done when",
|
|
599
599
|
"",
|
|
600
|
-
|
|
600
|
+
"- [ ] _State the smallest observable delivery contract before execution._",
|
|
601
601
|
"",
|
|
602
602
|
"## Source",
|
|
603
603
|
"",
|
|
@@ -792,7 +792,7 @@ function amendPlanArtifact(projectRoot, kind, id, options = {}) {
|
|
|
792
792
|
let body = artifact.body;
|
|
793
793
|
if (options.title !== undefined) body = replaceTitle(body, options.title);
|
|
794
794
|
if (options.request !== undefined) body = replaceSection(body, "Request", options.request);
|
|
795
|
-
if (options.acceptance !== undefined) body =
|
|
795
|
+
if (options.acceptance !== undefined) body = replaceFirstKnownSection(body, ["Done when", "Acceptance Criteria"], options.acceptance.map((item) => `- [ ] ${normalizeAmendText(item, "Delivery criterion")}`).join("\n"));
|
|
796
796
|
if (options.purpose !== undefined) body = replaceFirstKnownSection(body, ["Purpose", "Motivation"], options.purpose);
|
|
797
797
|
if (options.exitCriteria !== undefined) body = replaceFirstKnownSection(body, ["Exit Criteria", "Exit criteria"], options.exitCriteria.map((item) => `- [ ] ${normalizeAmendText(item, "Exit criterion")}`).join("\n"));
|
|
798
798
|
if (options.timebox !== undefined) body = replaceSection(body, "Timebox", options.timebox);
|
|
@@ -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,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Daily work is intentionally Markdown-first. This module keeps the
|
|
4
|
+
// enforcement at the boundary: project policy is pinned when ScrumRun is
|
|
5
|
+
// initialized/updated and `doctor --strict` / release audits detect drift.
|
|
6
|
+
|
|
7
|
+
const crypto = require("node:crypto");
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
const path = require("node:path");
|
|
10
|
+
|
|
11
|
+
function sha256(value) {
|
|
12
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readRegular(file) {
|
|
16
|
+
if (!fs.existsSync(file) || !fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink()) return null;
|
|
17
|
+
return fs.readFileSync(file, "utf8");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function expectedCoreFingerprint(packageRoot = path.resolve(__dirname, "../..")) {
|
|
21
|
+
const core = readRegular(path.join(packageRoot, "CORE.md"));
|
|
22
|
+
return core === null ? null : sha256(core);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readPolicyIntegrity(scrumDir) {
|
|
26
|
+
const marker = path.join(scrumDir, "method.json");
|
|
27
|
+
const raw = readRegular(marker);
|
|
28
|
+
if (raw === null) return { marker: null, integrity: null, error: "method.json is missing or unsafe" };
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
return { marker: parsed, integrity: parsed.integrity || null, error: null };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { marker: null, integrity: null, error: error.message };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sealPolicyIntegrity(scrumDir, { includeGuardrails = false, coreFingerprint = expectedCoreFingerprint() } = {}) {
|
|
38
|
+
const state = readPolicyIntegrity(scrumDir);
|
|
39
|
+
if (state.error) throw new Error(`Cannot seal ScrumRun policy: ${state.error}`);
|
|
40
|
+
const core = readRegular(path.join(scrumDir, "core.md"));
|
|
41
|
+
if (core === null) throw new Error("Cannot seal ScrumRun policy: core.md is missing or unsafe.");
|
|
42
|
+
const guardrails = readRegular(path.join(scrumDir, "guardrails.md"));
|
|
43
|
+
if (guardrails === null) throw new Error("Cannot seal ScrumRun policy: guardrails.md is missing or unsafe.");
|
|
44
|
+
const next = {
|
|
45
|
+
...state.marker,
|
|
46
|
+
workflow: { ...(state.marker.workflow || {}), daily: "markdown-first" },
|
|
47
|
+
integrity: {
|
|
48
|
+
...(state.integrity || {}),
|
|
49
|
+
schema: 1,
|
|
50
|
+
core_sha256: sha256(core),
|
|
51
|
+
core_package_sha256: coreFingerprint || sha256(core),
|
|
52
|
+
...(includeGuardrails || !state.integrity || !state.integrity.guardrails_sha256
|
|
53
|
+
? { guardrails_sha256: sha256(guardrails) }
|
|
54
|
+
: {})
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
return `${JSON.stringify(next, null, 2)}\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function auditPolicyIntegrity(scrumDir, { coreFingerprint = expectedCoreFingerprint() } = {}) {
|
|
61
|
+
const state = readPolicyIntegrity(scrumDir);
|
|
62
|
+
if (state.error) return [{ severity: "critical", code: "POLICY_MARKER", message: state.error }];
|
|
63
|
+
const integrity = state.integrity;
|
|
64
|
+
if (!integrity || integrity.schema !== 1) {
|
|
65
|
+
return [{ severity: "warning", code: "POLICY_INTEGRITY_UNSEALED", message: "Policy fingerprints are not sealed. Run `scrumrun update --project --seal-policy` after owner review." }];
|
|
66
|
+
}
|
|
67
|
+
const findings = [];
|
|
68
|
+
const core = readRegular(path.join(scrumDir, "core.md"));
|
|
69
|
+
const guardrails = readRegular(path.join(scrumDir, "guardrails.md"));
|
|
70
|
+
if (core === null) findings.push({ severity: "critical", code: "CORE_UNSAFE", message: "core.md is missing or unsafe." });
|
|
71
|
+
else {
|
|
72
|
+
const actual = sha256(core);
|
|
73
|
+
if (actual !== integrity.core_sha256) findings.push({ severity: "high", code: "CORE_TAMPERED", message: "core.md differs from the owner-sealed policy. Restore it with `scrumrun update --project`." });
|
|
74
|
+
if (coreFingerprint && actual !== coreFingerprint) findings.push({ severity: "high", code: "CORE_PACKAGE_DRIFT", message: "core.md differs from the installed ScrumRun Core. Review then run `scrumrun update --project`." });
|
|
75
|
+
}
|
|
76
|
+
if (guardrails === null) findings.push({ severity: "critical", code: "GUARDRAILS_UNSAFE", message: "guardrails.md is missing or unsafe." });
|
|
77
|
+
else if (sha256(guardrails) !== integrity.guardrails_sha256) {
|
|
78
|
+
findings.push({ severity: "high", code: "GUARDRAILS_TAMPERED", message: "guardrails.md changed after owner sealing. Review it, then explicitly run `scrumrun update --project --seal-policy`." });
|
|
79
|
+
}
|
|
80
|
+
return findings;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { auditPolicyIntegrity, expectedCoreFingerprint, sealPolicyIntegrity, sha256 };
|
|
@@ -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/artifacts.js
CHANGED
|
@@ -17,6 +17,7 @@ function sha256(content) {
|
|
|
17
17
|
|
|
18
18
|
function scalar(value) {
|
|
19
19
|
if (value === null || value === undefined) return "null";
|
|
20
|
+
if (Array.isArray(value)) return `[${value.map((item) => scalar(item)).join(", ")}]`;
|
|
20
21
|
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
21
22
|
const text = String(value);
|
|
22
23
|
if (/^[A-Za-z0-9._/-]+$/.test(text)) return text;
|
|
@@ -36,6 +37,11 @@ function parseScalar(value) {
|
|
|
36
37
|
return text;
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
if (text.startsWith("[") && text.endsWith("]")) {
|
|
41
|
+
const inner = text.slice(1, -1).trim();
|
|
42
|
+
if (!inner) return [];
|
|
43
|
+
return inner.split(",").map((item) => parseScalar(item));
|
|
44
|
+
}
|
|
39
45
|
return text;
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -102,6 +108,9 @@ function validateArtifact(record, expectedKind = null) {
|
|
|
102
108
|
errors.push(`${record.kind}.${field} must reference ${target.prefix}-NNN`);
|
|
103
109
|
}
|
|
104
110
|
}
|
|
111
|
+
if (record.kind === "run" && (record.task === undefined || record.task === null) && (record.sprint === undefined || record.sprint === null)) {
|
|
112
|
+
errors.push("run must reference TASK-NNN or SPRINT-NNN");
|
|
113
|
+
}
|
|
105
114
|
for (const [field, constraint] of Object.entries(SCALAR_FIELDS)) {
|
|
106
115
|
if (!constraint.kinds.includes(record.kind)) continue;
|
|
107
116
|
if (constraint.required && (record[field] === undefined || record[field] === null)) {
|