scrumrun 1.5.2 → 2.0.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 +42 -0
- package/CORE.md +224 -311
- package/DECISIONS.md +95 -11
- package/LICENSE +21 -0
- package/MIGRATION-1-to-2.md +109 -0
- package/README.md +137 -78
- package/SPEC.md +248 -257
- package/bin/claude-install.js +18 -132
- package/bin/scrumrun.js +1259 -174
- package/docs/COMMANDS.md +69 -0
- package/docs/ENTITY-MODEL.md +38 -0
- package/docs/RELEASE.md +44 -0
- package/docs/SCHEMA.md +80 -0
- package/docs/SEMANTIC-MEMORY.md +68 -0
- package/docs/TROUBLESHOOTING.md +66 -0
- package/lib/code-intel/adapter.js +20 -0
- package/lib/code-intel/javascript.js +199 -0
- package/lib/code-intel/learning.js +66 -0
- package/lib/code-intel/scanner.js +126 -0
- package/lib/commands/manifest.js +109 -0
- package/lib/commands/render.js +75 -0
- package/lib/memory/index.js +600 -0
- package/lib/memory/markdown.js +51 -0
- package/lib/memory/service.js +300 -0
- package/lib/runtime/budgets.js +19 -0
- package/lib/runtime/context.js +141 -0
- package/lib/runtime/orchestrator.js +270 -0
- package/lib/runtime/request-engine.js +128 -0
- package/lib/security/secrets.js +23 -0
- package/lib/v2/artifacts.js +343 -0
- package/lib/v2/conformance.js +167 -0
- package/lib/v2/migration.js +1161 -0
- package/lib/v2/project-store.js +44 -0
- package/lib/v2/schema.js +128 -0
- package/package.json +16 -6
- package/scripts/generate-contract-docs.js +113 -0
- package/templates/project/.scrumrun/config.md +4 -7
- package/templates/project/.scrumrun/guardrails.md +23 -0
- package/templates/project/.scrumrun/map.md +4 -16
- package/templates/project/.scrumrun/memory/decisions/.gitkeep +1 -0
- package/templates/project/.scrumrun/memory/dossiers/.gitkeep +1 -0
- package/templates/project/.scrumrun/memory/insights/.gitkeep +1 -0
- package/templates/project/.scrumrun/memory/knowledge/.gitkeep +1 -0
- package/templates/project/.scrumrun/method.json +4 -0
- package/templates/project/.scrumrun/project.md +6 -12
- package/templates/project/.scrumrun/runs/.gitkeep +1 -0
- package/templates/project/.scrumrun/sprints/.gitkeep +1 -0
- package/templates/project/.scrumrun/state.md +21 -0
- package/templates/project/.scrumrun/tasks/.gitkeep +1 -0
- package/templates/project/AGENTS.md +16 -49
- package/templates/project-lean/AGENTS.md +18 -0
- package/templates/shared/skills/scrumrun/SKILL.md +201 -0
- package/templates/codex/prompts/sc-agent.md +0 -14
- package/templates/codex/prompts/sc-backlog.md +0 -13
- package/templates/codex/prompts/sc-challenge.md +0 -57
- package/templates/codex/prompts/sc-config.md +0 -18
- package/templates/codex/prompts/sc-context.md +0 -24
- package/templates/codex/prompts/sc-decisions.md +0 -8
- package/templates/codex/prompts/sc-feature.md +0 -16
- package/templates/codex/prompts/sc-fix.md +0 -21
- package/templates/codex/prompts/sc-goal.md +0 -14
- package/templates/codex/prompts/sc-golden.md +0 -14
- package/templates/codex/prompts/sc-help.md +0 -12
- package/templates/codex/prompts/sc-init.md +0 -14
- package/templates/codex/prompts/sc-intake.md +0 -22
- package/templates/codex/prompts/sc-know.md +0 -75
- package/templates/codex/prompts/sc-map.md +0 -13
- package/templates/codex/prompts/sc-review.md +0 -13
- package/templates/codex/prompts/sc-sprint.md +0 -28
- package/templates/codex/prompts/sc-study.md +0 -23
- package/templates/codex/prompts/sc-uninstall.md +0 -14
- package/templates/codex/prompts/sc-update.md +0 -8
- package/templates/codex/prompts/sc-vault.md +0 -27
- package/templates/codex/skills/scrumrun/SKILL.md +0 -412
- package/templates/opencode/commands/sc-agent.md +0 -14
- package/templates/opencode/commands/sc-backlog.md +0 -13
- package/templates/opencode/commands/sc-challenge.md +0 -57
- package/templates/opencode/commands/sc-config.md +0 -18
- package/templates/opencode/commands/sc-context.md +0 -24
- package/templates/opencode/commands/sc-decisions.md +0 -8
- package/templates/opencode/commands/sc-feature.md +0 -16
- package/templates/opencode/commands/sc-fix.md +0 -21
- package/templates/opencode/commands/sc-goal.md +0 -14
- package/templates/opencode/commands/sc-golden.md +0 -14
- package/templates/opencode/commands/sc-help.md +0 -12
- package/templates/opencode/commands/sc-init.md +0 -14
- package/templates/opencode/commands/sc-intake.md +0 -22
- package/templates/opencode/commands/sc-know.md +0 -75
- package/templates/opencode/commands/sc-map.md +0 -13
- package/templates/opencode/commands/sc-review.md +0 -13
- package/templates/opencode/commands/sc-sprint.md +0 -28
- package/templates/opencode/commands/sc-study.md +0 -23
- package/templates/opencode/commands/sc-uninstall.md +0 -14
- package/templates/opencode/commands/sc-update.md +0 -8
- package/templates/opencode/commands/sc-vault.md +0 -27
- package/templates/opencode/skills/scrumrun/SKILL.md +0 -412
- package/templates/project/.scrumrun/agents.md +0 -36
- package/templates/project/.scrumrun/backlog.md +0 -7
- package/templates/project/.scrumrun/context.md +0 -61
- package/templates/project/.scrumrun/goals/main/decisions.md +0 -9
- package/templates/project/.scrumrun/goals/main/history.md +0 -51
- package/templates/project/.scrumrun/goals/main/sprint.md +0 -54
- package/templates/project/.scrumrun/golden-rules.md +0 -9
- package/templates/project/.scrumrun/knowledge.md +0 -15
- package/templates/project/.scrumrun/runbook.md +0 -101
- package/templates/project/.scrumrun/token-policy.md +0 -43
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
6
|
+
const { ARTIFACT_TYPES, ArtifactRepository, assertNoSymlinkPath, atomicWrite, sha256 } = require("../v2/artifacts");
|
|
7
|
+
const { extractEvidence, extractRelations } = require("./markdown");
|
|
8
|
+
const { scanProject } = require("../code-intel/scanner");
|
|
9
|
+
const { DEFAULT_CONTEXT_RESULTS, DEFAULT_RELATIONS_PER_RESULT, MAX_CONTEXT_RESULTS, MAX_RELATIONS_PER_RESULT } = require("../runtime/budgets");
|
|
10
|
+
const { containsSecret } = require("../security/secrets");
|
|
11
|
+
|
|
12
|
+
const CACHE_RELATIVE = path.join(".cache", "semantic-index.sqlite");
|
|
13
|
+
const INACTIVE = new Set(["rejected", "invalidated", "deprecated", "archived"]);
|
|
14
|
+
|
|
15
|
+
function indexPath(projectRoot) {
|
|
16
|
+
return path.join(projectRoot, ".scrumrun", CACHE_RELATIVE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function titleFromBody(body, fallback) {
|
|
20
|
+
return ((String(body || "").match(/^#\s+([^\r\n]+)/m) || [])[1] || fallback).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function canonicalArtifacts(scrumDir) {
|
|
24
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
25
|
+
const artifacts = [];
|
|
26
|
+
for (const kind of Object.keys(ARTIFACT_TYPES)) {
|
|
27
|
+
for (const artifact of repository.list(kind)) {
|
|
28
|
+
if (!artifact.record || artifact.errors.length) continue;
|
|
29
|
+
const content = fs.readFileSync(artifact.file, "utf8");
|
|
30
|
+
if (containsSecret(content)) throw new Error(`Secret-like content detected in canonical artifact: ${path.relative(scrumDir, artifact.file)}`);
|
|
31
|
+
artifacts.push({
|
|
32
|
+
record: artifact.record,
|
|
33
|
+
body: artifact.body,
|
|
34
|
+
content,
|
|
35
|
+
path: path.relative(scrumDir, artifact.file).split(path.sep).join("/"),
|
|
36
|
+
title: titleFromBody(artifact.body, artifact.record.id)
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return artifacts.sort((a, b) => a.record.id.localeCompare(b.record.id));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function memoryFingerprint(scrumDir) {
|
|
44
|
+
const rows = canonicalArtifacts(scrumDir)
|
|
45
|
+
.map((artifact) => `${artifact.path}\0${sha256(artifact.content)}`);
|
|
46
|
+
return sha256(rows.join("\n"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sourceSnapshot(projectRoot) {
|
|
50
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
51
|
+
const artifacts = canonicalArtifacts(scrumDir);
|
|
52
|
+
const code = scanProject(projectRoot);
|
|
53
|
+
const artifactRows = artifacts.map((artifact) => `${artifact.path}\0${sha256(artifact.content)}`);
|
|
54
|
+
const codeRows = code.nodes
|
|
55
|
+
.filter((node) => node.kind === "module")
|
|
56
|
+
.map((node) => `${node.path}\0${node.fingerprint}`);
|
|
57
|
+
const fingerprint = sha256([...artifactRows, ...codeRows, `commit\0${code.commit || "unavailable"}`].sort().join("\n"));
|
|
58
|
+
return { artifacts, code, fingerprint };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function explicitEdges(artifact) {
|
|
62
|
+
const edges = [];
|
|
63
|
+
const fields = [
|
|
64
|
+
["feature", "belongs_to"],
|
|
65
|
+
["sprint", "included_in"],
|
|
66
|
+
["task", "executes"],
|
|
67
|
+
["subject", "about"],
|
|
68
|
+
["subject_id", "about"],
|
|
69
|
+
["source_id", "evidenced_by"]
|
|
70
|
+
];
|
|
71
|
+
for (const [field, relation] of fields) {
|
|
72
|
+
const target = artifact.record[field];
|
|
73
|
+
if (typeof target === "string" && target.trim()) edges.push({ relation, target: target.trim(), evidence: `frontmatter:${field}` });
|
|
74
|
+
}
|
|
75
|
+
for (const item of extractRelations(artifact.body)) {
|
|
76
|
+
edges.push({ relation: item.relation, target: item.target, evidence: "body:Relations" });
|
|
77
|
+
}
|
|
78
|
+
for (const target of extractEvidence(artifact.body)) {
|
|
79
|
+
edges.push({ relation: "evidenced_by", target, evidence: "body:Evidence" });
|
|
80
|
+
}
|
|
81
|
+
const unique = new Map();
|
|
82
|
+
for (const edge of edges) unique.set(`${edge.relation}\0${edge.target}\0${edge.evidence}`, edge);
|
|
83
|
+
return [...unique.values()];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createSchema(database) {
|
|
87
|
+
database.exec(`
|
|
88
|
+
PRAGMA journal_mode = DELETE;
|
|
89
|
+
PRAGMA synchronous = FULL;
|
|
90
|
+
CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
91
|
+
CREATE TABLE artifacts (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
kind TEXT NOT NULL,
|
|
94
|
+
status TEXT NOT NULL,
|
|
95
|
+
title TEXT NOT NULL,
|
|
96
|
+
path TEXT NOT NULL UNIQUE,
|
|
97
|
+
hash TEXT NOT NULL,
|
|
98
|
+
content TEXT NOT NULL,
|
|
99
|
+
valid_from TEXT,
|
|
100
|
+
valid_until TEXT,
|
|
101
|
+
last_verified_commit TEXT,
|
|
102
|
+
review_trigger TEXT,
|
|
103
|
+
active INTEGER NOT NULL CHECK (active IN (0, 1))
|
|
104
|
+
);
|
|
105
|
+
CREATE TABLE edges (
|
|
106
|
+
from_id TEXT NOT NULL,
|
|
107
|
+
relation TEXT NOT NULL,
|
|
108
|
+
to_id TEXT NOT NULL,
|
|
109
|
+
evidence TEXT NOT NULL,
|
|
110
|
+
confidence TEXT NOT NULL,
|
|
111
|
+
PRIMARY KEY (from_id, relation, to_id, evidence)
|
|
112
|
+
);
|
|
113
|
+
CREATE TABLE code_nodes (
|
|
114
|
+
id TEXT PRIMARY KEY,
|
|
115
|
+
kind TEXT NOT NULL,
|
|
116
|
+
name TEXT NOT NULL,
|
|
117
|
+
qualified_name TEXT NOT NULL,
|
|
118
|
+
path TEXT NOT NULL,
|
|
119
|
+
line INTEGER NOT NULL,
|
|
120
|
+
fingerprint TEXT NOT NULL,
|
|
121
|
+
commit_hash TEXT,
|
|
122
|
+
status TEXT NOT NULL CHECK (status IN ('current', 'orphaned')),
|
|
123
|
+
language TEXT NOT NULL,
|
|
124
|
+
exported INTEGER NOT NULL CHECK (exported IN (0, 1)),
|
|
125
|
+
remapped_from TEXT
|
|
126
|
+
);
|
|
127
|
+
CREATE TABLE evidence_snapshots (
|
|
128
|
+
artifact_id TEXT NOT NULL,
|
|
129
|
+
reference TEXT NOT NULL,
|
|
130
|
+
fingerprint TEXT NOT NULL,
|
|
131
|
+
PRIMARY KEY (artifact_id, reference)
|
|
132
|
+
);
|
|
133
|
+
CREATE TABLE subject_snapshots (
|
|
134
|
+
artifact_id TEXT PRIMARY KEY,
|
|
135
|
+
subject_id TEXT NOT NULL,
|
|
136
|
+
symbol_id TEXT,
|
|
137
|
+
fingerprint TEXT NOT NULL
|
|
138
|
+
);
|
|
139
|
+
CREATE TABLE invalidations (
|
|
140
|
+
artifact_id TEXT NOT NULL,
|
|
141
|
+
reason TEXT NOT NULL,
|
|
142
|
+
reference TEXT NOT NULL,
|
|
143
|
+
artifact_hash TEXT NOT NULL,
|
|
144
|
+
PRIMARY KEY (artifact_id, reason, reference)
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX edges_from ON edges(from_id);
|
|
147
|
+
CREATE INDEX edges_to ON edges(to_id);
|
|
148
|
+
CREATE INDEX artifacts_status ON artifacts(status);
|
|
149
|
+
CREATE VIRTUAL TABLE artifacts_fts USING fts5(id UNINDEXED, title, content, tokenize='unicode61');
|
|
150
|
+
CREATE VIRTUAL TABLE code_fts USING fts5(id UNINDEXED, name, qualified_name, path, tokenize='unicode61');
|
|
151
|
+
`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function tableExists(database, name) {
|
|
155
|
+
return Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE name = ?").get(name));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function priorIndex(file) {
|
|
159
|
+
if (!fs.existsSync(file)) return { nodes: [], edges: [], evidence: [], subjects: [], invalidations: [] };
|
|
160
|
+
let database;
|
|
161
|
+
try {
|
|
162
|
+
database = new DatabaseSync(file, { readOnly: true });
|
|
163
|
+
if (!tableExists(database, "code_nodes")) return { nodes: [], edges: [], evidence: [], subjects: [], invalidations: [] };
|
|
164
|
+
return {
|
|
165
|
+
nodes: database.prepare("SELECT * FROM code_nodes").all(),
|
|
166
|
+
edges: database.prepare("SELECT e.* FROM edges e JOIN code_nodes c ON c.id = e.from_id").all(),
|
|
167
|
+
evidence: tableExists(database, "evidence_snapshots") ? database.prepare("SELECT * FROM evidence_snapshots").all() : [],
|
|
168
|
+
subjects: tableExists(database, "subject_snapshots") ? database.prepare("SELECT * FROM subject_snapshots").all() : [],
|
|
169
|
+
invalidations: tableExists(database, "invalidations") ? database.prepare("SELECT * FROM invalidations").all() : []
|
|
170
|
+
};
|
|
171
|
+
} catch {
|
|
172
|
+
return { nodes: [], edges: [], evidence: [], subjects: [], invalidations: [] };
|
|
173
|
+
} finally {
|
|
174
|
+
if (database) database.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function remapCode(currentNodes, prior) {
|
|
179
|
+
const previous = prior.nodes.filter((node) => node.status === "current");
|
|
180
|
+
const currentIds = new Set(currentNodes.map((node) => node.id));
|
|
181
|
+
const used = new Set();
|
|
182
|
+
const nodes = currentNodes.map((node) => {
|
|
183
|
+
if (currentIds.has(node.id) && previous.some((old) => old.id === node.id)) {
|
|
184
|
+
used.add(node.id);
|
|
185
|
+
return { ...node, status: "current", remappedFrom: null };
|
|
186
|
+
}
|
|
187
|
+
const candidates = previous.filter((old) => !used.has(old.id) && old.kind === node.kind && old.fingerprint === node.fingerprint);
|
|
188
|
+
const match = candidates.length === 1 ? candidates[0] : null;
|
|
189
|
+
if (match) used.add(match.id);
|
|
190
|
+
return { ...node, status: "current", remappedFrom: match ? match.id : null };
|
|
191
|
+
});
|
|
192
|
+
const newlyOrphaned = previous
|
|
193
|
+
.filter((node) => !currentIds.has(node.id) && !used.has(node.id))
|
|
194
|
+
.map((node) => ({
|
|
195
|
+
id: node.id,
|
|
196
|
+
name: node.name,
|
|
197
|
+
qualifiedName: node.qualified_name,
|
|
198
|
+
kind: node.kind,
|
|
199
|
+
path: node.path,
|
|
200
|
+
line: node.line,
|
|
201
|
+
fingerprint: node.fingerprint,
|
|
202
|
+
commit: node.commit_hash,
|
|
203
|
+
status: "orphaned",
|
|
204
|
+
language: node.language,
|
|
205
|
+
exported: Boolean(node.exported),
|
|
206
|
+
remappedFrom: null
|
|
207
|
+
}));
|
|
208
|
+
const carriedOrphans = prior.nodes
|
|
209
|
+
.filter((node) => node.status === "orphaned" && !currentIds.has(node.id))
|
|
210
|
+
.map((node) => ({
|
|
211
|
+
id: node.id,
|
|
212
|
+
name: node.name,
|
|
213
|
+
qualifiedName: node.qualified_name,
|
|
214
|
+
kind: node.kind,
|
|
215
|
+
path: node.path,
|
|
216
|
+
line: node.line,
|
|
217
|
+
fingerprint: node.fingerprint,
|
|
218
|
+
commit: node.commit_hash,
|
|
219
|
+
status: "orphaned",
|
|
220
|
+
language: node.language,
|
|
221
|
+
exported: Boolean(node.exported),
|
|
222
|
+
remappedFrom: null
|
|
223
|
+
}));
|
|
224
|
+
const orphaned = [...new Map([...newlyOrphaned, ...carriedOrphans].map((node) => [node.id, node])).values()];
|
|
225
|
+
return { nodes: [...nodes, ...orphaned], orphanIds: new Set(orphaned.map((node) => node.id)) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function evidenceFile(projectRoot, reference) {
|
|
229
|
+
const raw = String(reference);
|
|
230
|
+
if (/^[A-Z]+-\d{3,}$/.test(raw)) return { type: "artifact", value: raw };
|
|
231
|
+
const relative = raw.replace(/^path:\s*/i, "").replace(/#L\d+(?:-L\d+)?$/i, "").replace(/:\d+(?::\d+)?$/, "");
|
|
232
|
+
if (path.isAbsolute(relative) || relative.split(/[\\/]+/).includes("..") || /(^|[\\/])vault(?:\.local)?\.md$/i.test(relative)) {
|
|
233
|
+
return { type: "forbidden", value: relative };
|
|
234
|
+
}
|
|
235
|
+
return { type: "path", value: relative };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function evidenceSnapshots(projectRoot, artifacts) {
|
|
239
|
+
const artifactHashes = new Map(artifacts.map((artifact) => [artifact.record.id, sha256(artifact.content)]));
|
|
240
|
+
const snapshots = [];
|
|
241
|
+
for (const artifact of artifacts.filter((item) => ["knowledge", "decision", "insight", "dossier"].includes(item.record.kind))) {
|
|
242
|
+
for (const reference of extractEvidence(artifact.body)) {
|
|
243
|
+
const resolved = evidenceFile(projectRoot, reference);
|
|
244
|
+
let fingerprint = resolved.type;
|
|
245
|
+
if (resolved.type === "artifact") fingerprint = artifactHashes.get(resolved.value) || "missing";
|
|
246
|
+
if (resolved.type === "path") {
|
|
247
|
+
const candidates = [path.resolve(projectRoot, resolved.value), path.resolve(projectRoot, ".scrumrun", resolved.value)];
|
|
248
|
+
const file = candidates.find((candidate) => {
|
|
249
|
+
if (!candidate.startsWith(`${path.resolve(projectRoot)}${path.sep}`) || !fs.existsSync(candidate)) return false;
|
|
250
|
+
try {
|
|
251
|
+
assertNoSymlinkPath(projectRoot, candidate);
|
|
252
|
+
return fs.lstatSync(candidate).isFile();
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
fingerprint = file ? sha256(fs.readFileSync(file)) : "missing";
|
|
258
|
+
}
|
|
259
|
+
snapshots.push({ artifactId: artifact.record.id, reference, fingerprint });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return snapshots;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function codeSubject(record) {
|
|
266
|
+
const type = String(record.subject_type || "").toLowerCase();
|
|
267
|
+
const subject = typeof record.subject_id === "string" ? record.subject_id : typeof record.subject === "string" ? record.subject : null;
|
|
268
|
+
if (!subject) return null;
|
|
269
|
+
if (/^(?:symbol|module):/.test(subject) || /^(?:function|class|interface|type|enum):/.test(subject)) return subject;
|
|
270
|
+
if (["symbol", "function", "class", "interface", "type", "enum", "module"].includes(type)) return `${type}:${subject}`;
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resolveSubject(subject, codeNodes, priorSubject = null) {
|
|
275
|
+
const current = codeNodes.filter((node) => node.status === "current");
|
|
276
|
+
let matches = current.filter((node) => node.id === subject || node.qualifiedName === subject);
|
|
277
|
+
const generic = subject.match(/^(function|class|interface|type|enum):(.+)$/);
|
|
278
|
+
if (!matches.length && generic) matches = current.filter((node) => node.kind === generic[1] && node.name === generic[2]);
|
|
279
|
+
const symbolic = subject.match(/^symbol:(.+)$/);
|
|
280
|
+
if (!matches.length && symbolic) matches = current.filter((node) => node.kind !== "module" && node.name === symbolic[1]);
|
|
281
|
+
if (!matches.length && priorSubject && priorSubject.symbol_id) {
|
|
282
|
+
matches = current.filter((node) => node.remappedFrom === priorSubject.symbol_id);
|
|
283
|
+
}
|
|
284
|
+
if (matches.length !== 1) return { symbolId: null, fingerprint: matches.length ? "ambiguous" : "missing" };
|
|
285
|
+
return { symbolId: matches[0].id, fingerprint: matches[0].fingerprint };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function derivedInvalidations(projectRoot, artifacts, codeNodes, prior, evidence) {
|
|
289
|
+
const artifactHashes = new Map(artifacts.map((artifact) => [artifact.record.id, sha256(artifact.content)]));
|
|
290
|
+
const invalidations = new Map();
|
|
291
|
+
const add = (artifactId, reason, reference) => {
|
|
292
|
+
const artifactHash = artifactHashes.get(artifactId);
|
|
293
|
+
if (!artifactHash) return;
|
|
294
|
+
invalidations.set(`${artifactId}\0${reason}\0${reference}`, { artifactId, reason, reference, artifactHash });
|
|
295
|
+
};
|
|
296
|
+
for (const old of prior.invalidations) {
|
|
297
|
+
if (artifactHashes.get(old.artifact_id) === old.artifact_hash) add(old.artifact_id, old.reason, old.reference);
|
|
298
|
+
}
|
|
299
|
+
const priorEvidence = new Map(prior.evidence.map((item) => [`${item.artifact_id}\0${item.reference}`, item]));
|
|
300
|
+
for (const snapshot of evidence) {
|
|
301
|
+
const old = priorEvidence.get(`${snapshot.artifactId}\0${snapshot.reference}`);
|
|
302
|
+
if (old && old.fingerprint !== snapshot.fingerprint) add(snapshot.artifactId, "Evidence changed since the last index build", snapshot.reference);
|
|
303
|
+
if (["missing", "forbidden"].includes(snapshot.fingerprint)) add(snapshot.artifactId, "Evidence is no longer resolvable", snapshot.reference);
|
|
304
|
+
}
|
|
305
|
+
const priorSubjects = new Map(prior.subjects.map((item) => [item.artifact_id, item]));
|
|
306
|
+
const subjects = [];
|
|
307
|
+
for (const artifact of artifacts.filter((item) => ["knowledge", "decision", "insight", "dossier"].includes(item.record.kind))) {
|
|
308
|
+
const subject = codeSubject(artifact.record);
|
|
309
|
+
if (!subject) continue;
|
|
310
|
+
const old = priorSubjects.get(artifact.record.id);
|
|
311
|
+
const resolved = resolveSubject(subject, codeNodes, old);
|
|
312
|
+
subjects.push({ artifactId: artifact.record.id, subjectId: subject, ...resolved });
|
|
313
|
+
if (old && resolved.fingerprint !== old.fingerprint) {
|
|
314
|
+
add(artifact.record.id, resolved.fingerprint === "missing" ? "Code subject became orphaned" : "Code subject implementation changed", subject);
|
|
315
|
+
} else if (resolved.fingerprint === "missing") {
|
|
316
|
+
add(artifact.record.id, "Code subject cannot be resolved", subject);
|
|
317
|
+
} else if (resolved.fingerprint === "ambiguous") {
|
|
318
|
+
add(artifact.record.id, "Code subject is ambiguous", subject);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { invalidations: [...invalidations.values()], subjects };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function rebuildIndex(projectRoot) {
|
|
325
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
326
|
+
if (!fs.existsSync(scrumDir)) throw new Error("Not a ScrumRun project: .scrumrun/ is missing.");
|
|
327
|
+
const cacheDir = path.join(scrumDir, ".cache");
|
|
328
|
+
assertNoSymlinkPath(scrumDir, cacheDir);
|
|
329
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
330
|
+
const target = indexPath(projectRoot);
|
|
331
|
+
assertNoSymlinkPath(scrumDir, target);
|
|
332
|
+
const temp = path.join(cacheDir, `.semantic-index.${process.pid}.${Date.now()}.tmp`);
|
|
333
|
+
const snapshot = sourceSnapshot(projectRoot);
|
|
334
|
+
const artifacts = snapshot.artifacts;
|
|
335
|
+
const prior = priorIndex(target);
|
|
336
|
+
const remapped = remapCode(snapshot.code.nodes, prior);
|
|
337
|
+
const evidence = evidenceSnapshots(projectRoot, artifacts);
|
|
338
|
+
const derived = derivedInvalidations(projectRoot, artifacts, remapped.nodes, prior, evidence);
|
|
339
|
+
let database;
|
|
340
|
+
try {
|
|
341
|
+
database = new DatabaseSync(temp);
|
|
342
|
+
createSchema(database);
|
|
343
|
+
const insertArtifact = database.prepare("INSERT INTO artifacts(id, kind, status, title, path, hash, content, valid_from, valid_until, last_verified_commit, review_trigger, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
344
|
+
const insertFts = database.prepare("INSERT INTO artifacts_fts(id, title, content) VALUES (?, ?, ?)");
|
|
345
|
+
const insertEdge = database.prepare("INSERT OR IGNORE INTO edges(from_id, relation, to_id, evidence, confidence) VALUES (?, ?, ?, ?, ?)");
|
|
346
|
+
const insertCode = database.prepare("INSERT INTO code_nodes(id, kind, name, qualified_name, path, line, fingerprint, commit_hash, status, language, exported, remapped_from) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
347
|
+
const insertCodeFts = database.prepare("INSERT INTO code_fts(id, name, qualified_name, path) VALUES (?, ?, ?, ?)");
|
|
348
|
+
database.exec("BEGIN IMMEDIATE");
|
|
349
|
+
try {
|
|
350
|
+
for (const artifact of artifacts) {
|
|
351
|
+
insertArtifact.run(
|
|
352
|
+
artifact.record.id,
|
|
353
|
+
artifact.record.kind,
|
|
354
|
+
artifact.record.status,
|
|
355
|
+
artifact.title,
|
|
356
|
+
artifact.path,
|
|
357
|
+
sha256(artifact.content),
|
|
358
|
+
artifact.content,
|
|
359
|
+
artifact.record.valid_from || null,
|
|
360
|
+
artifact.record.valid_until || null,
|
|
361
|
+
artifact.record.last_verified_commit || null,
|
|
362
|
+
artifact.record.review_trigger || null,
|
|
363
|
+
INACTIVE.has(artifact.record.status) ? 0 : 1
|
|
364
|
+
);
|
|
365
|
+
insertFts.run(artifact.record.id, artifact.title, artifact.content);
|
|
366
|
+
for (const edge of explicitEdges(artifact)) insertEdge.run(artifact.record.id, edge.relation, edge.target, edge.evidence, "canonical");
|
|
367
|
+
}
|
|
368
|
+
for (const node of remapped.nodes) {
|
|
369
|
+
insertCode.run(node.id, node.kind, node.name, node.qualifiedName, node.path, node.line, node.fingerprint, node.commit || null, node.status, node.language, node.exported ? 1 : 0, node.remappedFrom || null);
|
|
370
|
+
insertCodeFts.run(node.id, node.name, node.qualifiedName, node.path);
|
|
371
|
+
}
|
|
372
|
+
for (const edge of snapshot.code.edges) insertEdge.run(edge.from, edge.relation, edge.to, edge.evidence, edge.confidence);
|
|
373
|
+
for (const edge of prior.edges.filter((item) => remapped.orphanIds.has(item.from_id))) {
|
|
374
|
+
insertEdge.run(edge.from_id, edge.relation, edge.to_id, edge.evidence, edge.confidence || "historical");
|
|
375
|
+
}
|
|
376
|
+
const insertEvidence = database.prepare("INSERT OR REPLACE INTO evidence_snapshots(artifact_id, reference, fingerprint) VALUES (?, ?, ?)");
|
|
377
|
+
for (const item of evidence) insertEvidence.run(item.artifactId, item.reference, item.fingerprint);
|
|
378
|
+
const insertSubject = database.prepare("INSERT INTO subject_snapshots(artifact_id, subject_id, symbol_id, fingerprint) VALUES (?, ?, ?, ?)");
|
|
379
|
+
for (const item of derived.subjects) insertSubject.run(item.artifactId, item.subjectId, item.symbolId, item.fingerprint);
|
|
380
|
+
const insertInvalidation = database.prepare("INSERT INTO invalidations(artifact_id, reason, reference, artifact_hash) VALUES (?, ?, ?, ?)");
|
|
381
|
+
for (const item of derived.invalidations) insertInvalidation.run(item.artifactId, item.reason, item.reference, item.artifactHash);
|
|
382
|
+
const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
|
|
383
|
+
metadata.run("schema_version", "2");
|
|
384
|
+
metadata.run("method_version", "2.0.0");
|
|
385
|
+
metadata.run("source_fingerprint", snapshot.fingerprint);
|
|
386
|
+
metadata.run("artifact_count", String(artifacts.length));
|
|
387
|
+
metadata.run("code_node_count", String(remapped.nodes.length));
|
|
388
|
+
metadata.run("code_adapter", snapshot.code.adapterIds.join(","));
|
|
389
|
+
database.exec("COMMIT");
|
|
390
|
+
} catch (error) {
|
|
391
|
+
database.exec("ROLLBACK");
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
database.close();
|
|
395
|
+
database = null;
|
|
396
|
+
fs.renameSync(temp, target);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (database) database.close();
|
|
399
|
+
if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
return { file: target, fingerprint: snapshot.fingerprint, artifacts: artifacts.length, codeNodes: remapped.nodes.length, warnings: snapshot.code.warnings };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function indexStatus(projectRoot) {
|
|
406
|
+
const file = indexPath(projectRoot);
|
|
407
|
+
if (!fs.existsSync(file)) return { exists: false, stale: true, file };
|
|
408
|
+
let database;
|
|
409
|
+
try {
|
|
410
|
+
assertNoSymlinkPath(path.join(projectRoot, ".scrumrun"), file);
|
|
411
|
+
database = new DatabaseSync(file, { readOnly: true });
|
|
412
|
+
const row = database.prepare("SELECT value FROM metadata WHERE key = 'source_fingerprint'").get();
|
|
413
|
+
const actual = sourceSnapshot(projectRoot).fingerprint;
|
|
414
|
+
return { exists: true, stale: !row || row.value !== actual, file, stored: row && row.value, actual };
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return { exists: true, stale: true, file, error: error.message };
|
|
417
|
+
} finally {
|
|
418
|
+
if (database) database.close();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function ftsQuery(query) {
|
|
423
|
+
const tokens = String(query || "").normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) || [];
|
|
424
|
+
return tokens.map((token) => `"${token.replace(/"/g, "")}"`).join(" AND ");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAULT_CONTEXT_RESULTS, relationLimit = DEFAULT_RELATIONS_PER_RESULT, rebuild = true } = {}) {
|
|
428
|
+
const text = String(query || "").trim();
|
|
429
|
+
if (!text) throw new Error("A non-empty memory query is required.");
|
|
430
|
+
const status = indexStatus(projectRoot);
|
|
431
|
+
let rebuilt = false;
|
|
432
|
+
if (status.stale) {
|
|
433
|
+
if (!rebuild) throw new Error("Semantic index is missing or stale; rebuild it first.");
|
|
434
|
+
rebuildIndex(projectRoot);
|
|
435
|
+
rebuilt = true;
|
|
436
|
+
}
|
|
437
|
+
const database = new DatabaseSync(indexPath(projectRoot), { readOnly: true });
|
|
438
|
+
try {
|
|
439
|
+
const cap = Math.max(1, Math.min(Number(limit) || DEFAULT_CONTEXT_RESULTS, MAX_CONTEXT_RESULTS));
|
|
440
|
+
const relationCap = Math.max(1, Math.min(Number(relationLimit) || DEFAULT_RELATIONS_PER_RESULT, MAX_RELATIONS_PER_RESULT));
|
|
441
|
+
const activeClause = includeInactive ? "" : "AND a.active = 1";
|
|
442
|
+
const matches = new Map();
|
|
443
|
+
const match = ftsQuery(text);
|
|
444
|
+
if (match) {
|
|
445
|
+
const rows = database.prepare(`
|
|
446
|
+
SELECT a.id, a.kind, a.status, a.title, a.path, a.active, a.valid_until, a.review_trigger, bm25(artifacts_fts) AS rank
|
|
447
|
+
FROM artifacts_fts JOIN artifacts a ON a.id = artifacts_fts.id
|
|
448
|
+
WHERE artifacts_fts MATCH ? ${activeClause}
|
|
449
|
+
ORDER BY rank, a.id LIMIT ?
|
|
450
|
+
`).all(match, cap);
|
|
451
|
+
for (const row of rows) matches.set(`artifact:${row.id}`, { ...row, nodeType: "artifact", match: "content" });
|
|
452
|
+
}
|
|
453
|
+
const like = `%${text.toLowerCase()}%`;
|
|
454
|
+
const relationRows = database.prepare(`
|
|
455
|
+
SELECT DISTINCT a.id, a.kind, a.status, a.title, a.path, a.active, a.valid_until, a.review_trigger,
|
|
456
|
+
e.relation, e.to_id, e.evidence
|
|
457
|
+
FROM edges e JOIN artifacts a ON a.id = e.from_id
|
|
458
|
+
WHERE (lower(e.to_id) LIKE ? OR lower(e.relation) LIKE ?) ${activeClause}
|
|
459
|
+
ORDER BY a.id LIMIT ?
|
|
460
|
+
`).all(like, like, cap);
|
|
461
|
+
for (const row of relationRows) {
|
|
462
|
+
const key = `artifact:${row.id}`;
|
|
463
|
+
const existing = matches.get(key);
|
|
464
|
+
if (existing) existing.match = "content+relation";
|
|
465
|
+
else matches.set(key, { ...row, nodeType: "artifact", match: "relation" });
|
|
466
|
+
}
|
|
467
|
+
if (match) {
|
|
468
|
+
const codeRows = database.prepare(`
|
|
469
|
+
SELECT c.id, c.kind, c.name AS title, c.qualified_name, c.path, c.line, c.fingerprint,
|
|
470
|
+
c.commit_hash, c.status, c.language, c.remapped_from, bm25(code_fts) AS rank
|
|
471
|
+
FROM code_fts JOIN code_nodes c ON c.id = code_fts.id
|
|
472
|
+
WHERE code_fts MATCH ?
|
|
473
|
+
ORDER BY CASE c.status WHEN 'current' THEN 0 ELSE 1 END, rank, c.id LIMIT ?
|
|
474
|
+
`).all(match, cap);
|
|
475
|
+
for (const row of codeRows) matches.set(`code:${row.id}`, { ...row, nodeType: "code", match: "code" });
|
|
476
|
+
}
|
|
477
|
+
const codeRelationRows = database.prepare(`
|
|
478
|
+
SELECT DISTINCT c.id, c.kind, c.name AS title, c.qualified_name, c.path, c.line, c.fingerprint,
|
|
479
|
+
c.commit_hash, c.status, c.language, c.remapped_from, e.relation, e.to_id, e.evidence
|
|
480
|
+
FROM edges e JOIN code_nodes c ON c.id = e.from_id
|
|
481
|
+
WHERE lower(e.to_id) LIKE ? OR lower(e.relation) LIKE ?
|
|
482
|
+
ORDER BY CASE c.status WHEN 'current' THEN 0 ELSE 1 END, c.id LIMIT ?
|
|
483
|
+
`).all(like, like, cap);
|
|
484
|
+
for (const row of codeRelationRows) {
|
|
485
|
+
const key = `code:${row.id}`;
|
|
486
|
+
const existing = matches.get(key);
|
|
487
|
+
if (existing) existing.match = existing.match === "code" ? "code+relation" : existing.match;
|
|
488
|
+
else matches.set(key, { ...row, nodeType: "code", match: "relation" });
|
|
489
|
+
}
|
|
490
|
+
const results = [...matches.values()].slice(0, cap).map((row) => {
|
|
491
|
+
const relationCount = Number(database.prepare("SELECT count(*) AS total FROM edges WHERE from_id = ?").get(row.id).total);
|
|
492
|
+
const relations = database.prepare("SELECT relation, to_id, evidence FROM edges WHERE from_id = ? ORDER BY relation, to_id LIMIT ?").all(row.id, relationCap);
|
|
493
|
+
if (row.nodeType === "code") {
|
|
494
|
+
return {
|
|
495
|
+
id: row.id,
|
|
496
|
+
kind: row.kind,
|
|
497
|
+
title: row.title,
|
|
498
|
+
qualifiedName: row.qualified_name,
|
|
499
|
+
path: row.path,
|
|
500
|
+
line: row.line,
|
|
501
|
+
fingerprint: row.fingerprint,
|
|
502
|
+
commit: row.commit_hash,
|
|
503
|
+
status: row.status,
|
|
504
|
+
language: row.language,
|
|
505
|
+
match: row.match,
|
|
506
|
+
truth: row.status === "orphaned" ? "orphaned" : "derived",
|
|
507
|
+
...(row.remapped_from ? { remappedFrom: row.remapped_from } : {}),
|
|
508
|
+
...(row.status === "orphaned" ? { warning: "Symbol no longer exists at this identity; inspect remaps or history." } : {}),
|
|
509
|
+
relationCount,
|
|
510
|
+
relationsTruncated: relationCount > relations.length,
|
|
511
|
+
relations
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const expired = row.valid_until && row.valid_until < new Date().toISOString().slice(0, 10);
|
|
515
|
+
const invalidations = database.prepare("SELECT reason, reference FROM invalidations WHERE artifact_id = ? ORDER BY reason, reference").all(row.id);
|
|
516
|
+
return {
|
|
517
|
+
id: row.id,
|
|
518
|
+
kind: row.kind,
|
|
519
|
+
status: row.status,
|
|
520
|
+
title: row.title,
|
|
521
|
+
path: row.path,
|
|
522
|
+
match: row.match,
|
|
523
|
+
truth: row.status === "stale" || expired || invalidations.length ? "stale" : row.active ? (CONFIRMED_STATUSES.has(row.status) ? "confirmed" : "candidate") : "inactive",
|
|
524
|
+
...(expired
|
|
525
|
+
? { warning: `Validity expired on ${row.valid_until}.` }
|
|
526
|
+
: invalidations.length
|
|
527
|
+
? { warning: invalidations.map((item) => `${item.reason}: ${item.reference}`).join("; ") }
|
|
528
|
+
: row.status === "stale"
|
|
529
|
+
? { warning: "Memory is marked stale and requires review." }
|
|
530
|
+
: {}),
|
|
531
|
+
...(row.review_trigger ? { reviewTrigger: row.review_trigger } : {}),
|
|
532
|
+
...(invalidations.length ? { invalidations } : {}),
|
|
533
|
+
relationCount,
|
|
534
|
+
relationsTruncated: relationCount > relations.length,
|
|
535
|
+
relations
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
return { indexFile: indexPath(projectRoot), rebuilt, query: text, results };
|
|
539
|
+
} finally {
|
|
540
|
+
database.close();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const CONFIRMED_STATUSES = new Set(["approved", "confirmed", "resolved", "completed", "passed", "active"]);
|
|
545
|
+
|
|
546
|
+
function graphIndex(projectRoot, { rebuild = true } = {}) {
|
|
547
|
+
const status = indexStatus(projectRoot);
|
|
548
|
+
if (status.stale && rebuild) rebuildIndex(projectRoot);
|
|
549
|
+
else if (status.stale) throw new Error("Semantic index is missing or stale; rebuild it first.");
|
|
550
|
+
const database = new DatabaseSync(indexPath(projectRoot), { readOnly: true });
|
|
551
|
+
try {
|
|
552
|
+
const artifacts = database.prepare("SELECT id, kind, status, title, path, 'artifact' AS node_type FROM artifacts ORDER BY id").all();
|
|
553
|
+
const code = database.prepare("SELECT id, kind, status, name AS title, path, 'code' AS node_type, fingerprint, remapped_from FROM code_nodes ORDER BY id").all();
|
|
554
|
+
return {
|
|
555
|
+
nodes: [...artifacts, ...code],
|
|
556
|
+
edges: database.prepare("SELECT from_id AS 'from', relation, to_id AS 'to', evidence, confidence FROM edges ORDER BY from_id, relation, to_id").all(),
|
|
557
|
+
invalidations: database.prepare("SELECT artifact_id, reason, reference FROM invalidations ORDER BY artifact_id, reason, reference").all()
|
|
558
|
+
};
|
|
559
|
+
} finally {
|
|
560
|
+
database.close();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function writeMap(projectRoot, { nodeLimit = 200, edgeLimit = 400 } = {}) {
|
|
565
|
+
const status = indexStatus(projectRoot);
|
|
566
|
+
if (status.stale) rebuildIndex(projectRoot);
|
|
567
|
+
const graph = graphIndex(projectRoot, { rebuild: false });
|
|
568
|
+
const current = indexStatus(projectRoot);
|
|
569
|
+
const nodes = graph.nodes.slice(0, Math.max(1, nodeLimit));
|
|
570
|
+
const edges = graph.edges.slice(0, Math.max(1, edgeLimit));
|
|
571
|
+
const body = [
|
|
572
|
+
"# ScrumRun Project Map",
|
|
573
|
+
"",
|
|
574
|
+
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
|
575
|
+
`Source fingerprint: ${current.stored}`,
|
|
576
|
+
"Authority: none; rebuild from canonical artifacts and source code.",
|
|
577
|
+
"",
|
|
578
|
+
"## Summary",
|
|
579
|
+
"",
|
|
580
|
+
`- Nodes: ${graph.nodes.length}`,
|
|
581
|
+
`- Edges: ${graph.edges.length}`,
|
|
582
|
+
`- Derived invalidations: ${graph.invalidations.length}`,
|
|
583
|
+
"",
|
|
584
|
+
"## Nodes",
|
|
585
|
+
"",
|
|
586
|
+
...(nodes.length ? nodes.map((node) => `- ${node.id} | ${node.node_type} | ${node.kind} | ${node.status} | ${node.title} | ${node.path}`) : ["- None."]),
|
|
587
|
+
...(graph.nodes.length > nodes.length ? [`- … ${graph.nodes.length - nodes.length} more nodes in the semantic index.`] : []),
|
|
588
|
+
"",
|
|
589
|
+
"## Relations",
|
|
590
|
+
"",
|
|
591
|
+
...(edges.length ? edges.map((edge) => `- ${edge.from} --${edge.relation}--> ${edge.to} | ${edge.evidence}`) : ["- None."]),
|
|
592
|
+
...(graph.edges.length > edges.length ? [`- … ${graph.edges.length - edges.length} more relations in the semantic index.`] : []),
|
|
593
|
+
""
|
|
594
|
+
].join("\n");
|
|
595
|
+
const file = path.join(projectRoot, ".scrumrun", "map.md");
|
|
596
|
+
atomicWrite(file, body);
|
|
597
|
+
return { file, nodes: graph.nodes.length, edges: graph.edges.length, fingerprint: current.stored };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
module.exports = { CACHE_RELATIVE, graphIndex, indexPath, indexStatus, memoryFingerprint, queryIndex, rebuildIndex, sourceSnapshot, writeMap };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function cleanInline(value) {
|
|
4
|
+
return String(value || "")
|
|
5
|
+
.trim()
|
|
6
|
+
.replace(/^`|`$/g, "")
|
|
7
|
+
.replace(/^\[([^\]]+)\]\([^)]*\)$/, "$1")
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sectionItems(body, names) {
|
|
12
|
+
const wanted = new Set(names.map((name) => name.toLowerCase()));
|
|
13
|
+
const items = [];
|
|
14
|
+
let active = false;
|
|
15
|
+
for (const line of String(body || "").split(/\r?\n/)) {
|
|
16
|
+
const heading = line.match(/^##\s+(.+?)\s*$/);
|
|
17
|
+
if (heading) {
|
|
18
|
+
const normalized = heading[1].toLowerCase();
|
|
19
|
+
active = [...wanted].some((name) => normalized === name || normalized.startsWith(`${name} `));
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (!active) continue;
|
|
23
|
+
const bullet = line.match(/^\s*-\s+(.+?)\s*$/);
|
|
24
|
+
if (bullet && !/^pending\b/i.test(bullet[1])) items.push(cleanInline(bullet[1]));
|
|
25
|
+
}
|
|
26
|
+
return items;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractEvidence(body) {
|
|
30
|
+
const evidence = sectionItems(body, ["evidence", "migration provenance"])
|
|
31
|
+
.map((item) => cleanInline(item.replace(/^(?:source|artifact|path):\s*/i, "")))
|
|
32
|
+
.filter((item) => !/^anchor:/i.test(item) && !/^source block sha-256:/i.test(item));
|
|
33
|
+
return [...new Set(evidence)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractRelations(body) {
|
|
37
|
+
const relations = [];
|
|
38
|
+
for (const item of sectionItems(body, ["relations"])) {
|
|
39
|
+
const match = item.match(/^([a-z][a-z0-9_]*)\s*(?::|→|->)\s*(.+)$/i);
|
|
40
|
+
if (!match) continue;
|
|
41
|
+
const targets = match[2].split(/\s*,\s*/).map(cleanInline).filter(Boolean);
|
|
42
|
+
for (const target of targets) relations.push({ relation: match[1].toLowerCase(), target });
|
|
43
|
+
}
|
|
44
|
+
return relations;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderBullets(items, pending) {
|
|
48
|
+
return items.length ? items.map((item) => `- ${item}`).join("\n") : `- Pending: ${pending}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { cleanInline, extractEvidence, extractRelations, renderBullets, sectionItems };
|