scrumrun 2.7.7 → 2.7.8

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ScrumRun gives an agent a small command surface and a precise project memory: what should be done, how each attempt happened, which decisions constrain the code, and why the architecture exists in its current form.
6
6
 
7
- **Package:** `2.7.7` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
7
+ **Package:** `2.7.8` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
8
8
 
9
9
  **New here?** Read the [Quickstart](docs/QUICKSTART.md) — first Run in under 10 minutes, no `SPEC.md` reading required. Full docs map in [`docs/INDEX.md`](docs/INDEX.md).
10
10
 
@@ -79,7 +79,7 @@ RECEIVED → CONTEXTUALIZING → POLICY → RISK → CLASSIFICATION
79
79
 
80
80
  The agent may assert the classification (`--type fix|task|feature|docs|discovery`) and attach a short technical preview (`--preview "…"`), rendered with color in the terminal before any Task exists. Nothing canonical is persisted before approval.
81
81
 
82
- Explicit approval atomically creates a Task and a Run. Execution then follows:
82
+ Explicit approval atomically materializes the required plan artifacts and always creates the linked Task and Run. Feature and Sprint artifacts are created automatically when the request calls for them. Execution then follows:
83
83
 
84
84
  ```text
85
85
  EXECUTING → VALIDATING → LEARNING → COMPLETED | FAILED | BLOCKED
@@ -173,7 +173,12 @@ function pipelineDetail(stage, plan) {
173
173
  if (stage === "CLASSIFICATION") {
174
174
  return classificationLabel(plan.classification).toLowerCase();
175
175
  }
176
- if (stage === "AWAITING_APPROVAL") return "";
176
+ if (stage === "PLANNING") {
177
+ return plan.proposal && plan.proposal.materialization ? `will materialize ${plan.proposal.materialization.summary}` : "";
178
+ }
179
+ if (stage === "AWAITING_APPROVAL") {
180
+ return plan.proposal && plan.proposal.materialization ? plan.proposal.materialization.summary : "";
181
+ }
177
182
  if (stage === "BLOCKED") return "policy denied — no token issued";
178
183
  return "";
179
184
  }
@@ -241,7 +246,8 @@ function renderIntake(plan) {
241
246
  } else if (plan.approvalToken) {
242
247
  const command = "scrumrun sc plan intake --approve";
243
248
  const tokenWidth = Math.max(16, width - 8);
244
- lines.push(boxLine(width, ` ${paint(FG.gray, "APPROVE · copy the command below to create Task + Run")}`));
249
+ const materialization = plan.proposal && plan.proposal.materialization ? plan.proposal.materialization.summary : "Task + Run";
250
+ lines.push(boxLine(width, ` ${paint(FG.gray, `APPROVE · copy the command below to create ${materialization}`)}`));
245
251
  lines.push(boxLine(width, ` ${paint(ACID_FG, "$")} ${paint(FG.white, command)}`));
246
252
  for (const chunk of hardWrap(plan.approvalToken, tokenWidth)) {
247
253
  lines.push(boxLine(width, ` ${paint(DIM_ACID_FG, chunk)}`));
@@ -260,6 +266,7 @@ function renderIntakePlain(plan) {
260
266
  lines.push(`State: ${plan.state}`);
261
267
  lines.push(`Classification: ${plan.classification.type} (${plan.classification.reason})`);
262
268
  if (plan.preview) lines.push(`Preview: ${plan.preview}`);
269
+ if (plan.proposal && plan.proposal.materialization) lines.push(`Materialization: ${plan.proposal.materialization.summary}`);
263
270
  lines.push(`Risk: ${plan.risk.level} — ${plan.risk.reasons.join("; ")}`);
264
271
  lines.push(`Policy: ${plan.policy.status} (${plan.policy.checked.length} checked; ${plan.policy.deferred.length} deferred)`);
265
272
  for (const violation of plan.policy.violations || []) lines.push(`BLOCKED: ${violation}`);
@@ -21,9 +21,12 @@ function generateBriefing(scrumDir, repository) {
21
21
  const activeWork = ["feature", "task", "sprint", "run", "review"]
22
22
  .flatMap((kind) => (snapshot.records[kind] || [])
23
23
  .filter((r) => r.id && !TERMINAL.has(r.status))
24
- .map((r) => r.assignee && r.assignee !== "agent"
25
- ? `- ${r.id} | ${r.status} | ${r.assignee} | ${r.title || r.id}`
26
- : `- ${r.id} | ${r.status} | ${r.title || r.id}`))
24
+ .map((r) => {
25
+ const owner = r.assignee && r.assignee !== "agent" ? ` | ${r.assignee}` : "";
26
+ const links = [r.feature ? `feature:${r.feature}` : null, r.sprint ? `sprint:${r.sprint}` : null].filter(Boolean);
27
+ const relation = links.length ? ` | ${links.join(" · ")}` : "";
28
+ return `- ${r.id} | ${r.status}${owner}${relation} | ${r.title || r.id}`;
29
+ }))
27
30
  .slice(0, 5);
28
31
 
29
32
  const completedRuns = (snapshot.records.run || [])
@@ -31,6 +31,7 @@ function artifactSnapshot(scrumDir) {
31
31
  }
32
32
  return artifact.record ? {
33
33
  id: artifact.record.id,
34
+ kind: artifact.record.kind,
34
35
  status: artifact.record.status,
35
36
  title: ((artifact.body || "").match(/^# ([^\r\n]+)/m) || [])[1] || artifact.record.id,
36
37
  task: artifact.record.task || null,
@@ -58,6 +58,97 @@ function recentTechnicalSummaries(scrumDir, runRecords, limit = 5) {
58
58
  return summaries;
59
59
  }
60
60
 
61
+ function latestRunSummaries(scrumDir) {
62
+ const repository = new ArtifactRepository(scrumDir);
63
+ const latestByTask = new Map();
64
+ for (const artifact of repository.list("run")) {
65
+ const record = artifact.record;
66
+ if (!record || record.status !== "completed" || !record.task) continue;
67
+ const current = latestByTask.get(record.task);
68
+ const currentAttempt = current ? Number(current.record.attempt) || 0 : -1;
69
+ const attempt = Number(record.attempt) || 0;
70
+ if (current && (attempt < currentAttempt || (attempt === currentAttempt && (record.updated || "") <= (current.record.updated || "")))) continue;
71
+ latestByTask.set(record.task, artifact);
72
+ }
73
+ return latestByTask;
74
+ }
75
+
76
+ function latestRunSummaryForTask(runByTask, taskId) {
77
+ const artifact = runByTask.get(taskId);
78
+ if (!artifact) return null;
79
+ const summary = extractTechnicalSummary(artifact.body);
80
+ return summary ? { run: artifact.record.id, summary: capped(summary, 500) } : null;
81
+ }
82
+
83
+ function relatedWork(scrumDir, artifacts, activeWork) {
84
+ const runByTask = latestRunSummaries(scrumDir);
85
+ const featureById = new Map((artifacts.records.feature || []).map((record) => [record.id, record]));
86
+ const sprintById = new Map((artifacts.records.sprint || []).map((record) => [record.id, record]));
87
+ const tasksByFeature = new Map();
88
+ const tasksBySprint = new Map();
89
+ for (const task of artifacts.records.task || []) {
90
+ if (task.feature) {
91
+ const list = tasksByFeature.get(task.feature) || [];
92
+ list.push(task);
93
+ tasksByFeature.set(task.feature, list);
94
+ }
95
+ if (task.sprint) {
96
+ const list = tasksBySprint.get(task.sprint) || [];
97
+ list.push(task);
98
+ tasksBySprint.set(task.sprint, list);
99
+ }
100
+ }
101
+ return activeWork.slice(0, 8).map((item) => {
102
+ const related = [];
103
+ if (item.kind === "task") {
104
+ if (item.feature && featureById.has(item.feature)) related.push({ relation: "belongs_to", id: item.feature });
105
+ if (item.sprint && sprintById.has(item.sprint)) related.push({ relation: "included_in", id: item.sprint });
106
+ const latest = latestRunSummaryForTask(runByTask, item.id);
107
+ return {
108
+ id: item.id,
109
+ kind: item.kind,
110
+ status: item.status,
111
+ related,
112
+ latestRun: latest
113
+ };
114
+ }
115
+ if (item.kind === "feature") {
116
+ const linked = tasksByFeature.get(item.id) || [];
117
+ for (const task of linked.slice(0, 5)) {
118
+ related.push({ relation: "contains", id: task.id, status: task.status });
119
+ }
120
+ const summaries = linked.map((task) => latestRunSummaryForTask(runByTask, task.id)).filter(Boolean).slice(0, 3);
121
+ return {
122
+ id: item.id,
123
+ kind: item.kind,
124
+ status: item.status,
125
+ related,
126
+ recentSummaries: summaries
127
+ };
128
+ }
129
+ if (item.kind === "sprint") {
130
+ const linked = tasksBySprint.get(item.id) || [];
131
+ for (const task of linked.slice(0, 5)) {
132
+ related.push({ relation: "groups", id: task.id, status: task.status });
133
+ }
134
+ const summaries = linked.map((task) => latestRunSummaryForTask(runByTask, task.id)).filter(Boolean).slice(0, 3);
135
+ return {
136
+ id: item.id,
137
+ kind: item.kind,
138
+ status: item.status,
139
+ related,
140
+ recentSummaries: summaries
141
+ };
142
+ }
143
+ return {
144
+ id: item.id,
145
+ kind: item.kind,
146
+ status: item.status,
147
+ related
148
+ };
149
+ });
150
+ }
151
+
61
152
  function contextFingerprint(scrumDir, artifactHashes) {
62
153
  return canonicalFingerprint(scrumDir, artifactHashes);
63
154
  }
@@ -85,6 +176,7 @@ function buildContextPackage(projectRoot, request) {
85
176
  const activeWork = ["feature", "task", "sprint", "run"].flatMap((kind) =>
86
177
  (artifacts.records[kind] || []).filter((record) => !["completed", "failed", "cancelled"].includes(record.status))
87
178
  ).slice(0, 50);
179
+ const relatedWorkView = relatedWork(scrumDir, artifacts, activeWork);
88
180
  const missing = [];
89
181
  for (const relative of ["guardrails.md", "config.md", "project.md"]) {
90
182
  if (!fs.existsSync(path.join(scrumDir, relative))) missing.push(relative);
@@ -105,6 +197,7 @@ function buildContextPackage(projectRoot, request) {
105
197
  config,
106
198
  project: project || "[missing]",
107
199
  history: { recentRuns, recentSummaries },
200
+ relatedWork: relatedWorkView,
108
201
  decisions: { open: openDecisions },
109
202
  activeWork,
110
203
  graph: {
@@ -17,7 +17,7 @@ const {
17
17
  const { buildContextPackage } = require("./context");
18
18
  const { agentIdentity, evaluatePolicy } = require("./policy-engine");
19
19
  const { artifactSnapshot, canonicalFingerprint, canonicalWatchSnapshot } = require("./canonical-snapshot");
20
- const { decodeApproval } = require("./request-engine");
20
+ const { decodeApproval, materializeRequest } = require("./request-engine");
21
21
  const { containsSecret } = require("../security/secrets");
22
22
  const { currentBranch } = require("./workspace-state");
23
23
  const { extractLearningCandidates } = require("../code-intel/learning");
@@ -46,6 +46,20 @@ function titleFor(request) {
46
46
  return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
47
47
  }
48
48
 
49
+ function initiativeBody(title, request, created, taskId) {
50
+ return `# ${title}\n\n## Purpose\n\n${request}\n\n## Linked Tasks\n\n- ${taskId} — ${title}\n\n## Exit Criteria\n\n- [ ] Define the initiative success condition before completion.\n\n## Source\n\n- ${created}: materialized automatically from approved intake.\n`;
51
+ }
52
+
53
+ function sprintBody(title, request, created, taskId) {
54
+ return `# ${title}\n\n## Timebox\n\n${request}\n\n## Tasks\n\n- ${taskId}\n\n## Exit Gate\n\n- [ ] Define the batch completion condition before completion.\n\n## Source\n\n- ${created}: materialized automatically from approved intake. The linked Task represents Sprint planning until the requested work is decomposed into atomic Tasks.\n`;
55
+ }
56
+
57
+ function buildTaskBody(request, classification, approvalId, fingerprint, risk, previewSection, links = {}) {
58
+ const featureSection = links.feature ? `\n## Feature\n\n- ${links.feature}\n` : "";
59
+ const sprintSection = links.sprint ? `\n## Sprint\n\n- ${links.sprint}\n` : "";
60
+ return `# ${titleFor(request)}\n\n## Request\n\n${request}\n\n## Acceptance Criteria\n\n- [ ] _Define what "done" means before execution._\n${previewSection}${featureSection}${sprintSection}\n## Classification\n\n- Type: ${classification.type}\n- Reason: ${classification.reason}\n- Risk: ${risk.level}\n\n## Approval\n\n- Explicit approval token: ${approvalId}\n- Context fingerprint: ${fingerprint}`;
61
+ }
62
+
49
63
  function stateFingerprint(repository) {
50
64
  return canonicalFingerprint(repository.scrumDir, artifactSnapshot(repository.scrumDir).hashes);
51
65
  }
@@ -112,6 +126,11 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, inter
112
126
  const enforceablePolicy = policyState(projectRoot);
113
127
  const baseline = publicWorkspace(workspace);
114
128
  const branch = currentBranch(projectRoot);
129
+ const materialization = materializeRequest(payload.classification);
130
+ const createFeature = materialization.links.feature;
131
+ const createSprint = materialization.links.sprint;
132
+ const featureId = createFeature ? nextId(repository, "feature") : null;
133
+ const sprintId = createSprint ? nextId(repository, "sprint") : null;
115
134
  const taskId = nextId(repository, "task");
116
135
  const runId = nextId(repository, "run");
117
136
  const task = {
@@ -122,8 +141,8 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, inter
122
141
  created,
123
142
  updated: created,
124
143
  method: METHOD_VERSION,
125
- feature: null,
126
- sprint: null,
144
+ feature: featureId,
145
+ sprint: sprintId,
127
146
  branch,
128
147
  assignee: agentIdentity(scrumDir) || "agent",
129
148
  approval_id: approvalId,
@@ -138,7 +157,8 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, inter
138
157
  updated: created,
139
158
  method: METHOD_VERSION,
140
159
  task: taskId,
141
- sprint: null,
160
+ feature: featureId,
161
+ sprint: sprintId,
142
162
  branch,
143
163
  attempt: 1,
144
164
  ledger: 1,
@@ -146,20 +166,56 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, inter
146
166
  workspace: 1,
147
167
  approval_id: approvalId
148
168
  };
169
+ const taskLinks = { feature: featureId, sprint: sprintId };
149
170
  const previewSection = payload.preview ? `\n\n## Preview\n\n${payload.preview}\n` : "";
150
- const taskBody = `# ${titleFor(payload.request)}\n\n## Request\n\n${payload.request}\n\n## Acceptance Criteria\n\n- [ ] _Define what "done" means before execution._\n${previewSection}\n\n## Classification\n\n- Type: ${payload.classification.type}\n- Reason: ${payload.classification.reason}\n- Risk: ${payload.risk.level}\n\n## Approval\n\n- Explicit approval token: ${approvalId}\n- Context fingerprint: ${payload.fingerprint}`;
171
+ const taskContent = buildTaskBody(payload.request, payload.classification, approvalId, payload.fingerprint, payload.risk, previewSection, taskLinks);
172
+ const featureRecord = featureId ? {
173
+ id: featureId,
174
+ kind: "feature",
175
+ status: "active",
176
+ created,
177
+ updated: created,
178
+ method: METHOD_VERSION,
179
+ branch,
180
+ type: "initiative"
181
+ } : null;
182
+ const featureBody = featureId ? initiativeBody(titleFor(payload.request), payload.request, created, taskId) : null;
183
+ const sprintRecord = sprintId ? {
184
+ id: sprintId,
185
+ kind: "sprint",
186
+ status: "running",
187
+ created,
188
+ updated: created,
189
+ method: METHOD_VERSION,
190
+ branch,
191
+ type: null,
192
+ feature: featureId
193
+ } : null;
194
+ const sprintBodyText = sprintId ? sprintBody(titleFor(payload.request), payload.request, created, taskId) : null;
151
195
  const runBody = createRunBody(run, {
152
196
  approvalId,
153
197
  obligations: payload.policy.obligations || [],
154
198
  policyFingerprint: enforceablePolicy.fingerprint,
155
199
  workspaceBaseline: baseline
156
200
  }).body;
201
+ const featurePath = featureRecord ? repository.pathFor(featureRecord) : null;
202
+ const sprintPath = sprintRecord ? repository.pathFor(sprintRecord) : null;
203
+ const taskPath = repository.pathFor(task);
204
+ const runPath = repository.pathFor(run);
205
+ const writes = [];
206
+ if (featureRecord) writes.push({ file: featurePath, previous: null, next: serializeArtifact(featureRecord, featureBody) });
207
+ if (sprintRecord) writes.push({ file: sprintPath, previous: null, next: serializeArtifact(sprintRecord, sprintBodyText) });
208
+ writes.push(
209
+ { file: taskPath, previous: null, next: serializeArtifact(task, taskContent) },
210
+ { file: runPath, previous: null, next: serializeArtifact(run, runBody) }
211
+ );
212
+ const failureTarget = {
213
+ "after-task": `after-${writes.findIndex((entry) => entry.file === taskPath) + 1}`,
214
+ "after-run": `after-${writes.findIndex((entry) => entry.file === runPath) + 1}`
215
+ };
157
216
  try {
158
- runKernelTransaction(scrumDir, "approve-task-run", [
159
- { file: repository.pathFor(task), previous: null, next: serializeArtifact(task, taskBody) },
160
- { file: repository.pathFor(run), previous: null, next: serializeArtifact(run, runBody) }
161
- ], {
162
- failurePoint: failurePoint === "after-task" ? "after-1" : failurePoint === "after-run" ? "after-2" : null,
217
+ runKernelTransaction(scrumDir, "approve-task-run", writes, {
218
+ failurePoint: failureTarget[failurePoint] || failurePoint,
163
219
  interruptPoint
164
220
  });
165
221
  } catch (error) {
@@ -51,6 +51,28 @@ function classifyRequest(request) {
51
51
  return { type: "task", taskType: "task", reason: "request is best represented as one atomic Task" };
52
52
  }
53
53
 
54
+ function materializeRequest(classification) {
55
+ const create = [];
56
+ const links = {
57
+ feature: false,
58
+ sprint: false
59
+ };
60
+ if (classification.type === "feature") {
61
+ create.push("Feature");
62
+ links.feature = true;
63
+ }
64
+ if (classification.type === "sprint") {
65
+ create.push("Sprint");
66
+ links.sprint = true;
67
+ }
68
+ create.push("Task", "Run");
69
+ return {
70
+ create,
71
+ links,
72
+ summary: create.join(" + ")
73
+ };
74
+ }
75
+
54
76
  function applyPolicy(context) {
55
77
  return evaluatePolicy(context);
56
78
  }
@@ -124,6 +146,7 @@ function planRequest(projectRoot, request, { typeOverride = null, preview = null
124
146
  if (containsSecret(preview)) throw new Error("Preview contains secret-like content.");
125
147
  safePreview = String(preview).trim().slice(0, 2000);
126
148
  }
149
+ const materialization = materializeRequest(classification);
127
150
  const plan = {
128
151
  state: policy.status === "passed" ? "awaiting_approval" : "blocked",
129
152
  pipeline: ["received", "contextualizing", "policy", "risk", "classification", "planning", policy.status === "passed" ? "awaiting_approval" : "blocked"],
@@ -135,7 +158,9 @@ function planRequest(projectRoot, request, { typeOverride = null, preview = null
135
158
  classification,
136
159
  preview: safePreview,
137
160
  proposal: {
138
- create: ["Task", "Run"],
161
+ create: materialization.create,
162
+ materialization,
163
+ relationships: materialization.links,
139
164
  sprint: classification.type === "sprint" ? "propose only after confirming real batch/timebox membership" : null,
140
165
  validation: risk.level === "high" ? "targeted tests plus configured reviewers" : "targeted tests"
141
166
  },
@@ -153,5 +178,6 @@ module.exports = {
153
178
  decodeApproval,
154
179
  encodeApproval,
155
180
  planRequest,
181
+ materializeRequest,
156
182
  stableJson
157
183
  };
@@ -17,7 +17,7 @@ const { canonicalPaths, PATHS_SCHEMA_VERSION } = require("./paths");
17
17
 
18
18
  const INVARIANTS = Object.freeze([
19
19
  { id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
20
- { 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"] },
20
+ { id: "I-02", summary: "approval materializes the requested plan shape atomically", tests: ["explicit approval creates exactly one linked Task and Run", "approval failure injection rolls back"] },
21
21
  { 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"] },
22
22
  { id: "I-04", summary: "retry preserves prior Runs", tests: ["retry creates a new Run and preserves"] },
23
23
  { id: "I-05", summary: "state transitions are declared, evidenced, ordered, and recoverable", tests: ["run state machine follows", "Run ledger uses stable event ids", "paired transition failure restores"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "2.7.7",
3
+ "version": "2.7.8",
4
4
  "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",