scrumrun 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/CORE.md +17 -3
- package/DECISIONS.md +56 -0
- package/MIGRATION-1-to-2.md +11 -0
- package/README.md +23 -5
- package/SPEC.md +30 -10
- package/bin/scrumrun.js +175 -11
- package/docs/COMMANDS.md +10 -4
- package/docs/ENTITY-MODEL.md +1 -1
- package/docs/RELEASE-SCORECARD.md +43 -0
- package/docs/RELEASE.md +19 -12
- package/docs/SCHEMA.md +11 -0
- package/docs/SEMANTIC-MEMORY.md +1 -1
- package/docs/TROUBLESHOOTING.md +13 -1
- package/lib/commands/manifest.js +15 -3
- package/lib/commands/render.js +4 -0
- package/lib/memory/index.js +201 -41
- package/lib/memory/service.js +3 -0
- package/lib/runtime/budgets.js +4 -0
- package/lib/runtime/canonical-snapshot.js +110 -0
- package/lib/runtime/context.js +5 -45
- package/lib/runtime/mutation-gateway.js +434 -0
- package/lib/runtime/orchestrator.js +130 -65
- package/lib/runtime/policy-engine.js +267 -0
- package/lib/runtime/request-engine.js +32 -24
- package/lib/runtime/review-service.js +92 -0
- package/lib/runtime/run-ledger.js +546 -0
- package/lib/runtime/workspace-state.js +146 -0
- package/lib/security/secrets.js +15 -1
- package/lib/v2/artifacts.js +24 -1
- package/lib/v2/conformance.js +78 -12
- package/lib/v2/migration.js +74 -10
- package/lib/v2/run-ledger-migration.js +268 -0
- package/lib/v2/schema.js +28 -1
- package/lib/v2/transaction.js +254 -0
- package/package.json +1 -1
- package/scripts/generate-contract-docs.js +11 -0
- package/templates/project/.scrumrun/guardrails.md +8 -0
- package/templates/project/.scrumrun/map.md +4 -3
- package/templates/project/.scrumrun/method.json +7 -1
- package/templates/project/.scrumrun/state.md +7 -14
- package/templates/project/AGENTS.md +2 -1
- package/templates/project-lean/AGENTS.md +3 -1
- package/templates/shared/skills/scrumrun/SKILL.md +19 -5
|
@@ -15,8 +15,13 @@ const {
|
|
|
15
15
|
withArtifactLock
|
|
16
16
|
} = require("../v2/artifacts");
|
|
17
17
|
const { buildContextPackage } = require("./context");
|
|
18
|
+
const { evaluatePolicy } = require("./policy-engine");
|
|
19
|
+
const { artifactSnapshot, canonicalFingerprint, canonicalWatchSnapshot } = require("./canonical-snapshot");
|
|
18
20
|
const { decodeApproval } = require("./request-engine");
|
|
19
21
|
const { extractLearningCandidates } = require("../code-intel/learning");
|
|
22
|
+
const { appendRunEvent, createRunBody, instant } = require("./run-ledger");
|
|
23
|
+
const { policyState, prepareCompletion, publicWorkspace, verifyWorkspaceIntegrity, workspaceState } = require("./mutation-gateway");
|
|
24
|
+
const { recoverPendingTransactions, runKernelTransaction } = require("../v2/transaction");
|
|
20
25
|
|
|
21
26
|
const TERMINAL = new Set(["completed", "failed", "cancelled", "resolved", "rejected", "deprecated", "invalidated", "archived", "passed"]);
|
|
22
27
|
|
|
@@ -39,40 +44,39 @@ function titleFor(request) {
|
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
function stateFingerprint(repository) {
|
|
42
|
-
|
|
43
|
-
for (const kind of Object.keys(ARTIFACT_TYPES)) {
|
|
44
|
-
for (const artifact of repository.list(kind)) {
|
|
45
|
-
hashes.push(`${path.relative(repository.scrumDir, artifact.file)}\0${sha256(fs.readFileSync(artifact.file))}`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
for (const relative of ["guardrails.md", "config.md", "project.md", "method.json"]) {
|
|
49
|
-
const file = path.join(repository.scrumDir, relative);
|
|
50
|
-
hashes.push(`${relative}\0${fs.existsSync(file) ? sha256(fs.readFileSync(file)) : "missing"}`);
|
|
51
|
-
}
|
|
52
|
-
return sha256(hashes.sort().join("\n"));
|
|
47
|
+
return canonicalFingerprint(repository.scrumDir, artifactSnapshot(repository.scrumDir).hashes);
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
function
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
return `- ${artifact.record.id} | ${kind} | ${artifact.record.status} | ${title}`;
|
|
61
|
-
}));
|
|
50
|
+
function stateProjection(repository) {
|
|
51
|
+
const snapshot = artifactSnapshot(repository.scrumDir);
|
|
52
|
+
const linesFor = (kinds) => kinds.flatMap((kind) => (snapshot.records[kind] || [])
|
|
53
|
+
.filter((record) => record.id && !TERMINAL.has(record.status))
|
|
54
|
+
.map((record) => `- ${record.id} | ${kind} | ${record.status} | ${record.title || record.id}`));
|
|
62
55
|
const work = linesFor(["feature", "task", "sprint", "run", "review"]);
|
|
63
56
|
const memory = linesFor(["decision", "knowledge", "insight", "dossier"]);
|
|
64
|
-
|
|
57
|
+
const sourceFingerprint = canonicalFingerprint(repository.scrumDir, snapshot.hashes);
|
|
58
|
+
const watch = canonicalWatchSnapshot(repository.scrumDir);
|
|
59
|
+
const content = `# ScrumRun State\n\nProjection schema: 1\nGenerated: ${new Date().toISOString()}\nSource fingerprint: ${sourceFingerprint}\nWatch fingerprint: ${watch.fingerprint}\nAuthority: none; rebuild from canonical artifacts.\n\n## Active Work\n\n${work.length ? work.join("\n") : "- No active canonical work."}\n\n## Relevant Memory\n\n${memory.length ? memory.join("\n") : "- No active canonical memory."}\n`;
|
|
60
|
+
return { content, sourceFingerprint, watchFingerprint: watch.fingerprint, sourceFiles: watch.files };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderState(repository) {
|
|
64
|
+
return stateProjection(repository).content;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function refreshState(scrumDir) {
|
|
68
68
|
const repository = new ArtifactRepository(scrumDir);
|
|
69
|
-
|
|
69
|
+
const projection = stateProjection(repository);
|
|
70
|
+
atomicWrite(path.join(scrumDir, "state.md"), projection.content);
|
|
71
|
+
return projection;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function stateIsStale(scrumDir) {
|
|
73
75
|
const content = fs.existsSync(path.join(scrumDir, "state.md")) ? fs.readFileSync(path.join(scrumDir, "state.md"), "utf8") : "";
|
|
74
76
|
const stored = (content.match(/^Source fingerprint:\s*([a-f0-9]{64})$/m) || [])[1];
|
|
75
77
|
if (!stored) return true;
|
|
78
|
+
const watched = (content.match(/^Watch fingerprint:\s*([a-f0-9]{64})$/m) || [])[1];
|
|
79
|
+
if (watched && watched === canonicalWatchSnapshot(scrumDir).fingerprint) return false;
|
|
76
80
|
return stored !== stateFingerprint(new ArtifactRepository(scrumDir));
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -83,12 +87,13 @@ function approvalExisting(repository, approvalId) {
|
|
|
83
87
|
return { task: task.record, run: run ? run.record : null };
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
function approveRequestUnlocked(projectRoot, token, { failurePoint = null } = {}) {
|
|
90
|
+
function approveRequestUnlocked(projectRoot, token, { failurePoint = null, interruptPoint = null } = {}) {
|
|
87
91
|
const payload = decodeApproval(token);
|
|
88
92
|
if (payload.policy.status !== "passed" || payload.policy.violations.length) throw new Error("Blocked intake cannot be approved.");
|
|
89
93
|
const issued = Date.parse(payload.issuedAt);
|
|
90
94
|
if (!Number.isFinite(issued) || Date.now() - issued > 24 * 60 * 60 * 1000) throw new Error("Approval token expired; run intake again.");
|
|
91
95
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
96
|
+
recoverPendingTransactions(scrumDir);
|
|
92
97
|
const repository = new ArtifactRepository(scrumDir);
|
|
93
98
|
const approvalId = sha256(token).slice(0, 20);
|
|
94
99
|
const existing = approvalExisting(repository, approvalId);
|
|
@@ -97,8 +102,14 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null } = {}
|
|
|
97
102
|
if (currentContext.fingerprint !== payload.fingerprint) {
|
|
98
103
|
throw new Error("Project context changed after planning; run intake again before approval.");
|
|
99
104
|
}
|
|
105
|
+
const workspace = workspaceState(projectRoot);
|
|
106
|
+
if (!payload.workspaceFingerprint || workspace.fingerprint !== payload.workspaceFingerprint) {
|
|
107
|
+
throw new Error("Project workspace changed after planning; run intake again before approval.");
|
|
108
|
+
}
|
|
100
109
|
|
|
101
110
|
const created = date();
|
|
111
|
+
const enforceablePolicy = policyState(projectRoot);
|
|
112
|
+
const baseline = publicWorkspace(workspace);
|
|
102
113
|
const taskId = nextId(repository, "task");
|
|
103
114
|
const runId = nextId(repository, "run");
|
|
104
115
|
const task = {
|
|
@@ -125,20 +136,29 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null } = {}
|
|
|
125
136
|
task: taskId,
|
|
126
137
|
sprint: null,
|
|
127
138
|
attempt: 1,
|
|
139
|
+
ledger: 1,
|
|
140
|
+
guardrails: 1,
|
|
141
|
+
workspace: 1,
|
|
128
142
|
approval_id: approvalId
|
|
129
143
|
};
|
|
130
144
|
const taskBody = `# ${titleFor(payload.request)}\n\n## Request\n\n${payload.request}\n\n## Classification\n\n- Type: ${payload.classification.type}\n- Reason: ${payload.classification.reason}\n- Risk: ${payload.risk.level}\n\n## Approval\n\n- Explicit approval token: ${approvalId}\n- Context fingerprint: ${payload.fingerprint}`;
|
|
131
|
-
const runBody =
|
|
132
|
-
|
|
133
|
-
|
|
145
|
+
const runBody = createRunBody(run, {
|
|
146
|
+
approvalId,
|
|
147
|
+
obligations: payload.policy.obligations || [],
|
|
148
|
+
policyFingerprint: enforceablePolicy.fingerprint,
|
|
149
|
+
workspaceBaseline: baseline
|
|
150
|
+
}).body;
|
|
134
151
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
152
|
+
runKernelTransaction(scrumDir, "approve-task-run", [
|
|
153
|
+
{ file: repository.pathFor(task), previous: null, next: serializeArtifact(task, taskBody) },
|
|
154
|
+
{ file: repository.pathFor(run), previous: null, next: serializeArtifact(run, runBody) }
|
|
155
|
+
], {
|
|
156
|
+
failurePoint: failurePoint === "after-task" ? "after-1" : failurePoint === "after-run" ? "after-2" : null,
|
|
157
|
+
interruptPoint
|
|
158
|
+
});
|
|
139
159
|
} catch (error) {
|
|
140
|
-
if (
|
|
141
|
-
if (
|
|
160
|
+
if (failurePoint === "after-task") throw new Error(`Injected approval failure after Task creation: ${error.message}`);
|
|
161
|
+
if (failurePoint === "after-run") throw new Error(`Injected approval failure after Run creation: ${error.message}`);
|
|
142
162
|
throw error;
|
|
143
163
|
}
|
|
144
164
|
refreshState(scrumDir);
|
|
@@ -149,45 +169,52 @@ function approveRequest(projectRoot, token, options = {}) {
|
|
|
149
169
|
return withArtifactLock(path.join(projectRoot, ".scrumrun"), "approval", () => approveRequestUnlocked(projectRoot, token, options));
|
|
150
170
|
}
|
|
151
171
|
|
|
152
|
-
function
|
|
172
|
+
function transitionedArtifactContent(content, kind, nextStatus, updated) {
|
|
153
173
|
const parsed = parseArtifact(content);
|
|
154
174
|
const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
|
|
155
175
|
if (errors.length) throw new Error(`Invalid ${kind} artifact: ${errors.join("; ")}`);
|
|
156
|
-
const nextRecord = transitionArtifact(parsed.record, nextStatus,
|
|
176
|
+
const nextRecord = transitionArtifact(parsed.record, nextStatus, updated);
|
|
157
177
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/);
|
|
158
178
|
let frontmatter = frontmatterMatch[1]
|
|
159
179
|
.replace(/^status:\s*.*$/m, `status: ${nextStatus}`)
|
|
160
180
|
.replace(/^updated:\s*.*$/m, `updated: ${nextRecord.updated}`);
|
|
161
|
-
const
|
|
162
|
-
const next = `---\n${frontmatter}\n---${frontmatterMatch[2].trimEnd()}${transition}`;
|
|
181
|
+
const next = `---\n${frontmatter}\n---${frontmatterMatch[2]}`;
|
|
163
182
|
const validated = parseArtifact(next);
|
|
164
183
|
const nextErrors = [...validated.errors, ...validateArtifact(validated.record, kind)];
|
|
165
184
|
if (nextErrors.length) throw new Error(`Transition validation failed: ${nextErrors.join("; ")}`);
|
|
166
185
|
return { content: next, record: validated.record };
|
|
167
186
|
}
|
|
168
187
|
|
|
169
|
-
function
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
188
|
+
function transitionedRunContent(content, nextStatus, options = {}) {
|
|
189
|
+
const parsed = parseArtifact(content);
|
|
190
|
+
const errors = [...parsed.errors, ...validateArtifact(parsed.record, "run")];
|
|
191
|
+
if (errors.length) throw new Error(`Invalid run artifact: ${errors.join("; ")}`);
|
|
192
|
+
const occurredAt = options.occurredAt ? instant(options.occurredAt) : instant();
|
|
193
|
+
const updated = occurredAt.slice(0, 10);
|
|
194
|
+
const nextRecord = transitionArtifact(parsed.record, nextStatus, updated);
|
|
195
|
+
const ledger = appendRunEvent(parsed.record, parsed.body, nextRecord, { ...options, occurredAt });
|
|
196
|
+
return {
|
|
197
|
+
content: serializeArtifact(nextRecord, ledger.body),
|
|
198
|
+
record: nextRecord,
|
|
199
|
+
event: ledger.event
|
|
200
|
+
};
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, failurePoint = null } = {}) {
|
|
203
|
+
function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, evidence = [], actor = "agent", occurredAt = null, failurePoint = null, interruptPoint = null } = {}) {
|
|
185
204
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
205
|
+
const recovered = recoverPendingTransactions(scrumDir);
|
|
186
206
|
const repository = new ArtifactRepository(scrumDir);
|
|
187
207
|
const runArtifact = repository.read("run", runId);
|
|
188
208
|
if (!runArtifact) throw new Error(`Run not found: ${runId}`);
|
|
189
209
|
const taskArtifact = repository.read("task", runArtifact.record.task);
|
|
190
210
|
if (!taskArtifact) throw new Error(`Task not found for ${runId}: ${runArtifact.record.task}`);
|
|
211
|
+
if (runArtifact.record.status === nextStatus && recovered.some((item) => item.action === "verified")) {
|
|
212
|
+
refreshState(scrumDir);
|
|
213
|
+
return { run: runArtifact.record, task: taskArtifact.record, learning: null, recovered };
|
|
214
|
+
}
|
|
215
|
+
if (runArtifact.record.guardrails === 1 && runArtifact.record.workspace === 1 && ["validating", "learning"].includes(nextStatus)) {
|
|
216
|
+
verifyWorkspaceIntegrity(projectRoot, runArtifact);
|
|
217
|
+
}
|
|
191
218
|
const taskStatuses = {
|
|
192
219
|
validating: "validating",
|
|
193
220
|
learning: "learning",
|
|
@@ -200,12 +227,19 @@ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, fa
|
|
|
200
227
|
if (!nextTaskStatus) throw new Error(`Run status cannot synchronize a Task: ${nextStatus}`);
|
|
201
228
|
const runPrevious = fs.readFileSync(runArtifact.file, "utf8");
|
|
202
229
|
const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
const prepared = nextStatus === "completed" ? prepareCompletion(projectRoot, runArtifact, { occurredAt }) : null;
|
|
231
|
+
const transitionSource = prepared ? serializeArtifact(prepared.record, prepared.body) : runPrevious;
|
|
232
|
+
const runNext = transitionedRunContent(transitionSource, nextStatus, { note, evidence, actor, occurredAt });
|
|
233
|
+
const taskNext = transitionedArtifactContent(taskPrevious, "task", nextTaskStatus, runNext.record.updated);
|
|
234
|
+
try {
|
|
235
|
+
runKernelTransaction(scrumDir, "transition-run-task", [
|
|
236
|
+
{ file: runArtifact.file, previous: runPrevious, next: runNext.content },
|
|
237
|
+
{ file: taskArtifact.file, previous: taskPrevious, next: taskNext.content }
|
|
238
|
+
], { failurePoint, interruptPoint });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (failurePoint) throw new Error(`Injected transition failure: ${error.message}`);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
209
243
|
refreshState(scrumDir);
|
|
210
244
|
let learning = null;
|
|
211
245
|
if (nextStatus === "learning") {
|
|
@@ -216,24 +250,41 @@ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, fa
|
|
|
216
250
|
}
|
|
217
251
|
refreshState(scrumDir);
|
|
218
252
|
}
|
|
219
|
-
return { run: runNext.record, task: taskNext.record, learning };
|
|
253
|
+
return { run: runNext.record, task: taskNext.record, learning, recovered };
|
|
220
254
|
}
|
|
221
255
|
|
|
222
256
|
function transitionRun(projectRoot, runId, nextStatus, options = {}) {
|
|
223
257
|
return withArtifactLock(path.join(projectRoot, ".scrumrun"), `run-${String(runId).toLowerCase()}`, () => transitionRunUnlocked(projectRoot, runId, nextStatus, options));
|
|
224
258
|
}
|
|
225
259
|
|
|
226
|
-
function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", failurePoint = null } = {}) {
|
|
260
|
+
function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", failurePoint = null, interruptPoint = null } = {}) {
|
|
227
261
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
262
|
+
const recovered = recoverPendingTransactions(scrumDir);
|
|
228
263
|
const repository = new ArtifactRepository(scrumDir);
|
|
229
264
|
const taskArtifact = repository.read("task", taskId);
|
|
230
265
|
if (!taskArtifact) throw new Error(`Task not found: ${taskId}`);
|
|
266
|
+
if (taskArtifact.record.status === "running" && recovered.some((item) => item.action === "verified")) {
|
|
267
|
+
const latest = repository.list("run")
|
|
268
|
+
.filter((artifact) => artifact.record && artifact.record.task === taskId)
|
|
269
|
+
.sort((left, right) => right.record.attempt - left.record.attempt)[0];
|
|
270
|
+
if (latest) {
|
|
271
|
+
refreshState(scrumDir);
|
|
272
|
+
return { run: latest.record, task: taskArtifact.record, content: fs.readFileSync(latest.file, "utf8"), recovered };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
231
275
|
if (!["failed", "blocked", "partial"].includes(taskArtifact.record.status)) {
|
|
232
276
|
throw new Error(`Task ${taskId} is ${taskArtifact.record.status}; retry requires failed, blocked, or partial.`);
|
|
233
277
|
}
|
|
234
278
|
const attempts = repository.list("run")
|
|
235
279
|
.filter((artifact) => artifact.record && artifact.record.task === taskId)
|
|
236
280
|
.map((artifact) => Number(artifact.record.attempt) || 0);
|
|
281
|
+
const requestMatch = taskArtifact.body.match(/^## Request[ \t]*\r?\n\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
|
|
282
|
+
const request = requestMatch ? requestMatch[1].trim() : `Retry ${taskId}`;
|
|
283
|
+
const context = buildContextPackage(projectRoot, request);
|
|
284
|
+
const policy = evaluatePolicy(context);
|
|
285
|
+
if (policy.status !== "passed") throw new Error(`Retry is blocked by current policy: ${policy.violations.join("; ")}`);
|
|
286
|
+
const enforceablePolicy = policyState(projectRoot);
|
|
287
|
+
const baseline = publicWorkspace(workspaceState(projectRoot));
|
|
237
288
|
const run = {
|
|
238
289
|
id: nextId(repository, "run"),
|
|
239
290
|
kind: "run",
|
|
@@ -244,23 +295,37 @@ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly appro
|
|
|
244
295
|
task: taskId,
|
|
245
296
|
sprint: taskArtifact.record.sprint || null,
|
|
246
297
|
attempt: attempts.length ? Math.max(...attempts) + 1 : 1,
|
|
298
|
+
ledger: 1,
|
|
299
|
+
guardrails: 1,
|
|
300
|
+
workspace: 1,
|
|
247
301
|
approval_id: taskArtifact.record.approval_id || null
|
|
248
302
|
};
|
|
249
|
-
const
|
|
303
|
+
const runBody = createRunBody(run, {
|
|
304
|
+
title: `Retry for ${taskId}`,
|
|
305
|
+
actor: "owner",
|
|
306
|
+
reason: note,
|
|
307
|
+
evidence: [{ kind: "approval", summary: note }],
|
|
308
|
+
obligations: policy.obligations || [],
|
|
309
|
+
policyFingerprint: enforceablePolicy.fingerprint,
|
|
310
|
+
workspaceBaseline: baseline
|
|
311
|
+
}).body;
|
|
312
|
+
const runContent = serializeArtifact(run, runBody);
|
|
250
313
|
const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
|
|
251
|
-
const taskNext =
|
|
252
|
-
let runWritten = false;
|
|
314
|
+
const taskNext = transitionedArtifactContent(taskPrevious, "task", "running", date());
|
|
253
315
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
316
|
+
runKernelTransaction(scrumDir, "retry-task-run", [
|
|
317
|
+
{ file: repository.pathFor(run), previous: null, next: runContent },
|
|
318
|
+
{ file: taskArtifact.file, previous: taskPrevious, next: taskNext.content }
|
|
319
|
+
], {
|
|
320
|
+
failurePoint: failurePoint === "after-run" ? "after-1" : failurePoint,
|
|
321
|
+
interruptPoint
|
|
322
|
+
});
|
|
257
323
|
} catch (error) {
|
|
258
|
-
if (
|
|
259
|
-
atomicWrite(taskArtifact.file, taskPrevious);
|
|
324
|
+
if (failurePoint === "after-run") throw new Error(`Injected retry failure after Run creation: ${error.message}`);
|
|
260
325
|
throw error;
|
|
261
326
|
}
|
|
262
327
|
refreshState(scrumDir);
|
|
263
|
-
return { run, task: taskNext.record, content: runContent };
|
|
328
|
+
return { run, task: taskNext.record, content: runContent, recovered };
|
|
264
329
|
}
|
|
265
330
|
|
|
266
331
|
function retryTask(projectRoot, taskId, options = {}) {
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { containsSecret } = require("../security/secrets");
|
|
4
|
+
|
|
5
|
+
const GUARDRAIL_STATUSES = new Set(["active", "retired", "superseded"]);
|
|
6
|
+
const ENFORCEMENTS = new Set([
|
|
7
|
+
"manual",
|
|
8
|
+
"builtin:secret-boundary",
|
|
9
|
+
"builtin:approval-gate",
|
|
10
|
+
"builtin:read-only-path",
|
|
11
|
+
"builtin:owner-work",
|
|
12
|
+
"builtin:canonical-write",
|
|
13
|
+
"builtin:canonical-truth",
|
|
14
|
+
"builtin:memory-candidate",
|
|
15
|
+
"builtin:migration-integrity",
|
|
16
|
+
"builtin:review-gate"
|
|
17
|
+
]);
|
|
18
|
+
const SCOPES = new Set(["all", "intake", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
|
|
19
|
+
|
|
20
|
+
function normalized(value) {
|
|
21
|
+
return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function capped(value, limit = 500) {
|
|
25
|
+
const text = String(value || "").trim();
|
|
26
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fieldMap(block) {
|
|
30
|
+
const fields = {};
|
|
31
|
+
for (const match of block.matchAll(/^([A-Za-z][A-Za-z _-]*):\s*(.+)$/gm)) {
|
|
32
|
+
fields[normalized(match[1]).replace(/[ _-]+/g, "_")] = match[2].trim();
|
|
33
|
+
}
|
|
34
|
+
return fields;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function proseRule(block) {
|
|
38
|
+
const lines = block.split(/\r?\n/).slice(1);
|
|
39
|
+
const prose = [];
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (/^#{1,6}\s/.test(line)) break;
|
|
42
|
+
if (/^[A-Za-z][A-Za-z _-]*:\s*/.test(line)) continue;
|
|
43
|
+
if (!line.trim() && prose.length) break;
|
|
44
|
+
if (line.trim()) prose.push(line.trim());
|
|
45
|
+
}
|
|
46
|
+
return prose.join(" ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function inferEnforcement(title, rule) {
|
|
50
|
+
const normalizedTitle = normalized(title);
|
|
51
|
+
const text = normalized(`${title} ${rule}`);
|
|
52
|
+
if (/secret|credential|vault|segredo|credencial/.test(text)) return "builtin:secret-boundary";
|
|
53
|
+
if (/intake|pre-approval|pre approval|recebimento/.test(text) && /approval|approve|aprov|consent/.test(text)) return "builtin:approval-gate";
|
|
54
|
+
if (/read.?only|somente leitura|path marked|caminho marcado/.test(text)) return "builtin:read-only-path";
|
|
55
|
+
if (/owner work|trabalho do dono/.test(text) || (/(owner|dono|user changes|mudancas do usuario)/.test(text) && /(preserv|overwrite|sobrescrev)/.test(text))) return "builtin:owner-work";
|
|
56
|
+
if (/migration|migracao|migrat|never guesses|nunca adivinha/.test(normalizedTitle)) return "builtin:migration-integrity";
|
|
57
|
+
if (/commit|release|publish|lancamento|publica/.test(normalizedTitle) || (/(review|revis)/.test(normalizedTitle) && /gate|bloque|obrigator/.test(normalizedTitle))) return "builtin:review-gate";
|
|
58
|
+
return "manual";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseGuardrails(content) {
|
|
62
|
+
const source = String(content || "");
|
|
63
|
+
const headings = [...source.matchAll(/^## (GR-\d{3,})\s*[-—:]\s*([^\r\n]+)$/gm)];
|
|
64
|
+
return headings.map((heading, index) => {
|
|
65
|
+
const end = index + 1 < headings.length ? headings[index + 1].index : source.length;
|
|
66
|
+
const block = source.slice(heading.index, end);
|
|
67
|
+
const fields = fieldMap(block);
|
|
68
|
+
const title = heading[2].trim();
|
|
69
|
+
const rule = capped(fields.rule || proseRule(block) || title);
|
|
70
|
+
const status = normalized(fields.status || "active").replace(/[._-]+$/, "");
|
|
71
|
+
const enforcement = normalized(fields.enforcement || inferEnforcement(title, rule));
|
|
72
|
+
const scope = (fields.scope || "all").split(/\s*,\s*/).map((value) => normalized(value)).filter(Boolean);
|
|
73
|
+
return {
|
|
74
|
+
id: heading[1],
|
|
75
|
+
title,
|
|
76
|
+
rule,
|
|
77
|
+
status,
|
|
78
|
+
enforcement,
|
|
79
|
+
scope,
|
|
80
|
+
source: fields.source || null,
|
|
81
|
+
explicit: {
|
|
82
|
+
status: Object.prototype.hasOwnProperty.call(fields, "status"),
|
|
83
|
+
enforcement: Object.prototype.hasOwnProperty.call(fields, "enforcement"),
|
|
84
|
+
scope: Object.prototype.hasOwnProperty.call(fields, "scope"),
|
|
85
|
+
rule: Object.prototype.hasOwnProperty.call(fields, "rule")
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function configFields(content) {
|
|
92
|
+
return fieldMap(String(content || ""));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readOnlyPaths(content) {
|
|
96
|
+
const value = configFields(content).read_only_paths;
|
|
97
|
+
if (!value) return [];
|
|
98
|
+
return value.split(/\s*,\s*/).map((entry) => entry.trim()).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function configWeakeningAttempts(content) {
|
|
102
|
+
const text = String(content || "");
|
|
103
|
+
const fields = configFields(text);
|
|
104
|
+
const violations = [];
|
|
105
|
+
if (/^(?:never|off|disabled|automatic|nunca|desativad[oa]|automatic[oa])$/i.test(normalized(fields.execution_approval || ""))) {
|
|
106
|
+
violations.push(`Execution Approval cannot be ${fields.execution_approval}.`);
|
|
107
|
+
}
|
|
108
|
+
if (/\b(?:disable|ignore|bypass|weaken|desativar|ignorar|contornar|enfraquecer)\s+(?:active\s+|ativ[oa]s?\s+)?(?:guardrail|policy|politica)/i.test(normalized(text))) {
|
|
109
|
+
violations.push("Configuration attempts to disable or bypass active Guardrails.");
|
|
110
|
+
}
|
|
111
|
+
for (const entry of readOnlyPaths(text)) {
|
|
112
|
+
if (entry.startsWith("/") || entry.includes("..") || entry === "." || entry === "~") violations.push(`Read-Only Paths contains an unsafe path: ${entry}`);
|
|
113
|
+
}
|
|
114
|
+
return violations;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function requestMentionsPath(request, configuredPath) {
|
|
118
|
+
const clean = normalized(configuredPath.replace(/^\.\//, ""));
|
|
119
|
+
const escaped = clean.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
120
|
+
return new RegExp(`(^|[\\s"'\`:(])${escaped}(?=$|[/\\\\\\s"'\`:),])`).test(normalized(request));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function evaluation(guardrail, status, code, message, evidence = []) {
|
|
124
|
+
return { guardrail: guardrail.id, status, code, message, enforcement: guardrail.enforcement, scope: guardrail.scope, evidence };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function evaluateGuardrail(guardrail, context) {
|
|
128
|
+
const request = context.request || "";
|
|
129
|
+
const requestText = normalized(request);
|
|
130
|
+
switch (guardrail.enforcement) {
|
|
131
|
+
case "builtin:secret-boundary": {
|
|
132
|
+
const unsafeRequest = containsSecret(request);
|
|
133
|
+
const unsafeContext = context.warnings.some((warning) => /secret-like/i.test(warning));
|
|
134
|
+
if (unsafeRequest || unsafeContext) {
|
|
135
|
+
return evaluation(guardrail, "blocked", "SECRET_BOUNDARY", "secret-like content must be removed or redacted before planning.", [unsafeRequest ? "request" : "canonical-context"]);
|
|
136
|
+
}
|
|
137
|
+
return evaluation(guardrail, "passed", "SECRET_BOUNDARY", "No secret-like request or canonical context was detected.");
|
|
138
|
+
}
|
|
139
|
+
case "builtin:approval-gate":
|
|
140
|
+
return evaluation(guardrail, "passed", "APPROVAL_GATE", "Intake remains read-only and execution still requires the emitted approval token.", ["pipeline:awaiting_approval"]);
|
|
141
|
+
case "builtin:read-only-path": {
|
|
142
|
+
const paths = readOnlyPaths(context.config);
|
|
143
|
+
const matched = paths.filter((entry) => requestMentionsPath(request, entry));
|
|
144
|
+
if (matched.length) return evaluation(guardrail, "blocked", "READ_ONLY_PATH", `Request targets configured read-only path(s): ${matched.join(", ")}.`, matched);
|
|
145
|
+
return evaluation(guardrail, paths.length ? "passed" : "deferred", "READ_ONLY_PATH", paths.length ? "Configured read-only paths were checked." : "No structured Read-Only Paths are configured; enforce owner path constraints during execution.", paths);
|
|
146
|
+
}
|
|
147
|
+
case "builtin:owner-work":
|
|
148
|
+
return evaluation(guardrail, "deferred", "OWNER_WORK", "Mutation-time hashes, scoped staging, and transaction preconditions enforce owner-work preservation.");
|
|
149
|
+
case "builtin:canonical-write":
|
|
150
|
+
return evaluation(guardrail, "deferred", "CANONICAL_WRITE", "Canonical writes require the lossless transaction gateway and post-write validation.");
|
|
151
|
+
case "builtin:canonical-truth":
|
|
152
|
+
return evaluation(guardrail, "deferred", "CANONICAL_TRUTH", "Completion must prove that generated caches did not replace canonical Markdown truth.");
|
|
153
|
+
case "builtin:memory-candidate":
|
|
154
|
+
return evaluation(guardrail, "deferred", "MEMORY_CANDIDATE", "Learning and memory mutations must preserve candidate-first AI knowledge.");
|
|
155
|
+
case "builtin:migration-integrity":
|
|
156
|
+
return evaluation(guardrail, /migrat|migrac/.test(requestText) ? "deferred" : "passed", "MIGRATION_INTEGRITY", /migrat|migrac/.test(requestText) ? "The explicit migration preflight/apply/rollback gate must enforce this Guardrail." : "Request does not invoke migration.");
|
|
157
|
+
case "builtin:review-gate":
|
|
158
|
+
return evaluation(guardrail, /commit|release|publish|publica|lanc|deploy/.test(requestText) ? "deferred" : "passed", "REVIEW_GATE", /commit|release|publish|publica|lanc|deploy/.test(requestText) ? "Configured reviews and the external owner gate must pass before the requested boundary." : "Request does not cross a commit or release gate.");
|
|
159
|
+
default:
|
|
160
|
+
return evaluation(guardrail, "deferred", "MANUAL_GUARDRAIL", "This Guardrail has no deterministic matcher and requires execution-time review.");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function evaluatePolicy(context) {
|
|
165
|
+
const activeGuardrails = context.guardrails.filter((guardrail) => guardrail.status === "active");
|
|
166
|
+
const evaluations = activeGuardrails.map((guardrail) => evaluateGuardrail(guardrail, context));
|
|
167
|
+
if (context.layout === "v1") evaluations.push({ guardrail: "I-15", status: "blocked", code: "V1_REQUIRES_MIGRATION", message: "Project must be explicitly migrated to v2 before approved execution.", enforcement: "builtin", evidence: [] });
|
|
168
|
+
if (!activeGuardrails.length) evaluations.push({ guardrail: "I-06", status: "blocked", code: "GUARDRAILS_MISSING", message: "No active canonical Guardrail could be loaded.", enforcement: "builtin", evidence: [] });
|
|
169
|
+
if (context.warnings.some((warning) => warning.includes("guardrails.md"))) evaluations.push({ guardrail: "I-06", status: "blocked", code: "GUARDRAILS_MISSING", message: "Canonical guardrails.md is missing.", enforcement: "builtin", evidence: [] });
|
|
170
|
+
for (const message of configWeakeningAttempts(context.config)) {
|
|
171
|
+
evaluations.push({ guardrail: "I-06", status: "blocked", code: "CONFIG_WEAKENS_POLICY", message, enforcement: "builtin", evidence: ["config.md"] });
|
|
172
|
+
}
|
|
173
|
+
if (containsSecret(context.request) && !evaluations.some((item) => item.code === "SECRET_BOUNDARY" && item.status === "blocked")) {
|
|
174
|
+
evaluations.push({ guardrail: "I-11", status: "blocked", code: "SECRET_BOUNDARY", message: "Request appears to contain a secret; remove or redact it.", enforcement: "builtin", evidence: ["request"] });
|
|
175
|
+
}
|
|
176
|
+
const blocked = evaluations.filter((item) => item.status === "blocked");
|
|
177
|
+
const postApprovalScopes = new Set(["all", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
|
|
178
|
+
const continuousEnforcement = new Set(["builtin:secret-boundary", "builtin:owner-work", "builtin:read-only-path", "builtin:canonical-write", "builtin:canonical-truth", "builtin:memory-candidate"]);
|
|
179
|
+
const obligations = evaluations
|
|
180
|
+
.filter((item) => item.status === "deferred" || (item.status === "passed" && continuousEnforcement.has(item.enforcement) && (item.scope || []).some((scope) => postApprovalScopes.has(scope))))
|
|
181
|
+
.map((item) => ({
|
|
182
|
+
guardrail: item.guardrail,
|
|
183
|
+
code: item.code,
|
|
184
|
+
enforcement: item.enforcement,
|
|
185
|
+
scope: item.scope,
|
|
186
|
+
gate: obligationGate(item)
|
|
187
|
+
}));
|
|
188
|
+
return {
|
|
189
|
+
status: blocked.length ? "blocked" : "passed",
|
|
190
|
+
checked: [...new Set(evaluations.map((item) => item.guardrail))],
|
|
191
|
+
deferred: [...new Set(evaluations.filter((item) => item.status === "deferred").map((item) => item.guardrail))],
|
|
192
|
+
obligations,
|
|
193
|
+
evaluations,
|
|
194
|
+
violations: blocked.map((item) => `${item.guardrail} ${item.code}: ${item.message}`)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function obligationGate(evaluationResult) {
|
|
199
|
+
const scopes = evaluationResult.scope || [];
|
|
200
|
+
for (const gate of ["mutation", "migration", "validation", "learning", "completion", "commit", "release", "memory", "canonical"]) {
|
|
201
|
+
if (scopes.includes(gate)) return gate;
|
|
202
|
+
}
|
|
203
|
+
switch (evaluationResult.enforcement) {
|
|
204
|
+
case "builtin:owner-work":
|
|
205
|
+
case "builtin:read-only-path":
|
|
206
|
+
case "builtin:secret-boundary":
|
|
207
|
+
return "mutation";
|
|
208
|
+
case "builtin:migration-integrity":
|
|
209
|
+
return "migration";
|
|
210
|
+
case "builtin:memory-candidate":
|
|
211
|
+
return "learning";
|
|
212
|
+
case "builtin:review-gate":
|
|
213
|
+
return "commit";
|
|
214
|
+
default:
|
|
215
|
+
return "completion";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateGuardrailDocument(content) {
|
|
220
|
+
const records = parseGuardrails(content);
|
|
221
|
+
const errors = [];
|
|
222
|
+
const ids = new Set();
|
|
223
|
+
for (const record of records) {
|
|
224
|
+
if (ids.has(record.id)) errors.push(`Duplicate Guardrail id: ${record.id}`);
|
|
225
|
+
ids.add(record.id);
|
|
226
|
+
if (!GUARDRAIL_STATUSES.has(record.status)) errors.push(`${record.id} has invalid status: ${record.status || "missing"}`);
|
|
227
|
+
if (!record.rule.trim()) errors.push(`${record.id} has no rule content`);
|
|
228
|
+
if (!ENFORCEMENTS.has(record.enforcement)) errors.push(`${record.id} has unknown enforcement: ${record.enforcement}`);
|
|
229
|
+
if (!record.scope.length) errors.push(`${record.id} has no scope`);
|
|
230
|
+
for (const scope of record.scope) if (!SCOPES.has(scope)) errors.push(`${record.id} has unknown scope: ${scope}`);
|
|
231
|
+
}
|
|
232
|
+
return { records, errors };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalizeGuardrailDocument(content) {
|
|
236
|
+
const source = String(content || "");
|
|
237
|
+
const records = parseGuardrails(source);
|
|
238
|
+
const headings = [...source.matchAll(/^## (GR-\d{3,})\s*[-—:]\s*([^\r\n]+)$/gm)];
|
|
239
|
+
let next = source;
|
|
240
|
+
for (let index = headings.length - 1; index >= 0; index--) {
|
|
241
|
+
const heading = headings[index];
|
|
242
|
+
const record = records.find((item) => item.id === heading[1]);
|
|
243
|
+
if (!record) continue;
|
|
244
|
+
const missing = [];
|
|
245
|
+
if (!record.explicit.status) missing.push(`Status: ${record.status}`);
|
|
246
|
+
if (!record.explicit.enforcement) missing.push(`Enforcement: ${record.enforcement}`);
|
|
247
|
+
if (!record.explicit.scope) missing.push(`Scope: ${record.scope.join(", ") || "all"}`);
|
|
248
|
+
if (!record.explicit.rule) missing.push(`Rule: ${record.rule}`);
|
|
249
|
+
if (!missing.length) continue;
|
|
250
|
+
const lineEnd = next.indexOf("\n", heading.index);
|
|
251
|
+
const insertAt = lineEnd < 0 ? next.length : lineEnd + 1;
|
|
252
|
+
next = `${next.slice(0, insertAt)}\n${missing.join("\n")}\n${next.slice(insertAt)}`;
|
|
253
|
+
}
|
|
254
|
+
return next.endsWith("\n") ? next : `${next}\n`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
configWeakeningAttempts,
|
|
259
|
+
evaluatePolicy,
|
|
260
|
+
inferEnforcement,
|
|
261
|
+
normalizeGuardrailDocument,
|
|
262
|
+
obligationGate,
|
|
263
|
+
normalized,
|
|
264
|
+
parseGuardrails,
|
|
265
|
+
readOnlyPaths,
|
|
266
|
+
validateGuardrailDocument
|
|
267
|
+
};
|