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,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const {
|
|
7
|
+
ARTIFACT_TRANSITIONS,
|
|
8
|
+
ARTIFACT_TYPES,
|
|
9
|
+
METHOD_VERSION,
|
|
10
|
+
SCALAR_FIELDS,
|
|
11
|
+
STRUCTURAL_RELATIONS
|
|
12
|
+
} = require("./schema");
|
|
13
|
+
|
|
14
|
+
function sha256(content) {
|
|
15
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scalar(value) {
|
|
19
|
+
if (value === null || value === undefined) return "null";
|
|
20
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
21
|
+
const text = String(value);
|
|
22
|
+
if (/^[A-Za-z0-9._/-]+$/.test(text)) return text;
|
|
23
|
+
return JSON.stringify(text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseScalar(value) {
|
|
27
|
+
const text = value.trim();
|
|
28
|
+
if (text === "null") return null;
|
|
29
|
+
if (text === "true") return true;
|
|
30
|
+
if (text === "false") return false;
|
|
31
|
+
if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
|
|
32
|
+
if (text.startsWith('"')) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
} catch {
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function serializeArtifact(record, body = "") {
|
|
43
|
+
const required = ["id", "kind", "status", "created", "updated", "method"];
|
|
44
|
+
const ordered = {};
|
|
45
|
+
for (const key of required) ordered[key] = record[key];
|
|
46
|
+
for (const [key, value] of Object.entries(record)) {
|
|
47
|
+
if (!required.includes(key)) ordered[key] = value;
|
|
48
|
+
}
|
|
49
|
+
const frontmatter = Object.entries(ordered)
|
|
50
|
+
.filter(([, value]) => value !== undefined)
|
|
51
|
+
.map(([key, value]) => `${key}: ${scalar(value)}`)
|
|
52
|
+
.join("\n");
|
|
53
|
+
return `---\n${frontmatter}\n---\n\n${body.trim()}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseArtifact(content) {
|
|
57
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
|
|
58
|
+
if (!match) return { record: null, body: content, errors: ["missing or malformed frontmatter"] };
|
|
59
|
+
const record = {};
|
|
60
|
+
const errors = [];
|
|
61
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
const field = line.match(/^([a-z][a-z0-9_]*):\s*(.*)$/i);
|
|
64
|
+
if (!field) {
|
|
65
|
+
errors.push(`malformed frontmatter line: ${line}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(record, field[1])) {
|
|
69
|
+
errors.push(`duplicate frontmatter field: ${field[1]}`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
record[field[1]] = parseScalar(field[2]);
|
|
73
|
+
}
|
|
74
|
+
return { record, body: match[2], errors };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateArtifact(record, expectedKind = null) {
|
|
78
|
+
const errors = [];
|
|
79
|
+
if (!record || typeof record !== "object") return ["artifact record is missing"];
|
|
80
|
+
const type = ARTIFACT_TYPES[record.kind];
|
|
81
|
+
if (!type) errors.push(`unknown kind: ${record.kind || "missing"}`);
|
|
82
|
+
if (expectedKind && record.kind !== expectedKind) errors.push(`expected kind ${expectedKind}, got ${record.kind}`);
|
|
83
|
+
if (type && !new RegExp(`^${type.prefix}-\\d{3,}$`).test(record.id || "")) {
|
|
84
|
+
errors.push(`invalid ${record.kind} id: ${record.id || "missing"}`);
|
|
85
|
+
}
|
|
86
|
+
if (type && !type.statuses.includes(record.status)) {
|
|
87
|
+
errors.push(`invalid ${record.kind} status: ${record.status || "missing"}`);
|
|
88
|
+
}
|
|
89
|
+
if (record.method !== METHOD_VERSION) errors.push(`method must be ${METHOD_VERSION}`);
|
|
90
|
+
for (const field of ["created", "updated"]) {
|
|
91
|
+
const value = record[field] || "";
|
|
92
|
+
const timestamp = /^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(`${value}T00:00:00.000Z`) : NaN;
|
|
93
|
+
const valid = Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
|
|
94
|
+
if (!valid) errors.push(`${field} must be a real YYYY-MM-DD date`);
|
|
95
|
+
}
|
|
96
|
+
for (const [field, relation] of Object.entries(STRUCTURAL_RELATIONS)) {
|
|
97
|
+
const target = ARTIFACT_TYPES[relation.targetKind];
|
|
98
|
+
if (record[field] !== undefined && record[field] !== null && !new RegExp(`^${target.prefix}-\\d{3,}$`).test(record[field])) {
|
|
99
|
+
errors.push(`${record.kind}.${field} must reference ${target.prefix}-NNN or null`);
|
|
100
|
+
}
|
|
101
|
+
if ((relation.requiredFor || []).includes(record.kind) && (record[field] === undefined || record[field] === null)) {
|
|
102
|
+
errors.push(`${record.kind}.${field} must reference ${target.prefix}-NNN`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const [field, constraint] of Object.entries(SCALAR_FIELDS)) {
|
|
106
|
+
if (!constraint.kinds.includes(record.kind)) continue;
|
|
107
|
+
if (constraint.required && (record[field] === undefined || record[field] === null)) {
|
|
108
|
+
errors.push(`${record.kind}.${field} is required`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (field === "attempt" && (!Number.isInteger(record[field]) || record[field] < 1)) {
|
|
112
|
+
errors.push(`${record.kind}.${field} must be a positive integer`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return errors;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function transitionArtifact(record, nextStatus, updated) {
|
|
119
|
+
const errors = validateArtifact(record);
|
|
120
|
+
if (errors.length) throw new Error(`Cannot transition invalid artifact: ${errors.join("; ")}`);
|
|
121
|
+
const allowed = (ARTIFACT_TRANSITIONS[record.kind] && ARTIFACT_TRANSITIONS[record.kind][record.status]) || [];
|
|
122
|
+
if (!allowed.includes(nextStatus)) {
|
|
123
|
+
throw new Error(`Invalid ${record.kind} transition: ${record.status} -> ${nextStatus}`);
|
|
124
|
+
}
|
|
125
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(updated || "")) throw new Error("Transition date must be YYYY-MM-DD");
|
|
126
|
+
return { ...record, status: nextStatus, updated };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function relativeArtifactPath(record) {
|
|
130
|
+
const type = ARTIFACT_TYPES[record.kind];
|
|
131
|
+
if (!type) throw new Error(`Unknown artifact kind: ${record.kind}`);
|
|
132
|
+
if (!new RegExp(`^${type.prefix}-\\d{3,}$`).test(record.id || "")) throw new Error(`Invalid artifact id: ${record.id}`);
|
|
133
|
+
return path.join(type.directory, `${record.id}.md`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function safePath(root, relative) {
|
|
137
|
+
const resolvedRoot = path.resolve(root);
|
|
138
|
+
const target = path.resolve(resolvedRoot, relative);
|
|
139
|
+
if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
140
|
+
throw new Error(`Path escapes artifact root: ${relative}`);
|
|
141
|
+
}
|
|
142
|
+
return target;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function assertNoSymlinkPath(root, target) {
|
|
146
|
+
const resolvedRoot = path.resolve(root);
|
|
147
|
+
const resolvedTarget = path.resolve(target);
|
|
148
|
+
if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
149
|
+
throw new Error(`Path escapes artifact root: ${target}`);
|
|
150
|
+
}
|
|
151
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
152
|
+
let current = resolvedRoot;
|
|
153
|
+
const candidates = [resolvedRoot, ...relative.split(path.sep).filter(Boolean).map((segment) => (current = path.join(current, segment)))];
|
|
154
|
+
for (const candidate of candidates) {
|
|
155
|
+
if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
|
|
156
|
+
throw new Error(`Symbolic links are forbidden in canonical artifact paths: ${path.relative(resolvedRoot, candidate) || "."}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return resolvedTarget;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function atomicWrite(file, content) {
|
|
163
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
164
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
165
|
+
try {
|
|
166
|
+
fs.writeFileSync(temp, content);
|
|
167
|
+
fs.renameSync(temp, file);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function wait(milliseconds) {
|
|
175
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function withArtifactLock(scrumDir, name, operation, { timeoutMs = 3000, staleMs = 30000 } = {}) {
|
|
179
|
+
if (!/^[a-z0-9-]+$/i.test(name)) throw new Error(`Invalid lock name: ${name}`);
|
|
180
|
+
const lockDir = path.join(scrumDir, ".cache", "locks");
|
|
181
|
+
assertNoSymlinkPath(scrumDir, lockDir);
|
|
182
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
183
|
+
const lockFile = path.join(lockDir, `${name}.lock`);
|
|
184
|
+
const token = `${process.pid}:${Date.now()}:${crypto.randomBytes(8).toString("hex")}`;
|
|
185
|
+
const started = Date.now();
|
|
186
|
+
let descriptor = null;
|
|
187
|
+
while (descriptor === null) {
|
|
188
|
+
try {
|
|
189
|
+
descriptor = fs.openSync(lockFile, "wx", 0o600);
|
|
190
|
+
fs.writeFileSync(descriptor, token);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error.code !== "EEXIST") throw error;
|
|
193
|
+
let stat;
|
|
194
|
+
try {
|
|
195
|
+
stat = fs.lstatSync(lockFile);
|
|
196
|
+
} catch (statError) {
|
|
197
|
+
if (statError.code === "ENOENT") continue;
|
|
198
|
+
throw statError;
|
|
199
|
+
}
|
|
200
|
+
if (stat.isSymbolicLink()) throw new Error(`Symbolic lock path is forbidden: ${name}`);
|
|
201
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
202
|
+
let current;
|
|
203
|
+
try {
|
|
204
|
+
current = fs.lstatSync(lockFile);
|
|
205
|
+
} catch (statError) {
|
|
206
|
+
if (statError.code === "ENOENT") continue;
|
|
207
|
+
throw statError;
|
|
208
|
+
}
|
|
209
|
+
if (current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) fs.rmSync(lockFile, { force: true });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (Date.now() - started >= timeoutMs) throw new Error(`Timed out waiting for ScrumRun lock: ${name}`);
|
|
213
|
+
wait(10);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return operation();
|
|
218
|
+
} finally {
|
|
219
|
+
if (descriptor !== null) fs.closeSync(descriptor);
|
|
220
|
+
try {
|
|
221
|
+
if (fs.existsSync(lockFile) && fs.readFileSync(lockFile, "utf8") === token) fs.rmSync(lockFile, { force: true });
|
|
222
|
+
} catch {
|
|
223
|
+
// A failed cleanup leaves a short-lived stale lock; it never changes canonical state.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function graphFromRecords(records) {
|
|
229
|
+
const nodes = [];
|
|
230
|
+
const edges = [];
|
|
231
|
+
for (const record of records) {
|
|
232
|
+
if (!record || validateArtifact(record).length) continue;
|
|
233
|
+
nodes.push({ id: record.id, kind: record.kind, status: record.status });
|
|
234
|
+
const relations = [
|
|
235
|
+
["feature", "belongs_to"],
|
|
236
|
+
["sprint", "included_in"],
|
|
237
|
+
["task", "executes"],
|
|
238
|
+
["subject", "about"],
|
|
239
|
+
["source_id", "evidenced_by"]
|
|
240
|
+
];
|
|
241
|
+
for (const [field, relation] of relations) {
|
|
242
|
+
if (typeof record[field] === "string" && /^[A-Z]+-\d{3,}$/.test(record[field])) {
|
|
243
|
+
edges.push({ from: record.id, relation, to: record[field] });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { nodes, edges };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class ArtifactRepository {
|
|
251
|
+
constructor(scrumDir) {
|
|
252
|
+
this.scrumDir = path.resolve(scrumDir);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
pathFor(record) {
|
|
256
|
+
const file = safePath(this.scrumDir, relativeArtifactPath(record));
|
|
257
|
+
return assertNoSymlinkPath(this.scrumDir, file);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
write(record, body, { overwrite = false } = {}) {
|
|
261
|
+
const errors = validateArtifact(record);
|
|
262
|
+
if (errors.length) throw new Error(`Invalid artifact ${record.id || "unknown"}: ${errors.join("; ")}`);
|
|
263
|
+
const file = this.pathFor(record);
|
|
264
|
+
const content = serializeArtifact(record, body);
|
|
265
|
+
if (fs.existsSync(file)) {
|
|
266
|
+
const current = fs.readFileSync(file, "utf8");
|
|
267
|
+
if (current === content) return { status: "unchanged", file, hash: sha256(content) };
|
|
268
|
+
if (!overwrite) throw new Error(`Artifact already exists with different content: ${path.relative(this.scrumDir, file)}`);
|
|
269
|
+
}
|
|
270
|
+
atomicWrite(file, content);
|
|
271
|
+
return { status: "written", file, hash: sha256(content) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
read(kind, id) {
|
|
275
|
+
const file = this.pathFor({ kind, id });
|
|
276
|
+
if (!fs.existsSync(file)) return null;
|
|
277
|
+
const content = fs.readFileSync(file, "utf8");
|
|
278
|
+
const parsed = parseArtifact(content);
|
|
279
|
+
const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
|
|
280
|
+
if (parsed.record && parsed.record.id !== id) errors.push(`artifact id ${parsed.record.id || "missing"} does not match filename ${id}`);
|
|
281
|
+
return { ...parsed, file, errors };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
transition(kind, id, nextStatus, updated) {
|
|
285
|
+
const artifact = this.read(kind, id);
|
|
286
|
+
if (!artifact) throw new Error(`Artifact not found: ${id}`);
|
|
287
|
+
if (artifact.errors.length) throw new Error(`Invalid artifact ${id}: ${artifact.errors.join("; ")}`);
|
|
288
|
+
transitionArtifact(artifact.record, nextStatus, updated);
|
|
289
|
+
const content = fs.readFileSync(artifact.file, "utf8");
|
|
290
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---([\s\S]*)$/);
|
|
291
|
+
if (!match) throw new Error(`Invalid artifact ${id}: missing frontmatter`);
|
|
292
|
+
let frontmatter = match[1];
|
|
293
|
+
frontmatter = frontmatter.replace(/^status:\s*.*$/m, `status: ${nextStatus}`);
|
|
294
|
+
frontmatter = frontmatter.replace(/^updated:\s*.*$/m, `updated: ${updated}`);
|
|
295
|
+
const next = `---\n${frontmatter}\n---${match[2]}`;
|
|
296
|
+
const parsed = parseArtifact(next);
|
|
297
|
+
const errors = [...parsed.errors, ...validateArtifact(parsed.record, kind)];
|
|
298
|
+
if (errors.length) throw new Error(`Transition validation failed for ${id}: ${errors.join("; ")}`);
|
|
299
|
+
atomicWrite(artifact.file, next);
|
|
300
|
+
return { status: "written", file: artifact.file, record: parsed.record, hash: sha256(next) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
list(kind) {
|
|
304
|
+
const type = ARTIFACT_TYPES[kind];
|
|
305
|
+
if (!type) throw new Error(`Unknown artifact kind: ${kind}`);
|
|
306
|
+
const directory = safePath(this.scrumDir, type.directory);
|
|
307
|
+
assertNoSymlinkPath(this.scrumDir, directory);
|
|
308
|
+
if (!fs.existsSync(directory)) return [];
|
|
309
|
+
return fs.readdirSync(directory, { withFileTypes: true })
|
|
310
|
+
.filter((entry) => entry.isFile() && new RegExp(`^${type.prefix}-\\d{3,}\\.md$`).test(entry.name))
|
|
311
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
312
|
+
.map((entry) => this.read(kind, path.basename(entry.name, ".md")));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
graph() {
|
|
316
|
+
const records = [];
|
|
317
|
+
for (const kind of Object.keys(ARTIFACT_TYPES)) {
|
|
318
|
+
for (const artifact of this.list(kind)) {
|
|
319
|
+
if (!artifact.record || artifact.errors.length) continue;
|
|
320
|
+
records.push(artifact.record);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return graphFromRecords(records);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = {
|
|
328
|
+
ARTIFACT_TYPES,
|
|
329
|
+
ARTIFACT_TRANSITIONS,
|
|
330
|
+
ArtifactRepository,
|
|
331
|
+
METHOD_VERSION,
|
|
332
|
+
assertNoSymlinkPath,
|
|
333
|
+
atomicWrite,
|
|
334
|
+
graphFromRecords,
|
|
335
|
+
parseArtifact,
|
|
336
|
+
relativeArtifactPath,
|
|
337
|
+
safePath,
|
|
338
|
+
serializeArtifact,
|
|
339
|
+
sha256,
|
|
340
|
+
transitionArtifact,
|
|
341
|
+
validateArtifact,
|
|
342
|
+
withArtifactLock
|
|
343
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { ArtifactRepository } = require("./artifacts");
|
|
6
|
+
const { ARTIFACT_TYPES, METHOD_VERSION } = require("./schema");
|
|
7
|
+
const { stateIsStale } = require("../runtime/orchestrator");
|
|
8
|
+
const { indexStatus } = require("../memory/index");
|
|
9
|
+
const { extractEvidence } = require("../memory/markdown");
|
|
10
|
+
const { resolveEvidence } = require("../memory/service");
|
|
11
|
+
const { containsSecret } = require("../security/secrets");
|
|
12
|
+
|
|
13
|
+
const INVARIANTS = Object.freeze([
|
|
14
|
+
{ id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
|
|
15
|
+
{ id: "I-02", summary: "approval creates Task and Run atomically", tests: ["explicit approval creates exactly one linked Task and Run", "approval failure injection rolls back"] },
|
|
16
|
+
{ id: "I-03", summary: "Task is atomic and Sprint only groups evidenced batches", tests: ["migration apply creates linked v2 artifacts", "artifact repository builds explicit Feature Task Sprint Run graph"] },
|
|
17
|
+
{ id: "I-04", summary: "retry preserves prior Runs", tests: ["retry creates a new Run and preserves"] },
|
|
18
|
+
{ id: "I-05", summary: "state transitions are declared and recoverable", tests: ["run state machine follows", "paired transition failure restores"] },
|
|
19
|
+
{ id: "I-06", summary: "guardrails are canonical and cannot be weakened", tests: ["guardrails build preserves canonical v2 rules"] },
|
|
20
|
+
{ id: "I-07", summary: "Markdown is canonical and indexes are disposable", tests: ["SQLite index is disposable"] },
|
|
21
|
+
{ id: "I-08", summary: "AI memory remains candidate", tests: ["AI insights stay candidate"] },
|
|
22
|
+
{ id: "I-09", summary: "confirmed claims require evidence", tests: ["confirmed facts reject missing"] },
|
|
23
|
+
{ id: "I-10", summary: "inactive memory is not active truth", tests: ["stale and expired memory are labeled"] },
|
|
24
|
+
{ id: "I-11", summary: "vault values never leave the vault boundary", tests: ["migration dry-run is read-only and never renders vault", "SQLite index is disposable"] },
|
|
25
|
+
{ id: "I-12", summary: "canonical schemas and identities are valid", tests: ["v2 artifact schemas round-trip", "malformed and mismatched artifacts are rejected"] },
|
|
26
|
+
{ id: "I-13", summary: "generated state exposes staleness", tests: ["generated state includes structured decisions", "approval rejects tampered or stale plans"] },
|
|
27
|
+
{ id: "I-14", summary: "context and retrieval are bounded", tests: ["lean context stays within twenty-five percent", "semantic query caps relation output"] },
|
|
28
|
+
{ id: "I-15", summary: "migration and ordinary update are read-only by default", tests: ["migration dry-run is read-only", "update preflights an ongoing v1 project"] },
|
|
29
|
+
{ id: "I-16", summary: "migration is hashed, idempotent, and reversible", tests: ["migration apply is idempotent and rollback restores"] },
|
|
30
|
+
{ id: "I-17", summary: "ambiguous migration is preserved and warned", tests: ["partial v1 layout is preserved"] },
|
|
31
|
+
{ id: "I-18", summary: "unsafe and partial writes fail safely", tests: ["canonical writes reject traversal and symlink paths", "repository refuses conflicting overwrite"] },
|
|
32
|
+
{ id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
|
|
33
|
+
{ id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] }
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function finding(severity, code, message, file = null) {
|
|
37
|
+
return { severity, code, message, ...(file ? { file } : {}) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function auditProject(projectRoot) {
|
|
41
|
+
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
42
|
+
const findings = [];
|
|
43
|
+
if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) {
|
|
44
|
+
findings.push(finding("critical", "PROJECT_MISSING", ".scrumrun/ is missing or is not a directory."));
|
|
45
|
+
return { method: METHOD_VERSION, passed: false, findings, counts: {} };
|
|
46
|
+
}
|
|
47
|
+
const marker = path.join(scrumDir, "method.json");
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.existsSync(marker) || !fs.lstatSync(marker).isFile()) throw new Error("marker is missing, not a regular file, or is a symbolic link");
|
|
50
|
+
const value = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
51
|
+
if (value.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
findings.push(finding("critical", "METHOD_MARKER", `method.json is missing or malformed: ${error.message}`, marker));
|
|
54
|
+
}
|
|
55
|
+
for (const relative of ["core.md", "guardrails.md", "config.md", "project.md"]) {
|
|
56
|
+
const file = path.join(scrumDir, relative);
|
|
57
|
+
if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) findings.push(finding("high", "CANONICAL_MISSING", `${relative} is missing or unsafe.`, file));
|
|
58
|
+
}
|
|
59
|
+
const guardrailsFile = path.join(scrumDir, "guardrails.md");
|
|
60
|
+
const guardrails = fs.existsSync(guardrailsFile) && fs.lstatSync(guardrailsFile).isFile() ? fs.readFileSync(guardrailsFile, "utf8") : "";
|
|
61
|
+
if (!/^## GR-\d{3,}/m.test(guardrails)) findings.push(finding("high", "GUARDRAILS_EMPTY", "No stable-id Guardrail is defined."));
|
|
62
|
+
|
|
63
|
+
const repository = new ArtifactRepository(scrumDir);
|
|
64
|
+
const counts = {};
|
|
65
|
+
const ids = new Set();
|
|
66
|
+
const records = {};
|
|
67
|
+
for (const kind of Object.keys(ARTIFACT_TYPES)) {
|
|
68
|
+
try {
|
|
69
|
+
const artifacts = repository.list(kind);
|
|
70
|
+
records[kind] = artifacts;
|
|
71
|
+
counts[kind] = artifacts.length;
|
|
72
|
+
for (const artifact of artifacts) {
|
|
73
|
+
for (const error of artifact.errors) findings.push(finding("high", "ARTIFACT_INVALID", `${path.basename(artifact.file)}: ${error}`, artifact.file));
|
|
74
|
+
if (containsSecret(fs.readFileSync(artifact.file, "utf8"))) findings.push(finding("critical", "SECRET_CANONICAL", `Secret-like content detected in ${path.relative(scrumDir, artifact.file)}.`, artifact.file));
|
|
75
|
+
if (artifact.record && ids.has(artifact.record.id)) findings.push(finding("critical", "ID_DUPLICATE", `Duplicate artifact id: ${artifact.record.id}`));
|
|
76
|
+
if (artifact.record) ids.add(artifact.record.id);
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
findings.push(finding("critical", "ARTIFACT_PATH", `${kind}: ${error.message}`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const run of records.run || []) {
|
|
83
|
+
const task = run.record ? repository.read("task", run.record.task) : null;
|
|
84
|
+
if (run.record && (!task || task.errors.length)) findings.push(finding("high", "RUN_TASK_MISSING", `${run.record.id} references missing or invalid ${run.record.task}.`, run.file));
|
|
85
|
+
if (run.record && task && !task.errors.length && (run.record.sprint || null) !== (task.record.sprint || null)) {
|
|
86
|
+
findings.push(finding("high", "RUN_SPRINT_MISMATCH", `${run.record.id}.sprint must match ${task.record.id}.sprint.`, run.file));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const byId = new Map(Object.values(records).flat().filter((artifact) => artifact.record).map((artifact) => [artifact.record.id, artifact]));
|
|
90
|
+
for (const task of records.task || []) {
|
|
91
|
+
for (const [field, prefix] of [["feature", "FEAT"], ["sprint", "SPRINT"]]) {
|
|
92
|
+
const target = task.record && task.record[field];
|
|
93
|
+
if (target && (!target.startsWith(`${prefix}-`) || !byId.has(target))) findings.push(finding("high", "RELATION_MISSING", `${task.record.id}.${field} references missing ${target}.`, task.file));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const run of records.run || []) {
|
|
97
|
+
const sprint = run.record && run.record.sprint;
|
|
98
|
+
if (sprint && !byId.has(sprint)) findings.push(finding("high", "RELATION_MISSING", `${run.record.id}.sprint references missing ${sprint}.`, run.file));
|
|
99
|
+
}
|
|
100
|
+
const attemptsByTask = new Map();
|
|
101
|
+
for (const run of records.run || []) {
|
|
102
|
+
if (!run.record || run.errors.length) continue;
|
|
103
|
+
if (!attemptsByTask.has(run.record.task)) attemptsByTask.set(run.record.task, []);
|
|
104
|
+
attemptsByTask.get(run.record.task).push(run.record.attempt);
|
|
105
|
+
}
|
|
106
|
+
for (const [taskId, attempts] of attemptsByTask) {
|
|
107
|
+
const ordered = [...attempts].sort((a, b) => a - b);
|
|
108
|
+
const expected = ordered.map((_, index) => index + 1);
|
|
109
|
+
if (ordered.length !== new Set(ordered).size || ordered.some((attempt, index) => attempt !== expected[index])) {
|
|
110
|
+
findings.push(finding("high", "RUN_ATTEMPT_SEQUENCE", `${taskId} Run attempts must be unique and contiguous from 1; found ${ordered.join(", ")}.`));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const sprint of records.sprint || []) {
|
|
114
|
+
if (!sprint.record || sprint.errors.length) continue;
|
|
115
|
+
const heading = /^## Tasks[ \t]*$/m.exec(sprint.body);
|
|
116
|
+
let taskSection = "";
|
|
117
|
+
if (heading) {
|
|
118
|
+
const tail = sprint.body.slice(heading.index + heading[0].length);
|
|
119
|
+
const nextHeading = /^## [^\r\n]+$/m.exec(tail);
|
|
120
|
+
taskSection = nextHeading ? tail.slice(0, nextHeading.index) : tail;
|
|
121
|
+
}
|
|
122
|
+
const listed = new Set([...taskSection.matchAll(/^-\s+(TASK-\d{3,})\s*$/gm)].map((match) => match[1]));
|
|
123
|
+
const expected = new Set((records.task || [])
|
|
124
|
+
.filter((task) => task.record && !task.errors.length && task.record.sprint === sprint.record.id)
|
|
125
|
+
.map((task) => task.record.id));
|
|
126
|
+
const missing = [...expected].filter((id) => !listed.has(id));
|
|
127
|
+
const extra = [...listed].filter((id) => !expected.has(id));
|
|
128
|
+
if (missing.length || extra.length) {
|
|
129
|
+
findings.push(finding("high", "SPRINT_MEMBERSHIP_MISMATCH", `${sprint.record.id} Tasks projection disagrees with Task.sprint (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"}).`, sprint.file));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const confirmed = new Set(["approved", "confirmed", "resolved", "active"]);
|
|
133
|
+
for (const kind of ["knowledge", "decision", "insight", "dossier"]) {
|
|
134
|
+
for (const artifact of records[kind] || []) {
|
|
135
|
+
if (!artifact.record || !confirmed.has(artifact.record.status)) continue;
|
|
136
|
+
const evidence = extractEvidence(artifact.body);
|
|
137
|
+
if (!evidence.length) {
|
|
138
|
+
findings.push(finding("high", "EVIDENCE_MISSING", `${artifact.record.id} is active truth without evidence.`, artifact.file));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
for (const reference of evidence) {
|
|
142
|
+
const result = resolveEvidence(projectRoot, repository, reference);
|
|
143
|
+
if (!result.resolved) findings.push(finding("high", "EVIDENCE_UNRESOLVED", `${artifact.record.id}: ${reference} (${result.reason}).`, artifact.file));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const ignoreFile = path.join(scrumDir, ".gitignore");
|
|
148
|
+
const ignore = fs.existsSync(ignoreFile) && fs.lstatSync(ignoreFile).isFile() ? fs.readFileSync(ignoreFile, "utf8") : "";
|
|
149
|
+
for (const pattern of [".cache/", "vault.local.md"]) {
|
|
150
|
+
if (!ignore.split(/\r?\n/).includes(pattern)) findings.push(finding("high", "IGNORE_MISSING", `${pattern} must be ignored by .scrumrun/.gitignore.`));
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
if (stateIsStale(scrumDir)) findings.push(finding("warning", "STATE_STALE", "state.md is missing or stale; rebuild it before relying on the view."));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
findings.push(finding("high", "STATE_UNSAFE", `state cannot be rebuilt safely: ${error.message}`));
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const semantic = indexStatus(projectRoot);
|
|
159
|
+
if (semantic.exists && semantic.stale) findings.push(finding("warning", "INDEX_STALE", "semantic-index.sqlite is stale and will be rebuilt on query."));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
findings.push(finding("high", "INDEX_UNSAFE", `semantic index cannot inspect canonical sources safely: ${error.message}`));
|
|
162
|
+
}
|
|
163
|
+
const blocking = findings.filter((item) => ["critical", "high"].includes(item.severity));
|
|
164
|
+
return { method: METHOD_VERSION, passed: blocking.length === 0, findings, counts, invariants: INVARIANTS.length };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { INVARIANTS, auditProject };
|