scrumrun 1.5.2 → 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.
Files changed (112) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CORE.md +231 -308
  3. package/DECISIONS.md +151 -11
  4. package/LICENSE +21 -0
  5. package/MIGRATION-1-to-2.md +120 -0
  6. package/README.md +147 -78
  7. package/SPEC.md +259 -251
  8. package/bin/claude-install.js +18 -132
  9. package/bin/scrumrun.js +1367 -174
  10. package/docs/COMMANDS.md +71 -0
  11. package/docs/ENTITY-MODEL.md +38 -0
  12. package/docs/RELEASE-SCORECARD.md +43 -0
  13. package/docs/RELEASE.md +51 -0
  14. package/docs/SCHEMA.md +89 -0
  15. package/docs/SEMANTIC-MEMORY.md +68 -0
  16. package/docs/TROUBLESHOOTING.md +78 -0
  17. package/lib/code-intel/adapter.js +20 -0
  18. package/lib/code-intel/javascript.js +199 -0
  19. package/lib/code-intel/learning.js +66 -0
  20. package/lib/code-intel/scanner.js +126 -0
  21. package/lib/commands/manifest.js +118 -0
  22. package/lib/commands/render.js +78 -0
  23. package/lib/memory/index.js +659 -0
  24. package/lib/memory/markdown.js +51 -0
  25. package/lib/memory/service.js +300 -0
  26. package/lib/runtime/budgets.js +23 -0
  27. package/lib/runtime/canonical-snapshot.js +110 -0
  28. package/lib/runtime/context.js +101 -0
  29. package/lib/runtime/orchestrator.js +303 -0
  30. package/lib/runtime/policy-engine.js +184 -0
  31. package/lib/runtime/request-engine.js +132 -0
  32. package/lib/runtime/run-ledger.js +324 -0
  33. package/lib/security/secrets.js +23 -0
  34. package/lib/v2/artifacts.js +363 -0
  35. package/lib/v2/conformance.js +214 -0
  36. package/lib/v2/migration.js +1221 -0
  37. package/lib/v2/project-store.js +44 -0
  38. package/lib/v2/run-ledger-migration.js +240 -0
  39. package/lib/v2/schema.js +148 -0
  40. package/lib/v2/transaction.js +254 -0
  41. package/package.json +16 -6
  42. package/scripts/generate-contract-docs.js +124 -0
  43. package/templates/project/.scrumrun/config.md +4 -7
  44. package/templates/project/.scrumrun/guardrails.md +31 -0
  45. package/templates/project/.scrumrun/map.md +5 -16
  46. package/templates/project/.scrumrun/memory/decisions/.gitkeep +1 -0
  47. package/templates/project/.scrumrun/memory/dossiers/.gitkeep +1 -0
  48. package/templates/project/.scrumrun/memory/insights/.gitkeep +1 -0
  49. package/templates/project/.scrumrun/memory/knowledge/.gitkeep +1 -0
  50. package/templates/project/.scrumrun/method.json +7 -0
  51. package/templates/project/.scrumrun/project.md +6 -12
  52. package/templates/project/.scrumrun/runs/.gitkeep +1 -0
  53. package/templates/project/.scrumrun/sprints/.gitkeep +1 -0
  54. package/templates/project/.scrumrun/state.md +14 -0
  55. package/templates/project/.scrumrun/tasks/.gitkeep +1 -0
  56. package/templates/project/AGENTS.md +16 -49
  57. package/templates/project-lean/AGENTS.md +18 -0
  58. package/templates/shared/skills/scrumrun/SKILL.md +211 -0
  59. package/templates/codex/prompts/sc-agent.md +0 -14
  60. package/templates/codex/prompts/sc-backlog.md +0 -13
  61. package/templates/codex/prompts/sc-challenge.md +0 -57
  62. package/templates/codex/prompts/sc-config.md +0 -18
  63. package/templates/codex/prompts/sc-context.md +0 -24
  64. package/templates/codex/prompts/sc-decisions.md +0 -8
  65. package/templates/codex/prompts/sc-feature.md +0 -16
  66. package/templates/codex/prompts/sc-fix.md +0 -21
  67. package/templates/codex/prompts/sc-goal.md +0 -14
  68. package/templates/codex/prompts/sc-golden.md +0 -14
  69. package/templates/codex/prompts/sc-help.md +0 -12
  70. package/templates/codex/prompts/sc-init.md +0 -14
  71. package/templates/codex/prompts/sc-intake.md +0 -22
  72. package/templates/codex/prompts/sc-know.md +0 -75
  73. package/templates/codex/prompts/sc-map.md +0 -13
  74. package/templates/codex/prompts/sc-review.md +0 -13
  75. package/templates/codex/prompts/sc-sprint.md +0 -28
  76. package/templates/codex/prompts/sc-study.md +0 -23
  77. package/templates/codex/prompts/sc-uninstall.md +0 -14
  78. package/templates/codex/prompts/sc-update.md +0 -8
  79. package/templates/codex/prompts/sc-vault.md +0 -27
  80. package/templates/codex/skills/scrumrun/SKILL.md +0 -412
  81. package/templates/opencode/commands/sc-agent.md +0 -14
  82. package/templates/opencode/commands/sc-backlog.md +0 -13
  83. package/templates/opencode/commands/sc-challenge.md +0 -57
  84. package/templates/opencode/commands/sc-config.md +0 -18
  85. package/templates/opencode/commands/sc-context.md +0 -24
  86. package/templates/opencode/commands/sc-decisions.md +0 -8
  87. package/templates/opencode/commands/sc-feature.md +0 -16
  88. package/templates/opencode/commands/sc-fix.md +0 -21
  89. package/templates/opencode/commands/sc-goal.md +0 -14
  90. package/templates/opencode/commands/sc-golden.md +0 -14
  91. package/templates/opencode/commands/sc-help.md +0 -12
  92. package/templates/opencode/commands/sc-init.md +0 -14
  93. package/templates/opencode/commands/sc-intake.md +0 -22
  94. package/templates/opencode/commands/sc-know.md +0 -75
  95. package/templates/opencode/commands/sc-map.md +0 -13
  96. package/templates/opencode/commands/sc-review.md +0 -13
  97. package/templates/opencode/commands/sc-sprint.md +0 -28
  98. package/templates/opencode/commands/sc-study.md +0 -23
  99. package/templates/opencode/commands/sc-uninstall.md +0 -14
  100. package/templates/opencode/commands/sc-update.md +0 -8
  101. package/templates/opencode/commands/sc-vault.md +0 -27
  102. package/templates/opencode/skills/scrumrun/SKILL.md +0 -412
  103. package/templates/project/.scrumrun/agents.md +0 -36
  104. package/templates/project/.scrumrun/backlog.md +0 -7
  105. package/templates/project/.scrumrun/context.md +0 -61
  106. package/templates/project/.scrumrun/goals/main/decisions.md +0 -9
  107. package/templates/project/.scrumrun/goals/main/history.md +0 -51
  108. package/templates/project/.scrumrun/goals/main/sprint.md +0 -54
  109. package/templates/project/.scrumrun/golden-rules.md +0 -9
  110. package/templates/project/.scrumrun/knowledge.md +0 -15
  111. package/templates/project/.scrumrun/runbook.md +0 -101
  112. package/templates/project/.scrumrun/token-policy.md +0 -43
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const {
6
+ ARTIFACT_TYPES,
7
+ ArtifactRepository,
8
+ METHOD_VERSION,
9
+ atomicWrite,
10
+ parseArtifact,
11
+ serializeArtifact,
12
+ sha256,
13
+ transitionArtifact,
14
+ validateArtifact,
15
+ withArtifactLock
16
+ } = require("../v2/artifacts");
17
+ const { buildContextPackage } = require("./context");
18
+ const { artifactSnapshot, canonicalFingerprint, canonicalWatchSnapshot } = require("./canonical-snapshot");
19
+ const { decodeApproval } = require("./request-engine");
20
+ const { extractLearningCandidates } = require("../code-intel/learning");
21
+ const { appendRunEvent, createRunBody, instant } = require("./run-ledger");
22
+ const { recoverPendingTransactions, runKernelTransaction } = require("../v2/transaction");
23
+
24
+ const TERMINAL = new Set(["completed", "failed", "cancelled", "resolved", "rejected", "deprecated", "invalidated", "archived", "passed"]);
25
+
26
+ function date() {
27
+ return new Date().toISOString().slice(0, 10);
28
+ }
29
+
30
+ function nextId(repository, kind) {
31
+ const type = ARTIFACT_TYPES[kind];
32
+ const max = repository.list(kind).reduce((highest, artifact) => {
33
+ const match = artifact.record && artifact.record.id.match(/-(\d+)$/);
34
+ return match ? Math.max(highest, Number(match[1])) : highest;
35
+ }, 0);
36
+ return `${type.prefix}-${String(max + 1).padStart(3, "0")}`;
37
+ }
38
+
39
+ function titleFor(request) {
40
+ const firstLine = request.split(/\r?\n/)[0].trim();
41
+ return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
42
+ }
43
+
44
+ function stateFingerprint(repository) {
45
+ return canonicalFingerprint(repository.scrumDir, artifactSnapshot(repository.scrumDir).hashes);
46
+ }
47
+
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}`));
53
+ const work = linesFor(["feature", "task", "sprint", "run", "review"]);
54
+ const memory = linesFor(["decision", "knowledge", "insight", "dossier"]);
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;
63
+ }
64
+
65
+ function refreshState(scrumDir) {
66
+ const repository = new ArtifactRepository(scrumDir);
67
+ const projection = stateProjection(repository);
68
+ atomicWrite(path.join(scrumDir, "state.md"), projection.content);
69
+ return projection;
70
+ }
71
+
72
+ function stateIsStale(scrumDir) {
73
+ const content = fs.existsSync(path.join(scrumDir, "state.md")) ? fs.readFileSync(path.join(scrumDir, "state.md"), "utf8") : "";
74
+ const stored = (content.match(/^Source fingerprint:\s*([a-f0-9]{64})$/m) || [])[1];
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;
78
+ return stored !== stateFingerprint(new ArtifactRepository(scrumDir));
79
+ }
80
+
81
+ function approvalExisting(repository, approvalId) {
82
+ const task = repository.list("task").find((artifact) => artifact.record && artifact.record.approval_id === approvalId);
83
+ if (!task) return null;
84
+ const run = repository.list("run").find((artifact) => artifact.record && artifact.record.approval_id === approvalId);
85
+ return { task: task.record, run: run ? run.record : null };
86
+ }
87
+
88
+ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, interruptPoint = null } = {}) {
89
+ const payload = decodeApproval(token);
90
+ if (payload.policy.status !== "passed" || payload.policy.violations.length) throw new Error("Blocked intake cannot be approved.");
91
+ const issued = Date.parse(payload.issuedAt);
92
+ if (!Number.isFinite(issued) || Date.now() - issued > 24 * 60 * 60 * 1000) throw new Error("Approval token expired; run intake again.");
93
+ const scrumDir = path.join(projectRoot, ".scrumrun");
94
+ recoverPendingTransactions(scrumDir);
95
+ const repository = new ArtifactRepository(scrumDir);
96
+ const approvalId = sha256(token).slice(0, 20);
97
+ const existing = approvalExisting(repository, approvalId);
98
+ if (existing) return { status: "already-approved", ...existing };
99
+ const currentContext = buildContextPackage(projectRoot, payload.request);
100
+ if (currentContext.fingerprint !== payload.fingerprint) {
101
+ throw new Error("Project context changed after planning; run intake again before approval.");
102
+ }
103
+
104
+ const created = date();
105
+ const taskId = nextId(repository, "task");
106
+ const runId = nextId(repository, "run");
107
+ const task = {
108
+ id: taskId,
109
+ kind: "task",
110
+ type: payload.classification.taskType,
111
+ status: "running",
112
+ created,
113
+ updated: created,
114
+ method: METHOD_VERSION,
115
+ feature: null,
116
+ sprint: null,
117
+ approval_id: approvalId,
118
+ context_fingerprint: payload.fingerprint,
119
+ risk: payload.risk.level
120
+ };
121
+ const run = {
122
+ id: runId,
123
+ kind: "run",
124
+ status: "executing",
125
+ created,
126
+ updated: created,
127
+ method: METHOD_VERSION,
128
+ task: taskId,
129
+ sprint: null,
130
+ attempt: 1,
131
+ ledger: 1,
132
+ approval_id: approvalId
133
+ };
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}`;
135
+ const runBody = createRunBody(run, { approvalId }).body;
136
+ try {
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
+ });
144
+ } catch (error) {
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}`);
147
+ throw error;
148
+ }
149
+ refreshState(scrumDir);
150
+ return { status: "approved", task, run };
151
+ }
152
+
153
+ function approveRequest(projectRoot, token, options = {}) {
154
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), "approval", () => approveRequestUnlocked(projectRoot, token, options));
155
+ }
156
+
157
+ function transitionedArtifactContent(content, kind, nextStatus, updated) {
158
+ const parsed = parseArtifact(content);
159
+ const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
160
+ if (errors.length) throw new Error(`Invalid ${kind} artifact: ${errors.join("; ")}`);
161
+ const nextRecord = transitionArtifact(parsed.record, nextStatus, updated);
162
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/);
163
+ let frontmatter = frontmatterMatch[1]
164
+ .replace(/^status:\s*.*$/m, `status: ${nextStatus}`)
165
+ .replace(/^updated:\s*.*$/m, `updated: ${nextRecord.updated}`);
166
+ const next = `---\n${frontmatter}\n---${frontmatterMatch[2]}`;
167
+ const validated = parseArtifact(next);
168
+ const nextErrors = [...validated.errors, ...validateArtifact(validated.record, kind)];
169
+ if (nextErrors.length) throw new Error(`Transition validation failed: ${nextErrors.join("; ")}`);
170
+ return { content: next, record: validated.record };
171
+ }
172
+
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
+ };
186
+ }
187
+
188
+ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, evidence = [], actor = "agent", occurredAt = null, failurePoint = null, interruptPoint = null } = {}) {
189
+ const scrumDir = path.join(projectRoot, ".scrumrun");
190
+ const recovered = recoverPendingTransactions(scrumDir);
191
+ const repository = new ArtifactRepository(scrumDir);
192
+ const runArtifact = repository.read("run", runId);
193
+ if (!runArtifact) throw new Error(`Run not found: ${runId}`);
194
+ const taskArtifact = repository.read("task", runArtifact.record.task);
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
+ }
200
+ const taskStatuses = {
201
+ validating: "validating",
202
+ learning: "learning",
203
+ completed: "completed",
204
+ failed: "failed",
205
+ blocked: "blocked",
206
+ executing: "running"
207
+ };
208
+ const nextTaskStatus = taskStatuses[nextStatus];
209
+ if (!nextTaskStatus) throw new Error(`Run status cannot synchronize a Task: ${nextStatus}`);
210
+ const runPrevious = fs.readFileSync(runArtifact.file, "utf8");
211
+ const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
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
+ }
223
+ refreshState(scrumDir);
224
+ let learning = null;
225
+ if (nextStatus === "learning") {
226
+ try {
227
+ learning = extractLearningCandidates(projectRoot, runId);
228
+ } catch (error) {
229
+ learning = { created: [], warnings: [`Learning extraction did not block the Run: ${error.message}`] };
230
+ }
231
+ refreshState(scrumDir);
232
+ }
233
+ return { run: runNext.record, task: taskNext.record, learning, recovered };
234
+ }
235
+
236
+ function transitionRun(projectRoot, runId, nextStatus, options = {}) {
237
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), `run-${String(runId).toLowerCase()}`, () => transitionRunUnlocked(projectRoot, runId, nextStatus, options));
238
+ }
239
+
240
+ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", failurePoint = null, interruptPoint = null } = {}) {
241
+ const scrumDir = path.join(projectRoot, ".scrumrun");
242
+ const recovered = recoverPendingTransactions(scrumDir);
243
+ const repository = new ArtifactRepository(scrumDir);
244
+ const taskArtifact = repository.read("task", taskId);
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
+ }
255
+ if (!["failed", "blocked", "partial"].includes(taskArtifact.record.status)) {
256
+ throw new Error(`Task ${taskId} is ${taskArtifact.record.status}; retry requires failed, blocked, or partial.`);
257
+ }
258
+ const attempts = repository.list("run")
259
+ .filter((artifact) => artifact.record && artifact.record.task === taskId)
260
+ .map((artifact) => Number(artifact.record.attempt) || 0);
261
+ const run = {
262
+ id: nextId(repository, "run"),
263
+ kind: "run",
264
+ status: "executing",
265
+ created: date(),
266
+ updated: date(),
267
+ method: METHOD_VERSION,
268
+ task: taskId,
269
+ sprint: taskArtifact.record.sprint || null,
270
+ attempt: attempts.length ? Math.max(...attempts) + 1 : 1,
271
+ ledger: 1,
272
+ approval_id: taskArtifact.record.approval_id || null
273
+ };
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);
281
+ const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
282
+ const taskNext = transitionedArtifactContent(taskPrevious, "task", "running", date());
283
+ try {
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
+ });
291
+ } catch (error) {
292
+ if (failurePoint === "after-run") throw new Error(`Injected retry failure after Run creation: ${error.message}`);
293
+ throw error;
294
+ }
295
+ refreshState(scrumDir);
296
+ return { run, task: taskNext.record, content: runContent, recovered };
297
+ }
298
+
299
+ function retryTask(projectRoot, taskId, options = {}) {
300
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), `task-${String(taskId).toLowerCase()}`, () => retryTaskUnlocked(projectRoot, taskId, options));
301
+ }
302
+
303
+ module.exports = { approveRequest, nextId, refreshState, renderState, retryTask, stateFingerprint, stateIsStale, transitionRun };
@@ -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
+ };
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+
3
+ const { sha256 } = require("../v2/artifacts");
4
+ const { buildContextPackage } = require("./context");
5
+ const { containsSecret } = require("../security/secrets");
6
+ const { evaluatePolicy, normalized } = require("./policy-engine");
7
+
8
+ function assessRisk(request) {
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) };
28
+ return { level: "low", reasons: ["no high-impact signals detected; validate scope before execution"] };
29
+ }
30
+
31
+ function classifyRequest(request) {
32
+ const text = normalized(request);
33
+ if (/\b(investigat|analy[sz]|study|discover|pesquis|analis|estud|descobr)/.test(text)) {
34
+ return { type: "discovery", taskType: "discovery", reason: "request is primarily knowledge-seeking" };
35
+ }
36
+ if (/\b(bug|fix|broken|regression|erro|falha|corrigir|consertar)\b/.test(text)) {
37
+ return { type: "fix", taskType: "fix", reason: "request describes corrective work" };
38
+ }
39
+ if (/\b(initiative|epic|roadmap|program|iniciativa|programa)\b/.test(text)) {
40
+ return { type: "feature", taskType: "feature", reason: "request describes a long-lived product initiative" };
41
+ }
42
+ if (/\b(sprint|timebox|batch|release train|lote)\b/.test(text) && /\b(tasks?|tarefas?|entregas?)\b/.test(text)) {
43
+ return { type: "sprint", taskType: "task", reason: "request explicitly asks for a timebox or delivery batch" };
44
+ }
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) {
48
+ return { type: "task", taskType: "docs", reason: "request appears bounded to documentation" };
49
+ }
50
+ return { type: "task", taskType: "task", reason: "request is best represented as one atomic Task" };
51
+ }
52
+
53
+ function applyPolicy(context) {
54
+ return evaluatePolicy(context);
55
+ }
56
+
57
+ function stableJson(value) {
58
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
59
+ if (value && typeof value === "object") {
60
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
61
+ }
62
+ return JSON.stringify(value);
63
+ }
64
+
65
+ function encodeApproval(plan) {
66
+ const payload = {
67
+ schema: 1,
68
+ method: "2.0.0",
69
+ request: plan.request,
70
+ fingerprint: plan.context.fingerprint,
71
+ classification: plan.classification,
72
+ risk: plan.risk,
73
+ policy: plan.policy,
74
+ issuedAt: plan.issuedAt
75
+ };
76
+ const encoded = Buffer.from(stableJson(payload)).toString("base64url");
77
+ const checksum = sha256(`scrumrun-v2-approval\0${encoded}`);
78
+ return `${encoded}.${checksum}`;
79
+ }
80
+
81
+ function decodeApproval(token) {
82
+ const [encoded, checksum, extra] = String(token || "").split(".");
83
+ if (!encoded || !checksum || extra || sha256(`scrumrun-v2-approval\0${encoded}`) !== checksum) {
84
+ throw new Error("Approval token is malformed or was modified.");
85
+ }
86
+ let payload;
87
+ try {
88
+ payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
89
+ } catch {
90
+ throw new Error("Approval token payload is invalid.");
91
+ }
92
+ if (payload.schema !== 1 || payload.method !== "2.0.0") throw new Error("Approval token version is unsupported.");
93
+ return payload;
94
+ }
95
+
96
+ function planRequest(projectRoot, request) {
97
+ const normalized = String(request || "").trim();
98
+ if (!normalized) throw new Error("A non-empty request is required for intake.");
99
+ if (normalized.length > 10000) throw new Error("Request exceeds the 10,000 character intake limit.");
100
+ const context = buildContextPackage(projectRoot, normalized);
101
+ const policy = applyPolicy(context);
102
+ const risk = assessRisk(normalized);
103
+ const classification = classifyRequest(normalized);
104
+ const plan = {
105
+ state: policy.status === "passed" ? "awaiting_approval" : "blocked",
106
+ pipeline: ["received", "contextualizing", "policy", "risk", "classification", "planning", policy.status === "passed" ? "awaiting_approval" : "blocked"],
107
+ request: normalized,
108
+ context,
109
+ policy,
110
+ risk,
111
+ classification,
112
+ proposal: {
113
+ create: ["Task", "Run"],
114
+ sprint: classification.type === "sprint" ? "propose only after confirming real batch/timebox membership" : null,
115
+ validation: risk.level === "high" ? "targeted tests plus configured reviewers" : "targeted tests"
116
+ },
117
+ issuedAt: new Date().toISOString()
118
+ };
119
+ plan.approvalToken = policy.status === "passed" ? encodeApproval(plan) : null;
120
+ return plan;
121
+ }
122
+
123
+ module.exports = {
124
+ applyPolicy,
125
+ assessRisk,
126
+ classifyRequest,
127
+ containsSecret,
128
+ decodeApproval,
129
+ encodeApproval,
130
+ planRequest,
131
+ stableJson
132
+ };