scrumrun 3.1.2 → 4.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.
- package/CHANGELOG.md +29 -0
- package/CORE.md +13 -5
- package/README.md +73 -16
- package/SPEC.md +38 -3
- package/bin/scrumrun.js +102 -13
- package/docs/COMMANDS.md +17 -7
- package/docs/ENTITY-MODEL.md +17 -14
- package/docs/ERROR-CODES.md +4 -0
- package/docs/QUICKSTART.md +21 -19
- package/docs/SCHEMA.md +14 -10
- package/docs/SEMANTIC-MEMORY.md +28 -0
- package/lib/actions/index.js +81 -0
- package/lib/commands/manifest.js +4 -3
- package/lib/commands/render.js +3 -2
- package/lib/errors.js +4 -0
- package/lib/git/context.js +30 -0
- package/lib/guardrails/changeset.js +45 -0
- package/lib/guardrails/evaluate.js +175 -0
- package/lib/memory/compaction.js +289 -0
- package/lib/memory/index.js +62 -2
- package/lib/migrate/ops.js +92 -0
- package/lib/migrate/run.js +108 -0
- package/lib/runtime/briefing.js +29 -0
- package/lib/runtime/context.js +3 -1
- package/lib/runtime/orchestrator.js +4 -4
- package/lib/runtime/policy-engine.js +11 -0
- package/lib/runtime/policy-integrity.js +83 -0
- package/lib/runtime/watcher.js +185 -0
- package/lib/v2/artifacts.js +9 -0
- package/lib/v2/conformance.js +45 -18
- package/lib/v2/paths.js +2 -1
- package/lib/v2/runs-jsonl.js +134 -0
- package/lib/v2/schema.js +7 -7
- package/lib/v2/task-schema.js +133 -0
- package/package.json +2 -2
- package/scripts/generate-contract-docs.js +6 -2
- package/templates/project/.scrumrun/config.md +9 -0
- package/templates/project/.scrumrun/method.json +3 -0
- package/templates/project/AGENTS.md +10 -4
- package/templates/project-lean/AGENTS.md +6 -2
- package/templates/shared/hooks/pre-commit +16 -0
- package/templates/shared/skills/scrumrun/SKILL.md +11 -7
- package/templates/shared/view.html +281 -0
- package/types/index.d.ts +1 -1
package/lib/v2/conformance.js
CHANGED
|
@@ -11,9 +11,14 @@ const { extractEvidence } = require("../memory/markdown");
|
|
|
11
11
|
const { resolveEvidence } = require("../memory/service");
|
|
12
12
|
const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = require("../security/secrets");
|
|
13
13
|
const { pendingTransactionStatus } = require("./transaction");
|
|
14
|
-
const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
14
|
+
const { configWeakeningAttempts, declarativeGuardrailConfig, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
15
15
|
const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
|
|
16
16
|
const { canonicalPaths, PATHS_SCHEMA_VERSION } = require("./paths");
|
|
17
|
+
const { auditPolicyIntegrity } = require("../runtime/policy-integrity");
|
|
18
|
+
const { evaluate } = require("../guardrails/evaluate");
|
|
19
|
+
const { collectChangeSet } = require("../guardrails/changeset");
|
|
20
|
+
const { validateArtifacts: validateTaskSchema } = require("./task-schema");
|
|
21
|
+
const { readGitContext } = require("../git/context");
|
|
17
22
|
|
|
18
23
|
const INVARIANTS = Object.freeze([
|
|
19
24
|
{ id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
|
|
@@ -64,7 +69,7 @@ function diffPathIndex(expected, actual, prefix = "") {
|
|
|
64
69
|
return drift;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
function auditProject(projectRoot) {
|
|
72
|
+
function auditProject(projectRoot, options = {}) {
|
|
68
73
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
69
74
|
const findings = [];
|
|
70
75
|
if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) {
|
|
@@ -79,10 +84,10 @@ function auditProject(projectRoot) {
|
|
|
79
84
|
if (methodMarker.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
|
|
80
85
|
const expectedPaths = canonicalPaths();
|
|
81
86
|
if (!methodMarker.paths || typeof methodMarker.paths !== "object" || Array.isArray(methodMarker.paths)) {
|
|
82
|
-
findings.push(finding("
|
|
87
|
+
findings.push(finding("warning", "METHOD_PATHS_MISSING", `method.json is missing a canonical "paths" block. Non-blocking in Markdown-first daily work; run \`scrumrun update --migrate\` if you want the declared index.`, marker));
|
|
83
88
|
} else {
|
|
84
89
|
const drift = diffPathIndex(expectedPaths, methodMarker.paths);
|
|
85
|
-
for (const entry of drift) findings.push(finding("
|
|
90
|
+
for (const entry of drift) findings.push(finding("warning", "METHOD_PATHS_DRIFT", `method.json paths[${entry.label}] is ${entry.actual === undefined ? "missing" : `"${entry.actual}"`}; expected "${entry.expected}". Non-blocking; regenerate via \`update --migrate\` when convenient.`, marker));
|
|
86
91
|
if (methodMarker.paths_schema !== PATHS_SCHEMA_VERSION) {
|
|
87
92
|
findings.push(finding("warning", "METHOD_PATHS_SCHEMA", `method.json paths_schema is ${methodMarker.paths_schema || "missing"}; expected ${PATHS_SCHEMA_VERSION}.`, marker));
|
|
88
93
|
}
|
|
@@ -94,6 +99,7 @@ function auditProject(projectRoot) {
|
|
|
94
99
|
const file = path.join(scrumDir, relative);
|
|
95
100
|
if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) findings.push(finding("high", "CANONICAL_MISSING", `${relative} is missing or unsafe.`, file));
|
|
96
101
|
}
|
|
102
|
+
for (const item of auditPolicyIntegrity(scrumDir)) findings.push(finding(item.severity, item.code, item.message, marker));
|
|
97
103
|
const guardrailsFile = path.join(scrumDir, "guardrails.md");
|
|
98
104
|
const guardrails = fs.existsSync(guardrailsFile) && fs.lstatSync(guardrailsFile).isFile() ? fs.readFileSync(guardrailsFile, "utf8") : "";
|
|
99
105
|
const guardrailValidation = validateGuardrailDocument(guardrails);
|
|
@@ -111,6 +117,16 @@ function auditProject(projectRoot) {
|
|
|
111
117
|
const configFile = path.join(scrumDir, "config.md");
|
|
112
118
|
const config = fs.existsSync(configFile) && fs.lstatSync(configFile).isFile() ? fs.readFileSync(configFile, "utf8") : "";
|
|
113
119
|
for (const error of configWeakeningAttempts(config)) findings.push(finding("high", "CONFIG_WEAKENS_POLICY", error, configFile));
|
|
120
|
+
const declarativeRules = guardrailValidation.records.filter((record) => record.status === "active" && record.enforcement_rule);
|
|
121
|
+
const guardrailEvaluation = evaluate(declarativeRules, options.changeSet || collectChangeSet(projectRoot, { staged: options.staged === true }), {
|
|
122
|
+
config: declarativeGuardrailConfig(config)
|
|
123
|
+
});
|
|
124
|
+
for (const item of guardrailEvaluation.blocked) {
|
|
125
|
+
findings.push(finding("high", "DECLARATIVE_GUARDRAIL_BLOCK", `${item.guardrail}: ${item.message} Evidence: ${item.evidence.join(", ")}.`, guardrailsFile));
|
|
126
|
+
}
|
|
127
|
+
for (const item of guardrailEvaluation.warnings) {
|
|
128
|
+
findings.push(finding("warning", "DECLARATIVE_GUARDRAIL_WARN", `${item.guardrail}: ${item.message} Evidence: ${item.evidence.join(", ")}.`, guardrailsFile));
|
|
129
|
+
}
|
|
114
130
|
|
|
115
131
|
const repository = new ArtifactRepository(scrumDir);
|
|
116
132
|
const counts = {};
|
|
@@ -141,9 +157,9 @@ function auditProject(projectRoot) {
|
|
|
141
157
|
task = null;
|
|
142
158
|
}
|
|
143
159
|
}
|
|
144
|
-
if (run.record && !run.record.task) {
|
|
145
|
-
findings.push(finding("high", "
|
|
146
|
-
} else if (run.record && (!task || task.errors.length)) {
|
|
160
|
+
if (run.record && !run.record.task && !run.record.sprint) {
|
|
161
|
+
findings.push(finding("high", "RUN_TARGET_MISSING", `${run.record.id} must reference a Task or Sprint.`, run.file));
|
|
162
|
+
} else if (run.record && run.record.task && (!task || task.errors.length)) {
|
|
147
163
|
findings.push(finding("high", "RUN_TASK_MISSING", `${run.record.id} references missing or invalid ${run.record.task}.`, run.file));
|
|
148
164
|
}
|
|
149
165
|
if (run.record && task && !task.errors.length && (run.record.sprint || null) !== (task.record.sprint || null)) {
|
|
@@ -170,6 +186,7 @@ function auditProject(projectRoot) {
|
|
|
170
186
|
}
|
|
171
187
|
}
|
|
172
188
|
const byId = new Map(Object.values(records).flat().filter((artifact) => artifact.record).map((artifact) => [artifact.record.id, artifact]));
|
|
189
|
+
const markdownFirst = methodMarker && methodMarker.workflow && methodMarker.workflow.daily === "markdown-first";
|
|
173
190
|
for (const task of records.task || []) {
|
|
174
191
|
for (const [field, prefix] of [["feature", "FEAT"], ["sprint", "SPRINT"]]) {
|
|
175
192
|
const target = task.record && task.record[field];
|
|
@@ -182,7 +199,7 @@ function auditProject(projectRoot) {
|
|
|
182
199
|
}
|
|
183
200
|
const attemptsByTask = new Map();
|
|
184
201
|
for (const run of records.run || []) {
|
|
185
|
-
if (!run.record || run.errors.length) continue;
|
|
202
|
+
if (!run.record || run.errors.length || !run.record.task) continue;
|
|
186
203
|
if (!attemptsByTask.has(run.record.task)) attemptsByTask.set(run.record.task, []);
|
|
187
204
|
attemptsByTask.get(run.record.task).push(run.record.attempt);
|
|
188
205
|
}
|
|
@@ -199,11 +216,14 @@ function auditProject(projectRoot) {
|
|
|
199
216
|
.filter((run) => run.record && !run.errors.length && run.record.task === task.record.id)
|
|
200
217
|
.sort((left, right) => right.record.attempt - left.record.attempt);
|
|
201
218
|
if (!attempts.length) {
|
|
219
|
+
if (markdownFirst) continue;
|
|
202
220
|
if (["running", "validating", "learning", "partial"].includes(task.record.status)) {
|
|
203
221
|
findings.push(finding(
|
|
204
|
-
"high",
|
|
222
|
+
markdownFirst ? "warning" : "high",
|
|
205
223
|
"TASK_ORPHANED",
|
|
206
|
-
|
|
224
|
+
markdownFirst
|
|
225
|
+
? `${task.record.id}.status is ${task.record.status} without a Run. This is valid Markdown-first work; keep the Task handoff current.`
|
|
226
|
+
: `${task.record.id}.status is ${task.record.status}, but no canonical Run references it. Review with \`scrumrun repair --recover-orphan-tasks\`, then apply the explicit recovery and start the Task to create its first Run.`,
|
|
207
227
|
task.file
|
|
208
228
|
));
|
|
209
229
|
}
|
|
@@ -219,17 +239,13 @@ function auditProject(projectRoot) {
|
|
|
219
239
|
blocked: "blocked",
|
|
220
240
|
partial: "partial"
|
|
221
241
|
};
|
|
222
|
-
if (taskStatuses[latest.status] && task.record.status !== taskStatuses[latest.status]) {
|
|
242
|
+
if (!markdownFirst && taskStatuses[latest.status] && task.record.status !== taskStatuses[latest.status]) {
|
|
223
243
|
findings.push(finding("high", "TASK_RUN_STATUS_MISMATCH", `${task.record.id}.status ${task.record.status} disagrees with latest ${latest.id}.status ${latest.status}.`, task.file));
|
|
224
244
|
}
|
|
225
245
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (!/^## Acceptance Criteria\b/m.test(task.body || "")) {
|
|
230
|
-
findings.push(finding("warning", "ACCEPTANCE_CRITERIA_MISSING", `${task.record.id} has no Acceptance Criteria section; define what "done" means before execution.`, task.file));
|
|
231
|
-
}
|
|
232
|
-
}
|
|
246
|
+
// Task bodies are intentionally extensible Markdown. Project Guardrails may
|
|
247
|
+
// require sections for selected Tasks; the kernel never requires one global
|
|
248
|
+
// heading or rejects unknown owner-defined structure.
|
|
233
249
|
for (const sprint of records.sprint || []) {
|
|
234
250
|
if (!sprint.record || sprint.errors.length) continue;
|
|
235
251
|
const heading = /^## Tasks[ \t]*$/m.exec(sprint.body);
|
|
@@ -286,6 +302,17 @@ function auditProject(projectRoot) {
|
|
|
286
302
|
} catch (error) {
|
|
287
303
|
findings.push(finding("high", "INDEX_UNSAFE", `semantic index cannot inspect canonical sources safely: ${error.message}`));
|
|
288
304
|
}
|
|
305
|
+
const allArtifacts = Object.values(records).flat();
|
|
306
|
+
const gitContext = options.gitContext || readGitContext(projectRoot);
|
|
307
|
+
const taskSchemaReport = validateTaskSchema(allArtifacts, { gitContext });
|
|
308
|
+
const schemaErrorSeverity = options.strict ? "high" : "warning";
|
|
309
|
+
for (const error of taskSchemaReport.errors) {
|
|
310
|
+
findings.push(finding(schemaErrorSeverity, error.code, error.message, error.file));
|
|
311
|
+
}
|
|
312
|
+
for (const warning of taskSchemaReport.warnings) {
|
|
313
|
+
findings.push(finding("warning", warning.code, warning.message, warning.file));
|
|
314
|
+
}
|
|
315
|
+
|
|
289
316
|
const transactions = pendingTransactionStatus(scrumDir);
|
|
290
317
|
if (transactions.error) {
|
|
291
318
|
findings.push(finding("high", "TRANSACTION_JOURNAL_UNSAFE", transactions.error));
|
package/lib/v2/paths.js
CHANGED
|
@@ -56,10 +56,11 @@ function flattenPaths(paths = CANONICAL_PATHS, prefix = "") {
|
|
|
56
56
|
return out;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function renderMethodJson({ methodVersion, layout = "v2", schemas = {}, migratedFrom, migration } = {}) {
|
|
59
|
+
function renderMethodJson({ methodVersion, layout = "v2", schemas = {}, workflow = { daily: "markdown-first" }, migratedFrom, migration } = {}) {
|
|
60
60
|
const payload = {
|
|
61
61
|
method: methodVersion,
|
|
62
62
|
layout,
|
|
63
|
+
workflow,
|
|
63
64
|
paths_schema: PATHS_SCHEMA_VERSION,
|
|
64
65
|
paths: canonicalPaths(),
|
|
65
66
|
schemas
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
|
|
7
|
+
const RUNS_JSONL_SCHEMA = 1;
|
|
8
|
+
const ALLOWED_EVENTS = Object.freeze(["created", "started", "transition", "note", "evidence", "guardrail", "mutation", "completed", "failed", "blocked"]);
|
|
9
|
+
const GENESIS_HASH = "0".repeat(64);
|
|
10
|
+
|
|
11
|
+
function sha256Hex(input) {
|
|
12
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function jsonlPath(scrumDir, taskId) {
|
|
16
|
+
return path.join(scrumDir, "runs", `${taskId}.jsonl`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertTaskId(taskId) {
|
|
20
|
+
if (!/^TASK-\d{3,}$/.test(String(taskId || ""))) throw new Error(`Invalid TASK id for runs.jsonl: ${taskId}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function canonicalizePayload(payload) {
|
|
24
|
+
if (payload === null || payload === undefined) return {};
|
|
25
|
+
if (typeof payload !== "object" || Array.isArray(payload)) throw new Error("payload must be a plain object");
|
|
26
|
+
const sorted = {};
|
|
27
|
+
for (const key of Object.keys(payload).sort()) sorted[key] = payload[key];
|
|
28
|
+
return sorted;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function computeHash(previousHash, entry) {
|
|
32
|
+
const material = JSON.stringify({ prev: previousHash, run_id: entry.run_id, ts: entry.ts, event: entry.event, payload: entry.payload });
|
|
33
|
+
return sha256Hex(material);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readEntries(scrumDir, taskId) {
|
|
37
|
+
assertTaskId(taskId);
|
|
38
|
+
const file = jsonlPath(scrumDir, taskId);
|
|
39
|
+
if (!fs.existsSync(file)) return [];
|
|
40
|
+
const contents = fs.readFileSync(file, "utf8");
|
|
41
|
+
if (!contents) return [];
|
|
42
|
+
const entries = [];
|
|
43
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
44
|
+
const line = rawLine.trim();
|
|
45
|
+
if (!line) continue;
|
|
46
|
+
entries.push(JSON.parse(line));
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateEntries(entries) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
let previous = GENESIS_HASH;
|
|
54
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
55
|
+
const entry = entries[index];
|
|
56
|
+
if (!entry || typeof entry !== "object") { errors.push(`entry ${index}: not an object`); continue; }
|
|
57
|
+
for (const field of ["run_id", "ts", "event", "payload", "prev", "hash"]) {
|
|
58
|
+
if (!(field in entry)) errors.push(`entry ${index}: missing ${field}`);
|
|
59
|
+
}
|
|
60
|
+
if (entry.prev !== previous) errors.push(`entry ${index}: prev ${entry.prev} != expected ${previous}`);
|
|
61
|
+
if (!ALLOWED_EVENTS.includes(entry.event)) errors.push(`entry ${index}: unknown event ${entry.event}`);
|
|
62
|
+
const expectedHash = computeHash(previous, entry);
|
|
63
|
+
if (entry.hash !== expectedHash) errors.push(`entry ${index}: hash ${entry.hash} != expected ${expectedHash}`);
|
|
64
|
+
previous = entry.hash || previous;
|
|
65
|
+
}
|
|
66
|
+
return { errors, headHash: previous };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function appendEntry(scrumDir, taskId, { run_id, ts, event, payload }) {
|
|
70
|
+
assertTaskId(taskId);
|
|
71
|
+
if (!run_id || !/^RUN-\d{3,}$/.test(String(run_id))) throw new Error(`Invalid run_id: ${run_id}`);
|
|
72
|
+
if (!ts || Number.isNaN(Date.parse(ts))) throw new Error(`Invalid ts (ISO-8601 required): ${ts}`);
|
|
73
|
+
if (!ALLOWED_EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`);
|
|
74
|
+
const canonicalPayload = canonicalizePayload(payload);
|
|
75
|
+
|
|
76
|
+
const file = jsonlPath(scrumDir, taskId);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
|
|
79
|
+
// Concurrency: exclusive-create a lockfile; retry with jittered backoff.
|
|
80
|
+
const lock = `${file}.lock`;
|
|
81
|
+
const deadline = Date.now() + 5000;
|
|
82
|
+
let handle = null;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
try {
|
|
85
|
+
handle = fs.openSync(lock, "wx");
|
|
86
|
+
break;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== "EEXIST") throw error;
|
|
89
|
+
const delay = 5 + Math.floor(Math.random() * 15);
|
|
90
|
+
const busyUntil = Date.now() + delay;
|
|
91
|
+
while (Date.now() < busyUntil) { /* spin briefly */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (handle === null) throw new Error(`Could not acquire lock on ${lock} within 5s`);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const entries = readEntries(scrumDir, taskId);
|
|
98
|
+
const previous = entries.length ? entries[entries.length - 1].hash : GENESIS_HASH;
|
|
99
|
+
const record = { run_id, ts, event, payload: canonicalPayload, prev: previous };
|
|
100
|
+
record.hash = computeHash(previous, record);
|
|
101
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
102
|
+
fs.appendFileSync(file, line);
|
|
103
|
+
return record;
|
|
104
|
+
} finally {
|
|
105
|
+
try { fs.closeSync(handle); } catch { /* ignore */ }
|
|
106
|
+
try { fs.unlinkSync(lock); } catch { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function listRunIds(scrumDir, taskId) {
|
|
111
|
+
const entries = readEntries(scrumDir, taskId);
|
|
112
|
+
return [...new Set(entries.map((entry) => entry.run_id))];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function listAllTaskLedgers(scrumDir) {
|
|
116
|
+
const dir = path.join(scrumDir, "runs");
|
|
117
|
+
if (!fs.existsSync(dir)) return [];
|
|
118
|
+
return fs.readdirSync(dir)
|
|
119
|
+
.filter((name) => /^TASK-\d{3,}\.jsonl$/.test(name))
|
|
120
|
+
.map((name) => name.replace(/\.jsonl$/, ""));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
RUNS_JSONL_SCHEMA,
|
|
125
|
+
ALLOWED_EVENTS,
|
|
126
|
+
GENESIS_HASH,
|
|
127
|
+
jsonlPath,
|
|
128
|
+
readEntries,
|
|
129
|
+
validateEntries,
|
|
130
|
+
appendEntry,
|
|
131
|
+
listRunIds,
|
|
132
|
+
listAllTaskLedgers,
|
|
133
|
+
computeHash
|
|
134
|
+
};
|
package/lib/v2/schema.js
CHANGED
|
@@ -38,8 +38,8 @@ const ARTIFACT_TYPES = deepFreeze({
|
|
|
38
38
|
task: {
|
|
39
39
|
prefix: "TASK",
|
|
40
40
|
directory: "tasks",
|
|
41
|
-
initial: ["backlog", "proposed", "running"],
|
|
42
|
-
statuses: ["backlog", "proposed", "running", "validating", "learning", "partial", "completed", "failed", "blocked", "cancelled"]
|
|
41
|
+
initial: ["backlog", "proposed", "in_progress", "running"],
|
|
42
|
+
statuses: ["backlog", "proposed", "in_progress", "running", "validating", "learning", "partial", "completed", "failed", "blocked", "cancelled"]
|
|
43
43
|
},
|
|
44
44
|
sprint: {
|
|
45
45
|
prefix: "SPRINT",
|
|
@@ -90,7 +90,7 @@ const ARTIFACT_TRANSITIONS = deepFreeze({
|
|
|
90
90
|
backlog: ["proposed", "active", "cancelled"], proposed: ["active", "cancelled"], active: ["paused", "completed", "cancelled"], paused: ["active", "cancelled"]
|
|
91
91
|
},
|
|
92
92
|
task: {
|
|
93
|
-
backlog: ["proposed", "running", "cancelled"], proposed: ["running", "cancelled"], running: ["validating", "failed", "blocked", "cancelled"], validating: ["learning", "failed", "blocked"], learning: ["completed", "failed", "blocked"], partial: ["running", "cancelled"], failed: ["running", "cancelled"], blocked: ["running", "cancelled"]
|
|
93
|
+
backlog: ["proposed", "in_progress", "running", "cancelled"], proposed: ["in_progress", "running", "cancelled"], in_progress: ["completed", "failed", "blocked", "cancelled"], running: ["validating", "completed", "failed", "blocked", "cancelled"], validating: ["learning", "completed", "failed", "blocked"], learning: ["completed", "failed", "blocked"], partial: ["in_progress", "running", "cancelled"], failed: ["in_progress", "running", "cancelled"], blocked: ["in_progress", "running", "cancelled"]
|
|
94
94
|
},
|
|
95
95
|
sprint: {
|
|
96
96
|
proposed: ["running", "cancelled"], running: ["partial", "completed", "blocked", "cancelled"], partial: ["running", "completed", "cancelled"], blocked: ["running", "cancelled"]
|
|
@@ -108,7 +108,7 @@ const ARTIFACT_TRANSITIONS = deepFreeze({
|
|
|
108
108
|
const STRUCTURAL_RELATIONS = deepFreeze({
|
|
109
109
|
feature: { targetKind: "feature", cardinality: "0..1", meaning: "long-lived initiative containing the artifact" },
|
|
110
110
|
sprint: { targetKind: "sprint", cardinality: "0..1", meaning: "optional delivery batch containing a Task or Run" },
|
|
111
|
-
task: { targetKind: "task", cardinality: "
|
|
111
|
+
task: { targetKind: "task", cardinality: "0..1", meaning: "atomic work executed or reviewed by the artifact" }
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
const SCALAR_FIELDS = deepFreeze({
|
|
@@ -120,9 +120,9 @@ const SCALAR_FIELDS = deepFreeze({
|
|
|
120
120
|
|
|
121
121
|
const TRUTH_OWNERSHIP = deepFreeze({
|
|
122
122
|
feature: { question: "Why does this initiative exist?", truth: "initiative purpose, scope, dependencies, and lifecycle" },
|
|
123
|
-
task: { question: "What
|
|
124
|
-
sprint: { question: "
|
|
125
|
-
run: { question: "
|
|
123
|
+
task: { question: "What concrete outcome is intended?", truth: "scope, owner-defined sections, links, and status" },
|
|
124
|
+
sprint: { question: "Which Tasks are grouped for this delivery?", truth: "timebox or delivery-batch membership; may be feature, fix, or maintenance" },
|
|
125
|
+
run: { question: "What happened while executing a Task or Sprint?", truth: "optional human-readable execution record and outcome" },
|
|
126
126
|
review: { question: "What independent validation was performed?", truth: "scoped findings, checks, evidence, and verdict" },
|
|
127
127
|
knowledge: { question: "What verified project fact is reusable?", truth: "approved evidence-backed fact and validity" },
|
|
128
128
|
decision: { question: "What normative choice constrains future work?", truth: "decision, rationale, validity, and lifecycle" },
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const REQUIRED_SECTIONS = Object.freeze({
|
|
4
|
+
task: ["## Request", "## Done when"],
|
|
5
|
+
feature: [],
|
|
6
|
+
sprint: [],
|
|
7
|
+
run: []
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const COMPLETION_MIN_LENGTH = 24;
|
|
11
|
+
|
|
12
|
+
function findSectionLine(lines, heading) {
|
|
13
|
+
const target = heading.trim().toLowerCase();
|
|
14
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
15
|
+
if (lines[index].trim().toLowerCase() === target) return index + 1;
|
|
16
|
+
}
|
|
17
|
+
return -1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sectionBody(lines, heading) {
|
|
21
|
+
const start = findSectionLine(lines, heading);
|
|
22
|
+
if (start === -1) return null;
|
|
23
|
+
const body = [];
|
|
24
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
25
|
+
const line = lines[index];
|
|
26
|
+
if (/^## /.test(line) && index !== start - 1) break;
|
|
27
|
+
if (index !== start - 1) body.push(line);
|
|
28
|
+
}
|
|
29
|
+
return body.join("\n").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bodyLines(bodyOrSource) {
|
|
33
|
+
const text = String(bodyOrSource || "");
|
|
34
|
+
const lines = text.split(/\r?\n/);
|
|
35
|
+
if (lines[0] !== "---") return lines;
|
|
36
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
37
|
+
if (lines[index] === "---") return lines.slice(index + 1);
|
|
38
|
+
}
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function completionSatisfied(taskBody, runs = []) {
|
|
43
|
+
const body = sectionBody(bodyLines(taskBody), "## Completion");
|
|
44
|
+
if (body && body.replace(/\s+/g, " ").length >= COMPLETION_MIN_LENGTH) return { ok: true, via: "section" };
|
|
45
|
+
for (const run of runs) {
|
|
46
|
+
const runBody = String(run.body || "");
|
|
47
|
+
const summary = sectionBody(bodyLines(runBody), "## Technical Summary");
|
|
48
|
+
if (summary && summary.replace(/\s+/g, " ").length >= COMPLETION_MIN_LENGTH) {
|
|
49
|
+
return { ok: true, via: `run:${run.id || "unknown"}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { ok: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateTaskArtifact(artifact, options = {}) {
|
|
56
|
+
const errors = [];
|
|
57
|
+
const warnings = [];
|
|
58
|
+
if (!artifact || !artifact.record) return { errors, warnings };
|
|
59
|
+
const record = artifact.record;
|
|
60
|
+
const source = artifact.body != null ? artifact.body : (artifact.source || "");
|
|
61
|
+
const lines = bodyLines(source);
|
|
62
|
+
const required = REQUIRED_SECTIONS[record.kind] || [];
|
|
63
|
+
const schemaOptIn = Number(record.task_schema || 0) >= 1;
|
|
64
|
+
if (schemaOptIn) {
|
|
65
|
+
for (const heading of required) {
|
|
66
|
+
if (findSectionLine(lines, heading) === -1) {
|
|
67
|
+
errors.push({
|
|
68
|
+
code: "SR-E-452",
|
|
69
|
+
message: `${record.id}: missing required section "${heading}".`,
|
|
70
|
+
file: artifact.file || null,
|
|
71
|
+
line: null
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (schemaOptIn && record.kind === "task" && record.status === "completed") {
|
|
77
|
+
const runs = options.runsByTask ? options.runsByTask[record.id] || [] : [];
|
|
78
|
+
const result = completionSatisfied(source, runs);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
const line = findSectionLine(lines, "## Completion");
|
|
81
|
+
errors.push({
|
|
82
|
+
code: "SR-E-453",
|
|
83
|
+
message: `${record.id} is completed but lacks a non-empty "## Completion" (min ${COMPLETION_MIN_LENGTH} chars) and no associated Run carries a "## Technical Summary".`,
|
|
84
|
+
file: artifact.file || null,
|
|
85
|
+
line: line === -1 ? null : line
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (schemaOptIn && record.kind === "task" && ["running", "in_progress", "validating", "learning"].includes(record.status)) {
|
|
90
|
+
const git = options.gitContext;
|
|
91
|
+
if (git && git.isRepo) {
|
|
92
|
+
const branch = record.branch || (record.git && record.git.branch);
|
|
93
|
+
if (!branch) {
|
|
94
|
+
warnings.push({
|
|
95
|
+
code: "SR-E-454",
|
|
96
|
+
message: `${record.id} is ${record.status} inside a git repository but has no git.branch recorded in frontmatter. Reconcile with \`scrumrun repair --apply\` or add \`git: { branch, base_sha }\`.`,
|
|
97
|
+
file: artifact.file || null
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { errors, warnings };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateArtifacts(artifacts, options = {}) {
|
|
106
|
+
const runsByTask = {};
|
|
107
|
+
for (const artifact of artifacts) {
|
|
108
|
+
if (!artifact || !artifact.record) continue;
|
|
109
|
+
if (artifact.record.kind !== "run") continue;
|
|
110
|
+
const taskId = artifact.record.task;
|
|
111
|
+
if (!taskId) continue;
|
|
112
|
+
if (!runsByTask[taskId]) runsByTask[taskId] = [];
|
|
113
|
+
runsByTask[taskId].push({ id: artifact.record.id, body: (artifact.body != null ? artifact.body : (artifact.source || "")) });
|
|
114
|
+
}
|
|
115
|
+
const errors = [];
|
|
116
|
+
const warnings = [];
|
|
117
|
+
for (const artifact of artifacts) {
|
|
118
|
+
const result = validateTaskArtifact(artifact, { ...options, runsByTask });
|
|
119
|
+
errors.push(...result.errors);
|
|
120
|
+
warnings.push(...result.warnings);
|
|
121
|
+
}
|
|
122
|
+
return { errors, warnings };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
REQUIRED_SECTIONS,
|
|
127
|
+
COMPLETION_MIN_LENGTH,
|
|
128
|
+
validateTaskArtifact,
|
|
129
|
+
validateArtifacts,
|
|
130
|
+
findSectionLine,
|
|
131
|
+
sectionBody,
|
|
132
|
+
bodyLines
|
|
133
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrumrun",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
|
+
"description": "Markdown-first Agile memory and guardrails for AI coding agents.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"scrumrun": "bin/scrumrun.js",
|
|
7
7
|
"sr-claude": "bin/claude-install.js"
|
|
@@ -69,7 +69,7 @@ Every artifact also requires \`id\`, \`kind\`, \`status\`, \`created\`, \`update
|
|
|
69
69
|
|---|---|---|---|
|
|
70
70
|
${relationRows.join("\n")}
|
|
71
71
|
|
|
72
|
-
Task is the atomic unit. A Task may have zero or one Sprint. A
|
|
72
|
+
Task is the atomic unit. A Task may have zero or one Sprint. A Run is optional Markdown history and may reference either a Task or a Sprint; it never controls Task status in Markdown-first work. Sprint membership is authoritative on \`Task.sprint\`; a Sprint's \`## Tasks\` list is a human-readable projection that must agree with it. Additional owner-defined relations such as \`depends_on: [TASK-014, DEC-018]\` are preserved as local Markdown graph data.
|
|
73
73
|
|
|
74
74
|
## Scalar constraints
|
|
75
75
|
|
|
@@ -85,7 +85,7 @@ Newly authored Runs use \`ledger: ${RUN_LEDGER_VERSION}\`. Their \`## Events\` s
|
|
|
85
85
|
|
|
86
86
|
Every event requires \`schema\`, \`id\`, contiguous \`sequence\`, RFC3339 \`occurred_at\`, \`timestamp_precision\`, \`actor\`, \`from\`, \`to\`, \`reason\`, and structured \`evidence\`. Event types are ${RUN_EVENT_TYPES.map((value) => `\`${value}\``).join(", ")}. Evidence kinds are ${RUN_EVIDENCE_KINDS.map((value) => `\`${value}\``).join(", ")}.
|
|
87
87
|
|
|
88
|
-
A native ledger begins with \`created → executing\`; an evidenced migration \`snapshot\` may establish one historical baseline without inventing missing transitions. Event order, transition legality, final status, updated date, and completion evidence are machine-validated. Run owns
|
|
88
|
+
A native ledger begins with \`created → executing\`; an evidenced migration \`snapshot\` may establish one historical baseline without inventing missing transitions. Event order, transition legality, final status, updated date, and completion evidence are machine-validated when a strict audit Run is used. Run owns optional event history; Task Markdown owns the delivered scope and status in normal work.
|
|
89
89
|
|
|
90
90
|
## Truth questions
|
|
91
91
|
|
|
@@ -97,6 +97,10 @@ ${Object.entries(TRUTH_OWNERSHIP).map(([kind, owner]) => `- **${kind}:** ${owner
|
|
|
97
97
|
|---|---|
|
|
98
98
|
${lifecycleRows.join("\n")}
|
|
99
99
|
|
|
100
|
+
## Declarative Guardrail enforcement
|
|
101
|
+
|
|
102
|
+
Project Guardrails may include an optional fenced \`yaml enforcement\` block. Its restricted, dependency-free YAML schema is defined normatively in \`SPEC.md §6.1\`: \`match.paths[]\`, \`match.diff[]\`, \`match.symbols[]\`, \`on_violation\` (\`block\` or \`warn\`), \`severity\`, and optional \`evidence\`. The pure evaluator consumes only that normalized rule data and a supplied ChangeSet; it has no network or LLM dependency. Prose-only Guardrails remain valid.
|
|
103
|
+
|
|
100
104
|
## Projections
|
|
101
105
|
|
|
102
106
|
\`state.md\`, \`map.md\`, context packages, and \`.cache/\` are disposable. They may summarize or index canonical artifacts, but they cannot introduce status, policy, relations, decisions, or knowledge.
|
|
@@ -7,7 +7,16 @@ Interaction Mode: guided
|
|
|
7
7
|
Execution Approval: always
|
|
8
8
|
Quick Tasks: ask
|
|
9
9
|
Agent Identity: agent
|
|
10
|
+
watcher.enabled: false
|
|
11
|
+
watcher.debounce_ms: 250
|
|
12
|
+
watcher.poll_interval_ms: 1500
|
|
13
|
+
memory.compaction.threshold: 0.6
|
|
14
|
+
memory.compaction.min_members: 3
|
|
10
15
|
|
|
11
16
|
These are operating preferences. They can never weaken `.scrumrun/guardrails.md`.
|
|
12
17
|
|
|
13
18
|
`Agent Identity` is the default agent name recorded as the Task `assignee` and Run event `actor`. In shared teams, prefer the per-agent `SCRUMRUN_AGENT` environment variable over this project-wide default.
|
|
19
|
+
|
|
20
|
+
`watcher.enabled` is opt-in. When enabled, `scrumrun config watch --start` keeps only generated `state.md`, `map.md`, and `.cache/` projections fresh; it never writes canonical Markdown.
|
|
21
|
+
|
|
22
|
+
`memory.compaction.*` controls an explicitly invoked, deterministic Dossier suggestion. It never calls an LLM, runs automatically, or deletes memory records.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AGENTS.md - {{PROJECT_NAME}}
|
|
2
2
|
|
|
3
|
-
## ScrumRun
|
|
3
|
+
## ScrumRun 4.0 — execution-first Markdown
|
|
4
4
|
|
|
5
5
|
This project uses ScrumRun. The method is mandatory; the direct CLI is `scrumrun <noun> <subject> <action>`. `/sc` is only an optional client shortcut.
|
|
6
6
|
|
|
@@ -15,18 +15,24 @@ Natural-language product requests begin with a read-only understanding pass. Bef
|
|
|
15
15
|
|
|
16
16
|
After approval:
|
|
17
17
|
|
|
18
|
-
- Task is the
|
|
18
|
+
- execute continuously until the approved Task is delivered: keep working through discover → implement → verify → fix → verify. A progress report is allowed only when the owner asks for status and must be followed immediately by more execution; it never closes the workflow. Do not stop to send an inventory, a progress report, a decomposition, or a list of work still to do. A missing implementation found during the work remains work to do now, not a follow-up, a “next step”, or a reason to reply. Stop only for an owner decision, external access, an explicit Guardrail, a security/secret risk, destructive work without approval, or an unmet required Acceptance Criterion;
|
|
19
|
+
- Task is the atomic work item; create or refine its Markdown directly, define a short `## Done when` delivery contract, and retain a short `## Completion` / `## Follow-ups` handoff;
|
|
20
|
+
- you may decompose implementation privately or add linked child Tasks when needed, but do not make the owner manage that decomposition and do not stop after planning it;
|
|
19
21
|
- validation is scoped: only a test/review/environment explicitly required by the owner, the Task's Acceptance Criteria, or an active Guardrail can block completion; a missing optional E2E suite is a documented follow-up/risk, never a reason to fail an otherwise accepted Task;
|
|
20
22
|
- Feature and Sprint remain useful organization, but are optional; create them only when they clarify real initiative or timebox context;
|
|
21
23
|
- Sprint is only a real timebox/batch of Tasks;
|
|
22
24
|
- Run is an optional audit/handoff record, never an administrative prerequisite to start, amend, or complete a Task; preserve useful prior attempts but do not let missing/invalid Run metadata stop work;
|
|
23
|
-
-
|
|
25
|
+
- only claim a condition validated when the check actually covers that condition; a narrow checker never proves a broad delivery claim. Record a `## Completion` at completion so the next agent inherits what was done; `## Follow-ups` may contain only work outside the approved `## Done when`, never unfinished acceptance work;
|
|
24
26
|
- work directly in code and Task Markdown after approval; do not call `npx scrumrun@latest` or normal `scrumrun plan/run` commands during execution;
|
|
25
27
|
- use the CLI only for `init`, `update --project`, `migrate`, `repair`, `doctor`, reports, or release checks. It audits/repairs the folder; it does not own the daily workflow;
|
|
26
28
|
- never invoke `plan run --fail`, `--block`, `--retry`, `--finalize`, `--complete`, `--validate`, or `plan task --start` in normal work. A failed legacy Run due to administrative state remains historical; write the corrected delivery outcome directly in the Task instead;
|
|
27
|
-
- learning proposes evidence-backed Knowledge, Decisions, or candidate Insights when the
|
|
29
|
+
- learning proposes evidence-backed Knowledge, Decisions, or candidate Insights after delivery, or only when it materially helps the current implementation; it never interrupts execution;
|
|
28
30
|
- guardrails remain mandatory: stop only for an explicit Guardrail, security/secret risk, destructive action without approval, or an unmet required Acceptance Criterion. Status vocabulary, missing Runs, unavailable optional tests, and stale generated state are warnings to reconcile, not blockers.
|
|
29
31
|
|
|
32
|
+
`core.md` and `guardrails.md` are sealed policy. Never edit either during a product Task. A policy change requires an explicit owner request; after review, seal it at the maintenance edge with `scrumrun update --project --seal-policy`.
|
|
33
|
+
|
|
34
|
+
Artifacts are local Markdown connected by stable IDs and relative links. Preserve any owner-defined frontmatter and sections in a Task. A Guardrail may require sections such as `## Migration Plan`, `## Rollback`, or `## Guardrail Evidence`; add them only to the affected Task.
|
|
35
|
+
|
|
30
36
|
Never bypass guardrails, overwrite owner work, treat generated state/cache as truth, auto-confirm AI knowledge, auto-migrate a v1 project, or print vault values.
|
|
31
37
|
|
|
32
38
|
Never use `npx scrumrun@latest` in the normal work loop. If the installed CLI is unavailable, stop and report that blocker rather than substituting a network command.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AGENTS.md - {{PROJECT_NAME}}
|
|
2
2
|
|
|
3
|
-
## ScrumRun
|
|
3
|
+
## ScrumRun 4.0 lean read policy — execution-first Markdown
|
|
4
4
|
|
|
5
5
|
This project stores the complete ScrumRun v2 truth but uses a bounded default read path:
|
|
6
6
|
|
|
@@ -11,12 +11,16 @@ This project stores the complete ScrumRun v2 truth but uses a bounded default re
|
|
|
11
11
|
|
|
12
12
|
Do not scan every Task, Run, Sprint, Feature, or Memory file by default. Generated `state.md`, `map.md`, and `.cache/` guide retrieval but never override canonical Markdown.
|
|
13
13
|
|
|
14
|
-
Natural-language product work begins as a read-only understanding pass. Explicit approval authorizes direct work in source files and `.scrumrun/` Markdown.
|
|
14
|
+
Natural-language product work begins as a read-only understanding pass. Explicit approval authorizes direct work in source files and `.scrumrun/` Markdown. Execute continuously until the approved Task is delivered: keep working through discover → implement → verify → fix → verify. A progress report is allowed only when the owner asks for status and must be followed immediately by more execution; it never closes the workflow. Do not stop to provide an inventory, a partial progress report, or a list of remaining work. A missing implementation found during the work remains work to do now, not a follow-up or “next step”. Define a short `## Done when` delivery contract, record `## Completion` only at the end, and never move unfinished contract work to `## Follow-ups` without explicit owner approval. A Run is optional handoff/audit context, not a state machine that may prevent starting, amending, or completing work. A Sprint exists only for a real batch/timebox.
|
|
15
15
|
|
|
16
16
|
Guardrails are mandatory, but administrative state is not: block only for an explicit Guardrail, security/secret risk, destructive action without approval, or an unmet required Acceptance Criterion. Missing Runs, invalid legacy status vocabulary, stale generated views, and optional unrun tests are warnings to reconcile in Markdown. Record meaningful optional coverage gaps under `## Follow-ups`; they do not fail a delivered Task.
|
|
17
17
|
|
|
18
18
|
`.scrumrun/guardrails.md` is canonical policy. Never bypass it, overwrite owner work, auto-confirm AI knowledge, auto-migrate v1 state, or print vault values.
|
|
19
19
|
|
|
20
|
+
`core.md` and `guardrails.md` are sealed policy. Never edit them during a product Task. A policy change requires an explicit owner request and `scrumrun update --project --seal-policy` after review.
|
|
21
|
+
|
|
22
|
+
Artifacts are local Markdown connected by stable IDs and relative links. Preserve owner-defined frontmatter and sections in each Task; Guardrails may require a section only on affected Tasks.
|
|
23
|
+
|
|
20
24
|
Use the installed CLI only for `init`, `update --project`, `migrate`, `repair`, `doctor`, reports, and release checks. For daily product work, follow `.scrumrun/core.md` and edit the relevant Markdown directly.
|
|
21
25
|
|
|
22
26
|
Never invoke `plan run --fail`, `--block`, `--retry`, `--finalize`, `--complete`, `--validate`, or `plan task --start` during normal work. If an old Run says failed for an administrative reason, leave it as history and record the actual delivered outcome in the Task's Technical Summary and Follow-ups.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Optional local hook. Copy to .git/hooks/pre-commit and chmod +x it.
|
|
3
|
+
# It is intentionally offline: the evaluator reads only the staged Git diff.
|
|
4
|
+
#
|
|
5
|
+
# Cross-platform notes:
|
|
6
|
+
# - macOS / Linux: works out of the box.
|
|
7
|
+
# - Windows: Git for Windows ships an embedded POSIX shell, so this hook
|
|
8
|
+
# runs as-is when committing from any git client (Git Bash, VS Code,
|
|
9
|
+
# JetBrains, GitHub Desktop). No `chmod` is needed on NTFS.
|
|
10
|
+
set -eu
|
|
11
|
+
|
|
12
|
+
if command -v scrumrun >/dev/null 2>&1; then
|
|
13
|
+
exec scrumrun review artifact --run --staged
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
echo "ScrumRun pre-commit hook skipped: scrumrun is not installed." >&2
|