scrumrun 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ "use strict";
2
+
3
+ const {
4
+ ARTIFACT_TRANSITIONS,
5
+ RUN_EVENT_TYPES,
6
+ RUN_EVIDENCE_KINDS,
7
+ RUN_LEDGER_VERSION
8
+ } = require("../v2/schema");
9
+ const { containsSecret } = require("../security/secrets");
10
+
11
+ const EVENT_ID = /^(RUN-\d{3,})-EVT-(\d{3,})$/;
12
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/;
13
+
14
+ function instant(value = new Date()) {
15
+ const parsed = value instanceof Date ? value : new Date(value);
16
+ if (!Number.isFinite(parsed.getTime())) throw new Error("Run event timestamp is invalid.");
17
+ return parsed.toISOString();
18
+ }
19
+
20
+ function dateInstant(value) {
21
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value || "")) throw new Error(`Legacy Run event date is invalid: ${value || "missing"}`);
22
+ return `${value}T00:00:00.000Z`;
23
+ }
24
+
25
+ function eventId(runId, sequence) {
26
+ return `${runId}-EVT-${String(sequence).padStart(3, "0")}`;
27
+ }
28
+
29
+ function normalizeEvidence(evidence = []) {
30
+ if (!Array.isArray(evidence)) throw new Error("Run event evidence must be an array.");
31
+ return evidence.map((item) => {
32
+ if (typeof item === "string") return { kind: "note", summary: item.trim() };
33
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
34
+ const normalized = { kind: item.kind };
35
+ if (item.ref !== undefined && item.ref !== null) normalized.ref = String(item.ref).trim();
36
+ if (item.summary !== undefined && item.summary !== null) normalized.summary = String(item.summary).trim();
37
+ return normalized;
38
+ });
39
+ }
40
+
41
+ function evidenceForTransition(nextStatus, note, evidence = []) {
42
+ const normalized = normalizeEvidence(evidence);
43
+ if (note && !normalized.length) {
44
+ const kind = nextStatus === "validating" ? "test" : nextStatus === "learning" ? "insight" : nextStatus === "blocked" || nextStatus === "failed" ? "risk" : "note";
45
+ normalized.push({ kind, summary: String(note).trim() });
46
+ }
47
+ return normalized;
48
+ }
49
+
50
+ function makeRunEvent({
51
+ runId,
52
+ sequence,
53
+ type = "transition",
54
+ occurredAt = instant(),
55
+ timestampPrecision = "instant",
56
+ actor = "agent",
57
+ from,
58
+ to,
59
+ reason,
60
+ evidence = []
61
+ }) {
62
+ return {
63
+ schema: RUN_LEDGER_VERSION,
64
+ id: eventId(runId, sequence),
65
+ sequence,
66
+ type,
67
+ occurred_at: occurredAt,
68
+ timestamp_precision: timestampPrecision,
69
+ actor,
70
+ from: from === undefined ? null : from,
71
+ to,
72
+ reason,
73
+ evidence: normalizeEvidence(evidence)
74
+ };
75
+ }
76
+
77
+ function renderRunEvent(event) {
78
+ return `### ${event.id}\n\n\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``;
79
+ }
80
+
81
+ function eventsSection(events) {
82
+ return `## Events\n\n${events.map(renderRunEvent).join("\n\n")}`;
83
+ }
84
+
85
+ function runTitle(body, fallback) {
86
+ return ((body || "").match(/^# ([^\r\n]+)$/m) || [])[1] || fallback;
87
+ }
88
+
89
+ function createRunBody(record, {
90
+ title = `Run for ${record.task}`,
91
+ approvalId = null,
92
+ occurredAt = instant(),
93
+ actor = "owner",
94
+ reason = "Explicit approval accepted.",
95
+ evidence = null
96
+ } = {}) {
97
+ const eventEvidence = evidence || [{
98
+ kind: "approval",
99
+ ...(approvalId ? { ref: `approval:${approvalId}` } : {}),
100
+ summary: reason
101
+ }];
102
+ const event = makeRunEvent({
103
+ runId: record.id,
104
+ sequence: 1,
105
+ occurredAt,
106
+ actor,
107
+ from: "created",
108
+ to: "executing",
109
+ reason,
110
+ evidence: eventEvidence
111
+ });
112
+ return { body: `# ${title}\n\n${eventsSection([event])}`, event };
113
+ }
114
+
115
+ function parseRunLedger(body) {
116
+ const events = [];
117
+ const errors = [];
118
+ const headings = [...String(body || "").matchAll(/^### (RUN-\d{3,}-EVT-\d{3,})[ \t]*$/gm)];
119
+ 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)];
120
+ if (headings.length !== blocks.length) errors.push("one or more Run event blocks are malformed");
121
+ for (const block of blocks) {
122
+ try {
123
+ const event = JSON.parse(block[2]);
124
+ if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("event payload must be an object");
125
+ if (event.id !== block[1]) errors.push(`${block[1]} payload id does not match its heading`);
126
+ events.push(event);
127
+ } catch (error) {
128
+ errors.push(`${block[1]} contains invalid JSON: ${error.message}`);
129
+ }
130
+ }
131
+ return { events, errors };
132
+ }
133
+
134
+ function validateEvidence(event) {
135
+ const errors = [];
136
+ if (!Array.isArray(event.evidence)) return [`${event.id}.evidence must be an array`];
137
+ for (let index = 0; index < event.evidence.length; index++) {
138
+ const item = event.evidence[index];
139
+ const label = `${event.id}.evidence[${index}]`;
140
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
141
+ errors.push(`${label} must be an object`);
142
+ continue;
143
+ }
144
+ if (!RUN_EVIDENCE_KINDS.includes(item.kind)) errors.push(`${label}.kind is invalid: ${item.kind || "missing"}`);
145
+ if (!(typeof item.ref === "string" && item.ref.trim()) && !(typeof item.summary === "string" && item.summary.trim())) {
146
+ errors.push(`${label} requires ref or summary`);
147
+ }
148
+ }
149
+ return errors;
150
+ }
151
+
152
+ function validateRunLedger(record, body) {
153
+ const parsed = parseRunLedger(body);
154
+ const errors = [...parsed.errors];
155
+ const events = parsed.events;
156
+ if (record.ledger !== RUN_LEDGER_VERSION) errors.push(`ledger must be ${RUN_LEDGER_VERSION}`);
157
+ if (!events.length) errors.push("Run ledger has no events");
158
+ const ids = new Set();
159
+ let previous = null;
160
+ let previousTime = null;
161
+ for (let index = 0; index < events.length; index++) {
162
+ const event = events[index];
163
+ const expectedSequence = index + 1;
164
+ const match = EVENT_ID.exec(event.id || "");
165
+ if (!match || match[1] !== record.id) errors.push(`invalid event id for ${record.id}: ${event.id || "missing"}`);
166
+ if (ids.has(event.id)) errors.push(`duplicate Run event id: ${event.id}`);
167
+ ids.add(event.id);
168
+ if (event.schema !== RUN_LEDGER_VERSION) errors.push(`${event.id}.schema must be ${RUN_LEDGER_VERSION}`);
169
+ if (event.sequence !== expectedSequence || (match && Number(match[2]) !== expectedSequence)) {
170
+ errors.push(`${event.id}.sequence must be contiguous from 1; expected ${expectedSequence}`);
171
+ }
172
+ if (!RUN_EVENT_TYPES.includes(event.type)) errors.push(`${event.id}.type is invalid: ${event.type || "missing"}`);
173
+ if (!RFC3339.test(event.occurred_at || "") || !Number.isFinite(Date.parse(event.occurred_at))) {
174
+ errors.push(`${event.id}.occurred_at must be a real RFC3339 timestamp`);
175
+ } else {
176
+ const currentTime = Date.parse(event.occurred_at);
177
+ if (previousTime !== null && currentTime < previousTime) errors.push(`${event.id}.occurred_at precedes the previous event`);
178
+ previousTime = currentTime;
179
+ }
180
+ if (!["instant", "date"].includes(event.timestamp_precision)) errors.push(`${event.id}.timestamp_precision must be instant or date`);
181
+ if (typeof event.actor !== "string" || !event.actor.trim()) errors.push(`${event.id}.actor is required`);
182
+ if (typeof event.reason !== "string" || !event.reason.trim()) errors.push(`${event.id}.reason is required`);
183
+ if (containsSecret(JSON.stringify(event))) errors.push(`${event.id} contains secret-like content`);
184
+ errors.push(...validateEvidence(event));
185
+
186
+ if (index === 0 && event.type === "snapshot") {
187
+ if (event.from !== null) errors.push(`${event.id} snapshot.from must be null`);
188
+ if (!event.evidence.some((item) => item && ["migration", "legacy"].includes(item.kind))) {
189
+ errors.push(`${event.id} snapshot requires migration or legacy evidence`);
190
+ }
191
+ } else {
192
+ const expectedFrom = index === 0 ? "created" : previous.to;
193
+ if (event.type !== "transition") errors.push(`${event.id} snapshot is allowed only as the first event`);
194
+ if (event.from !== expectedFrom) errors.push(`${event.id}.from must be ${expectedFrom}`);
195
+ if (event.from === "created") {
196
+ if (event.to !== "executing") errors.push(`${event.id} initial transition must enter executing`);
197
+ } else {
198
+ const allowed = (ARTIFACT_TRANSITIONS.run[event.from] || []);
199
+ if (!allowed.includes(event.to)) errors.push(`${event.id} contains invalid Run transition: ${event.from} -> ${event.to}`);
200
+ }
201
+ }
202
+ if (!["snapshot"].includes(event.type) && !event.evidence.length) errors.push(`${event.id} transition requires evidence`);
203
+ previous = event;
204
+ }
205
+ if (previous && previous.to !== record.status) errors.push(`Run status ${record.status} disagrees with final event state ${previous.to}`);
206
+ if (previous && record.updated !== String(previous.occurred_at || "").slice(0, 10)) {
207
+ errors.push(`Run updated date ${record.updated} disagrees with final event timestamp`);
208
+ }
209
+ if (record.status === "completed" && events[0] && events[0].type !== "snapshot") {
210
+ for (const required of ["validating", "learning", "completed"]) {
211
+ const event = events.find((item) => item.type === "transition" && item.to === required);
212
+ if (!event || !event.evidence.length) errors.push(`completed Run requires evidenced ${required} transition`);
213
+ }
214
+ }
215
+ return { events, errors };
216
+ }
217
+
218
+ function appendRunEvent(record, body, nextRecord, {
219
+ note,
220
+ evidence = [],
221
+ actor = "agent",
222
+ occurredAt = instant()
223
+ } = {}) {
224
+ const current = validateRunLedger(record, body);
225
+ if (current.errors.length) throw new Error(`Invalid Run ledger: ${current.errors.join("; ")}`);
226
+ const normalizedEvidence = evidenceForTransition(nextRecord.status, note, evidence);
227
+ if (!note && !normalizedEvidence.length) throw new Error(`Run transition to ${nextRecord.status} requires a reason or structured evidence.`);
228
+ const reason = String(note || normalizedEvidence.map((item) => item.summary || item.ref).filter(Boolean).join("; ")).trim();
229
+ const event = makeRunEvent({
230
+ runId: record.id,
231
+ sequence: current.events.length + 1,
232
+ occurredAt,
233
+ actor,
234
+ from: record.status,
235
+ to: nextRecord.status,
236
+ reason,
237
+ evidence: normalizedEvidence
238
+ });
239
+ const nextBody = `${String(body).trimEnd()}\n\n${renderRunEvent(event)}\n`;
240
+ const validated = validateRunLedger(nextRecord, nextBody);
241
+ if (validated.errors.length) throw new Error(`Run ledger transition failed validation: ${validated.errors.join("; ")}`);
242
+ return { body: nextBody, event, events: validated.events };
243
+ }
244
+
245
+ function legacyTransitionEvents(record, body, legacyHash) {
246
+ const raw = [];
247
+ const log = String(body || "").match(/^## Transition Log[ \t]*\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
248
+ if (log) {
249
+ const initial = log[1].match(/^-\s+(\d{4}-\d{2}-\d{2}):\s*([a-z]+)\s*(?:→|->)\s*([a-z]+)\s*(?:—|-)\s*(.+)$/m);
250
+ if (initial) raw.push({ date: initial[1], from: initial[2], to: initial[3], note: initial[4].trim() });
251
+ }
252
+ 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)) {
253
+ raw.push({ date: match[1], from: match[2], to: match[3], note: match[4].trim() });
254
+ }
255
+ if (!raw.length) return [];
256
+ let previous = "created";
257
+ for (const item of raw) {
258
+ if (item.from !== previous) return [];
259
+ const allowed = item.from === "created" ? ["executing"] : (ARTIFACT_TRANSITIONS.run[item.from] || []);
260
+ if (!allowed.includes(item.to)) return [];
261
+ previous = item.to;
262
+ }
263
+ if (previous !== record.status) return [];
264
+ return raw.map((item, index) => makeRunEvent({
265
+ runId: record.id,
266
+ sequence: index + 1,
267
+ occurredAt: dateInstant(item.date),
268
+ timestampPrecision: "date",
269
+ actor: "migration",
270
+ from: item.from,
271
+ to: item.to,
272
+ reason: item.note,
273
+ evidence: [
274
+ { kind: index === 0 ? "approval" : "note", summary: item.note },
275
+ { kind: "legacy", ref: `sha256:${legacyHash}`, summary: "Recovered from the byte-exact legacy Run record." }
276
+ ]
277
+ }));
278
+ }
279
+
280
+ function migrateLegacyRun(record, body, { legacyHash, backupRef }) {
281
+ let events = legacyTransitionEvents(record, body, legacyHash);
282
+ let mode = "transitions";
283
+ if (!events.length) {
284
+ mode = "snapshot";
285
+ events = [makeRunEvent({
286
+ runId: record.id,
287
+ sequence: 1,
288
+ type: "snapshot",
289
+ occurredAt: dateInstant(record.updated),
290
+ timestampPrecision: "date",
291
+ actor: "migration",
292
+ from: null,
293
+ to: record.status,
294
+ reason: "Imported the recorded Run state without inventing missing transitions.",
295
+ evidence: [{
296
+ kind: "legacy",
297
+ ref: `sha256:${legacyHash}`,
298
+ summary: `Byte-exact source preserved at ${backupRef}.`
299
+ }]
300
+ })];
301
+ }
302
+ const title = runTitle(body, `Run for ${record.task}`);
303
+ const provenance = `## Ledger Migration\n\n- Mode: ${mode}\n- Legacy SHA-256: \`${legacyHash}\`\n- Byte-exact backup: \`${backupRef}\``;
304
+ const nextRecord = { ...record, ledger: RUN_LEDGER_VERSION };
305
+ const nextBody = `# ${title}\n\n${eventsSection(events)}\n\n${provenance}\n`;
306
+ const validation = validateRunLedger(nextRecord, nextBody);
307
+ if (validation.errors.length) throw new Error(`${record.id} ledger migration failed: ${validation.errors.join("; ")}`);
308
+ return { record: nextRecord, body: nextBody, events, mode };
309
+ }
310
+
311
+ module.exports = {
312
+ appendRunEvent,
313
+ createRunBody,
314
+ dateInstant,
315
+ eventId,
316
+ eventsSection,
317
+ evidenceForTransition,
318
+ instant,
319
+ makeRunEvent,
320
+ migrateLegacyRun,
321
+ parseRunLedger,
322
+ renderRunEvent,
323
+ validateRunLedger
324
+ };
@@ -111,6 +111,9 @@ function validateArtifact(record, expectedKind = null) {
111
111
  if (field === "attempt" && (!Number.isInteger(record[field]) || record[field] < 1)) {
112
112
  errors.push(`${record.kind}.${field} must be a positive integer`);
113
113
  }
114
+ if (field === "ledger" && record[field] !== undefined && record[field] !== 1) {
115
+ errors.push(`${record.kind}.${field} must be 1`);
116
+ }
114
117
  }
115
118
  return errors;
116
119
  }
@@ -162,10 +165,27 @@ function assertNoSymlinkPath(root, target) {
162
165
  function atomicWrite(file, content) {
163
166
  fs.mkdirSync(path.dirname(file), { recursive: true });
164
167
  const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
168
+ let descriptor = null;
165
169
  try {
166
- fs.writeFileSync(temp, content);
170
+ const mode = fs.existsSync(file) ? fs.lstatSync(file).mode & 0o777 : 0o666;
171
+ descriptor = fs.openSync(temp, "wx", mode);
172
+ fs.writeFileSync(descriptor, content);
173
+ fs.fsyncSync(descriptor);
174
+ fs.closeSync(descriptor);
175
+ descriptor = null;
167
176
  fs.renameSync(temp, file);
177
+ try {
178
+ const directory = fs.openSync(path.dirname(file), "r");
179
+ try {
180
+ fs.fsyncSync(directory);
181
+ } finally {
182
+ fs.closeSync(directory);
183
+ }
184
+ } catch (error) {
185
+ if (!["EINVAL", "ENOTSUP", "EBADF"].includes(error.code)) throw error;
186
+ }
168
187
  } catch (error) {
188
+ if (descriptor !== null) fs.closeSync(descriptor);
169
189
  if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
170
190
  throw error;
171
191
  }
@@ -3,32 +3,35 @@
3
3
  const fs = require("node:fs");
4
4
  const path = require("node:path");
5
5
  const { ArtifactRepository } = require("./artifacts");
6
- const { ARTIFACT_TYPES, METHOD_VERSION } = require("./schema");
6
+ const { ARTIFACT_TYPES, METHOD_VERSION, RUN_LEDGER_VERSION } = require("./schema");
7
7
  const { stateIsStale } = require("../runtime/orchestrator");
8
- const { indexStatus } = require("../memory/index");
8
+ const { validateRunLedger } = require("../runtime/run-ledger");
9
+ const { indexStatus, mapStatus } = require("../memory/index");
9
10
  const { extractEvidence } = require("../memory/markdown");
10
11
  const { resolveEvidence } = require("../memory/service");
11
12
  const { containsSecret } = require("../security/secrets");
13
+ const { pendingTransactionStatus } = require("./transaction");
14
+ const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
12
15
 
13
16
  const INVARIANTS = Object.freeze([
14
17
  { id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
15
18
  { id: "I-02", summary: "approval creates Task and Run atomically", tests: ["explicit approval creates exactly one linked Task and Run", "approval failure injection rolls back"] },
16
19
  { id: "I-03", summary: "Task is atomic and Sprint only groups evidenced batches", tests: ["migration apply creates linked v2 artifacts", "artifact repository builds explicit Feature Task Sprint Run graph"] },
17
20
  { id: "I-04", summary: "retry preserves prior Runs", tests: ["retry creates a new Run and preserves"] },
18
- { id: "I-05", summary: "state transitions are declared and recoverable", tests: ["run state machine follows", "paired transition failure restores"] },
19
- { id: "I-06", summary: "guardrails are canonical and cannot be weakened", tests: ["guardrails build preserves canonical v2 rules"] },
21
+ { id: "I-05", summary: "state transitions are declared, evidenced, ordered, and recoverable", tests: ["run state machine follows", "Run ledger uses stable event ids", "paired transition failure restores"] },
22
+ { id: "I-06", summary: "guardrails are canonical and cannot be weakened", tests: ["guardrails build preserves canonical v2 rules", "Policy Engine reports exact Guardrail ids"] },
20
23
  { id: "I-07", summary: "Markdown is canonical and indexes are disposable", tests: ["SQLite index is disposable"] },
21
24
  { id: "I-08", summary: "AI memory remains candidate", tests: ["AI insights stay candidate"] },
22
25
  { id: "I-09", summary: "confirmed claims require evidence", tests: ["confirmed facts reject missing"] },
23
26
  { id: "I-10", summary: "inactive memory is not active truth", tests: ["stale and expired memory are labeled"] },
24
27
  { id: "I-11", summary: "vault values never leave the vault boundary", tests: ["migration dry-run is read-only and never renders vault", "SQLite index is disposable"] },
25
28
  { id: "I-12", summary: "canonical schemas and identities are valid", tests: ["v2 artifact schemas round-trip", "malformed and mismatched artifacts are rejected"] },
26
- { id: "I-13", summary: "generated state exposes staleness", tests: ["generated state includes structured decisions", "approval rejects tampered or stale plans"] },
27
- { id: "I-14", summary: "context and retrieval are bounded", tests: ["lean context stays within twenty-five percent", "semantic query caps relation output"] },
29
+ { id: "I-13", summary: "generated state exposes staleness", tests: ["generated state includes structured decisions", "approval rejects tampered or stale plans", "state staleness uses metadata fast path", "generated map status refuses stale projection"] },
30
+ { id: "I-14", summary: "context and retrieval are bounded", tests: ["lean context stays within twenty-five percent", "semantic query caps relation output", "semantic index staleness uses metadata fast path"] },
28
31
  { id: "I-15", summary: "migration and ordinary update are read-only by default", tests: ["migration dry-run is read-only", "update preflights an ongoing v1 project"] },
29
- { id: "I-16", summary: "migration is hashed, idempotent, and reversible", tests: ["migration apply is idempotent and rollback restores"] },
32
+ { id: "I-16", summary: "migration is hashed, idempotent, and reversible", tests: ["migration apply is idempotent and rollback restores", "Run ledger migration failure and rollback restore"] },
30
33
  { id: "I-17", summary: "ambiguous migration is preserved and warned", tests: ["partial v1 layout is preserved"] },
31
- { id: "I-18", summary: "unsafe and partial writes fail safely", tests: ["canonical writes reject traversal and symlink paths", "repository refuses conflicting overwrite"] },
34
+ { 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"] },
32
35
  { id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
33
36
  { id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] }
34
37
  ]);
@@ -45,10 +48,11 @@ function auditProject(projectRoot) {
45
48
  return { method: METHOD_VERSION, passed: false, findings, counts: {} };
46
49
  }
47
50
  const marker = path.join(scrumDir, "method.json");
51
+ let methodMarker = null;
48
52
  try {
49
53
  if (!fs.existsSync(marker) || !fs.lstatSync(marker).isFile()) throw new Error("marker is missing, not a regular file, or is a symbolic link");
50
- const value = JSON.parse(fs.readFileSync(marker, "utf8"));
51
- if (value.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
54
+ methodMarker = JSON.parse(fs.readFileSync(marker, "utf8"));
55
+ if (methodMarker.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
52
56
  } catch (error) {
53
57
  findings.push(finding("critical", "METHOD_MARKER", `method.json is missing or malformed: ${error.message}`, marker));
54
58
  }
@@ -58,7 +62,13 @@ function auditProject(projectRoot) {
58
62
  }
59
63
  const guardrailsFile = path.join(scrumDir, "guardrails.md");
60
64
  const guardrails = fs.existsSync(guardrailsFile) && fs.lstatSync(guardrailsFile).isFile() ? fs.readFileSync(guardrailsFile, "utf8") : "";
61
- if (!/^## GR-\d{3,}/m.test(guardrails)) findings.push(finding("high", "GUARDRAILS_EMPTY", "No stable-id Guardrail is defined."));
65
+ const guardrailValidation = validateGuardrailDocument(guardrails);
66
+ if (!guardrailValidation.records.length) findings.push(finding("high", "GUARDRAILS_EMPTY", "No stable-id Guardrail is defined."));
67
+ if (!guardrailValidation.records.some((record) => record.status === "active")) findings.push(finding("high", "GUARDRAILS_INACTIVE", "No active stable-id Guardrail is defined."));
68
+ for (const error of guardrailValidation.errors) findings.push(finding("high", "GUARDRAIL_INVALID", error, guardrailsFile));
69
+ const configFile = path.join(scrumDir, "config.md");
70
+ const config = fs.existsSync(configFile) && fs.lstatSync(configFile).isFile() ? fs.readFileSync(configFile, "utf8") : "";
71
+ for (const error of configWeakeningAttempts(config)) findings.push(finding("high", "CONFIG_WEAKENS_POLICY", error, configFile));
62
72
 
63
73
  const repository = new ArtifactRepository(scrumDir);
64
74
  const counts = {};
@@ -85,6 +95,13 @@ function auditProject(projectRoot) {
85
95
  if (run.record && task && !task.errors.length && (run.record.sprint || null) !== (task.record.sprint || null)) {
86
96
  findings.push(finding("high", "RUN_SPRINT_MISMATCH", `${run.record.id}.sprint must match ${task.record.id}.sprint.`, run.file));
87
97
  }
98
+ if (run.record && run.record.ledger === RUN_LEDGER_VERSION) {
99
+ const ledger = validateRunLedger(run.record, run.body);
100
+ for (const error of ledger.errors) findings.push(finding("high", "RUN_LEDGER_INVALID", `${run.record.id}: ${error}`, run.file));
101
+ } else if (run.record) {
102
+ const declared = methodMarker && methodMarker.schemas && methodMarker.schemas.run_ledger === RUN_LEDGER_VERSION;
103
+ findings.push(finding(declared ? "high" : "warning", "RUN_LEDGER_LEGACY", `${run.record.id} requires the Run ledger ${RUN_LEDGER_VERSION} migration.`, run.file));
104
+ }
88
105
  }
89
106
  const byId = new Map(Object.values(records).flat().filter((artifact) => artifact.record).map((artifact) => [artifact.record.id, artifact]));
90
107
  for (const task of records.task || []) {
@@ -110,6 +127,26 @@ function auditProject(projectRoot) {
110
127
  findings.push(finding("high", "RUN_ATTEMPT_SEQUENCE", `${taskId} Run attempts must be unique and contiguous from 1; found ${ordered.join(", ")}.`));
111
128
  }
112
129
  }
130
+ for (const task of records.task || []) {
131
+ if (!task.record || task.errors.length) continue;
132
+ const attempts = (records.run || [])
133
+ .filter((run) => run.record && !run.errors.length && run.record.task === task.record.id)
134
+ .sort((left, right) => right.record.attempt - left.record.attempt);
135
+ if (!attempts.length) continue;
136
+ const latest = attempts[0].record;
137
+ const taskStatuses = {
138
+ executing: "running",
139
+ validating: "validating",
140
+ learning: "learning",
141
+ completed: "completed",
142
+ failed: "failed",
143
+ blocked: "blocked",
144
+ partial: "partial"
145
+ };
146
+ if (taskStatuses[latest.status] && task.record.status !== taskStatuses[latest.status]) {
147
+ findings.push(finding("high", "TASK_RUN_STATUS_MISMATCH", `${task.record.id}.status ${task.record.status} disagrees with latest ${latest.id}.status ${latest.status}.`, task.file));
148
+ }
149
+ }
113
150
  for (const sprint of records.sprint || []) {
114
151
  if (!sprint.record || sprint.errors.length) continue;
115
152
  const heading = /^## Tasks[ \t]*$/m.exec(sprint.body);
@@ -157,9 +194,19 @@ function auditProject(projectRoot) {
157
194
  try {
158
195
  const semantic = indexStatus(projectRoot);
159
196
  if (semantic.exists && semantic.stale) findings.push(finding("warning", "INDEX_STALE", "semantic-index.sqlite is stale and will be rebuilt on query."));
197
+ const map = mapStatus(projectRoot, { semanticStatus: semantic });
198
+ if (semantic.exists && map.stale) findings.push(finding("warning", "MAP_STALE", `map.md is stale: ${map.reason || map.error || "unknown reason"}.`));
160
199
  } catch (error) {
161
200
  findings.push(finding("high", "INDEX_UNSAFE", `semantic index cannot inspect canonical sources safely: ${error.message}`));
162
201
  }
202
+ const transactions = pendingTransactionStatus(scrumDir);
203
+ if (transactions.error) {
204
+ findings.push(finding("high", "TRANSACTION_JOURNAL_UNSAFE", transactions.error));
205
+ } else {
206
+ for (const transaction of transactions.pending) {
207
+ findings.push(finding("high", "TRANSACTION_PENDING", `${transaction.id} (${transaction.name}) is ${transaction.status}; run the approved operation again or recover explicitly before trusting canonical state.`));
208
+ }
209
+ }
163
210
  const blocking = findings.filter((item) => ["critical", "high"].includes(item.severity));
164
211
  return { method: METHOD_VERSION, passed: blocking.length === 0, findings, counts, invariants: INVARIANTS.length };
165
212
  }
@@ -16,6 +16,8 @@ const {
16
16
  sha256,
17
17
  validateArtifact
18
18
  } = require("./artifacts");
19
+ const { RUN_LEDGER_VERSION } = require("./schema");
20
+ const { migrateLegacyRun } = require("../runtime/run-ledger");
19
21
 
20
22
  const MIGRATION_NAME = "v1-to-v2";
21
23
  const MANIFEST_PATH = path.join(".migration", MIGRATION_NAME, "manifest.json");
@@ -322,19 +324,30 @@ function migrationPlan(projectRoot) {
322
324
  const created = today();
323
325
 
324
326
  function addArtifact(record, body, source = null) {
325
- const validation = validateArtifact(record);
327
+ let canonicalRecord = record;
328
+ let canonicalBody = body;
329
+ if (record.kind === "run" && record.ledger !== RUN_LEDGER_VERSION) {
330
+ const legacyHash = sha256(source ? source.raw : body);
331
+ const backupRef = source
332
+ ? `.scrumrun/${backupRelative(source.path)}`
333
+ : `.scrumrun/${backupRelative(relativeArtifactPath(record))}`;
334
+ const migrated = migrateLegacyRun(record, body, { legacyHash, backupRef });
335
+ canonicalRecord = migrated.record;
336
+ canonicalBody = migrated.body;
337
+ }
338
+ const validation = validateArtifact(canonicalRecord);
326
339
  if (validation.length) {
327
- errors.push(`${record.id || "unknown"}: ${validation.join("; ")}`);
340
+ errors.push(`${canonicalRecord.id || "unknown"}: ${validation.join("; ")}`);
328
341
  return null;
329
342
  }
330
- const relative = posix(relativeArtifactPath(record));
331
- const content = serializeArtifact(record, body);
343
+ const relative = posix(relativeArtifactPath(canonicalRecord));
344
+ const content = serializeArtifact(canonicalRecord, canonicalBody);
332
345
  if (generated.has(relative) && generated.get(relative) !== content) {
333
346
  errors.push(`Generated artifact collision: ${relative}`);
334
347
  return null;
335
348
  }
336
349
  generated.set(relative, content);
337
- artifacts.push({ id: record.id, kind: record.kind, status: record.status, relative, record });
350
+ artifacts.push({ id: canonicalRecord.id, kind: canonicalRecord.kind, status: canonicalRecord.status, relative, record: canonicalRecord });
338
351
  if (source) {
339
352
  mappings.push({
340
353
  source: source.path,
@@ -342,10 +355,10 @@ function migrationPlan(projectRoot) {
342
355
  sourceSha256: sha256(source.raw),
343
356
  outcome: "transformed",
344
357
  destination: relative,
345
- id: record.id
358
+ id: canonicalRecord.id
346
359
  });
347
360
  }
348
- return { record, relative };
361
+ return { record: canonicalRecord, relative };
349
362
  }
350
363
 
351
364
  function normalizeExistingArtifacts() {
@@ -367,6 +380,18 @@ function migrationPlan(projectRoot) {
367
380
  const record = { ...artifact.record };
368
381
  let body = artifact.body;
369
382
  let changed = false;
383
+ if (kind === "run" && record.ledger !== RUN_LEDGER_VERSION) {
384
+ const relative = posix(path.relative(scrumDir, artifact.file));
385
+ const legacyContent = fs.readFileSync(artifact.file, "utf8");
386
+ const migrated = migrateLegacyRun(record, body, {
387
+ legacyHash: sha256(legacyContent),
388
+ backupRef: `.scrumrun/${backupRelative(relative)}`
389
+ });
390
+ Object.assign(record, migrated.record);
391
+ body = migrated.body;
392
+ changed = true;
393
+ warnings.push(`${record.id}: migrated the existing Run to ledger ${RUN_LEDGER_VERSION} using ${migrated.mode}.`);
394
+ }
370
395
  const alias = aliases[kind] && aliases[kind][record.status];
371
396
  if (alias) {
372
397
  record.status = alias;
@@ -487,6 +512,33 @@ function migrationPlan(projectRoot) {
487
512
  return candidates.length ? candidates[0][1] : null;
488
513
  }
489
514
 
515
+ function synchronizeGeneratedTask(task, status) {
516
+ const mapped = {
517
+ executing: "running",
518
+ validating: "validating",
519
+ learning: "learning",
520
+ completed: "completed",
521
+ failed: "failed",
522
+ blocked: "blocked",
523
+ partial: "partial"
524
+ }[status];
525
+ if (!mapped || task.status === mapped) return;
526
+ const relative = posix(relativeArtifactPath(task));
527
+ const content = generated.get(relative);
528
+ if (!content) return;
529
+ const parsed = parseArtifact(content);
530
+ if (!parsed.record || parsed.errors.length) return;
531
+ task.status = mapped;
532
+ task.updated = created;
533
+ generated.set(relative, serializeArtifact({ ...parsed.record, status: mapped, updated: created }, parsed.body));
534
+ const entry = artifacts.find((artifact) => artifact.id === task.id);
535
+ if (entry) {
536
+ entry.status = mapped;
537
+ entry.record.status = mapped;
538
+ entry.record.updated = created;
539
+ }
540
+ }
541
+
490
542
  function migrateHistory(relative, { lane = "main" } = {}) {
491
543
  const content = readText(scrumDir, relative);
492
544
  if (!content) return;
@@ -512,10 +564,11 @@ function migrationPlan(projectRoot) {
512
564
  continue;
513
565
  }
514
566
  const id = allocator.claim("run");
567
+ const status = runStatus(block.raw);
515
568
  addArtifact({
516
569
  id,
517
570
  kind: "run",
518
- status: runStatus(block.raw),
571
+ status,
519
572
  created,
520
573
  updated: created,
521
574
  method: METHOD_VERSION,
@@ -524,6 +577,7 @@ function migrationPlan(projectRoot) {
524
577
  attempt,
525
578
  legacy_source: relative
526
579
  }, sourceBody(block.heading, relative, block.heading, block.raw), { path: relative, anchor: block.heading, raw: block.raw });
580
+ synchronizeGeneratedTask(task, status);
527
581
  }
528
582
  }
529
583
 
@@ -868,7 +922,13 @@ function migrationPlan(projectRoot) {
868
922
  }
869
923
 
870
924
  const sourceLayout = existingIds.size ? "hybrid-v1-v2" : "1.x";
871
- generated.set("method.json", `${JSON.stringify({ method: METHOD_VERSION, layout: "v2", migrated_from: sourceLayout, migration: MIGRATION_NAME }, null, 2)}\n`);
925
+ generated.set("method.json", `${JSON.stringify({
926
+ method: METHOD_VERSION,
927
+ layout: "v2",
928
+ schemas: { run_ledger: RUN_LEDGER_VERSION },
929
+ migrated_from: sourceLayout,
930
+ migration: MIGRATION_NAME
931
+ }, null, 2)}\n`);
872
932
 
873
933
  const counts = {};
874
934
  for (const artifact of artifacts) counts[artifact.kind] = (counts[artifact.kind] || 0) + 1;