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.
Files changed (44) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CORE.md +13 -5
  3. package/README.md +73 -16
  4. package/SPEC.md +38 -3
  5. package/bin/scrumrun.js +102 -13
  6. package/docs/COMMANDS.md +17 -7
  7. package/docs/ENTITY-MODEL.md +17 -14
  8. package/docs/ERROR-CODES.md +4 -0
  9. package/docs/QUICKSTART.md +21 -19
  10. package/docs/SCHEMA.md +14 -10
  11. package/docs/SEMANTIC-MEMORY.md +28 -0
  12. package/lib/actions/index.js +81 -0
  13. package/lib/commands/manifest.js +4 -3
  14. package/lib/commands/render.js +3 -2
  15. package/lib/errors.js +4 -0
  16. package/lib/git/context.js +30 -0
  17. package/lib/guardrails/changeset.js +45 -0
  18. package/lib/guardrails/evaluate.js +175 -0
  19. package/lib/memory/compaction.js +289 -0
  20. package/lib/memory/index.js +62 -2
  21. package/lib/migrate/ops.js +92 -0
  22. package/lib/migrate/run.js +108 -0
  23. package/lib/runtime/briefing.js +29 -0
  24. package/lib/runtime/context.js +3 -1
  25. package/lib/runtime/orchestrator.js +4 -4
  26. package/lib/runtime/policy-engine.js +11 -0
  27. package/lib/runtime/policy-integrity.js +83 -0
  28. package/lib/runtime/watcher.js +185 -0
  29. package/lib/v2/artifacts.js +9 -0
  30. package/lib/v2/conformance.js +45 -18
  31. package/lib/v2/paths.js +2 -1
  32. package/lib/v2/runs-jsonl.js +134 -0
  33. package/lib/v2/schema.js +7 -7
  34. package/lib/v2/task-schema.js +133 -0
  35. package/package.json +2 -2
  36. package/scripts/generate-contract-docs.js +6 -2
  37. package/templates/project/.scrumrun/config.md +9 -0
  38. package/templates/project/.scrumrun/method.json +3 -0
  39. package/templates/project/AGENTS.md +10 -4
  40. package/templates/project-lean/AGENTS.md +6 -2
  41. package/templates/shared/hooks/pre-commit +16 -0
  42. package/templates/shared/skills/scrumrun/SKILL.md +11 -7
  43. package/templates/shared/view.html +281 -0
  44. 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 };
@@ -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."}
@@ -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
- const config = safeControl("config.md", 1000);
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## Acceptance Criteria\n\n- [ ] _Define what "done" means before execution._\n\n## Validation Scope\n\n- Required: validate the stated Acceptance Criteria and active Guardrails proportionately to risk.\n- Non-blocking: tests, reviews, or environments not explicitly required by the owner, Acceptance Criteria, or an active Guardrail. Record a material coverage gap as a follow-up; do not fail the Task solely because that optional check was not run.\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}`;
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
- "## Acceptance Criteria",
598
+ "## Done when",
599
599
  "",
600
- '- [ ] _Define what "done" means before execution._',
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 = replaceSection(body, "Acceptance Criteria", options.acceptance.map((item) => `- [ ] ${normalizeAmendText(item, "Acceptance criterion")}`).join("\n"));
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 };
@@ -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)) {