scrumrun 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CORE.md +17 -3
  3. package/DECISIONS.md +56 -0
  4. package/MIGRATION-1-to-2.md +11 -0
  5. package/README.md +23 -5
  6. package/SPEC.md +30 -10
  7. package/bin/scrumrun.js +175 -11
  8. package/docs/COMMANDS.md +10 -4
  9. package/docs/ENTITY-MODEL.md +1 -1
  10. package/docs/RELEASE-SCORECARD.md +43 -0
  11. package/docs/RELEASE.md +19 -12
  12. package/docs/SCHEMA.md +11 -0
  13. package/docs/SEMANTIC-MEMORY.md +1 -1
  14. package/docs/TROUBLESHOOTING.md +13 -1
  15. package/lib/commands/manifest.js +15 -3
  16. package/lib/commands/render.js +4 -0
  17. package/lib/memory/index.js +201 -41
  18. package/lib/memory/service.js +3 -0
  19. package/lib/runtime/budgets.js +4 -0
  20. package/lib/runtime/canonical-snapshot.js +110 -0
  21. package/lib/runtime/context.js +5 -45
  22. package/lib/runtime/mutation-gateway.js +434 -0
  23. package/lib/runtime/orchestrator.js +130 -65
  24. package/lib/runtime/policy-engine.js +267 -0
  25. package/lib/runtime/request-engine.js +32 -24
  26. package/lib/runtime/review-service.js +92 -0
  27. package/lib/runtime/run-ledger.js +546 -0
  28. package/lib/runtime/workspace-state.js +146 -0
  29. package/lib/security/secrets.js +15 -1
  30. package/lib/v2/artifacts.js +24 -1
  31. package/lib/v2/conformance.js +78 -12
  32. package/lib/v2/migration.js +74 -10
  33. package/lib/v2/run-ledger-migration.js +268 -0
  34. package/lib/v2/schema.js +28 -1
  35. package/lib/v2/transaction.js +254 -0
  36. package/package.json +1 -1
  37. package/scripts/generate-contract-docs.js +11 -0
  38. package/templates/project/.scrumrun/guardrails.md +8 -0
  39. package/templates/project/.scrumrun/map.md +4 -3
  40. package/templates/project/.scrumrun/method.json +7 -1
  41. package/templates/project/.scrumrun/state.md +7 -14
  42. package/templates/project/AGENTS.md +2 -1
  43. package/templates/project-lean/AGENTS.md +3 -1
  44. package/templates/shared/skills/scrumrun/SKILL.md +19 -5
@@ -0,0 +1,546 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+
5
+ const {
6
+ ARTIFACT_TRANSITIONS,
7
+ GUARDRAIL_RESULTS,
8
+ RUN_EVENT_TYPES,
9
+ RUN_EVIDENCE_KINDS,
10
+ RUN_LEDGER_VERSION
11
+ } = require("../v2/schema");
12
+ const { containsSecret } = require("../security/secrets");
13
+
14
+ const EVENT_ID = /^(RUN-\d{3,})-EVT-(\d{3,})$/;
15
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/;
16
+
17
+ function instant(value = new Date()) {
18
+ const parsed = value instanceof Date ? value : new Date(value);
19
+ if (!Number.isFinite(parsed.getTime())) throw new Error("Run event timestamp is invalid.");
20
+ return parsed.toISOString();
21
+ }
22
+
23
+ function dateInstant(value) {
24
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value || "")) throw new Error(`Legacy Run event date is invalid: ${value || "missing"}`);
25
+ return `${value}T00:00:00.000Z`;
26
+ }
27
+
28
+ function eventId(runId, sequence) {
29
+ return `${runId}-EVT-${String(sequence).padStart(3, "0")}`;
30
+ }
31
+
32
+ function normalizeEvidence(evidence = []) {
33
+ if (!Array.isArray(evidence)) throw new Error("Run event evidence must be an array.");
34
+ return evidence.map((item) => {
35
+ if (typeof item === "string") return { kind: "note", summary: item.trim() };
36
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
37
+ const normalized = { kind: item.kind };
38
+ if (item.ref !== undefined && item.ref !== null) normalized.ref = String(item.ref).trim();
39
+ if (item.summary !== undefined && item.summary !== null) normalized.summary = String(item.summary).trim();
40
+ return normalized;
41
+ });
42
+ }
43
+
44
+ function evidenceForTransition(nextStatus, note, evidence = []) {
45
+ const normalized = normalizeEvidence(evidence);
46
+ if (note && !normalized.length) {
47
+ const kind = nextStatus === "validating" ? "test" : nextStatus === "learning" ? "insight" : nextStatus === "blocked" || nextStatus === "failed" ? "risk" : "note";
48
+ normalized.push({ kind, summary: String(note).trim() });
49
+ }
50
+ return normalized;
51
+ }
52
+
53
+ function makeRunEvent({
54
+ runId,
55
+ sequence,
56
+ type = "transition",
57
+ occurredAt = instant(),
58
+ timestampPrecision = "instant",
59
+ actor = "agent",
60
+ from,
61
+ to,
62
+ reason,
63
+ evidence = [],
64
+ ...details
65
+ }) {
66
+ return {
67
+ schema: RUN_LEDGER_VERSION,
68
+ id: eventId(runId, sequence),
69
+ sequence,
70
+ type,
71
+ occurred_at: occurredAt,
72
+ timestamp_precision: timestampPrecision,
73
+ actor,
74
+ from: from === undefined ? null : from,
75
+ to,
76
+ reason,
77
+ evidence: normalizeEvidence(evidence),
78
+ ...details
79
+ };
80
+ }
81
+
82
+ function renderRunEvent(event) {
83
+ return `### ${event.id}\n\n\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``;
84
+ }
85
+
86
+ function eventsSection(events) {
87
+ return `## Events\n\n${events.map(renderRunEvent).join("\n\n")}`;
88
+ }
89
+
90
+ function runTitle(body, fallback) {
91
+ return ((body || "").match(/^# ([^\r\n]+)$/m) || [])[1] || fallback;
92
+ }
93
+
94
+ function createRunBody(record, {
95
+ title = `Run for ${record.task}`,
96
+ approvalId = null,
97
+ occurredAt = instant(),
98
+ actor = "owner",
99
+ reason = "Explicit approval accepted.",
100
+ evidence = null,
101
+ obligations = [],
102
+ policyFingerprint = null,
103
+ workspaceBaseline = null
104
+ } = {}) {
105
+ const eventEvidence = evidence || [{
106
+ kind: "approval",
107
+ ...(approvalId ? { ref: `approval:${approvalId}` } : {}),
108
+ summary: reason
109
+ }];
110
+ const event = makeRunEvent({
111
+ runId: record.id,
112
+ sequence: 1,
113
+ occurredAt,
114
+ actor,
115
+ from: "created",
116
+ to: "executing",
117
+ reason,
118
+ evidence: eventEvidence,
119
+ ...(policyFingerprint ? { policy_fingerprint: policyFingerprint } : {}),
120
+ ...(workspaceBaseline ? { workspace_baseline: workspaceBaseline } : {})
121
+ });
122
+ const declarations = obligations.map((obligation, index) => makeRunEvent({
123
+ runId: record.id,
124
+ sequence: index + 2,
125
+ type: "guardrail",
126
+ occurredAt,
127
+ actor: "policy-engine",
128
+ from: "executing",
129
+ to: "executing",
130
+ reason: `Guardrail obligation declared for ${obligation.gate}.`,
131
+ evidence: [{ kind: "guardrail", ref: obligation.guardrail, summary: `${obligation.code} must pass at ${obligation.gate}.` }],
132
+ guardrail: obligation.guardrail,
133
+ code: obligation.code,
134
+ enforcement: obligation.enforcement,
135
+ gate: obligation.gate,
136
+ result: "pending",
137
+ scope: obligation.scope || []
138
+ }));
139
+ const events = [event, ...declarations];
140
+ return { body: `# ${title}\n\n${eventsSection(events)}`, event, events };
141
+ }
142
+
143
+ function validateWorkspaceState(value, label) {
144
+ const errors = [];
145
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [`${label} must be an object`];
146
+ if (value.schema !== 1) errors.push(`${label}.schema must be 1`);
147
+ if (!['git', 'files'].includes(value.mode)) errors.push(`${label}.mode must be git or files`);
148
+ if (!/^[a-f0-9]{64}$/.test(value.fingerprint || "")) errors.push(`${label}.fingerprint must be sha256`);
149
+ if (!Array.isArray(value.files)) errors.push(`${label}.files must be an array`);
150
+ const paths = new Set();
151
+ for (const item of Array.isArray(value.files) ? value.files : []) {
152
+ if (!item || typeof item !== "object" || typeof item.path !== "string" || !item.path || item.path.startsWith("/") || item.path.split("/").includes("..")) {
153
+ errors.push(`${label}.files contains an unsafe path`);
154
+ continue;
155
+ }
156
+ if (paths.has(item.path)) errors.push(`${label}.files contains duplicate path: ${item.path}`);
157
+ paths.add(item.path);
158
+ if (item.sha256 !== null && !/^[a-f0-9]{64}$/.test(item.sha256 || "")) errors.push(`${label}.${item.path}.sha256 is invalid`);
159
+ if (!["file", "missing", "symlink", "other", "clean"].includes(item.kind)) errors.push(`${label}.${item.path}.kind is invalid`);
160
+ if (item.mode !== null && (!Number.isInteger(item.mode) || item.mode < 0 || item.mode > 0o777)) errors.push(`${label}.${item.path}.mode is invalid`);
161
+ if (!["text", "binary", "too-large", "not-applicable"].includes(item.scan)) errors.push(`${label}.${item.path}.scan is invalid`);
162
+ if (item.kind === "file" && !["text", "binary", "too-large"].includes(item.scan)) errors.push(`${label}.${item.path}.scan is invalid for a file`);
163
+ if (item.kind !== "file" && item.scan !== "not-applicable") errors.push(`${label}.${item.path}.scan must be not-applicable`);
164
+ }
165
+ const canonical = value && {
166
+ schema: value.schema,
167
+ mode: value.mode,
168
+ head: value.head || null,
169
+ files: Array.isArray(value.files) ? value.files.map((item) => ({ path: item.path, sha256: item.sha256, kind: item.kind, mode: item.mode, scan: item.scan })) : []
170
+ };
171
+ const fingerprint = crypto.createHash("sha256").update(stableJson(canonical)).digest("hex");
172
+ if (value && value.fingerprint !== fingerprint) errors.push(`${label}.fingerprint does not match its workspace state`);
173
+ return errors;
174
+ }
175
+
176
+ function validateGuardrailEvent(event, declared) {
177
+ const errors = [];
178
+ if (!/^GR-\d{3,}$/.test(event.guardrail || "")) errors.push(`${event.id}.guardrail is invalid`);
179
+ if (!GUARDRAIL_RESULTS.includes(event.result)) errors.push(`${event.id}.result is invalid: ${event.result || "missing"}`);
180
+ if (typeof event.code !== "string" || !event.code.trim()) errors.push(`${event.id}.code is required`);
181
+ if (typeof event.enforcement !== "string" || !event.enforcement.trim()) errors.push(`${event.id}.enforcement is required`);
182
+ if (typeof event.gate !== "string" || !event.gate.trim()) errors.push(`${event.id}.gate is required`);
183
+ if (!Array.isArray(event.scope)) errors.push(`${event.id}.scope must be an array`);
184
+ if (event.result === "pending") {
185
+ if (declared.has(event.guardrail)) errors.push(`${event.id} redeclares ${event.guardrail}`);
186
+ declared.set(event.guardrail, { code: event.code, enforcement: event.enforcement, gate: event.gate, scope: event.scope || [] });
187
+ } else if (!declared.has(event.guardrail)) {
188
+ errors.push(`${event.id} resolves undeclared ${event.guardrail}`);
189
+ } else {
190
+ const original = declared.get(event.guardrail);
191
+ for (const field of ["code", "enforcement", "gate"]) {
192
+ if (event[field] !== original[field]) errors.push(`${event.id}.${field} disagrees with the ${event.guardrail} declaration`);
193
+ }
194
+ if (stableJson(event.scope || []) !== stableJson(original.scope)) errors.push(`${event.id}.scope disagrees with the ${event.guardrail} declaration`);
195
+ }
196
+ return errors;
197
+ }
198
+
199
+ function stableJson(value) {
200
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
201
+ if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
202
+ return JSON.stringify(value);
203
+ }
204
+
205
+ function safeEventPath(value) {
206
+ return typeof value === "string" && value && !value.startsWith("/") && !value.split("/").includes("..") && value !== ".scrumrun" && !value.startsWith(".scrumrun/");
207
+ }
208
+
209
+ function validateMutationEvent(event, expectedWorkspace, policyFingerprint) {
210
+ const errors = [];
211
+ if (!/^MUT-[A-Za-z0-9-]{8,}$/.test(event.mutation_id || "")) errors.push(`${event.id}.mutation_id is invalid`);
212
+ if (!Array.isArray(event.paths) || !event.paths.length) errors.push(`${event.id}.paths must be a non-empty array`);
213
+ if (!Array.isArray(event.changes) || !event.changes.length) errors.push(`${event.id}.changes must be a non-empty array`);
214
+ if (!/^[a-f0-9]{64}$/.test(event.workspace_before || "")) errors.push(`${event.id}.workspace_before must be sha256`);
215
+ if (expectedWorkspace && event.workspace_before !== expectedWorkspace) errors.push(`${event.id}.workspace_before breaks the mutation chain`);
216
+ if (event.policy_fingerprint !== policyFingerprint) errors.push(`${event.id}.policy_fingerprint disagrees with Run approval`);
217
+ const paths = new Set();
218
+ for (const value of Array.isArray(event.paths) ? event.paths : []) {
219
+ if (!safeEventPath(value)) errors.push(`${event.id}.paths contains an unsafe path`);
220
+ if (paths.has(value)) errors.push(`${event.id}.paths contains duplicate path: ${value}`);
221
+ paths.add(value);
222
+ }
223
+ const changed = new Set();
224
+ for (const change of Array.isArray(event.changes) ? event.changes : []) {
225
+ if (!change || !safeEventPath(change.path)) {
226
+ errors.push(`${event.id}.changes contains an unsafe path`);
227
+ continue;
228
+ }
229
+ if (changed.has(change.path)) errors.push(`${event.id}.changes contains duplicate path: ${change.path}`);
230
+ changed.add(change.path);
231
+ if (![...paths].some((allowed) => change.path === allowed || change.path.startsWith(`${allowed}/`))) errors.push(`${event.id}.${change.path} is outside declared paths`);
232
+ for (const field of ["before_sha256", "after_sha256"]) {
233
+ if (change[field] !== null && !/^[a-f0-9]{64}$/.test(change[field] || "")) errors.push(`${event.id}.${change.path}.${field} is invalid`);
234
+ }
235
+ for (const field of ["before_scan", "after_scan"]) {
236
+ if (!["text", "binary", "too-large", "not-applicable"].includes(change[field])) errors.push(`${event.id}.${change.path}.${field} is invalid`);
237
+ }
238
+ for (const field of ["before_mode", "after_mode"]) {
239
+ if (change[field] !== null && (!Number.isInteger(change[field]) || change[field] < 0 || change[field] > 0o777)) errors.push(`${event.id}.${change.path}.${field} is invalid`);
240
+ }
241
+ }
242
+ if (!event.workspace_after) errors.push(`${event.id}.workspace_after is required`);
243
+ else errors.push(...validateWorkspaceState(event.workspace_after, `${event.id}.workspace_after`));
244
+ return errors;
245
+ }
246
+
247
+ function parseRunLedger(body) {
248
+ const events = [];
249
+ const errors = [];
250
+ const headings = [...String(body || "").matchAll(/^### (RUN-\d{3,}-EVT-\d{3,})[ \t]*$/gm)];
251
+ const blocks = [...String(body || "").matchAll(/^### (RUN-\d{3,}-EVT-\d{3,})[ \t]*\r?\n[ \t]*\r?\n```json[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/gm)];
252
+ if (headings.length !== blocks.length) errors.push("one or more Run event blocks are malformed");
253
+ for (const block of blocks) {
254
+ try {
255
+ const event = JSON.parse(block[2]);
256
+ if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("event payload must be an object");
257
+ if (event.id !== block[1]) errors.push(`${block[1]} payload id does not match its heading`);
258
+ events.push(event);
259
+ } catch (error) {
260
+ errors.push(`${block[1]} contains invalid JSON: ${error.message}`);
261
+ }
262
+ }
263
+ return { events, errors };
264
+ }
265
+
266
+ function validateEvidence(event) {
267
+ const errors = [];
268
+ if (!Array.isArray(event.evidence)) return [`${event.id}.evidence must be an array`];
269
+ for (let index = 0; index < event.evidence.length; index++) {
270
+ const item = event.evidence[index];
271
+ const label = `${event.id}.evidence[${index}]`;
272
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
273
+ errors.push(`${label} must be an object`);
274
+ continue;
275
+ }
276
+ if (!RUN_EVIDENCE_KINDS.includes(item.kind)) errors.push(`${label}.kind is invalid: ${item.kind || "missing"}`);
277
+ if (!(typeof item.ref === "string" && item.ref.trim()) && !(typeof item.summary === "string" && item.summary.trim())) {
278
+ errors.push(`${label} requires ref or summary`);
279
+ }
280
+ }
281
+ return errors;
282
+ }
283
+
284
+ function validateRunLedger(record, body) {
285
+ const parsed = parseRunLedger(body);
286
+ const errors = [...parsed.errors];
287
+ const events = parsed.events;
288
+ if (record.ledger !== RUN_LEDGER_VERSION) errors.push(`ledger must be ${RUN_LEDGER_VERSION}`);
289
+ if (!events.length) errors.push("Run ledger has no events");
290
+ const ids = new Set();
291
+ let previous = null;
292
+ let previousTime = null;
293
+ const declared = new Map();
294
+ const obligationResults = new Map();
295
+ let expectedWorkspace = events[0] && events[0].workspace_baseline && events[0].workspace_baseline.fingerprint;
296
+ const policyFingerprint = events[0] && events[0].policy_fingerprint;
297
+ for (let index = 0; index < events.length; index++) {
298
+ const event = events[index];
299
+ const expectedSequence = index + 1;
300
+ const match = EVENT_ID.exec(event.id || "");
301
+ if (!match || match[1] !== record.id) errors.push(`invalid event id for ${record.id}: ${event.id || "missing"}`);
302
+ if (ids.has(event.id)) errors.push(`duplicate Run event id: ${event.id}`);
303
+ ids.add(event.id);
304
+ if (event.schema !== RUN_LEDGER_VERSION) errors.push(`${event.id}.schema must be ${RUN_LEDGER_VERSION}`);
305
+ if (event.sequence !== expectedSequence || (match && Number(match[2]) !== expectedSequence)) {
306
+ errors.push(`${event.id}.sequence must be contiguous from 1; expected ${expectedSequence}`);
307
+ }
308
+ if (!RUN_EVENT_TYPES.includes(event.type)) errors.push(`${event.id}.type is invalid: ${event.type || "missing"}`);
309
+ if (!RFC3339.test(event.occurred_at || "") || !Number.isFinite(Date.parse(event.occurred_at))) {
310
+ errors.push(`${event.id}.occurred_at must be a real RFC3339 timestamp`);
311
+ } else {
312
+ const currentTime = Date.parse(event.occurred_at);
313
+ if (previousTime !== null && currentTime < previousTime) errors.push(`${event.id}.occurred_at precedes the previous event`);
314
+ previousTime = currentTime;
315
+ }
316
+ if (!["instant", "date"].includes(event.timestamp_precision)) errors.push(`${event.id}.timestamp_precision must be instant or date`);
317
+ if (typeof event.actor !== "string" || !event.actor.trim()) errors.push(`${event.id}.actor is required`);
318
+ if (typeof event.reason !== "string" || !event.reason.trim()) errors.push(`${event.id}.reason is required`);
319
+ if (containsSecret(JSON.stringify(event))) errors.push(`${event.id} contains secret-like content`);
320
+ errors.push(...validateEvidence(event));
321
+
322
+ if (index === 0 && event.type === "snapshot") {
323
+ if (event.from !== null) errors.push(`${event.id} snapshot.from must be null`);
324
+ if (!event.evidence.some((item) => item && ["migration", "legacy"].includes(item.kind))) {
325
+ errors.push(`${event.id} snapshot requires migration or legacy evidence`);
326
+ }
327
+ } else if (["guardrail", "mutation"].includes(event.type)) {
328
+ if (!previous) errors.push(`${event.id} cannot precede the initial Run event`);
329
+ const currentState = previous && previous.to;
330
+ if (event.from !== currentState || event.to !== currentState) errors.push(`${event.id} must not change Run state ${currentState || "unknown"}`);
331
+ if (event.type === "guardrail") {
332
+ errors.push(...validateGuardrailEvent(event, declared));
333
+ if (event.result !== "pending") obligationResults.set(event.guardrail, event.result);
334
+ else obligationResults.set(event.guardrail, "pending");
335
+ } else {
336
+ errors.push(...validateMutationEvent(event, expectedWorkspace, policyFingerprint));
337
+ if (event.workspace_after && event.workspace_after.fingerprint) expectedWorkspace = event.workspace_after.fingerprint;
338
+ }
339
+ } else {
340
+ const expectedFrom = index === 0 ? "created" : previous.to;
341
+ if (event.type !== "transition") errors.push(`${event.id} snapshot is allowed only as the first event`);
342
+ if (event.from !== expectedFrom) errors.push(`${event.id}.from must be ${expectedFrom}`);
343
+ if (event.from === "created") {
344
+ if (event.to !== "executing") errors.push(`${event.id} initial transition must enter executing`);
345
+ } else {
346
+ const allowed = (ARTIFACT_TRANSITIONS.run[event.from] || []);
347
+ if (!allowed.includes(event.to)) errors.push(`${event.id} contains invalid Run transition: ${event.from} -> ${event.to}`);
348
+ }
349
+ }
350
+ if (!["snapshot"].includes(event.type) && !event.evidence.length) errors.push(`${event.id} transition requires evidence`);
351
+ previous = event;
352
+ }
353
+ if (record.workspace === 1 && events[0] && events[0].type !== "snapshot") {
354
+ errors.push(...validateWorkspaceState(events[0].workspace_baseline, `${events[0].id}.workspace_baseline`));
355
+ }
356
+ if (record.guardrails === 1 && events[0] && events[0].type !== "snapshot") {
357
+ if (!/^[a-f0-9]{64}$/.test(events[0].policy_fingerprint || "")) errors.push(`${events[0].id}.policy_fingerprint must be sha256`);
358
+ }
359
+ if (previous && previous.to !== record.status) errors.push(`Run status ${record.status} disagrees with final event state ${previous.to}`);
360
+ if (previous && record.updated !== String(previous.occurred_at || "").slice(0, 10)) {
361
+ errors.push(`Run updated date ${record.updated} disagrees with final event timestamp`);
362
+ }
363
+ if (record.status === "completed" && events[0] && events[0].type !== "snapshot") {
364
+ for (const required of ["validating", "learning", "completed"]) {
365
+ const event = events.find((item) => item.type === "transition" && item.to === required);
366
+ if (!event || !event.evidence.length) errors.push(`completed Run requires evidenced ${required} transition`);
367
+ }
368
+ for (const guardrail of declared.keys()) {
369
+ if (obligationResults.get(guardrail) !== "passed") errors.push(`completed Run has unresolved Guardrail obligation: ${guardrail}`);
370
+ }
371
+ }
372
+ return { events, errors, obligations: [...declared.keys()].map((guardrail) => ({ guardrail, status: obligationResults.get(guardrail) || "pending" })) };
373
+ }
374
+
375
+ function appendAuxiliaryEvent(record, body, details, options = {}) {
376
+ const current = validateRunLedger(record, body);
377
+ if (current.errors.length) throw new Error(`Invalid Run ledger: ${current.errors.join("; ")}`);
378
+ const occurredAt = options.occurredAt ? instant(options.occurredAt) : instant();
379
+ const nextRecord = { ...record, updated: occurredAt.slice(0, 10) };
380
+ const evidence = normalizeEvidence(options.evidence || []);
381
+ if (!evidence.length) throw new Error(`${details.type} event requires structured evidence.`);
382
+ const event = makeRunEvent({
383
+ runId: record.id,
384
+ sequence: current.events.length + 1,
385
+ type: details.type,
386
+ occurredAt,
387
+ actor: options.actor || "agent",
388
+ from: record.status,
389
+ to: record.status,
390
+ reason: String(options.note || evidence.map((item) => item.summary || item.ref).filter(Boolean).join("; ")).trim(),
391
+ evidence,
392
+ ...details
393
+ });
394
+ const nextBody = `${String(body).trimEnd()}\n\n${renderRunEvent(event)}\n`;
395
+ const validated = validateRunLedger(nextRecord, nextBody);
396
+ if (validated.errors.length) throw new Error(`${details.type} event failed validation: ${validated.errors.join("; ")}`);
397
+ return { body: nextBody, record: nextRecord, event, events: validated.events, obligations: validated.obligations };
398
+ }
399
+
400
+ function appendGuardrailEvent(record, body, obligation, result, options = {}) {
401
+ if (!GUARDRAIL_RESULTS.includes(result) || result === "pending") throw new Error(`Guardrail result must be passed or blocked.`);
402
+ return appendAuxiliaryEvent(record, body, {
403
+ type: "guardrail",
404
+ guardrail: obligation.guardrail,
405
+ code: obligation.code,
406
+ enforcement: obligation.enforcement,
407
+ gate: obligation.gate,
408
+ result,
409
+ scope: obligation.scope || []
410
+ }, options);
411
+ }
412
+
413
+ function appendMutationEvent(record, body, mutation, options = {}) {
414
+ return appendAuxiliaryEvent(record, body, { type: "mutation", ...mutation }, options);
415
+ }
416
+
417
+ function guardrailState(record, body) {
418
+ const ledger = validateRunLedger(record, body);
419
+ if (ledger.errors.length) throw new Error(`Invalid Run ledger: ${ledger.errors.join("; ")}`);
420
+ const declarations = new Map();
421
+ const results = new Map();
422
+ for (const event of ledger.events.filter((item) => item.type === "guardrail")) {
423
+ if (event.result === "pending") declarations.set(event.guardrail, event);
424
+ else results.set(event.guardrail, event);
425
+ }
426
+ return [...declarations.values()].map((event) => ({
427
+ guardrail: event.guardrail,
428
+ code: event.code,
429
+ enforcement: event.enforcement,
430
+ gate: event.gate,
431
+ scope: event.scope || [],
432
+ status: results.has(event.guardrail) ? results.get(event.guardrail).result : "pending",
433
+ resultEvent: results.get(event.guardrail) || null
434
+ }));
435
+ }
436
+
437
+ function appendRunEvent(record, body, nextRecord, {
438
+ note,
439
+ evidence = [],
440
+ actor = "agent",
441
+ occurredAt = instant()
442
+ } = {}) {
443
+ const current = validateRunLedger(record, body);
444
+ if (current.errors.length) throw new Error(`Invalid Run ledger: ${current.errors.join("; ")}`);
445
+ const normalizedEvidence = evidenceForTransition(nextRecord.status, note, evidence);
446
+ if (!note && !normalizedEvidence.length) throw new Error(`Run transition to ${nextRecord.status} requires a reason or structured evidence.`);
447
+ const reason = String(note || normalizedEvidence.map((item) => item.summary || item.ref).filter(Boolean).join("; ")).trim();
448
+ const event = makeRunEvent({
449
+ runId: record.id,
450
+ sequence: current.events.length + 1,
451
+ occurredAt,
452
+ actor,
453
+ from: record.status,
454
+ to: nextRecord.status,
455
+ reason,
456
+ evidence: normalizedEvidence
457
+ });
458
+ const nextBody = `${String(body).trimEnd()}\n\n${renderRunEvent(event)}\n`;
459
+ const validated = validateRunLedger(nextRecord, nextBody);
460
+ if (validated.errors.length) throw new Error(`Run ledger transition failed validation: ${validated.errors.join("; ")}`);
461
+ return { body: nextBody, event, events: validated.events };
462
+ }
463
+
464
+ function legacyTransitionEvents(record, body, legacyHash) {
465
+ const raw = [];
466
+ const log = String(body || "").match(/^## Transition Log[ \t]*\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
467
+ if (log) {
468
+ const initial = log[1].match(/^-\s+(\d{4}-\d{2}-\d{2}):\s*([a-z]+)\s*(?:→|->)\s*([a-z]+)\s*(?:—|-)\s*(.+)$/m);
469
+ if (initial) raw.push({ date: initial[1], from: initial[2], to: initial[3], note: initial[4].trim() });
470
+ }
471
+ for (const match of String(body || "").matchAll(/^## Transition (\d{4}-\d{2}-\d{2}) · ([a-z]+)-to-([a-z]+)[ \t]*\r?\n\r?\n-\s+([^\r\n]+)$/gm)) {
472
+ raw.push({ date: match[1], from: match[2], to: match[3], note: match[4].trim() });
473
+ }
474
+ if (!raw.length) return [];
475
+ let previous = "created";
476
+ for (const item of raw) {
477
+ if (item.from !== previous) return [];
478
+ const allowed = item.from === "created" ? ["executing"] : (ARTIFACT_TRANSITIONS.run[item.from] || []);
479
+ if (!allowed.includes(item.to)) return [];
480
+ previous = item.to;
481
+ }
482
+ if (previous !== record.status) return [];
483
+ return raw.map((item, index) => makeRunEvent({
484
+ runId: record.id,
485
+ sequence: index + 1,
486
+ occurredAt: dateInstant(item.date),
487
+ timestampPrecision: "date",
488
+ actor: "migration",
489
+ from: item.from,
490
+ to: item.to,
491
+ reason: item.note,
492
+ evidence: [
493
+ { kind: index === 0 ? "approval" : "note", summary: item.note },
494
+ { kind: "legacy", ref: `sha256:${legacyHash}`, summary: "Recovered from the byte-exact legacy Run record." }
495
+ ]
496
+ }));
497
+ }
498
+
499
+ function migrateLegacyRun(record, body, { legacyHash, backupRef }) {
500
+ let events = legacyTransitionEvents(record, body, legacyHash);
501
+ let mode = "transitions";
502
+ if (!events.length) {
503
+ mode = "snapshot";
504
+ events = [makeRunEvent({
505
+ runId: record.id,
506
+ sequence: 1,
507
+ type: "snapshot",
508
+ occurredAt: dateInstant(record.updated),
509
+ timestampPrecision: "date",
510
+ actor: "migration",
511
+ from: null,
512
+ to: record.status,
513
+ reason: "Imported the recorded Run state without inventing missing transitions.",
514
+ evidence: [{
515
+ kind: "legacy",
516
+ ref: `sha256:${legacyHash}`,
517
+ summary: `Byte-exact source preserved at ${backupRef}.`
518
+ }]
519
+ })];
520
+ }
521
+ const title = runTitle(body, `Run for ${record.task}`);
522
+ const provenance = `## Ledger Migration\n\n- Mode: ${mode}\n- Legacy SHA-256: \`${legacyHash}\`\n- Byte-exact backup: \`${backupRef}\``;
523
+ const nextRecord = { ...record, ledger: RUN_LEDGER_VERSION };
524
+ const nextBody = `# ${title}\n\n${eventsSection(events)}\n\n${provenance}\n`;
525
+ const validation = validateRunLedger(nextRecord, nextBody);
526
+ if (validation.errors.length) throw new Error(`${record.id} ledger migration failed: ${validation.errors.join("; ")}`);
527
+ return { record: nextRecord, body: nextBody, events, mode };
528
+ }
529
+
530
+ module.exports = {
531
+ appendGuardrailEvent,
532
+ appendMutationEvent,
533
+ appendRunEvent,
534
+ createRunBody,
535
+ dateInstant,
536
+ eventId,
537
+ eventsSection,
538
+ evidenceForTransition,
539
+ guardrailState,
540
+ instant,
541
+ makeRunEvent,
542
+ migrateLegacyRun,
543
+ parseRunLedger,
544
+ renderRunEvent,
545
+ validateRunLedger
546
+ };