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.
@@ -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
- const hashes = [];
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 renderState(repository) {
56
- const linesFor = (kinds) => kinds.flatMap((kind) => repository.list(kind)
57
- .filter((artifact) => artifact.record && !TERMINAL.has(artifact.record.status))
58
- .map((artifact) => {
59
- const title = ((artifact.body || "").match(/^# ([^\r\n]+)/m) || [])[1] || artifact.record.id;
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
- return `# ScrumRun State\n\nGenerated: ${date()}\nSource fingerprint: ${stateFingerprint(repository)}\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`;
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
- atomicWrite(path.join(scrumDir, "state.md"), renderState(repository));
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 = `# Run for ${taskId}\n\n## Transition Log\n\n- ${created}: created → executing — explicit approval accepted.\n\n## Validation\n\n- Pending.\n\n## Learning\n\n- Pending.`;
132
- let taskWritten = false;
133
- let runWritten = false;
135
+ const runBody = createRunBody(run, { approvalId }).body;
134
136
  try {
135
- taskWritten = repository.write(task, taskBody).status === "written";
136
- if (failurePoint === "after-task") throw new Error("Injected approval failure after Task creation.");
137
- runWritten = repository.write(run, runBody).status === "written";
138
- if (failurePoint === "after-run") throw new Error("Injected approval failure after Run creation.");
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 (runWritten) fs.rmSync(repository.pathFor(run), { force: true });
141
- if (taskWritten) fs.rmSync(repository.pathFor(task), { force: true });
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 transitionedContent(content, kind, nextStatus, note) {
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, date());
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 transition = `\n\n## Transition ${date()} · ${parsed.record.status}-to-${nextStatus}\n\n- ${note || "State transition requested through ScrumRun."}\n`;
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 writePairWithRollback(changes, failurePoint = null) {
170
- const written = [];
171
- try {
172
- for (let index = 0; index < changes.length; index++) {
173
- const change = changes[index];
174
- atomicWrite(change.file, change.next);
175
- written.push(change);
176
- if (failurePoint === `after-${index + 1}`) throw new Error(`Injected transition failure after write ${index + 1}.`);
177
- }
178
- } catch (error) {
179
- for (const change of written.reverse()) atomicWrite(change.file, change.previous);
180
- throw error;
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 = transitionedContent(runPrevious, "run", nextStatus, note);
204
- const taskNext = transitionedContent(taskPrevious, "task", nextTaskStatus, `Synchronized from ${runId}: ${note || nextStatus}.`);
205
- writePairWithRollback([
206
- { file: runArtifact.file, previous: runPrevious, next: runNext.content },
207
- { file: taskArtifact.file, previous: taskPrevious, next: taskNext.content }
208
- ], failurePoint);
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 runContent = serializeArtifact(run, `# Retry for ${taskId}\n\n## Transition Log\n\n- ${date()}: created → executing — ${note}`);
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 = transitionedContent(taskPrevious, "task", "running", `${run.id} created: ${note}`);
252
- let runWritten = false;
282
+ const taskNext = transitionedArtifactContent(taskPrevious, "task", "running", date());
253
283
  try {
254
- runWritten = repository.write(run, `# Retry for ${taskId}\n\n## Transition Log\n\n- ${date()}: created → executing — ${note}`).status === "written";
255
- if (failurePoint === "after-run") throw new Error("Injected retry failure after Run creation.");
256
- atomicWrite(taskArtifact.file, taskNext.content);
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 (runWritten) fs.rmSync(repository.pathFor(run), { force: true });
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.toLowerCase();
9
- const high = ["delete", "drop ", "migration", "migrate", "payment", "pricing", "auth", "permission", "security", "secret", "production", "publish", "release", "deploy"];
10
- const medium = ["database", "schema", "api", "cache", "performance", "refactor", "dependency", "upgrade", "rename", "move"];
11
- const reasons = [];
12
- for (const term of high) if (text.includes(term)) reasons.push(`high-impact term: ${term.trim()}`);
13
- if (reasons.length) return { level: "high", reasons: [...new Set(reasons)].slice(0, 6) };
14
- for (const term of medium) if (text.includes(term)) reasons.push(`cross-cutting term: ${term}`);
15
- if (reasons.length) return { level: "medium", reasons: [...new Set(reasons)].slice(0, 6) };
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.toLowerCase();
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|consert)/.test(text)) {
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(feature|initiative|epic|iniciativa|funcionalidade)\b/.test(text)) {
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
- if (/\b(document|docs|readme|typo|texto|documenta)/.test(text)) {
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
- const violations = [];
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) {