p-backlog 0.3.0 → 0.4.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 (33) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli.js +363 -103
  3. package/dist/web/assets/{ChartTooltip-DwQKHXkN.js → ChartTooltip-DWaDSRan.js} +3 -3
  4. package/dist/web/assets/CodeTab-C87LaJFD.js +1 -0
  5. package/dist/web/assets/{CostTab-B21EB-7P.js → CostTab-D4ByOnHX.js} +1 -1
  6. package/dist/web/assets/EffectTab-Dq06ghtZ.js +1 -0
  7. package/dist/web/assets/Figure-BC1eGh3_.js +1 -0
  8. package/dist/web/assets/OverviewTab-BBmohHJR.js +1 -0
  9. package/dist/web/assets/QualityTab-Rb7zRcGH.js +1 -0
  10. package/dist/web/assets/StatsPage-Dtyc1gAc.js +1 -0
  11. package/dist/web/assets/StatsTabState-Br-8FgKd.js +1 -0
  12. package/dist/web/assets/{StatsTable-Bd3vAxKU.js → StatsTable-BXG0IS8p.js} +1 -1
  13. package/dist/web/assets/UnavailableRepos-KiTvsm1X.js +1 -0
  14. package/dist/web/assets/cx-CNNf0q6V.js +58 -0
  15. package/dist/web/assets/index-AJ1Qzly1.js +2 -0
  16. package/dist/web/assets/start-BGXTwsB0.js +38 -0
  17. package/dist/web/assets/{index-CAKUAPJF.css → start-Cy19NMke.css} +1 -1
  18. package/dist/web/assets/{value-dot-kiMizZCu.js → value-dot-W-8PsGpd.js} +1 -1
  19. package/dist/web/index.html +1 -5
  20. package/package.json +1 -1
  21. package/skill/backlog/SKILL.md +3 -2
  22. package/skill/backlog-en/SKILL.md +3 -2
  23. package/dist/web/assets/CodeTab-BGxsIZMH.js +0 -1
  24. package/dist/web/assets/EffectTab-97OFZkWY.js +0 -1
  25. package/dist/web/assets/Figure-CiIqHKnb.js +0 -1
  26. package/dist/web/assets/OverviewTab-X_g8WfUl.js +0 -1
  27. package/dist/web/assets/QualityTab-DYnFz37G.js +0 -1
  28. package/dist/web/assets/StatsPage-D9gJHHQg.js +0 -1
  29. package/dist/web/assets/StatsTabState-DbuUIt-D.js +0 -1
  30. package/dist/web/assets/UnavailableRepos-D2iehY7u.js +0 -1
  31. package/dist/web/assets/cx-BZTW4xgb.js +0 -58
  32. package/dist/web/assets/index-BrkoyuWi.js +0 -38
  33. package/dist/web/assets/paths-ZO6w2vYU.js +0 -1
package/dist/cli.js CHANGED
@@ -170,6 +170,17 @@ function completedEpicChildren(task, index) {
170
170
  const complete = children.length > 0 && children.every((child) => isClosed(child.status));
171
171
  return complete ? children.map((child) => child.id) : null;
172
172
  }
173
+ function isAutoClosedEpic(task) {
174
+ return task.type === "epic" && isClosed(task.status) && task.resolution === "epic-done";
175
+ }
176
+ function planEpicReopening(tasks) {
177
+ const index = buildIndex(tasks);
178
+ return tasks.flatMap((epic) => {
179
+ if (!isAutoClosedEpic(epic)) return [];
180
+ const openChildIds = epicChildren(epic, index).flatMap((child) => isClosed(child.status) ? [] : [child.id]);
181
+ return openChildIds.length === 0 ? [] : [{ epic, childIds: openChildIds }];
182
+ });
183
+ }
173
184
  function epicDoneClosure(childIds, messages) {
174
185
  return { resolution: "epic-done", reason: messages.epicDoneReason(childIds) };
175
186
  }
@@ -936,18 +947,18 @@ function compareIds(a, b) {
936
947
  if (!left || !right) return a.localeCompare(b);
937
948
  return left.prefix === right.prefix ? left.number - right.number : left.prefix.localeCompare(right.prefix);
938
949
  }
939
- function derivePrefix(basename10, taken) {
940
- const base = prefixBase(basename10);
950
+ function derivePrefix(basename11, taken) {
951
+ const base = prefixBase(basename11);
941
952
  return firstFree(base, taken, (n) => `${base}${n}`);
942
953
  }
943
- function deriveProjectId(basename10, taken) {
944
- const slug = basename10.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
954
+ function deriveProjectId(basename11, taken) {
955
+ const slug = basename11.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
945
956
  const base = slug === "" ? "project" : slug;
946
957
  return firstFree(base, taken, (n) => `${base}-${n}`);
947
958
  }
948
959
  var PREFIX_BASE_LENGTH = 4;
949
- function prefixBase(basename10) {
950
- const words = basename10.split(/[^A-Za-z0-9]+/).filter(Boolean);
960
+ function prefixBase(basename11) {
961
+ const words = basename11.split(/[^A-Za-z0-9]+/).filter(Boolean);
951
962
  const letters = words.length > 1 ? words.map((word) => word.charAt(0)).join("") : words[0] ?? "";
952
963
  const prefix = letters.slice(0, PREFIX_BASE_LENGTH).toUpperCase();
953
964
  if (prefix === "") return "PROJ";
@@ -1040,7 +1051,7 @@ function recordedMethodOf({ method, bySymbol, byAnchor }) {
1040
1051
  if (method !== void 0) return method;
1041
1052
  return bySymbol === true || byAnchor === true ? checkMethodOf({ bySymbol, byAnchor }) : "unknown";
1042
1053
  }
1043
- var eventBase = { at: z4.iso.datetime({ offset: true }), task: z4.string().min(1), via: recordedEnum(CHANGE_SOURCES) };
1054
+ var eventBase = { at: z4.iso.datetime({ offset: true }), task: z4.string().min(1), via: recordedEnum(CHANGE_SOURCES), undo: z4.literal(true).optional().catch(void 0) };
1044
1055
  var taskSnapshotSchema = taskFrontmatterSchema.extend({
1045
1056
  priority: recordedEnum(PRIORITIES),
1046
1057
  category: recordedEnum(TASK_CATEGORIES).optional(),
@@ -1129,6 +1140,10 @@ function changeEvents(before, after, now, via) {
1129
1140
  }
1130
1141
  return events;
1131
1142
  }
1143
+ function statusBeforeAutoClose(journal, epicId) {
1144
+ const autoClose = journal.findLast((event) => event.task === epicId && event.kind === "status" && event.resolution === "epic-done");
1145
+ return autoClose?.kind === "status" && !isClosed(autoClose.from) ? autoClose.from : "backlog";
1146
+ }
1132
1147
  function deletedEvent(task, now, via) {
1133
1148
  return { at: formatLocalIso(now), task: task.id, via, kind: "deleted", snapshot: snapshotOf(task) };
1134
1149
  }
@@ -1164,13 +1179,26 @@ function candidateGoneEvents(sightings, tasks, states, now, checked = CANDIDATE_
1164
1179
  }
1165
1180
  function episodeStates(journal) {
1166
1181
  const states = /* @__PURE__ */ new Map();
1182
+ const beforeClosing = /* @__PURE__ */ new Map();
1167
1183
  for (const event of journal) {
1168
1184
  if (event.kind === "candidate") states.set(episodeKey(event.task, event.evidence), "open");
1169
1185
  else if (event.kind === "candidate-gone") states.set(episodeKey(event.task, event.evidence), "ended");
1170
- else if (endsEpisodes(event)) for (const evidence2 of CANDIDATE_EVIDENCE) states.set(episodeKey(event.task, evidence2), "ended");
1186
+ else if (undoesClosing(event)) {
1187
+ for (const [key, state] of beforeClosing.get(event.task) ?? []) {
1188
+ if (state === void 0) states.delete(key);
1189
+ else states.set(key, state);
1190
+ }
1191
+ } else if (endsEpisodes(event)) {
1192
+ const keys = CANDIDATE_EVIDENCE.map((evidence2) => episodeKey(event.task, evidence2));
1193
+ if (event.kind === "status") beforeClosing.set(event.task, keys.map((key) => [key, states.get(key)]));
1194
+ for (const key of keys) states.set(key, "ended");
1195
+ }
1171
1196
  }
1172
1197
  return states;
1173
1198
  }
1199
+ function undoesClosing(event) {
1200
+ return event.kind === "status" && event.undo === true && isClosed(event.from) && !isClosed(event.to);
1201
+ }
1174
1202
  function endsEpisodes(event) {
1175
1203
  if (event.kind === "verified" || event.kind === "deleted") return true;
1176
1204
  return event.kind === "status" && isClosed(event.to);
@@ -1203,7 +1231,7 @@ function taskHistories(tasks, journals, unparsedIds = /* @__PURE__ */ new Set())
1203
1231
  if (event.kind === "category") item.categoryEvents.push({ at: Date.parse(event.at), to: event.to });
1204
1232
  if (event.kind === "priority") item.priorityEvents.push({ at: Date.parse(event.at), to: event.to });
1205
1233
  if (event.kind === "status") {
1206
- item.transitions.push({ at: Date.parse(event.at), from: event.from, to: event.to, resolution: event.resolution, via: event.via });
1234
+ item.transitions.push({ at: Date.parse(event.at), from: event.from, to: event.to, resolution: event.resolution, via: event.via, undo: event.undo });
1207
1235
  }
1208
1236
  if (event.kind === "candidate") item.candidates.push({ at: Date.parse(event.at), evidence: event.evidence, method: recordedMethodOf(event), match: event.match ?? "unknown" });
1209
1237
  if (event.kind === "candidate-filtered") item.filtered.push(Date.parse(event.at));
@@ -1235,7 +1263,7 @@ function historyOf(id, { projectId, final, created, categoryEvents, priorityEven
1235
1263
  const createdIso = final?.created ?? created?.at;
1236
1264
  const type = final?.type ?? created?.type;
1237
1265
  if (createdIso === void 0 || type === void 0) return [];
1238
- const ordered = [...transitions].sort((a, b) => a.at - b.at);
1266
+ const ordered = withoutUndoneClosings([...transitions].sort((a, b) => a.at - b.at));
1239
1267
  const fateUnknown = final === void 0 && unparsed;
1240
1268
  return [
1241
1269
  {
@@ -1257,6 +1285,15 @@ function historyOf(id, { projectId, final, created, categoryEvents, priorityEven
1257
1285
  }
1258
1286
  ];
1259
1287
  }
1288
+ function withoutUndoneClosings(ordered) {
1289
+ const kept = [];
1290
+ for (const transition of ordered) {
1291
+ const lastKept = kept.at(-1);
1292
+ if (transition.undo === true && lastKept !== void 0 && isClosing(lastKept) && transition.from === lastKept.to) kept.pop();
1293
+ else kept.push(transition);
1294
+ }
1295
+ return kept;
1296
+ }
1260
1297
  function categoryOf(final, created, categoryEvents) {
1261
1298
  if (final !== void 0) return final.category;
1262
1299
  const lastCategoryEvent = [...categoryEvents].sort((a, b) => a.at - b.at).at(-1);
@@ -1462,6 +1499,8 @@ function problem(p) {
1462
1499
  return `epic ${p.epic} not found`;
1463
1500
  case "epic-not-epic":
1464
1501
  return `${p.epic} is not an epic`;
1502
+ case "epic-foreign-project":
1503
+ return `epic ${p.epic} belongs to another project \u2014 clear the epic or pick one from this project`;
1465
1504
  case "epic-in-epic":
1466
1505
  return "an epic cannot belong to another epic";
1467
1506
  case "reference-missing":
@@ -1494,6 +1533,8 @@ function checkFix(fix) {
1494
1533
  return `${fix.taskId}: removed references to missing tasks: ${fix.ids.join(", ")}`;
1495
1534
  case "epic-closed":
1496
1535
  return `${fix.taskId}: epic closed \u2014 ${epicDoneReason(fix.childIds)}`;
1536
+ case "epic-reopened":
1537
+ return `${fix.taskId}: epic reopened \u2014 it has open tasks: ${fix.childIds.join(", ")}`;
1497
1538
  case "source-moved":
1498
1539
  return `${fix.taskId}: source moved ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
1499
1540
  }
@@ -1691,6 +1732,8 @@ function problem2(p) {
1691
1732
  return `\u044D\u043F\u0438\u043A ${p.epic} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`;
1692
1733
  case "epic-not-epic":
1693
1734
  return `${p.epic} \u043D\u0435 \u044F\u0432\u043B\u044F\u0435\u0442\u0441\u044F \u044D\u043F\u0438\u043A\u043E\u043C`;
1735
+ case "epic-foreign-project":
1736
+ return `\u044D\u043F\u0438\u043A ${p.epic} \u0438\u0437 \u0434\u0440\u0443\u0433\u043E\u0433\u043E \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u2014 \u0441\u043D\u0438\u043C\u0438\u0442\u0435 \u044D\u043F\u0438\u043A \u0438\u043B\u0438 \u0432\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u044D\u043F\u0438\u043A \u044D\u0442\u043E\u0433\u043E \u043F\u0440\u043E\u0435\u043A\u0442\u0430`;
1694
1737
  case "epic-in-epic":
1695
1738
  return "\u044D\u043F\u0438\u043A \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0432\u0445\u043E\u0434\u0438\u0442\u044C \u0432 \u0434\u0440\u0443\u0433\u043E\u0439 \u044D\u043F\u0438\u043A";
1696
1739
  case "reference-missing":
@@ -1723,6 +1766,8 @@ function checkFix2(fix) {
1723
1766
  return `${fix.taskId}: \u0443\u0431\u0440\u0430\u043D\u044B \u0441\u0441\u044B\u043B\u043A\u0438 \u043D\u0430 \u043D\u0435\u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044E\u0449\u0438\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${fix.ids.join(", ")}`;
1724
1767
  case "epic-closed":
1725
1768
  return `${fix.taskId}: \u044D\u043F\u0438\u043A \u0437\u0430\u043A\u0440\u044B\u0442 \u2014 ${epicDoneReason2(fix.childIds)}`;
1769
+ case "epic-reopened":
1770
+ return `${fix.taskId}: \u044D\u043F\u0438\u043A \u0441\u043D\u043E\u0432\u0430 \u043E\u0442\u043A\u0440\u044B\u0442 \u2014 \u0432 \u043D\u0451\u043C \u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${fix.childIds.join(", ")}`;
1726
1771
  case "source-moved":
1727
1772
  return `${fix.taskId}: source \u0441\u0434\u0432\u0438\u043D\u0443\u043B\u0441\u044F ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
1728
1773
  }
@@ -2001,7 +2046,7 @@ function isTaskFileName(name) {
2001
2046
  }
2002
2047
 
2003
2048
  // src/cli/lookups.ts
2004
- import { basename as basename6, dirname as dirname3, relative as relative2, sep as sep2 } from "node:path";
2049
+ import { basename as basename6, dirname as dirname4, relative as relative2, sep as sep2 } from "node:path";
2005
2050
 
2006
2051
  // src/core/store/create.ts
2007
2052
  import { mkdir as mkdir3 } from "node:fs/promises";
@@ -2035,6 +2080,7 @@ function epicProblems(candidate, resolve7) {
2035
2080
  const epic = resolve7(candidate.epic);
2036
2081
  if (!epic) problems3.push({ code: "epic-missing", epic: candidate.epic });
2037
2082
  else if (epic.type !== "epic") problems3.push({ code: "epic-not-epic", epic: candidate.epic });
2083
+ else if (epic.projectId !== candidate.projectId) problems3.push({ code: "epic-foreign-project", epic: candidate.epic });
2038
2084
  }
2039
2085
  if (candidate.type === "epic") problems3.push({ code: "epic-in-epic" });
2040
2086
  return problems3;
@@ -2134,11 +2180,93 @@ function taskText(draft) {
2134
2180
  return { ok: true, value: { text, task: { ...parsed.value, version: contentVersion(text) } } };
2135
2181
  }
2136
2182
 
2183
+ // src/core/store/update.ts
2184
+ import { dirname as dirname3 } from "node:path";
2185
+
2137
2186
  // src/core/store/write-result.ts
2138
2187
  function invalid(errors) {
2139
2188
  return { ok: false, reason: "invalid", errors };
2140
2189
  }
2141
2190
 
2191
+ // src/core/store/update.ts
2192
+ var CHANGE_FIELDS = ["title", "type", "priority", "tags", "blockedBy", "related", "body", "source", "verified"];
2193
+ async function updateTaskInIndex(index, request) {
2194
+ const before = index.byId.get(request.id);
2195
+ const result = await writeChanges(index, request);
2196
+ if (result.ok) await reopenEpicOfOpenedTask(index, { before, after: result.task, now: request.now, via: request.via, undo: request.undo });
2197
+ return result;
2198
+ }
2199
+ async function statusToReopen(epic) {
2200
+ const { events } = await readJournal(dirname3(epic.path), epic.projectId);
2201
+ return statusBeforeAutoClose(events, epic.id);
2202
+ }
2203
+ async function reopenEpicOfOpenedTask(index, { before, after, now, via, undo }) {
2204
+ if (after.epic === void 0 || isClosed(after.status)) return;
2205
+ const becameOpenInEpic = before === void 0 || isClosed(before.status) || before.epic !== after.epic;
2206
+ const epic = index.byId.get(after.epic);
2207
+ if (!becameOpenInEpic || epic === void 0 || !isAutoClosedEpic(epic)) return;
2208
+ try {
2209
+ if (await diskChange(epic, epic.version) !== null) return;
2210
+ await writeChanges(index, { id: epic.id, changes: { status: await statusToReopen(epic) }, expectedVersion: epic.version, now, via, undo });
2211
+ } catch (error) {
2212
+ if (!(error instanceof FileBusyError)) console.error(`${epic.path}: ${errorText(error)}`);
2213
+ }
2214
+ }
2215
+ async function writeChanges(index, { id, changes, expectedVersion, now, closure, via, undo = false }) {
2216
+ const current = index.byId.get(id);
2217
+ if (!current) return { ok: false, reason: "not-found" };
2218
+ if (expectedVersion !== current.version) return { ok: false, reason: "conflict", current };
2219
+ const normalized = taskText(applyChanges(current, changes, now, closure));
2220
+ if (!normalized.ok) return invalid(normalized.problems);
2221
+ const { text, task } = normalized.value;
2222
+ const errors = integrityErrors(task, index);
2223
+ if (errors.length > 0) return invalid(errors);
2224
+ return withFileLock(current.path, async () => {
2225
+ const changedOnDisk = await diskChange(current, expectedVersion);
2226
+ if (changedOnDisk !== null) return changedOnDisk;
2227
+ await writeFileAtomic(current.path, text);
2228
+ const events = changeEvents(current, task, now, via);
2229
+ await appendJournal(dirname3(current.path), undo ? events.map((event) => ({ ...event, undo: true })) : events);
2230
+ return { ok: true, task };
2231
+ });
2232
+ }
2233
+ async function diskChange(snapshot, expectedVersion) {
2234
+ const text = await readTextOrNull(snapshot.path);
2235
+ if (text === null) return { ok: false, reason: "not-found" };
2236
+ const version = contentVersion(text);
2237
+ if (version === expectedVersion) return null;
2238
+ const fresh = parseTaskFile(text, { projectId: snapshot.projectId, path: snapshot.path, version });
2239
+ return { ok: false, reason: "conflict", current: fresh.ok ? fresh.value : snapshot };
2240
+ }
2241
+ function applyChanges(task, changes, now, closure) {
2242
+ const edited = {
2243
+ ...task,
2244
+ ...pickDefined(changes, CHANGE_FIELDS),
2245
+ epic: nextOptional(task.epic, changes.epic),
2246
+ category: nextOptional(task.category, changes.category),
2247
+ anchor: nextAnchor(task, changes)
2248
+ };
2249
+ const moved = changes.status === void 0 ? edited : changeStatus(edited, changes.status, now, closure);
2250
+ return settleLifecycle(moved, now);
2251
+ }
2252
+ function nextAnchor(task, changes) {
2253
+ if (changes.anchor !== void 0) return changes.anchor ?? void 0;
2254
+ const sourceMoved = changes.source !== void 0 && changes.source !== task.source;
2255
+ return sourceMoved ? void 0 : task.anchor;
2256
+ }
2257
+ function pickDefined(source, keys) {
2258
+ const picked = {};
2259
+ for (const key of keys) {
2260
+ const value = source[key];
2261
+ if (value !== void 0) picked[key] = value;
2262
+ }
2263
+ return picked;
2264
+ }
2265
+ function nextOptional(current, change) {
2266
+ if (change === null) return void 0;
2267
+ return change ?? current;
2268
+ }
2269
+
2142
2270
  // src/core/store/create.ts
2143
2271
  var MAX_ID_ATTEMPTS = 20;
2144
2272
  async function createTask(root, request) {
@@ -2156,6 +2284,7 @@ async function createTask(root, request) {
2156
2284
  try {
2157
2285
  await createFileAtomic(path, text);
2158
2286
  await appendJournal(dir, [createdEvent(task, request.now, request.via, request.provenance)]);
2287
+ await reopenEpicOfOpenedTask(index, { before: void 0, after: task, now: request.now, via: request.via });
2159
2288
  return { ok: true, task };
2160
2289
  } catch (error) {
2161
2290
  if (!hasErrorCode(error, "EEXIST")) throw error;
@@ -2166,7 +2295,8 @@ async function createTask(root, request) {
2166
2295
  async function createProject(root, repoRoot, existingProjects) {
2167
2296
  const name = basename5(repoRoot);
2168
2297
  const entries = await listDir(root);
2169
- const id = deriveProjectId(name, new Set(entries.map((entry) => entry.name)));
2298
+ const joinable = await Promise.all(entries.map((entry) => entry.isDirectory() && isJoinableProjectDir(join9(root, entry.name), repoRoot)));
2299
+ const id = deriveProjectId(name, new Set(entries.filter((_, index) => !joinable[index]).map((entry) => entry.name)));
2170
2300
  const prefixesOnDisk = await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => takenPrefixes(join9(root, entry.name))));
2171
2301
  const prefix = derivePrefix(name, /* @__PURE__ */ new Set([...existingProjects.map((project) => project.prefix), ...prefixesOnDisk.flat()]));
2172
2302
  const dir = join9(root, id);
@@ -2184,6 +2314,11 @@ async function createProject(root, repoRoot, existingProjects) {
2184
2314
  if (!parsed.ok) throw new Error(`${path}: ${parsed.problems.map((problem3) => problem3.code).join(", ")}`);
2185
2315
  return parsed.value;
2186
2316
  }
2317
+ async function isJoinableProjectDir(dir, repoRoot) {
2318
+ const path = join9(dir, PROJECT_FILE);
2319
+ if (await readTextOrNull(path) !== null) return await projectOfRepo(basename5(dir), path, repoRoot) !== null;
2320
+ return (await listDir(dir)).every((entry) => !entry.name.endsWith(".md"));
2321
+ }
2187
2322
  async function projectOfRepo(id, path, repoRoot) {
2188
2323
  const parsed = await readProjectFile({ id, path });
2189
2324
  return parsed?.ok === true && parsed.value.repos.includes(repoRoot) ? parsed.value : null;
@@ -2424,7 +2559,7 @@ async function brokenProjectFilesOf(loaded, roots, home2) {
2424
2559
  const ownId = deriveProjectId(basename6(roots.main), /* @__PURE__ */ new Set());
2425
2560
  const broken = loaded.errors.filter((error) => basename6(error.path) === PROJECT_FILE);
2426
2561
  const texts = await Promise.all(broken.map(async (error) => await readTextOrNull(error.path) ?? ""));
2427
- return broken.filter((error, position) => basename6(dirname3(error.path)) === ownId || repoPaths.some((path) => mentionsPath(texts[position] ?? "", path)));
2562
+ return broken.filter((error, position) => basename6(dirname4(error.path)) === ownId || repoPaths.some((path) => mentionsPath(texts[position] ?? "", path)));
2428
2563
  }
2429
2564
  var PATH_BOUNDARY = /[\s"',[\]]/;
2430
2565
  var TRAILING_SEPARATORS = /^[\\/]*/;
@@ -2446,63 +2581,6 @@ function findProject(loaded, io, explicitId) {
2446
2581
  return explicitId === void 0 ? findProjectForDir(loaded.projects, io.cwd, io.home) : loaded.projects.find((project) => project.id === explicitId);
2447
2582
  }
2448
2583
 
2449
- // src/core/store/update.ts
2450
- import { dirname as dirname4 } from "node:path";
2451
- var CHANGE_FIELDS = ["title", "type", "priority", "tags", "blockedBy", "related", "body", "source", "verified"];
2452
- async function updateTaskInIndex(index, { id, changes, expectedVersion, now, closure, via }) {
2453
- const current = index.byId.get(id);
2454
- if (!current) return { ok: false, reason: "not-found" };
2455
- if (expectedVersion !== current.version) return { ok: false, reason: "conflict", current };
2456
- const normalized = taskText(applyChanges(current, changes, now, closure));
2457
- if (!normalized.ok) return invalid(normalized.problems);
2458
- const { text, task } = normalized.value;
2459
- const errors = integrityErrors(task, index);
2460
- if (errors.length > 0) return invalid(errors);
2461
- return withFileLock(current.path, async () => {
2462
- const changedOnDisk = await diskChange(current, expectedVersion);
2463
- if (changedOnDisk !== null) return changedOnDisk;
2464
- await writeFileAtomic(current.path, text);
2465
- await appendJournal(dirname4(current.path), changeEvents(current, task, now, via));
2466
- return { ok: true, task };
2467
- });
2468
- }
2469
- async function diskChange(snapshot, expectedVersion) {
2470
- const text = await readTextOrNull(snapshot.path);
2471
- if (text === null) return { ok: false, reason: "not-found" };
2472
- const version = contentVersion(text);
2473
- if (version === expectedVersion) return null;
2474
- const fresh = parseTaskFile(text, { projectId: snapshot.projectId, path: snapshot.path, version });
2475
- return { ok: false, reason: "conflict", current: fresh.ok ? fresh.value : snapshot };
2476
- }
2477
- function applyChanges(task, changes, now, closure) {
2478
- const edited = {
2479
- ...task,
2480
- ...pickDefined(changes, CHANGE_FIELDS),
2481
- epic: nextOptional(task.epic, changes.epic),
2482
- category: nextOptional(task.category, changes.category),
2483
- anchor: nextAnchor(task, changes)
2484
- };
2485
- const moved = changes.status === void 0 ? edited : changeStatus(edited, changes.status, now, closure);
2486
- return settleLifecycle(moved, now);
2487
- }
2488
- function nextAnchor(task, changes) {
2489
- if (changes.anchor !== void 0) return changes.anchor ?? void 0;
2490
- const sourceMoved = changes.source !== void 0 && changes.source !== task.source;
2491
- return sourceMoved ? void 0 : task.anchor;
2492
- }
2493
- function pickDefined(source, keys) {
2494
- const picked = {};
2495
- for (const key of keys) {
2496
- const value = source[key];
2497
- if (value !== void 0) picked[key] = value;
2498
- }
2499
- return picked;
2500
- }
2501
- function nextOptional(current, change) {
2502
- if (change === null) return void 0;
2503
- return change ?? current;
2504
- }
2505
-
2506
2584
  // src/cli/update-failure.ts
2507
2585
  function reportUpdateFailure(io, id, result) {
2508
2586
  switch (result.reason) {
@@ -3384,14 +3462,12 @@ function sightingOf(candidate) {
3384
3462
  }
3385
3463
  async function applyFixes(loaded, inScope, { now, messages }) {
3386
3464
  const isGone = goneTaskCheck(loaded);
3387
- const epicClosures = new Map(
3388
- planEpicClosing(loaded.tasks, loaded.errors).close.map(({ epic, childIds }) => [epic.id, { closure: epicDoneClosure(childIds, messages), childIds }])
3389
- );
3465
+ const epicFixes = await epicStatusFixes(loaded, inScope, messages);
3390
3466
  const index = buildIndex(loaded.tasks);
3391
3467
  const fixed = [];
3392
3468
  const failed = [];
3393
3469
  for (const task of loaded.tasks.filter((candidate) => inScope(candidate.projectId))) {
3394
- const fix = planFix(task, isGone, epicClosures.get(task.id));
3470
+ const fix = planFix(task, isGone, epicFixes.get(task.id));
3395
3471
  if (fix === null) continue;
3396
3472
  const result = await updateTaskInIndex(index, { id: task.id, changes: fix.changes, expectedVersion: task.version, now, closure: fix.closure, via: "check" });
3397
3473
  if (result.ok) fixed.push(...fix.done);
@@ -3409,14 +3485,24 @@ function fixFailure(taskId, failure) {
3409
3485
  return { kind: "fix-failed", taskId, cause: "gone-during-check" };
3410
3486
  }
3411
3487
  }
3412
- function planFix(task, isGone, epicClosing) {
3488
+ async function epicStatusFixes(loaded, inScope, messages) {
3489
+ const closing = planEpicClosing(loaded.tasks, loaded.errors).close.map(({ epic, childIds }) => [
3490
+ epic.id,
3491
+ { status: "done", closure: epicDoneClosure(childIds, messages), done: { kind: "epic-closed", taskId: epic.id, childIds } }
3492
+ ]);
3493
+ const reopenable = planEpicReopening(loaded.tasks).filter(({ epic }) => inScope(epic.projectId));
3494
+ const reopening = await Promise.all(
3495
+ reopenable.map(async ({ epic, childIds }) => [epic.id, { status: await statusToReopen(epic), done: { kind: "epic-reopened", taskId: epic.id, childIds } }])
3496
+ );
3497
+ return new Map([...closing, ...reopening]);
3498
+ }
3499
+ function planFix(task, isGone, epicFix) {
3413
3500
  const cleanup = referenceCleanup(task, isGone);
3414
- if (cleanup === null && epicClosing === void 0) return null;
3501
+ if (cleanup === null && epicFix === void 0) return null;
3415
3502
  const done = [];
3416
3503
  if (cleanup !== null) done.push({ kind: "references-removed", taskId: task.id, ids: goneReferences(task, isGone) });
3417
- if (epicClosing === void 0) return { changes: cleanup ?? {}, done };
3418
- done.push({ kind: "epic-closed", taskId: task.id, childIds: epicClosing.childIds });
3419
- return { changes: { ...cleanup, status: "done" }, closure: epicClosing.closure, done };
3504
+ if (epicFix === void 0) return { changes: cleanup ?? {}, done };
3505
+ return { changes: { ...cleanup, status: epicFix.status }, closure: epicFix.closure, done: [...done, epicFix.done] };
3420
3506
  }
3421
3507
  function goneReferences(task, isGone) {
3422
3508
  const references = [...task.blockedBy, ...task.related, ...task.epic === void 0 ? [] : [task.epic]];
@@ -4603,6 +4689,13 @@ var serverEn = {
4603
4689
  projectNotFound: (id) => `Project ${id} not found`,
4604
4690
  confirmMismatch: "Confirmation does not match the project id",
4605
4691
  bodyNotParsed: "Request body could not be parsed: JSON expected",
4692
+ batchSkipped: {
4693
+ changed: (id) => `${id} changed on disk`,
4694
+ "not-found": (id) => `${id} not found`,
4695
+ "already-closed": (id) => `${id} is already closed`,
4696
+ invalid: (id) => `${id}: the action doesn't apply to it`,
4697
+ busy: (id) => `${id} is busy in another process`
4698
+ },
4606
4699
  unknownRoute: (path) => `Unknown API route: ${path}`,
4607
4700
  hostRejected: (host) => `Requests from host ${host} are not accepted`,
4608
4701
  jsonContentTypeExpected: "Content-Type: application/json is expected",
@@ -4613,6 +4706,7 @@ Backlog directory: ${root}
4613
4706
  settingsFileInvalid: (path) => `${path} could not be parsed, the language for this run was detected automatically. The file was not changed \u2014 fix it manually.`,
4614
4707
  sweepFailed: (detail) => `Could not delete closed tasks: ${detail}`,
4615
4708
  closedEpics: (ids) => `Closed completed epics: ${ids}`,
4709
+ reopenedEpics: (ids) => `Reopened epics that got an open task again: ${ids}`,
4616
4710
  epicsBlockedByFiles: (paths) => `Epics will not close until these files are fixed: ${paths}`,
4617
4711
  deletedClosedTasks: (ids) => `Deleted closed tasks: ${ids}`,
4618
4712
  conflictedDuringSweep: (ids) => `Tasks changed during the sweep, will retry next time: ${ids}`,
@@ -4633,6 +4727,13 @@ var serverRu = {
4633
4727
  projectNotFound: (id) => `\u041F\u0440\u043E\u0435\u043A\u0442 ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`,
4634
4728
  confirmMismatch: "\u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u0435 \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 \u0441 id \u043F\u0440\u043E\u0435\u043A\u0442\u0430",
4635
4729
  bodyNotParsed: "\u0422\u0435\u043B\u043E \u0437\u0430\u043F\u0440\u043E\u0441\u0430 \u043D\u0435 \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D\u043E: \u043E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F JSON",
4730
+ batchSkipped: {
4731
+ changed: (id) => `${id} \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0430\u0441\u044C \u043D\u0430 \u0434\u0438\u0441\u043A\u0435`,
4732
+ "not-found": (id) => `${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430`,
4733
+ "already-closed": (id) => `${id} \u0443\u0436\u0435 \u0437\u0430\u043A\u0440\u044B\u0442\u0430`,
4734
+ invalid: (id) => `${id}: \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435 \u043A \u043D\u0435\u0439 \u043D\u0435 \u043F\u043E\u0434\u0445\u043E\u0434\u0438\u0442`,
4735
+ busy: (id) => `${id} \u0437\u0430\u043D\u044F\u0442\u0430 \u0434\u0440\u0443\u0433\u0438\u043C \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u043C`
4736
+ },
4636
4737
  unknownRoute: (path) => `\u041D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0430\u0434\u0440\u0435\u0441 API: ${path}`,
4637
4738
  hostRejected: (host) => `\u0417\u0430\u043F\u0440\u043E\u0441\u044B \u0441 \u0445\u043E\u0441\u0442\u0430 ${host} \u043D\u0435 \u043F\u0440\u0438\u043D\u0438\u043C\u0430\u044E\u0442\u0441\u044F`,
4638
4739
  jsonContentTypeExpected: "\u041E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F Content-Type: application/json",
@@ -4643,6 +4744,7 @@ var serverRu = {
4643
4744
  settingsFileInvalid: (path) => `${path} \u043D\u0435 \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D, \u044F\u0437\u044B\u043A \u0434\u043B\u044F \u044D\u0442\u043E\u0433\u043E \u0437\u0430\u043F\u0443\u0441\u043A\u0430 \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0451\u043D \u0430\u0432\u0442\u043E\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438. \u0424\u0430\u0439\u043B \u043D\u0435 \u0438\u0437\u043C\u0435\u043D\u0451\u043D \u2014 \u043F\u043E\u043F\u0440\u0430\u0432\u044C\u0442\u0435 \u0435\u0433\u043E \u0432\u0440\u0443\u0447\u043D\u0443\u044E.`,
4644
4745
  sweepFailed: (detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0443\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043A\u0440\u044B\u0442\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${detail}`,
4645
4746
  closedEpics: (ids) => `\u0417\u0430\u043A\u0440\u044B\u0442\u044B \u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043D\u043D\u044B\u0435 \u044D\u043F\u0438\u043A\u0438: ${ids}`,
4747
+ reopenedEpics: (ids) => `\u0421\u043D\u043E\u0432\u0430 \u043E\u0442\u043A\u0440\u044B\u0442\u044B \u044D\u043F\u0438\u043A\u0438, \u0432 \u043A\u043E\u0442\u043E\u0440\u044B\u0445 \u043E\u0442\u043A\u0440\u044B\u043B\u0438 \u0437\u0430\u0434\u0430\u0447\u0443: ${ids}`,
4646
4748
  epicsBlockedByFiles: (paths) => `\u042D\u043F\u0438\u043A\u0438 \u043D\u0435 \u0437\u0430\u043A\u0440\u044B\u0432\u0430\u044E\u0442\u0441\u044F, \u043F\u043E\u043A\u0430 \u043D\u0435 \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D\u044B \u0444\u0430\u0439\u043B\u044B: ${paths}`,
4647
4749
  deletedClosedTasks: (ids) => `\u0423\u0434\u0430\u043B\u0435\u043D\u044B \u0437\u0430\u043A\u0440\u044B\u0442\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${ids}`,
4648
4750
  conflictedDuringSweep: (ids) => `\u0417\u0430\u0434\u0430\u0447\u0438 \u043C\u0435\u043D\u044F\u043B\u0438\u0441\u044C \u0432\u043E \u0432\u0440\u0435\u043C\u044F \u043F\u0440\u043E\u0445\u043E\u0434\u0430, \u043F\u043E\u0432\u0442\u043E\u0440\u044E \u043F\u0440\u0438 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0435\u043C: ${ids}`,
@@ -4692,19 +4794,22 @@ import { join as join25 } from "node:path";
4692
4794
  import { dirname as dirname7, join as join20 } from "node:path";
4693
4795
  async function sweepClosed(root, now, messages) {
4694
4796
  const initial = await loadBacklog(root);
4797
+ const reopening = await reopenEpicsWithOpenTasks(initial, now);
4695
4798
  const epics = await closeCompletedEpics(initial, now, messages);
4696
- const { projects, tasks } = epics.closed.length > 0 ? await loadBacklog(root) : initial;
4799
+ const { projects, tasks } = epics.closed.length > 0 || reopening.reopened.length > 0 ? await loadBacklog(root) : initial;
4697
4800
  const waitsForEpic = (task) => task.epic !== void 0 && epics.leftOpen.has(task.epic);
4698
- const expired = tasks.filter((task) => isExpired(task, now) && !waitsForEpic(task));
4801
+ const stillHoldsOpenTasks = new Set(planEpicReopening(tasks).map(({ epic }) => epic.id));
4802
+ const expired = tasks.filter((task) => isExpired(task, now) && !waitsForEpic(task) && !stillHoldsOpenTasks.has(task.id));
4699
4803
  const reserved = await reserveNumbers(projects, expired);
4700
4804
  const removable = expired.filter((task) => reserved.has(task.projectId));
4701
4805
  const removal = await removeWithReferences(tasks, removable, now);
4702
4806
  await removeAbandonedTemporaries(root, now);
4703
4807
  return {
4704
4808
  closedEpics: epics.closed,
4809
+ reopenedEpics: reopening.reopened,
4705
4810
  blockingFiles: epics.blockingFiles,
4706
4811
  deleted: removal.deleted,
4707
- ...failureLists([...epics.failures, ...removal.failures], messages)
4812
+ ...failureLists([...reopening.failures, ...epics.failures, ...removal.failures], messages)
4708
4813
  };
4709
4814
  }
4710
4815
  async function removeAbandonedTemporaries(root, now) {
@@ -4735,6 +4840,17 @@ async function unchangedOnDisk(tasks) {
4735
4840
  );
4736
4841
  return tasks.filter((_, position) => checked[position]);
4737
4842
  }
4843
+ async function reopenEpicsWithOpenTasks(loaded, now) {
4844
+ const index = buildIndex(loaded.tasks);
4845
+ const reopened = [];
4846
+ const failures = [];
4847
+ for (const { epic } of planEpicReopening(loaded.tasks)) {
4848
+ const failure = await repairFailure(index, epic, { status: await statusToReopen(epic) }, now);
4849
+ if (failure === null) reopened.push(epic.id);
4850
+ else failures.push(failure);
4851
+ }
4852
+ return { reopened, failures };
4853
+ }
4738
4854
  async function closeCompletedEpics(loaded, now, messages) {
4739
4855
  const plan = planEpicClosing(loaded.tasks, loaded.errors);
4740
4856
  const index = buildIndex(loaded.tasks);
@@ -4850,6 +4966,23 @@ var updateTaskRequestSchema = z12.strictObject({
4850
4966
  version: z12.string().min(1),
4851
4967
  changes: taskChangesSchema
4852
4968
  });
4969
+ var batchPreviousSchema = z12.strictObject({
4970
+ status: z12.enum(TASK_STATUSES),
4971
+ priority: z12.enum(PRIORITIES),
4972
+ epic: taskIdSchema.nullable(),
4973
+ resolution: z12.enum(RESOLUTIONS).nullable(),
4974
+ reason: z12.string().nullable()
4975
+ });
4976
+ var BATCH_TASKS_LIMIT = 500;
4977
+ var batchRequestSchema = z12.strictObject({
4978
+ tasks: z12.array(z12.strictObject({ id: taskIdSchema, version: z12.string().min(1) })).min(1).max(BATCH_TASKS_LIMIT).refine((tasks) => new Set(tasks.map((task) => task.id)).size === tasks.length),
4979
+ action: z12.discriminatedUnion("kind", [
4980
+ z12.strictObject({ kind: z12.literal("close"), reason: z12.string().transform((reason) => reason.replace(/\s*\n\s*/g, " ").trim()).pipe(z12.string().min(1)) }),
4981
+ z12.strictObject({ kind: z12.literal("priority"), priority: z12.enum(PRIORITIES) }),
4982
+ z12.strictObject({ kind: z12.literal("epic"), epic: taskIdSchema.nullable() }),
4983
+ z12.strictObject({ kind: z12.literal("restore"), changes: z12.record(taskIdSchema, batchPreviousSchema) })
4984
+ ])
4985
+ });
4853
4986
 
4854
4987
  // src/core/check/graph-health.ts
4855
4988
  async function projectGraphHealth(project, tasks, home2) {
@@ -4879,6 +5012,58 @@ function isStale(graph, pinned, hashOf) {
4879
5012
  return count3("changed") > count3("fresh");
4880
5013
  }
4881
5014
 
5015
+ // src/core/store/batch.ts
5016
+ async function applyBatch(index, { tasks, action, now }) {
5017
+ const ordered = [...tasks].sort((left, right) => compareIds(left.id, right.id));
5018
+ const outcomes = [];
5019
+ for (const task of ordered) outcomes.push(await applyOne(index, task, action, now));
5020
+ return outcomes;
5021
+ }
5022
+ async function applyOne(index, { id, version }, action, now) {
5023
+ const current = index.byId.get(id);
5024
+ if (!current) return { id, outcome: "skipped", reason: "not-found" };
5025
+ const plan = planFor(current, action);
5026
+ if ("skip" in plan) return { id, outcome: "skipped", reason: plan.skip };
5027
+ const result = await updateUnlessBusy(index, { id, changes: plan.changes, closure: plan.closure, expectedVersion: version, now, via: "web", undo: action.kind === "restore" });
5028
+ if (result === "busy") return { id, outcome: "skipped", reason: "busy" };
5029
+ if (result.ok) return { id, outcome: "done", task: result.task, previous: previousOf(current) };
5030
+ if (result.reason === "conflict") return { id, outcome: "skipped", reason: "changed" };
5031
+ if (result.reason === "not-found") return { id, outcome: "skipped", reason: "not-found" };
5032
+ return { id, outcome: "skipped", reason: "invalid", problems: result.errors };
5033
+ }
5034
+ async function updateUnlessBusy(index, request) {
5035
+ try {
5036
+ return await updateTaskInIndex(index, request);
5037
+ } catch (error) {
5038
+ if (error instanceof FileBusyError) return "busy";
5039
+ throw error;
5040
+ }
5041
+ }
5042
+ function planFor(current, action) {
5043
+ switch (action.kind) {
5044
+ case "close":
5045
+ return isClosed(current.status) ? { skip: "already-closed" } : { changes: { status: "cancelled" }, closure: { resolution: "obsolete", reason: action.reason } };
5046
+ case "priority":
5047
+ return { changes: { priority: action.priority } };
5048
+ case "epic":
5049
+ return epicPlan(current, action.epic);
5050
+ case "restore":
5051
+ return restorePlan(current, action.changes[current.id]);
5052
+ }
5053
+ }
5054
+ function epicPlan(current, epic) {
5055
+ return current.type === "epic" ? { skip: "invalid" } : { changes: { epic } };
5056
+ }
5057
+ function restorePlan(current, previous) {
5058
+ if (!previous) return { skip: "invalid" };
5059
+ const wouldClose = previous.status !== current.status && isClosed(previous.status);
5060
+ if (wouldClose) return { skip: "invalid" };
5061
+ return { changes: { status: previous.status, priority: previous.priority, epic: previous.epic } };
5062
+ }
5063
+ function previousOf(task) {
5064
+ return { status: task.status, priority: task.priority, epic: task.epic ?? null, resolution: task.resolution ?? null, reason: task.reason ?? null };
5065
+ }
5066
+
4882
5067
  // src/server/report-cache.ts
4883
5068
  function createReportCache({ ttlMs, now }) {
4884
5069
  let entries = /* @__PURE__ */ new Map();
@@ -4898,6 +5083,44 @@ function createReportCache({ ttlMs, now }) {
4898
5083
  };
4899
5084
  }
4900
5085
 
5086
+ // src/server/revisions.ts
5087
+ import { randomUUID as randomUUID3 } from "node:crypto";
5088
+ import { stat as stat4 } from "node:fs/promises";
5089
+ import { basename as basename9 } from "node:path";
5090
+ function createRevisions(boot = randomUUID3()) {
5091
+ let seq = 0;
5092
+ const ownMarks = /* @__PURE__ */ new Map();
5093
+ const isOwn = async (path) => {
5094
+ if (basename9(path) === JOURNAL_FILE) return true;
5095
+ const expected = ownMarks.get(path);
5096
+ ownMarks.delete(path);
5097
+ if (expected === void 0) return false;
5098
+ const actual = await markOf(path);
5099
+ return actual !== null && actual.version === expected.version && actual.mtimeMs === expected.mtimeMs;
5100
+ };
5101
+ return {
5102
+ current: () => ({ boot, seq }),
5103
+ recordOwnWrites: async (writes) => {
5104
+ const marks = await Promise.all(writes.map(async ({ path }) => ({ path, mark: await markOf(path) })));
5105
+ seq += 1;
5106
+ marks.forEach(({ path, mark }, index) => {
5107
+ if (mark !== null && mark.version === writes[index]?.version) ownMarks.set(path, mark);
5108
+ else ownMarks.delete(path);
5109
+ });
5110
+ },
5111
+ settle: async (paths) => {
5112
+ const own = paths.length > 0 && (await Promise.all(paths.map((path) => isOwn(path).catch(() => false)))).every(Boolean);
5113
+ if (own) return "own";
5114
+ seq += 1;
5115
+ return "foreign";
5116
+ }
5117
+ };
5118
+ }
5119
+ async function markOf(path) {
5120
+ const [text, stats] = await Promise.all([readTextOrNull(path), stat4(path).catch(() => null)]);
5121
+ return text === null || stats === null ? null : { version: contentVersion(text), mtimeMs: stats.mtimeMs };
5122
+ }
5123
+
4901
5124
  // src/server/stats-api.ts
4902
5125
  import { Hono } from "hono";
4903
5126
 
@@ -5880,9 +6103,10 @@ async function resolveRepoRoots(lookupRepoRoot, cwds) {
5880
6103
  var GRAPH_STATE_TTL_MS = 60 * 1e3;
5881
6104
  function createApi({ root, readLanguage: readLanguage2, changes, now, home: home2, usage, memory, warn: warn2 }) {
5882
6105
  const api = new Hono2();
6106
+ const revisions = createRevisions();
5883
6107
  let snapshot = null;
5884
6108
  const backlog = () => {
5885
- snapshot ??= loadSnapshot(root).catch((error) => {
6109
+ snapshot ??= loadSnapshot(root, revisions.current()).catch((error) => {
5886
6110
  snapshot = null;
5887
6111
  throw error;
5888
6112
  });
@@ -5895,19 +6119,29 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
5895
6119
  stats.forget();
5896
6120
  graphStates.clear();
5897
6121
  };
5898
- changes.subscribe(forgetBacklog);
6122
+ const recordOwnWrites = async (writes) => {
6123
+ if (writes.length > 0) await revisions.recordOwnWrites(writes);
6124
+ forgetBacklog();
6125
+ };
6126
+ const streams = /* @__PURE__ */ new Set();
6127
+ changes.subscribe(async (paths) => {
6128
+ if (await revisions.settle(paths) === "foreign") forgetBacklog();
6129
+ else stats.forget();
6130
+ const revision = revisions.current();
6131
+ for (const send of streams) send(revision);
6132
+ });
5899
6133
  api.get("/projects", async (c) => {
5900
- const { projects, tasks } = await backlog();
6134
+ const { projects, tasks, revision } = await backlog();
5901
6135
  const withGraph = async (project) => {
5902
6136
  const codeGraph2 = await graphStates.get(project.id, async () => (await projectGraphHealth(project, tasks, home2)).state);
5903
6137
  return { ...project, codeGraph: codeGraph2 };
5904
6138
  };
5905
- return c.json(await Promise.all(projects.map(withGraph)));
6139
+ return c.json({ projects: await Promise.all(projects.map(withGraph)), revision });
5906
6140
  });
5907
6141
  api.get("/tasks", async (c) => {
5908
- const [{ tasks, errors }, language] = await Promise.all([backlog(), readLanguage2()]);
6142
+ const [{ tasks, errors, revision }, language] = await Promise.all([backlog(), readLanguage2()]);
5909
6143
  const messages = coreMessages(language);
5910
- return c.json({ tasks, errors: errors.map(({ problems: problems3, ...error }) => ({ ...error, message: messages.problems(problems3) })) });
6144
+ return c.json({ tasks, errors: errors.map(({ problems: problems3, ...error }) => ({ ...error, message: messages.problems(problems3) })), revision });
5911
6145
  });
5912
6146
  api.route("/", stats.routes);
5913
6147
  api.get("/settings", async (c) => c.json({ language: await readLanguage2() }));
@@ -5923,13 +6157,26 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
5923
6157
  const id = c.req.param("id");
5924
6158
  const { index } = await backlog();
5925
6159
  const result = await updateTaskInIndex(index, { id, changes: body.data.changes, expectedVersion: body.data.version, now: now(), via: "web" });
5926
- forgetBacklog();
6160
+ await recordOwnWrites(result.ok ? [result.task] : []);
5927
6161
  if (result.ok) return c.json(result.task);
5928
6162
  const messages = serverMessages(body.language);
5929
6163
  if (result.reason === "not-found") return c.json({ errors: [messages.taskNotFound(id)] }, 404);
5930
6164
  if (result.reason === "conflict") return c.json({ errors: [messages.taskChangedOnDisk], current: result.current }, 409);
5931
6165
  return invalidResponse(c, result, coreMessages(body.language));
5932
6166
  });
6167
+ api.post("/tasks/batch", async (c) => {
6168
+ const body = await readBody(c, batchRequestSchema, readLanguage2);
6169
+ if (!body.ok) return body.response;
6170
+ const { index } = await backlog();
6171
+ const outcomes = await applyBatch(index, { ...body.data, now: now() }).catch((error) => {
6172
+ forgetBacklog();
6173
+ throw error;
6174
+ });
6175
+ await recordOwnWrites(outcomes.flatMap((outcome) => outcome.outcome === "done" ? [outcome.task] : []));
6176
+ const messages = serverMessages(body.language);
6177
+ const core = coreMessages(body.language);
6178
+ return c.json({ results: outcomes.map((outcome) => viewOf(outcome, messages, core)) });
6179
+ });
5933
6180
  api.patch("/projects/:id", async (c) => {
5934
6181
  const body = await readBody(c, projectActiveSchema, readLanguage2);
5935
6182
  if (!body.ok) return body.response;
@@ -5953,21 +6200,27 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
5953
6200
  api.get(
5954
6201
  "/events",
5955
6202
  (c) => streamSSE(c, async (stream) => {
5956
- const unsubscribe = changes.subscribe(() => void stream.writeSSE({ event: "change", data: "" }));
6203
+ const send = (revision) => void stream.writeSSE({ event: "change", data: JSON.stringify(revision) });
6204
+ streams.add(send);
5957
6205
  const clientGone = new Promise((resolve7) => stream.onAbort(resolve7));
5958
6206
  await Promise.race([clientGone, changes.closed]);
5959
- unsubscribe();
6207
+ streams.delete(send);
5960
6208
  })
5961
6209
  );
5962
6210
  return api;
5963
6211
  }
5964
- async function loadSnapshot(root) {
6212
+ async function loadSnapshot(root, revision) {
5965
6213
  const loaded = await loadBacklog(root);
5966
- return { ...loaded, index: buildIndex(loaded.tasks) };
6214
+ return { ...loaded, index: buildIndex(loaded.tasks), revision };
5967
6215
  }
5968
6216
  function invalidResponse(c, result, messages) {
5969
6217
  return c.json({ errors: result.errors.map(messages.problem) }, 422);
5970
6218
  }
6219
+ function viewOf(outcome, messages, core) {
6220
+ if (outcome.outcome === "done") return { id: outcome.id, outcome: "done", version: outcome.task.version, previous: outcome.previous };
6221
+ const message = outcome.reason === "invalid" && outcome.problems ? core.problems(outcome.problems) : messages.batchSkipped[outcome.reason](outcome.id);
6222
+ return { id: outcome.id, outcome: "skipped", reason: outcome.reason, message };
6223
+ }
5971
6224
  async function readBody(c, schema, readLanguage2) {
5972
6225
  const [body, language] = await Promise.all([readJson(c), readLanguage2()]);
5973
6226
  if (body.ok === false) return { ok: false, response: c.json({ errors: [serverMessages(language).bodyNotParsed] }, 400) };
@@ -6061,11 +6314,17 @@ function isHiddenPath(root, path) {
6061
6314
  function createChangeFeed({ root, debounceMs, messages, warn: warn2 }) {
6062
6315
  const listeners = /* @__PURE__ */ new Set();
6063
6316
  const { promise: closed, resolve: markClosed } = Promise.withResolvers();
6317
+ const changedPaths = /* @__PURE__ */ new Set();
6064
6318
  const debouncer = createDebouncer(debounceMs, () => {
6065
- for (const listener of listeners) listener();
6319
+ const paths = [...changedPaths];
6320
+ changedPaths.clear();
6321
+ for (const listener of listeners) void listener(paths);
6066
6322
  });
6067
6323
  const watcher = watch(root, { ignoreInitial: true, ignored: (path) => isHiddenPath(root, path) });
6068
- watcher.on("all", () => debouncer.schedule());
6324
+ watcher.on("all", (_event, path) => {
6325
+ changedPaths.add(path);
6326
+ debouncer.schedule();
6327
+ });
6069
6328
  watcher.on("error", (error) => {
6070
6329
  void messages().then((texts) => warn2(texts.watcherError(root, errorText(error))));
6071
6330
  });
@@ -6130,8 +6389,9 @@ function startSweeper({ sweep, intervalMs, log: log2, warn: warn2, messages }) {
6130
6389
  return current;
6131
6390
  };
6132
6391
  }
6133
- function logReport({ closedEpics, blockingFiles, deleted, conflicts, invalid: invalid2 }, messages, log2) {
6392
+ function logReport({ closedEpics, reopenedEpics, blockingFiles, deleted, conflicts, invalid: invalid2 }, messages, log2) {
6134
6393
  if (closedEpics.length > 0) log2(messages.closedEpics(closedEpics.join(", ")));
6394
+ if (reopenedEpics.length > 0) log2(messages.reopenedEpics(reopenedEpics.join(", ")));
6135
6395
  if (blockingFiles.length > 0) log2(messages.epicsBlockedByFiles(blockingFiles.join(", ")));
6136
6396
  if (deleted.length > 0) log2(messages.deletedClosedTasks(deleted.join(", ")));
6137
6397
  if (conflicts.length > 0) log2(messages.conflictedDuringSweep(conflicts.join(", ")));
@@ -6142,8 +6402,8 @@ function logReport({ closedEpics, blockingFiles, deleted, conflicts, invalid: in
6142
6402
 
6143
6403
  // src/core/usage/transcripts.ts
6144
6404
  import { createHash as createHash4 } from "node:crypto";
6145
- import { open as open2, stat as stat4 } from "node:fs/promises";
6146
- import { basename as basename9, join as join24 } from "node:path";
6405
+ import { open as open2, stat as stat5 } from "node:fs/promises";
6406
+ import { basename as basename10, join as join24 } from "node:path";
6147
6407
 
6148
6408
  // src/core/stats/cost/attribute.ts
6149
6409
  import { z as z16 } from "zod";
@@ -6531,7 +6791,7 @@ async function listTranscripts(claudeProjectsDir2) {
6531
6791
  }
6532
6792
  async function listedFile(path) {
6533
6793
  try {
6534
- const { size, mtimeMs } = await stat4(path);
6794
+ const { size, mtimeMs } = await stat5(path);
6535
6795
  return [{ path, size, mtimeMs }];
6536
6796
  } catch {
6537
6797
  return [];
@@ -6560,9 +6820,9 @@ async function scanTranscripts({ files, cache, byteBudget, now }) {
6560
6820
  }
6561
6821
  function deletedStillReported(cache, listedFiles, now) {
6562
6822
  const reportStart = now.getTime() - COST_REPORT_DAYS * DAY_MS;
6563
- const listedSessions = new Set(Object.keys(listedFiles).map((path) => basename9(path)));
6823
+ const listedSessions = new Set(Object.keys(listedFiles).map((path) => basename10(path)));
6564
6824
  return Object.fromEntries(
6565
- Object.entries(cache.files).filter(([path, entry]) => !listedSessions.has(basename9(path)) && entry.buckets.some((bucket) => Date.parse(bucket.slot) >= reportStart))
6825
+ Object.entries(cache.files).filter(([path, entry]) => !listedSessions.has(basename10(path)) && entry.buckets.some((bucket) => Date.parse(bucket.slot) >= reportStart))
6566
6826
  );
6567
6827
  }
6568
6828
  async function scanChunk(file, start, chunkSize, { longestReadableLine, now }) {
@@ -7173,7 +7433,7 @@ function reportFailure({ failed, code, output }, io) {
7173
7433
  import { join as join29 } from "node:path";
7174
7434
 
7175
7435
  // src/cli/stop-hook.ts
7176
- import { mkdir as mkdir10, readFile as readFile5, readlink as readlink2, realpath as realpath2, stat as stat5 } from "node:fs/promises";
7436
+ import { mkdir as mkdir10, readFile as readFile5, readlink as readlink2, realpath as realpath2, stat as stat6 } from "node:fs/promises";
7177
7437
  import { dirname as dirname11, resolve as resolve4 } from "node:path";
7178
7438
  import { z as z18 } from "zod";
7179
7439
  var POSIX_COMMAND = "command -v backlog >/dev/null && backlog hook stop || true";
@@ -7201,7 +7461,7 @@ async function addStopHook(settingsPath, platform) {
7201
7461
  stopGroups.push({ hooks: [stopHookFor(platform)] });
7202
7462
  await mkdir10(dirname11(settingsPath), { recursive: true });
7203
7463
  const target = await writeTargetOf(settingsPath);
7204
- const mode = await stat5(target).then(({ mode: mode2 }) => mode2 & 511, () => void 0);
7464
+ const mode = await stat6(target).then(({ mode: mode2 }) => mode2 & 511, () => void 0);
7205
7465
  await writeFileAtomic(target, `${JSON.stringify(settings, null, 2)}
7206
7466
  `, mode);
7207
7467
  return "added";