scrumrun 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/CORE.md +17 -3
- package/DECISIONS.md +56 -0
- package/MIGRATION-1-to-2.md +11 -0
- package/README.md +23 -5
- package/SPEC.md +30 -10
- package/bin/scrumrun.js +175 -11
- package/docs/COMMANDS.md +10 -4
- package/docs/ENTITY-MODEL.md +1 -1
- package/docs/RELEASE-SCORECARD.md +43 -0
- package/docs/RELEASE.md +19 -12
- package/docs/SCHEMA.md +11 -0
- package/docs/SEMANTIC-MEMORY.md +1 -1
- package/docs/TROUBLESHOOTING.md +13 -1
- package/lib/commands/manifest.js +15 -3
- package/lib/commands/render.js +4 -0
- package/lib/memory/index.js +201 -41
- package/lib/memory/service.js +3 -0
- package/lib/runtime/budgets.js +4 -0
- package/lib/runtime/canonical-snapshot.js +110 -0
- package/lib/runtime/context.js +5 -45
- package/lib/runtime/mutation-gateway.js +434 -0
- package/lib/runtime/orchestrator.js +130 -65
- package/lib/runtime/policy-engine.js +267 -0
- package/lib/runtime/request-engine.js +32 -24
- package/lib/runtime/review-service.js +92 -0
- package/lib/runtime/run-ledger.js +546 -0
- package/lib/runtime/workspace-state.js +146 -0
- package/lib/security/secrets.js +15 -1
- package/lib/v2/artifacts.js +24 -1
- package/lib/v2/conformance.js +78 -12
- package/lib/v2/migration.js +74 -10
- package/lib/v2/run-ledger-migration.js +268 -0
- package/lib/v2/schema.js +28 -1
- package/lib/v2/transaction.js +254 -0
- package/package.json +1 -1
- package/scripts/generate-contract-docs.js +11 -0
- package/templates/project/.scrumrun/guardrails.md +8 -0
- package/templates/project/.scrumrun/map.md +4 -3
- package/templates/project/.scrumrun/method.json +7 -1
- package/templates/project/.scrumrun/state.md +7 -14
- package/templates/project/AGENTS.md +2 -1
- package/templates/project-lean/AGENTS.md +3 -1
- package/templates/shared/skills/scrumrun/SKILL.md +19 -5
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const {
|
|
6
|
+
ArtifactRepository,
|
|
7
|
+
METHOD_VERSION,
|
|
8
|
+
assertNoSymlinkPath,
|
|
9
|
+
atomicWrite,
|
|
10
|
+
parseArtifact,
|
|
11
|
+
serializeArtifact,
|
|
12
|
+
sha256,
|
|
13
|
+
validateArtifact,
|
|
14
|
+
withArtifactLock
|
|
15
|
+
} = require("./artifacts");
|
|
16
|
+
const { RUN_LEDGER_VERSION } = require("./schema");
|
|
17
|
+
const { migrateLegacyRun, validateRunLedger } = require("../runtime/run-ledger");
|
|
18
|
+
const { normalizeGuardrailDocument, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
19
|
+
|
|
20
|
+
const MIGRATION_NAME = "run-ledger-v1";
|
|
21
|
+
const MIGRATION_DIR = path.join(".migration", MIGRATION_NAME);
|
|
22
|
+
const MANIFEST_PATH = path.join(MIGRATION_DIR, "manifest.json");
|
|
23
|
+
|
|
24
|
+
function posix(value) {
|
|
25
|
+
return value.split(path.sep).join("/");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function backupPath(relative) {
|
|
29
|
+
return posix(path.join(MIGRATION_DIR, "backup", relative));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readMarker(scrumDir) {
|
|
33
|
+
const file = assertNoSymlinkPath(scrumDir, path.join(scrumDir, "method.json"));
|
|
34
|
+
if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) throw new Error("Canonical method.json is missing or unsafe.");
|
|
35
|
+
try {
|
|
36
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
37
|
+
if (value.method !== METHOD_VERSION) throw new Error(`method must be ${METHOD_VERSION}`);
|
|
38
|
+
return { file, value, content: fs.readFileSync(file, "utf8") };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(`Canonical method.json is malformed: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function planRunLedgerMigration(projectRoot) {
|
|
45
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
46
|
+
if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) throw new Error("Not a ScrumRun project: .scrumrun/ is missing.");
|
|
47
|
+
const marker = readMarker(scrumDir);
|
|
48
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
49
|
+
const changes = [];
|
|
50
|
+
const warnings = [];
|
|
51
|
+
const errors = [];
|
|
52
|
+
|
|
53
|
+
const guardrailsFile = assertNoSymlinkPath(scrumDir, path.join(scrumDir, "guardrails.md"));
|
|
54
|
+
if (fs.existsSync(guardrailsFile) && fs.lstatSync(guardrailsFile).isFile()) {
|
|
55
|
+
const before = fs.readFileSync(guardrailsFile, "utf8");
|
|
56
|
+
const after = normalizeGuardrailDocument(before);
|
|
57
|
+
const validation = validateGuardrailDocument(after);
|
|
58
|
+
if (validation.errors.length) errors.push(`guardrails.md: ${validation.errors.join("; ")}`);
|
|
59
|
+
else if (before !== after) {
|
|
60
|
+
changes.push({
|
|
61
|
+
relative: "guardrails.md",
|
|
62
|
+
before,
|
|
63
|
+
after,
|
|
64
|
+
beforeSha256: sha256(before),
|
|
65
|
+
afterSha256: sha256(after),
|
|
66
|
+
mode: "guardrail-schema"
|
|
67
|
+
});
|
|
68
|
+
warnings.push("guardrails.md: inferred legacy policy fields were made explicit without deleting source prose.");
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
errors.push("guardrails.md is missing or unsafe.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const artifact of repository.list("run")) {
|
|
75
|
+
if (!artifact.record || artifact.errors.length) {
|
|
76
|
+
errors.push(`${path.relative(scrumDir, artifact.file)}: ${artifact.errors.join("; ")}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (artifact.record.ledger === RUN_LEDGER_VERSION) {
|
|
80
|
+
const ledger = validateRunLedger(artifact.record, artifact.body);
|
|
81
|
+
for (const error of ledger.errors) errors.push(`${artifact.record.id}: ${error}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const relative = posix(path.relative(scrumDir, artifact.file));
|
|
85
|
+
const before = fs.readFileSync(artifact.file, "utf8");
|
|
86
|
+
try {
|
|
87
|
+
const migrated = migrateLegacyRun(artifact.record, artifact.body, {
|
|
88
|
+
legacyHash: sha256(before),
|
|
89
|
+
backupRef: `.scrumrun/${backupPath(relative)}`
|
|
90
|
+
});
|
|
91
|
+
const after = serializeArtifact(migrated.record, migrated.body);
|
|
92
|
+
const parsed = parseArtifact(after);
|
|
93
|
+
const validation = [...parsed.errors, ...validateArtifact(parsed.record, "run"), ...validateRunLedger(parsed.record, parsed.body).errors];
|
|
94
|
+
if (validation.length) throw new Error(validation.join("; "));
|
|
95
|
+
changes.push({ relative, before, after, beforeSha256: sha256(before), afterSha256: sha256(after), mode: migrated.mode });
|
|
96
|
+
if (migrated.mode === "snapshot") warnings.push(`${artifact.record.id}: missing transition history is preserved as an evidenced snapshot, not reconstructed.`);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
errors.push(`${artifact.record.id}: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const markerValue = {
|
|
103
|
+
...marker.value,
|
|
104
|
+
schemas: {
|
|
105
|
+
...(marker.value.schemas || {}),
|
|
106
|
+
run_ledger: RUN_LEDGER_VERSION,
|
|
107
|
+
guardrails: 1,
|
|
108
|
+
run_obligations: 1,
|
|
109
|
+
mutation_gateway: 1
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const markerAfter = `${JSON.stringify(markerValue, null, 2)}\n`;
|
|
113
|
+
if (marker.content !== markerAfter) {
|
|
114
|
+
changes.push({
|
|
115
|
+
relative: "method.json",
|
|
116
|
+
before: marker.content,
|
|
117
|
+
after: markerAfter,
|
|
118
|
+
beforeSha256: sha256(marker.content),
|
|
119
|
+
afterSha256: sha256(markerAfter),
|
|
120
|
+
mode: "schema-marker"
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const fingerprint = sha256(changes.map((change) => `${change.relative}\0${change.beforeSha256}`).sort().join("\n"));
|
|
124
|
+
return {
|
|
125
|
+
projectRoot,
|
|
126
|
+
scrumDir,
|
|
127
|
+
status: errors.length ? "blocked" : changes.length ? "ready" : "current",
|
|
128
|
+
migration: MIGRATION_NAME,
|
|
129
|
+
ledger: RUN_LEDGER_VERSION,
|
|
130
|
+
fingerprint,
|
|
131
|
+
changes,
|
|
132
|
+
warnings,
|
|
133
|
+
errors
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function reportRunLedgerMigration(plan, status = plan.status) {
|
|
138
|
+
const mappings = plan.changes.length
|
|
139
|
+
? plan.changes.map((change) => `- \`${change.relative}\` · ${change.mode} · \`${change.beforeSha256.slice(0, 12)}\` → \`${change.afterSha256.slice(0, 12)}\``).join("\n")
|
|
140
|
+
: "- No changes required.";
|
|
141
|
+
const warnings = plan.warnings.length ? plan.warnings.map((warning) => `- ${warning}`).join("\n") : "- None.";
|
|
142
|
+
const errors = plan.errors.length ? plan.errors.map((error) => `- ${error}`).join("\n") : "- None.";
|
|
143
|
+
return `# ScrumRun Kernel Schema Migration\n\nStatus: ${status}\nRun ledger schema: ${RUN_LEDGER_VERSION}\nGuardrail schema: 1\nMutation Gateway schema: 1\nSource fingerprint: \`${plan.fingerprint}\`\nChanged files: ${plan.changes.length}\n\n## Mappings\n\n${mappings}\n\n## Warnings\n\n${warnings}\n\n## Blockers\n\n${errors}\n`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function manifestFile(scrumDir) {
|
|
147
|
+
return assertNoSymlinkPath(scrumDir, path.join(scrumDir, MANIFEST_PATH));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadManifest(scrumDir) {
|
|
151
|
+
const file = manifestFile(scrumDir);
|
|
152
|
+
if (!fs.existsSync(file)) return null;
|
|
153
|
+
if (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink()) throw new Error("Run ledger migration manifest is unsafe.");
|
|
154
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function backupChanges(plan) {
|
|
158
|
+
for (const change of plan.changes) {
|
|
159
|
+
const target = assertNoSymlinkPath(plan.scrumDir, path.join(plan.scrumDir, backupPath(change.relative)));
|
|
160
|
+
if (fs.existsSync(target)) {
|
|
161
|
+
if (sha256(fs.readFileSync(target)) !== change.beforeSha256) throw new Error(`Migration backup conflicts for ${change.relative}.`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
atomicWrite(target, change.before);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function verifyChanges(scrumDir, changes, field) {
|
|
169
|
+
const problems = [];
|
|
170
|
+
for (const change of changes) {
|
|
171
|
+
const file = assertNoSymlinkPath(scrumDir, path.join(scrumDir, change.relative));
|
|
172
|
+
const actual = fs.existsSync(file) && fs.lstatSync(file).isFile() ? sha256(fs.readFileSync(file)) : "missing";
|
|
173
|
+
if (actual !== change[field]) problems.push(`${change.relative}: expected ${change[field]}, found ${actual}`);
|
|
174
|
+
}
|
|
175
|
+
return problems;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function restoreChanges(scrumDir, changes) {
|
|
179
|
+
for (const change of [...changes].reverse()) {
|
|
180
|
+
const backup = assertNoSymlinkPath(scrumDir, path.join(scrumDir, backupPath(change.relative)));
|
|
181
|
+
if (!fs.existsSync(backup) || sha256(fs.readFileSync(backup)) !== change.beforeSha256) {
|
|
182
|
+
throw new Error(`Run ledger backup is missing or corrupt for ${change.relative}.`);
|
|
183
|
+
}
|
|
184
|
+
const target = assertNoSymlinkPath(scrumDir, path.join(scrumDir, change.relative));
|
|
185
|
+
atomicWrite(target, fs.readFileSync(backup));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function recoverPrepared(scrumDir, manifest) {
|
|
190
|
+
if (!manifest || manifest.status !== "prepared") return false;
|
|
191
|
+
const allowed = [];
|
|
192
|
+
for (const change of manifest.changes) {
|
|
193
|
+
const file = path.join(scrumDir, change.relative);
|
|
194
|
+
const actual = fs.existsSync(file) ? sha256(fs.readFileSync(file)) : "missing";
|
|
195
|
+
if (![change.beforeSha256, change.afterSha256].includes(actual)) allowed.push(`${change.relative}: unexpected ${actual}`);
|
|
196
|
+
}
|
|
197
|
+
if (allowed.length) throw new Error(`Interrupted Run ledger migration cannot recover safely:\n${allowed.join("\n")}`);
|
|
198
|
+
restoreChanges(scrumDir, manifest.changes);
|
|
199
|
+
atomicWrite(manifestFile(scrumDir), `${JSON.stringify({ ...manifest, status: "recovered", recovered_at: new Date().toISOString() }, null, 2)}\n`);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function applyRunLedgerMigration(projectRoot, { failurePoint = null } = {}) {
|
|
204
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
205
|
+
const preview = planRunLedgerMigration(projectRoot);
|
|
206
|
+
if (preview.errors.length) throw new Error(`Run ledger migration is blocked:\n${preview.errors.join("\n")}`);
|
|
207
|
+
if (!preview.changes.length) return { status: "current", plan: preview, output: reportRunLedgerMigration(preview, "current") };
|
|
208
|
+
return withArtifactLock(scrumDir, "run-ledger-migration", () => {
|
|
209
|
+
const existing = loadManifest(scrumDir);
|
|
210
|
+
if (existing && existing.status === "prepared") recoverPrepared(scrumDir, existing);
|
|
211
|
+
const plan = planRunLedgerMigration(projectRoot);
|
|
212
|
+
if (plan.errors.length) throw new Error(`Run ledger migration is blocked:\n${plan.errors.join("\n")}`);
|
|
213
|
+
if (!plan.changes.length) return { status: "current", plan, output: reportRunLedgerMigration(plan, "current") };
|
|
214
|
+
const changed = verifyChanges(scrumDir, plan.changes, "beforeSha256");
|
|
215
|
+
if (changed.length) throw new Error(`Run ledger source changed after preflight:\n${changed.join("\n")}`);
|
|
216
|
+
backupChanges(plan);
|
|
217
|
+
const manifest = {
|
|
218
|
+
migration: MIGRATION_NAME,
|
|
219
|
+
schema: RUN_LEDGER_VERSION,
|
|
220
|
+
status: "prepared",
|
|
221
|
+
prepared_at: new Date().toISOString(),
|
|
222
|
+
source_fingerprint: plan.fingerprint,
|
|
223
|
+
changes: plan.changes.map(({ relative, beforeSha256, afterSha256, mode }) => ({ relative, beforeSha256, afterSha256, mode }))
|
|
224
|
+
};
|
|
225
|
+
atomicWrite(manifestFile(scrumDir), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
226
|
+
try {
|
|
227
|
+
for (let index = 0; index < plan.changes.length; index++) {
|
|
228
|
+
const change = plan.changes[index];
|
|
229
|
+
const target = assertNoSymlinkPath(scrumDir, path.join(scrumDir, change.relative));
|
|
230
|
+
atomicWrite(target, change.after);
|
|
231
|
+
if (failurePoint === `after-${index + 1}`) throw new Error(`Injected Run ledger migration failure after write ${index + 1}.`);
|
|
232
|
+
}
|
|
233
|
+
const afterProblems = verifyChanges(scrumDir, plan.changes, "afterSha256");
|
|
234
|
+
if (afterProblems.length) throw new Error(`Run ledger migration verification failed:\n${afterProblems.join("\n")}`);
|
|
235
|
+
const applied = { ...manifest, status: "applied", applied_at: new Date().toISOString() };
|
|
236
|
+
atomicWrite(manifestFile(scrumDir), `${JSON.stringify(applied, null, 2)}\n`);
|
|
237
|
+
return { status: "applied", plan, manifest: applied, output: reportRunLedgerMigration(plan, "applied") };
|
|
238
|
+
} catch (error) {
|
|
239
|
+
restoreChanges(scrumDir, manifest.changes);
|
|
240
|
+
atomicWrite(manifestFile(scrumDir), `${JSON.stringify({ ...manifest, status: "recovered", recovered_at: new Date().toISOString() }, null, 2)}\n`);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function rollbackRunLedgerMigration(projectRoot) {
|
|
247
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
248
|
+
return withArtifactLock(scrumDir, "run-ledger-migration", () => {
|
|
249
|
+
const manifest = loadManifest(scrumDir);
|
|
250
|
+
if (!manifest || !["applied", "prepared"].includes(manifest.status)) throw new Error("No applied Run ledger migration is available for rollback.");
|
|
251
|
+
if (manifest.status === "applied") {
|
|
252
|
+
const changed = verifyChanges(scrumDir, manifest.changes, "afterSha256");
|
|
253
|
+
if (changed.length) throw new Error(`Rollback would erase post-migration changes:\n${changed.join("\n")}`);
|
|
254
|
+
}
|
|
255
|
+
restoreChanges(scrumDir, manifest.changes);
|
|
256
|
+
const rolledBack = { ...manifest, status: "rolled-back", rolled_back_at: new Date().toISOString() };
|
|
257
|
+
atomicWrite(manifestFile(scrumDir), `${JSON.stringify(rolledBack, null, 2)}\n`);
|
|
258
|
+
return { status: "rolled-back", manifest: rolledBack };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
MIGRATION_NAME,
|
|
264
|
+
applyRunLedgerMigration,
|
|
265
|
+
planRunLedgerMigration,
|
|
266
|
+
reportRunLedgerMigration,
|
|
267
|
+
rollbackRunLedgerMigration
|
|
268
|
+
};
|
package/lib/v2/schema.js
CHANGED
|
@@ -7,6 +7,26 @@ function deepFreeze(value) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const METHOD_VERSION = "2.0.0";
|
|
10
|
+
const RUN_LEDGER_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
const RUN_EVENT_TYPES = deepFreeze(["transition", "snapshot", "guardrail", "mutation"]);
|
|
13
|
+
const RUN_EVIDENCE_KINDS = deepFreeze([
|
|
14
|
+
"approval",
|
|
15
|
+
"command",
|
|
16
|
+
"test",
|
|
17
|
+
"file",
|
|
18
|
+
"review",
|
|
19
|
+
"decision",
|
|
20
|
+
"insight",
|
|
21
|
+
"risk",
|
|
22
|
+
"note",
|
|
23
|
+
"migration",
|
|
24
|
+
"legacy",
|
|
25
|
+
"guardrail",
|
|
26
|
+
"mutation"
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const GUARDRAIL_RESULTS = deepFreeze(["pending", "passed", "blocked"]);
|
|
10
30
|
|
|
11
31
|
const ARTIFACT_TYPES = deepFreeze({
|
|
12
32
|
feature: {
|
|
@@ -92,7 +112,10 @@ const STRUCTURAL_RELATIONS = deepFreeze({
|
|
|
92
112
|
});
|
|
93
113
|
|
|
94
114
|
const SCALAR_FIELDS = deepFreeze({
|
|
95
|
-
attempt: { kinds: ["run"], required: true, type: "positive integer", meaning: "monotonic execution-attempt number within one Task" }
|
|
115
|
+
attempt: { kinds: ["run"], required: true, type: "positive integer", meaning: "monotonic execution-attempt number within one Task" },
|
|
116
|
+
ledger: { kinds: ["run"], required: false, type: `integer ${RUN_LEDGER_VERSION}`, meaning: "canonical Run event-ledger schema; required for newly authored Runs" },
|
|
117
|
+
guardrails: { kinds: ["run"], required: false, type: "integer 1", meaning: "append-only Guardrail obligation schema" },
|
|
118
|
+
workspace: { kinds: ["run"], required: false, type: "integer 1", meaning: "workspace mutation-gateway schema" }
|
|
96
119
|
});
|
|
97
120
|
|
|
98
121
|
const TRUTH_OWNERSHIP = deepFreeze({
|
|
@@ -120,7 +143,11 @@ module.exports = {
|
|
|
120
143
|
ARTIFACT_TRANSITIONS,
|
|
121
144
|
ARTIFACT_TYPES,
|
|
122
145
|
AUTHORITY,
|
|
146
|
+
GUARDRAIL_RESULTS,
|
|
123
147
|
METHOD_VERSION,
|
|
148
|
+
RUN_EVENT_TYPES,
|
|
149
|
+
RUN_EVIDENCE_KINDS,
|
|
150
|
+
RUN_LEDGER_VERSION,
|
|
124
151
|
SCALAR_FIELDS,
|
|
125
152
|
STRUCTURAL_RELATIONS,
|
|
126
153
|
TRUTH_OWNERSHIP,
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const {
|
|
7
|
+
assertNoSymlinkPath,
|
|
8
|
+
atomicWrite,
|
|
9
|
+
sha256,
|
|
10
|
+
withArtifactLock
|
|
11
|
+
} = require("./artifacts");
|
|
12
|
+
|
|
13
|
+
const TRANSACTION_SCHEMA = 1;
|
|
14
|
+
const TRANSACTION_ROOT = path.join(".backup", "transactions");
|
|
15
|
+
const PENDING_DIR = path.join(TRANSACTION_ROOT, "pending");
|
|
16
|
+
const RECEIPT_DIR = path.join(TRANSACTION_ROOT, "receipts");
|
|
17
|
+
const FORBIDDEN = [".backup/", ".cache/", ".migration/", "vault.local.md"];
|
|
18
|
+
|
|
19
|
+
function posix(value) {
|
|
20
|
+
return value.split(path.sep).join("/");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function transactionId() {
|
|
24
|
+
return `TXN-${Date.now()}-${crypto.randomBytes(6).toString("hex")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeTransactionPath(scrumDir, relative) {
|
|
28
|
+
return assertNoSymlinkPath(scrumDir, path.join(scrumDir, relative));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function relativeTarget(scrumDir, file) {
|
|
32
|
+
const target = assertNoSymlinkPath(scrumDir, path.resolve(file));
|
|
33
|
+
const relative = posix(path.relative(path.resolve(scrumDir), target));
|
|
34
|
+
if (!relative || relative.startsWith("../") || path.isAbsolute(relative)) throw new Error(`Transaction target escapes .scrumrun: ${file}`);
|
|
35
|
+
if (FORBIDDEN.some((entry) => relative === entry.replace(/\/$/, "") || relative.startsWith(entry))) {
|
|
36
|
+
throw new Error(`Transaction target is outside the canonical mutation boundary: ${relative}`);
|
|
37
|
+
}
|
|
38
|
+
return { relative, file: target };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function snapshot(content) {
|
|
42
|
+
if (content === null) return { exists: false, sha256: "missing", content_base64: null };
|
|
43
|
+
const value = Buffer.isBuffer(content) ? content : Buffer.from(String(content));
|
|
44
|
+
return { exists: true, sha256: sha256(value), content_base64: value.toString("base64") };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fileSnapshot(scrumDir, relative) {
|
|
48
|
+
const file = safeTransactionPath(scrumDir, relative);
|
|
49
|
+
if (!fs.existsSync(file)) return snapshot(null);
|
|
50
|
+
if (!fs.lstatSync(file).isFile()) throw new Error(`Transaction target is not a regular file: ${relative}`);
|
|
51
|
+
return snapshot(fs.readFileSync(file));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sameSnapshot(left, right) {
|
|
55
|
+
return Boolean(left && right && left.exists === right.exists && left.sha256 === right.sha256);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function applySnapshot(scrumDir, relative, value) {
|
|
59
|
+
const file = safeTransactionPath(scrumDir, relative);
|
|
60
|
+
if (!value.exists) {
|
|
61
|
+
if (fs.existsSync(file)) fs.rmSync(file, { force: true });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
atomicWrite(file, Buffer.from(value.content_base64, "base64"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pendingDirectory(scrumDir) {
|
|
68
|
+
return safeTransactionPath(scrumDir, PENDING_DIR);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function receipt(transaction, status, details = {}) {
|
|
72
|
+
return {
|
|
73
|
+
schema: TRANSACTION_SCHEMA,
|
|
74
|
+
id: transaction.id,
|
|
75
|
+
name: transaction.name,
|
|
76
|
+
status,
|
|
77
|
+
created_at: transaction.created_at,
|
|
78
|
+
finished_at: new Date().toISOString(),
|
|
79
|
+
changes: transaction.changes.map((change) => ({
|
|
80
|
+
relative: change.relative,
|
|
81
|
+
before_sha256: change.before.sha256,
|
|
82
|
+
after_sha256: change.after.sha256
|
|
83
|
+
})),
|
|
84
|
+
...details
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function finishJournal(scrumDir, journalFile, transaction, status, details = {}) {
|
|
89
|
+
const receiptFile = safeTransactionPath(scrumDir, path.join(RECEIPT_DIR, `${transaction.id}.json`));
|
|
90
|
+
atomicWrite(receiptFile, `${JSON.stringify(receipt(transaction, status, details), null, 2)}\n`);
|
|
91
|
+
fs.rmSync(journalFile, { force: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pendingTransactions(scrumDir) {
|
|
95
|
+
const directory = pendingDirectory(scrumDir);
|
|
96
|
+
if (!fs.existsSync(directory)) return [];
|
|
97
|
+
if (!fs.lstatSync(directory).isDirectory()) throw new Error("Transaction pending path is not a directory.");
|
|
98
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (entry.isSymbolicLink() || !entry.isFile() || !/^TXN-[a-zA-Z0-9-]+\.json$/.test(entry.name)) {
|
|
101
|
+
throw new Error(`Unexpected transaction journal entry: ${entry.name}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return entries.map((entry) => {
|
|
105
|
+
const file = safeTransactionPath(scrumDir, path.join(PENDING_DIR, entry.name));
|
|
106
|
+
try {
|
|
107
|
+
const transaction = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
108
|
+
return { file, transaction };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(`Transaction journal is malformed: ${entry.name}: ${error.message}`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateJournal(transaction) {
|
|
116
|
+
const errors = [];
|
|
117
|
+
if (!transaction || transaction.schema !== TRANSACTION_SCHEMA) errors.push(`schema must be ${TRANSACTION_SCHEMA}`);
|
|
118
|
+
if (!/^TXN-[a-zA-Z0-9-]+$/.test(transaction && transaction.id || "")) errors.push("id is invalid");
|
|
119
|
+
if (!transaction || !["prepared", "committed"].includes(transaction.status)) errors.push("status must be prepared or committed");
|
|
120
|
+
if (!transaction || !Array.isArray(transaction.changes) || !transaction.changes.length) errors.push("changes must be a non-empty array");
|
|
121
|
+
for (const change of transaction && Array.isArray(transaction.changes) ? transaction.changes : []) {
|
|
122
|
+
if (typeof change.relative !== "string" || !change.relative) errors.push("change.relative is required");
|
|
123
|
+
for (const key of ["before", "after"]) {
|
|
124
|
+
const value = change[key];
|
|
125
|
+
if (!value || typeof value.exists !== "boolean" || typeof value.sha256 !== "string") errors.push(`${change.relative || "change"}.${key} is malformed`);
|
|
126
|
+
if (value && value.exists && typeof value.content_base64 !== "string") errors.push(`${change.relative || "change"}.${key} content is missing`);
|
|
127
|
+
if (value && value.exists && typeof value.content_base64 === "string") {
|
|
128
|
+
const decoded = Buffer.from(value.content_base64, "base64");
|
|
129
|
+
if (decoded.toString("base64") !== value.content_base64 || sha256(decoded) !== value.sha256) {
|
|
130
|
+
errors.push(`${change.relative || "change"}.${key} content hash is invalid`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (value && !value.exists && (value.sha256 !== "missing" || value.content_base64 !== null)) {
|
|
134
|
+
errors.push(`${change.relative || "change"}.${key} missing snapshot is invalid`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return errors;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function recoverPendingTransactionsUnlocked(scrumDir) {
|
|
142
|
+
const recovered = [];
|
|
143
|
+
for (const { file, transaction } of pendingTransactions(scrumDir)) {
|
|
144
|
+
const errors = validateJournal(transaction);
|
|
145
|
+
if (errors.length) throw new Error(`Unsafe transaction journal ${path.basename(file)}: ${errors.join("; ")}`);
|
|
146
|
+
for (const change of transaction.changes) {
|
|
147
|
+
const target = relativeTarget(scrumDir, path.join(scrumDir, change.relative));
|
|
148
|
+
if (target.relative !== change.relative) throw new Error(`Unsafe transaction target in ${transaction.id}: ${change.relative}`);
|
|
149
|
+
}
|
|
150
|
+
const current = transaction.changes.map((change) => ({ change, value: fileSnapshot(scrumDir, change.relative) }));
|
|
151
|
+
const unexpected = current.filter(({ change, value }) => !sameSnapshot(value, change.before) && !sameSnapshot(value, change.after));
|
|
152
|
+
if (unexpected.length) {
|
|
153
|
+
throw new Error(`Pending transaction ${transaction.id} cannot recover without overwriting owner changes: ${unexpected.map(({ change }) => change.relative).join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
if (transaction.status === "prepared") {
|
|
156
|
+
for (const change of [...transaction.changes].reverse()) applySnapshot(scrumDir, change.relative, change.before);
|
|
157
|
+
const unrestored = transaction.changes.filter((change) => !sameSnapshot(fileSnapshot(scrumDir, change.relative), change.before));
|
|
158
|
+
if (unrestored.length) throw new Error(`Transaction ${transaction.id} recovery verification failed: ${unrestored.map((change) => change.relative).join(", ")}`);
|
|
159
|
+
finishJournal(scrumDir, file, transaction, "recovered", { action: "rolled-back-prepared" });
|
|
160
|
+
recovered.push({ id: transaction.id, action: "rolled-back" });
|
|
161
|
+
} else {
|
|
162
|
+
const incomplete = current.filter(({ change, value }) => !sameSnapshot(value, change.after));
|
|
163
|
+
if (incomplete.length) throw new Error(`Committed transaction ${transaction.id} is incomplete: ${incomplete.map(({ change }) => change.relative).join(", ")}`);
|
|
164
|
+
finishJournal(scrumDir, file, transaction, "committed", { action: "verified-commit" });
|
|
165
|
+
recovered.push({ id: transaction.id, action: "verified" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return recovered;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function recoverPendingTransactions(scrumDir) {
|
|
172
|
+
return withArtifactLock(scrumDir, "kernel", () => recoverPendingTransactionsUnlocked(scrumDir));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeChanges(scrumDir, changes) {
|
|
176
|
+
if (!Array.isArray(changes) || !changes.length) throw new Error("Kernel transaction requires at least one change.");
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
return changes.map((change) => {
|
|
179
|
+
const target = relativeTarget(scrumDir, change.file);
|
|
180
|
+
if (seen.has(target.relative)) throw new Error(`Duplicate transaction target: ${target.relative}`);
|
|
181
|
+
seen.add(target.relative);
|
|
182
|
+
if (!Object.prototype.hasOwnProperty.call(change, "previous") || !Object.prototype.hasOwnProperty.call(change, "next")) {
|
|
183
|
+
throw new Error(`Transaction change requires previous and next content: ${target.relative}`);
|
|
184
|
+
}
|
|
185
|
+
return { relative: target.relative, before: snapshot(change.previous), after: snapshot(change.next) };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function writeKernelTransactionUnlocked(scrumDir, name, changes, { failurePoint = null, interruptPoint = null } = {}) {
|
|
190
|
+
if (!/^[a-z0-9-]+$/i.test(name || "")) throw new Error(`Invalid transaction name: ${name || "missing"}`);
|
|
191
|
+
const normalized = normalizeChanges(scrumDir, changes);
|
|
192
|
+
const conflicts = normalized.filter((change) => !sameSnapshot(fileSnapshot(scrumDir, change.relative), change.before));
|
|
193
|
+
if (conflicts.length) throw new Error(`Transaction source changed before prepare: ${conflicts.map((change) => change.relative).join(", ")}`);
|
|
194
|
+
const transaction = {
|
|
195
|
+
schema: TRANSACTION_SCHEMA,
|
|
196
|
+
id: transactionId(),
|
|
197
|
+
name,
|
|
198
|
+
status: "prepared",
|
|
199
|
+
created_at: new Date().toISOString(),
|
|
200
|
+
changes: normalized
|
|
201
|
+
};
|
|
202
|
+
const journalFile = safeTransactionPath(scrumDir, path.join(PENDING_DIR, `${transaction.id}.json`));
|
|
203
|
+
atomicWrite(journalFile, `${JSON.stringify(transaction, null, 2)}\n`);
|
|
204
|
+
if (interruptPoint === "after-prepare") throw Object.assign(new Error("Simulated interruption after transaction prepare."), { code: "SCRUMRUN_INTERRUPTED" });
|
|
205
|
+
try {
|
|
206
|
+
for (let index = 0; index < transaction.changes.length; index++) {
|
|
207
|
+
const change = transaction.changes[index];
|
|
208
|
+
applySnapshot(scrumDir, change.relative, change.after);
|
|
209
|
+
if (interruptPoint === `after-${index + 1}`) {
|
|
210
|
+
throw Object.assign(new Error(`Simulated interruption after transaction write ${index + 1}.`), { code: "SCRUMRUN_INTERRUPTED" });
|
|
211
|
+
}
|
|
212
|
+
if (failurePoint === `after-${index + 1}`) throw new Error(`Injected transaction failure after write ${index + 1}.`);
|
|
213
|
+
}
|
|
214
|
+
const invalid = transaction.changes.filter((change) => !sameSnapshot(fileSnapshot(scrumDir, change.relative), change.after));
|
|
215
|
+
if (invalid.length) throw new Error(`Transaction verification failed: ${invalid.map((change) => change.relative).join(", ")}`);
|
|
216
|
+
const committed = { ...transaction, status: "committed", committed_at: new Date().toISOString() };
|
|
217
|
+
atomicWrite(journalFile, `${JSON.stringify(committed, null, 2)}\n`);
|
|
218
|
+
if (interruptPoint === "after-commit") {
|
|
219
|
+
throw Object.assign(new Error("Simulated interruption after transaction commit."), { code: "SCRUMRUN_INTERRUPTED" });
|
|
220
|
+
}
|
|
221
|
+
finishJournal(scrumDir, journalFile, committed, "committed", { action: "applied" });
|
|
222
|
+
return { id: transaction.id, status: "committed", changes: transaction.changes.length };
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error.code === "SCRUMRUN_INTERRUPTED") throw error;
|
|
225
|
+
for (const change of [...transaction.changes].reverse()) applySnapshot(scrumDir, change.relative, change.before);
|
|
226
|
+
finishJournal(scrumDir, journalFile, transaction, "recovered", { action: "rolled-back-error", error: error.message });
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function runKernelTransaction(scrumDir, name, changes, options = {}) {
|
|
232
|
+
return withArtifactLock(scrumDir, "kernel", () => {
|
|
233
|
+
const recovered = recoverPendingTransactionsUnlocked(scrumDir);
|
|
234
|
+
const result = writeKernelTransactionUnlocked(scrumDir, name, changes, options);
|
|
235
|
+
return { ...result, recovered };
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function pendingTransactionStatus(scrumDir) {
|
|
240
|
+
try {
|
|
241
|
+
return { pending: pendingTransactions(scrumDir).map(({ transaction }) => ({ id: transaction.id, name: transaction.name, status: transaction.status })) };
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return { pending: [], error: error.message };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
TRANSACTION_SCHEMA,
|
|
249
|
+
pendingTransactionStatus,
|
|
250
|
+
recoverPendingTransactions,
|
|
251
|
+
recoverPendingTransactionsUnlocked,
|
|
252
|
+
runKernelTransaction,
|
|
253
|
+
writeKernelTransactionUnlocked
|
|
254
|
+
};
|
package/package.json
CHANGED
|
@@ -8,6 +8,9 @@ const {
|
|
|
8
8
|
ARTIFACT_TYPES,
|
|
9
9
|
AUTHORITY,
|
|
10
10
|
METHOD_VERSION,
|
|
11
|
+
RUN_EVENT_TYPES,
|
|
12
|
+
RUN_EVIDENCE_KINDS,
|
|
13
|
+
RUN_LEDGER_VERSION,
|
|
11
14
|
SCALAR_FIELDS,
|
|
12
15
|
STRUCTURAL_RELATIONS,
|
|
13
16
|
TRUTH_OWNERSHIP
|
|
@@ -76,6 +79,14 @@ ${scalarRows.join("\n")}
|
|
|
76
79
|
|
|
77
80
|
Native creation uses the declared initial statuses. Migration may restore a historical non-initial status only with provenance and validation.
|
|
78
81
|
|
|
82
|
+
## Run event ledger
|
|
83
|
+
|
|
84
|
+
Newly authored Runs use \`ledger: ${RUN_LEDGER_VERSION}\`. Their \`## Events\` section contains append-only JSON event blocks with stable ids in the form \`RUN-NNN-EVT-NNN\`.
|
|
85
|
+
|
|
86
|
+
Every event requires \`schema\`, \`id\`, contiguous \`sequence\`, RFC3339 \`occurred_at\`, \`timestamp_precision\`, \`actor\`, \`from\`, \`to\`, \`reason\`, and structured \`evidence\`. Event types are ${RUN_EVENT_TYPES.map((value) => `\`${value}\``).join(", ")}. Evidence kinds are ${RUN_EVIDENCE_KINDS.map((value) => `\`${value}\``).join(", ")}.
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
79
90
|
## Truth questions
|
|
80
91
|
|
|
81
92
|
${Object.entries(TRUTH_OWNERSHIP).map(([kind, owner]) => `- **${kind}:** ${owner.question}`).join("\n")}
|
|
@@ -5,19 +5,27 @@ Canonical project policy. Universal method invariants live in `core.md`; this fi
|
|
|
5
5
|
## GR-001 - Protect secrets
|
|
6
6
|
|
|
7
7
|
Status: active
|
|
8
|
+
Enforcement: builtin:secret-boundary
|
|
9
|
+
Scope: all
|
|
8
10
|
Rule: Never commit or print real secrets. Keep local development values in `vault.local.md` and runtime values in environment/config.
|
|
9
11
|
|
|
10
12
|
## GR-002 - Preserve owner work
|
|
11
13
|
|
|
12
14
|
Status: active
|
|
15
|
+
Enforcement: builtin:owner-work
|
|
16
|
+
Scope: all
|
|
13
17
|
Rule: Never overwrite unrelated or pre-existing owner changes. Canonical mutations must be scoped, lossless, validated, and recoverable.
|
|
14
18
|
|
|
15
19
|
## GR-003 - Respect read-only paths
|
|
16
20
|
|
|
17
21
|
Status: active
|
|
22
|
+
Enforcement: builtin:read-only-path
|
|
23
|
+
Scope: all
|
|
18
24
|
Rule: Never modify a path marked read-only by the owner or project configuration.
|
|
19
25
|
|
|
20
26
|
## GR-004 - Approval gates execution
|
|
21
27
|
|
|
22
28
|
Status: active
|
|
29
|
+
Enforcement: builtin:approval-gate
|
|
30
|
+
Scope: intake, execution
|
|
23
31
|
Rule: Intake remains read-only. Create/update a Task and create a Run only after explicit valid approval.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
# ScrumRun Map
|
|
1
|
+
# ScrumRun Project Map
|
|
2
2
|
|
|
3
3
|
Generated: {{DATE}}
|
|
4
|
+
Status: stale
|
|
4
5
|
Authority: none; rebuild from canonical artifacts and source code.
|
|
5
6
|
|
|
6
|
-
##
|
|
7
|
+
## Summary
|
|
7
8
|
|
|
8
|
-
-
|
|
9
|
+
- No source fingerprint exists until `/sc knowledge map --build` creates the disposable semantic index and this bounded view.
|