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.
Files changed (44) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CORE.md +13 -5
  3. package/README.md +73 -16
  4. package/SPEC.md +38 -3
  5. package/bin/scrumrun.js +102 -13
  6. package/docs/COMMANDS.md +17 -7
  7. package/docs/ENTITY-MODEL.md +17 -14
  8. package/docs/ERROR-CODES.md +4 -0
  9. package/docs/QUICKSTART.md +21 -19
  10. package/docs/SCHEMA.md +14 -10
  11. package/docs/SEMANTIC-MEMORY.md +28 -0
  12. package/lib/actions/index.js +81 -0
  13. package/lib/commands/manifest.js +4 -3
  14. package/lib/commands/render.js +3 -2
  15. package/lib/errors.js +4 -0
  16. package/lib/git/context.js +30 -0
  17. package/lib/guardrails/changeset.js +45 -0
  18. package/lib/guardrails/evaluate.js +175 -0
  19. package/lib/memory/compaction.js +289 -0
  20. package/lib/memory/index.js +62 -2
  21. package/lib/migrate/ops.js +92 -0
  22. package/lib/migrate/run.js +108 -0
  23. package/lib/runtime/briefing.js +29 -0
  24. package/lib/runtime/context.js +3 -1
  25. package/lib/runtime/orchestrator.js +4 -4
  26. package/lib/runtime/policy-engine.js +11 -0
  27. package/lib/runtime/policy-integrity.js +83 -0
  28. package/lib/runtime/watcher.js +185 -0
  29. package/lib/v2/artifacts.js +9 -0
  30. package/lib/v2/conformance.js +45 -18
  31. package/lib/v2/paths.js +2 -1
  32. package/lib/v2/runs-jsonl.js +134 -0
  33. package/lib/v2/schema.js +7 -7
  34. package/lib/v2/task-schema.js +133 -0
  35. package/package.json +2 -2
  36. package/scripts/generate-contract-docs.js +6 -2
  37. package/templates/project/.scrumrun/config.md +9 -0
  38. package/templates/project/.scrumrun/method.json +3 -0
  39. package/templates/project/AGENTS.md +10 -4
  40. package/templates/project-lean/AGENTS.md +6 -2
  41. package/templates/shared/hooks/pre-commit +16 -0
  42. package/templates/shared/skills/scrumrun/SKILL.md +11 -7
  43. package/templates/shared/view.html +281 -0
  44. package/types/index.d.ts +1 -1
@@ -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("high", "METHOD_PATHS_MISSING", `method.json must declare a canonical "paths" block so agents navigate by declaration, not by search. Run \`scrumrun update --migrate\` to backfill.`, marker));
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("high", "METHOD_PATHS_DRIFT", `method.json paths[${entry.label}] is ${entry.actual === undefined ? "missing" : `"${entry.actual}"`}; expected "${entry.expected}". Regenerate via \`update --migrate\`.`, marker));
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", "RUN_TASK_MISSING", `${run.record.id} has no task reference (record.task is null).`, run.file));
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
- `${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.`,
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
- for (const task of records.task || []) {
227
- if (!task.record || task.errors.length) continue;
228
- if (["backlog", "proposed", "cancelled"].includes(task.record.status)) continue;
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: "1 for Run; otherwise 0..1", requiredFor: ["run"], meaning: "atomic work executed or reviewed by the artifact" }
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 approved atomic outcome is intended?", truth: "scope, acceptance criteria, approval, and intended status" },
124
- sprint: { question: "When are related Tasks grouped?", truth: "timebox or delivery-batch membership; never execution history" },
125
- run: { question: "How did one execution attempt actually happen?", truth: "append-only execution events, evidence, result, and attempt number" },
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": "3.1.2",
4
- "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
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 Task may have many Runs, but every Run belongs to exactly one Task and has a monotonically increasing attempt number within that Task. Sprint membership is authoritative on \`Task.sprint\`; a Sprint's \`## Tasks\` list is a human-readable projection that must agree with it.
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 the event history; Task stores its intended scope and synchronized current status without copying Run events.
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,9 @@
1
1
  {
2
2
  "method": "2.0.0",
3
3
  "layout": "v2",
4
+ "workflow": {
5
+ "daily": "markdown-first"
6
+ },
4
7
  "paths_schema": 1,
5
8
  "paths": {
6
9
  "guardrails": "guardrails.md",
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md - {{PROJECT_NAME}}
2
2
 
3
- ## ScrumRun 3.1Markdown-first
3
+ ## ScrumRun 4.0execution-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 atomic work item; create or refine its Markdown directly, define its `## Acceptance Criteria`, and retain a short `## Technical Summary` / `## Follow-ups` handoff;
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
- - record a `## Technical Summary` at completion so the next agent inherits what was done; record optional missing coverage in `## Follow-ups`;
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 work reveals reusable context;
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.1 lean read policy — Markdown-first
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. Define the Task's `## Acceptance Criteria` before execution and record a `## Technical Summary` at completion. 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.
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