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.
- package/CHANGELOG.md +39 -0
- package/CORE.md +2 -0
- package/README.md +59 -1
- package/SPEC.md +3 -1
- package/bin/scrumrun.js +62 -4
- package/docs/DEMO.md +67 -0
- package/docs/ERROR-CODES.md +141 -0
- package/docs/INDEX.md +53 -0
- package/docs/QUICKSTART.md +156 -0
- package/lib/commands/manifest.js +3 -1
- package/lib/commands/run-render.js +161 -0
- package/lib/commands/run-stats.js +206 -0
- package/lib/errors.js +98 -0
- package/lib/memory/index.js +1 -1
- package/lib/v2/conformance.js +37 -2
- package/lib/v2/migration.js +5 -5
- package/lib/v2/paths.js +72 -0
- package/lib/v2/transaction.js +59 -0
- package/package.json +1 -1
- package/templates/project/.scrumrun/method.json +22 -0
- package/templates/shared/skills/scrumrun/SKILL.md +6 -5
package/lib/v2/conformance.js
CHANGED
|
@@ -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.
|
|
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) {
|
package/lib/v2/migration.js
CHANGED
|
@@ -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",
|
|
931
|
-
|
|
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
|
-
|
|
934
|
+
migratedFrom: sourceLayout,
|
|
935
935
|
migration: MIGRATION_NAME
|
|
936
|
-
}
|
|
936
|
+
}));
|
|
937
937
|
|
|
938
938
|
const counts = {};
|
|
939
939
|
for (const artifact of artifacts) counts[artifact.kind] = (counts[artifact.kind] || 0) + 1;
|
package/lib/v2/paths.js
ADDED
|
@@ -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 };
|
package/lib/v2/transaction.js
CHANGED
|
@@ -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,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 `
|
|
33
|
-
2. read
|
|
34
|
-
3. read `.scrumrun/
|
|
35
|
-
4.
|
|
36
|
-
5.
|
|
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
|
|