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,175 @@
1
+ "use strict";
2
+
3
+ // Deliberately small YAML subset for Guardrail declarations. It accepts the
4
+ // mapping + string-list shape documented in SPEC.md without pulling a parser
5
+ // into every client installation. JSON is valid YAML, but this keeps authored
6
+ // Markdown pleasant to read and rejects unsupported YAML instead of guessing.
7
+
8
+ const ACTIONS = new Set(["block", "warn"]);
9
+ const SEVERITIES = new Set(["low", "medium", "high", "critical"]);
10
+
11
+ function unquote(value) {
12
+ const text = String(value || "").trim();
13
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
14
+ try { return text.startsWith('"') ? JSON.parse(text) : text.slice(1, -1).replace(/''/g, "'"); } catch { return text; }
15
+ }
16
+ return text;
17
+ }
18
+
19
+ function yamlError(id, message) {
20
+ return `SR-E-153 ${id}: ${message}`;
21
+ }
22
+
23
+ function parseInlineList(value) {
24
+ const text = String(value || "").trim();
25
+ if (!text.startsWith("[") || !text.endsWith("]")) return null;
26
+ const inner = text.slice(1, -1).trim();
27
+ return inner ? inner.split(",").map((item) => unquote(item)).filter(Boolean) : [];
28
+ }
29
+
30
+ function parseEnforcementYaml(source, id = "GR-???") {
31
+ const lines = String(source || "").replace(/\r/g, "").split("\n");
32
+ const value = { match: { paths: [], diff: [], symbols: [] }, evidence: [] };
33
+ let section = null;
34
+ let sawMatch = false;
35
+ for (let index = 0; index < lines.length; index++) {
36
+ const raw = lines[index];
37
+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
38
+ const top = raw.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
39
+ if (top) {
40
+ const [, key, rawValue] = top;
41
+ const inline = parseInlineList(rawValue);
42
+ if (key === "match") {
43
+ if (rawValue.trim()) throw new Error(yamlError(id, "match must contain paths, diff, and/or symbols."));
44
+ section = "match";
45
+ sawMatch = true;
46
+ } else if (key === "evidence") {
47
+ if (inline) value.evidence = inline;
48
+ else if (rawValue.trim()) value.evidence = [unquote(rawValue)];
49
+ else section = "evidence";
50
+ } else if (["on_violation", "severity"].includes(key)) {
51
+ if (!rawValue.trim()) throw new Error(yamlError(id, `${key} requires a scalar value.`));
52
+ value[key] = unquote(rawValue);
53
+ section = null;
54
+ } else {
55
+ throw new Error(yamlError(id, `unsupported enforcement field: ${key}`));
56
+ }
57
+ continue;
58
+ }
59
+ const nested = raw.match(/^ ([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
60
+ if (nested && (section === "match" || /^match\./.test(section || ""))) {
61
+ const [, key, rawValue] = nested;
62
+ if (!Object.hasOwn(value.match, key)) throw new Error(yamlError(id, `match.${key} is not supported.`));
63
+ const inline = parseInlineList(rawValue);
64
+ if (inline) value.match[key] = inline;
65
+ else if (rawValue.trim()) value.match[key] = [unquote(rawValue)];
66
+ else section = `match.${key}`;
67
+ continue;
68
+ }
69
+ const list = raw.match(/^\s{2,}-\s+(.+)$/);
70
+ if (list && (section === "evidence" || /^match\.(paths|diff|symbols)$/.test(section || ""))) {
71
+ const target = section === "evidence" ? value.evidence : value.match[section.slice("match.".length)];
72
+ target.push(unquote(list[1]));
73
+ continue;
74
+ }
75
+ throw new Error(yamlError(id, `malformed YAML line ${index + 1}.`));
76
+ }
77
+ if (!sawMatch) throw new Error(yamlError(id, "enforcement requires a match mapping."));
78
+ if (!value.on_violation || !ACTIONS.has(value.on_violation)) throw new Error(yamlError(id, "on_violation must be block or warn."));
79
+ if (!value.severity) value.severity = "high";
80
+ if (!SEVERITIES.has(value.severity)) throw new Error(yamlError(id, "severity must be low, medium, high, or critical."));
81
+ if (!value.match.paths.length && !value.match.diff.length && !value.match.symbols.length) throw new Error(yamlError(id, "match requires paths, diff, or symbols."));
82
+ return value;
83
+ }
84
+
85
+ function enforcementBlock(markdown, id) {
86
+ const source = String(markdown || "");
87
+ const named = source.match(/```ya?ml\s+enforcement\s*\n([\s\S]*?)```/i);
88
+ const nested = source.match(/```ya?ml\s*\n\s*enforcement:\s*\n([\s\S]*?)```/i);
89
+ const body = named ? named[1] : nested ? nested[1].split("\n").map((line) => line.replace(/^ /, "")).join("\n") : null;
90
+ if (body === null) return { value: null, errors: [] };
91
+ try { return { value: parseEnforcementYaml(body, id), errors: [] }; }
92
+ catch (error) { return { value: null, errors: [error.message] }; }
93
+ }
94
+
95
+ function regex(value) {
96
+ const source = String(value || "").trim();
97
+ const slash = source.match(/^\/(.*)\/([gimsuy]*)$/);
98
+ try { return slash ? new RegExp(slash[1], slash[2].replace("g", "")) : new RegExp(source); }
99
+ catch { return null; }
100
+ }
101
+
102
+ function globRegex(pattern) {
103
+ let out = "^";
104
+ const source = String(pattern || "").replace(/\\/g, "/");
105
+ for (let index = 0; index < source.length; index++) {
106
+ const character = source[index];
107
+ if (character === "*") {
108
+ if (source[index + 1] === "*") {
109
+ if (source[index + 2] === "/") { out += "(?:.*/)?"; index += 2; }
110
+ else { out += ".*"; index++; }
111
+ }
112
+ else out += "[^/]*";
113
+ } else if (character === "?") out += "[^/]";
114
+ else out += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
115
+ }
116
+ return new RegExp(`${out}$`);
117
+ }
118
+
119
+ function matchPath(pattern, candidate) {
120
+ const text = String(candidate || "").replace(/\\/g, "/");
121
+ const rule = String(pattern || "").trim();
122
+ if (!rule) return false;
123
+ if (rule.startsWith("regex:")) { const expression = regex(rule.slice(6)); return Boolean(expression && expression.test(text)); }
124
+ if (/^\/.+\/[gimsuy]*$/.test(rule)) { const expression = regex(rule); return Boolean(expression && expression.test(text)); }
125
+ if (rule.startsWith("ext:")) return text.endsWith(rule.slice(4));
126
+ if (rule.startsWith(".")) return text.endsWith(rule);
127
+ return globRegex(rule.replace(/^glob:/, "")).test(text);
128
+ }
129
+
130
+ function matchDiff(pattern, diff) {
131
+ const rule = String(pattern || "").trim();
132
+ const expression = regex(rule.startsWith("regex:") ? rule.slice(6) : rule);
133
+ return expression ? expression.test(String(diff || "")) : String(diff || "").includes(rule);
134
+ }
135
+
136
+ function symbols(changeSet) {
137
+ return (changeSet.symbols || []).map((item) => typeof item === "string" ? item : item && (item.name || item.symbol)).filter(Boolean);
138
+ }
139
+
140
+ function effectiveAction(rule, config = {}) {
141
+ // Configuration may only make a rule stricter; explicit policy always wins.
142
+ return rule.on_violation === "block" || config.on_violation === "block" ? "block" : "warn";
143
+ }
144
+
145
+ function evaluate(rules, changeSet = {}, options = {}) {
146
+ const paths = [...new Set([...(changeSet.paths || []), ...(changeSet.files || [])])];
147
+ const diff = changeSet.diff || "";
148
+ const knownSymbols = symbols(changeSet);
149
+ const result = { passed: [], blocked: [], warnings: [] };
150
+ for (const rule of rules || []) {
151
+ if (!rule || !rule.enforcement_rule) continue;
152
+ const matcher = rule.enforcement_rule.match;
153
+ const matchedPaths = paths.filter((item) => matcher.paths.some((pattern) => matchPath(pattern, item)));
154
+ const matchedDiff = matcher.diff.filter((pattern) => matchDiff(pattern, diff));
155
+ const matchedSymbols = matcher.symbols.filter((pattern) => knownSymbols.some((item) => matchPath(pattern, item) || item === pattern));
156
+ const evidence = [...matchedPaths, ...matchedDiff, ...matchedSymbols];
157
+ if (!evidence.length) {
158
+ result.passed.push({ guardrail: rule.id, severity: rule.enforcement_rule.severity, evidence: rule.enforcement_rule.evidence || [] });
159
+ continue;
160
+ }
161
+ const finding = {
162
+ guardrail: rule.id,
163
+ severity: rule.enforcement_rule.severity,
164
+ code: "DECLARATIVE_GUARDRAIL_MATCH",
165
+ message: `${rule.id} matched declarative enforcement.`,
166
+ evidence: [...new Set(evidence)],
167
+ declared_evidence: rule.enforcement_rule.evidence || []
168
+ };
169
+ if (effectiveAction(rule.enforcement_rule, options.config) === "block") result.blocked.push(finding);
170
+ else result.warnings.push(finding);
171
+ }
172
+ return result;
173
+ }
174
+
175
+ module.exports = { ACTIONS, SEVERITIES, enforcementBlock, evaluate, parseEnforcementYaml };
@@ -0,0 +1,289 @@
1
+ "use strict";
2
+
3
+ // Deterministic, opt-in memory compaction. This deliberately has no model,
4
+ // embedding, or network dependency: the only inputs are canonical Markdown
5
+ // records and the project's explicitly configured threshold.
6
+ const fs = require("node:fs");
7
+ const path = require("node:path");
8
+ const {
9
+ ARTIFACT_TYPES,
10
+ ArtifactRepository,
11
+ METHOD_VERSION,
12
+ atomicWrite,
13
+ parseArtifact,
14
+ sha256,
15
+ validateArtifact,
16
+ withArtifactLock
17
+ } = require("../v2/artifacts");
18
+ const { extractRelations, sectionItems } = require("./markdown");
19
+ const { assertNoSecret } = require("../security/secrets");
20
+ const { assertCanonicalWrite } = require("../runtime/mutation-gateway");
21
+
22
+ const DEFAULT_THRESHOLD = 0.6;
23
+ const DEFAULT_MIN_MEMBERS = 3;
24
+ const RECORD_PREFIX = "<!-- scrumrun-compaction-v1: ";
25
+ const RECORD_SUFFIX = " -->";
26
+
27
+ function today() {
28
+ return new Date().toISOString().slice(0, 10);
29
+ }
30
+
31
+ function configNumber(content, key, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
32
+ const match = String(content || "").match(new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*([^\\r\\n#]+)`, "m"));
33
+ if (!match) return fallback;
34
+ const value = Number(match[1].trim());
35
+ return Number.isFinite(value) && value >= min && value <= max ? value : fallback;
36
+ }
37
+
38
+ function compactionConfig(projectRoot) {
39
+ const file = path.join(projectRoot, ".scrumrun", "config.md");
40
+ const content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
41
+ return {
42
+ threshold: configNumber(content, "memory.compaction.threshold", DEFAULT_THRESHOLD, { min: 0, max: 1 }),
43
+ minMembers: Math.floor(configNumber(content, "memory.compaction.min_members", DEFAULT_MIN_MEMBERS, { min: 2, max: 100 }))
44
+ };
45
+ }
46
+
47
+ function titleOf(body, fallback) {
48
+ return ((String(body || "").match(/^#\s+([^\r\n]+)/m) || [])[1] || fallback).trim();
49
+ }
50
+
51
+ function featureSet(artifact) {
52
+ const features = new Set();
53
+ if (artifact.record.subject_id) features.add(`subject:${String(artifact.record.subject_id).toLowerCase()}`);
54
+ if (artifact.record.subject_type) features.add(`subject-type:${String(artifact.record.subject_type).toLowerCase()}`);
55
+ for (const relation of extractRelations(artifact.body)) {
56
+ features.add(`relation:${relation.relation.toLowerCase()}:${String(relation.target).toLowerCase()}`);
57
+ // Keep used_by separately visible in preview/evidence, while also being a relation.
58
+ if (relation.relation.toLowerCase() === "used_by") features.add(`used_by:${String(relation.target).toLowerCase()}`);
59
+ }
60
+ const tagText = [
61
+ String(artifact.body || "").match(/(?:^|\s)#([a-z][a-z0-9_-]{1,})\b/gi) || [],
62
+ ...sectionItems(artifact.body, ["tags", "topics"])
63
+ ].join(" ");
64
+ for (const token of tagText.match(/#?([a-z][a-z0-9_-]{1,})/gi) || []) {
65
+ const normalized = token.replace(/^#/, "").toLowerCase();
66
+ if (normalized) features.add(`tag:${normalized}`);
67
+ }
68
+ return features;
69
+ }
70
+
71
+ function overlap(left, right) {
72
+ const union = new Set([...left, ...right]);
73
+ if (!union.size) return 0;
74
+ let common = 0;
75
+ for (const value of left) if (right.has(value)) common++;
76
+ return common / union.size;
77
+ }
78
+
79
+ function activeMemory(repository) {
80
+ return ["knowledge", "insight"].flatMap((kind) => repository.list(kind))
81
+ .filter((artifact) => !artifact.errors.length)
82
+ .filter((artifact) => (artifact.record.kind === "knowledge" && artifact.record.status === "approved")
83
+ || (artifact.record.kind === "insight" && artifact.record.status === "confirmed"))
84
+ .filter((artifact) => !artifact.record.superseded_by)
85
+ .map((artifact) => ({ ...artifact, title: titleOf(artifact.body, artifact.record.id), features: featureSet(artifact) }))
86
+ .sort((left, right) => left.record.id.localeCompare(right.record.id));
87
+ }
88
+
89
+ function labelFor(members) {
90
+ const counts = new Map();
91
+ for (const member of members) {
92
+ for (const feature of member.features) counts.set(feature, (counts.get(feature) || 0) + 1);
93
+ }
94
+ const shared = [...counts.entries()]
95
+ .filter(([, count]) => count === members.length)
96
+ .map(([feature]) => feature)
97
+ .sort();
98
+ return shared[0] || `memory-${members.map((member) => member.record.id).join("-").toLowerCase()}`;
99
+ }
100
+
101
+ function compactKey(members) {
102
+ return sha256(members.map((member) => member.record.id).sort().join("\n")).slice(0, 16);
103
+ }
104
+
105
+ function proposedClusters(projectRoot) {
106
+ const repository = new ArtifactRepository(path.join(projectRoot, ".scrumrun"));
107
+ const config = compactionConfig(projectRoot);
108
+ const members = activeMemory(repository);
109
+ const parent = members.map((_, index) => index);
110
+ const find = (index) => parent[index] === index ? index : (parent[index] = find(parent[index]));
111
+ const join = (left, right) => {
112
+ const a = find(left); const b = find(right);
113
+ if (a !== b) parent[b] = a;
114
+ };
115
+ const links = [];
116
+ for (let left = 0; left < members.length; left++) {
117
+ for (let right = left + 1; right < members.length; right++) {
118
+ const score = overlap(members[left].features, members[right].features);
119
+ if (score >= config.threshold) {
120
+ join(left, right);
121
+ links.push({ left: members[left].record.id, right: members[right].record.id, score });
122
+ }
123
+ }
124
+ }
125
+ const groups = new Map();
126
+ for (let index = 0; index < members.length; index++) {
127
+ const root = find(index);
128
+ if (!groups.has(root)) groups.set(root, []);
129
+ groups.get(root).push(members[index]);
130
+ }
131
+ const clusters = [...groups.values()]
132
+ .filter((group) => group.length >= config.minMembers)
133
+ .map((group) => {
134
+ const label = labelFor(group);
135
+ return {
136
+ key: compactKey(group),
137
+ label,
138
+ members: group.map((member) => ({ id: member.record.id, kind: member.record.kind, title: member.title })),
139
+ evidence: group.map((member) => member.record.id),
140
+ links: links.filter((link) => group.some((member) => member.record.id === link.left) && group.some((member) => member.record.id === link.right))
141
+ };
142
+ })
143
+ .sort((left, right) => left.key.localeCompare(right.key));
144
+ return { config, clusters };
145
+ }
146
+
147
+ function updateFrontmatter(content, updates) {
148
+ const match = String(content).match(/^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/);
149
+ if (!match) throw new Error("Memory artifact has malformed frontmatter.");
150
+ let frontmatter = match[1];
151
+ for (const [field, value] of Object.entries(updates)) {
152
+ const line = new RegExp(`^${field}:\\s*.*$`, "m");
153
+ const rendered = value === null ? "null" : String(value);
154
+ frontmatter = line.test(frontmatter) ? frontmatter.replace(line, `${field}: ${rendered}`) : `${frontmatter}\n${field}: ${rendered}`;
155
+ }
156
+ return `---\n${frontmatter}\n---${match[2]}`;
157
+ }
158
+
159
+ function nextDossierId(repository) {
160
+ const largest = repository.list("dossier").reduce((max, artifact) => Math.max(max, Number((artifact.record.id.match(/-(\d+)$/) || [])[1]) || 0), 0);
161
+ return `DOS-${String(largest + 1).padStart(3, "0")}`;
162
+ }
163
+
164
+ function recordComment(record) {
165
+ return `${RECORD_PREFIX}${Buffer.from(JSON.stringify(record)).toString("base64")}${RECORD_SUFFIX}`;
166
+ }
167
+
168
+ function readRecord(body) {
169
+ const match = String(body || "").match(/<!-- scrumrun-compaction-v1: ([A-Za-z0-9+/=]+) -->/);
170
+ if (!match) throw new Error("Dossier does not contain a reversible compaction record.");
171
+ try {
172
+ const record = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
173
+ if (record.version !== 1 || !Array.isArray(record.sources)) throw new Error("invalid record");
174
+ return record;
175
+ } catch {
176
+ throw new Error("Dossier compaction record is malformed.");
177
+ }
178
+ }
179
+
180
+ function dossierBody(cluster, record) {
181
+ const summaries = cluster.members.map((member) => `- ${member.id}: ${member.title}`).join("\n");
182
+ return [
183
+ `# Compacted memory — ${cluster.label}`,
184
+ "",
185
+ "## Scope",
186
+ "",
187
+ `Deterministic compaction of ${cluster.members.length} related confirmed memory records around \`${cluster.label}\`.`,
188
+ "",
189
+ "## Sources",
190
+ "",
191
+ summaries,
192
+ "",
193
+ "## Evidence",
194
+ "",
195
+ cluster.evidence.map((id) => `- ${id}`).join("\n"),
196
+ "",
197
+ "## Relations",
198
+ "",
199
+ cluster.evidence.map((id) => `- compacts: ${id}`).join("\n"),
200
+ "",
201
+ "## Compaction",
202
+ "",
203
+ `- Key: ${cluster.key}`,
204
+ `- Threshold: ${record.threshold}`,
205
+ `- Applied: ${record.applied_at}`,
206
+ "",
207
+ recordComment(record)
208
+ ].join("\n");
209
+ }
210
+
211
+ function applyCompaction(projectRoot, { approved = false } = {}) {
212
+ if (!approved) throw new Error("Memory compaction requires explicit approval: pass --approve with --apply.");
213
+ const preview = proposedClusters(projectRoot);
214
+ if (!preview.clusters.length) return { ...preview, applied: [] };
215
+ const scrumDir = path.join(projectRoot, ".scrumrun");
216
+ return withArtifactLock(scrumDir, "memory-compaction", () => {
217
+ const repository = new ArtifactRepository(scrumDir);
218
+ const applied = [];
219
+ for (const cluster of preview.clusters) {
220
+ const sources = cluster.members.map(({ id, kind }) => {
221
+ const artifact = repository.read(kind, id);
222
+ if (!artifact || artifact.errors.length || artifact.record.superseded_by) throw new Error(`Compaction source changed: ${id}`);
223
+ return artifact;
224
+ });
225
+ const date = today();
226
+ const dossierId = nextDossierId(repository);
227
+ const snapshots = sources.map((source) => ({
228
+ id: source.record.id,
229
+ kind: source.record.kind,
230
+ file: path.relative(scrumDir, source.file).split(path.sep).join("/"),
231
+ original: Buffer.from(fs.readFileSync(source.file, "utf8")).toString("base64")
232
+ }));
233
+ const record = { version: 1, key: cluster.key, threshold: preview.config.threshold, applied_at: date, sources: snapshots };
234
+ const body = dossierBody(cluster, record);
235
+ const dossier = { id: dossierId, kind: "dossier", status: "active", created: date, updated: date, method: METHOD_VERSION, compaction_key: cluster.key };
236
+ assertNoSecret([body, ...snapshots.map((source) => source.original)]);
237
+ assertCanonicalWrite(projectRoot, "compact-memory", [body, ...sources.map((source) => fs.readFileSync(source.file, "utf8"))]);
238
+ const changed = [];
239
+ try {
240
+ repository.write(dossier, body);
241
+ for (const source of sources) {
242
+ const current = fs.readFileSync(source.file, "utf8");
243
+ const next = updateFrontmatter(current, { superseded_by: dossierId, updated: date });
244
+ const parsed = parseArtifact(next);
245
+ const errors = [...parsed.errors, ...validateArtifact(parsed.record, source.record.kind)];
246
+ if (errors.length) throw new Error(`Compaction source validation failed for ${source.record.id}: ${errors.join("; ")}`);
247
+ atomicWrite(source.file, next);
248
+ changed.push({ file: source.file, content: current, applied_sha256: sha256(next) });
249
+ }
250
+ // Store the expected post-apply bytes only after all source writes succeeded.
251
+ record.sources.forEach((source, index) => { source.applied_sha256 = changed[index].applied_sha256; });
252
+ repository.write(dossier, dossierBody(cluster, record), { overwrite: true });
253
+ applied.push({ dossier: dossierId, sources: sources.map((source) => source.record.id), key: cluster.key });
254
+ } catch (error) {
255
+ for (const source of changed.reverse()) atomicWrite(source.file, source.content);
256
+ const file = repository.pathFor(dossier);
257
+ if (fs.existsSync(file)) fs.rmSync(file, { force: true });
258
+ throw error;
259
+ }
260
+ }
261
+ return { ...preview, applied };
262
+ });
263
+ }
264
+
265
+ function rollbackCompaction(projectRoot, dossierId) {
266
+ const scrumDir = path.join(projectRoot, ".scrumrun");
267
+ return withArtifactLock(scrumDir, "memory-compaction", () => {
268
+ const repository = new ArtifactRepository(scrumDir);
269
+ const dossier = repository.read("dossier", dossierId);
270
+ if (!dossier || dossier.errors.length) throw new Error(`Invalid compaction Dossier: ${dossierId}`);
271
+ if (dossier.record.status !== "active") throw new Error(`Compaction Dossier is not active: ${dossierId}`);
272
+ const record = readRecord(dossier.body);
273
+ const sources = record.sources.map((snapshot) => {
274
+ const artifact = repository.read(snapshot.kind, snapshot.id);
275
+ if (!artifact || artifact.errors.length) throw new Error(`Compaction source is missing or invalid: ${snapshot.id}`);
276
+ const content = fs.readFileSync(artifact.file, "utf8");
277
+ if (sha256(content) !== snapshot.applied_sha256) throw new Error(`Refusing rollback: ${snapshot.id} changed after compaction.`);
278
+ return { ...snapshot, file: artifact.file, content };
279
+ });
280
+ const date = today();
281
+ assertCanonicalWrite(projectRoot, "rollback-memory-compaction", [fs.readFileSync(dossier.file, "utf8"), ...sources.map((source) => source.content)]);
282
+ for (const source of sources) atomicWrite(source.file, Buffer.from(source.original, "base64").toString("utf8"));
283
+ const archived = updateFrontmatter(fs.readFileSync(dossier.file, "utf8"), { status: "archived", updated: date });
284
+ atomicWrite(dossier.file, `${archived.trimEnd()}\n\n## Compaction rollback\n\n- Restored: ${date}\n`);
285
+ return { dossier: dossierId, restored: sources.map((source) => source.id) };
286
+ });
287
+ }
288
+
289
+ module.exports = { DEFAULT_MIN_MEMBERS, DEFAULT_THRESHOLD, applyCompaction, compactionConfig, proposedClusters, rollbackCompaction };
@@ -14,6 +14,10 @@ const { canonicalWatchSnapshot, fileWatchSnapshot } = require("../runtime/canoni
14
14
  const CACHE_RELATIVE = path.join(".cache", "semantic-index.sqlite");
15
15
  const INDEX_SCHEMA_VERSION = 5;
16
16
  const INACTIVE = new Set(["rejected", "invalidated", "deprecated", "archived"]);
17
+
18
+ function isSearchActive(record) {
19
+ return !INACTIVE.has(record.status) && !record.superseded_by;
20
+ }
17
21
  const SEARCH_BACKENDS = new Set(["auto", "fts5", "like"]);
18
22
  const MAX_SEARCH_TOKENS = 32;
19
23
 
@@ -407,7 +411,7 @@ function rebuildIndex(projectRoot, { searchBackend = "auto" } = {}) {
407
411
  artifact.record.last_verified_commit || null,
408
412
  artifact.record.review_trigger || null,
409
413
  normalizeSearchText(artifact.title, artifact.content),
410
- INACTIVE.has(artifact.record.status) ? 0 : 1
414
+ isSearchActive(artifact.record) ? 1 : 0
411
415
  );
412
416
  if (insertFts) insertFts.run(artifact.record.id, artifact.title, artifact.content);
413
417
  for (const edge of explicitEdges(artifact)) insertEdge.run(artifact.record.id, edge.relation, edge.target, edge.evidence, "canonical");
@@ -474,6 +478,62 @@ function rebuildIndex(projectRoot, { searchBackend = "auto" } = {}) {
474
478
  };
475
479
  }
476
480
 
481
+ function incrementalArtifact(projectRoot, changedPaths = []) {
482
+ if (!Array.isArray(changedPaths) || changedPaths.length !== 1) return null;
483
+ const relative = String(changedPaths[0]).split(path.sep).join("/").replace(/^\.scrumrun\//, "");
484
+ const kind = Object.entries(ARTIFACT_TYPES).find(([, spec]) => relative.startsWith(`${spec.directory}/`));
485
+ if (!kind || !relative.endsWith(".md")) return null;
486
+ const target = indexPath(projectRoot);
487
+ if (!fs.existsSync(target)) return null;
488
+ const scrumDir = path.join(projectRoot, ".scrumrun");
489
+ const repository = new ArtifactRepository(scrumDir);
490
+ const id = path.basename(relative, ".md");
491
+ const artifact = repository.read(kind[0], id);
492
+ if (!artifact || artifact.errors.length || /^(?:##\s+(?:Evidence|Subject|Relations)\b)/mi.test(artifact.body || "")) return null;
493
+ const content = fs.readFileSync(artifact.file, "utf8");
494
+ if (containsSecret(content)) throw new Error(`Secret-like content detected in canonical artifact: ${relative}`);
495
+ let database;
496
+ try {
497
+ database = new DatabaseSync(target);
498
+ const metadata = new Map(database.prepare("SELECT key, value FROM metadata").all().map((row) => [row.key, row.value]));
499
+ if (metadata.get("schema_version") !== String(INDEX_SCHEMA_VERSION)) return null;
500
+ const backend = metadata.get("search_backend");
501
+ const title = titleFromBody(artifact.body, artifact.record.id);
502
+ const hash = sha256(content);
503
+ database.exec("BEGIN IMMEDIATE");
504
+ try {
505
+ database.prepare("DELETE FROM edges WHERE from_id = ?").run(artifact.record.id);
506
+ database.prepare("DELETE FROM artifacts WHERE id = ?").run(artifact.record.id);
507
+ if (backend === "fts5") database.prepare("DELETE FROM artifacts_fts WHERE id = ?").run(artifact.record.id);
508
+ database.prepare("INSERT INTO artifacts(id, kind, status, title, path, hash, content, branch, valid_from, valid_until, last_verified_commit, review_trigger, search_text, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
509
+ artifact.record.id, artifact.record.kind, artifact.record.status, title, relative, hash, content,
510
+ artifact.record.branch || null, artifact.record.valid_from || null, artifact.record.valid_until || null,
511
+ artifact.record.last_verified_commit || null, artifact.record.review_trigger || null,
512
+ normalizeSearchText(title, content), isSearchActive(artifact.record) ? 1 : 0
513
+ );
514
+ if (backend === "fts5") database.prepare("INSERT INTO artifacts_fts(id, title, content) VALUES (?, ?, ?)").run(artifact.record.id, title, content);
515
+ const insertEdge = database.prepare("INSERT OR IGNORE INTO edges(from_id, relation, to_id, evidence, confidence) VALUES (?, ?, ?, ?, ?)");
516
+ for (const edge of explicitEdges({ record: artifact.record, body: artifact.body })) insertEdge.run(artifact.record.id, edge.relation, edge.target, edge.evidence, "canonical");
517
+ const rows = [
518
+ ...database.prepare("SELECT path, hash FROM artifacts").all().map((row) => `${row.path}\0${row.hash}`),
519
+ ...database.prepare("SELECT path, fingerprint FROM code_nodes WHERE kind = 'module'").all().map((row) => `${row.path}\0${row.fingerprint}`)
520
+ ];
521
+ const watch = sourceWatchSnapshot(projectRoot);
522
+ const update = database.prepare("INSERT OR REPLACE INTO metadata(key, value) VALUES (?, ?)");
523
+ update.run("source_fingerprint", sha256(rows.sort().join("\n")));
524
+ update.run("source_watch_fingerprint", watch.fingerprint);
525
+ update.run("artifact_count", String(database.prepare("SELECT COUNT(*) AS count FROM artifacts").get().count));
526
+ database.exec("COMMIT");
527
+ return { file: target, mode: "incremental-artifact", changed: relative, fingerprint: sha256(rows.sort().join("\n")), watchFingerprint: watch.fingerprint };
528
+ } catch (error) {
529
+ database.exec("ROLLBACK");
530
+ throw error;
531
+ }
532
+ } finally {
533
+ if (database) database.close();
534
+ }
535
+ }
536
+
477
537
  function runtimeSupportsFts5(database) {
478
538
  try {
479
539
  database.prepare("SELECT 1 FROM artifacts_fts LIMIT 1").get();
@@ -762,4 +822,4 @@ function mapStatus(projectRoot, { semanticStatus = null } = {}) {
762
822
  }
763
823
  }
764
824
 
765
- module.exports = { CACHE_RELATIVE, INDEX_SCHEMA_VERSION, graphIndex, indexPath, indexStatus, mapStatus, memoryFingerprint, queryIndex, rebuildIndex, sourceSnapshot, sourceWatchSnapshot, writeMap };
825
+ module.exports = { CACHE_RELATIVE, INDEX_SCHEMA_VERSION, graphIndex, incrementalArtifact, indexPath, indexStatus, mapStatus, memoryFingerprint, queryIndex, rebuildIndex, sourceSnapshot, sourceWatchSnapshot, writeMap };
@@ -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 };