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.
Files changed (44) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CORE.md +17 -3
  3. package/DECISIONS.md +56 -0
  4. package/MIGRATION-1-to-2.md +11 -0
  5. package/README.md +23 -5
  6. package/SPEC.md +30 -10
  7. package/bin/scrumrun.js +175 -11
  8. package/docs/COMMANDS.md +10 -4
  9. package/docs/ENTITY-MODEL.md +1 -1
  10. package/docs/RELEASE-SCORECARD.md +43 -0
  11. package/docs/RELEASE.md +19 -12
  12. package/docs/SCHEMA.md +11 -0
  13. package/docs/SEMANTIC-MEMORY.md +1 -1
  14. package/docs/TROUBLESHOOTING.md +13 -1
  15. package/lib/commands/manifest.js +15 -3
  16. package/lib/commands/render.js +4 -0
  17. package/lib/memory/index.js +201 -41
  18. package/lib/memory/service.js +3 -0
  19. package/lib/runtime/budgets.js +4 -0
  20. package/lib/runtime/canonical-snapshot.js +110 -0
  21. package/lib/runtime/context.js +5 -45
  22. package/lib/runtime/mutation-gateway.js +434 -0
  23. package/lib/runtime/orchestrator.js +130 -65
  24. package/lib/runtime/policy-engine.js +267 -0
  25. package/lib/runtime/request-engine.js +32 -24
  26. package/lib/runtime/review-service.js +92 -0
  27. package/lib/runtime/run-ledger.js +546 -0
  28. package/lib/runtime/workspace-state.js +146 -0
  29. package/lib/security/secrets.js +15 -1
  30. package/lib/v2/artifacts.js +24 -1
  31. package/lib/v2/conformance.js +78 -12
  32. package/lib/v2/migration.js +74 -10
  33. package/lib/v2/run-ledger-migration.js +268 -0
  34. package/lib/v2/schema.js +28 -1
  35. package/lib/v2/transaction.js +254 -0
  36. package/package.json +1 -1
  37. package/scripts/generate-contract-docs.js +11 -0
  38. package/templates/project/.scrumrun/guardrails.md +8 -0
  39. package/templates/project/.scrumrun/map.md +4 -3
  40. package/templates/project/.scrumrun/method.json +7 -1
  41. package/templates/project/.scrumrun/state.md +7 -14
  42. package/templates/project/AGENTS.md +2 -1
  43. package/templates/project-lean/AGENTS.md +3 -1
  44. package/templates/shared/skills/scrumrun/SKILL.md +19 -5
@@ -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 { scanProject } = require("../code-intel/scanner");
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, `commit\0${code.commit || "unavailable"}`].sort().join("\n"));
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 = database.prepare("INSERT INTO artifacts_fts(id, title, content) VALUES (?, ?, ?)");
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 = database.prepare("INSERT INTO code_fts(id, name, qualified_name, path) VALUES (?, ?, ?, ?)");
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(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);
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", "2");
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 { file: target, fingerprint: snapshot.fingerprint, artifacts: artifacts.length, codeNodes: remapped.nodes.length, warnings: snapshot.code.warnings };
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 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 };
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 = String(query || "").normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) || [];
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 (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);
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 (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);
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
- `Generated: ${new Date().toISOString().slice(0, 10)}`,
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
- module.exports = { CACHE_RELATIVE, graphIndex, indexPath, indexStatus, memoryFingerprint, queryIndex, rebuildIndex, sourceSnapshot, writeMap };
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 };
@@ -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
  }
@@ -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
+ };
@@ -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
- function guardrailRecords(content) {
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
- const canonical = ["guardrails.md", "config.md", "project.md", "method.json"]
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 };