scrumrun 2.7.1 → 2.7.2

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 CHANGED
@@ -4,6 +4,16 @@ All notable changes follow Semantic Versioning.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.7.2 - 2026-08-21
8
+
9
+ ### Fixed
10
+
11
+ - **Duplicate artifact ids across concurrent agents.** All create operations now serialize on a single global `create` lock (`approveRequest`, `retryTask`, `startBacklogTask`, `createMemory`, `recordArtifactReview`) instead of independent per-operation locks (`approval`, `task-…`, `memory-…`, `review-artifact`). Previously two agents could read the same `max` id and both write `TASK-NNN`/`RUN-NNN`; the race is now closed at the source. Conformance's `ID_DUPLICATE` finding remains as a detection backstop for hand-written files.
12
+
13
+ ### Validation
14
+
15
+ - Added `tests/id-concurrency.test.js` — concurrent backlog starts and memory proposals across child processes allocate unique ids.
16
+
7
17
  ## 2.7.1 - 2026-08-21
8
18
 
9
19
  ### Added
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.1` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
7
+ **Package:** `2.7.2` · **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
 
package/bin/scrumrun.js CHANGED
@@ -31,7 +31,7 @@ const { ARTIFACT_TYPES, ArtifactRepository } = require(path.join(root, "lib", "v
31
31
  const { aliases: COMMAND_ALIASES, resolveAlias, resolveRoute } = require(path.join(root, "lib", "commands", "manifest"));
32
32
  const { renderCommandHelp, renderCompatibilityPrompt, renderRootPrompt } = require(path.join(root, "lib", "commands", "render"));
33
33
  const { planRequest } = require(path.join(root, "lib", "runtime", "request-engine"));
34
- const { approveRequest, nextBacklogTask, refreshState, retryTask, startBacklogTask, transitionRun } = require(path.join(root, "lib", "runtime", "orchestrator"));
34
+ const { addPlanArtifact, approveRequest, nextBacklogTask, refreshState, retryTask, startBacklogTask, transitionRun } = require(path.join(root, "lib", "runtime", "orchestrator"));
35
35
  const { authorizeMutation, recordMutation, satisfyGuardrail } = require(path.join(root, "lib", "runtime", "mutation-gateway"));
36
36
  const { recordArtifactReview } = require(path.join(root, "lib", "runtime", "review-service"));
37
37
  const { createMemory, listMemory, showMemory, transitionMemory } = require(path.join(root, "lib", "memory", "service"));
@@ -1451,6 +1451,18 @@ function executeRootRoute(route) {
1451
1451
  console.log(`Started ${result.task.id} (${result.run.id}) assigned to ${result.task.assignee || "agent"}.`);
1452
1452
  return;
1453
1453
  }
1454
+ if (noun === "plan" && ["task", "feature", "sprint"].includes(subject) && routeArgs[0] === "--add") {
1455
+ const label = removeOptionPairs(routeArgs.slice(1), ["--type", "--status"])
1456
+ .filter((arg) => !arg.startsWith("--"))
1457
+ .join(" ")
1458
+ .trim();
1459
+ const result = addPlanArtifact(process.cwd(), subject, label, {
1460
+ type: optionValue(routeArgs, "--type"),
1461
+ status: optionValue(routeArgs, "--status")
1462
+ });
1463
+ console.log(`Created ${result.record.status} ${result.record.id}: ${path.relative(process.cwd(), result.file)}`);
1464
+ return;
1465
+ }
1454
1466
  if (noun === "plan" && subject === "run" && routeArgs[0] === "--render") {
1455
1467
  const { renderRunFromDisk } = require(path.join(root, "lib", "commands", "run-render"));
1456
1468
  const runId = routeArgs[1];
@@ -1503,7 +1515,7 @@ function executeRootRoute(route) {
1503
1515
  }
1504
1516
  return;
1505
1517
  }
1506
- if (noun === "plan" && ["task", "run"].includes(subject) && ["--list", "--show"].includes(routeArgs[0])) {
1518
+ if (noun === "plan" && ["task", "run", "feature", "sprint"].includes(subject) && ["--list", "--show"].includes(routeArgs[0])) {
1507
1519
  const repository = new ArtifactRepository(projectFile());
1508
1520
  if (routeArgs[0] === "--list") {
1509
1521
  const artifacts = repository.list(subject).map((artifact) => `${artifact.record.id} | ${artifact.record.status} | ${((artifact.body || "").match(/^# ([^\r\n]+)/m) || [])[1] || artifact.record.id}`);
@@ -213,7 +213,7 @@ function createMemory(projectRoot, kind, options = {}) {
213
213
  if (!MEMORY_KINDS.has(kind)) throw new Error(`Unsupported memory kind: ${kind}`);
214
214
  assertV2Project(projectRoot);
215
215
  const scrumDir = path.join(projectRoot, ".scrumrun");
216
- return withArtifactLock(scrumDir, `memory-${kind}`, () => createMemoryUnlocked(projectRoot, kind, options));
216
+ return withArtifactLock(scrumDir, "create", () => createMemoryUnlocked(projectRoot, kind, options));
217
217
  }
218
218
 
219
219
  function setFrontmatter(content, updates) {
@@ -18,10 +18,11 @@ const { buildContextPackage } = require("./context");
18
18
  const { agentIdentity, evaluatePolicy } = require("./policy-engine");
19
19
  const { artifactSnapshot, canonicalFingerprint, canonicalWatchSnapshot } = require("./canonical-snapshot");
20
20
  const { decodeApproval } = require("./request-engine");
21
+ const { containsSecret } = require("../security/secrets");
21
22
  const { extractLearningCandidates } = require("../code-intel/learning");
22
23
  const { appendRunEvent, appendTechnicalSummary, createRunBody, instant } = require("./run-ledger");
23
24
  const { generateBriefing } = require("./briefing");
24
- const { policyState, prepareCompletion, publicWorkspace, verifyWorkspaceIntegrity, workspaceState } = require("./mutation-gateway");
25
+ const { assertCanonicalWrite, policyState, prepareCompletion, publicWorkspace, verifyWorkspaceIntegrity, workspaceState } = require("./mutation-gateway");
25
26
  const { recoverPendingTransactions, runKernelTransaction } = require("../v2/transaction");
26
27
 
27
28
  const TERMINAL = new Set(["completed", "failed", "cancelled", "resolved", "rejected", "deprecated", "invalidated", "archived", "passed"]);
@@ -160,7 +161,7 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, inter
160
161
  }
161
162
 
162
163
  function approveRequest(projectRoot, token, options = {}) {
163
- return withArtifactLock(path.join(projectRoot, ".scrumrun"), "approval", () => approveRequestUnlocked(projectRoot, token, options));
164
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), "create", () => approveRequestUnlocked(projectRoot, token, options));
164
165
  }
165
166
 
166
167
  function transitionedArtifactContent(content, kind, nextStatus, updated, assignee = null) {
@@ -338,7 +339,7 @@ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly appro
338
339
  }
339
340
 
340
341
  function retryTask(projectRoot, taskId, options = {}) {
341
- return withArtifactLock(path.join(projectRoot, ".scrumrun"), `task-${String(taskId).toLowerCase()}`, () => retryTaskUnlocked(projectRoot, taskId, options));
342
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), "create", () => retryTaskUnlocked(projectRoot, taskId, options));
342
343
  }
343
344
 
344
345
  function nextBacklogTask(repository) {
@@ -408,7 +409,119 @@ function startBacklogTaskUnlocked(projectRoot, taskId, { note = "Backlog Task st
408
409
  }
409
410
 
410
411
  function startBacklogTask(projectRoot, taskId, options = {}) {
411
- return withArtifactLock(path.join(projectRoot, ".scrumrun"), `task-${String(taskId).toLowerCase()}`, () => startBacklogTaskUnlocked(projectRoot, taskId, options));
412
+ return withArtifactLock(path.join(projectRoot, ".scrumrun"), "create", () => startBacklogTaskUnlocked(projectRoot, taskId, options));
412
413
  }
413
414
 
414
- module.exports = { approveRequest, nextBacklogTask, nextId, refreshState, renderState, retryTask, startBacklogTask, stateFingerprint, stateIsStale, transitionRun };
415
+ const ADDABLE_PLAN_KINDS = new Set(["task", "feature", "sprint"]);
416
+ const ADD_PLAN_DEFAULTS = Object.freeze({
417
+ task: { status: "backlog", type: "task" },
418
+ feature: { status: "backlog", type: "initiative" },
419
+ sprint: { status: "proposed", type: null }
420
+ });
421
+ const ADD_PLAN_INITIAL_STATUS = Object.freeze({
422
+ task: ["backlog", "proposed"],
423
+ feature: ["backlog", "proposed"],
424
+ sprint: ["proposed"]
425
+ });
426
+ const TASK_TYPES = new Set(["task", "fix", "docs", "discovery"]);
427
+
428
+ function planArtifactBody(kind, title, created) {
429
+ const source = `- ${created}: created via CLI (\`sc plan ${kind} --add\`).`;
430
+ if (kind === "task") {
431
+ return [
432
+ `# ${title}`,
433
+ "",
434
+ "## Request",
435
+ "",
436
+ title,
437
+ "",
438
+ "## Acceptance Criteria",
439
+ "",
440
+ '- [ ] _Define what "done" means before execution._',
441
+ "",
442
+ "## Source",
443
+ "",
444
+ source
445
+ ].join("\n");
446
+ }
447
+ if (kind === "feature") {
448
+ return [
449
+ `# ${title}`,
450
+ "",
451
+ "## Motivation",
452
+ "",
453
+ title,
454
+ "",
455
+ "## Exit criteria",
456
+ "",
457
+ '- [ ] _Define what success looks like._',
458
+ "",
459
+ "## Source",
460
+ "",
461
+ source
462
+ ].join("\n");
463
+ }
464
+ return [
465
+ `# ${title}`,
466
+ "",
467
+ "## Tasks",
468
+ "",
469
+ "_Pending: add Task ids here._",
470
+ "",
471
+ "## Exit Gate",
472
+ "",
473
+ '- [ ] _Define the batch completion condition._',
474
+ "",
475
+ "## Source",
476
+ "",
477
+ source
478
+ ].join("\n");
479
+ }
480
+
481
+ function addPlanArtifactUnlocked(projectRoot, kind, label, { type = null, status = null } = {}) {
482
+ if (!ADDABLE_PLAN_KINDS.has(kind)) throw new Error(`Unsupported plan kind: ${kind}`);
483
+ const scrumDir = path.join(projectRoot, ".scrumrun");
484
+ recoverPendingTransactions(scrumDir);
485
+ const repository = new ArtifactRepository(scrumDir);
486
+ const title = String(label || "").trim();
487
+ if (!title) throw new Error(`A non-empty title is required for ${kind} --add.`);
488
+ if (/\r|\n/.test(title)) throw new Error(`${kind} title must be one line.`);
489
+ if (title.length > 200) throw new Error(`${kind} title exceeds the 200 character limit.`);
490
+ if (containsSecret(title)) throw new Error("Secret-like content is forbidden outside the local vault.");
491
+ const chosenStatus = status || ADD_PLAN_DEFAULTS[kind].status;
492
+ if (!ADD_PLAN_INITIAL_STATUS[kind].includes(chosenStatus)) {
493
+ throw new Error(`Invalid ${kind} status: ${chosenStatus}. Allowed for --add: ${ADD_PLAN_INITIAL_STATUS[kind].join(", ")}.`);
494
+ }
495
+ let chosenType = ADD_PLAN_DEFAULTS[kind].type;
496
+ if (kind === "task" && type) {
497
+ if (!TASK_TYPES.has(type)) throw new Error(`Invalid task --type: ${type}. Valid: ${[...TASK_TYPES].join(", ")}.`);
498
+ chosenType = type;
499
+ }
500
+ const created = date();
501
+ const record = {
502
+ id: nextId(repository, kind),
503
+ kind,
504
+ status: chosenStatus,
505
+ created,
506
+ updated: created,
507
+ method: METHOD_VERSION
508
+ };
509
+ if (kind === "task") {
510
+ record.type = chosenType;
511
+ record.feature = null;
512
+ record.sprint = null;
513
+ } else if (kind === "feature") {
514
+ record.type = chosenType;
515
+ }
516
+ const body = planArtifactBody(kind, title, created);
517
+ assertCanonicalWrite(projectRoot, `add-${kind}`, [title, body]);
518
+ repository.write(record, body);
519
+ return repository.read(kind, record.id);
520
+ }
521
+
522
+ function addPlanArtifact(projectRoot, kind, label, options = {}) {
523
+ const scrumDir = path.join(projectRoot, ".scrumrun");
524
+ return withArtifactLock(scrumDir, "create", () => addPlanArtifactUnlocked(projectRoot, kind, label, options));
525
+ }
526
+
527
+ module.exports = { addPlanArtifact, approveRequest, nextBacklogTask, nextId, refreshState, renderState, retryTask, startBacklogTask, stateFingerprint, stateIsStale, transitionRun };
@@ -86,7 +86,7 @@ function recordArtifactReviewUnlocked(projectRoot, options) {
86
86
 
87
87
  function recordArtifactReview(projectRoot, options = {}) {
88
88
  const scrumDir = path.join(projectRoot, ".scrumrun");
89
- return withArtifactLock(scrumDir, "review-artifact", () => recordArtifactReviewUnlocked(projectRoot, options));
89
+ return withArtifactLock(scrumDir, "create", () => recordArtifactReviewUnlocked(projectRoot, options));
90
90
  }
91
91
 
92
92
  module.exports = { recordArtifactReview };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrumrun",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "Evidence-driven Agile runtime and semantic project memory for AI coding agents.",
5
5
  "bin": {
6
6
  "scrumrun": "bin/scrumrun.js",