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
|
@@ -15,8 +15,11 @@ const {
|
|
|
15
15
|
withArtifactLock
|
|
16
16
|
} = require("../v2/artifacts");
|
|
17
17
|
const { buildContextPackage } = require("./context");
|
|
18
|
+
const { artifactSnapshot, canonicalFingerprint, canonicalWatchSnapshot } = require("./canonical-snapshot");
|
|
18
19
|
const { decodeApproval } = require("./request-engine");
|
|
19
20
|
const { extractLearningCandidates } = require("../code-intel/learning");
|
|
21
|
+
const { appendRunEvent, createRunBody, instant } = require("./run-ledger");
|
|
22
|
+
const { recoverPendingTransactions, runKernelTransaction } = require("../v2/transaction");
|
|
20
23
|
|
|
21
24
|
const TERMINAL = new Set(["completed", "failed", "cancelled", "resolved", "rejected", "deprecated", "invalidated", "archived", "passed"]);
|
|
22
25
|
|
|
@@ -39,40 +42,39 @@ function titleFor(request) {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
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"));
|
|
45
|
+
return canonicalFingerprint(repository.scrumDir, artifactSnapshot(repository.scrumDir).hashes);
|
|
53
46
|
}
|
|
54
47
|
|
|
55
|
-
function
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
return `- ${artifact.record.id} | ${kind} | ${artifact.record.status} | ${title}`;
|
|
61
|
-
}));
|
|
48
|
+
function stateProjection(repository) {
|
|
49
|
+
const snapshot = artifactSnapshot(repository.scrumDir);
|
|
50
|
+
const linesFor = (kinds) => kinds.flatMap((kind) => (snapshot.records[kind] || [])
|
|
51
|
+
.filter((record) => record.id && !TERMINAL.has(record.status))
|
|
52
|
+
.map((record) => `- ${record.id} | ${kind} | ${record.status} | ${record.title || record.id}`));
|
|
62
53
|
const work = linesFor(["feature", "task", "sprint", "run", "review"]);
|
|
63
54
|
const memory = linesFor(["decision", "knowledge", "insight", "dossier"]);
|
|
64
|
-
|
|
55
|
+
const sourceFingerprint = canonicalFingerprint(repository.scrumDir, snapshot.hashes);
|
|
56
|
+
const watch = canonicalWatchSnapshot(repository.scrumDir);
|
|
57
|
+
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`;
|
|
58
|
+
return { content, sourceFingerprint, watchFingerprint: watch.fingerprint, sourceFiles: watch.files };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderState(repository) {
|
|
62
|
+
return stateProjection(repository).content;
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
function refreshState(scrumDir) {
|
|
68
66
|
const repository = new ArtifactRepository(scrumDir);
|
|
69
|
-
|
|
67
|
+
const projection = stateProjection(repository);
|
|
68
|
+
atomicWrite(path.join(scrumDir, "state.md"), projection.content);
|
|
69
|
+
return projection;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function stateIsStale(scrumDir) {
|
|
73
73
|
const content = fs.existsSync(path.join(scrumDir, "state.md")) ? fs.readFileSync(path.join(scrumDir, "state.md"), "utf8") : "";
|
|
74
74
|
const stored = (content.match(/^Source fingerprint:\s*([a-f0-9]{64})$/m) || [])[1];
|
|
75
75
|
if (!stored) return true;
|
|
76
|
+
const watched = (content.match(/^Watch fingerprint:\s*([a-f0-9]{64})$/m) || [])[1];
|
|
77
|
+
if (watched && watched === canonicalWatchSnapshot(scrumDir).fingerprint) return false;
|
|
76
78
|
return stored !== stateFingerprint(new ArtifactRepository(scrumDir));
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -83,12 +85,13 @@ function approvalExisting(repository, approvalId) {
|
|
|
83
85
|
return { task: task.record, run: run ? run.record : null };
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
function approveRequestUnlocked(projectRoot, token, { failurePoint = null } = {}) {
|
|
88
|
+
function approveRequestUnlocked(projectRoot, token, { failurePoint = null, interruptPoint = null } = {}) {
|
|
87
89
|
const payload = decodeApproval(token);
|
|
88
90
|
if (payload.policy.status !== "passed" || payload.policy.violations.length) throw new Error("Blocked intake cannot be approved.");
|
|
89
91
|
const issued = Date.parse(payload.issuedAt);
|
|
90
92
|
if (!Number.isFinite(issued) || Date.now() - issued > 24 * 60 * 60 * 1000) throw new Error("Approval token expired; run intake again.");
|
|
91
93
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
94
|
+
recoverPendingTransactions(scrumDir);
|
|
92
95
|
const repository = new ArtifactRepository(scrumDir);
|
|
93
96
|
const approvalId = sha256(token).slice(0, 20);
|
|
94
97
|
const existing = approvalExisting(repository, approvalId);
|
|
@@ -125,20 +128,22 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null } = {}
|
|
|
125
128
|
task: taskId,
|
|
126
129
|
sprint: null,
|
|
127
130
|
attempt: 1,
|
|
131
|
+
ledger: 1,
|
|
128
132
|
approval_id: approvalId
|
|
129
133
|
};
|
|
130
134
|
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
|
-
let taskWritten = false;
|
|
133
|
-
let runWritten = false;
|
|
135
|
+
const runBody = createRunBody(run, { approvalId }).body;
|
|
134
136
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
runKernelTransaction(scrumDir, "approve-task-run", [
|
|
138
|
+
{ file: repository.pathFor(task), previous: null, next: serializeArtifact(task, taskBody) },
|
|
139
|
+
{ file: repository.pathFor(run), previous: null, next: serializeArtifact(run, runBody) }
|
|
140
|
+
], {
|
|
141
|
+
failurePoint: failurePoint === "after-task" ? "after-1" : failurePoint === "after-run" ? "after-2" : null,
|
|
142
|
+
interruptPoint
|
|
143
|
+
});
|
|
139
144
|
} catch (error) {
|
|
140
|
-
if (
|
|
141
|
-
if (
|
|
145
|
+
if (failurePoint === "after-task") throw new Error(`Injected approval failure after Task creation: ${error.message}`);
|
|
146
|
+
if (failurePoint === "after-run") throw new Error(`Injected approval failure after Run creation: ${error.message}`);
|
|
142
147
|
throw error;
|
|
143
148
|
}
|
|
144
149
|
refreshState(scrumDir);
|
|
@@ -149,45 +154,49 @@ function approveRequest(projectRoot, token, options = {}) {
|
|
|
149
154
|
return withArtifactLock(path.join(projectRoot, ".scrumrun"), "approval", () => approveRequestUnlocked(projectRoot, token, options));
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
function
|
|
157
|
+
function transitionedArtifactContent(content, kind, nextStatus, updated) {
|
|
153
158
|
const parsed = parseArtifact(content);
|
|
154
159
|
const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
|
|
155
160
|
if (errors.length) throw new Error(`Invalid ${kind} artifact: ${errors.join("; ")}`);
|
|
156
|
-
const nextRecord = transitionArtifact(parsed.record, nextStatus,
|
|
161
|
+
const nextRecord = transitionArtifact(parsed.record, nextStatus, updated);
|
|
157
162
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/);
|
|
158
163
|
let frontmatter = frontmatterMatch[1]
|
|
159
164
|
.replace(/^status:\s*.*$/m, `status: ${nextStatus}`)
|
|
160
165
|
.replace(/^updated:\s*.*$/m, `updated: ${nextRecord.updated}`);
|
|
161
|
-
const
|
|
162
|
-
const next = `---\n${frontmatter}\n---${frontmatterMatch[2].trimEnd()}${transition}`;
|
|
166
|
+
const next = `---\n${frontmatter}\n---${frontmatterMatch[2]}`;
|
|
163
167
|
const validated = parseArtifact(next);
|
|
164
168
|
const nextErrors = [...validated.errors, ...validateArtifact(validated.record, kind)];
|
|
165
169
|
if (nextErrors.length) throw new Error(`Transition validation failed: ${nextErrors.join("; ")}`);
|
|
166
170
|
return { content: next, record: validated.record };
|
|
167
171
|
}
|
|
168
172
|
|
|
169
|
-
function
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
173
|
+
function transitionedRunContent(content, nextStatus, options = {}) {
|
|
174
|
+
const parsed = parseArtifact(content);
|
|
175
|
+
const errors = [...parsed.errors, ...validateArtifact(parsed.record, "run")];
|
|
176
|
+
if (errors.length) throw new Error(`Invalid run artifact: ${errors.join("; ")}`);
|
|
177
|
+
const occurredAt = options.occurredAt ? instant(options.occurredAt) : instant();
|
|
178
|
+
const updated = occurredAt.slice(0, 10);
|
|
179
|
+
const nextRecord = transitionArtifact(parsed.record, nextStatus, updated);
|
|
180
|
+
const ledger = appendRunEvent(parsed.record, parsed.body, nextRecord, { ...options, occurredAt });
|
|
181
|
+
return {
|
|
182
|
+
content: serializeArtifact(nextRecord, ledger.body),
|
|
183
|
+
record: nextRecord,
|
|
184
|
+
event: ledger.event
|
|
185
|
+
};
|
|
182
186
|
}
|
|
183
187
|
|
|
184
|
-
function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, failurePoint = null } = {}) {
|
|
188
|
+
function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, evidence = [], actor = "agent", occurredAt = null, failurePoint = null, interruptPoint = null } = {}) {
|
|
185
189
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
190
|
+
const recovered = recoverPendingTransactions(scrumDir);
|
|
186
191
|
const repository = new ArtifactRepository(scrumDir);
|
|
187
192
|
const runArtifact = repository.read("run", runId);
|
|
188
193
|
if (!runArtifact) throw new Error(`Run not found: ${runId}`);
|
|
189
194
|
const taskArtifact = repository.read("task", runArtifact.record.task);
|
|
190
195
|
if (!taskArtifact) throw new Error(`Task not found for ${runId}: ${runArtifact.record.task}`);
|
|
196
|
+
if (runArtifact.record.status === nextStatus && recovered.some((item) => item.action === "verified")) {
|
|
197
|
+
refreshState(scrumDir);
|
|
198
|
+
return { run: runArtifact.record, task: taskArtifact.record, learning: null, recovered };
|
|
199
|
+
}
|
|
191
200
|
const taskStatuses = {
|
|
192
201
|
validating: "validating",
|
|
193
202
|
learning: "learning",
|
|
@@ -200,12 +209,17 @@ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, fa
|
|
|
200
209
|
if (!nextTaskStatus) throw new Error(`Run status cannot synchronize a Task: ${nextStatus}`);
|
|
201
210
|
const runPrevious = fs.readFileSync(runArtifact.file, "utf8");
|
|
202
211
|
const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
|
|
203
|
-
const runNext =
|
|
204
|
-
const taskNext =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
const runNext = transitionedRunContent(runPrevious, nextStatus, { note, evidence, actor, occurredAt });
|
|
213
|
+
const taskNext = transitionedArtifactContent(taskPrevious, "task", nextTaskStatus, runNext.record.updated);
|
|
214
|
+
try {
|
|
215
|
+
runKernelTransaction(scrumDir, "transition-run-task", [
|
|
216
|
+
{ file: runArtifact.file, previous: runPrevious, next: runNext.content },
|
|
217
|
+
{ file: taskArtifact.file, previous: taskPrevious, next: taskNext.content }
|
|
218
|
+
], { failurePoint, interruptPoint });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (failurePoint) throw new Error(`Injected transition failure: ${error.message}`);
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
209
223
|
refreshState(scrumDir);
|
|
210
224
|
let learning = null;
|
|
211
225
|
if (nextStatus === "learning") {
|
|
@@ -216,18 +230,28 @@ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, fa
|
|
|
216
230
|
}
|
|
217
231
|
refreshState(scrumDir);
|
|
218
232
|
}
|
|
219
|
-
return { run: runNext.record, task: taskNext.record, learning };
|
|
233
|
+
return { run: runNext.record, task: taskNext.record, learning, recovered };
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
function transitionRun(projectRoot, runId, nextStatus, options = {}) {
|
|
223
237
|
return withArtifactLock(path.join(projectRoot, ".scrumrun"), `run-${String(runId).toLowerCase()}`, () => transitionRunUnlocked(projectRoot, runId, nextStatus, options));
|
|
224
238
|
}
|
|
225
239
|
|
|
226
|
-
function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", failurePoint = null } = {}) {
|
|
240
|
+
function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", failurePoint = null, interruptPoint = null } = {}) {
|
|
227
241
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
242
|
+
const recovered = recoverPendingTransactions(scrumDir);
|
|
228
243
|
const repository = new ArtifactRepository(scrumDir);
|
|
229
244
|
const taskArtifact = repository.read("task", taskId);
|
|
230
245
|
if (!taskArtifact) throw new Error(`Task not found: ${taskId}`);
|
|
246
|
+
if (taskArtifact.record.status === "running" && recovered.some((item) => item.action === "verified")) {
|
|
247
|
+
const latest = repository.list("run")
|
|
248
|
+
.filter((artifact) => artifact.record && artifact.record.task === taskId)
|
|
249
|
+
.sort((left, right) => right.record.attempt - left.record.attempt)[0];
|
|
250
|
+
if (latest) {
|
|
251
|
+
refreshState(scrumDir);
|
|
252
|
+
return { run: latest.record, task: taskArtifact.record, content: fs.readFileSync(latest.file, "utf8"), recovered };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
231
255
|
if (!["failed", "blocked", "partial"].includes(taskArtifact.record.status)) {
|
|
232
256
|
throw new Error(`Task ${taskId} is ${taskArtifact.record.status}; retry requires failed, blocked, or partial.`);
|
|
233
257
|
}
|
|
@@ -244,23 +268,32 @@ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly appro
|
|
|
244
268
|
task: taskId,
|
|
245
269
|
sprint: taskArtifact.record.sprint || null,
|
|
246
270
|
attempt: attempts.length ? Math.max(...attempts) + 1 : 1,
|
|
271
|
+
ledger: 1,
|
|
247
272
|
approval_id: taskArtifact.record.approval_id || null
|
|
248
273
|
};
|
|
249
|
-
const
|
|
274
|
+
const runBody = createRunBody(run, {
|
|
275
|
+
title: `Retry for ${taskId}`,
|
|
276
|
+
actor: "owner",
|
|
277
|
+
reason: note,
|
|
278
|
+
evidence: [{ kind: "approval", summary: note }]
|
|
279
|
+
}).body;
|
|
280
|
+
const runContent = serializeArtifact(run, runBody);
|
|
250
281
|
const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
|
|
251
|
-
const taskNext =
|
|
252
|
-
let runWritten = false;
|
|
282
|
+
const taskNext = transitionedArtifactContent(taskPrevious, "task", "running", date());
|
|
253
283
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
284
|
+
runKernelTransaction(scrumDir, "retry-task-run", [
|
|
285
|
+
{ file: repository.pathFor(run), previous: null, next: runContent },
|
|
286
|
+
{ file: taskArtifact.file, previous: taskPrevious, next: taskNext.content }
|
|
287
|
+
], {
|
|
288
|
+
failurePoint: failurePoint === "after-run" ? "after-1" : failurePoint,
|
|
289
|
+
interruptPoint
|
|
290
|
+
});
|
|
257
291
|
} catch (error) {
|
|
258
|
-
if (
|
|
259
|
-
atomicWrite(taskArtifact.file, taskPrevious);
|
|
292
|
+
if (failurePoint === "after-run") throw new Error(`Injected retry failure after Run creation: ${error.message}`);
|
|
260
293
|
throw error;
|
|
261
294
|
}
|
|
262
295
|
refreshState(scrumDir);
|
|
263
|
-
return { run, task: taskNext.record, content: runContent };
|
|
296
|
+
return { run, task: taskNext.record, content: runContent, recovered };
|
|
264
297
|
}
|
|
265
298
|
|
|
266
299
|
function retryTask(projectRoot, taskId, options = {}) {
|
|
@@ -0,0 +1,184 @@
|
|
|
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:migration-integrity",
|
|
13
|
+
"builtin:review-gate"
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function normalized(value) {
|
|
17
|
+
return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function capped(value, limit = 500) {
|
|
21
|
+
const text = String(value || "").trim();
|
|
22
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fieldMap(block) {
|
|
26
|
+
const fields = {};
|
|
27
|
+
for (const match of block.matchAll(/^([A-Za-z][A-Za-z _-]*):\s*(.+)$/gm)) {
|
|
28
|
+
fields[normalized(match[1]).replace(/[ _-]+/g, "_")] = match[2].trim();
|
|
29
|
+
}
|
|
30
|
+
return fields;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function proseRule(block) {
|
|
34
|
+
const lines = block.split(/\r?\n/).slice(1);
|
|
35
|
+
const prose = [];
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
if (/^#{1,6}\s/.test(line)) break;
|
|
38
|
+
if (/^[A-Za-z][A-Za-z _-]*:\s*/.test(line)) continue;
|
|
39
|
+
if (!line.trim() && prose.length) break;
|
|
40
|
+
if (line.trim()) prose.push(line.trim());
|
|
41
|
+
}
|
|
42
|
+
return prose.join(" ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inferEnforcement(title, rule) {
|
|
46
|
+
const normalizedTitle = normalized(title);
|
|
47
|
+
const text = normalized(`${title} ${rule}`);
|
|
48
|
+
if (/secret|credential|vault|segredo|credencial/.test(text)) return "builtin:secret-boundary";
|
|
49
|
+
if (/intake|pre-approval|pre approval|recebimento/.test(text) && /approval|approve|aprov|consent/.test(text)) return "builtin:approval-gate";
|
|
50
|
+
if (/read.?only|somente leitura|path marked|caminho marcado/.test(text)) return "builtin:read-only-path";
|
|
51
|
+
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";
|
|
52
|
+
if (/migration|migracao|migrat|never guesses|nunca adivinha/.test(normalizedTitle)) return "builtin:migration-integrity";
|
|
53
|
+
if (/commit|release|publish|lancamento|publica/.test(normalizedTitle) || (/(review|revis)/.test(normalizedTitle) && /gate|bloque|obrigator/.test(normalizedTitle))) return "builtin:review-gate";
|
|
54
|
+
return "manual";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseGuardrails(content) {
|
|
58
|
+
const source = String(content || "");
|
|
59
|
+
const headings = [...source.matchAll(/^## (GR-\d{3,})\s*[-—:]\s*([^\r\n]+)$/gm)];
|
|
60
|
+
return headings.map((heading, index) => {
|
|
61
|
+
const end = index + 1 < headings.length ? headings[index + 1].index : source.length;
|
|
62
|
+
const block = source.slice(heading.index, end);
|
|
63
|
+
const fields = fieldMap(block);
|
|
64
|
+
const title = heading[2].trim();
|
|
65
|
+
const rule = capped(fields.rule || proseRule(block) || title);
|
|
66
|
+
const status = normalized(fields.status || "active").replace(/[._-]+$/, "");
|
|
67
|
+
const enforcement = normalized(fields.enforcement || inferEnforcement(title, rule));
|
|
68
|
+
const scope = (fields.scope || "all").split(/\s*,\s*/).map((value) => normalized(value)).filter(Boolean);
|
|
69
|
+
return { id: heading[1], title, rule, status, enforcement, scope, source: fields.source || null };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function configFields(content) {
|
|
74
|
+
return fieldMap(String(content || ""));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readOnlyPaths(content) {
|
|
78
|
+
const value = configFields(content).read_only_paths;
|
|
79
|
+
if (!value) return [];
|
|
80
|
+
return value.split(/\s*,\s*/).map((entry) => entry.trim()).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function configWeakeningAttempts(content) {
|
|
84
|
+
const text = String(content || "");
|
|
85
|
+
const fields = configFields(text);
|
|
86
|
+
const violations = [];
|
|
87
|
+
if (/^(?:never|off|disabled|automatic|nunca|desativad[oa]|automatic[oa])$/i.test(normalized(fields.execution_approval || ""))) {
|
|
88
|
+
violations.push(`Execution Approval cannot be ${fields.execution_approval}.`);
|
|
89
|
+
}
|
|
90
|
+
if (/\b(?:disable|ignore|bypass|weaken|desativar|ignorar|contornar|enfraquecer)\s+(?:active\s+|ativ[oa]s?\s+)?(?:guardrail|policy|politica)/i.test(normalized(text))) {
|
|
91
|
+
violations.push("Configuration attempts to disable or bypass active Guardrails.");
|
|
92
|
+
}
|
|
93
|
+
for (const entry of readOnlyPaths(text)) {
|
|
94
|
+
if (entry.startsWith("/") || entry.includes("..") || entry === "." || entry === "~") violations.push(`Read-Only Paths contains an unsafe path: ${entry}`);
|
|
95
|
+
}
|
|
96
|
+
return violations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requestMentionsPath(request, configuredPath) {
|
|
100
|
+
const clean = normalized(configuredPath.replace(/^\.\//, ""));
|
|
101
|
+
const escaped = clean.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
102
|
+
return new RegExp(`(^|[\\s"'\`:(])${escaped}(?=$|[/\\\\\\s"'\`:),])`).test(normalized(request));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function evaluation(guardrail, status, code, message, evidence = []) {
|
|
106
|
+
return { guardrail: guardrail.id, status, code, message, enforcement: guardrail.enforcement, evidence };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function evaluateGuardrail(guardrail, context) {
|
|
110
|
+
const request = context.request || "";
|
|
111
|
+
const requestText = normalized(request);
|
|
112
|
+
switch (guardrail.enforcement) {
|
|
113
|
+
case "builtin:secret-boundary": {
|
|
114
|
+
const unsafeRequest = containsSecret(request);
|
|
115
|
+
const unsafeContext = context.warnings.some((warning) => /secret-like/i.test(warning));
|
|
116
|
+
if (unsafeRequest || unsafeContext) {
|
|
117
|
+
return evaluation(guardrail, "blocked", "SECRET_BOUNDARY", "secret-like content must be removed or redacted before planning.", [unsafeRequest ? "request" : "canonical-context"]);
|
|
118
|
+
}
|
|
119
|
+
return evaluation(guardrail, "passed", "SECRET_BOUNDARY", "No secret-like request or canonical context was detected.");
|
|
120
|
+
}
|
|
121
|
+
case "builtin:approval-gate":
|
|
122
|
+
return evaluation(guardrail, "passed", "APPROVAL_GATE", "Intake remains read-only and execution still requires the emitted approval token.", ["pipeline:awaiting_approval"]);
|
|
123
|
+
case "builtin:read-only-path": {
|
|
124
|
+
const paths = readOnlyPaths(context.config);
|
|
125
|
+
const matched = paths.filter((entry) => requestMentionsPath(request, entry));
|
|
126
|
+
if (matched.length) return evaluation(guardrail, "blocked", "READ_ONLY_PATH", `Request targets configured read-only path(s): ${matched.join(", ")}.`, matched);
|
|
127
|
+
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);
|
|
128
|
+
}
|
|
129
|
+
case "builtin:owner-work":
|
|
130
|
+
return evaluation(guardrail, "deferred", "OWNER_WORK", "Mutation-time hashes, scoped staging, and transaction preconditions enforce owner-work preservation.");
|
|
131
|
+
case "builtin:migration-integrity":
|
|
132
|
+
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.");
|
|
133
|
+
case "builtin:review-gate":
|
|
134
|
+
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.");
|
|
135
|
+
default:
|
|
136
|
+
return evaluation(guardrail, "deferred", "MANUAL_GUARDRAIL", "This Guardrail has no deterministic matcher and requires execution-time review.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function evaluatePolicy(context) {
|
|
141
|
+
const activeGuardrails = context.guardrails.filter((guardrail) => guardrail.status === "active");
|
|
142
|
+
const evaluations = activeGuardrails.map((guardrail) => evaluateGuardrail(guardrail, context));
|
|
143
|
+
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: [] });
|
|
144
|
+
if (!activeGuardrails.length) evaluations.push({ guardrail: "I-06", status: "blocked", code: "GUARDRAILS_MISSING", message: "No active canonical Guardrail could be loaded.", enforcement: "builtin", evidence: [] });
|
|
145
|
+
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: [] });
|
|
146
|
+
for (const message of configWeakeningAttempts(context.config)) {
|
|
147
|
+
evaluations.push({ guardrail: "I-06", status: "blocked", code: "CONFIG_WEAKENS_POLICY", message, enforcement: "builtin", evidence: ["config.md"] });
|
|
148
|
+
}
|
|
149
|
+
if (containsSecret(context.request) && !evaluations.some((item) => item.code === "SECRET_BOUNDARY" && item.status === "blocked")) {
|
|
150
|
+
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"] });
|
|
151
|
+
}
|
|
152
|
+
const blocked = evaluations.filter((item) => item.status === "blocked");
|
|
153
|
+
return {
|
|
154
|
+
status: blocked.length ? "blocked" : "passed",
|
|
155
|
+
checked: [...new Set(evaluations.map((item) => item.guardrail))],
|
|
156
|
+
deferred: [...new Set(evaluations.filter((item) => item.status === "deferred").map((item) => item.guardrail))],
|
|
157
|
+
evaluations,
|
|
158
|
+
violations: blocked.map((item) => `${item.guardrail} ${item.code}: ${item.message}`)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateGuardrailDocument(content) {
|
|
163
|
+
const records = parseGuardrails(content);
|
|
164
|
+
const errors = [];
|
|
165
|
+
const ids = new Set();
|
|
166
|
+
for (const record of records) {
|
|
167
|
+
if (ids.has(record.id)) errors.push(`Duplicate Guardrail id: ${record.id}`);
|
|
168
|
+
ids.add(record.id);
|
|
169
|
+
if (!GUARDRAIL_STATUSES.has(record.status)) errors.push(`${record.id} has invalid status: ${record.status || "missing"}`);
|
|
170
|
+
if (!record.rule.trim()) errors.push(`${record.id} has no rule content`);
|
|
171
|
+
if (!ENFORCEMENTS.has(record.enforcement)) errors.push(`${record.id} has unknown enforcement: ${record.enforcement}`);
|
|
172
|
+
}
|
|
173
|
+
return { records, errors };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
configWeakeningAttempts,
|
|
178
|
+
evaluatePolicy,
|
|
179
|
+
inferEnforcement,
|
|
180
|
+
normalized,
|
|
181
|
+
parseGuardrails,
|
|
182
|
+
readOnlyPaths,
|
|
183
|
+
validateGuardrailDocument
|
|
184
|
+
};
|
|
@@ -3,51 +3,55 @@
|
|
|
3
3
|
const { sha256 } = require("../v2/artifacts");
|
|
4
4
|
const { buildContextPackage } = require("./context");
|
|
5
5
|
const { containsSecret } = require("../security/secrets");
|
|
6
|
+
const { evaluatePolicy, normalized } = require("./policy-engine");
|
|
6
7
|
|
|
7
8
|
function assessRisk(request) {
|
|
8
|
-
const text = request
|
|
9
|
-
const high = [
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
const text = normalized(request);
|
|
10
|
+
const high = [
|
|
11
|
+
["destructive", /\b(delete|drop|truncate|erase|wipe|apagar|excluir|remover tudo)\b/],
|
|
12
|
+
["migration", /\b(migrat\w*|migrac\w*)\b/],
|
|
13
|
+
["external-release", /\b(publish|release|deploy|publicar|lancar|implant|producao)\b/],
|
|
14
|
+
["security-boundary", /\b(auth|authorization|permission|security|secret|credential|autentic|autoriz|permiss|seguranca|segredo|credencial)\b/],
|
|
15
|
+
["money", /\b(payment|pricing|billing|pagamento|preco|faturamento)\b/]
|
|
16
|
+
];
|
|
17
|
+
const medium = [
|
|
18
|
+
["data-or-contract", /\b(database|schema|api|contract|banco|contrato)\b/],
|
|
19
|
+
["runtime-kernel", /\b(runtime|kernel|transaction|journal|recovery|multi-file|transacion|recuperac|multi-arquivo)\b/],
|
|
20
|
+
["performance-or-cache", /\b(cache|performance|latency|index|desempenho|latencia|indice)\b/],
|
|
21
|
+
["dependency-or-upgrade", /\b(dependency|upgrade|package|version|dependencia|atualiz|versao)\b/],
|
|
22
|
+
["cross-cutting-change", /\b(refactor|rename|move|architecture|cli|refator|renome|mover|arquitetura)\b/]
|
|
23
|
+
];
|
|
24
|
+
const highSignals = high.filter(([, matcher]) => matcher.test(text)).map(([signal]) => `high-impact signal: ${signal}`);
|
|
25
|
+
if (highSignals.length) return { level: "high", reasons: highSignals.slice(0, 6) };
|
|
26
|
+
const mediumSignals = medium.filter(([, matcher]) => matcher.test(text)).map(([signal]) => `cross-cutting signal: ${signal}`);
|
|
27
|
+
if (mediumSignals.length) return { level: "medium", reasons: mediumSignals.slice(0, 6) };
|
|
16
28
|
return { level: "low", reasons: ["no high-impact signals detected; validate scope before execution"] };
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
function classifyRequest(request) {
|
|
20
|
-
const text = request
|
|
32
|
+
const text = normalized(request);
|
|
21
33
|
if (/\b(investigat|analy[sz]|study|discover|pesquis|analis|estud|descobr)/.test(text)) {
|
|
22
34
|
return { type: "discovery", taskType: "discovery", reason: "request is primarily knowledge-seeking" };
|
|
23
35
|
}
|
|
24
|
-
if (/\b(bug|fix|broken|regression|erro|falha|corrigir|
|
|
36
|
+
if (/\b(bug|fix|broken|regression|erro|falha|corrigir|consertar)\b/.test(text)) {
|
|
25
37
|
return { type: "fix", taskType: "fix", reason: "request describes corrective work" };
|
|
26
38
|
}
|
|
27
|
-
if (/\b(
|
|
39
|
+
if (/\b(initiative|epic|roadmap|program|iniciativa|programa)\b/.test(text)) {
|
|
28
40
|
return { type: "feature", taskType: "feature", reason: "request describes a long-lived product initiative" };
|
|
29
41
|
}
|
|
30
|
-
if (/\b(sprint|timebox|batch|release train|lote)\b/.test(text)) {
|
|
42
|
+
if (/\b(sprint|timebox|batch|release train|lote)\b/.test(text) && /\b(tasks?|tarefas?|entregas?)\b/.test(text)) {
|
|
31
43
|
return { type: "sprint", taskType: "task", reason: "request explicitly asks for a timebox or delivery batch" };
|
|
32
44
|
}
|
|
33
|
-
|
|
45
|
+
const documentation = /\b(document|docs|readme|typo|texto|documenta)/.test(text);
|
|
46
|
+
const implementation = /\b(implement\w*|build\w*|creat\w*|add\w*|runtime|kernel|schema|migrat\w*|migrac\w*|transaction\w*|code|cli|criar|adicionar|construir|codigo|transacion\w*)\b/.test(text);
|
|
47
|
+
if (documentation && !implementation) {
|
|
34
48
|
return { type: "task", taskType: "docs", reason: "request appears bounded to documentation" };
|
|
35
49
|
}
|
|
36
50
|
return { type: "task", taskType: "task", reason: "request is best represented as one atomic Task" };
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
function applyPolicy(context) {
|
|
40
|
-
|
|
41
|
-
if (context.layout === "v1") violations.push("Project must be explicitly migrated to v2 before approved execution.");
|
|
42
|
-
if (containsSecret(context.request)) violations.push("Request appears to contain a secret; remove/redact it before planning or persistence.");
|
|
43
|
-
if (context.warnings.some((warning) => warning.includes("Secret-like content"))) violations.push("Canonical project context contains secret-like content; remove it before planning.");
|
|
44
|
-
if (!context.guardrails.length) violations.push("No active canonical guardrail could be loaded.");
|
|
45
|
-
if (context.warnings.some((warning) => warning.includes("guardrails.md"))) violations.push("Canonical guardrails are missing.");
|
|
46
|
-
return {
|
|
47
|
-
status: violations.length ? "blocked" : "passed",
|
|
48
|
-
checked: context.guardrails.map((guardrail) => guardrail.id),
|
|
49
|
-
violations
|
|
50
|
-
};
|
|
54
|
+
return evaluatePolicy(context);
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
function stableJson(value) {
|