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.
Files changed (44) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CORE.md +17 -3
  3. package/DECISIONS.md +56 -0
  4. package/MIGRATION-1-to-2.md +11 -0
  5. package/README.md +23 -5
  6. package/SPEC.md +30 -10
  7. package/bin/scrumrun.js +175 -11
  8. package/docs/COMMANDS.md +10 -4
  9. package/docs/ENTITY-MODEL.md +1 -1
  10. package/docs/RELEASE-SCORECARD.md +43 -0
  11. package/docs/RELEASE.md +19 -12
  12. package/docs/SCHEMA.md +11 -0
  13. package/docs/SEMANTIC-MEMORY.md +1 -1
  14. package/docs/TROUBLESHOOTING.md +13 -1
  15. package/lib/commands/manifest.js +15 -3
  16. package/lib/commands/render.js +4 -0
  17. package/lib/memory/index.js +201 -41
  18. package/lib/memory/service.js +3 -0
  19. package/lib/runtime/budgets.js +4 -0
  20. package/lib/runtime/canonical-snapshot.js +110 -0
  21. package/lib/runtime/context.js +5 -45
  22. package/lib/runtime/mutation-gateway.js +434 -0
  23. package/lib/runtime/orchestrator.js +130 -65
  24. package/lib/runtime/policy-engine.js +267 -0
  25. package/lib/runtime/request-engine.js +32 -24
  26. package/lib/runtime/review-service.js +92 -0
  27. package/lib/runtime/run-ledger.js +546 -0
  28. package/lib/runtime/workspace-state.js +146 -0
  29. package/lib/security/secrets.js +15 -1
  30. package/lib/v2/artifacts.js +24 -1
  31. package/lib/v2/conformance.js +78 -12
  32. package/lib/v2/migration.js +74 -10
  33. package/lib/v2/run-ledger-migration.js +268 -0
  34. package/lib/v2/schema.js +28 -1
  35. package/lib/v2/transaction.js +254 -0
  36. package/package.json +1 -1
  37. package/scripts/generate-contract-docs.js +11 -0
  38. package/templates/project/.scrumrun/guardrails.md +8 -0
  39. package/templates/project/.scrumrun/map.md +4 -3
  40. package/templates/project/.scrumrun/method.json +7 -1
  41. package/templates/project/.scrumrun/state.md +7 -14
  42. package/templates/project/AGENTS.md +2 -1
  43. package/templates/project-lean/AGENTS.md +3 -1
  44. 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
- 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"));
47
+ return canonicalFingerprint(repository.scrumDir, artifactSnapshot(repository.scrumDir).hashes);
53
48
  }
54
49
 
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
- }));
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
- 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`;
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
- atomicWrite(path.join(scrumDir, "state.md"), renderState(repository));
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 = `# 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;
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
- 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.");
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 (runWritten) fs.rmSync(repository.pathFor(run), { force: true });
141
- if (taskWritten) fs.rmSync(repository.pathFor(task), { force: true });
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 transitionedContent(content, kind, nextStatus, note) {
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, date());
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 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}`;
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 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
- }
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 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);
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 runContent = serializeArtifact(run, `# Retry for ${taskId}\n\n## Transition Log\n\n- ${date()}: created → executing — ${note}`);
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 = transitionedContent(taskPrevious, "task", "running", `${run.id} created: ${note}`);
252
- let runWritten = false;
314
+ const taskNext = transitionedArtifactContent(taskPrevious, "task", "running", date());
253
315
  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);
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 (runWritten) fs.rmSync(repository.pathFor(run), { force: true });
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
+ };