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.
- package/CHANGELOG.md +42 -0
- package/CORE.md +224 -311
- package/DECISIONS.md +95 -11
- package/LICENSE +21 -0
- package/MIGRATION-1-to-2.md +109 -0
- package/README.md +137 -78
- package/SPEC.md +248 -257
- package/bin/claude-install.js +18 -132
- package/bin/scrumrun.js +1259 -174
- package/docs/COMMANDS.md +69 -0
- package/docs/ENTITY-MODEL.md +38 -0
- package/docs/RELEASE.md +44 -0
- package/docs/SCHEMA.md +80 -0
- package/docs/SEMANTIC-MEMORY.md +68 -0
- package/docs/TROUBLESHOOTING.md +66 -0
- package/lib/code-intel/adapter.js +20 -0
- package/lib/code-intel/javascript.js +199 -0
- package/lib/code-intel/learning.js +66 -0
- package/lib/code-intel/scanner.js +126 -0
- package/lib/commands/manifest.js +109 -0
- package/lib/commands/render.js +75 -0
- package/lib/memory/index.js +600 -0
- package/lib/memory/markdown.js +51 -0
- package/lib/memory/service.js +300 -0
- package/lib/runtime/budgets.js +19 -0
- package/lib/runtime/context.js +141 -0
- package/lib/runtime/orchestrator.js +270 -0
- package/lib/runtime/request-engine.js +128 -0
- package/lib/security/secrets.js +23 -0
- package/lib/v2/artifacts.js +343 -0
- package/lib/v2/conformance.js +167 -0
- package/lib/v2/migration.js +1161 -0
- package/lib/v2/project-store.js +44 -0
- package/lib/v2/schema.js +128 -0
- package/package.json +16 -6
- package/scripts/generate-contract-docs.js +113 -0
- package/templates/project/.scrumrun/config.md +4 -7
- package/templates/project/.scrumrun/guardrails.md +23 -0
- package/templates/project/.scrumrun/map.md +4 -16
- package/templates/project/.scrumrun/memory/decisions/.gitkeep +1 -0
- package/templates/project/.scrumrun/memory/dossiers/.gitkeep +1 -0
- package/templates/project/.scrumrun/memory/insights/.gitkeep +1 -0
- package/templates/project/.scrumrun/memory/knowledge/.gitkeep +1 -0
- package/templates/project/.scrumrun/method.json +4 -0
- package/templates/project/.scrumrun/project.md +6 -12
- package/templates/project/.scrumrun/runs/.gitkeep +1 -0
- package/templates/project/.scrumrun/sprints/.gitkeep +1 -0
- package/templates/project/.scrumrun/state.md +21 -0
- package/templates/project/.scrumrun/tasks/.gitkeep +1 -0
- package/templates/project/AGENTS.md +16 -49
- package/templates/project-lean/AGENTS.md +18 -0
- package/templates/shared/skills/scrumrun/SKILL.md +201 -0
- package/templates/codex/prompts/sc-agent.md +0 -14
- package/templates/codex/prompts/sc-backlog.md +0 -13
- package/templates/codex/prompts/sc-challenge.md +0 -57
- package/templates/codex/prompts/sc-config.md +0 -18
- package/templates/codex/prompts/sc-context.md +0 -24
- package/templates/codex/prompts/sc-decisions.md +0 -8
- package/templates/codex/prompts/sc-feature.md +0 -16
- package/templates/codex/prompts/sc-fix.md +0 -21
- package/templates/codex/prompts/sc-goal.md +0 -14
- package/templates/codex/prompts/sc-golden.md +0 -14
- package/templates/codex/prompts/sc-help.md +0 -12
- package/templates/codex/prompts/sc-init.md +0 -14
- package/templates/codex/prompts/sc-intake.md +0 -22
- package/templates/codex/prompts/sc-know.md +0 -75
- package/templates/codex/prompts/sc-map.md +0 -13
- package/templates/codex/prompts/sc-review.md +0 -13
- package/templates/codex/prompts/sc-sprint.md +0 -28
- package/templates/codex/prompts/sc-study.md +0 -23
- package/templates/codex/prompts/sc-uninstall.md +0 -14
- package/templates/codex/prompts/sc-update.md +0 -8
- package/templates/codex/prompts/sc-vault.md +0 -27
- package/templates/codex/skills/scrumrun/SKILL.md +0 -412
- package/templates/opencode/commands/sc-agent.md +0 -14
- package/templates/opencode/commands/sc-backlog.md +0 -13
- package/templates/opencode/commands/sc-challenge.md +0 -57
- package/templates/opencode/commands/sc-config.md +0 -18
- package/templates/opencode/commands/sc-context.md +0 -24
- package/templates/opencode/commands/sc-decisions.md +0 -8
- package/templates/opencode/commands/sc-feature.md +0 -16
- package/templates/opencode/commands/sc-fix.md +0 -21
- package/templates/opencode/commands/sc-goal.md +0 -14
- package/templates/opencode/commands/sc-golden.md +0 -14
- package/templates/opencode/commands/sc-help.md +0 -12
- package/templates/opencode/commands/sc-init.md +0 -14
- package/templates/opencode/commands/sc-intake.md +0 -22
- package/templates/opencode/commands/sc-know.md +0 -75
- package/templates/opencode/commands/sc-map.md +0 -13
- package/templates/opencode/commands/sc-review.md +0 -13
- package/templates/opencode/commands/sc-sprint.md +0 -28
- package/templates/opencode/commands/sc-study.md +0 -23
- package/templates/opencode/commands/sc-uninstall.md +0 -14
- package/templates/opencode/commands/sc-update.md +0 -8
- package/templates/opencode/commands/sc-vault.md +0 -27
- package/templates/opencode/skills/scrumrun/SKILL.md +0 -412
- package/templates/project/.scrumrun/agents.md +0 -36
- package/templates/project/.scrumrun/backlog.md +0 -7
- package/templates/project/.scrumrun/context.md +0 -61
- package/templates/project/.scrumrun/goals/main/decisions.md +0 -9
- package/templates/project/.scrumrun/goals/main/history.md +0 -51
- package/templates/project/.scrumrun/goals/main/sprint.md +0 -54
- package/templates/project/.scrumrun/golden-rules.md +0 -9
- package/templates/project/.scrumrun/knowledge.md +0 -15
- package/templates/project/.scrumrun/runbook.md +0 -101
- package/templates/project/.scrumrun/token-policy.md +0 -43
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
assertNoSymlinkPath,
|
|
10
|
+
atomicWrite,
|
|
11
|
+
parseArtifact,
|
|
12
|
+
transitionArtifact,
|
|
13
|
+
validateArtifact,
|
|
14
|
+
withArtifactLock
|
|
15
|
+
} = require("../v2/artifacts");
|
|
16
|
+
const { extractEvidence, renderBullets } = require("./markdown");
|
|
17
|
+
const { assertNoSecret } = require("../security/secrets");
|
|
18
|
+
|
|
19
|
+
const MEMORY_KINDS = new Set(["knowledge", "decision", "insight", "dossier"]);
|
|
20
|
+
const DEFAULT_STATUS = Object.freeze({ knowledge: "candidate", decision: "open", insight: "candidate", dossier: "active" });
|
|
21
|
+
const ACTIONS = Object.freeze({
|
|
22
|
+
knowledge: { approve: "approved", reject: "rejected", deprecate: "deprecated", invalidate: "invalidated" },
|
|
23
|
+
decision: { resolve: "resolved", deprecate: "deprecated", invalidate: "invalidated" },
|
|
24
|
+
insight: { confirm: "confirmed", stale: "stale", reject: "invalidated", invalidate: "invalidated", deprecate: "deprecated" },
|
|
25
|
+
dossier: { refresh: "active", stale: "stale", archive: "archived", deprecate: "deprecated" }
|
|
26
|
+
});
|
|
27
|
+
const CONFIRMED_STATUS = new Set(["approved", "confirmed", "resolved"]);
|
|
28
|
+
const ARTIFACT_PREFIX_KIND = Object.freeze(Object.fromEntries(
|
|
29
|
+
Object.entries(ARTIFACT_TYPES).map(([kind, spec]) => [spec.prefix, kind])
|
|
30
|
+
));
|
|
31
|
+
|
|
32
|
+
function today() {
|
|
33
|
+
return new Date().toISOString().slice(0, 10);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function oneLine(value, field) {
|
|
37
|
+
const text = String(value || "").trim();
|
|
38
|
+
if (!text) throw new Error(`${field} is required.`);
|
|
39
|
+
if (/\r|\n/.test(text)) throw new Error(`${field} must be one line.`);
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizedList(values, field) {
|
|
44
|
+
if (values === undefined || values === null) return [];
|
|
45
|
+
const list = Array.isArray(values) ? values : [values];
|
|
46
|
+
return [...new Set(list.map((value) => oneLine(value, field)))];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function optionalDate(value, field) {
|
|
50
|
+
if (value === undefined || value === null || value === "") return null;
|
|
51
|
+
const text = oneLine(value, field);
|
|
52
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) throw new Error(`${field} must be YYYY-MM-DD.`);
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nextMemoryId(repository, kind) {
|
|
57
|
+
const prefix = ARTIFACT_TYPES[kind].prefix;
|
|
58
|
+
const highest = repository.list(kind).reduce((max, artifact) => {
|
|
59
|
+
const match = artifact.record && artifact.record.id.match(/-(\d+)$/);
|
|
60
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
61
|
+
}, 0);
|
|
62
|
+
return `${prefix}-${String(highest + 1).padStart(3, "0")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function assertV2Project(projectRoot) {
|
|
66
|
+
const marker = path.join(projectRoot, ".scrumrun", "method.json");
|
|
67
|
+
if (!fs.existsSync(marker) || !fs.lstatSync(marker).isFile()) throw new Error("Canonical ScrumRun 2.0 method.json is missing.");
|
|
68
|
+
let value;
|
|
69
|
+
try {
|
|
70
|
+
value = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
71
|
+
} catch {
|
|
72
|
+
throw new Error("Canonical ScrumRun method.json is malformed.");
|
|
73
|
+
}
|
|
74
|
+
if (value.method !== METHOD_VERSION) throw new Error(`Project method must be ${METHOD_VERSION}.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function currentCommit(projectRoot) {
|
|
78
|
+
const gitDir = path.join(projectRoot, ".git");
|
|
79
|
+
const headFile = path.join(gitDir, "HEAD");
|
|
80
|
+
if (!fs.existsSync(headFile)) return null;
|
|
81
|
+
const head = fs.readFileSync(headFile, "utf8").trim();
|
|
82
|
+
if (/^[a-f0-9]{40,64}$/i.test(head)) return head;
|
|
83
|
+
const ref = head.match(/^ref:\s+(.+)$/);
|
|
84
|
+
if (!ref || ref[1].includes("..")) return null;
|
|
85
|
+
const refFile = path.resolve(gitDir, ref[1]);
|
|
86
|
+
if (!refFile.startsWith(`${path.resolve(gitDir)}${path.sep}`) || !fs.existsSync(refFile)) return null;
|
|
87
|
+
const value = fs.readFileSync(refFile, "utf8").trim();
|
|
88
|
+
return /^[a-f0-9]{40,64}$/i.test(value) ? value : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function kindForArtifactId(reference) {
|
|
92
|
+
const match = String(reference).match(/^([A-Z]+)-\d{3,}$/);
|
|
93
|
+
return match ? ARTIFACT_PREFIX_KIND[match[1]] || null : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function evidencePath(reference) {
|
|
97
|
+
return String(reference)
|
|
98
|
+
.replace(/^path:\s*/i, "")
|
|
99
|
+
.replace(/#L\d+(?:-L\d+)?$/i, "")
|
|
100
|
+
.replace(/:\d+(?::\d+)?$/, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveEvidence(projectRoot, repository, reference) {
|
|
104
|
+
const value = oneLine(reference, "evidence");
|
|
105
|
+
const artifactKind = kindForArtifactId(value);
|
|
106
|
+
if (artifactKind) {
|
|
107
|
+
const artifact = repository.read(artifactKind, value);
|
|
108
|
+
return artifact && !artifact.errors.length
|
|
109
|
+
? { reference: value, type: "artifact", resolved: true }
|
|
110
|
+
: { reference: value, type: "artifact", resolved: false, reason: "artifact not found or invalid" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const relative = evidencePath(value);
|
|
114
|
+
if (path.isAbsolute(relative) || relative.split(/[\\/]+/).includes("..")) {
|
|
115
|
+
return { reference: value, type: "path", resolved: false, reason: "unsafe path" };
|
|
116
|
+
}
|
|
117
|
+
if (/(^|[\\/])vault(?:\.local)?\.md$|(^|[\\/])vault([\\/]|$)/i.test(relative)) {
|
|
118
|
+
return { reference: value, type: "path", resolved: false, reason: "vault evidence is forbidden" };
|
|
119
|
+
}
|
|
120
|
+
const root = path.resolve(projectRoot);
|
|
121
|
+
const target = path.resolve(root, relative);
|
|
122
|
+
const inside = target === root || target.startsWith(`${root}${path.sep}`);
|
|
123
|
+
if (inside) {
|
|
124
|
+
try {
|
|
125
|
+
assertNoSymlinkPath(root, target);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { reference: value, type: "path", resolved: false, reason: error.message };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
reference: value,
|
|
132
|
+
type: "path",
|
|
133
|
+
resolved: inside && fs.existsSync(target),
|
|
134
|
+
reason: inside ? (fs.existsSync(target) ? null : "path not found") : "path escapes project"
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assertResolvableEvidence(projectRoot, repository, evidence) {
|
|
139
|
+
if (!evidence.length) throw new Error("Confirmed memory requires at least one evidence reference.");
|
|
140
|
+
const resolution = evidence.map((item) => resolveEvidence(projectRoot, repository, item));
|
|
141
|
+
const unresolved = resolution.filter((item) => !item.resolved);
|
|
142
|
+
if (unresolved.length) {
|
|
143
|
+
throw new Error(`Unresolved evidence: ${unresolved.map((item) => `${item.reference} (${item.reason})`).join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
return resolution;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createMemoryUnlocked(projectRoot, kind, options = {}) {
|
|
149
|
+
if (!MEMORY_KINDS.has(kind)) throw new Error(`Unsupported memory kind: ${kind}`);
|
|
150
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
151
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
152
|
+
const created = today();
|
|
153
|
+
const title = oneLine(options.title || options.content, "title");
|
|
154
|
+
const content = String(options.content || title).trim();
|
|
155
|
+
if (!content) throw new Error("content is required.");
|
|
156
|
+
const evidence = normalizedList(options.evidence, "evidence");
|
|
157
|
+
const relations = normalizedList(options.relations, "relation");
|
|
158
|
+
assertNoSecret([title, content, ...evidence, ...relations]);
|
|
159
|
+
const malformedRelation = relations.find((relation) => !/^[a-z][a-z0-9_]*\s*(?::|→|->)\s*\S/i.test(relation));
|
|
160
|
+
if (malformedRelation) throw new Error(`Malformed relation: ${malformedRelation}. Use relation: target.`);
|
|
161
|
+
const sourceType = oneLine(options.sourceType || "human", "sourceType").toLowerCase();
|
|
162
|
+
const confidence = options.confidence === undefined || options.confidence === null
|
|
163
|
+
? null
|
|
164
|
+
: Number(options.confidence);
|
|
165
|
+
if (confidence !== null && (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)) {
|
|
166
|
+
throw new Error("confidence must be a number between 0 and 1.");
|
|
167
|
+
}
|
|
168
|
+
const record = {
|
|
169
|
+
id: nextMemoryId(repository, kind),
|
|
170
|
+
kind,
|
|
171
|
+
status: DEFAULT_STATUS[kind],
|
|
172
|
+
created,
|
|
173
|
+
updated: created,
|
|
174
|
+
method: METHOD_VERSION,
|
|
175
|
+
subject_type: options.subjectType ? oneLine(options.subjectType, "subjectType") : null,
|
|
176
|
+
subject_id: options.subjectId ? oneLine(options.subjectId, "subjectId") : null,
|
|
177
|
+
source_type: sourceType,
|
|
178
|
+
source_id: options.sourceId ? oneLine(options.sourceId, "sourceId") : null,
|
|
179
|
+
confidence,
|
|
180
|
+
valid_from: optionalDate(options.validFrom, "validFrom"),
|
|
181
|
+
valid_until: optionalDate(options.validUntil, "validUntil"),
|
|
182
|
+
last_verified_commit: null,
|
|
183
|
+
review_trigger: options.reviewTrigger ? oneLine(options.reviewTrigger, "reviewTrigger") : null
|
|
184
|
+
};
|
|
185
|
+
if (kind === "insight" && sourceType === "ai") record.status = "candidate";
|
|
186
|
+
if (kind === "dossier") assertResolvableEvidence(projectRoot, repository, evidence);
|
|
187
|
+
const body = [
|
|
188
|
+
`# ${title}`,
|
|
189
|
+
"",
|
|
190
|
+
kind === "decision" ? "## Decision" : kind === "dossier" ? "## Scope" : "## Statement",
|
|
191
|
+
"",
|
|
192
|
+
content,
|
|
193
|
+
"",
|
|
194
|
+
"## Evidence",
|
|
195
|
+
"",
|
|
196
|
+
renderBullets(evidence, "confirmation requires resolvable evidence."),
|
|
197
|
+
"",
|
|
198
|
+
"## Relations",
|
|
199
|
+
"",
|
|
200
|
+
renderBullets(relations, "no explicit relations."),
|
|
201
|
+
"",
|
|
202
|
+
"## Lifecycle",
|
|
203
|
+
"",
|
|
204
|
+
`- ${created}: ${record.status} — proposed by ${sourceType}.`
|
|
205
|
+
].join("\n");
|
|
206
|
+
repository.write(record, body);
|
|
207
|
+
return repository.read(kind, record.id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function createMemory(projectRoot, kind, options = {}) {
|
|
211
|
+
if (!MEMORY_KINDS.has(kind)) throw new Error(`Unsupported memory kind: ${kind}`);
|
|
212
|
+
assertV2Project(projectRoot);
|
|
213
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
214
|
+
return withArtifactLock(scrumDir, `memory-${kind}`, () => createMemoryUnlocked(projectRoot, kind, options));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function setFrontmatter(content, updates) {
|
|
218
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/);
|
|
219
|
+
if (!match) throw new Error("Memory artifact has malformed frontmatter.");
|
|
220
|
+
let frontmatter = match[1];
|
|
221
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
222
|
+
const rendered = value === null ? "null" : String(value);
|
|
223
|
+
const line = new RegExp(`^${field}:\\s*.*$`, "m");
|
|
224
|
+
frontmatter = line.test(frontmatter)
|
|
225
|
+
? frontmatter.replace(line, `${field}: ${rendered}`)
|
|
226
|
+
: `${frontmatter}\n${field}: ${rendered}`;
|
|
227
|
+
}
|
|
228
|
+
return `---\n${frontmatter}\n---${match[2]}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function transitionMemoryUnlocked(projectRoot, kind, id, action, options = {}) {
|
|
232
|
+
if (!MEMORY_KINDS.has(kind)) throw new Error(`Unsupported memory kind: ${kind}`);
|
|
233
|
+
const nextStatus = ACTIONS[kind] && ACTIONS[kind][action];
|
|
234
|
+
if (!nextStatus) throw new Error(`Unsupported ${kind} action: ${action}`);
|
|
235
|
+
const repository = new ArtifactRepository(path.join(projectRoot, ".scrumrun"));
|
|
236
|
+
const artifact = repository.read(kind, id);
|
|
237
|
+
if (!artifact) throw new Error(`${id} not found.`);
|
|
238
|
+
if (artifact.errors.length) throw new Error(`Invalid ${id}: ${artifact.errors.join("; ")}`);
|
|
239
|
+
const addedEvidence = normalizedList(options.evidence, "evidence");
|
|
240
|
+
assertNoSecret([options.note || "", ...addedEvidence]);
|
|
241
|
+
const allEvidence = [...new Set([...extractEvidence(artifact.body), ...addedEvidence])];
|
|
242
|
+
if (CONFIRMED_STATUS.has(nextStatus)) assertResolvableEvidence(projectRoot, repository, allEvidence);
|
|
243
|
+
|
|
244
|
+
const date = today();
|
|
245
|
+
const refreshInPlace = kind === "dossier" && action === "refresh" && artifact.record.status === "active";
|
|
246
|
+
if (!refreshInPlace) transitionArtifact(artifact.record, nextStatus, date);
|
|
247
|
+
const previous = fs.readFileSync(artifact.file, "utf8");
|
|
248
|
+
const verified = CONFIRMED_STATUS.has(nextStatus) || action === "refresh";
|
|
249
|
+
const commit = verified ? currentCommit(projectRoot) : artifact.record.last_verified_commit;
|
|
250
|
+
let next = setFrontmatter(previous, {
|
|
251
|
+
status: nextStatus,
|
|
252
|
+
updated: date,
|
|
253
|
+
...(verified ? { last_verified_commit: commit || "unavailable" } : {})
|
|
254
|
+
});
|
|
255
|
+
const note = options.note ? oneLine(options.note, "note") : `${action} requested through ScrumRun.`;
|
|
256
|
+
const review = [
|
|
257
|
+
"",
|
|
258
|
+
`## Memory Review ${date} · ${action}`,
|
|
259
|
+
"",
|
|
260
|
+
`- Transition: ${refreshInPlace ? `${nextStatus} (refreshed)` : `${artifact.record.status} → ${nextStatus}`}`,
|
|
261
|
+
`- Note: ${note}`
|
|
262
|
+
].join("\n");
|
|
263
|
+
const evidenceReview = addedEvidence.length
|
|
264
|
+
? `\n\n## Evidence ${date} · ${action}\n\n${renderBullets(addedEvidence, "none.")}`
|
|
265
|
+
: "";
|
|
266
|
+
next = `${next.trimEnd()}\n${review}${evidenceReview}\n`;
|
|
267
|
+
const parsed = parseArtifact(next);
|
|
268
|
+
const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
|
|
269
|
+
if (errors.length) throw new Error(`Memory transition validation failed: ${errors.join("; ")}`);
|
|
270
|
+
atomicWrite(artifact.file, next);
|
|
271
|
+
return repository.read(kind, id);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function transitionMemory(projectRoot, kind, id, action, options = {}) {
|
|
275
|
+
if (!MEMORY_KINDS.has(kind)) throw new Error(`Unsupported memory kind: ${kind}`);
|
|
276
|
+
assertV2Project(projectRoot);
|
|
277
|
+
return withArtifactLock(path.join(projectRoot, ".scrumrun"), `memory-${kind}`, () => transitionMemoryUnlocked(projectRoot, kind, id, action, options));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function listMemory(projectRoot, kind, { includeInactive = true } = {}) {
|
|
281
|
+
if (!MEMORY_KINDS.has(kind)) throw new Error(`Unsupported memory kind: ${kind}`);
|
|
282
|
+
const excluded = new Set(["rejected", "invalidated", "deprecated", "archived"]);
|
|
283
|
+
return new ArtifactRepository(path.join(projectRoot, ".scrumrun")).list(kind)
|
|
284
|
+
.filter((artifact) => includeInactive || !excluded.has(artifact.record.status));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function showMemory(projectRoot, kind, id) {
|
|
288
|
+
if (!MEMORY_KINDS.has(kind)) throw new Error(`Unsupported memory kind: ${kind}`);
|
|
289
|
+
return new ArtifactRepository(path.join(projectRoot, ".scrumrun")).read(kind, id);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
ACTIONS,
|
|
294
|
+
assertV2Project,
|
|
295
|
+
createMemory,
|
|
296
|
+
listMemory,
|
|
297
|
+
resolveEvidence,
|
|
298
|
+
showMemory,
|
|
299
|
+
transitionMemory
|
|
300
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const V1_CONTROL_CONTEXT_BASELINE_CHARS = 48000;
|
|
4
|
+
const LEAN_CONTROL_CONTEXT_MAX_RATIO = 0.25;
|
|
5
|
+
const LEAN_CONTROL_CONTEXT_MAX_CHARS = V1_CONTROL_CONTEXT_BASELINE_CHARS * LEAN_CONTROL_CONTEXT_MAX_RATIO;
|
|
6
|
+
const MAX_CONTEXT_RESULTS = 100;
|
|
7
|
+
const DEFAULT_CONTEXT_RESULTS = 10;
|
|
8
|
+
const MAX_RELATIONS_PER_RESULT = 100;
|
|
9
|
+
const DEFAULT_RELATIONS_PER_RESULT = 40;
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
DEFAULT_CONTEXT_RESULTS,
|
|
13
|
+
DEFAULT_RELATIONS_PER_RESULT,
|
|
14
|
+
LEAN_CONTROL_CONTEXT_MAX_CHARS,
|
|
15
|
+
LEAN_CONTROL_CONTEXT_MAX_RATIO,
|
|
16
|
+
MAX_CONTEXT_RESULTS,
|
|
17
|
+
MAX_RELATIONS_PER_RESULT,
|
|
18
|
+
V1_CONTROL_CONTEXT_BASELINE_CHARS
|
|
19
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { ARTIFACT_TYPES, ArtifactRepository, sha256 } = require("../v2/artifacts");
|
|
6
|
+
const { readProjectModel } = require("../v2/project-store");
|
|
7
|
+
const { containsSecret } = require("../security/secrets");
|
|
8
|
+
|
|
9
|
+
const EXCLUDED_ROOT_ENTRIES = new Set([".git", ".scrumrun", "node_modules", "dist", "build", "coverage"]);
|
|
10
|
+
|
|
11
|
+
function read(file) {
|
|
12
|
+
return fs.existsSync(file) && fs.lstatSync(file).isFile() ? fs.readFileSync(file, "utf8") : "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function capped(text, limit = 1200) {
|
|
16
|
+
if (text.length <= limit) return text;
|
|
17
|
+
return `${text.slice(0, limit)}\n…[truncated ${text.length - limit} chars]`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function projectScan(projectRoot) {
|
|
21
|
+
const entries = fs.readdirSync(projectRoot, { withFileTypes: true })
|
|
22
|
+
.filter((entry) => !EXCLUDED_ROOT_ENTRIES.has(entry.name))
|
|
23
|
+
.map((entry) => ({ name: entry.name, kind: entry.isDirectory() ? "directory" : "file" }))
|
|
24
|
+
.slice(0, 80);
|
|
25
|
+
const packageFile = path.join(projectRoot, "package.json");
|
|
26
|
+
let packageSummary = null;
|
|
27
|
+
if (fs.existsSync(packageFile) && fs.lstatSync(packageFile).isFile()) {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(fs.readFileSync(packageFile, "utf8"));
|
|
30
|
+
packageSummary = {
|
|
31
|
+
name: pkg.name || null,
|
|
32
|
+
scripts: Object.keys(pkg.scripts || {}).sort(),
|
|
33
|
+
dependencies: Object.keys(pkg.dependencies || {}).sort().slice(0, 100),
|
|
34
|
+
devDependencies: Object.keys(pkg.devDependencies || {}).sort().slice(0, 100)
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
packageSummary = { error: "package.json is malformed" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { project: path.basename(projectRoot), entries, package: packageSummary };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function guardrailRecords(content) {
|
|
44
|
+
const headings = [...content.matchAll(/^## (GR-\d{3,})\s*[-—:]\s*([^\r\n]+)$/gm)];
|
|
45
|
+
return headings.map((heading, index) => {
|
|
46
|
+
const end = index + 1 < headings.length ? headings[index + 1].index : content.length;
|
|
47
|
+
const block = content.slice(heading.index, end);
|
|
48
|
+
const rule = (block.match(/^Rule:\s*(.+)$/m) || [])[1] || heading[2];
|
|
49
|
+
const status = ((block.match(/^Status:\s*(.+)$/m) || [])[1] || "active").trim();
|
|
50
|
+
return { id: heading[1], title: heading[2].trim(), rule: capped(rule.trim(), 500), status };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function artifactSnapshot(scrumDir) {
|
|
55
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
56
|
+
const records = {};
|
|
57
|
+
const hashes = [];
|
|
58
|
+
const warnings = [];
|
|
59
|
+
for (const kind of Object.keys(ARTIFACT_TYPES)) {
|
|
60
|
+
records[kind] = repository.list(kind).map((artifact) => {
|
|
61
|
+
const relative = path.relative(scrumDir, artifact.file).split(path.sep).join("/");
|
|
62
|
+
const content = read(artifact.file);
|
|
63
|
+
hashes.push(`${relative}\0${sha256(content)}`);
|
|
64
|
+
if (containsSecret(content)) {
|
|
65
|
+
warnings.push(`Secret-like content detected in canonical artifact: ${relative}`);
|
|
66
|
+
return { id: artifact.record ? artifact.record.id : path.basename(artifact.file, ".md"), status: "unsafe", title: "[redacted]" };
|
|
67
|
+
}
|
|
68
|
+
return artifact.record ? {
|
|
69
|
+
id: artifact.record.id,
|
|
70
|
+
status: artifact.record.status,
|
|
71
|
+
title: ((artifact.body || "").match(/^# ([^\r\n]+)/m) || [])[1] || artifact.record.id,
|
|
72
|
+
task: artifact.record.task || null,
|
|
73
|
+
sprint: artifact.record.sprint || null,
|
|
74
|
+
feature: artifact.record.feature || null
|
|
75
|
+
} : { id: path.basename(artifact.file, ".md"), status: "invalid", errors: artifact.errors };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return { records, hashes, warnings };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function contextFingerprint(scrumDir, artifactHashes) {
|
|
82
|
+
const canonical = ["guardrails.md", "config.md", "project.md", "method.json"]
|
|
83
|
+
.map((relative) => {
|
|
84
|
+
const content = read(path.join(scrumDir, relative));
|
|
85
|
+
return `${relative}\0${content ? sha256(content) : "missing"}`;
|
|
86
|
+
});
|
|
87
|
+
return sha256([...canonical, ...artifactHashes].sort().join("\n"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildContextPackage(projectRoot, request) {
|
|
91
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
92
|
+
if (!fs.existsSync(scrumDir)) throw new Error("Not a ScrumRun project: .scrumrun/ is missing.");
|
|
93
|
+
const model = readProjectModel(projectRoot);
|
|
94
|
+
const securityWarnings = [];
|
|
95
|
+
const safeControl = (relative, limit) => {
|
|
96
|
+
const content = read(path.join(scrumDir, relative));
|
|
97
|
+
if (containsSecret(content)) {
|
|
98
|
+
securityWarnings.push(`Secret-like content detected in canonical control: ${relative}`);
|
|
99
|
+
return "[redacted: secret-like content]";
|
|
100
|
+
}
|
|
101
|
+
return capped(content, limit);
|
|
102
|
+
};
|
|
103
|
+
const guardrailsContent = safeControl("guardrails.md", 10000);
|
|
104
|
+
const config = safeControl("config.md", 1000);
|
|
105
|
+
const project = safeControl("project.md", 1400);
|
|
106
|
+
const artifacts = model.source === "canonical-v2" ? artifactSnapshot(scrumDir) : { records: {}, hashes: [], warnings: [] };
|
|
107
|
+
const openDecisions = (artifacts.records.decision || []).filter((record) => record.status === "open").slice(-10);
|
|
108
|
+
const recentRuns = (artifacts.records.run || []).slice(-10).reverse();
|
|
109
|
+
const activeWork = ["feature", "task", "sprint", "run"].flatMap((kind) =>
|
|
110
|
+
(artifacts.records[kind] || []).filter((record) => !["completed", "failed", "cancelled"].includes(record.status))
|
|
111
|
+
).slice(0, 50);
|
|
112
|
+
const missing = [];
|
|
113
|
+
for (const relative of ["guardrails.md", "config.md", "project.md"]) {
|
|
114
|
+
if (!fs.existsSync(path.join(scrumDir, relative))) missing.push(relative);
|
|
115
|
+
}
|
|
116
|
+
const fingerprint = contextFingerprint(scrumDir, artifacts.hashes);
|
|
117
|
+
const stateContent = read(path.join(scrumDir, "state.md"));
|
|
118
|
+
const stateFingerprint = (stateContent.match(/^Source fingerprint:\s*([a-f0-9]{64})$/m) || [])[1];
|
|
119
|
+
if (!stateFingerprint) missing.push("state.md has no source fingerprint and is stale");
|
|
120
|
+
else if (stateFingerprint !== fingerprint) missing.push("state.md source fingerprint is stale");
|
|
121
|
+
return {
|
|
122
|
+
request: capped(request.trim(), 10000),
|
|
123
|
+
layout: model.layout,
|
|
124
|
+
source: model.source,
|
|
125
|
+
fingerprint,
|
|
126
|
+
projectScan: projectScan(projectRoot),
|
|
127
|
+
guardrails: guardrailRecords(guardrailsContent).filter((record) => record.status === "active").slice(0, 100),
|
|
128
|
+
config,
|
|
129
|
+
project: project || "[missing]",
|
|
130
|
+
history: { recentRuns },
|
|
131
|
+
decisions: { open: openDecisions },
|
|
132
|
+
activeWork,
|
|
133
|
+
graph: {
|
|
134
|
+
nodeCount: model.graph.nodes.length,
|
|
135
|
+
edgeCount: model.graph.edges.length
|
|
136
|
+
},
|
|
137
|
+
warnings: [...(model.warnings || []), ...securityWarnings, ...(artifacts.warnings || []), ...missing.map((relative) => relative.startsWith("state.md") ? relative : `Missing canonical context: ${relative}`)]
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { buildContextPackage, contextFingerprint, guardrailRecords, projectScan };
|