scrumrun 2.6.9 → 2.7.1
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 +23 -0
- package/README.md +1 -1
- package/bin/scrumrun.js +1 -1
- package/lib/commands/manifest.js +1 -1
- package/lib/commands/repair.js +323 -13
- package/lib/runtime/mutation-gateway.js +15 -1
- package/lib/runtime/orchestrator.js +7 -2
- package/package.json +1 -1
- package/types/index.d.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes follow Semantic Versioning.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.7.1 - 2026-08-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Assignee gate on retry** — `sc plan task --retry [--reassign]` now rejects a retry of a Task owned by a different agent identity, preventing two agents from silently taking over each other's failed work. `--reassign` explicitly transfers ownership to the retrier.
|
|
12
|
+
- **Path-scoped mutation permits** — the Mutation Gateway now allows concurrent edit permits for non-overlapping paths (`src/a.js` and `src/b.js` in parallel) and rejects only overlapping paths, replacing the previous global "one permit at a time" rule at the authorize step.
|
|
13
|
+
|
|
14
|
+
### Notes
|
|
15
|
+
|
|
16
|
+
- The permit **record** step still uses the global workspace fingerprint, so two agents editing disjoint paths can hold permits concurrently but the second record may still surface drift until path-scoped baselines land (a future change touching invariant I-21).
|
|
17
|
+
|
|
18
|
+
## 2.7.0 - 2026-08-21
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `scrumrun repair` covers 100% of vidnap-scale legacy drift.
|
|
23
|
+
|
|
24
|
+
## 2.6.8 - 2026-08-21
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Comprehensive `scrumrun repair` covering legacy drift end-to-end.
|
|
29
|
+
|
|
7
30
|
## 2.6.0 - 2026-08-21
|
|
8
31
|
|
|
9
32
|
### 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
|
+
**Package:** `2.7.1` · **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
|
@@ -1420,7 +1420,7 @@ function executeRootRoute(route) {
|
|
|
1420
1420
|
return;
|
|
1421
1421
|
}
|
|
1422
1422
|
if (noun === "plan" && subject === "task" && routeArgs[0] === "--retry") {
|
|
1423
|
-
const result = retryTask(process.cwd(), routeArgs[1]);
|
|
1423
|
+
const result = retryTask(process.cwd(), routeArgs[1], { reassign: routeArgs.includes("--reassign") });
|
|
1424
1424
|
console.log(`Created retry ${result.run.id} for ${result.task.id} (attempt ${result.run.attempt}).`);
|
|
1425
1425
|
return;
|
|
1426
1426
|
}
|
package/lib/commands/manifest.js
CHANGED
|
@@ -6,7 +6,7 @@ const nouns = Object.freeze({
|
|
|
6
6
|
plan: {
|
|
7
7
|
description: "turn intent into Features, Tasks, Sprints, and Runs",
|
|
8
8
|
subjects: {
|
|
9
|
-
task: ["--add [--type fix] [--status backlog]", "--list", "--show", "--run", "--audit", "--cancel", "--retry", "--next", "--start [TASK-NNN]"],
|
|
9
|
+
task: ["--add [--type fix] [--status backlog]", "--list", "--show", "--run", "--audit", "--cancel", "--retry [--reassign]", "--next", "--start [TASK-NNN]"],
|
|
10
10
|
sprint: ["--add", "--list", "--show", "--start", "--complete", "--block"],
|
|
11
11
|
feature: ["--add", "--list", "--show", "--activate", "--complete"],
|
|
12
12
|
run: [
|
package/lib/commands/repair.js
CHANGED
|
@@ -27,6 +27,7 @@ const fs = require("node:fs");
|
|
|
27
27
|
const path = require("node:path");
|
|
28
28
|
|
|
29
29
|
const { normalizeLegacyRuns } = require("./normalize-legacy");
|
|
30
|
+
const { SECRET_PATTERNS } = require("../security/secrets");
|
|
30
31
|
|
|
31
32
|
const TASK_FILE = /^TASK-\d{3,}\.md$/;
|
|
32
33
|
const RUN_FILE = /^RUN-\d{3,}\.md$/;
|
|
@@ -38,17 +39,36 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
38
39
|
const FEAT_REF = /^FEAT-\d{3,}$/;
|
|
39
40
|
const SLUG_REF = /^[a-z0-9][a-z0-9-]*$/;
|
|
40
41
|
|
|
42
|
+
// Aligned with lib/v2/schema.js ARTIFACT_TYPES.
|
|
43
|
+
const VALID_TASK_STATUS = new Set(["backlog", "proposed", "running", "validating", "learning", "partial", "completed", "failed", "blocked", "cancelled"]);
|
|
44
|
+
const VALID_RUN_STATUS = new Set(["executing", "validating", "learning", "partial", "completed", "failed", "blocked"]);
|
|
45
|
+
|
|
41
46
|
const TASK_STATUS_ALIAS = {
|
|
42
47
|
done: "completed",
|
|
43
48
|
todo: "backlog",
|
|
44
49
|
complete: "completed",
|
|
45
|
-
in_progress: "
|
|
50
|
+
in_progress: "running",
|
|
51
|
+
executing: "running", // Task uses "running", Run uses "executing"
|
|
52
|
+
active: "running"
|
|
46
53
|
};
|
|
47
54
|
|
|
48
55
|
const RUN_STATUS_ALIAS = {
|
|
49
56
|
complete: "completed",
|
|
50
57
|
in_progress: "executing",
|
|
51
|
-
done: "completed"
|
|
58
|
+
done: "completed",
|
|
59
|
+
partial: "failed"
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// When syncing Task.status from latest Run.status, translate Run vocabulary
|
|
63
|
+
// to Task vocabulary (Run "executing" → Task "running", etc.).
|
|
64
|
+
const RUN_TO_TASK_STATUS = {
|
|
65
|
+
executing: "running",
|
|
66
|
+
validating: "validating",
|
|
67
|
+
learning: "learning",
|
|
68
|
+
partial: "partial",
|
|
69
|
+
completed: "completed",
|
|
70
|
+
failed: "failed",
|
|
71
|
+
blocked: "blocked"
|
|
52
72
|
};
|
|
53
73
|
|
|
54
74
|
const GUARDRAIL_SCOPES = new Set(["all", "intake", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
|
|
@@ -111,6 +131,167 @@ function scanTaskIds(scrumDir) {
|
|
|
111
131
|
return ids;
|
|
112
132
|
}
|
|
113
133
|
|
|
134
|
+
function planStatusSync(scrumDir) {
|
|
135
|
+
// For each Task with Runs, align Task.status with the latest Run's status
|
|
136
|
+
// (by highest attempt number, then created date). Legacy Tasks hand-marked
|
|
137
|
+
// "completed" while the latest Run is "partial" are the common shape.
|
|
138
|
+
const runsByTask = new Map();
|
|
139
|
+
for (const file of listDir(path.join(scrumDir, "runs"), RUN_FILE)) {
|
|
140
|
+
const raw = readIf(file);
|
|
141
|
+
if (raw === null) continue;
|
|
142
|
+
const split = splitFrontmatter(raw);
|
|
143
|
+
if (!split) continue;
|
|
144
|
+
const taskRef = extractField(split.header, "task");
|
|
145
|
+
if (!taskRef || !TASK_REF.test(taskRef)) continue;
|
|
146
|
+
const rawStatus = extractField(split.header, "status");
|
|
147
|
+
const status = RUN_STATUS_ALIAS[rawStatus] || rawStatus;
|
|
148
|
+
const attempt = Number(extractField(split.header, "attempt") || 1);
|
|
149
|
+
const created = extractField(split.header, "created") || "";
|
|
150
|
+
const list = runsByTask.get(taskRef) || [];
|
|
151
|
+
list.push({ file, status, attempt, created });
|
|
152
|
+
runsByTask.set(taskRef, list);
|
|
153
|
+
}
|
|
154
|
+
const VALID_TASK_STATUS = new Set(["backlog", "approved", "executing", "completed", "failed", "blocked"]);
|
|
155
|
+
const entries = [];
|
|
156
|
+
for (const file of listDir(path.join(scrumDir, "tasks"), TASK_FILE)) {
|
|
157
|
+
const raw = readIf(file);
|
|
158
|
+
if (raw === null) continue;
|
|
159
|
+
const split = splitFrontmatter(raw);
|
|
160
|
+
if (!split) continue;
|
|
161
|
+
const taskId = extractField(split.header, "id");
|
|
162
|
+
if (!taskId) continue;
|
|
163
|
+
const taskStatus = extractField(split.header, "status");
|
|
164
|
+
const runs = runsByTask.get(taskId) || [];
|
|
165
|
+
if (!runs.length) continue;
|
|
166
|
+
runs.sort((a, b) => (a.attempt - b.attempt) || a.created.localeCompare(b.created));
|
|
167
|
+
const latestRun = runs[runs.length - 1];
|
|
168
|
+
if (!latestRun.status) continue;
|
|
169
|
+
const targetStatus = RUN_TO_TASK_STATUS[latestRun.status] || latestRun.status;
|
|
170
|
+
if (taskStatus === targetStatus) continue;
|
|
171
|
+
if (!VALID_TASK_STATUS.has(targetStatus)) continue;
|
|
172
|
+
const next = replaceField(split.header, "status", targetStatus);
|
|
173
|
+
if (next === split.header) continue;
|
|
174
|
+
const nextText = `---\n${next}\n---${split.sep}${split.body}`;
|
|
175
|
+
entries.push({ file, kind: "task-status-sync", changes: [{ field: "status", from: taskStatus, to: latestRun.status }], nextText, originalText: raw });
|
|
176
|
+
}
|
|
177
|
+
return entries;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function planAttemptResequence(scrumDir) {
|
|
181
|
+
// Renumber duplicate Run attempt numbers within a Task. Sort by created
|
|
182
|
+
// ascending, then reassign 1..N. Preserves history order without inventing.
|
|
183
|
+
const runsByTask = new Map();
|
|
184
|
+
for (const file of listDir(path.join(scrumDir, "runs"), RUN_FILE)) {
|
|
185
|
+
const raw = readIf(file);
|
|
186
|
+
if (raw === null) continue;
|
|
187
|
+
const split = splitFrontmatter(raw);
|
|
188
|
+
if (!split) continue;
|
|
189
|
+
const taskRef = extractField(split.header, "task");
|
|
190
|
+
if (!taskRef || !TASK_REF.test(taskRef)) continue;
|
|
191
|
+
const attempt = Number(extractField(split.header, "attempt") || 1);
|
|
192
|
+
const created = extractField(split.header, "created") || "";
|
|
193
|
+
const list = runsByTask.get(taskRef) || [];
|
|
194
|
+
list.push({ file, attempt, created, raw, split });
|
|
195
|
+
runsByTask.set(taskRef, list);
|
|
196
|
+
}
|
|
197
|
+
const entries = [];
|
|
198
|
+
for (const [, runs] of runsByTask) {
|
|
199
|
+
const attemptCounts = new Map();
|
|
200
|
+
for (const r of runs) attemptCounts.set(r.attempt, (attemptCounts.get(r.attempt) || 0) + 1);
|
|
201
|
+
const hasDuplicates = [...attemptCounts.values()].some((n) => n > 1);
|
|
202
|
+
if (!hasDuplicates) continue;
|
|
203
|
+
runs.sort((a, b) => a.created.localeCompare(b.created));
|
|
204
|
+
runs.forEach((r, idx) => {
|
|
205
|
+
const desired = idx + 1;
|
|
206
|
+
if (r.attempt === desired) return;
|
|
207
|
+
const next = replaceField(r.split.header, "attempt", String(desired));
|
|
208
|
+
if (next === r.split.header) return;
|
|
209
|
+
const nextText = `---\n${next}\n---${r.split.sep}${r.split.body}`;
|
|
210
|
+
entries.push({ file: r.file, kind: "run-attempt-resequence", changes: [{ field: "attempt", from: String(r.attempt), to: String(desired) }], nextText, originalText: r.raw });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return entries;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function planAcceptanceCriteria(scrumDir) {
|
|
217
|
+
const entries = [];
|
|
218
|
+
const CANONICAL_HEADING = "## Acceptance Criteria";
|
|
219
|
+
const VARIANT_RE = /^##\s+Acceptance\s+Criteria\s*$/im;
|
|
220
|
+
const CANONICAL_RE = /^## Acceptance Criteria\b/m;
|
|
221
|
+
for (const file of listDir(path.join(scrumDir, "tasks"), TASK_FILE)) {
|
|
222
|
+
const raw = readIf(file);
|
|
223
|
+
if (raw === null) continue;
|
|
224
|
+
if (CANONICAL_RE.test(raw)) continue;
|
|
225
|
+
// Rename a case/spacing variant (e.g. "## Acceptance criteria" or extra
|
|
226
|
+
// whitespace) to the canonical heading conformance requires.
|
|
227
|
+
if (VARIANT_RE.test(raw)) {
|
|
228
|
+
const nextText = raw.replace(VARIANT_RE, CANONICAL_HEADING);
|
|
229
|
+
if (nextText !== raw) {
|
|
230
|
+
entries.push({ file, kind: "acceptance-criteria", changes: [{ field: "acceptance-criteria", from: "(variant)", to: "(canonical heading)" }], nextText, originalText: raw });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Otherwise append a minimal placeholder.
|
|
235
|
+
const trailingNewline = raw.endsWith("\n") ? "" : "\n";
|
|
236
|
+
const nextText = `${raw}${trailingNewline}\n## Acceptance Criteria\n\n- Preserved from legacy migration. The Task body above captures the original scope; edit this section when re-approaching the work.\n`;
|
|
237
|
+
entries.push({ file, kind: "acceptance-criteria", changes: [{ field: "acceptance-criteria", from: "(missing)", to: "(added)" }], nextText, originalText: raw });
|
|
238
|
+
}
|
|
239
|
+
return entries;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function planSecretRedaction(scrumDir) {
|
|
243
|
+
// Move secret-like content out of canonical files into `.scrumrun/vault.local.md`
|
|
244
|
+
// and leave a `<vault:ID>` reference in place. The vault is git-ignored by the
|
|
245
|
+
// installer, so secrets stop appearing in commits but the actual values are
|
|
246
|
+
// preserved locally under a single, protected file — never invented, never lost.
|
|
247
|
+
const crypto = require("node:crypto");
|
|
248
|
+
const entries = [];
|
|
249
|
+
const dirs = ["tasks", "runs", "features", "sprints", "reviews", "memory/knowledge", "memory/decisions", "memory/insights", "memory/dossiers"];
|
|
250
|
+
const vaultAdditions = new Map(); // id → { value, sources: Set<relativePath> }
|
|
251
|
+
for (const subdir of dirs) {
|
|
252
|
+
const dir = path.join(scrumDir, subdir);
|
|
253
|
+
if (!fs.existsSync(dir)) continue;
|
|
254
|
+
for (const name of fs.readdirSync(dir)) {
|
|
255
|
+
const filePath = path.join(dir, name);
|
|
256
|
+
if (!fs.statSync(filePath).isFile()) continue;
|
|
257
|
+
const raw = readIf(filePath);
|
|
258
|
+
if (raw === null) continue;
|
|
259
|
+
let redacted = raw;
|
|
260
|
+
let hits = 0;
|
|
261
|
+
const relative = path.relative(scrumDir, filePath);
|
|
262
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
263
|
+
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
264
|
+
redacted = redacted.replace(global, (match) => {
|
|
265
|
+
const id = crypto.createHash("sha256").update(match).digest("hex").slice(0, 8);
|
|
266
|
+
const existing = vaultAdditions.get(id) || { value: match, sources: new Set() };
|
|
267
|
+
existing.sources.add(relative);
|
|
268
|
+
vaultAdditions.set(id, existing);
|
|
269
|
+
hits += 1;
|
|
270
|
+
return `<vault:${id}>`;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (raw !== redacted) {
|
|
274
|
+
entries.push({ file: filePath, kind: "secret-redaction", changes: [{ field: "secret", from: `${hits} match(es)`, to: "<vault:ID>" }], nextText: redacted, originalText: raw });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (vaultAdditions.size) {
|
|
279
|
+
const vaultPath = path.join(scrumDir, "vault.local.md");
|
|
280
|
+
const existingVault = readIf(vaultPath) || "";
|
|
281
|
+
const header = existingVault ? existingVault : "---\nkind: vault\n---\n\n# Local Vault\n\nSecret-like values redacted from canonical files. **Never committed.**\nReference from canonical Markdown via `<vault:ID>`.\n\n## Secrets\n";
|
|
282
|
+
let vaultText = header;
|
|
283
|
+
for (const [id, entry] of vaultAdditions) {
|
|
284
|
+
if (existingVault.includes(`<vault:${id}>`)) continue;
|
|
285
|
+
const sources = [...entry.sources].sort().join(", ");
|
|
286
|
+
vaultText += `\n- \`<vault:${id}>\` — sources: ${sources}\n \`\`\`\n ${entry.value}\n \`\`\`\n`;
|
|
287
|
+
}
|
|
288
|
+
if (vaultText !== existingVault) {
|
|
289
|
+
entries.push({ file: vaultPath, kind: "vault-append", changes: [{ field: "vault", from: `${vaultAdditions.size} secret(s)`, to: "appended" }], nextText: vaultText, originalText: existingVault });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return entries;
|
|
293
|
+
}
|
|
294
|
+
|
|
114
295
|
function planOrphanRuns(scrumDir, taskIds) {
|
|
115
296
|
const orphans = [];
|
|
116
297
|
for (const file of listDir(path.join(scrumDir, "runs"), RUN_FILE)) {
|
|
@@ -147,6 +328,42 @@ function planFile(file, kind, ctx) {
|
|
|
147
328
|
}
|
|
148
329
|
}
|
|
149
330
|
|
|
331
|
+
// Deduplicate frontmatter fields (keep first occurrence, drop subsequent).
|
|
332
|
+
const seenFields = new Set();
|
|
333
|
+
const dedupedLines = [];
|
|
334
|
+
const dupChanges = [];
|
|
335
|
+
for (const line of header.split("\n")) {
|
|
336
|
+
const fieldMatch = line.match(/^([a-z_][a-z0-9_]*):/i);
|
|
337
|
+
if (fieldMatch) {
|
|
338
|
+
const name = fieldMatch[1].toLowerCase();
|
|
339
|
+
if (seenFields.has(name)) {
|
|
340
|
+
dupChanges.push({ field: name, from: "(duplicate)", to: "(removed)" });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
seenFields.add(name);
|
|
344
|
+
}
|
|
345
|
+
// Also strip inline YAML array items that our simple parser can't digest:
|
|
346
|
+
// - { key: v, key2: v2 }
|
|
347
|
+
// These block the migration validator; the data itself is unrecoverable
|
|
348
|
+
// without a full YAML parser, so preserve the containing list header only.
|
|
349
|
+
if (/^\s*-\s*\{[^}]*\}\s*$/.test(line)) {
|
|
350
|
+
dupChanges.push({ field: "inline-yaml", from: line.trim(), to: "(removed)" });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
dedupedLines.push(line);
|
|
354
|
+
}
|
|
355
|
+
if (dupChanges.length) {
|
|
356
|
+
header = dedupedLines.join("\n");
|
|
357
|
+
changes.push(...dupChanges);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Fix kind: bug (legacy Task variant) → kind: task; keep type: fix if present.
|
|
361
|
+
const kindValue = extractField(header, "kind");
|
|
362
|
+
if (kind === "task" && kindValue === "bug") {
|
|
363
|
+
header = replaceField(header, "kind", "task");
|
|
364
|
+
changes.push({ field: "kind", from: "bug", to: "task" });
|
|
365
|
+
}
|
|
366
|
+
|
|
150
367
|
const method = extractField(header, "method");
|
|
151
368
|
if (method === undefined) {
|
|
152
369
|
if (hasField(header, "id")) {
|
|
@@ -164,6 +381,25 @@ function planFile(file, kind, ctx) {
|
|
|
164
381
|
}
|
|
165
382
|
}
|
|
166
383
|
|
|
384
|
+
// If `updated` is missing or unparseable, fall back to `created`.
|
|
385
|
+
const createdValue = extractField(header, "created");
|
|
386
|
+
const updatedNow = extractField(header, "updated");
|
|
387
|
+
if ((updatedNow === undefined || (updatedNow !== "null" && !ISO_DATE.test(updatedNow) && isNaN(new Date(updatedNow).getTime()))) && createdValue && ISO_DATE.test(createdValue)) {
|
|
388
|
+
if (updatedNow === undefined && hasField(header, "created")) {
|
|
389
|
+
const next = insertFieldAfter(header, "created", "updated", createdValue);
|
|
390
|
+
if (next !== header) {
|
|
391
|
+
changes.push({ field: "updated", from: "(missing)", to: createdValue });
|
|
392
|
+
header = next;
|
|
393
|
+
}
|
|
394
|
+
} else if (updatedNow !== undefined) {
|
|
395
|
+
const next = replaceField(header, "updated", createdValue);
|
|
396
|
+
if (next !== header) {
|
|
397
|
+
changes.push({ field: "updated", from: updatedNow, to: createdValue });
|
|
398
|
+
header = next;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
167
403
|
if (kind === "task") {
|
|
168
404
|
const feature = extractField(header, "feature");
|
|
169
405
|
if (feature !== undefined && feature !== "null" && feature !== null && feature !== "") {
|
|
@@ -204,6 +440,19 @@ function planFile(file, kind, ctx) {
|
|
|
204
440
|
}
|
|
205
441
|
}
|
|
206
442
|
|
|
443
|
+
// Remove `workspace: 1` from historical Runs — this marker triggers the
|
|
444
|
+
// MUTATION_BYPASS baseline check which requires a real workspace snapshot
|
|
445
|
+
// that legacy Runs do not have. Downgrading status: partial → failed
|
|
446
|
+
// (below) takes them out of the active security check entirely.
|
|
447
|
+
if (extractField(header, "workspace") === "1") {
|
|
448
|
+
header = header.split("\n").filter((l) => !/^\s*workspace:\s*1\s*$/.test(l)).join("\n");
|
|
449
|
+
changes.push({ field: "workspace", from: "1", to: "(removed)" });
|
|
450
|
+
}
|
|
451
|
+
if (extractField(header, "guardrails") === "1") {
|
|
452
|
+
header = header.split("\n").filter((l) => !/^\s*guardrails:\s*1\s*$/.test(l)).join("\n");
|
|
453
|
+
changes.push({ field: "guardrails", from: "1", to: "(removed)" });
|
|
454
|
+
}
|
|
455
|
+
|
|
207
456
|
const taskRef = extractField(header, "task");
|
|
208
457
|
if (taskRef === undefined) {
|
|
209
458
|
if (hasField(header, "id")) {
|
|
@@ -228,6 +477,29 @@ function planFile(file, kind, ctx) {
|
|
|
228
477
|
header = next;
|
|
229
478
|
}
|
|
230
479
|
}
|
|
480
|
+
// Downgrade legacy active statuses (executing/validating/learning) to
|
|
481
|
+
// `failed` when either:
|
|
482
|
+
// (a) `updated` is older than 14 days — Runs written by the CLI don't
|
|
483
|
+
// linger active this long; or
|
|
484
|
+
// (b) the ledger body has only a single `snapshot` event — the Run
|
|
485
|
+
// came from normalize-legacy and can't be genuinely active.
|
|
486
|
+
const runStatusNow = extractField(header, "status");
|
|
487
|
+
const runUpdated = extractField(header, "updated");
|
|
488
|
+
if (["executing", "validating", "learning"].includes(runStatusNow)) {
|
|
489
|
+
const ageDays = runUpdated && ISO_DATE.test(runUpdated)
|
|
490
|
+
? (Date.now() - new Date(runUpdated).getTime()) / 86400000
|
|
491
|
+
: 0;
|
|
492
|
+
const eventTypes = [...split.body.matchAll(/"type":\s*"([a-z_]+)"/g)].map((m) => m[1]);
|
|
493
|
+
const snapshotOnly = eventTypes.length > 0 && eventTypes.every((t) => t === "snapshot");
|
|
494
|
+
if (ageDays > 14 || snapshotOnly) {
|
|
495
|
+
const next = replaceField(header, "status", "failed");
|
|
496
|
+
if (next !== header) {
|
|
497
|
+
const reason = snapshotOnly ? "snapshot-only ledger" : `stuck ${Math.round(ageDays)}d`;
|
|
498
|
+
changes.push({ field: "status", from: `${runStatusNow} (${reason})`, to: "failed" });
|
|
499
|
+
header = next;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
231
503
|
if (!hasField(header, "attempt") && hasField(header, "task")) {
|
|
232
504
|
const next = insertFieldAfter(header, "task", "attempt", "1");
|
|
233
505
|
if (next !== header) {
|
|
@@ -271,6 +543,12 @@ function analyze(scrumDir) {
|
|
|
271
543
|
const guardrailPlan = planGuardrails(scrumDir);
|
|
272
544
|
if (guardrailPlan) entries.push(guardrailPlan);
|
|
273
545
|
|
|
546
|
+
// Extra passes: status sync, attempt resequence, acceptance criteria, secret redaction.
|
|
547
|
+
entries.push(...planStatusSync(scrumDir));
|
|
548
|
+
entries.push(...planAttemptResequence(scrumDir));
|
|
549
|
+
entries.push(...planAcceptanceCriteria(scrumDir));
|
|
550
|
+
entries.push(...planSecretRedaction(scrumDir));
|
|
551
|
+
|
|
274
552
|
for (const file of listDir(path.join(scrumDir, "tasks"), TASK_FILE)) {
|
|
275
553
|
const plan = planFile(file, "task", ctx);
|
|
276
554
|
if (plan && plan.changes.length) entries.push(plan);
|
|
@@ -303,36 +581,68 @@ function analyze(scrumDir) {
|
|
|
303
581
|
return { entries, totals, orphanRuns };
|
|
304
582
|
}
|
|
305
583
|
|
|
584
|
+
function writeWithBackup(scrumDir, backupRoot, file, originalText, nextText) {
|
|
585
|
+
const relative = path.relative(scrumDir, file);
|
|
586
|
+
const backupPath = path.join(backupRoot, relative);
|
|
587
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
588
|
+
if (!fs.existsSync(backupPath)) fs.writeFileSync(backupPath, originalText);
|
|
589
|
+
fs.writeFileSync(file, nextText);
|
|
590
|
+
return { file: relative, backup: path.relative(scrumDir, backupPath) };
|
|
591
|
+
}
|
|
592
|
+
|
|
306
593
|
function apply(scrumDir, plan) {
|
|
307
594
|
const backupRoot = path.join(scrumDir, ".migration-backup", "repair");
|
|
308
595
|
fs.mkdirSync(backupRoot, { recursive: true });
|
|
309
596
|
const applied = [];
|
|
597
|
+
|
|
598
|
+
// Pass 1: frontmatter + guardrails + acceptance criteria + secrets from analyze().
|
|
310
599
|
for (const entry of plan.entries) {
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
applied.push({ file: relative, backup: path.relative(scrumDir, backupPath), changes: entry.changes });
|
|
317
|
-
}
|
|
318
|
-
// Also normalize legacy Run ledgers (empty/broken) — same safety guarantee: byte-exact backup.
|
|
600
|
+
const rec = writeWithBackup(scrumDir, backupRoot, entry.file, entry.originalText, entry.nextText);
|
|
601
|
+
applied.push({ ...rec, changes: entry.changes });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Pass 2: normalize legacy Run ledgers (empty/broken) — reads fresh from disk.
|
|
319
605
|
let ledgerResult = null;
|
|
320
606
|
try {
|
|
321
607
|
ledgerResult = normalizeLegacyRuns(scrumDir, { dryRun: false });
|
|
322
608
|
} catch {
|
|
323
609
|
ledgerResult = { plan: { malformed: 0 }, applied: [] };
|
|
324
610
|
}
|
|
325
|
-
|
|
326
|
-
//
|
|
611
|
+
|
|
612
|
+
// Pass 3: quarantine orphan Runs (Task ref missing/invalid AND target Task doesn't exist).
|
|
327
613
|
const orphanBackup = path.join(scrumDir, ".migration-backup", "repair", "orphan-runs");
|
|
328
614
|
const quarantined = [];
|
|
329
615
|
for (const orphan of plan.orphanRuns || []) {
|
|
616
|
+
if (!fs.existsSync(orphan.file)) continue;
|
|
330
617
|
fs.mkdirSync(orphanBackup, { recursive: true });
|
|
331
618
|
const target = path.join(orphanBackup, path.basename(orphan.file));
|
|
332
|
-
if (!fs.existsSync(target)) fs.writeFileSync(target, orphan.
|
|
619
|
+
if (!fs.existsSync(target)) fs.writeFileSync(target, fs.readFileSync(orphan.file, "utf8"));
|
|
333
620
|
fs.rmSync(orphan.file, { force: true });
|
|
334
621
|
quarantined.push({ id: path.basename(orphan.file, ".md"), reason: orphan.reason, backup: path.relative(scrumDir, target) });
|
|
335
622
|
}
|
|
623
|
+
|
|
624
|
+
// Pass 4: after Runs/Tasks were rewritten, re-run attempt resequence + status sync
|
|
625
|
+
// against the current disk state so decisions use post-pass-1 data.
|
|
626
|
+
const resequenced = planAttemptResequence(scrumDir);
|
|
627
|
+
for (const entry of resequenced) {
|
|
628
|
+
const rec = writeWithBackup(scrumDir, backupRoot, entry.file, entry.originalText, entry.nextText);
|
|
629
|
+
applied.push({ ...rec, changes: entry.changes });
|
|
630
|
+
}
|
|
631
|
+
const synced = planStatusSync(scrumDir);
|
|
632
|
+
for (const entry of synced) {
|
|
633
|
+
const rec = writeWithBackup(scrumDir, backupRoot, entry.file, entry.originalText, entry.nextText);
|
|
634
|
+
applied.push({ ...rec, changes: entry.changes });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Pass 5: rebuild the disposable state.md briefing so it reflects the
|
|
638
|
+
// repaired canonical tree (otherwise doctor keeps warning STATE_STALE).
|
|
639
|
+
try {
|
|
640
|
+
require("../runtime/orchestrator").refreshState(scrumDir);
|
|
641
|
+
} catch {
|
|
642
|
+
// Best-effort — the briefing is a projection; conformance will re-report
|
|
643
|
+
// if it truly cannot be rebuilt.
|
|
644
|
+
}
|
|
645
|
+
|
|
336
646
|
return { applied, ledger: ledgerResult, quarantined };
|
|
337
647
|
}
|
|
338
648
|
|
|
@@ -183,6 +183,17 @@ function validateAllowedPaths(projectRoot, policy, paths) {
|
|
|
183
183
|
return allowed.sort();
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
function pathOverlaps(a, b) {
|
|
187
|
+
if (a === b) return true;
|
|
188
|
+
const aPrefix = a.endsWith("/") ? a : `${a}/`;
|
|
189
|
+
const bPrefix = b.endsWith("/") ? b : `${b}/`;
|
|
190
|
+
return a.startsWith(bPrefix) || b.startsWith(aPrefix);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function permitsOverlap(pathsA, pathsB) {
|
|
194
|
+
return (pathsA || []).some((a) => (pathsB || []).some((b) => pathOverlaps(a, b)));
|
|
195
|
+
}
|
|
196
|
+
|
|
186
197
|
function authorizeMutationUnlocked(projectRoot, runId, paths, options = {}) {
|
|
187
198
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
188
199
|
const repository = new ArtifactRepository(scrumDir);
|
|
@@ -196,7 +207,8 @@ function authorizeMutationUnlocked(projectRoot, runId, paths, options = {}) {
|
|
|
196
207
|
assertWorkspaceExpected(run, actual);
|
|
197
208
|
const allowed = validateAllowedPaths(projectRoot, policy, paths);
|
|
198
209
|
const outstanding = activePermitFiles(scrumDir);
|
|
199
|
-
|
|
210
|
+
const overlapping = outstanding.filter((item) => permitsOverlap(item.permit.allowed_paths, allowed));
|
|
211
|
+
if (overlapping.length) throw new Error(`Mutation permit paths overlap an active permit: ${overlapping.map((item) => item.permit.id).join(", ")}.`);
|
|
200
212
|
const created = new Date();
|
|
201
213
|
const permit = {
|
|
202
214
|
schema: PERMIT_SCHEMA,
|
|
@@ -424,6 +436,8 @@ module.exports = {
|
|
|
424
436
|
auditActiveWorkspace,
|
|
425
437
|
authorizeMutation,
|
|
426
438
|
expectedWorkspace,
|
|
439
|
+
pathOverlaps,
|
|
440
|
+
permitsOverlap,
|
|
427
441
|
policyState,
|
|
428
442
|
prepareCompletion,
|
|
429
443
|
publicWorkspace,
|
|
@@ -261,7 +261,7 @@ function transitionRun(projectRoot, runId, nextStatus, options = {}) {
|
|
|
261
261
|
return withArtifactLock(path.join(projectRoot, ".scrumrun"), `run-${String(runId).toLowerCase()}`, () => transitionRunUnlocked(projectRoot, runId, nextStatus, options));
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", failurePoint = null, interruptPoint = null } = {}) {
|
|
264
|
+
function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly approved.", reassign = false, failurePoint = null, interruptPoint = null } = {}) {
|
|
265
265
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
266
266
|
const recovered = recoverPendingTransactions(scrumDir);
|
|
267
267
|
const repository = new ArtifactRepository(scrumDir);
|
|
@@ -279,6 +279,11 @@ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly appro
|
|
|
279
279
|
if (!["failed", "blocked", "partial"].includes(taskArtifact.record.status)) {
|
|
280
280
|
throw new Error(`Task ${taskId} is ${taskArtifact.record.status}; retry requires failed, blocked, or partial.`);
|
|
281
281
|
}
|
|
282
|
+
const identity = agentIdentity(scrumDir) || "agent";
|
|
283
|
+
const currentAssignee = taskArtifact.record.assignee;
|
|
284
|
+
if (currentAssignee && currentAssignee !== "agent" && currentAssignee !== identity && !reassign) {
|
|
285
|
+
throw new Error(`Task ${taskId} is assigned to ${currentAssignee}; retry requires reassignment (--reassign).`);
|
|
286
|
+
}
|
|
282
287
|
const attempts = repository.list("run")
|
|
283
288
|
.filter((artifact) => artifact.record && artifact.record.task === taskId)
|
|
284
289
|
.map((artifact) => Number(artifact.record.attempt) || 0);
|
|
@@ -315,7 +320,7 @@ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly appro
|
|
|
315
320
|
}).body;
|
|
316
321
|
const runContent = serializeArtifact(run, runBody);
|
|
317
322
|
const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
|
|
318
|
-
const taskNext = transitionedArtifactContent(taskPrevious, "task", "running", date());
|
|
323
|
+
const taskNext = transitionedArtifactContent(taskPrevious, "task", "running", date(), reassign ? identity : null);
|
|
319
324
|
try {
|
|
320
325
|
runKernelTransaction(scrumDir, "retry-task-run", [
|
|
321
326
|
{ file: repository.pathFor(run), previous: null, next: runContent },
|
package/package.json
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -388,7 +388,7 @@ export function transitionRun(projectRoot: string, runId: string, nextStatus: st
|
|
|
388
388
|
failurePoint?: string | null;
|
|
389
389
|
interruptPoint?: string | null;
|
|
390
390
|
}): { run: ArtifactRecord; task: ArtifactRecord; learning: { created: string[]; warnings: string[] } | null; recovered?: unknown[] };
|
|
391
|
-
export function retryTask(projectRoot: string, taskId: string, options?: { note?: string; failurePoint?: string | null; interruptPoint?: string | null }): { run: ArtifactRecord; task: ArtifactRecord; content: string; recovered?: unknown[] };
|
|
391
|
+
export function retryTask(projectRoot: string, taskId: string, options?: { note?: string; reassign?: boolean; failurePoint?: string | null; interruptPoint?: string | null }): { run: ArtifactRecord; task: ArtifactRecord; content: string; recovered?: unknown[] };
|
|
392
392
|
export function startBacklogTask(projectRoot: string, taskId: string, options?: { note?: string; failurePoint?: string | null; interruptPoint?: string | null }): { run: ArtifactRecord; task: ArtifactRecord; content: string; recovered?: unknown[] };
|
|
393
393
|
export function nextBacklogTask(repository: ArtifactRepository): ArtifactRecord | null;
|
|
394
394
|
export function refreshState(scrumDir: string): StateProjection;
|
|
@@ -458,6 +458,8 @@ export function prepareCompletion(projectRoot: string, runArtifact: { record: Ar
|
|
|
458
458
|
export function auditActiveWorkspace(projectRoot: string, runArtifact: { record: ArtifactRecord; body: string }): string | null;
|
|
459
459
|
export function assertCanonicalWrite(projectRoot: string, operation: string, contents?: string[]): { operation: string; policy: string };
|
|
460
460
|
export function expectedWorkspace(runArtifact: { record: ArtifactRecord; body: string }): unknown;
|
|
461
|
+
export function pathOverlaps(a: string, b: string): boolean;
|
|
462
|
+
export function permitsOverlap(pathsA: string[], pathsB: string[]): boolean;
|
|
461
463
|
|
|
462
464
|
// ---------------------------------------------------------------------------
|
|
463
465
|
// lib/runtime/policy-engine
|