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.
- package/CHANGELOG.md +22 -0
- package/CORE.md +11 -1
- package/DECISIONS.md +56 -0
- package/MIGRATION-1-to-2.md +11 -0
- package/README.md +14 -4
- package/SPEC.md +25 -8
- package/bin/scrumrun.js +119 -11
- package/docs/COMMANDS.md +4 -2
- package/docs/ENTITY-MODEL.md +1 -1
- package/docs/RELEASE-SCORECARD.md +43 -0
- package/docs/RELEASE.md +19 -12
- package/docs/SCHEMA.md +9 -0
- package/docs/TROUBLESHOOTING.md +12 -0
- package/lib/commands/manifest.js +11 -2
- package/lib/commands/render.js +3 -0
- package/lib/memory/index.js +70 -11
- 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/orchestrator.js +98 -65
- package/lib/runtime/policy-engine.js +184 -0
- package/lib/runtime/request-engine.js +28 -24
- package/lib/runtime/run-ledger.js +324 -0
- package/lib/v2/artifacts.js +21 -1
- package/lib/v2/conformance.js +58 -11
- package/lib/v2/migration.js +69 -9
- package/lib/v2/run-ledger-migration.js +240 -0
- package/lib/v2/schema.js +21 -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 +4 -1
- package/templates/project/.scrumrun/state.md +7 -14
- package/templates/shared/skills/scrumrun/SKILL.md +13 -3
|
@@ -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
|
+
};
|
package/lib/v2/artifacts.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/lib/v2/conformance.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
51
|
-
if (
|
|
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
|
-
|
|
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
|
}
|
package/lib/v2/migration.js
CHANGED
|
@@ -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
|
-
|
|
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(`${
|
|
340
|
+
errors.push(`${canonicalRecord.id || "unknown"}: ${validation.join("; ")}`);
|
|
328
341
|
return null;
|
|
329
342
|
}
|
|
330
|
-
const relative = posix(relativeArtifactPath(
|
|
331
|
-
const content = serializeArtifact(
|
|
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:
|
|
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:
|
|
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
|
|
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({
|
|
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;
|