scrumrun 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/RELEASE.md CHANGED
@@ -1,7 +1,9 @@
1
- # ScrumRun 2.0 Release Procedure
1
+ # ScrumRun Package Release Procedure
2
2
 
3
3
  Publication is owner-gated. Tests, packaging, local commits, and release metadata preparation do not authorize npm publication, git push, tags, GitHub releases, or dist-tag changes.
4
4
 
5
+ The npm package/CLI follows SemVer independently from the ScrumRun method contract. For this release, package `2.1.0` implements method `2.0.0`. Published package `2.0.0` is immutable and must never be overwritten or reused.
6
+
5
7
  ## Local release gate
6
8
 
7
9
  ```bash
@@ -12,11 +14,13 @@ npm pack --dry-run
12
14
  npx scrumrun@latest sc review artifact --run # in a clean v2 fixture/current package equivalent
13
15
  ```
14
16
 
15
- Confirm package contents exclude repository-local `.scrumrun/` state, caches, migration records, backups, vaults, tests, and secrets. The `.scrumrun/` directory inside project templates is expected. Confirm `package.json`, README, changelog, SPEC, CORE, skill, migration guide, and tag all name 2.0.0.
17
+ Confirm package contents exclude repository-local `.scrumrun/` state, caches, migration records, backups, vaults, tests, and secrets. The `.scrumrun/` directory inside project templates is expected. Confirm package metadata, README, changelog, tarball filename, checksum, and Git tag agree on `2.1.0`; SPEC, CORE, artifact frontmatter, migration, and installed skill continue to declare method `2.0.0`.
18
+
19
+ Record the local evidence and residual risks against [`RELEASE-SCORECARD.md`](./RELEASE-SCORECARD.md). Scores describe local readiness only and never replace registry smoke or owner approval.
16
20
 
17
- ## Release candidate (owner approval required)
21
+ ## Optional registry candidate (separate owner approval required)
18
22
 
19
- 1. Set `2.0.0-rc.1` metadata and create a reviewed commit.
23
+ 1. Set a new unpublished prerelease such as `2.1.0-rc.1` and create a reviewed commit.
20
24
  2. `npm pack`; record tarball SHA-256/integrity.
21
25
  3. Publish to `next`, never `latest`:
22
26
 
@@ -28,17 +32,20 @@ Confirm package contents exclude repository-local `.scrumrun/` state, caches, mi
28
32
  5. Run init, intake/approval, memory confirmation/query, v1 update preflight/apply/rollback, doctor, and uninstall.
29
33
  6. Fix findings in a new RC; never replace an already-published version.
30
34
 
31
- ## Final promotion (new owner approval required)
35
+ An RC is immutable. Fixes create `rc.2`, `rc.3`, and so on. Skipping an RC does not skip any local, tarball, owner, registry-smoke, or final-promotion gate.
36
+
37
+ ## Final release (new owner approval required)
32
38
 
33
- 1. Set `2.0.0`, rerun every local/registry gate, and create the final metadata commit.
34
- 2. Create/push tag `v2.0.0` and publish npm `2.0.0`.
35
- 3. Promote `latest` only after registry smoke tests pass.
36
- 4. Create the GitHub release from `CHANGELOG.md` and link the migration guide.
37
- 5. Verify npm metadata, Git tag/commit, GitHub release, README, and checksums agree.
39
+ 1. Set `2.1.0`, rerun every local gate, inspect the exact tarball, and create the final local metadata commit.
40
+ 2. Stop and request explicit owner authorization for each external boundary.
41
+ 3. Publish npm `2.1.0` to `next` using the reviewed tarball/source commit (`npm publish --tag next`); never publish from a changed worktree and do not move `latest` yet.
42
+ 4. Install the registry artifact in a clean fixture and run init, intake/approval, Run lifecycle, memory query, v1 migration apply/rollback, doctor, and uninstall.
43
+ 5. Only after registry smoke passes, create/push tag `v2.1.0`, promote/verify `latest` with an explicit dist-tag command, and create the GitHub release from `CHANGELOG.md` with the migration guide and checksum.
44
+ 6. Verify npm metadata, dist-tags, Git tag/commit, GitHub release, README, and checksums all agree.
38
45
 
39
46
  ## Recovery
40
47
 
41
48
  - RC defect: publish another RC; keep `latest` unchanged.
42
- - Final package defect before `latest`: do not promote; publish `2.0.1` after correction if 2.0.0 already exists.
43
- - Defect after `latest`: assess deprecation vs immediate 2.0.1; never overwrite/unpublish without explicit owner decision and npm-policy review.
49
+ - Final package defect before `latest`: do not promote; publish a new patch after correction because `2.1.0` cannot be replaced.
50
+ - Defect after `latest`: assess deprecation vs immediate patch; never overwrite/unpublish without explicit owner decision and current npm-policy review.
44
51
  - Migration issue: stop promotion, preserve registry artifact/checksum, use the documented rollback fixture, and publish a corrected version.
package/docs/SCHEMA.md CHANGED
@@ -46,9 +46,18 @@ Task is the atomic unit. A Task may have zero or one Sprint. A Task may have man
46
46
  | Field | Kinds | Presence | Type | Meaning |
47
47
  |---|---|---|---|---|
48
48
  | `attempt` | run | required | positive integer | monotonic execution-attempt number within one Task |
49
+ | `ledger` | run | optional | integer 1 | canonical Run event-ledger schema; required for newly authored Runs |
49
50
 
50
51
  Native creation uses the declared initial statuses. Migration may restore a historical non-initial status only with provenance and validation.
51
52
 
53
+ ## Run event ledger
54
+
55
+ Newly authored Runs use `ledger: 1`. Their `## Events` section contains append-only JSON event blocks with stable ids in the form `RUN-NNN-EVT-NNN`.
56
+
57
+ Every event requires `schema`, `id`, contiguous `sequence`, RFC3339 `occurred_at`, `timestamp_precision`, `actor`, `from`, `to`, `reason`, and structured `evidence`. Event types are `transition`, `snapshot`. Evidence kinds are `approval`, `command`, `test`, `file`, `review`, `decision`, `insight`, `risk`, `note`, `migration`, `legacy`.
58
+
59
+ A native ledger begins with `created → executing`; an evidenced migration `snapshot` may establish one historical baseline without inventing missing transitions. Event order, transition legality, final status, updated date, and completion evidence are machine-validated. Run owns the event history; Task stores its intended scope and synchronized current status without copying Run events.
60
+
52
61
  ## Truth questions
53
62
 
54
63
  - **feature:** Why does this initiative exist?
@@ -25,8 +25,20 @@ Rollback detected post-migration work that it would erase. Copy/export wanted ch
25
25
  npx scrumrun@latest sc knowledge map --build
26
26
  ```
27
27
 
28
+ Fresh projections include a source fingerprint and a watch fingerprint. A matching watch avoids a full read. If file metadata changed, ScrumRun hashes canonical/source content before deciding whether the projection is actually stale. `check: "schema"` means the disposable SQLite format changed and one rebuild is required; `check: "hash"` means the safe fallback was used.
29
+
28
30
  Stale generated views are warnings, not canonical corruption.
29
31
 
32
+ ## Doctor reports `TRANSACTION_PENDING`
33
+
34
+ An approved multi-file mutation was interrupted after its durable journal was prepared. Ordinary doctor/audit is read-only and leaves the evidence untouched. Either retry the same approved operation, which recovers before writing, or explicitly run:
35
+
36
+ ```bash
37
+ npx scrumrun@latest doctor codex --recover --strict
38
+ ```
39
+
40
+ Prepared transactions restore their original bytes; committed transactions verify the applied bytes and finalize. Recovery refuses if a target changed to content matching neither journal side, because that would overwrite later owner work. Receipts contain hashes and outcomes, not file contents.
41
+
30
42
  ## SQLite is missing/corrupt
31
43
 
32
44
  Delete or clear only the disposable cache:
@@ -9,7 +9,16 @@ const nouns = Object.freeze({
9
9
  task: ["--add [--type fix] [--status backlog]", "--list", "--show", "--run", "--audit", "--cancel", "--retry"],
10
10
  sprint: ["--add", "--list", "--show", "--start", "--complete", "--block"],
11
11
  feature: ["--add", "--list", "--show", "--activate", "--complete"],
12
- run: ["--list", "--show", "--validate", "--learn", "--complete", "--resume", "--fail", "--block"],
12
+ run: [
13
+ "--list",
14
+ "--show",
15
+ "--validate [--note] [--evidence] [--command] [--test] [--file] [--review] [--actor] [--at]",
16
+ "--learn [--note] [--evidence] [--decision] [--insight] [--file] [--actor] [--at]",
17
+ "--complete [--note] [--evidence] [--review] [--test] [--file] [--actor] [--at]",
18
+ "--resume [--note] [--evidence] [--risk] [--actor] [--at]",
19
+ "--fail [--note] [--evidence] [--risk] [--test] [--actor] [--at]",
20
+ "--block [--note] [--evidence] [--risk] [--actor] [--at]"
21
+ ],
13
22
  intake: ["<request>", "--request", "--approve"],
14
23
  challenge: ["<question>"]
15
24
  }
@@ -50,7 +59,7 @@ const nouns = Object.freeze({
50
59
  init: ["--local", "--shared", "--lean", "--no-agent-hint", "--force"],
51
60
  update: ["all [--migrate]", "codex [--migrate]", "opencode [--migrate]", "claude [--migrate]"],
52
61
  migrate: ["--to 2 --dry-run", "--to 2 --apply", "--to 2 --rollback"],
53
- doctor: ["all [--strict]", "codex [--strict]", "opencode [--strict]", "claude [--strict]"],
62
+ doctor: ["all [--strict] [--recover]", "codex [--strict] [--recover]", "opencode [--strict] [--recover]", "claude [--strict] [--recover]"],
54
63
  uninstall: ["--force"],
55
64
  help: ["<topic>"]
56
65
  }
@@ -31,7 +31,10 @@ ${grammarLines().join("\n")}
31
31
  - Intake, contextualization, policy, risk, classification, and planning are read-only until explicit approval.
32
32
  - Approved work creates/updates a Task and creates a Run; a Sprint only groups Tasks when a real timebox/batch exists.
33
33
  - Run execution follows \`executing → validating → learning → completed|failed|blocked\`.
34
+ - Run owns one structured, evidenced event ledger; Task synchronizes current status without copying Run history.
35
+ - Linked Task/Run writes use a durable recovery journal; audit reports pending recovery and never repairs without explicit authorization.
34
36
  - \`guardrails.md\` is canonical project policy; \`golden-rules.md\` is v1 compatibility only.
37
+ - Evaluate active Guardrails as \`passed\`, \`blocked\`, or \`deferred\`; cite exact \`GR-NNN\` ids and keep deferred execution gates visible.
35
38
  - Knowledge/Decision/Insight records require evidence; AI-proposed Insights remain \`candidate\` until confirmed.
36
39
  - Never print vault values or write before approval.
37
40
  - Unknown nouns, subjects, actions, ids, or ambiguous approval must produce a deterministic explanation, never a guessed mutation.
@@ -5,11 +5,14 @@ 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 = 3;
13
16
  const INACTIVE = new Set(["rejected", "invalidated", "deprecated", "archived"]);
14
17
 
15
18
  function indexPath(projectRoot) {
@@ -46,7 +49,7 @@ function memoryFingerprint(scrumDir) {
46
49
  return sha256(rows.join("\n"));
47
50
  }
48
51
 
49
- function sourceSnapshot(projectRoot) {
52
+ function sourceSnapshot(projectRoot, { watch = null } = {}) {
50
53
  const scrumDir = path.join(projectRoot, ".scrumrun");
51
54
  const artifacts = canonicalArtifacts(scrumDir);
52
55
  const code = scanProject(projectRoot);
@@ -54,8 +57,26 @@ function sourceSnapshot(projectRoot) {
54
57
  const codeRows = code.nodes
55
58
  .filter((node) => node.kind === "module")
56
59
  .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 };
60
+ const fingerprint = sha256([...artifactRows, ...codeRows].sort().join("\n"));
61
+ return { artifacts, code, fingerprint, watch: watch || sourceWatchSnapshot(projectRoot) };
62
+ }
63
+
64
+ function sourceWatchSnapshot(projectRoot) {
65
+ const scrumDir = path.join(projectRoot, ".scrumrun");
66
+ const canonical = canonicalWatchSnapshot(scrumDir);
67
+ const adapters = [new JavaScriptAdapter()];
68
+ const discovered = sourceFiles(path.resolve(projectRoot), adapters);
69
+ const code = fileWatchSnapshot(projectRoot, discovered.files.map((item) => item.file), adapters.map((adapter) => `adapter\0${adapter.id}`));
70
+ const rows = [
71
+ ...canonical.rows.map((row) => `canonical\0${row}`),
72
+ ...code.rows.map((row) => `code\0${row}`)
73
+ ];
74
+ return {
75
+ fingerprint: sha256(rows.sort().join("\n")),
76
+ canonicalFiles: canonical.files,
77
+ sourceFiles: code.files,
78
+ warnings: discovered.warnings
79
+ };
59
80
  }
60
81
 
61
82
  function explicitEdges(artifact) {
@@ -380,11 +401,13 @@ function rebuildIndex(projectRoot) {
380
401
  const insertInvalidation = database.prepare("INSERT INTO invalidations(artifact_id, reason, reference, artifact_hash) VALUES (?, ?, ?, ?)");
381
402
  for (const item of derived.invalidations) insertInvalidation.run(item.artifactId, item.reason, item.reference, item.artifactHash);
382
403
  const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
383
- metadata.run("schema_version", "2");
404
+ metadata.run("schema_version", String(INDEX_SCHEMA_VERSION));
384
405
  metadata.run("method_version", "2.0.0");
385
406
  metadata.run("source_fingerprint", snapshot.fingerprint);
407
+ metadata.run("source_watch_fingerprint", snapshot.watch.fingerprint);
386
408
  metadata.run("artifact_count", String(artifacts.length));
387
409
  metadata.run("code_node_count", String(remapped.nodes.length));
410
+ metadata.run("source_file_count", String(snapshot.watch.sourceFiles));
388
411
  metadata.run("code_adapter", snapshot.code.adapterIds.join(","));
389
412
  database.exec("COMMIT");
390
413
  } catch (error) {
@@ -399,7 +422,7 @@ function rebuildIndex(projectRoot) {
399
422
  if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
400
423
  throw error;
401
424
  }
402
- return { file: target, fingerprint: snapshot.fingerprint, artifacts: artifacts.length, codeNodes: remapped.nodes.length, warnings: snapshot.code.warnings };
425
+ return { file: target, fingerprint: snapshot.fingerprint, watchFingerprint: snapshot.watch.fingerprint, artifacts: artifacts.length, codeNodes: remapped.nodes.length, warnings: [...snapshot.code.warnings, ...snapshot.watch.warnings] };
403
426
  }
404
427
 
405
428
  function indexStatus(projectRoot) {
@@ -409,9 +432,26 @@ function indexStatus(projectRoot) {
409
432
  try {
410
433
  assertNoSymlinkPath(path.join(projectRoot, ".scrumrun"), file);
411
434
  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 };
435
+ const metadata = new Map(database.prepare("SELECT key, value FROM metadata").all().map((row) => [row.key, row.value]));
436
+ const stored = metadata.get("source_fingerprint") || null;
437
+ if (metadata.get("schema_version") !== String(INDEX_SCHEMA_VERSION) || !metadata.get("source_watch_fingerprint")) {
438
+ return { exists: true, stale: true, file, stored, check: "schema", reason: `semantic index schema ${INDEX_SCHEMA_VERSION} rebuild required` };
439
+ }
440
+ const watch = sourceWatchSnapshot(projectRoot);
441
+ if (metadata.get("source_watch_fingerprint") === watch.fingerprint) {
442
+ return { exists: true, stale: false, file, stored, actual: stored, watch: watch.fingerprint, check: "metadata" };
443
+ }
444
+ const actual = sourceSnapshot(projectRoot, { watch }).fingerprint;
445
+ return {
446
+ exists: true,
447
+ stale: !stored || stored !== actual,
448
+ file,
449
+ stored,
450
+ actual,
451
+ watch: watch.fingerprint,
452
+ check: "hash",
453
+ reason: stored === actual ? "file metadata changed but semantic content is unchanged" : "canonical or source content changed"
454
+ };
415
455
  } catch (error) {
416
456
  return { exists: true, stale: true, file, error: error.message };
417
457
  } finally {
@@ -571,7 +611,8 @@ function writeMap(projectRoot, { nodeLimit = 200, edgeLimit = 400 } = {}) {
571
611
  const body = [
572
612
  "# ScrumRun Project Map",
573
613
  "",
574
- `Generated: ${new Date().toISOString().slice(0, 10)}`,
614
+ "Projection schema: 1",
615
+ `Generated: ${new Date().toISOString()}`,
575
616
  `Source fingerprint: ${current.stored}`,
576
617
  "Authority: none; rebuild from canonical artifacts and source code.",
577
618
  "",
@@ -597,4 +638,22 @@ function writeMap(projectRoot, { nodeLimit = 200, edgeLimit = 400 } = {}) {
597
638
  return { file, nodes: graph.nodes.length, edges: graph.edges.length, fingerprint: current.stored };
598
639
  }
599
640
 
600
- module.exports = { CACHE_RELATIVE, graphIndex, indexPath, indexStatus, memoryFingerprint, queryIndex, rebuildIndex, sourceSnapshot, writeMap };
641
+ function mapStatus(projectRoot, { semanticStatus = null } = {}) {
642
+ const file = path.join(projectRoot, ".scrumrun", "map.md");
643
+ if (!fs.existsSync(file)) return { exists: false, stale: true, file, reason: "map.md is missing" };
644
+ try {
645
+ assertNoSymlinkPath(path.join(projectRoot, ".scrumrun"), file);
646
+ if (!fs.lstatSync(file).isFile()) return { exists: true, stale: true, file, reason: "map.md is not a regular file" };
647
+ const content = fs.readFileSync(file, "utf8");
648
+ const stored = (content.match(/^Source fingerprint:\s*([a-f0-9]{64})$/m) || [])[1] || null;
649
+ if (!stored) return { exists: true, stale: true, file, stored, reason: "map.md has no source fingerprint" };
650
+ const semantic = semanticStatus || indexStatus(projectRoot);
651
+ if (!semantic.exists) return { exists: true, stale: true, file, stored, reason: "semantic index is missing" };
652
+ if (semantic.stale) return { exists: true, stale: true, file, stored, reason: semantic.reason || "semantic index is stale" };
653
+ return { exists: true, stale: stored !== semantic.stored, file, stored, actual: semantic.stored, reason: stored === semantic.stored ? null : "map fingerprint differs from the semantic index" };
654
+ } catch (error) {
655
+ return { exists: true, stale: true, file, error: error.message, reason: "map freshness check failed" };
656
+ }
657
+ }
658
+
659
+ module.exports = { CACHE_RELATIVE, INDEX_SCHEMA_VERSION, graphIndex, indexPath, indexStatus, mapStatus, memoryFingerprint, queryIndex, rebuildIndex, sourceSnapshot, sourceWatchSnapshot, writeMap };
@@ -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 };