scrumrun 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/CORE.md +17 -3
- package/DECISIONS.md +56 -0
- package/MIGRATION-1-to-2.md +11 -0
- package/README.md +23 -5
- package/SPEC.md +30 -10
- package/bin/scrumrun.js +175 -11
- package/docs/COMMANDS.md +10 -4
- package/docs/ENTITY-MODEL.md +1 -1
- package/docs/RELEASE-SCORECARD.md +43 -0
- package/docs/RELEASE.md +19 -12
- package/docs/SCHEMA.md +11 -0
- package/docs/SEMANTIC-MEMORY.md +1 -1
- package/docs/TROUBLESHOOTING.md +13 -1
- package/lib/commands/manifest.js +15 -3
- package/lib/commands/render.js +4 -0
- package/lib/memory/index.js +201 -41
- package/lib/memory/service.js +3 -0
- package/lib/runtime/budgets.js +4 -0
- package/lib/runtime/canonical-snapshot.js +110 -0
- package/lib/runtime/context.js +5 -45
- package/lib/runtime/mutation-gateway.js +434 -0
- package/lib/runtime/orchestrator.js +130 -65
- package/lib/runtime/policy-engine.js +267 -0
- package/lib/runtime/request-engine.js +32 -24
- package/lib/runtime/review-service.js +92 -0
- package/lib/runtime/run-ledger.js +546 -0
- package/lib/runtime/workspace-state.js +146 -0
- package/lib/security/secrets.js +15 -1
- package/lib/v2/artifacts.js +24 -1
- package/lib/v2/conformance.js +78 -12
- package/lib/v2/migration.js +74 -10
- package/lib/v2/run-ledger-migration.js +268 -0
- package/lib/v2/schema.js +28 -1
- package/lib/v2/transaction.js +254 -0
- package/package.json +1 -1
- package/scripts/generate-contract-docs.js +11 -0
- package/templates/project/.scrumrun/guardrails.md +8 -0
- package/templates/project/.scrumrun/map.md +4 -3
- package/templates/project/.scrumrun/method.json +7 -1
- package/templates/project/.scrumrun/state.md +7 -14
- package/templates/project/AGENTS.md +2 -1
- package/templates/project-lean/AGENTS.md +3 -1
- package/templates/shared/skills/scrumrun/SKILL.md +19 -5
package/lib/memory/index.js
CHANGED
|
@@ -5,12 +5,17 @@ const path = require("node:path");
|
|
|
5
5
|
const { DatabaseSync } = require("node:sqlite");
|
|
6
6
|
const { ARTIFACT_TYPES, ArtifactRepository, assertNoSymlinkPath, atomicWrite, sha256 } = require("../v2/artifacts");
|
|
7
7
|
const { extractEvidence, extractRelations } = require("./markdown");
|
|
8
|
-
const {
|
|
8
|
+
const { JavaScriptAdapter } = require("../code-intel/javascript");
|
|
9
|
+
const { scanProject, sourceFiles } = require("../code-intel/scanner");
|
|
9
10
|
const { DEFAULT_CONTEXT_RESULTS, DEFAULT_RELATIONS_PER_RESULT, MAX_CONTEXT_RESULTS, MAX_RELATIONS_PER_RESULT } = require("../runtime/budgets");
|
|
10
11
|
const { containsSecret } = require("../security/secrets");
|
|
12
|
+
const { canonicalWatchSnapshot, fileWatchSnapshot } = require("../runtime/canonical-snapshot");
|
|
11
13
|
|
|
12
14
|
const CACHE_RELATIVE = path.join(".cache", "semantic-index.sqlite");
|
|
15
|
+
const INDEX_SCHEMA_VERSION = 4;
|
|
13
16
|
const INACTIVE = new Set(["rejected", "invalidated", "deprecated", "archived"]);
|
|
17
|
+
const SEARCH_BACKENDS = new Set(["auto", "fts5", "like"]);
|
|
18
|
+
const MAX_SEARCH_TOKENS = 32;
|
|
14
19
|
|
|
15
20
|
function indexPath(projectRoot) {
|
|
16
21
|
return path.join(projectRoot, ".scrumrun", CACHE_RELATIVE);
|
|
@@ -20,6 +25,10 @@ function titleFromBody(body, fallback) {
|
|
|
20
25
|
return ((String(body || "").match(/^#\s+([^\r\n]+)/m) || [])[1] || fallback).trim();
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
function normalizeSearchText(...values) {
|
|
29
|
+
return values.map((value) => String(value || "")).join(" ").normalize("NFKC").toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
function canonicalArtifacts(scrumDir) {
|
|
24
33
|
const repository = new ArtifactRepository(scrumDir);
|
|
25
34
|
const artifacts = [];
|
|
@@ -46,7 +55,7 @@ function memoryFingerprint(scrumDir) {
|
|
|
46
55
|
return sha256(rows.join("\n"));
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
function sourceSnapshot(projectRoot) {
|
|
58
|
+
function sourceSnapshot(projectRoot, { watch = null } = {}) {
|
|
50
59
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
51
60
|
const artifacts = canonicalArtifacts(scrumDir);
|
|
52
61
|
const code = scanProject(projectRoot);
|
|
@@ -54,8 +63,26 @@ function sourceSnapshot(projectRoot) {
|
|
|
54
63
|
const codeRows = code.nodes
|
|
55
64
|
.filter((node) => node.kind === "module")
|
|
56
65
|
.map((node) => `${node.path}\0${node.fingerprint}`);
|
|
57
|
-
const fingerprint = sha256([...artifactRows, ...codeRows
|
|
58
|
-
return { artifacts, code, fingerprint };
|
|
66
|
+
const fingerprint = sha256([...artifactRows, ...codeRows].sort().join("\n"));
|
|
67
|
+
return { artifacts, code, fingerprint, watch: watch || sourceWatchSnapshot(projectRoot) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sourceWatchSnapshot(projectRoot) {
|
|
71
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
72
|
+
const canonical = canonicalWatchSnapshot(scrumDir);
|
|
73
|
+
const adapters = [new JavaScriptAdapter()];
|
|
74
|
+
const discovered = sourceFiles(path.resolve(projectRoot), adapters);
|
|
75
|
+
const code = fileWatchSnapshot(projectRoot, discovered.files.map((item) => item.file), adapters.map((adapter) => `adapter\0${adapter.id}`));
|
|
76
|
+
const rows = [
|
|
77
|
+
...canonical.rows.map((row) => `canonical\0${row}`),
|
|
78
|
+
...code.rows.map((row) => `code\0${row}`)
|
|
79
|
+
];
|
|
80
|
+
return {
|
|
81
|
+
fingerprint: sha256(rows.sort().join("\n")),
|
|
82
|
+
canonicalFiles: canonical.files,
|
|
83
|
+
sourceFiles: code.files,
|
|
84
|
+
warnings: discovered.warnings
|
|
85
|
+
};
|
|
59
86
|
}
|
|
60
87
|
|
|
61
88
|
function explicitEdges(artifact) {
|
|
@@ -83,7 +110,8 @@ function explicitEdges(artifact) {
|
|
|
83
110
|
return [...unique.values()];
|
|
84
111
|
}
|
|
85
112
|
|
|
86
|
-
function createSchema(database) {
|
|
113
|
+
function createSchema(database, { searchBackend = "auto" } = {}) {
|
|
114
|
+
if (!SEARCH_BACKENDS.has(searchBackend)) throw new Error(`Unknown semantic search backend: ${searchBackend}`);
|
|
87
115
|
database.exec(`
|
|
88
116
|
PRAGMA journal_mode = DELETE;
|
|
89
117
|
PRAGMA synchronous = FULL;
|
|
@@ -100,6 +128,7 @@ function createSchema(database) {
|
|
|
100
128
|
valid_until TEXT,
|
|
101
129
|
last_verified_commit TEXT,
|
|
102
130
|
review_trigger TEXT,
|
|
131
|
+
search_text TEXT NOT NULL,
|
|
103
132
|
active INTEGER NOT NULL CHECK (active IN (0, 1))
|
|
104
133
|
);
|
|
105
134
|
CREATE TABLE edges (
|
|
@@ -122,7 +151,8 @@ function createSchema(database) {
|
|
|
122
151
|
status TEXT NOT NULL CHECK (status IN ('current', 'orphaned')),
|
|
123
152
|
language TEXT NOT NULL,
|
|
124
153
|
exported INTEGER NOT NULL CHECK (exported IN (0, 1)),
|
|
125
|
-
remapped_from TEXT
|
|
154
|
+
remapped_from TEXT,
|
|
155
|
+
search_text TEXT NOT NULL
|
|
126
156
|
);
|
|
127
157
|
CREATE TABLE evidence_snapshots (
|
|
128
158
|
artifact_id TEXT NOT NULL,
|
|
@@ -146,9 +176,18 @@ function createSchema(database) {
|
|
|
146
176
|
CREATE INDEX edges_from ON edges(from_id);
|
|
147
177
|
CREATE INDEX edges_to ON edges(to_id);
|
|
148
178
|
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
179
|
`);
|
|
180
|
+
if (searchBackend === "like") return "like";
|
|
181
|
+
try {
|
|
182
|
+
database.exec(`
|
|
183
|
+
CREATE VIRTUAL TABLE artifacts_fts USING fts5(id UNINDEXED, title, content, tokenize='unicode61');
|
|
184
|
+
CREATE VIRTUAL TABLE code_fts USING fts5(id UNINDEXED, name, qualified_name, path, tokenize='unicode61');
|
|
185
|
+
`);
|
|
186
|
+
return "fts5";
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (searchBackend === "fts5" || !/no such module:\s*fts5/i.test(error.message)) throw error;
|
|
189
|
+
return "like";
|
|
190
|
+
}
|
|
152
191
|
}
|
|
153
192
|
|
|
154
193
|
function tableExists(database, name) {
|
|
@@ -321,7 +360,7 @@ function derivedInvalidations(projectRoot, artifacts, codeNodes, prior, evidence
|
|
|
321
360
|
return { invalidations: [...invalidations.values()], subjects };
|
|
322
361
|
}
|
|
323
362
|
|
|
324
|
-
function rebuildIndex(projectRoot) {
|
|
363
|
+
function rebuildIndex(projectRoot, { searchBackend = "auto" } = {}) {
|
|
325
364
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
326
365
|
if (!fs.existsSync(scrumDir)) throw new Error("Not a ScrumRun project: .scrumrun/ is missing.");
|
|
327
366
|
const cacheDir = path.join(scrumDir, ".cache");
|
|
@@ -337,14 +376,19 @@ function rebuildIndex(projectRoot) {
|
|
|
337
376
|
const evidence = evidenceSnapshots(projectRoot, artifacts);
|
|
338
377
|
const derived = derivedInvalidations(projectRoot, artifacts, remapped.nodes, prior, evidence);
|
|
339
378
|
let database;
|
|
379
|
+
let selectedSearchBackend;
|
|
340
380
|
try {
|
|
341
381
|
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 =
|
|
382
|
+
selectedSearchBackend = createSchema(database, { searchBackend });
|
|
383
|
+
const insertArtifact = database.prepare("INSERT INTO artifacts(id, kind, status, title, path, hash, content, valid_from, valid_until, last_verified_commit, review_trigger, search_text, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
384
|
+
const insertFts = selectedSearchBackend === "fts5"
|
|
385
|
+
? database.prepare("INSERT INTO artifacts_fts(id, title, content) VALUES (?, ?, ?)")
|
|
386
|
+
: null;
|
|
345
387
|
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 =
|
|
388
|
+
const insertCode = database.prepare("INSERT INTO code_nodes(id, kind, name, qualified_name, path, line, fingerprint, commit_hash, status, language, exported, remapped_from, search_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
389
|
+
const insertCodeFts = selectedSearchBackend === "fts5"
|
|
390
|
+
? database.prepare("INSERT INTO code_fts(id, name, qualified_name, path) VALUES (?, ?, ?, ?)")
|
|
391
|
+
: null;
|
|
348
392
|
database.exec("BEGIN IMMEDIATE");
|
|
349
393
|
try {
|
|
350
394
|
for (const artifact of artifacts) {
|
|
@@ -360,14 +404,29 @@ function rebuildIndex(projectRoot) {
|
|
|
360
404
|
artifact.record.valid_until || null,
|
|
361
405
|
artifact.record.last_verified_commit || null,
|
|
362
406
|
artifact.record.review_trigger || null,
|
|
407
|
+
normalizeSearchText(artifact.title, artifact.content),
|
|
363
408
|
INACTIVE.has(artifact.record.status) ? 0 : 1
|
|
364
409
|
);
|
|
365
|
-
insertFts.run(artifact.record.id, artifact.title, artifact.content);
|
|
410
|
+
if (insertFts) insertFts.run(artifact.record.id, artifact.title, artifact.content);
|
|
366
411
|
for (const edge of explicitEdges(artifact)) insertEdge.run(artifact.record.id, edge.relation, edge.target, edge.evidence, "canonical");
|
|
367
412
|
}
|
|
368
413
|
for (const node of remapped.nodes) {
|
|
369
|
-
insertCode.run(
|
|
370
|
-
|
|
414
|
+
insertCode.run(
|
|
415
|
+
node.id,
|
|
416
|
+
node.kind,
|
|
417
|
+
node.name,
|
|
418
|
+
node.qualifiedName,
|
|
419
|
+
node.path,
|
|
420
|
+
node.line,
|
|
421
|
+
node.fingerprint,
|
|
422
|
+
node.commit || null,
|
|
423
|
+
node.status,
|
|
424
|
+
node.language,
|
|
425
|
+
node.exported ? 1 : 0,
|
|
426
|
+
node.remappedFrom || null,
|
|
427
|
+
normalizeSearchText(node.name, node.qualifiedName, node.path)
|
|
428
|
+
);
|
|
429
|
+
if (insertCodeFts) insertCodeFts.run(node.id, node.name, node.qualifiedName, node.path);
|
|
371
430
|
}
|
|
372
431
|
for (const edge of snapshot.code.edges) insertEdge.run(edge.from, edge.relation, edge.to, edge.evidence, edge.confidence);
|
|
373
432
|
for (const edge of prior.edges.filter((item) => remapped.orphanIds.has(item.from_id))) {
|
|
@@ -380,11 +439,14 @@ function rebuildIndex(projectRoot) {
|
|
|
380
439
|
const insertInvalidation = database.prepare("INSERT INTO invalidations(artifact_id, reason, reference, artifact_hash) VALUES (?, ?, ?, ?)");
|
|
381
440
|
for (const item of derived.invalidations) insertInvalidation.run(item.artifactId, item.reason, item.reference, item.artifactHash);
|
|
382
441
|
const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
|
|
383
|
-
metadata.run("schema_version",
|
|
442
|
+
metadata.run("schema_version", String(INDEX_SCHEMA_VERSION));
|
|
384
443
|
metadata.run("method_version", "2.0.0");
|
|
444
|
+
metadata.run("search_backend", selectedSearchBackend);
|
|
385
445
|
metadata.run("source_fingerprint", snapshot.fingerprint);
|
|
446
|
+
metadata.run("source_watch_fingerprint", snapshot.watch.fingerprint);
|
|
386
447
|
metadata.run("artifact_count", String(artifacts.length));
|
|
387
448
|
metadata.run("code_node_count", String(remapped.nodes.length));
|
|
449
|
+
metadata.run("source_file_count", String(snapshot.watch.sourceFiles));
|
|
388
450
|
metadata.run("code_adapter", snapshot.code.adapterIds.join(","));
|
|
389
451
|
database.exec("COMMIT");
|
|
390
452
|
} catch (error) {
|
|
@@ -399,7 +461,24 @@ function rebuildIndex(projectRoot) {
|
|
|
399
461
|
if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
|
|
400
462
|
throw error;
|
|
401
463
|
}
|
|
402
|
-
return {
|
|
464
|
+
return {
|
|
465
|
+
file: target,
|
|
466
|
+
fingerprint: snapshot.fingerprint,
|
|
467
|
+
watchFingerprint: snapshot.watch.fingerprint,
|
|
468
|
+
searchBackend: selectedSearchBackend,
|
|
469
|
+
artifacts: artifacts.length,
|
|
470
|
+
codeNodes: remapped.nodes.length,
|
|
471
|
+
warnings: [...snapshot.code.warnings, ...snapshot.watch.warnings]
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function runtimeSupportsFts5(database) {
|
|
476
|
+
try {
|
|
477
|
+
database.prepare("SELECT 1 FROM artifacts_fts LIMIT 1").get();
|
|
478
|
+
return true;
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
403
482
|
}
|
|
404
483
|
|
|
405
484
|
function indexStatus(projectRoot) {
|
|
@@ -409,9 +488,35 @@ function indexStatus(projectRoot) {
|
|
|
409
488
|
try {
|
|
410
489
|
assertNoSymlinkPath(path.join(projectRoot, ".scrumrun"), file);
|
|
411
490
|
database = new DatabaseSync(file, { readOnly: true });
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
|
|
491
|
+
const metadata = new Map(database.prepare("SELECT key, value FROM metadata").all().map((row) => [row.key, row.value]));
|
|
492
|
+
const stored = metadata.get("source_fingerprint") || null;
|
|
493
|
+
const searchBackend = metadata.get("search_backend") || null;
|
|
494
|
+
if (
|
|
495
|
+
metadata.get("schema_version") !== String(INDEX_SCHEMA_VERSION)
|
|
496
|
+
|| !metadata.get("source_watch_fingerprint")
|
|
497
|
+
|| !["fts5", "like"].includes(searchBackend)
|
|
498
|
+
) {
|
|
499
|
+
return { exists: true, stale: true, file, stored, check: "schema", reason: `semantic index schema ${INDEX_SCHEMA_VERSION} rebuild required` };
|
|
500
|
+
}
|
|
501
|
+
if (searchBackend === "fts5" && !runtimeSupportsFts5(database)) {
|
|
502
|
+
return { exists: true, stale: true, file, stored, searchBackend, check: "schema", reason: "semantic index requires unavailable FTS5; fallback rebuild required" };
|
|
503
|
+
}
|
|
504
|
+
const watch = sourceWatchSnapshot(projectRoot);
|
|
505
|
+
if (metadata.get("source_watch_fingerprint") === watch.fingerprint) {
|
|
506
|
+
return { exists: true, stale: false, file, stored, actual: stored, watch: watch.fingerprint, searchBackend, check: "metadata" };
|
|
507
|
+
}
|
|
508
|
+
const actual = sourceSnapshot(projectRoot, { watch }).fingerprint;
|
|
509
|
+
return {
|
|
510
|
+
exists: true,
|
|
511
|
+
stale: !stored || stored !== actual,
|
|
512
|
+
file,
|
|
513
|
+
stored,
|
|
514
|
+
actual,
|
|
515
|
+
watch: watch.fingerprint,
|
|
516
|
+
searchBackend,
|
|
517
|
+
check: "hash",
|
|
518
|
+
reason: stored === actual ? "file metadata changed but semantic content is unchanged" : "canonical or source content changed"
|
|
519
|
+
};
|
|
415
520
|
} catch (error) {
|
|
416
521
|
return { exists: true, stale: true, file, error: error.message };
|
|
417
522
|
} finally {
|
|
@@ -419,11 +524,40 @@ function indexStatus(projectRoot) {
|
|
|
419
524
|
}
|
|
420
525
|
}
|
|
421
526
|
|
|
527
|
+
function searchTokens(query) {
|
|
528
|
+
return (String(query || "").normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) || []).slice(0, MAX_SEARCH_TOKENS);
|
|
529
|
+
}
|
|
530
|
+
|
|
422
531
|
function ftsQuery(query) {
|
|
423
|
-
const tokens =
|
|
532
|
+
const tokens = searchTokens(query);
|
|
424
533
|
return tokens.map((token) => `"${token.replace(/"/g, "")}"`).join(" AND ");
|
|
425
534
|
}
|
|
426
535
|
|
|
536
|
+
function fallbackArtifactRows(database, tokens, activeClause, cap) {
|
|
537
|
+
if (!tokens.length) return [];
|
|
538
|
+
const clauses = tokens.map(() => "instr(a.search_text, ?) > 0").join(" AND ");
|
|
539
|
+
const parameters = tokens.map((token) => normalizeSearchText(token));
|
|
540
|
+
return database.prepare(`
|
|
541
|
+
SELECT a.id, a.kind, a.status, a.title, a.path, a.active, a.valid_until, a.review_trigger
|
|
542
|
+
FROM artifacts a
|
|
543
|
+
WHERE ${clauses} ${activeClause}
|
|
544
|
+
ORDER BY a.id LIMIT ?
|
|
545
|
+
`).all(...parameters, cap);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function fallbackCodeRows(database, tokens, cap) {
|
|
549
|
+
if (!tokens.length) return [];
|
|
550
|
+
const clauses = tokens.map(() => "instr(c.search_text, ?) > 0").join(" AND ");
|
|
551
|
+
const parameters = tokens.map((token) => normalizeSearchText(token));
|
|
552
|
+
return database.prepare(`
|
|
553
|
+
SELECT c.id, c.kind, c.name AS title, c.qualified_name, c.path, c.line, c.fingerprint,
|
|
554
|
+
c.commit_hash, c.status, c.language, c.remapped_from
|
|
555
|
+
FROM code_nodes c
|
|
556
|
+
WHERE ${clauses}
|
|
557
|
+
ORDER BY CASE c.status WHEN 'current' THEN 0 ELSE 1 END, c.id LIMIT ?
|
|
558
|
+
`).all(...parameters, cap);
|
|
559
|
+
}
|
|
560
|
+
|
|
427
561
|
function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAULT_CONTEXT_RESULTS, relationLimit = DEFAULT_RELATIONS_PER_RESULT, rebuild = true } = {}) {
|
|
428
562
|
const text = String(query || "").trim();
|
|
429
563
|
if (!text) throw new Error("A non-empty memory query is required.");
|
|
@@ -436,18 +570,23 @@ function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAU
|
|
|
436
570
|
}
|
|
437
571
|
const database = new DatabaseSync(indexPath(projectRoot), { readOnly: true });
|
|
438
572
|
try {
|
|
573
|
+
const metadata = new Map(database.prepare("SELECT key, value FROM metadata").all().map((row) => [row.key, row.value]));
|
|
574
|
+
const searchBackend = metadata.get("search_backend");
|
|
439
575
|
const cap = Math.max(1, Math.min(Number(limit) || DEFAULT_CONTEXT_RESULTS, MAX_CONTEXT_RESULTS));
|
|
440
576
|
const relationCap = Math.max(1, Math.min(Number(relationLimit) || DEFAULT_RELATIONS_PER_RESULT, MAX_RELATIONS_PER_RESULT));
|
|
441
577
|
const activeClause = includeInactive ? "" : "AND a.active = 1";
|
|
442
578
|
const matches = new Map();
|
|
579
|
+
const tokens = searchTokens(text);
|
|
443
580
|
const match = ftsQuery(text);
|
|
444
|
-
if (
|
|
445
|
-
const rows =
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
581
|
+
if (tokens.length) {
|
|
582
|
+
const rows = searchBackend === "fts5"
|
|
583
|
+
? database.prepare(`
|
|
584
|
+
SELECT a.id, a.kind, a.status, a.title, a.path, a.active, a.valid_until, a.review_trigger, bm25(artifacts_fts) AS rank
|
|
585
|
+
FROM artifacts_fts JOIN artifacts a ON a.id = artifacts_fts.id
|
|
586
|
+
WHERE artifacts_fts MATCH ? ${activeClause}
|
|
587
|
+
ORDER BY rank, a.id LIMIT ?
|
|
588
|
+
`).all(match, cap)
|
|
589
|
+
: fallbackArtifactRows(database, tokens, activeClause, cap);
|
|
451
590
|
for (const row of rows) matches.set(`artifact:${row.id}`, { ...row, nodeType: "artifact", match: "content" });
|
|
452
591
|
}
|
|
453
592
|
const like = `%${text.toLowerCase()}%`;
|
|
@@ -464,14 +603,16 @@ function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAU
|
|
|
464
603
|
if (existing) existing.match = "content+relation";
|
|
465
604
|
else matches.set(key, { ...row, nodeType: "artifact", match: "relation" });
|
|
466
605
|
}
|
|
467
|
-
if (
|
|
468
|
-
const codeRows =
|
|
469
|
-
|
|
470
|
-
c.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
606
|
+
if (tokens.length) {
|
|
607
|
+
const codeRows = searchBackend === "fts5"
|
|
608
|
+
? database.prepare(`
|
|
609
|
+
SELECT c.id, c.kind, c.name AS title, c.qualified_name, c.path, c.line, c.fingerprint,
|
|
610
|
+
c.commit_hash, c.status, c.language, c.remapped_from, bm25(code_fts) AS rank
|
|
611
|
+
FROM code_fts JOIN code_nodes c ON c.id = code_fts.id
|
|
612
|
+
WHERE code_fts MATCH ?
|
|
613
|
+
ORDER BY CASE c.status WHEN 'current' THEN 0 ELSE 1 END, rank, c.id LIMIT ?
|
|
614
|
+
`).all(match, cap)
|
|
615
|
+
: fallbackCodeRows(database, tokens, cap);
|
|
475
616
|
for (const row of codeRows) matches.set(`code:${row.id}`, { ...row, nodeType: "code", match: "code" });
|
|
476
617
|
}
|
|
477
618
|
const codeRelationRows = database.prepare(`
|
|
@@ -535,7 +676,7 @@ function queryIndex(projectRoot, query, { includeInactive = false, limit = DEFAU
|
|
|
535
676
|
relations
|
|
536
677
|
};
|
|
537
678
|
});
|
|
538
|
-
return { indexFile: indexPath(projectRoot), rebuilt, query: text, results };
|
|
679
|
+
return { indexFile: indexPath(projectRoot), rebuilt, searchBackend, query: text, results };
|
|
539
680
|
} finally {
|
|
540
681
|
database.close();
|
|
541
682
|
}
|
|
@@ -571,7 +712,8 @@ function writeMap(projectRoot, { nodeLimit = 200, edgeLimit = 400 } = {}) {
|
|
|
571
712
|
const body = [
|
|
572
713
|
"# ScrumRun Project Map",
|
|
573
714
|
"",
|
|
574
|
-
|
|
715
|
+
"Projection schema: 1",
|
|
716
|
+
`Generated: ${new Date().toISOString()}`,
|
|
575
717
|
`Source fingerprint: ${current.stored}`,
|
|
576
718
|
"Authority: none; rebuild from canonical artifacts and source code.",
|
|
577
719
|
"",
|
|
@@ -597,4 +739,22 @@ function writeMap(projectRoot, { nodeLimit = 200, edgeLimit = 400 } = {}) {
|
|
|
597
739
|
return { file, nodes: graph.nodes.length, edges: graph.edges.length, fingerprint: current.stored };
|
|
598
740
|
}
|
|
599
741
|
|
|
600
|
-
|
|
742
|
+
function mapStatus(projectRoot, { semanticStatus = null } = {}) {
|
|
743
|
+
const file = path.join(projectRoot, ".scrumrun", "map.md");
|
|
744
|
+
if (!fs.existsSync(file)) return { exists: false, stale: true, file, reason: "map.md is missing" };
|
|
745
|
+
try {
|
|
746
|
+
assertNoSymlinkPath(path.join(projectRoot, ".scrumrun"), file);
|
|
747
|
+
if (!fs.lstatSync(file).isFile()) return { exists: true, stale: true, file, reason: "map.md is not a regular file" };
|
|
748
|
+
const content = fs.readFileSync(file, "utf8");
|
|
749
|
+
const stored = (content.match(/^Source fingerprint:\s*([a-f0-9]{64})$/m) || [])[1] || null;
|
|
750
|
+
if (!stored) return { exists: true, stale: true, file, stored, reason: "map.md has no source fingerprint" };
|
|
751
|
+
const semantic = semanticStatus || indexStatus(projectRoot);
|
|
752
|
+
if (!semantic.exists) return { exists: true, stale: true, file, stored, reason: "semantic index is missing" };
|
|
753
|
+
if (semantic.stale) return { exists: true, stale: true, file, stored, reason: semantic.reason || "semantic index is stale" };
|
|
754
|
+
return { exists: true, stale: stored !== semantic.stored, file, stored, actual: semantic.stored, reason: stored === semantic.stored ? null : "map fingerprint differs from the semantic index" };
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return { exists: true, stale: true, file, error: error.message, reason: "map freshness check failed" };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
module.exports = { CACHE_RELATIVE, INDEX_SCHEMA_VERSION, graphIndex, indexPath, indexStatus, mapStatus, memoryFingerprint, queryIndex, rebuildIndex, sourceSnapshot, sourceWatchSnapshot, writeMap };
|
package/lib/memory/service.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
} = require("../v2/artifacts");
|
|
16
16
|
const { extractEvidence, renderBullets } = require("./markdown");
|
|
17
17
|
const { assertNoSecret } = require("../security/secrets");
|
|
18
|
+
const { assertCanonicalWrite } = require("../runtime/mutation-gateway");
|
|
18
19
|
|
|
19
20
|
const MEMORY_KINDS = new Set(["knowledge", "decision", "insight", "dossier"]);
|
|
20
21
|
const DEFAULT_STATUS = Object.freeze({ knowledge: "candidate", decision: "open", insight: "candidate", dossier: "active" });
|
|
@@ -203,6 +204,7 @@ function createMemoryUnlocked(projectRoot, kind, options = {}) {
|
|
|
203
204
|
"",
|
|
204
205
|
`- ${created}: ${record.status} — proposed by ${sourceType}.`
|
|
205
206
|
].join("\n");
|
|
207
|
+
assertCanonicalWrite(projectRoot, `create-${kind}`, [title, content, body]);
|
|
206
208
|
repository.write(record, body);
|
|
207
209
|
return repository.read(kind, record.id);
|
|
208
210
|
}
|
|
@@ -267,6 +269,7 @@ function transitionMemoryUnlocked(projectRoot, kind, id, action, options = {}) {
|
|
|
267
269
|
const parsed = parseArtifact(next);
|
|
268
270
|
const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
|
|
269
271
|
if (errors.length) throw new Error(`Memory transition validation failed: ${errors.join("; ")}`);
|
|
272
|
+
assertCanonicalWrite(projectRoot, `transition-${kind}`, [next]);
|
|
270
273
|
atomicWrite(artifact.file, next);
|
|
271
274
|
return repository.read(kind, id);
|
|
272
275
|
}
|
package/lib/runtime/budgets.js
CHANGED
|
@@ -7,10 +7,14 @@ const MAX_CONTEXT_RESULTS = 100;
|
|
|
7
7
|
const DEFAULT_CONTEXT_RESULTS = 10;
|
|
8
8
|
const MAX_RELATIONS_PER_RESULT = 100;
|
|
9
9
|
const DEFAULT_RELATIONS_PER_RESULT = 40;
|
|
10
|
+
const KERNEL_LIFECYCLE_MAX_MS = 1500;
|
|
11
|
+
const INDEX_STATUS_100_MAX_MS = 2500;
|
|
10
12
|
|
|
11
13
|
module.exports = {
|
|
12
14
|
DEFAULT_CONTEXT_RESULTS,
|
|
13
15
|
DEFAULT_RELATIONS_PER_RESULT,
|
|
16
|
+
INDEX_STATUS_100_MAX_MS,
|
|
17
|
+
KERNEL_LIFECYCLE_MAX_MS,
|
|
14
18
|
LEAN_CONTROL_CONTEXT_MAX_CHARS,
|
|
15
19
|
LEAN_CONTROL_CONTEXT_MAX_RATIO,
|
|
16
20
|
MAX_CONTEXT_RESULTS,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { ARTIFACT_TYPES, ArtifactRepository, assertNoSymlinkPath, sha256 } = require("../v2/artifacts");
|
|
6
|
+
const { containsSecret } = require("../security/secrets");
|
|
7
|
+
|
|
8
|
+
const CONTROL_FILES = Object.freeze(["guardrails.md", "config.md", "project.md", "method.json"]);
|
|
9
|
+
|
|
10
|
+
function posix(value) {
|
|
11
|
+
return value.split(path.sep).join("/");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function read(file) {
|
|
15
|
+
return fs.existsSync(file) && fs.lstatSync(file).isFile() ? fs.readFileSync(file, "utf8") : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function artifactSnapshot(scrumDir) {
|
|
19
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
20
|
+
const records = {};
|
|
21
|
+
const hashes = [];
|
|
22
|
+
const warnings = [];
|
|
23
|
+
for (const kind of Object.keys(ARTIFACT_TYPES)) {
|
|
24
|
+
records[kind] = repository.list(kind).map((artifact) => {
|
|
25
|
+
const relative = posix(path.relative(scrumDir, artifact.file));
|
|
26
|
+
const content = read(artifact.file);
|
|
27
|
+
hashes.push(`${relative}\0${sha256(content)}`);
|
|
28
|
+
if (containsSecret(content)) {
|
|
29
|
+
warnings.push(`Secret-like content detected in canonical artifact: ${relative}`);
|
|
30
|
+
return { id: artifact.record ? artifact.record.id : path.basename(artifact.file, ".md"), status: "unsafe", title: "[redacted]" };
|
|
31
|
+
}
|
|
32
|
+
return artifact.record ? {
|
|
33
|
+
id: artifact.record.id,
|
|
34
|
+
status: artifact.record.status,
|
|
35
|
+
title: ((artifact.body || "").match(/^# ([^\r\n]+)/m) || [])[1] || artifact.record.id,
|
|
36
|
+
task: artifact.record.task || null,
|
|
37
|
+
sprint: artifact.record.sprint || null,
|
|
38
|
+
feature: artifact.record.feature || null
|
|
39
|
+
} : { id: path.basename(artifact.file, ".md"), status: "invalid", errors: artifact.errors };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return { records, hashes, warnings };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function canonicalFingerprint(scrumDir, artifactHashes = null) {
|
|
46
|
+
const hashes = artifactHashes || artifactSnapshot(scrumDir).hashes;
|
|
47
|
+
const controls = CONTROL_FILES.map((relative) => {
|
|
48
|
+
const file = path.join(scrumDir, relative);
|
|
49
|
+
const exists = fs.existsSync(file) && fs.lstatSync(file).isFile();
|
|
50
|
+
return `${relative}\0${exists ? sha256(fs.readFileSync(file)) : "missing"}`;
|
|
51
|
+
});
|
|
52
|
+
return sha256([...controls, ...hashes].sort().join("\n"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function statRow(root, file) {
|
|
56
|
+
assertNoSymlinkPath(root, file);
|
|
57
|
+
const stat = fs.statSync(file, { bigint: true });
|
|
58
|
+
if (!stat.isFile()) throw new Error(`Projection source is not a regular file: ${posix(path.relative(root, file))}`);
|
|
59
|
+
return [
|
|
60
|
+
posix(path.relative(root, file)),
|
|
61
|
+
stat.size,
|
|
62
|
+
stat.mtimeNs,
|
|
63
|
+
stat.ctimeNs,
|
|
64
|
+
stat.ino,
|
|
65
|
+
stat.dev
|
|
66
|
+
].join("\0");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function canonicalWatchSnapshot(scrumDir) {
|
|
70
|
+
const root = path.resolve(scrumDir);
|
|
71
|
+
const rows = [];
|
|
72
|
+
let files = 0;
|
|
73
|
+
for (const relative of CONTROL_FILES) {
|
|
74
|
+
const file = path.join(root, relative);
|
|
75
|
+
if (!fs.existsSync(file)) rows.push(`${relative}\0missing`);
|
|
76
|
+
else {
|
|
77
|
+
rows.push(statRow(root, file));
|
|
78
|
+
files++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const type of Object.values(ARTIFACT_TYPES)) {
|
|
82
|
+
const directory = path.join(root, type.directory);
|
|
83
|
+
assertNoSymlinkPath(root, directory);
|
|
84
|
+
if (!fs.existsSync(directory)) continue;
|
|
85
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
86
|
+
if (!new RegExp(`^${type.prefix}-\\d{3,}\\.md$`).test(entry.name)) continue;
|
|
87
|
+
const file = path.join(directory, entry.name);
|
|
88
|
+
if (!entry.isFile()) throw new Error(`Canonical projection source is not a regular file: ${posix(path.relative(root, file))}`);
|
|
89
|
+
rows.push(statRow(root, file));
|
|
90
|
+
files++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { fingerprint: sha256(rows.sort().join("\n")), files, rows };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fileWatchSnapshot(root, files, extraRows = []) {
|
|
97
|
+
const resolvedRoot = path.resolve(root);
|
|
98
|
+
const rows = files.map((file) => statRow(resolvedRoot, path.resolve(file)));
|
|
99
|
+
rows.push(...extraRows.map(String));
|
|
100
|
+
return { fingerprint: sha256(rows.sort().join("\n")), files: files.length, rows };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
CONTROL_FILES,
|
|
105
|
+
artifactSnapshot,
|
|
106
|
+
canonicalFingerprint,
|
|
107
|
+
canonicalWatchSnapshot,
|
|
108
|
+
fileWatchSnapshot,
|
|
109
|
+
statRow
|
|
110
|
+
};
|
package/lib/runtime/context.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
|
-
const { ARTIFACT_TYPES, ArtifactRepository, sha256 } = require("../v2/artifacts");
|
|
6
5
|
const { readProjectModel } = require("../v2/project-store");
|
|
7
6
|
const { containsSecret } = require("../security/secrets");
|
|
7
|
+
const { parseGuardrails } = require("./policy-engine");
|
|
8
|
+
const { artifactSnapshot, canonicalFingerprint } = require("./canonical-snapshot");
|
|
8
9
|
|
|
9
10
|
const EXCLUDED_ROOT_ENTRIES = new Set([".git", ".scrumrun", "node_modules", "dist", "build", "coverage"]);
|
|
10
11
|
|
|
@@ -40,51 +41,10 @@ function projectScan(projectRoot) {
|
|
|
40
41
|
return { project: path.basename(projectRoot), entries, package: packageSummary };
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
const headings = [...content.matchAll(/^## (GR-\d{3,})\s*[-—:]\s*([^\r\n]+)$/gm)];
|
|
45
|
-
return headings.map((heading, index) => {
|
|
46
|
-
const end = index + 1 < headings.length ? headings[index + 1].index : content.length;
|
|
47
|
-
const block = content.slice(heading.index, end);
|
|
48
|
-
const rule = (block.match(/^Rule:\s*(.+)$/m) || [])[1] || heading[2];
|
|
49
|
-
const status = ((block.match(/^Status:\s*(.+)$/m) || [])[1] || "active").trim();
|
|
50
|
-
return { id: heading[1], title: heading[2].trim(), rule: capped(rule.trim(), 500), status };
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function artifactSnapshot(scrumDir) {
|
|
55
|
-
const repository = new ArtifactRepository(scrumDir);
|
|
56
|
-
const records = {};
|
|
57
|
-
const hashes = [];
|
|
58
|
-
const warnings = [];
|
|
59
|
-
for (const kind of Object.keys(ARTIFACT_TYPES)) {
|
|
60
|
-
records[kind] = repository.list(kind).map((artifact) => {
|
|
61
|
-
const relative = path.relative(scrumDir, artifact.file).split(path.sep).join("/");
|
|
62
|
-
const content = read(artifact.file);
|
|
63
|
-
hashes.push(`${relative}\0${sha256(content)}`);
|
|
64
|
-
if (containsSecret(content)) {
|
|
65
|
-
warnings.push(`Secret-like content detected in canonical artifact: ${relative}`);
|
|
66
|
-
return { id: artifact.record ? artifact.record.id : path.basename(artifact.file, ".md"), status: "unsafe", title: "[redacted]" };
|
|
67
|
-
}
|
|
68
|
-
return artifact.record ? {
|
|
69
|
-
id: artifact.record.id,
|
|
70
|
-
status: artifact.record.status,
|
|
71
|
-
title: ((artifact.body || "").match(/^# ([^\r\n]+)/m) || [])[1] || artifact.record.id,
|
|
72
|
-
task: artifact.record.task || null,
|
|
73
|
-
sprint: artifact.record.sprint || null,
|
|
74
|
-
feature: artifact.record.feature || null
|
|
75
|
-
} : { id: path.basename(artifact.file, ".md"), status: "invalid", errors: artifact.errors };
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
return { records, hashes, warnings };
|
|
79
|
-
}
|
|
44
|
+
const guardrailRecords = parseGuardrails;
|
|
80
45
|
|
|
81
46
|
function contextFingerprint(scrumDir, artifactHashes) {
|
|
82
|
-
|
|
83
|
-
.map((relative) => {
|
|
84
|
-
const content = read(path.join(scrumDir, relative));
|
|
85
|
-
return `${relative}\0${content ? sha256(content) : "missing"}`;
|
|
86
|
-
});
|
|
87
|
-
return sha256([...canonical, ...artifactHashes].sort().join("\n"));
|
|
47
|
+
return canonicalFingerprint(scrumDir, artifactHashes);
|
|
88
48
|
}
|
|
89
49
|
|
|
90
50
|
function buildContextPackage(projectRoot, request) {
|
|
@@ -138,4 +98,4 @@ function buildContextPackage(projectRoot, request) {
|
|
|
138
98
|
};
|
|
139
99
|
}
|
|
140
100
|
|
|
141
|
-
module.exports = { buildContextPackage, contextFingerprint, guardrailRecords, projectScan };
|
|
101
|
+
module.exports = { artifactSnapshot, buildContextPackage, contextFingerprint, guardrailRecords, projectScan };
|