scrumrun 1.5.2 → 2.0.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 (106) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CORE.md +224 -311
  3. package/DECISIONS.md +95 -11
  4. package/LICENSE +21 -0
  5. package/MIGRATION-1-to-2.md +109 -0
  6. package/README.md +137 -78
  7. package/SPEC.md +248 -257
  8. package/bin/claude-install.js +18 -132
  9. package/bin/scrumrun.js +1259 -174
  10. package/docs/COMMANDS.md +69 -0
  11. package/docs/ENTITY-MODEL.md +38 -0
  12. package/docs/RELEASE.md +44 -0
  13. package/docs/SCHEMA.md +80 -0
  14. package/docs/SEMANTIC-MEMORY.md +68 -0
  15. package/docs/TROUBLESHOOTING.md +66 -0
  16. package/lib/code-intel/adapter.js +20 -0
  17. package/lib/code-intel/javascript.js +199 -0
  18. package/lib/code-intel/learning.js +66 -0
  19. package/lib/code-intel/scanner.js +126 -0
  20. package/lib/commands/manifest.js +109 -0
  21. package/lib/commands/render.js +75 -0
  22. package/lib/memory/index.js +600 -0
  23. package/lib/memory/markdown.js +51 -0
  24. package/lib/memory/service.js +300 -0
  25. package/lib/runtime/budgets.js +19 -0
  26. package/lib/runtime/context.js +141 -0
  27. package/lib/runtime/orchestrator.js +270 -0
  28. package/lib/runtime/request-engine.js +128 -0
  29. package/lib/security/secrets.js +23 -0
  30. package/lib/v2/artifacts.js +343 -0
  31. package/lib/v2/conformance.js +167 -0
  32. package/lib/v2/migration.js +1161 -0
  33. package/lib/v2/project-store.js +44 -0
  34. package/lib/v2/schema.js +128 -0
  35. package/package.json +16 -6
  36. package/scripts/generate-contract-docs.js +113 -0
  37. package/templates/project/.scrumrun/config.md +4 -7
  38. package/templates/project/.scrumrun/guardrails.md +23 -0
  39. package/templates/project/.scrumrun/map.md +4 -16
  40. package/templates/project/.scrumrun/memory/decisions/.gitkeep +1 -0
  41. package/templates/project/.scrumrun/memory/dossiers/.gitkeep +1 -0
  42. package/templates/project/.scrumrun/memory/insights/.gitkeep +1 -0
  43. package/templates/project/.scrumrun/memory/knowledge/.gitkeep +1 -0
  44. package/templates/project/.scrumrun/method.json +4 -0
  45. package/templates/project/.scrumrun/project.md +6 -12
  46. package/templates/project/.scrumrun/runs/.gitkeep +1 -0
  47. package/templates/project/.scrumrun/sprints/.gitkeep +1 -0
  48. package/templates/project/.scrumrun/state.md +21 -0
  49. package/templates/project/.scrumrun/tasks/.gitkeep +1 -0
  50. package/templates/project/AGENTS.md +16 -49
  51. package/templates/project-lean/AGENTS.md +18 -0
  52. package/templates/shared/skills/scrumrun/SKILL.md +201 -0
  53. package/templates/codex/prompts/sc-agent.md +0 -14
  54. package/templates/codex/prompts/sc-backlog.md +0 -13
  55. package/templates/codex/prompts/sc-challenge.md +0 -57
  56. package/templates/codex/prompts/sc-config.md +0 -18
  57. package/templates/codex/prompts/sc-context.md +0 -24
  58. package/templates/codex/prompts/sc-decisions.md +0 -8
  59. package/templates/codex/prompts/sc-feature.md +0 -16
  60. package/templates/codex/prompts/sc-fix.md +0 -21
  61. package/templates/codex/prompts/sc-goal.md +0 -14
  62. package/templates/codex/prompts/sc-golden.md +0 -14
  63. package/templates/codex/prompts/sc-help.md +0 -12
  64. package/templates/codex/prompts/sc-init.md +0 -14
  65. package/templates/codex/prompts/sc-intake.md +0 -22
  66. package/templates/codex/prompts/sc-know.md +0 -75
  67. package/templates/codex/prompts/sc-map.md +0 -13
  68. package/templates/codex/prompts/sc-review.md +0 -13
  69. package/templates/codex/prompts/sc-sprint.md +0 -28
  70. package/templates/codex/prompts/sc-study.md +0 -23
  71. package/templates/codex/prompts/sc-uninstall.md +0 -14
  72. package/templates/codex/prompts/sc-update.md +0 -8
  73. package/templates/codex/prompts/sc-vault.md +0 -27
  74. package/templates/codex/skills/scrumrun/SKILL.md +0 -412
  75. package/templates/opencode/commands/sc-agent.md +0 -14
  76. package/templates/opencode/commands/sc-backlog.md +0 -13
  77. package/templates/opencode/commands/sc-challenge.md +0 -57
  78. package/templates/opencode/commands/sc-config.md +0 -18
  79. package/templates/opencode/commands/sc-context.md +0 -24
  80. package/templates/opencode/commands/sc-decisions.md +0 -8
  81. package/templates/opencode/commands/sc-feature.md +0 -16
  82. package/templates/opencode/commands/sc-fix.md +0 -21
  83. package/templates/opencode/commands/sc-goal.md +0 -14
  84. package/templates/opencode/commands/sc-golden.md +0 -14
  85. package/templates/opencode/commands/sc-help.md +0 -12
  86. package/templates/opencode/commands/sc-init.md +0 -14
  87. package/templates/opencode/commands/sc-intake.md +0 -22
  88. package/templates/opencode/commands/sc-know.md +0 -75
  89. package/templates/opencode/commands/sc-map.md +0 -13
  90. package/templates/opencode/commands/sc-review.md +0 -13
  91. package/templates/opencode/commands/sc-sprint.md +0 -28
  92. package/templates/opencode/commands/sc-study.md +0 -23
  93. package/templates/opencode/commands/sc-uninstall.md +0 -14
  94. package/templates/opencode/commands/sc-update.md +0 -8
  95. package/templates/opencode/commands/sc-vault.md +0 -27
  96. package/templates/opencode/skills/scrumrun/SKILL.md +0 -412
  97. package/templates/project/.scrumrun/agents.md +0 -36
  98. package/templates/project/.scrumrun/backlog.md +0 -7
  99. package/templates/project/.scrumrun/context.md +0 -61
  100. package/templates/project/.scrumrun/goals/main/decisions.md +0 -9
  101. package/templates/project/.scrumrun/goals/main/history.md +0 -51
  102. package/templates/project/.scrumrun/goals/main/sprint.md +0 -54
  103. package/templates/project/.scrumrun/golden-rules.md +0 -9
  104. package/templates/project/.scrumrun/knowledge.md +0 -15
  105. package/templates/project/.scrumrun/runbook.md +0 -101
  106. package/templates/project/.scrumrun/token-policy.md +0 -43
@@ -0,0 +1,270 @@
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 { decodeApproval } = require("./request-engine");
19
+ const { extractLearningCandidates } = require("../code-intel/learning");
20
+
21
+ const TERMINAL = new Set(["completed", "failed", "cancelled", "resolved", "rejected", "deprecated", "invalidated", "archived", "passed"]);
22
+
23
+ function date() {
24
+ return new Date().toISOString().slice(0, 10);
25
+ }
26
+
27
+ function nextId(repository, kind) {
28
+ const type = ARTIFACT_TYPES[kind];
29
+ const max = repository.list(kind).reduce((highest, artifact) => {
30
+ const match = artifact.record && artifact.record.id.match(/-(\d+)$/);
31
+ return match ? Math.max(highest, Number(match[1])) : highest;
32
+ }, 0);
33
+ return `${type.prefix}-${String(max + 1).padStart(3, "0")}`;
34
+ }
35
+
36
+ function titleFor(request) {
37
+ const firstLine = request.split(/\r?\n/)[0].trim();
38
+ return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
39
+ }
40
+
41
+ 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"));
53
+ }
54
+
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
+ }));
62
+ const work = linesFor(["feature", "task", "sprint", "run", "review"]);
63
+ 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`;
65
+ }
66
+
67
+ function refreshState(scrumDir) {
68
+ const repository = new ArtifactRepository(scrumDir);
69
+ atomicWrite(path.join(scrumDir, "state.md"), renderState(repository));
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
+ return stored !== stateFingerprint(new ArtifactRepository(scrumDir));
77
+ }
78
+
79
+ function approvalExisting(repository, approvalId) {
80
+ const task = repository.list("task").find((artifact) => artifact.record && artifact.record.approval_id === approvalId);
81
+ if (!task) return null;
82
+ const run = repository.list("run").find((artifact) => artifact.record && artifact.record.approval_id === approvalId);
83
+ return { task: task.record, run: run ? run.record : null };
84
+ }
85
+
86
+ function approveRequestUnlocked(projectRoot, token, { failurePoint = null } = {}) {
87
+ const payload = decodeApproval(token);
88
+ if (payload.policy.status !== "passed" || payload.policy.violations.length) throw new Error("Blocked intake cannot be approved.");
89
+ const issued = Date.parse(payload.issuedAt);
90
+ if (!Number.isFinite(issued) || Date.now() - issued > 24 * 60 * 60 * 1000) throw new Error("Approval token expired; run intake again.");
91
+ const scrumDir = path.join(projectRoot, ".scrumrun");
92
+ const repository = new ArtifactRepository(scrumDir);
93
+ const approvalId = sha256(token).slice(0, 20);
94
+ const existing = approvalExisting(repository, approvalId);
95
+ if (existing) return { status: "already-approved", ...existing };
96
+ const currentContext = buildContextPackage(projectRoot, payload.request);
97
+ if (currentContext.fingerprint !== payload.fingerprint) {
98
+ throw new Error("Project context changed after planning; run intake again before approval.");
99
+ }
100
+
101
+ const created = date();
102
+ const taskId = nextId(repository, "task");
103
+ const runId = nextId(repository, "run");
104
+ const task = {
105
+ id: taskId,
106
+ kind: "task",
107
+ type: payload.classification.taskType,
108
+ status: "running",
109
+ created,
110
+ updated: created,
111
+ method: METHOD_VERSION,
112
+ feature: null,
113
+ sprint: null,
114
+ approval_id: approvalId,
115
+ context_fingerprint: payload.fingerprint,
116
+ risk: payload.risk.level
117
+ };
118
+ const run = {
119
+ id: runId,
120
+ kind: "run",
121
+ status: "executing",
122
+ created,
123
+ updated: created,
124
+ method: METHOD_VERSION,
125
+ task: taskId,
126
+ sprint: null,
127
+ attempt: 1,
128
+ approval_id: approvalId
129
+ };
130
+ 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;
134
+ 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.");
139
+ } catch (error) {
140
+ if (runWritten) fs.rmSync(repository.pathFor(run), { force: true });
141
+ if (taskWritten) fs.rmSync(repository.pathFor(task), { force: true });
142
+ throw error;
143
+ }
144
+ refreshState(scrumDir);
145
+ return { status: "approved", task, run };
146
+ }
147
+
148
+ function approveRequest(projectRoot, token, options = {}) {
149
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), "approval", () => approveRequestUnlocked(projectRoot, token, options));
150
+ }
151
+
152
+ function transitionedContent(content, kind, nextStatus, note) {
153
+ const parsed = parseArtifact(content);
154
+ const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
155
+ if (errors.length) throw new Error(`Invalid ${kind} artifact: ${errors.join("; ")}`);
156
+ const nextRecord = transitionArtifact(parsed.record, nextStatus, date());
157
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/);
158
+ let frontmatter = frontmatterMatch[1]
159
+ .replace(/^status:\s*.*$/m, `status: ${nextStatus}`)
160
+ .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}`;
163
+ const validated = parseArtifact(next);
164
+ const nextErrors = [...validated.errors, ...validateArtifact(validated.record, kind)];
165
+ if (nextErrors.length) throw new Error(`Transition validation failed: ${nextErrors.join("; ")}`);
166
+ return { content: next, record: validated.record };
167
+ }
168
+
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
+ }
182
+ }
183
+
184
+ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, failurePoint = null } = {}) {
185
+ const scrumDir = path.join(projectRoot, ".scrumrun");
186
+ const repository = new ArtifactRepository(scrumDir);
187
+ const runArtifact = repository.read("run", runId);
188
+ if (!runArtifact) throw new Error(`Run not found: ${runId}`);
189
+ const taskArtifact = repository.read("task", runArtifact.record.task);
190
+ if (!taskArtifact) throw new Error(`Task not found for ${runId}: ${runArtifact.record.task}`);
191
+ const taskStatuses = {
192
+ validating: "validating",
193
+ learning: "learning",
194
+ completed: "completed",
195
+ failed: "failed",
196
+ blocked: "blocked",
197
+ executing: "running"
198
+ };
199
+ const nextTaskStatus = taskStatuses[nextStatus];
200
+ if (!nextTaskStatus) throw new Error(`Run status cannot synchronize a Task: ${nextStatus}`);
201
+ const runPrevious = fs.readFileSync(runArtifact.file, "utf8");
202
+ 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);
209
+ refreshState(scrumDir);
210
+ let learning = null;
211
+ if (nextStatus === "learning") {
212
+ try {
213
+ learning = extractLearningCandidates(projectRoot, runId);
214
+ } catch (error) {
215
+ learning = { created: [], warnings: [`Learning extraction did not block the Run: ${error.message}`] };
216
+ }
217
+ refreshState(scrumDir);
218
+ }
219
+ return { run: runNext.record, task: taskNext.record, learning };
220
+ }
221
+
222
+ function transitionRun(projectRoot, runId, nextStatus, options = {}) {
223
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), `run-${String(runId).toLowerCase()}`, () => transitionRunUnlocked(projectRoot, runId, nextStatus, options));
224
+ }
225
+
226
+ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", failurePoint = null } = {}) {
227
+ const scrumDir = path.join(projectRoot, ".scrumrun");
228
+ const repository = new ArtifactRepository(scrumDir);
229
+ const taskArtifact = repository.read("task", taskId);
230
+ if (!taskArtifact) throw new Error(`Task not found: ${taskId}`);
231
+ if (!["failed", "blocked", "partial"].includes(taskArtifact.record.status)) {
232
+ throw new Error(`Task ${taskId} is ${taskArtifact.record.status}; retry requires failed, blocked, or partial.`);
233
+ }
234
+ const attempts = repository.list("run")
235
+ .filter((artifact) => artifact.record && artifact.record.task === taskId)
236
+ .map((artifact) => Number(artifact.record.attempt) || 0);
237
+ const run = {
238
+ id: nextId(repository, "run"),
239
+ kind: "run",
240
+ status: "executing",
241
+ created: date(),
242
+ updated: date(),
243
+ method: METHOD_VERSION,
244
+ task: taskId,
245
+ sprint: taskArtifact.record.sprint || null,
246
+ attempt: attempts.length ? Math.max(...attempts) + 1 : 1,
247
+ approval_id: taskArtifact.record.approval_id || null
248
+ };
249
+ const runContent = serializeArtifact(run, `# Retry for ${taskId}\n\n## Transition Log\n\n- ${date()}: created → executing — ${note}`);
250
+ const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
251
+ const taskNext = transitionedContent(taskPrevious, "task", "running", `${run.id} created: ${note}`);
252
+ let runWritten = false;
253
+ 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);
257
+ } catch (error) {
258
+ if (runWritten) fs.rmSync(repository.pathFor(run), { force: true });
259
+ atomicWrite(taskArtifact.file, taskPrevious);
260
+ throw error;
261
+ }
262
+ refreshState(scrumDir);
263
+ return { run, task: taskNext.record, content: runContent };
264
+ }
265
+
266
+ function retryTask(projectRoot, taskId, options = {}) {
267
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), `task-${String(taskId).toLowerCase()}`, () => retryTaskUnlocked(projectRoot, taskId, options));
268
+ }
269
+
270
+ module.exports = { approveRequest, nextId, refreshState, renderState, retryTask, stateFingerprint, stateIsStale, transitionRun };
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+
3
+ const { sha256 } = require("../v2/artifacts");
4
+ const { buildContextPackage } = require("./context");
5
+ const { containsSecret } = require("../security/secrets");
6
+
7
+ 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) };
16
+ return { level: "low", reasons: ["no high-impact signals detected; validate scope before execution"] };
17
+ }
18
+
19
+ function classifyRequest(request) {
20
+ const text = request.toLowerCase();
21
+ if (/\b(investigat|analy[sz]|study|discover|pesquis|analis|estud|descobr)/.test(text)) {
22
+ return { type: "discovery", taskType: "discovery", reason: "request is primarily knowledge-seeking" };
23
+ }
24
+ if (/\b(bug|fix|broken|regression|erro|falha|corrigir|consert)/.test(text)) {
25
+ return { type: "fix", taskType: "fix", reason: "request describes corrective work" };
26
+ }
27
+ if (/\b(feature|initiative|epic|iniciativa|funcionalidade)\b/.test(text)) {
28
+ return { type: "feature", taskType: "feature", reason: "request describes a long-lived product initiative" };
29
+ }
30
+ if (/\b(sprint|timebox|batch|release train|lote)\b/.test(text)) {
31
+ return { type: "sprint", taskType: "task", reason: "request explicitly asks for a timebox or delivery batch" };
32
+ }
33
+ if (/\b(document|docs|readme|typo|texto|documenta)/.test(text)) {
34
+ return { type: "task", taskType: "docs", reason: "request appears bounded to documentation" };
35
+ }
36
+ return { type: "task", taskType: "task", reason: "request is best represented as one atomic Task" };
37
+ }
38
+
39
+ 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
+ };
51
+ }
52
+
53
+ function stableJson(value) {
54
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
55
+ if (value && typeof value === "object") {
56
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
57
+ }
58
+ return JSON.stringify(value);
59
+ }
60
+
61
+ function encodeApproval(plan) {
62
+ const payload = {
63
+ schema: 1,
64
+ method: "2.0.0",
65
+ request: plan.request,
66
+ fingerprint: plan.context.fingerprint,
67
+ classification: plan.classification,
68
+ risk: plan.risk,
69
+ policy: plan.policy,
70
+ issuedAt: plan.issuedAt
71
+ };
72
+ const encoded = Buffer.from(stableJson(payload)).toString("base64url");
73
+ const checksum = sha256(`scrumrun-v2-approval\0${encoded}`);
74
+ return `${encoded}.${checksum}`;
75
+ }
76
+
77
+ function decodeApproval(token) {
78
+ const [encoded, checksum, extra] = String(token || "").split(".");
79
+ if (!encoded || !checksum || extra || sha256(`scrumrun-v2-approval\0${encoded}`) !== checksum) {
80
+ throw new Error("Approval token is malformed or was modified.");
81
+ }
82
+ let payload;
83
+ try {
84
+ payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
85
+ } catch {
86
+ throw new Error("Approval token payload is invalid.");
87
+ }
88
+ if (payload.schema !== 1 || payload.method !== "2.0.0") throw new Error("Approval token version is unsupported.");
89
+ return payload;
90
+ }
91
+
92
+ function planRequest(projectRoot, request) {
93
+ const normalized = String(request || "").trim();
94
+ if (!normalized) throw new Error("A non-empty request is required for intake.");
95
+ if (normalized.length > 10000) throw new Error("Request exceeds the 10,000 character intake limit.");
96
+ const context = buildContextPackage(projectRoot, normalized);
97
+ const policy = applyPolicy(context);
98
+ const risk = assessRisk(normalized);
99
+ const classification = classifyRequest(normalized);
100
+ const plan = {
101
+ state: policy.status === "passed" ? "awaiting_approval" : "blocked",
102
+ pipeline: ["received", "contextualizing", "policy", "risk", "classification", "planning", policy.status === "passed" ? "awaiting_approval" : "blocked"],
103
+ request: normalized,
104
+ context,
105
+ policy,
106
+ risk,
107
+ classification,
108
+ proposal: {
109
+ create: ["Task", "Run"],
110
+ sprint: classification.type === "sprint" ? "propose only after confirming real batch/timebox membership" : null,
111
+ validation: risk.level === "high" ? "targeted tests plus configured reviewers" : "targeted tests"
112
+ },
113
+ issuedAt: new Date().toISOString()
114
+ };
115
+ plan.approvalToken = policy.status === "passed" ? encodeApproval(plan) : null;
116
+ return plan;
117
+ }
118
+
119
+ module.exports = {
120
+ applyPolicy,
121
+ assessRisk,
122
+ classifyRequest,
123
+ containsSecret,
124
+ decodeApproval,
125
+ encodeApproval,
126
+ planRequest,
127
+ stableJson
128
+ };
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ const SECRET_PATTERNS = Object.freeze([
4
+ /\bsk-[A-Za-z0-9_-]{16,}\b/,
5
+ /\bAKIA[0-9A-Z]{16}\b/,
6
+ /\bgh[pousr]_[A-Za-z0-9]{30,}\b/,
7
+ /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/i,
8
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
9
+ /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|private[_-]?key)\s*[:=]\s*[^\s]+/i,
10
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/
11
+ ]);
12
+
13
+ function containsSecret(value) {
14
+ const text = String(value || "");
15
+ return SECRET_PATTERNS.some((pattern) => pattern.test(text));
16
+ }
17
+
18
+ function assertNoSecret(values, message = "Secret-like content is forbidden outside the local vault.") {
19
+ const list = Array.isArray(values) ? values : [values];
20
+ if (list.some(containsSecret)) throw new Error(message);
21
+ }
22
+
23
+ module.exports = { SECRET_PATTERNS, assertNoSecret, containsSecret };