scrumrun 2.2.0 → 2.4.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.
@@ -13,6 +13,7 @@ const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = req
13
13
  const { pendingTransactionStatus } = require("./transaction");
14
14
  const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
15
15
  const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
16
+ const { canonicalPaths, PATHS_SCHEMA_VERSION } = require("./paths");
16
17
 
17
18
  const INVARIANTS = Object.freeze([
18
19
  { id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
@@ -35,13 +36,33 @@ const INVARIANTS = Object.freeze([
35
36
  { id: "I-18", summary: "unsafe and partial writes fail safely", tests: ["canonical writes reject traversal and symlink paths", "repository refuses conflicting overwrite", "interrupted kernel transaction is recovered"] },
36
37
  { id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
37
38
  { id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] },
38
- { id: "I-21", summary: "material mutations are scoped, policy-bound, and fail closed", tests: ["Mutation Gateway rejects bypass and out-of-scope writes", "Run completion rejects unresolved Guardrail obligations"] }
39
+ { id: "I-21", summary: "material mutations are scoped, policy-bound, and fail closed", tests: ["Mutation Gateway rejects bypass and out-of-scope writes", "Run completion rejects unresolved Guardrail obligations"] },
40
+ { id: "I-22", summary: "declared search backend matches observed runtime capabilities", tests: ["conformance detects a semantic index that declares fts5 when the runtime does not provide it"] },
41
+ { id: "I-23", summary: "method.json declares canonical paths so agents navigate by index, not by search", tests: ["method.json path index is present, well-formed, and matches the canonical layout"] }
39
42
  ]);
40
43
 
41
44
  function finding(severity, code, message, file = null) {
42
45
  return { severity, code, message, ...(file ? { file } : {}) };
43
46
  }
44
47
 
48
+ function diffPathIndex(expected, actual, prefix = "") {
49
+ const drift = [];
50
+ for (const [key, value] of Object.entries(expected)) {
51
+ const label = prefix ? `${prefix}.${key}` : key;
52
+ const actualValue = actual && typeof actual === "object" ? actual[key] : undefined;
53
+ if (value && typeof value === "object" && !Array.isArray(value)) {
54
+ if (!actualValue || typeof actualValue !== "object" || Array.isArray(actualValue)) {
55
+ drift.push({ label, expected: "<object>", actual: actualValue });
56
+ continue;
57
+ }
58
+ drift.push(...diffPathIndex(value, actualValue, label));
59
+ } else if (actualValue !== value) {
60
+ drift.push({ label, expected: value, actual: actualValue });
61
+ }
62
+ }
63
+ return drift;
64
+ }
65
+
45
66
  function auditProject(projectRoot) {
46
67
  const scrumDir = path.join(projectRoot, ".scrumrun");
47
68
  const findings = [];
@@ -55,6 +76,16 @@ function auditProject(projectRoot) {
55
76
  if (!fs.existsSync(marker) || !fs.lstatSync(marker).isFile()) throw new Error("marker is missing, not a regular file, or is a symbolic link");
56
77
  methodMarker = JSON.parse(fs.readFileSync(marker, "utf8"));
57
78
  if (methodMarker.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
79
+ const expectedPaths = canonicalPaths();
80
+ if (!methodMarker.paths || typeof methodMarker.paths !== "object" || Array.isArray(methodMarker.paths)) {
81
+ findings.push(finding("high", "METHOD_PATHS_MISSING", `method.json must declare a canonical "paths" block so agents navigate by declaration, not by search. Run \`npx scrumrun@latest update --migrate\` to backfill.`, marker));
82
+ } else {
83
+ const drift = diffPathIndex(expectedPaths, methodMarker.paths);
84
+ for (const entry of drift) findings.push(finding("high", "METHOD_PATHS_DRIFT", `method.json paths[${entry.label}] is ${entry.actual === undefined ? "missing" : `"${entry.actual}"`}; expected "${entry.expected}". Regenerate via \`update --migrate\`.`, marker));
85
+ if (methodMarker.paths_schema !== PATHS_SCHEMA_VERSION) {
86
+ findings.push(finding("warning", "METHOD_PATHS_SCHEMA", `method.json paths_schema is ${methodMarker.paths_schema || "missing"}; expected ${PATHS_SCHEMA_VERSION}.`, marker));
87
+ }
88
+ }
58
89
  } catch (error) {
59
90
  findings.push(finding("critical", "METHOD_MARKER", `method.json is missing or malformed: ${error.message}`, marker));
60
91
  }
@@ -213,7 +244,11 @@ function auditProject(projectRoot) {
213
244
  }
214
245
  try {
215
246
  const semantic = indexStatus(projectRoot);
216
- if (semantic.exists && semantic.stale) findings.push(finding("warning", "INDEX_STALE", "semantic-index.sqlite is stale and will be rebuilt on query."));
247
+ if (semantic.exists && semantic.backendMismatch) {
248
+ findings.push(finding("high", "SEARCH_BACKEND_MISMATCH", `semantic index declares search_backend=${semantic.searchBackend} but the current Node.js runtime does not provide it; delete .scrumrun/.cache/semantic-index.sqlite to rebuild against the observed capabilities.`));
249
+ } else if (semantic.exists && semantic.stale) {
250
+ findings.push(finding("warning", "INDEX_STALE", "semantic-index.sqlite is stale and will be rebuilt on query."));
251
+ }
217
252
  const map = mapStatus(projectRoot, { semanticStatus: semantic });
218
253
  if (semantic.exists && map.stale) findings.push(finding("warning", "MAP_STALE", `map.md is stale: ${map.reason || map.error || "unknown reason"}.`));
219
254
  } catch (error) {
@@ -18,6 +18,7 @@ const {
18
18
  } = require("./artifacts");
19
19
  const { inferEnforcement, normalizeGuardrailDocument } = require("../runtime/policy-engine");
20
20
  const { RUN_LEDGER_VERSION } = require("./schema");
21
+ const { renderMethodJson } = require("./paths");
21
22
  const { migrateLegacyRun } = require("../runtime/run-ledger");
22
23
 
23
24
  const MIGRATION_NAME = "v1-to-v2";
@@ -927,13 +928,12 @@ function migrationPlan(projectRoot) {
927
928
  }
928
929
 
929
930
  const sourceLayout = existingIds.size ? "hybrid-v1-v2" : "1.x";
930
- generated.set("method.json", `${JSON.stringify({
931
- method: METHOD_VERSION,
932
- layout: "v2",
931
+ generated.set("method.json", renderMethodJson({
932
+ methodVersion: METHOD_VERSION,
933
933
  schemas: { run_ledger: RUN_LEDGER_VERSION, guardrails: 1, run_obligations: 1, mutation_gateway: 1 },
934
- migrated_from: sourceLayout,
934
+ migratedFrom: sourceLayout,
935
935
  migration: MIGRATION_NAME
936
- }, null, 2)}\n`);
936
+ }));
937
937
 
938
938
  const counts = {};
939
939
  for (const artifact of artifacts) counts[artifact.kind] = (counts[artifact.kind] || 0) + 1;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+
3
+ // Single source of truth for the canonical layout inside `.scrumrun/`.
4
+ //
5
+ // This module powers `method.json.paths` — the machine-readable index a
6
+ // ScrumRun-aware agent must consult BEFORE searching. Any change here
7
+ // must land in the template, the migration generator, and the doctor
8
+ // invariant together so no drift is possible.
9
+
10
+ const CANONICAL_PATHS = Object.freeze({
11
+ guardrails: "guardrails.md",
12
+ config: "config.md",
13
+ project: "project.md",
14
+ core: "core.md",
15
+ state_view: "state.md",
16
+ map_view: "map.md",
17
+ tasks: "tasks/",
18
+ sprints: "sprints/",
19
+ features: "features/",
20
+ runs: "runs/",
21
+ reviews: "reviews/",
22
+ memory: Object.freeze({
23
+ knowledge: "memory/knowledge/",
24
+ decisions: "memory/decisions/",
25
+ insights: "memory/insights/",
26
+ dossiers: "memory/dossiers/"
27
+ }),
28
+ vault_local: "vault.local.md",
29
+ cache: ".cache/"
30
+ });
31
+
32
+ const PATHS_SCHEMA_VERSION = 1;
33
+
34
+ function clone(value) {
35
+ if (value === null || typeof value !== "object") return value;
36
+ if (Array.isArray(value)) return value.map(clone);
37
+ const out = {};
38
+ for (const [key, entry] of Object.entries(value)) out[key] = clone(entry);
39
+ return out;
40
+ }
41
+
42
+ function canonicalPaths() {
43
+ return clone(CANONICAL_PATHS);
44
+ }
45
+
46
+ function flattenPaths(paths = CANONICAL_PATHS, prefix = "") {
47
+ const out = [];
48
+ for (const [key, value] of Object.entries(paths)) {
49
+ const label = prefix ? `${prefix}.${key}` : key;
50
+ if (value && typeof value === "object" && !Array.isArray(value)) {
51
+ out.push(...flattenPaths(value, label));
52
+ } else if (typeof value === "string") {
53
+ out.push({ label, relative: value });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function renderMethodJson({ methodVersion, layout = "v2", schemas = {}, migratedFrom, migration } = {}) {
60
+ const payload = {
61
+ method: methodVersion,
62
+ layout,
63
+ paths_schema: PATHS_SCHEMA_VERSION,
64
+ paths: canonicalPaths(),
65
+ schemas
66
+ };
67
+ if (migratedFrom) payload.migrated_from = migratedFrom;
68
+ if (migration) payload.migration = migration;
69
+ return `${JSON.stringify(payload, null, 2)}\n`;
70
+ }
71
+
72
+ module.exports = { CANONICAL_PATHS, PATHS_SCHEMA_VERSION, canonicalPaths, flattenPaths, renderMethodJson };
@@ -236,6 +236,64 @@ function runKernelTransaction(scrumDir, name, changes, options = {}) {
236
236
  });
237
237
  }
238
238
 
239
+ function previewPendingRecovery(scrumDir) {
240
+ const preview = { plans: [], errors: [] };
241
+ let entries;
242
+ try {
243
+ entries = pendingTransactions(scrumDir);
244
+ } catch (error) {
245
+ preview.errors.push(error.message);
246
+ return preview;
247
+ }
248
+ for (const { file, transaction } of entries) {
249
+ const plan = { id: transaction.id, name: transaction.name || null, journal: path.basename(file), status: transaction.status, action: null, files: [], warnings: [] };
250
+ const journalErrors = validateJournal(transaction);
251
+ if (journalErrors.length) {
252
+ plan.action = "blocked";
253
+ plan.warnings.push(`journal validation failed: ${journalErrors.join("; ")}`);
254
+ preview.plans.push(plan);
255
+ continue;
256
+ }
257
+ let unsafePath = null;
258
+ for (const change of transaction.changes) {
259
+ const target = relativeTarget(scrumDir, path.join(scrumDir, change.relative));
260
+ if (target.relative !== change.relative) {
261
+ unsafePath = change.relative;
262
+ break;
263
+ }
264
+ }
265
+ if (unsafePath) {
266
+ plan.action = "blocked";
267
+ plan.warnings.push(`unsafe transaction target: ${unsafePath}`);
268
+ preview.plans.push(plan);
269
+ continue;
270
+ }
271
+ const current = transaction.changes.map((change) => ({ change, value: fileSnapshot(scrumDir, change.relative) }));
272
+ const unexpected = current.filter(({ change, value }) => !sameSnapshot(value, change.before) && !sameSnapshot(value, change.after));
273
+ if (unexpected.length) {
274
+ plan.action = "blocked";
275
+ plan.warnings.push(`would overwrite owner changes at: ${unexpected.map(({ change }) => change.relative).join(", ")}`);
276
+ preview.plans.push(plan);
277
+ continue;
278
+ }
279
+ if (transaction.status === "prepared") {
280
+ plan.action = "rollback";
281
+ plan.files = transaction.changes.map((change) => ({ relative: change.relative, from: "current", to: "pre-transaction" }));
282
+ } else {
283
+ const incomplete = current.filter(({ change, value }) => !sameSnapshot(value, change.after));
284
+ if (incomplete.length) {
285
+ plan.action = "blocked";
286
+ plan.warnings.push(`committed transaction is incomplete: ${incomplete.map(({ change }) => change.relative).join(", ")}`);
287
+ } else {
288
+ plan.action = "verify-commit";
289
+ plan.files = transaction.changes.map((change) => ({ relative: change.relative, from: "current", to: "verified-commit" }));
290
+ }
291
+ }
292
+ preview.plans.push(plan);
293
+ }
294
+ return preview;
295
+ }
296
+
239
297
  function pendingTransactionStatus(scrumDir) {
240
298
  try {
241
299
  return { pending: pendingTransactions(scrumDir).map(({ transaction }) => ({ id: transaction.id, name: transaction.name, status: transaction.status })) };
@@ -247,6 +305,7 @@ function pendingTransactionStatus(scrumDir) {
247
305
  module.exports = {
248
306
  TRANSACTION_SCHEMA,
249
307
  pendingTransactionStatus,
308
+ previewPendingRecovery,
250
309
  recoverPendingTransactions,
251
310
  recoverPendingTransactionsUnlocked,
252
311
  runKernelTransaction,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "method": "2.0.0",
3
3
  "layout": "v2",
4
+ "paths_schema": 1,
5
+ "paths": {
6
+ "guardrails": "guardrails.md",
7
+ "config": "config.md",
8
+ "project": "project.md",
9
+ "core": "core.md",
10
+ "state_view": "state.md",
11
+ "map_view": "map.md",
12
+ "tasks": "tasks/",
13
+ "sprints": "sprints/",
14
+ "features": "features/",
15
+ "runs": "runs/",
16
+ "reviews": "reviews/",
17
+ "memory": {
18
+ "knowledge": "memory/knowledge/",
19
+ "decisions": "memory/decisions/",
20
+ "insights": "memory/insights/",
21
+ "dossiers": "memory/dossiers/"
22
+ },
23
+ "vault_local": "vault.local.md",
24
+ "cache": ".cache/"
25
+ },
4
26
  "schemas": {
5
27
  "run_ledger": 1,
6
28
  "guardrails": 1,
@@ -29,11 +29,12 @@ Apply instructions in this order:
29
29
 
30
30
  Normal hot path:
31
31
 
32
- 1. read `AGENTS.md`;
33
- 2. read `.scrumrun/guardrails.md`;
34
- 3. read `.scrumrun/state.md`;
35
- 4. follow the ids/pointers to only the relevant canonical artifacts;
36
- 5. load `.scrumrun/core.md` when the method contract or an exceptional transition is needed.
32
+ 1. read `.scrumrun/method.json` — its `paths` block is the authoritative index of every canonical location; navigate by that index and never grep for legacy paths (`goals/`, `backlog.md`, `sprint.md`, `history.md`);
33
+ 2. read `AGENTS.md`;
34
+ 3. read `.scrumrun/guardrails.md`;
35
+ 4. read `.scrumrun/state.md`;
36
+ 5. follow the ids/pointers to only the relevant canonical artifacts;
37
+ 6. load `.scrumrun/core.md` when the method contract or an exceptional transition is needed.
37
38
 
38
39
  Lean mode is a read policy, not an incomplete store. Generated files and `.scrumrun/.cache/` are never authoritative.
39
40