p-backlog 0.3.0 → 0.5.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 (35) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +46 -30
  3. package/README.ru.md +46 -30
  4. package/dist/cli.js +930 -300
  5. package/dist/web/assets/{ChartTooltip-DwQKHXkN.js → ChartTooltip-b-yZtIRd.js} +3 -3
  6. package/dist/web/assets/CodeTab-BU7UU1i4.js +1 -0
  7. package/dist/web/assets/{CostTab-B21EB-7P.js → CostTab-Be8xR9DU.js} +1 -1
  8. package/dist/web/assets/EffectTab-D0kaZiSl.js +1 -0
  9. package/dist/web/assets/Figure-B6Ry1zXo.js +1 -0
  10. package/dist/web/assets/OverviewTab-DqU7o9Ug.js +1 -0
  11. package/dist/web/assets/QualityTab-DtnTgxXj.js +1 -0
  12. package/dist/web/assets/StatsPage-BLZKtSL6.js +1 -0
  13. package/dist/web/assets/StatsTabState-CYkDWYp7.js +1 -0
  14. package/dist/web/assets/{StatsTable-Bd3vAxKU.js → StatsTable-C0coWtNM.js} +1 -1
  15. package/dist/web/assets/UnavailableRepos-DDHmBHg0.js +1 -0
  16. package/dist/web/assets/cx-Cfec8QGz.js +58 -0
  17. package/dist/web/assets/index-DN4lSwOL.js +2 -0
  18. package/dist/web/assets/{index-CAKUAPJF.css → start-Cy19NMke.css} +1 -1
  19. package/dist/web/assets/start-sbZVqXmP.js +38 -0
  20. package/dist/web/assets/{value-dot-kiMizZCu.js → value-dot-CfFRNT_m.js} +1 -1
  21. package/dist/web/index.html +1 -5
  22. package/package.json +2 -1
  23. package/skill/backlog/SKILL.md +5 -4
  24. package/skill/backlog-en/SKILL.md +5 -4
  25. package/dist/web/assets/CodeTab-BGxsIZMH.js +0 -1
  26. package/dist/web/assets/EffectTab-97OFZkWY.js +0 -1
  27. package/dist/web/assets/Figure-CiIqHKnb.js +0 -1
  28. package/dist/web/assets/OverviewTab-X_g8WfUl.js +0 -1
  29. package/dist/web/assets/QualityTab-DYnFz37G.js +0 -1
  30. package/dist/web/assets/StatsPage-D9gJHHQg.js +0 -1
  31. package/dist/web/assets/StatsTabState-DbuUIt-D.js +0 -1
  32. package/dist/web/assets/UnavailableRepos-D2iehY7u.js +0 -1
  33. package/dist/web/assets/cx-BZTW4xgb.js +0 -58
  34. package/dist/web/assets/index-BrkoyuWi.js +0 -38
  35. 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
  }
@@ -544,7 +555,7 @@ var cliEn = {
544
555
  runNotRecorded: (error) => `Could not record the run: ${error}`,
545
556
  runsNotTrimmed: (error) => `Could not trim the run log: ${error}`,
546
557
  settingsFileInvalid: (path) => `${path} could not be parsed, the language for this run was detected automatically. The file was not changed \u2014 fix it manually.`,
547
- optionLabel: { status: "status", category: "category", priority: "priority", language: "language" },
558
+ optionLabel: { status: "status", category: "category", priority: "priority", language: "language", agent: "agent" },
548
559
  skillForeign: (target) => `${target} is a foreign directory, the skill link was not moved`,
549
560
  installSkillLinked: (target, source) => `Skill installed: ${target} \u2192 ${source}`,
550
561
  installSkillKept: (target) => `Skill already installed: ${target}`,
@@ -552,8 +563,25 @@ var cliEn = {
552
563
  installSkillLinkFailed: (target, detail) => `Could not create the symlink ${target} (${detail}).`,
553
564
  installHookExists: (settingsPath) => `Stop hook is already present in ${settingsPath}`,
554
565
  installHookAdded: (settingsPath) => `Stop hook added to ${settingsPath}`,
566
+ installHookUpdated: (settingsPath) => `Stop hook updated in ${settingsPath}`,
567
+ codexHookApproval: "approve the hook in Codex: /hooks",
555
568
  installSettingsUnreadable: (settingsPath, detail) => `Could not read ${settingsPath} (${detail}), Stop hook not added.`,
556
569
  installSettingsInvalid: (settingsPath) => `${settingsPath} is not a JSON object, Stop hook not added. Fix the file and try again.`,
570
+ agentNotFound: (dir) => `not found (${dir})`,
571
+ pluginManages: (plugin) => `the ${plugin} plugin provides the skill and the hook. Remove the manual ones with: backlog setup --remove-manual`,
572
+ pluginLanguageHint: (installed, wanted) => `the skill comes from the ${installed} plugin; for this language switch plugins: /plugin uninstall ${installed}, then /plugin install ${wanted}`,
573
+ removeManualWithService: "--remove-manual cannot be combined with --service: remove the service with backlog service uninstall",
574
+ manualSkillRemoval: {
575
+ removed: (target) => `skill link removed: ${target}`,
576
+ absent: (target) => `no skill link: ${target}`,
577
+ foreign: (target) => `${target} is not a p-backlog link, left as is`
578
+ },
579
+ manualHookRemoval: {
580
+ removed: (path) => `Stop hook removed from ${path}`,
581
+ absent: (path) => `no p-backlog Stop hook in ${path}`
582
+ },
583
+ hookConfigUnreadable: (path, detail) => `Could not read ${path} (${detail}), the hook was not removed.`,
584
+ hookConfigInvalid: (path) => `${path} is not a JSON object, the hook was not removed. Fix the file and try again.`,
557
585
  serviceInstalled: (file) => `Service installed: ${file}`,
558
586
  serviceLogs: (path) => `Logs: ${path}`,
559
587
  serviceUninstalled: "Service removed",
@@ -648,11 +676,12 @@ var cliEn = {
648
676
  alreadyInStatus: (id, status) => `${id} is already ${status}`,
649
677
  blockedByOpenTasks: (id) => `${id} is blocked by open tasks:`,
650
678
  noTakeableInProject: (id) => `Project ${id} has no tasks that can be taken`,
651
- hookUsage: (stopEvent) => `${stopEvent} (for the Stop hook in Claude Code, reads the event from stdin)`,
679
+ hookUsage: (stopEvent, agents) => `${stopEvent} [--agent ${agents.join("|")}] (for the Stop hook in Claude Code, Codex or Cursor, reads the event from stdin)`,
652
680
  sessionShownReadFailed: (error) => `Could not read the tasks already shown this session: ${error}`,
653
681
  sessionShownWriteFailed: (error) => `Could not remember the tasks shown this session: ${error}`,
654
682
  alertsComputeFailed: (error) => `Could not compute alerts: ${error}`,
655
683
  alertsShownWriteFailed: (error) => `Could not save the shown alerts: ${error}`,
684
+ hookTurnClaimFailed: (error) => `Could not record the turn for the double-hook guard: ${error}`,
656
685
  newUsage: (types, priorities, found) => [
657
686
  `--title <title> [--type ${types}] [--priority ${priorities}] [--tags a,b]`,
658
687
  `--category <category> (required for tasks) [--found ${found}]`,
@@ -702,7 +731,7 @@ var cliRu = {
702
731
  runNotRecorded: (error) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u0438\u0441\u0430\u0442\u044C \u0437\u0430\u043F\u0443\u0441\u043A: ${error}`,
703
732
  runsNotTrimmed: (error) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u0440\u0435\u0437\u0430\u0442\u044C \u0436\u0443\u0440\u043D\u0430\u043B \u0437\u0430\u043F\u0443\u0441\u043A\u043E\u0432: ${error}`,
704
733
  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.`,
705
- optionLabel: { status: "\u0441\u0442\u0430\u0442\u0443\u0441", category: "\u043A\u0430\u0442\u0435\u0433\u043E\u0440\u0438\u044F", priority: "\u043F\u0440\u0438\u043E\u0440\u0438\u0442\u0435\u0442", language: "\u044F\u0437\u044B\u043A" },
734
+ optionLabel: { status: "\u0441\u0442\u0430\u0442\u0443\u0441", category: "\u043A\u0430\u0442\u0435\u0433\u043E\u0440\u0438\u044F", priority: "\u043F\u0440\u0438\u043E\u0440\u0438\u0442\u0435\u0442", language: "\u044F\u0437\u044B\u043A", agent: "\u0430\u0433\u0435\u043D\u0442" },
706
735
  skillForeign: (target) => `${target} \u2014 \u0447\u0443\u0436\u043E\u0439 \u043A\u0430\u0442\u0430\u043B\u043E\u0433, \u0441\u043A\u0438\u043B\u043B \u043D\u0435 \u043F\u0435\u0440\u0435\u0441\u0442\u0430\u0432\u043B\u0435\u043D`,
707
736
  installSkillLinked: (target, source) => `\u0421\u043A\u0438\u043B\u043B \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D: ${target} \u2192 ${source}`,
708
737
  installSkillKept: (target) => `\u0421\u043A\u0438\u043B\u043B \u0443\u0436\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D: ${target}`,
@@ -710,8 +739,25 @@ var cliRu = {
710
739
  installSkillLinkFailed: (target, detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043E\u0437\u0434\u0430\u0442\u044C \u0441\u0441\u044B\u043B\u043A\u0443 ${target} (${detail}).`,
711
740
  installHookExists: (settingsPath) => `\u0425\u0443\u043A Stop \u0443\u0436\u0435 \u0435\u0441\u0442\u044C \u0432 ${settingsPath}`,
712
741
  installHookAdded: (settingsPath) => `\u0425\u0443\u043A Stop \u0434\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u0432 ${settingsPath}`,
742
+ installHookUpdated: (settingsPath) => `\u0425\u0443\u043A Stop \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0432 ${settingsPath}`,
743
+ codexHookApproval: "\u043E\u0434\u043E\u0431\u0440\u0438\u0442\u0435 \u0445\u0443\u043A \u0432 Codex: /hooks",
713
744
  installSettingsUnreadable: (settingsPath, detail) => `${settingsPath} \u043D\u0435 \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C (${detail}), \u0445\u0443\u043A Stop \u043D\u0435 \u0434\u043E\u0431\u0430\u0432\u043B\u0435\u043D.`,
714
745
  installSettingsInvalid: (settingsPath) => `${settingsPath} \u2014 \u043D\u0435 \u043E\u0431\u044A\u0435\u043A\u0442 JSON, \u0445\u0443\u043A Stop \u043D\u0435 \u0434\u043E\u0431\u0430\u0432\u043B\u0435\u043D. \u0418\u0441\u043F\u0440\u0430\u0432\u044C\u0442\u0435 \u0444\u0430\u0439\u043B \u0438 \u043F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435.`,
746
+ agentNotFound: (dir) => `\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D (${dir})`,
747
+ pluginManages: (plugin) => `\u0441\u043A\u0438\u043B\u043B \u0438 \u0445\u0443\u043A \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0430\u0435\u0442 \u043F\u043B\u0430\u0433\u0438\u043D ${plugin}. \u0420\u0443\u0447\u043D\u044B\u0435 \u043C\u043E\u0436\u043D\u043E \u0443\u0431\u0440\u0430\u0442\u044C: backlog setup --remove-manual`,
748
+ pluginLanguageHint: (installed, wanted) => `\u0441\u043A\u0438\u043B\u043B \u0434\u0430\u0451\u0442 \u043F\u043B\u0430\u0433\u0438\u043D ${installed}; \u0434\u043B\u044F \u044D\u0442\u043E\u0433\u043E \u044F\u0437\u044B\u043A\u0430 \u043F\u043E\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u0434\u0440\u0443\u0433\u043E\u0439: /plugin uninstall ${installed}, \u0437\u0430\u0442\u0435\u043C /plugin install ${wanted}`,
749
+ removeManualWithService: "--remove-manual \u043D\u0435 \u0441\u043E\u0447\u0435\u0442\u0430\u0435\u0442\u0441\u044F \u0441 --service: \u0441\u043B\u0443\u0436\u0431\u0443 \u0441\u043D\u0438\u043C\u0430\u0435\u0442 backlog service uninstall",
750
+ manualSkillRemoval: {
751
+ removed: (target) => `\u0441\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u0441\u043A\u0438\u043B\u043B \u0441\u043D\u044F\u0442\u0430: ${target}`,
752
+ absent: (target) => `\u0441\u0441\u044B\u043B\u043A\u0438 \u043D\u0430 \u0441\u043A\u0438\u043B\u043B \u043D\u0435\u0442: ${target}`,
753
+ foreign: (target) => `${target} \u2014 \u043D\u0435 \u0441\u0441\u044B\u043B\u043A\u0430 p-backlog, \u043D\u0435 \u0442\u0440\u043E\u043D\u0443\u0442`
754
+ },
755
+ manualHookRemoval: {
756
+ removed: (path) => `\u0445\u0443\u043A Stop \u0441\u043D\u044F\u0442 \u0438\u0437 ${path}`,
757
+ absent: (path) => `\u0445\u0443\u043A\u0430 Stop p-backlog \u0432 ${path} \u043D\u0435\u0442`
758
+ },
759
+ hookConfigUnreadable: (path, detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C ${path} (${detail}), \u0445\u0443\u043A \u043D\u0435 \u0441\u043D\u044F\u0442.`,
760
+ hookConfigInvalid: (path) => `${path} \u2014 \u043D\u0435 \u043E\u0431\u044A\u0435\u043A\u0442 JSON, \u0445\u0443\u043A \u043D\u0435 \u0441\u043D\u044F\u0442. \u0418\u0441\u043F\u0440\u0430\u0432\u044C\u0442\u0435 \u0444\u0430\u0439\u043B \u0438 \u043F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435.`,
715
761
  serviceInstalled: (file) => `\u0421\u043B\u0443\u0436\u0431\u0430 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u0430: ${file}`,
716
762
  serviceLogs: (path) => `\u041B\u043E\u0433\u0438: ${path}`,
717
763
  serviceUninstalled: "\u0421\u043B\u0443\u0436\u0431\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0430",
@@ -818,11 +864,12 @@ var cliRu = {
818
864
  alreadyInStatus: (id, status) => `${id} \u0443\u0436\u0435 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441\u0435 ${status}`,
819
865
  blockedByOpenTasks: (id) => `${id} \u0437\u0430\u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u0430\u043D\u0430 \u043E\u0442\u043A\u0440\u044B\u0442\u044B\u043C\u0438 \u0437\u0430\u0434\u0430\u0447\u0430\u043C\u0438:`,
820
866
  noTakeableInProject: (id) => `\u0412 \u043F\u0440\u043E\u0435\u043A\u0442\u0435 ${id} \u043D\u0435\u0442 \u0437\u0430\u0434\u0430\u0447, \u043A\u043E\u0442\u043E\u0440\u044B\u0435 \u043C\u043E\u0436\u043D\u043E \u0432\u0437\u044F\u0442\u044C \u0432 \u0440\u0430\u0431\u043E\u0442\u0443`,
821
- hookUsage: (stopEvent) => `${stopEvent} (\u0434\u043B\u044F \u0445\u0443\u043A\u0430 Stop \u0432 Claude Code, \u0441\u043E\u0431\u044B\u0442\u0438\u0435 \u0447\u0438\u0442\u0430\u0435\u0442\u0441\u044F \u0438\u0437 stdin)`,
867
+ hookUsage: (stopEvent, agents) => `${stopEvent} [--agent ${agents.join("|")}] (\u0434\u043B\u044F \u0445\u0443\u043A\u0430 Stop \u0432 Claude Code, Codex \u0438\u043B\u0438 Cursor, \u0441\u043E\u0431\u044B\u0442\u0438\u0435 \u0447\u0438\u0442\u0430\u0435\u0442\u0441\u044F \u0438\u0437 stdin)`,
822
868
  sessionShownReadFailed: (error) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u043D\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0441\u0435\u0441\u0441\u0438\u0438: ${error}`,
823
869
  sessionShownWriteFailed: (error) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u043E\u043C\u043D\u0438\u0442\u044C \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u043D\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0441\u0435\u0441\u0441\u0438\u0438: ${error}`,
824
870
  alertsComputeFailed: (error) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u043E\u0441\u0447\u0438\u0442\u0430\u0442\u044C \u0442\u0440\u0435\u0432\u043E\u0433\u0438: ${error}`,
825
871
  alertsShownWriteFailed: (error) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C \u043F\u043E\u043A\u0430\u0437\u0430\u043D\u043D\u044B\u0435 \u0442\u0440\u0435\u0432\u043E\u0433\u0438: ${error}`,
872
+ hookTurnClaimFailed: (error) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0442\u043C\u0435\u0442\u0438\u0442\u044C \u0445\u043E\u0434 \u0432 \u0437\u0430\u0449\u0438\u0442\u0435 \u043E\u0442 \u0434\u0432\u043E\u0439\u043D\u043E\u0433\u043E \u0445\u0443\u043A\u0430: ${error}`,
826
873
  newUsage: (types, priorities, found) => [
827
874
  `--title <\u0437\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A> [--type ${types}] [--priority ${priorities}] [--tags a,b]`,
828
875
  `--category <\u043A\u0430\u0442\u0435\u0433\u043E\u0440\u0438\u044F> (\u0434\u043B\u044F \u0437\u0430\u0434\u0430\u0447 \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D) [--found ${found}]`,
@@ -936,18 +983,18 @@ function compareIds(a, b) {
936
983
  if (!left || !right) return a.localeCompare(b);
937
984
  return left.prefix === right.prefix ? left.number - right.number : left.prefix.localeCompare(right.prefix);
938
985
  }
939
- function derivePrefix(basename10, taken) {
940
- const base = prefixBase(basename10);
986
+ function derivePrefix(basename11, taken) {
987
+ const base = prefixBase(basename11);
941
988
  return firstFree(base, taken, (n) => `${base}${n}`);
942
989
  }
943
- function deriveProjectId(basename10, taken) {
944
- const slug = basename10.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
990
+ function deriveProjectId(basename11, taken) {
991
+ const slug = basename11.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
945
992
  const base = slug === "" ? "project" : slug;
946
993
  return firstFree(base, taken, (n) => `${base}-${n}`);
947
994
  }
948
995
  var PREFIX_BASE_LENGTH = 4;
949
- function prefixBase(basename10) {
950
- const words = basename10.split(/[^A-Za-z0-9]+/).filter(Boolean);
996
+ function prefixBase(basename11) {
997
+ const words = basename11.split(/[^A-Za-z0-9]+/).filter(Boolean);
951
998
  const letters = words.length > 1 ? words.map((word) => word.charAt(0)).join("") : words[0] ?? "";
952
999
  const prefix = letters.slice(0, PREFIX_BASE_LENGTH).toUpperCase();
953
1000
  if (prefix === "") return "PROJ";
@@ -1040,7 +1087,7 @@ function recordedMethodOf({ method, bySymbol, byAnchor }) {
1040
1087
  if (method !== void 0) return method;
1041
1088
  return bySymbol === true || byAnchor === true ? checkMethodOf({ bySymbol, byAnchor }) : "unknown";
1042
1089
  }
1043
- var eventBase = { at: z4.iso.datetime({ offset: true }), task: z4.string().min(1), via: recordedEnum(CHANGE_SOURCES) };
1090
+ 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
1091
  var taskSnapshotSchema = taskFrontmatterSchema.extend({
1045
1092
  priority: recordedEnum(PRIORITIES),
1046
1093
  category: recordedEnum(TASK_CATEGORIES).optional(),
@@ -1129,6 +1176,10 @@ function changeEvents(before, after, now, via) {
1129
1176
  }
1130
1177
  return events;
1131
1178
  }
1179
+ function statusBeforeAutoClose(journal, epicId) {
1180
+ const autoClose = journal.findLast((event) => event.task === epicId && event.kind === "status" && event.resolution === "epic-done");
1181
+ return autoClose?.kind === "status" && !isClosed(autoClose.from) ? autoClose.from : "backlog";
1182
+ }
1132
1183
  function deletedEvent(task, now, via) {
1133
1184
  return { at: formatLocalIso(now), task: task.id, via, kind: "deleted", snapshot: snapshotOf(task) };
1134
1185
  }
@@ -1164,13 +1215,26 @@ function candidateGoneEvents(sightings, tasks, states, now, checked = CANDIDATE_
1164
1215
  }
1165
1216
  function episodeStates(journal) {
1166
1217
  const states = /* @__PURE__ */ new Map();
1218
+ const beforeClosing = /* @__PURE__ */ new Map();
1167
1219
  for (const event of journal) {
1168
1220
  if (event.kind === "candidate") states.set(episodeKey(event.task, event.evidence), "open");
1169
1221
  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");
1222
+ else if (undoesClosing(event)) {
1223
+ for (const [key, state] of beforeClosing.get(event.task) ?? []) {
1224
+ if (state === void 0) states.delete(key);
1225
+ else states.set(key, state);
1226
+ }
1227
+ } else if (endsEpisodes(event)) {
1228
+ const keys = CANDIDATE_EVIDENCE.map((evidence2) => episodeKey(event.task, evidence2));
1229
+ if (event.kind === "status") beforeClosing.set(event.task, keys.map((key) => [key, states.get(key)]));
1230
+ for (const key of keys) states.set(key, "ended");
1231
+ }
1171
1232
  }
1172
1233
  return states;
1173
1234
  }
1235
+ function undoesClosing(event) {
1236
+ return event.kind === "status" && event.undo === true && isClosed(event.from) && !isClosed(event.to);
1237
+ }
1174
1238
  function endsEpisodes(event) {
1175
1239
  if (event.kind === "verified" || event.kind === "deleted") return true;
1176
1240
  return event.kind === "status" && isClosed(event.to);
@@ -1203,7 +1267,7 @@ function taskHistories(tasks, journals, unparsedIds = /* @__PURE__ */ new Set())
1203
1267
  if (event.kind === "category") item.categoryEvents.push({ at: Date.parse(event.at), to: event.to });
1204
1268
  if (event.kind === "priority") item.priorityEvents.push({ at: Date.parse(event.at), to: event.to });
1205
1269
  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 });
1270
+ item.transitions.push({ at: Date.parse(event.at), from: event.from, to: event.to, resolution: event.resolution, via: event.via, undo: event.undo });
1207
1271
  }
1208
1272
  if (event.kind === "candidate") item.candidates.push({ at: Date.parse(event.at), evidence: event.evidence, method: recordedMethodOf(event), match: event.match ?? "unknown" });
1209
1273
  if (event.kind === "candidate-filtered") item.filtered.push(Date.parse(event.at));
@@ -1235,7 +1299,7 @@ function historyOf(id, { projectId, final, created, categoryEvents, priorityEven
1235
1299
  const createdIso = final?.created ?? created?.at;
1236
1300
  const type = final?.type ?? created?.type;
1237
1301
  if (createdIso === void 0 || type === void 0) return [];
1238
- const ordered = [...transitions].sort((a, b) => a.at - b.at);
1302
+ const ordered = withoutUndoneClosings([...transitions].sort((a, b) => a.at - b.at));
1239
1303
  const fateUnknown = final === void 0 && unparsed;
1240
1304
  return [
1241
1305
  {
@@ -1257,6 +1321,15 @@ function historyOf(id, { projectId, final, created, categoryEvents, priorityEven
1257
1321
  }
1258
1322
  ];
1259
1323
  }
1324
+ function withoutUndoneClosings(ordered) {
1325
+ const kept = [];
1326
+ for (const transition of ordered) {
1327
+ const lastKept = kept.at(-1);
1328
+ if (transition.undo === true && lastKept !== void 0 && isClosing(lastKept) && transition.from === lastKept.to) kept.pop();
1329
+ else kept.push(transition);
1330
+ }
1331
+ return kept;
1332
+ }
1260
1333
  function categoryOf(final, created, categoryEvents) {
1261
1334
  if (final !== void 0) return final.category;
1262
1335
  const lastCategoryEvent = [...categoryEvents].sort((a, b) => a.at - b.at).at(-1);
@@ -1462,6 +1535,8 @@ function problem(p) {
1462
1535
  return `epic ${p.epic} not found`;
1463
1536
  case "epic-not-epic":
1464
1537
  return `${p.epic} is not an epic`;
1538
+ case "epic-foreign-project":
1539
+ return `epic ${p.epic} belongs to another project \u2014 clear the epic or pick one from this project`;
1465
1540
  case "epic-in-epic":
1466
1541
  return "an epic cannot belong to another epic";
1467
1542
  case "reference-missing":
@@ -1494,6 +1569,8 @@ function checkFix(fix) {
1494
1569
  return `${fix.taskId}: removed references to missing tasks: ${fix.ids.join(", ")}`;
1495
1570
  case "epic-closed":
1496
1571
  return `${fix.taskId}: epic closed \u2014 ${epicDoneReason(fix.childIds)}`;
1572
+ case "epic-reopened":
1573
+ return `${fix.taskId}: epic reopened \u2014 it has open tasks: ${fix.childIds.join(", ")}`;
1497
1574
  case "source-moved":
1498
1575
  return `${fix.taskId}: source moved ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
1499
1576
  }
@@ -1691,6 +1768,8 @@ function problem2(p) {
1691
1768
  return `\u044D\u043F\u0438\u043A ${p.epic} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`;
1692
1769
  case "epic-not-epic":
1693
1770
  return `${p.epic} \u043D\u0435 \u044F\u0432\u043B\u044F\u0435\u0442\u0441\u044F \u044D\u043F\u0438\u043A\u043E\u043C`;
1771
+ case "epic-foreign-project":
1772
+ 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
1773
  case "epic-in-epic":
1695
1774
  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
1775
  case "reference-missing":
@@ -1723,6 +1802,8 @@ function checkFix2(fix) {
1723
1802
  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
1803
  case "epic-closed":
1725
1804
  return `${fix.taskId}: \u044D\u043F\u0438\u043A \u0437\u0430\u043A\u0440\u044B\u0442 \u2014 ${epicDoneReason2(fix.childIds)}`;
1805
+ case "epic-reopened":
1806
+ 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
1807
  case "source-moved":
1727
1808
  return `${fix.taskId}: source \u0441\u0434\u0432\u0438\u043D\u0443\u043B\u0441\u044F ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
1728
1809
  }
@@ -2001,7 +2082,7 @@ function isTaskFileName(name) {
2001
2082
  }
2002
2083
 
2003
2084
  // src/cli/lookups.ts
2004
- import { basename as basename6, dirname as dirname3, relative as relative2, sep as sep2 } from "node:path";
2085
+ import { basename as basename6, dirname as dirname4, relative as relative2, sep as sep2 } from "node:path";
2005
2086
 
2006
2087
  // src/core/store/create.ts
2007
2088
  import { mkdir as mkdir3 } from "node:fs/promises";
@@ -2035,6 +2116,7 @@ function epicProblems(candidate, resolve7) {
2035
2116
  const epic = resolve7(candidate.epic);
2036
2117
  if (!epic) problems3.push({ code: "epic-missing", epic: candidate.epic });
2037
2118
  else if (epic.type !== "epic") problems3.push({ code: "epic-not-epic", epic: candidate.epic });
2119
+ else if (epic.projectId !== candidate.projectId) problems3.push({ code: "epic-foreign-project", epic: candidate.epic });
2038
2120
  }
2039
2121
  if (candidate.type === "epic") problems3.push({ code: "epic-in-epic" });
2040
2122
  return problems3;
@@ -2134,11 +2216,93 @@ function taskText(draft) {
2134
2216
  return { ok: true, value: { text, task: { ...parsed.value, version: contentVersion(text) } } };
2135
2217
  }
2136
2218
 
2219
+ // src/core/store/update.ts
2220
+ import { dirname as dirname3 } from "node:path";
2221
+
2137
2222
  // src/core/store/write-result.ts
2138
2223
  function invalid(errors) {
2139
2224
  return { ok: false, reason: "invalid", errors };
2140
2225
  }
2141
2226
 
2227
+ // src/core/store/update.ts
2228
+ var CHANGE_FIELDS = ["title", "type", "priority", "tags", "blockedBy", "related", "body", "source", "verified"];
2229
+ async function updateTaskInIndex(index, request) {
2230
+ const before = index.byId.get(request.id);
2231
+ const result = await writeChanges(index, request);
2232
+ if (result.ok) await reopenEpicOfOpenedTask(index, { before, after: result.task, now: request.now, via: request.via, undo: request.undo });
2233
+ return result;
2234
+ }
2235
+ async function statusToReopen(epic) {
2236
+ const { events } = await readJournal(dirname3(epic.path), epic.projectId);
2237
+ return statusBeforeAutoClose(events, epic.id);
2238
+ }
2239
+ async function reopenEpicOfOpenedTask(index, { before, after, now, via, undo }) {
2240
+ if (after.epic === void 0 || isClosed(after.status)) return;
2241
+ const becameOpenInEpic = before === void 0 || isClosed(before.status) || before.epic !== after.epic;
2242
+ const epic = index.byId.get(after.epic);
2243
+ if (!becameOpenInEpic || epic === void 0 || !isAutoClosedEpic(epic)) return;
2244
+ try {
2245
+ if (await diskChange(epic, epic.version) !== null) return;
2246
+ await writeChanges(index, { id: epic.id, changes: { status: await statusToReopen(epic) }, expectedVersion: epic.version, now, via, undo });
2247
+ } catch (error) {
2248
+ if (!(error instanceof FileBusyError)) console.error(`${epic.path}: ${errorText(error)}`);
2249
+ }
2250
+ }
2251
+ async function writeChanges(index, { id, changes, expectedVersion, now, closure, via, undo = false }) {
2252
+ const current = index.byId.get(id);
2253
+ if (!current) return { ok: false, reason: "not-found" };
2254
+ if (expectedVersion !== current.version) return { ok: false, reason: "conflict", current };
2255
+ const normalized = taskText(applyChanges(current, changes, now, closure));
2256
+ if (!normalized.ok) return invalid(normalized.problems);
2257
+ const { text, task } = normalized.value;
2258
+ const errors = integrityErrors(task, index);
2259
+ if (errors.length > 0) return invalid(errors);
2260
+ return withFileLock(current.path, async () => {
2261
+ const changedOnDisk = await diskChange(current, expectedVersion);
2262
+ if (changedOnDisk !== null) return changedOnDisk;
2263
+ await writeFileAtomic(current.path, text);
2264
+ const events = changeEvents(current, task, now, via);
2265
+ await appendJournal(dirname3(current.path), undo ? events.map((event) => ({ ...event, undo: true })) : events);
2266
+ return { ok: true, task };
2267
+ });
2268
+ }
2269
+ async function diskChange(snapshot, expectedVersion) {
2270
+ const text = await readTextOrNull(snapshot.path);
2271
+ if (text === null) return { ok: false, reason: "not-found" };
2272
+ const version = contentVersion(text);
2273
+ if (version === expectedVersion) return null;
2274
+ const fresh = parseTaskFile(text, { projectId: snapshot.projectId, path: snapshot.path, version });
2275
+ return { ok: false, reason: "conflict", current: fresh.ok ? fresh.value : snapshot };
2276
+ }
2277
+ function applyChanges(task, changes, now, closure) {
2278
+ const edited = {
2279
+ ...task,
2280
+ ...pickDefined(changes, CHANGE_FIELDS),
2281
+ epic: nextOptional(task.epic, changes.epic),
2282
+ category: nextOptional(task.category, changes.category),
2283
+ anchor: nextAnchor(task, changes)
2284
+ };
2285
+ const moved = changes.status === void 0 ? edited : changeStatus(edited, changes.status, now, closure);
2286
+ return settleLifecycle(moved, now);
2287
+ }
2288
+ function nextAnchor(task, changes) {
2289
+ if (changes.anchor !== void 0) return changes.anchor ?? void 0;
2290
+ const sourceMoved = changes.source !== void 0 && changes.source !== task.source;
2291
+ return sourceMoved ? void 0 : task.anchor;
2292
+ }
2293
+ function pickDefined(source, keys) {
2294
+ const picked = {};
2295
+ for (const key of keys) {
2296
+ const value = source[key];
2297
+ if (value !== void 0) picked[key] = value;
2298
+ }
2299
+ return picked;
2300
+ }
2301
+ function nextOptional(current, change) {
2302
+ if (change === null) return void 0;
2303
+ return change ?? current;
2304
+ }
2305
+
2142
2306
  // src/core/store/create.ts
2143
2307
  var MAX_ID_ATTEMPTS = 20;
2144
2308
  async function createTask(root, request) {
@@ -2156,6 +2320,7 @@ async function createTask(root, request) {
2156
2320
  try {
2157
2321
  await createFileAtomic(path, text);
2158
2322
  await appendJournal(dir, [createdEvent(task, request.now, request.via, request.provenance)]);
2323
+ await reopenEpicOfOpenedTask(index, { before: void 0, after: task, now: request.now, via: request.via });
2159
2324
  return { ok: true, task };
2160
2325
  } catch (error) {
2161
2326
  if (!hasErrorCode(error, "EEXIST")) throw error;
@@ -2166,7 +2331,8 @@ async function createTask(root, request) {
2166
2331
  async function createProject(root, repoRoot, existingProjects) {
2167
2332
  const name = basename5(repoRoot);
2168
2333
  const entries = await listDir(root);
2169
- const id = deriveProjectId(name, new Set(entries.map((entry) => entry.name)));
2334
+ const joinable = await Promise.all(entries.map((entry) => entry.isDirectory() && isJoinableProjectDir(join9(root, entry.name), repoRoot)));
2335
+ const id = deriveProjectId(name, new Set(entries.filter((_, index) => !joinable[index]).map((entry) => entry.name)));
2170
2336
  const prefixesOnDisk = await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => takenPrefixes(join9(root, entry.name))));
2171
2337
  const prefix = derivePrefix(name, /* @__PURE__ */ new Set([...existingProjects.map((project) => project.prefix), ...prefixesOnDisk.flat()]));
2172
2338
  const dir = join9(root, id);
@@ -2184,6 +2350,11 @@ async function createProject(root, repoRoot, existingProjects) {
2184
2350
  if (!parsed.ok) throw new Error(`${path}: ${parsed.problems.map((problem3) => problem3.code).join(", ")}`);
2185
2351
  return parsed.value;
2186
2352
  }
2353
+ async function isJoinableProjectDir(dir, repoRoot) {
2354
+ const path = join9(dir, PROJECT_FILE);
2355
+ if (await readTextOrNull(path) !== null) return await projectOfRepo(basename5(dir), path, repoRoot) !== null;
2356
+ return (await listDir(dir)).every((entry) => !entry.name.endsWith(".md"));
2357
+ }
2187
2358
  async function projectOfRepo(id, path, repoRoot) {
2188
2359
  const parsed = await readProjectFile({ id, path });
2189
2360
  return parsed?.ok === true && parsed.value.repos.includes(repoRoot) ? parsed.value : null;
@@ -2424,7 +2595,7 @@ async function brokenProjectFilesOf(loaded, roots, home2) {
2424
2595
  const ownId = deriveProjectId(basename6(roots.main), /* @__PURE__ */ new Set());
2425
2596
  const broken = loaded.errors.filter((error) => basename6(error.path) === PROJECT_FILE);
2426
2597
  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)));
2598
+ return broken.filter((error, position) => basename6(dirname4(error.path)) === ownId || repoPaths.some((path) => mentionsPath(texts[position] ?? "", path)));
2428
2599
  }
2429
2600
  var PATH_BOUNDARY = /[\s"',[\]]/;
2430
2601
  var TRAILING_SEPARATORS = /^[\\/]*/;
@@ -2446,63 +2617,6 @@ function findProject(loaded, io, explicitId) {
2446
2617
  return explicitId === void 0 ? findProjectForDir(loaded.projects, io.cwd, io.home) : loaded.projects.find((project) => project.id === explicitId);
2447
2618
  }
2448
2619
 
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
2620
  // src/cli/update-failure.ts
2507
2621
  function reportUpdateFailure(io, id, result) {
2508
2622
  switch (result.reason) {
@@ -3384,14 +3498,12 @@ function sightingOf(candidate) {
3384
3498
  }
3385
3499
  async function applyFixes(loaded, inScope, { now, messages }) {
3386
3500
  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
- );
3501
+ const epicFixes = await epicStatusFixes(loaded, inScope, messages);
3390
3502
  const index = buildIndex(loaded.tasks);
3391
3503
  const fixed = [];
3392
3504
  const failed = [];
3393
3505
  for (const task of loaded.tasks.filter((candidate) => inScope(candidate.projectId))) {
3394
- const fix = planFix(task, isGone, epicClosures.get(task.id));
3506
+ const fix = planFix(task, isGone, epicFixes.get(task.id));
3395
3507
  if (fix === null) continue;
3396
3508
  const result = await updateTaskInIndex(index, { id: task.id, changes: fix.changes, expectedVersion: task.version, now, closure: fix.closure, via: "check" });
3397
3509
  if (result.ok) fixed.push(...fix.done);
@@ -3409,14 +3521,24 @@ function fixFailure(taskId, failure) {
3409
3521
  return { kind: "fix-failed", taskId, cause: "gone-during-check" };
3410
3522
  }
3411
3523
  }
3412
- function planFix(task, isGone, epicClosing) {
3524
+ async function epicStatusFixes(loaded, inScope, messages) {
3525
+ const closing = planEpicClosing(loaded.tasks, loaded.errors).close.map(({ epic, childIds }) => [
3526
+ epic.id,
3527
+ { status: "done", closure: epicDoneClosure(childIds, messages), done: { kind: "epic-closed", taskId: epic.id, childIds } }
3528
+ ]);
3529
+ const reopenable = planEpicReopening(loaded.tasks).filter(({ epic }) => inScope(epic.projectId));
3530
+ const reopening = await Promise.all(
3531
+ reopenable.map(async ({ epic, childIds }) => [epic.id, { status: await statusToReopen(epic), done: { kind: "epic-reopened", taskId: epic.id, childIds } }])
3532
+ );
3533
+ return new Map([...closing, ...reopening]);
3534
+ }
3535
+ function planFix(task, isGone, epicFix) {
3413
3536
  const cleanup = referenceCleanup(task, isGone);
3414
- if (cleanup === null && epicClosing === void 0) return null;
3537
+ if (cleanup === null && epicFix === void 0) return null;
3415
3538
  const done = [];
3416
3539
  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 };
3540
+ if (epicFix === void 0) return { changes: cleanup ?? {}, done };
3541
+ return { changes: { ...cleanup, status: epicFix.status }, closure: epicFix.closure, done: [...done, epicFix.done] };
3420
3542
  }
3421
3543
  function goneReferences(task, isGone) {
3422
3544
  const references = [...task.blockedBy, ...task.related, ...task.epic === void 0 ? [] : [task.epic]];
@@ -3788,7 +3910,11 @@ async function fixCommitFound(loaded, task, reason, io) {
3788
3910
  }
3789
3911
 
3790
3912
  // src/cli/commands/config.ts
3791
- import { join as join17 } from "node:path";
3913
+ import { join as join18 } from "node:path";
3914
+
3915
+ // src/cli/agents/agent.ts
3916
+ import { stat as stat4 } from "node:fs/promises";
3917
+ import { join as join16 } from "node:path";
3792
3918
 
3793
3919
  // src/core/claude-dir.ts
3794
3920
  import { join as join15 } from "node:path";
@@ -3805,18 +3931,67 @@ function claudeProjectsDir(env, home2) {
3805
3931
  return join15(claudeDir(env, home2), "projects");
3806
3932
  }
3807
3933
 
3934
+ // src/cli/agents/agent.ts
3935
+ var AGENTS = ["claude", "codex", "cursor"];
3936
+ var AGENT_LABELS = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
3937
+ function agentHomeDir(agent, env, home2) {
3938
+ if (agent === "claude") return claudeDir(env, home2);
3939
+ if (agent === "codex") return env.CODEX_HOME || join16(home2, ".codex");
3940
+ return join16(home2, ".cursor");
3941
+ }
3942
+ function agentSkillsDir(agent, env, home2) {
3943
+ if (agent === "claude") return claudeSkillsDir(env, home2);
3944
+ if (agent === "codex") return join16(home2, ".agents", "skills");
3945
+ return join16(agentHomeDir(agent, env, home2), "skills");
3946
+ }
3947
+ function legacySkillsDirs(agent, env, home2) {
3948
+ return agent === "codex" ? [join16(agentHomeDir(agent, env, home2), "skills")] : [];
3949
+ }
3950
+ async function detectAgents(env, home2) {
3951
+ const detection = { found: [], missing: [] };
3952
+ for (const agent of AGENTS) {
3953
+ const dir = agentHomeDir(agent, env, home2);
3954
+ const present = agent === "claude" || ((await stat4(dir).catch(() => null))?.isDirectory() ?? false);
3955
+ if (present) detection.found.push(agent);
3956
+ else detection.missing.push({ agent, dir });
3957
+ }
3958
+ return detection;
3959
+ }
3960
+
3961
+ // src/cli/agents/claude-plugin.ts
3962
+ import { z as z8 } from "zod";
3963
+ var PLUGIN_BY_LANGUAGE = { en: "p-backlog", ru: "p-backlog-ru" };
3964
+ var PLUGIN_NAMES = new Set(Object.values(PLUGIN_BY_LANGUAGE));
3965
+ var MARKETPLACE_SEPARATOR = "@";
3966
+ var enabledPluginsSchema = z8.object({ enabledPlugins: z8.record(z8.string(), z8.unknown()).optional() });
3967
+ async function agentPlugin(agent, env, home2) {
3968
+ if (agent !== "claude") return null;
3969
+ const settings = await readJsonFile(claudeSettingsPath(env, home2), enabledPluginsSchema);
3970
+ const enabled = Object.entries(settings?.enabledPlugins ?? {}).find(([id, on]) => on === true && PLUGIN_NAMES.has(pluginName(id)));
3971
+ return enabled?.[0] ?? null;
3972
+ }
3973
+ function pluginToSwitchTo(pluginId, language) {
3974
+ const wanted = PLUGIN_BY_LANGUAGE[language];
3975
+ if (pluginName(pluginId) === wanted) return null;
3976
+ const marketplace = pluginId.slice(pluginId.indexOf(MARKETPLACE_SEPARATOR) + 1);
3977
+ return `${wanted}${MARKETPLACE_SEPARATOR}${marketplace}`;
3978
+ }
3979
+ function pluginName(pluginId) {
3980
+ return pluginId.split(MARKETPLACE_SEPARATOR)[0] ?? pluginId;
3981
+ }
3982
+
3808
3983
  // src/cli/skill-link.ts
3809
3984
  import { lstat, mkdir as mkdir4, readlink, symlink, unlink } from "node:fs/promises";
3810
- import { basename as basename8, dirname as dirname5, join as join16, resolve as resolve3 } from "node:path";
3811
- import { z as z8 } from "zod";
3985
+ import { basename as basename8, dirname as dirname5, join as join17, resolve as resolve3 } from "node:path";
3986
+ import { z as z9 } from "zod";
3812
3987
  var SKILL_VARIANTS = /* @__PURE__ */ new Set(["backlog", "backlog-en"]);
3813
- var packageManifestSchema = z8.object({ name: z8.string() });
3988
+ var packageManifestSchema = z9.object({ name: z9.string() });
3814
3989
  function skillSourceDir(packageRoot2, language) {
3815
- return join16(packageRoot2, "skill", language === "ru" ? "backlog" : "backlog-en");
3990
+ return join17(packageRoot2, "skill", language === "ru" ? "backlog" : "backlog-en");
3816
3991
  }
3817
3992
  async function isPBacklogSkill(path) {
3818
3993
  if (!SKILL_VARIANTS.has(basename8(path)) || basename8(dirname5(path)) !== "skill") return false;
3819
- const manifest = await readTextOrNull(join16(dirname5(dirname5(path)), "package.json"));
3994
+ const manifest = await readTextOrNull(join17(dirname5(dirname5(path)), "package.json"));
3820
3995
  if (manifest !== null) return parseJson(manifest, packageManifestSchema)?.name === "p-backlog";
3821
3996
  return !await pathExists(path);
3822
3997
  }
@@ -3824,7 +3999,7 @@ async function pathExists(path) {
3824
3999
  return await lstat(path).catch(() => null) !== null;
3825
4000
  }
3826
4001
  async function linkSkillFor(language, { skillsDir, packageRoot: packageRoot2, platform }) {
3827
- const target = join16(skillsDir, "backlog");
4002
+ const target = join17(skillsDir, "backlog");
3828
4003
  const source = skillSourceDir(packageRoot2, language);
3829
4004
  const existing = await lstat(target).catch(() => null);
3830
4005
  if (existing !== null && !existing.isSymbolicLink()) return "foreign";
@@ -3838,6 +4013,18 @@ async function linkSkillFor(language, { skillsDir, packageRoot: packageRoot2, pl
3838
4013
  await symlink(source, target, platform === "win32" ? "junction" : "dir");
3839
4014
  return "linked";
3840
4015
  }
4016
+ async function relinkExistingSkill(language, options) {
4017
+ if (!await pathExists(join17(options.skillsDir, "backlog"))) return "absent";
4018
+ return linkSkillFor(language, options);
4019
+ }
4020
+ async function unlinkOurSkill(skillsDir) {
4021
+ const target = join17(skillsDir, "backlog");
4022
+ const existing = await lstat(target).catch(() => null);
4023
+ if (existing === null) return "absent";
4024
+ if (!existing.isSymbolicLink() || !await isPBacklogSkill(resolve3(skillsDir, await readlink(target)))) return "foreign";
4025
+ await unlink(target);
4026
+ return "removed";
4027
+ }
3841
4028
 
3842
4029
  // src/cli/commands/config.ts
3843
4030
  var configCommand = {
@@ -3861,11 +4048,29 @@ async function runLanguage(positionals, io) {
3861
4048
  const language = parseChoice(io.language, value, LANGUAGES, cliMessages(io.language).optionLabel.language);
3862
4049
  await writeSettings(io.backlogRoot, { language });
3863
4050
  io.print(`${io.language} \u2192 ${language}`);
3864
- const skillsDir = claudeSkillsDir(io.env, io.home);
3865
- const result = await linkSkillFor(language, { skillsDir, packageRoot: io.packageRoot, platform: io.platform });
3866
- if (result === "foreign") io.warn(cliMessages(language).skillForeign(join17(skillsDir, "backlog")));
4051
+ const { found } = await detectAgents(io.env, io.home);
4052
+ for (const agent of found) await relinkSkill(agent, language, io);
3867
4053
  return EXIT.ok;
3868
4054
  }
4055
+ async function relinkSkill(agent, language, io) {
4056
+ const messages = cliMessages(language);
4057
+ const label = AGENT_LABELS[agent];
4058
+ const plugin = await agentPlugin(agent, io.env, io.home);
4059
+ if (plugin !== null) {
4060
+ const wanted = pluginToSwitchTo(plugin, language);
4061
+ if (wanted !== null) io.print(`${label}: ${messages.pluginLanguageHint(plugin, wanted)}`);
4062
+ return;
4063
+ }
4064
+ const skillsDir = agentSkillsDir(agent, io.env, io.home);
4065
+ const target = join18(skillsDir, "backlog");
4066
+ const options = { skillsDir, packageRoot: io.packageRoot, platform: io.platform };
4067
+ try {
4068
+ const result = agent === "claude" ? await linkSkillFor(language, options) : await relinkExistingSkill(language, options);
4069
+ if (result === "foreign") io.warn(`${label}: ${messages.skillForeign(target)}`);
4070
+ } catch (error) {
4071
+ io.warn(`${label}: ${messages.installSkillLinkFailed(target, errorCodeOrText(error))}`);
4072
+ }
4073
+ }
3869
4074
 
3870
4075
  // src/cli/apply-all.ts
3871
4076
  async function applyAll(items, apply) {
@@ -3918,31 +4123,47 @@ function epicWord(cli, epic) {
3918
4123
 
3919
4124
  // src/cli/commands/hook.ts
3920
4125
  import { dirname as dirname6 } from "node:path";
3921
- import { z as z11 } from "zod";
4126
+
4127
+ // src/core/store/hook-turns.ts
4128
+ import { join as join19 } from "node:path";
4129
+ import { z as z10 } from "zod";
4130
+ var HOOK_TURNS_FILE = ".hook-turns.json";
4131
+ var CLAIM_WINDOW_MS = 6e4;
4132
+ var hookTurnsSchema = z10.record(z10.string(), z10.iso.datetime({ offset: true }));
4133
+ async function claimHookTurn(backlogRoot2, turnKey, now) {
4134
+ const path = join19(backlogRoot2, HOOK_TURNS_FILE);
4135
+ return withFileLock(path, async () => {
4136
+ const turns = await readJsonFile(path, hookTurnsSchema) ?? {};
4137
+ const claimed = Object.entries(turns).filter(([, at]) => now.getTime() - Date.parse(at) < CLAIM_WINDOW_MS);
4138
+ if (claimed.some(([key]) => key === turnKey)) return false;
4139
+ await writeJsonFile(path, { ...Object.fromEntries(claimed), [turnKey]: now.toISOString() });
4140
+ return true;
4141
+ });
4142
+ }
3922
4143
 
3923
4144
  // src/core/store/signals-shown.ts
3924
- import { join as join18 } from "node:path";
3925
- import { z as z9 } from "zod";
4145
+ import { join as join20 } from "node:path";
4146
+ import { z as z11 } from "zod";
3926
4147
  var SIGNALS_SHOWN_FILE = ".signals-shown.json";
3927
- var shownSchema = z9.record(z9.string(), z9.string());
4148
+ var shownSchema = z11.record(z11.string(), z11.string());
3928
4149
  async function readSignalsShown(projectDir2) {
3929
- return await readJsonFile(join18(projectDir2, SIGNALS_SHOWN_FILE), shownSchema) ?? {};
4150
+ return await readJsonFile(join20(projectDir2, SIGNALS_SHOWN_FILE), shownSchema) ?? {};
3930
4151
  }
3931
4152
  async function writeSignalsShown(projectDir2, shown) {
3932
- await writeJsonFile(join18(projectDir2, SIGNALS_SHOWN_FILE), shown);
4153
+ await writeJsonFile(join20(projectDir2, SIGNALS_SHOWN_FILE), shown);
3933
4154
  }
3934
4155
 
3935
4156
  // src/core/store/session-shown.ts
3936
- import { join as join19 } from "node:path";
3937
- import { z as z10 } from "zod";
4157
+ import { join as join21 } from "node:path";
4158
+ import { z as z12 } from "zod";
3938
4159
  var SESSION_SHOWN_FILE = ".candidates-shown.json";
3939
4160
  var SESSION_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
3940
- var sessionsShownSchema = z10.record(z10.string(), z10.object({ tasks: z10.array(z10.string()), at: z10.iso.datetime({ offset: true }) }));
4161
+ var sessionsShownSchema = z12.record(z12.string(), z12.object({ tasks: z12.array(z12.string()), at: z12.iso.datetime({ offset: true }) }));
3941
4162
  async function readSessionShown(projectDir2, session) {
3942
4163
  return (await readSessions(projectDir2))[session]?.tasks ?? [];
3943
4164
  }
3944
4165
  async function rememberSessionShown(projectDir2, session, taskIds, now) {
3945
- const path = join19(projectDir2, SESSION_SHOWN_FILE);
4166
+ const path = join21(projectDir2, SESSION_SHOWN_FILE);
3946
4167
  await withFileLock(path, async () => {
3947
4168
  const sessions = await readSessions(projectDir2);
3948
4169
  const recent = Object.entries(sessions).filter(([, shown]) => now.getTime() - Date.parse(shown.at) < SESSION_RETENTION_MS);
@@ -3951,7 +4172,7 @@ async function rememberSessionShown(projectDir2, session, taskIds, now) {
3951
4172
  });
3952
4173
  }
3953
4174
  async function readSessions(projectDir2) {
3954
- return await readJsonFile(join19(projectDir2, SESSION_SHOWN_FILE), sessionsShownSchema) ?? {};
4175
+ return await readJsonFile(join21(projectDir2, SESSION_SHOWN_FILE), sessionsShownSchema) ?? {};
3955
4176
  }
3956
4177
 
3957
4178
  // src/core/stats/numbers.ts
@@ -4229,6 +4450,56 @@ function markShown(shown, signals, today) {
4229
4450
  return { ...shown, ...Object.fromEntries(signals.map((signal3) => [signal3.kind, today])) };
4230
4451
  }
4231
4452
 
4453
+ // src/cli/agents/stop-event.ts
4454
+ import { createHash as createHash4 } from "node:crypto";
4455
+ import { z as z13 } from "zod";
4456
+ var STOP_PROTOCOL = { claude: "claude", codex: "claude", cursor: "cursor" };
4457
+ var TURN_DIGEST_LENGTH = 16;
4458
+ var CURSOR_COMPLETED = "completed";
4459
+ var claudeEventSchema = z13.object({
4460
+ cwd: z13.string(),
4461
+ session_id: z13.string().optional(),
4462
+ turn_id: z13.string().optional(),
4463
+ stop_hook_active: z13.boolean().optional(),
4464
+ last_assistant_message: z13.string().nullish()
4465
+ });
4466
+ var cursorEventSchema = z13.object({
4467
+ workspace_roots: z13.tuple([z13.string()], z13.string()),
4468
+ conversation_id: z13.string().optional(),
4469
+ generation_id: z13.string().optional(),
4470
+ status: z13.string().optional(),
4471
+ loop_count: z13.number().optional()
4472
+ });
4473
+ function parseStopEvent(agent, text) {
4474
+ return STOP_PROTOCOL[agent] === "cursor" ? parseCursorEvent(text) : parseClaudeEvent(text);
4475
+ }
4476
+ function carriesSystemMessage(agent) {
4477
+ return STOP_PROTOCOL[agent] === "claude";
4478
+ }
4479
+ function formatStopAnswer(agent, answer) {
4480
+ if (STOP_PROTOCOL[agent] === "cursor") return answer.reason === null ? null : JSON.stringify({ followup_message: answer.reason });
4481
+ const response = {
4482
+ ...answer.reason !== null ? { decision: "block", reason: answer.reason } : {},
4483
+ ...answer.systemMessage !== null ? { systemMessage: answer.systemMessage } : {}
4484
+ };
4485
+ return Object.keys(response).length > 0 ? JSON.stringify(response) : null;
4486
+ }
4487
+ function parseClaudeEvent(text) {
4488
+ const event = parseJson(text, claudeEventSchema);
4489
+ if (event === null) return null;
4490
+ return { cwd: event.cwd, session: event.session_id, turn: event.turn_id ?? digest(event.last_assistant_message), skip: event.stop_hook_active === true };
4491
+ }
4492
+ function parseCursorEvent(text) {
4493
+ const event = parseJson(text, cursorEventSchema);
4494
+ if (event === null) return null;
4495
+ const followsOurFollowUp = (event.loop_count ?? 0) > 0;
4496
+ const interrupted = (event.status ?? CURSOR_COMPLETED) !== CURSOR_COMPLETED;
4497
+ return { cwd: event.workspace_roots[0], session: event.conversation_id, turn: event.generation_id, skip: followsOurFollowUp || interrupted };
4498
+ }
4499
+ function digest(text) {
4500
+ return text ? createHash4("sha256").update(text).digest("hex").slice(0, TURN_DIGEST_LENGTH) : void 0;
4501
+ }
4502
+
4232
4503
  // src/cli/stop-reason.ts
4233
4504
  var STOP_REASON_LIMIT = 500;
4234
4505
  function stopReason(language, projectId, candidates) {
@@ -4257,37 +4528,50 @@ function briefEvidence(cli, candidate) {
4257
4528
  }
4258
4529
 
4259
4530
  // src/cli/commands/hook.ts
4260
- var stopEventSchema = z11.object({ cwd: z11.string(), session_id: z11.string().optional(), stop_hook_active: z11.boolean().optional() });
4531
+ var DEFAULT_AGENT = "claude";
4261
4532
  var hookCommand = {
4262
4533
  name: "hook",
4263
- usage: (language) => [cliMessages(language).hookUsage(HOOK_STOP_EVENT)],
4534
+ usage: (language) => [cliMessages(language).hookUsage(HOOK_STOP_EVENT, AGENTS)],
4264
4535
  run: runHook,
4265
4536
  failureExit: EXIT.ok
4266
4537
  };
4267
4538
  async function runHook(args, io) {
4268
- if (args.length !== 1 || args[0] !== HOOK_STOP_EVENT) throw usageError(hookCommand, io.language);
4269
- const event = parseJson(await io.readStdin(), stopEventSchema);
4270
- if (event === null || event.stop_hook_active === true) return EXIT.ok;
4539
+ const { values, positionals } = parseCommandArgs(io.language, args, { agent: { type: "string" } });
4540
+ if (positionals.length !== 1 || positionals[0] !== HOOK_STOP_EVENT) throw usageError(hookCommand, io.language);
4541
+ const agent = values.agent === void 0 ? DEFAULT_AGENT : parseChoice(io.language, values.agent, AGENTS, cliMessages(io.language).optionLabel.agent);
4542
+ const event = parseStopEvent(agent, await io.readStdin());
4543
+ if (event === null || event.skip) return EXIT.ok;
4271
4544
  const loaded = await loadBacklog(io.backlogRoot);
4272
4545
  const project = findProjectForDir(loaded.projects, event.cwd, io.home);
4273
4546
  if (!project) return EXIT.ok;
4547
+ if (!await isFirstHookOfTurn(agent, event, io)) return EXIT.ok;
4274
4548
  const messages = coreMessages(io.language);
4275
4549
  const { candidates } = await checkBacklog(io.backlogRoot, loaded, { projectIds: [project.id], mode: "changed", now: io.now(), home: io.home, messages, workingDir: event.cwd });
4276
4550
  const lowPriority = new Set(loaded.tasks.filter((task) => task.priority === "low").map((task) => task.id));
4277
4551
  const worthTelling = candidates.filter((candidate) => !lowPriority.has(candidate.task.id));
4278
4552
  const lowCount = candidates.length - worthTelling.length;
4279
- const session = await sessionMemory(project, event.session_id, io);
4553
+ const session = await sessionMemory(project, event.session, io);
4280
4554
  const blocking = worthTelling.filter((candidate) => !session.told.has(candidate.task.id));
4281
- const signals = await freshSignals(project, loaded.tasks.filter((task) => task.projectId === project.id), lowChangedSignals(lowCount), io);
4282
- const response = {
4283
- ...blocking.length > 0 ? { decision: "block", reason: stopReason(io.language, project.id, blocking) } : {},
4284
- ...signals.fresh.length > 0 ? { systemMessage: hookMessage(io.language, project.id, signals.fresh.map((signal3) => messages.signal(signal3)).join("; ")) } : {}
4285
- };
4286
- if (Object.keys(response).length > 0) io.print(JSON.stringify(response));
4555
+ const signals = carriesSystemMessage(agent) ? await freshSignals(project, loaded.tasks.filter((task) => task.projectId === project.id), lowChangedSignals(lowCount), io) : NO_SIGNALS;
4556
+ const answer = formatStopAnswer(agent, {
4557
+ reason: blocking.length > 0 ? stopReason(io.language, project.id, blocking) : null,
4558
+ systemMessage: signals.fresh.length > 0 ? hookMessage(io.language, project.id, signals.fresh.map((signal3) => messages.signal(signal3)).join("; ")) : null
4559
+ });
4560
+ if (answer !== null) io.print(answer);
4287
4561
  await signals.remember();
4288
4562
  await session.remember(blocking.map((candidate) => candidate.task.id));
4289
4563
  return EXIT.ok;
4290
4564
  }
4565
+ async function isFirstHookOfTurn(agent, event, io) {
4566
+ if (event.session === void 0 || event.turn === void 0) return true;
4567
+ try {
4568
+ return await claimHookTurn(io.backlogRoot, `${agent}:${event.session}:${event.turn}`, io.now());
4569
+ } catch (error) {
4570
+ if (error instanceof FileBusyError) return false;
4571
+ io.warn(cliMessages(io.language).hookTurnClaimFailed(errorText(error)));
4572
+ return true;
4573
+ }
4574
+ }
4291
4575
  async function sessionMemory(project, session, io) {
4292
4576
  if (session === void 0) return { told: /* @__PURE__ */ new Set(), remember: () => Promise.resolve() };
4293
4577
  const projectDir2 = dirname6(project.path);
@@ -4303,6 +4587,7 @@ async function sessionMemory(project, session, io) {
4303
4587
  return { told: /* @__PURE__ */ new Set(), remember: () => Promise.resolve() };
4304
4588
  }
4305
4589
  }
4590
+ var NO_SIGNALS = { fresh: [], remember: () => Promise.resolve() };
4306
4591
  function lowChangedSignals(count3) {
4307
4592
  if (count3 === 0) return [];
4308
4593
  return [{ kind: "low-changed", params: { count: count3 } }];
@@ -4603,6 +4888,13 @@ var serverEn = {
4603
4888
  projectNotFound: (id) => `Project ${id} not found`,
4604
4889
  confirmMismatch: "Confirmation does not match the project id",
4605
4890
  bodyNotParsed: "Request body could not be parsed: JSON expected",
4891
+ batchSkipped: {
4892
+ changed: (id) => `${id} changed on disk`,
4893
+ "not-found": (id) => `${id} not found`,
4894
+ "already-closed": (id) => `${id} is already closed`,
4895
+ invalid: (id) => `${id}: the action doesn't apply to it`,
4896
+ busy: (id) => `${id} is busy in another process`
4897
+ },
4606
4898
  unknownRoute: (path) => `Unknown API route: ${path}`,
4607
4899
  hostRejected: (host) => `Requests from host ${host} are not accepted`,
4608
4900
  jsonContentTypeExpected: "Content-Type: application/json is expected",
@@ -4613,6 +4905,7 @@ Backlog directory: ${root}
4613
4905
  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
4906
  sweepFailed: (detail) => `Could not delete closed tasks: ${detail}`,
4615
4907
  closedEpics: (ids) => `Closed completed epics: ${ids}`,
4908
+ reopenedEpics: (ids) => `Reopened epics that got an open task again: ${ids}`,
4616
4909
  epicsBlockedByFiles: (paths) => `Epics will not close until these files are fixed: ${paths}`,
4617
4910
  deletedClosedTasks: (ids) => `Deleted closed tasks: ${ids}`,
4618
4911
  conflictedDuringSweep: (ids) => `Tasks changed during the sweep, will retry next time: ${ids}`,
@@ -4633,6 +4926,13 @@ var serverRu = {
4633
4926
  projectNotFound: (id) => `\u041F\u0440\u043E\u0435\u043A\u0442 ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`,
4634
4927
  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
4928
  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",
4929
+ batchSkipped: {
4930
+ changed: (id) => `${id} \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0430\u0441\u044C \u043D\u0430 \u0434\u0438\u0441\u043A\u0435`,
4931
+ "not-found": (id) => `${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430`,
4932
+ "already-closed": (id) => `${id} \u0443\u0436\u0435 \u0437\u0430\u043A\u0440\u044B\u0442\u0430`,
4933
+ 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`,
4934
+ busy: (id) => `${id} \u0437\u0430\u043D\u044F\u0442\u0430 \u0434\u0440\u0443\u0433\u0438\u043C \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u043C`
4935
+ },
4636
4936
  unknownRoute: (path) => `\u041D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0430\u0434\u0440\u0435\u0441 API: ${path}`,
4637
4937
  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
4938
  jsonContentTypeExpected: "\u041E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F Content-Type: application/json",
@@ -4643,6 +4943,7 @@ var serverRu = {
4643
4943
  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
4944
  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
4945
  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}`,
4946
+ 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
4947
  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
4948
  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
4949
  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}`,
@@ -4686,30 +4987,33 @@ function listenFailure(error, port, messages) {
4686
4987
  // src/server/start.ts
4687
4988
  import { serve } from "@hono/node-server";
4688
4989
  import { mkdir as mkdir6, rm as rm4, writeFile as writeFile3 } from "node:fs/promises";
4689
- import { join as join25 } from "node:path";
4990
+ import { join as join27 } from "node:path";
4690
4991
 
4691
4992
  // src/core/store/sweep.ts
4692
- import { dirname as dirname7, join as join20 } from "node:path";
4993
+ import { dirname as dirname7, join as join22 } from "node:path";
4693
4994
  async function sweepClosed(root, now, messages) {
4694
4995
  const initial = await loadBacklog(root);
4996
+ const reopening = await reopenEpicsWithOpenTasks(initial, now);
4695
4997
  const epics = await closeCompletedEpics(initial, now, messages);
4696
- const { projects, tasks } = epics.closed.length > 0 ? await loadBacklog(root) : initial;
4998
+ const { projects, tasks } = epics.closed.length > 0 || reopening.reopened.length > 0 ? await loadBacklog(root) : initial;
4697
4999
  const waitsForEpic = (task) => task.epic !== void 0 && epics.leftOpen.has(task.epic);
4698
- const expired = tasks.filter((task) => isExpired(task, now) && !waitsForEpic(task));
5000
+ const stillHoldsOpenTasks = new Set(planEpicReopening(tasks).map(({ epic }) => epic.id));
5001
+ const expired = tasks.filter((task) => isExpired(task, now) && !waitsForEpic(task) && !stillHoldsOpenTasks.has(task.id));
4699
5002
  const reserved = await reserveNumbers(projects, expired);
4700
5003
  const removable = expired.filter((task) => reserved.has(task.projectId));
4701
5004
  const removal = await removeWithReferences(tasks, removable, now);
4702
5005
  await removeAbandonedTemporaries(root, now);
4703
5006
  return {
4704
5007
  closedEpics: epics.closed,
5008
+ reopenedEpics: reopening.reopened,
4705
5009
  blockingFiles: epics.blockingFiles,
4706
5010
  deleted: removal.deleted,
4707
- ...failureLists([...epics.failures, ...removal.failures], messages)
5011
+ ...failureLists([...reopening.failures, ...epics.failures, ...removal.failures], messages)
4708
5012
  };
4709
5013
  }
4710
5014
  async function removeAbandonedTemporaries(root, now) {
4711
5015
  const cutoff = new Date(now.getTime() - DAY_MS);
4712
- const projectDirs = (await listDir(root)).filter((entry) => entry.isDirectory()).map((entry) => join20(root, entry.name));
5016
+ const projectDirs = (await listDir(root)).filter((entry) => entry.isDirectory()).map((entry) => join22(root, entry.name));
4713
5017
  for (const dir of [root, ...projectDirs]) await removeTemporariesBefore(dir, cutoff);
4714
5018
  }
4715
5019
  async function removeWithReferences(tasks, removable, now) {
@@ -4735,6 +5039,17 @@ async function unchangedOnDisk(tasks) {
4735
5039
  );
4736
5040
  return tasks.filter((_, position) => checked[position]);
4737
5041
  }
5042
+ async function reopenEpicsWithOpenTasks(loaded, now) {
5043
+ const index = buildIndex(loaded.tasks);
5044
+ const reopened = [];
5045
+ const failures = [];
5046
+ for (const { epic } of planEpicReopening(loaded.tasks)) {
5047
+ const failure = await repairFailure(index, epic, { status: await statusToReopen(epic) }, now);
5048
+ if (failure === null) reopened.push(epic.id);
5049
+ else failures.push(failure);
5050
+ }
5051
+ return { reopened, failures };
5052
+ }
4738
5053
  async function closeCompletedEpics(loaded, now, messages) {
4739
5054
  const plan = planEpicClosing(loaded.tasks, loaded.errors);
4740
5055
  const index = buildIndex(loaded.tasks);
@@ -4822,34 +5137,51 @@ async function reserveNumbers(projects, expired) {
4822
5137
  import { serveStatic } from "@hono/node-server/serve-static";
4823
5138
  import { Hono as Hono3 } from "hono";
4824
5139
  import { secureHeaders } from "hono/secure-headers";
4825
- import { join as join22 } from "node:path";
5140
+ import { join as join24 } from "node:path";
4826
5141
 
4827
5142
  // src/server/api.ts
4828
5143
  import { Hono as Hono2 } from "hono";
4829
5144
  import { streamSSE } from "hono/streaming";
4830
5145
 
4831
5146
  // src/core/api/contract.ts
4832
- import { z as z12 } from "zod";
4833
- var idList = z12.array(taskIdSchema);
4834
- var taskChangesSchema = z12.strictObject({
4835
- title: z12.string().optional(),
4836
- type: z12.enum(TASK_TYPES).optional(),
4837
- status: z12.enum(TASK_STATUSES).optional(),
4838
- priority: z12.enum(PRIORITIES).optional(),
4839
- tags: z12.array(z12.string()).optional(),
5147
+ import { z as z14 } from "zod";
5148
+ var idList = z14.array(taskIdSchema);
5149
+ var taskChangesSchema = z14.strictObject({
5150
+ title: z14.string().optional(),
5151
+ type: z14.enum(TASK_TYPES).optional(),
5152
+ status: z14.enum(TASK_STATUSES).optional(),
5153
+ priority: z14.enum(PRIORITIES).optional(),
5154
+ tags: z14.array(z14.string()).optional(),
4840
5155
  epic: taskIdSchema.nullable().optional(),
4841
5156
  blockedBy: idList.optional(),
4842
5157
  related: idList.optional(),
4843
- body: z12.string().optional(),
4844
- category: z12.enum(TASK_CATEGORIES).nullable().optional()
5158
+ body: z14.string().optional(),
5159
+ category: z14.enum(TASK_CATEGORIES).nullable().optional()
4845
5160
  });
4846
- var projectActiveSchema = z12.strictObject({ active: z12.boolean() });
4847
- var projectDeleteSchema = z12.strictObject({ confirm: z12.string() });
4848
- var settingsRequestSchema = z12.strictObject(settingsSchema.shape);
4849
- var updateTaskRequestSchema = z12.strictObject({
4850
- version: z12.string().min(1),
5161
+ var projectActiveSchema = z14.strictObject({ active: z14.boolean() });
5162
+ var projectDeleteSchema = z14.strictObject({ confirm: z14.string() });
5163
+ var settingsRequestSchema = z14.strictObject(settingsSchema.shape);
5164
+ var updateTaskRequestSchema = z14.strictObject({
5165
+ version: z14.string().min(1),
4851
5166
  changes: taskChangesSchema
4852
5167
  });
5168
+ var batchPreviousSchema = z14.strictObject({
5169
+ status: z14.enum(TASK_STATUSES),
5170
+ priority: z14.enum(PRIORITIES),
5171
+ epic: taskIdSchema.nullable(),
5172
+ resolution: z14.enum(RESOLUTIONS).nullable(),
5173
+ reason: z14.string().nullable()
5174
+ });
5175
+ var BATCH_TASKS_LIMIT = 500;
5176
+ var batchRequestSchema = z14.strictObject({
5177
+ tasks: z14.array(z14.strictObject({ id: taskIdSchema, version: z14.string().min(1) })).min(1).max(BATCH_TASKS_LIMIT).refine((tasks) => new Set(tasks.map((task) => task.id)).size === tasks.length),
5178
+ action: z14.discriminatedUnion("kind", [
5179
+ z14.strictObject({ kind: z14.literal("close"), reason: z14.string().transform((reason) => reason.replace(/\s*\n\s*/g, " ").trim()).pipe(z14.string().min(1)) }),
5180
+ z14.strictObject({ kind: z14.literal("priority"), priority: z14.enum(PRIORITIES) }),
5181
+ z14.strictObject({ kind: z14.literal("epic"), epic: taskIdSchema.nullable() }),
5182
+ z14.strictObject({ kind: z14.literal("restore"), changes: z14.record(taskIdSchema, batchPreviousSchema) })
5183
+ ])
5184
+ });
4853
5185
 
4854
5186
  // src/core/check/graph-health.ts
4855
5187
  async function projectGraphHealth(project, tasks, home2) {
@@ -4879,6 +5211,58 @@ function isStale(graph, pinned, hashOf) {
4879
5211
  return count3("changed") > count3("fresh");
4880
5212
  }
4881
5213
 
5214
+ // src/core/store/batch.ts
5215
+ async function applyBatch(index, { tasks, action, now }) {
5216
+ const ordered = [...tasks].sort((left, right) => compareIds(left.id, right.id));
5217
+ const outcomes = [];
5218
+ for (const task of ordered) outcomes.push(await applyOne(index, task, action, now));
5219
+ return outcomes;
5220
+ }
5221
+ async function applyOne(index, { id, version }, action, now) {
5222
+ const current = index.byId.get(id);
5223
+ if (!current) return { id, outcome: "skipped", reason: "not-found" };
5224
+ const plan = planFor(current, action);
5225
+ if ("skip" in plan) return { id, outcome: "skipped", reason: plan.skip };
5226
+ const result = await updateUnlessBusy(index, { id, changes: plan.changes, closure: plan.closure, expectedVersion: version, now, via: "web", undo: action.kind === "restore" });
5227
+ if (result === "busy") return { id, outcome: "skipped", reason: "busy" };
5228
+ if (result.ok) return { id, outcome: "done", task: result.task, previous: previousOf(current) };
5229
+ if (result.reason === "conflict") return { id, outcome: "skipped", reason: "changed" };
5230
+ if (result.reason === "not-found") return { id, outcome: "skipped", reason: "not-found" };
5231
+ return { id, outcome: "skipped", reason: "invalid", problems: result.errors };
5232
+ }
5233
+ async function updateUnlessBusy(index, request) {
5234
+ try {
5235
+ return await updateTaskInIndex(index, request);
5236
+ } catch (error) {
5237
+ if (error instanceof FileBusyError) return "busy";
5238
+ throw error;
5239
+ }
5240
+ }
5241
+ function planFor(current, action) {
5242
+ switch (action.kind) {
5243
+ case "close":
5244
+ return isClosed(current.status) ? { skip: "already-closed" } : { changes: { status: "cancelled" }, closure: { resolution: "obsolete", reason: action.reason } };
5245
+ case "priority":
5246
+ return { changes: { priority: action.priority } };
5247
+ case "epic":
5248
+ return epicPlan(current, action.epic);
5249
+ case "restore":
5250
+ return restorePlan(current, action.changes[current.id]);
5251
+ }
5252
+ }
5253
+ function epicPlan(current, epic) {
5254
+ return current.type === "epic" ? { skip: "invalid" } : { changes: { epic } };
5255
+ }
5256
+ function restorePlan(current, previous) {
5257
+ if (!previous) return { skip: "invalid" };
5258
+ const wouldClose = previous.status !== current.status && isClosed(previous.status);
5259
+ if (wouldClose) return { skip: "invalid" };
5260
+ return { changes: { status: previous.status, priority: previous.priority, epic: previous.epic } };
5261
+ }
5262
+ function previousOf(task) {
5263
+ return { status: task.status, priority: task.priority, epic: task.epic ?? null, resolution: task.resolution ?? null, reason: task.reason ?? null };
5264
+ }
5265
+
4882
5266
  // src/server/report-cache.ts
4883
5267
  function createReportCache({ ttlMs, now }) {
4884
5268
  let entries = /* @__PURE__ */ new Map();
@@ -4898,30 +5282,68 @@ function createReportCache({ ttlMs, now }) {
4898
5282
  };
4899
5283
  }
4900
5284
 
5285
+ // src/server/revisions.ts
5286
+ import { randomUUID as randomUUID3 } from "node:crypto";
5287
+ import { stat as stat5 } from "node:fs/promises";
5288
+ import { basename as basename9 } from "node:path";
5289
+ function createRevisions(boot = randomUUID3()) {
5290
+ let seq = 0;
5291
+ const ownMarks = /* @__PURE__ */ new Map();
5292
+ const isOwn = async (path) => {
5293
+ if (basename9(path) === JOURNAL_FILE) return true;
5294
+ const expected = ownMarks.get(path);
5295
+ ownMarks.delete(path);
5296
+ if (expected === void 0) return false;
5297
+ const actual = await markOf(path);
5298
+ return actual !== null && actual.version === expected.version && actual.mtimeMs === expected.mtimeMs;
5299
+ };
5300
+ return {
5301
+ current: () => ({ boot, seq }),
5302
+ recordOwnWrites: async (writes) => {
5303
+ const marks = await Promise.all(writes.map(async ({ path }) => ({ path, mark: await markOf(path) })));
5304
+ seq += 1;
5305
+ marks.forEach(({ path, mark }, index) => {
5306
+ if (mark !== null && mark.version === writes[index]?.version) ownMarks.set(path, mark);
5307
+ else ownMarks.delete(path);
5308
+ });
5309
+ },
5310
+ settle: async (paths) => {
5311
+ const own = paths.length > 0 && (await Promise.all(paths.map((path) => isOwn(path).catch(() => false)))).every(Boolean);
5312
+ if (own) return "own";
5313
+ seq += 1;
5314
+ return "foreign";
5315
+ }
5316
+ };
5317
+ }
5318
+ async function markOf(path) {
5319
+ const [text, stats] = await Promise.all([readTextOrNull(path), stat5(path).catch(() => null)]);
5320
+ return text === null || stats === null ? null : { version: contentVersion(text), mtimeMs: stats.mtimeMs };
5321
+ }
5322
+
4901
5323
  // src/server/stats-api.ts
4902
5324
  import { Hono } from "hono";
4903
5325
 
4904
5326
  // src/core/code/code-cache.ts
4905
- import { join as join21 } from "node:path";
4906
- import { z as z13 } from "zod";
5327
+ import { join as join23 } from "node:path";
5328
+ import { z as z15 } from "zod";
4907
5329
  var CODE_CACHE_FILE = ".code-cache.json";
4908
5330
  var CODE_CACHE_VERSION = 1;
4909
- var repoCodeSchema = z13.object({
4910
- commits: z13.array(z13.array(z13.string())),
4911
- lines: z13.array(z13.object({ path: z13.string(), lines: z13.number() })),
4912
- units: z13.array(z13.object({ date: z13.string(), lines: z13.number() }))
5331
+ var repoCodeSchema = z15.object({
5332
+ commits: z15.array(z15.array(z15.string())),
5333
+ lines: z15.array(z15.object({ path: z15.string(), lines: z15.number() })),
5334
+ units: z15.array(z15.object({ date: z15.string(), lines: z15.number() }))
4913
5335
  });
4914
- var fixCommitSchema = z13.object({ date: z13.string(), landedAt: z13.string().optional(), byAgent: z13.boolean(), lines: z13.number(), testLines: z13.number() });
4915
- var snapshotSchema = z13.object({
4916
- version: z13.literal(CODE_CACHE_VERSION),
4917
- repos: z13.record(z13.string(), z13.object({ key: z13.string(), code: repoCodeSchema })),
4918
- fixes: z13.record(z13.string(), fixCommitSchema)
5336
+ var fixCommitSchema = z15.object({ date: z15.string(), landedAt: z15.string().optional(), byAgent: z15.boolean(), lines: z15.number(), testLines: z15.number() });
5337
+ var snapshotSchema = z15.object({
5338
+ version: z15.literal(CODE_CACHE_VERSION),
5339
+ repos: z15.record(z15.string(), z15.object({ key: z15.string(), code: repoCodeSchema })),
5340
+ fixes: z15.record(z15.string(), fixCommitSchema)
4919
5341
  });
4920
5342
  function emptyCodeCache() {
4921
5343
  return { repos: {}, fixes: {} };
4922
5344
  }
4923
5345
  function createCodeCacheFile(root) {
4924
- const path = join21(root, CODE_CACHE_FILE);
5346
+ const path = join23(root, CODE_CACHE_FILE);
4925
5347
  return {
4926
5348
  read: async () => {
4927
5349
  const snapshot = await readJsonFile(path, snapshotSchema);
@@ -5318,13 +5740,13 @@ function codeFixRequests(input, base = reportBase(input)) {
5318
5740
  }
5319
5741
 
5320
5742
  // src/core/stats/cost/token-counts.ts
5321
- import { z as z14 } from "zod";
5322
- var tokenCountsSchema = z14.object({
5323
- input: z14.number(),
5324
- cacheWrite5m: z14.number(),
5325
- cacheWrite1h: z14.number(),
5326
- cacheRead: z14.number(),
5327
- output: z14.number()
5743
+ import { z as z16 } from "zod";
5744
+ var tokenCountsSchema = z16.object({
5745
+ input: z16.number(),
5746
+ cacheWrite5m: z16.number(),
5747
+ cacheWrite1h: z16.number(),
5748
+ cacheRead: z16.number(),
5749
+ output: z16.number()
5328
5750
  });
5329
5751
  var ZERO_TOKENS = { input: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0, output: 0 };
5330
5752
  function addTokens(a, b) {
@@ -5339,24 +5761,24 @@ function totalTokens(tokens) {
5339
5761
  }
5340
5762
 
5341
5763
  // src/core/stats/cost/usage-state.ts
5342
- import { z as z15 } from "zod";
5764
+ import { z as z17 } from "zod";
5343
5765
  var COST_REPORT_DAYS = 30;
5344
- var estimatedKindSchema = z15.enum(["cli", "skill"]);
5345
- var usageBucketSchema = z15.object({
5346
- slot: z15.string(),
5347
- cwd: z15.string(),
5348
- model: z15.string(),
5349
- kind: z15.enum(["hook", ...estimatedKindSchema.options]),
5766
+ var estimatedKindSchema = z17.enum(["cli", "skill"]);
5767
+ var usageBucketSchema = z17.object({
5768
+ slot: z17.string(),
5769
+ cwd: z17.string(),
5770
+ model: z17.string(),
5771
+ kind: z17.enum(["hook", ...estimatedKindSchema.options]),
5350
5772
  tokens: tokenCountsSchema,
5351
- hookTurns: z15.number()
5773
+ hookTurns: z17.number()
5352
5774
  });
5353
- var transcriptStateSchema = z15.object({
5354
- hookOpen: z15.boolean(),
5355
- lastModel: z15.string().nullable(),
5356
- lastMessageId: z15.string().nullable(),
5775
+ var transcriptStateSchema = z17.object({
5776
+ hookOpen: z17.boolean(),
5777
+ lastModel: z17.string().nullable(),
5778
+ lastMessageId: z17.string().nullable(),
5357
5779
  lastMessageTokens: tokenCountsSchema.nullable().optional(),
5358
- pending: z15.record(z15.string(), estimatedKindSchema),
5359
- pendingEstimates: z15.array(z15.object({ kind: estimatedKindSchema, chars: z15.number(), slot: z15.string(), cwd: z15.string() }))
5780
+ pending: z17.record(z17.string(), estimatedKindSchema),
5781
+ pendingEstimates: z17.array(z17.object({ kind: estimatedKindSchema, chars: z17.number(), slot: z17.string(), cwd: z17.string() }))
5360
5782
  });
5361
5783
 
5362
5784
  // src/core/stats/cost/pricing.ts
@@ -5880,9 +6302,10 @@ async function resolveRepoRoots(lookupRepoRoot, cwds) {
5880
6302
  var GRAPH_STATE_TTL_MS = 60 * 1e3;
5881
6303
  function createApi({ root, readLanguage: readLanguage2, changes, now, home: home2, usage, memory, warn: warn2 }) {
5882
6304
  const api = new Hono2();
6305
+ const revisions = createRevisions();
5883
6306
  let snapshot = null;
5884
6307
  const backlog = () => {
5885
- snapshot ??= loadSnapshot(root).catch((error) => {
6308
+ snapshot ??= loadSnapshot(root, revisions.current()).catch((error) => {
5886
6309
  snapshot = null;
5887
6310
  throw error;
5888
6311
  });
@@ -5895,19 +6318,29 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
5895
6318
  stats.forget();
5896
6319
  graphStates.clear();
5897
6320
  };
5898
- changes.subscribe(forgetBacklog);
6321
+ const recordOwnWrites = async (writes) => {
6322
+ if (writes.length > 0) await revisions.recordOwnWrites(writes);
6323
+ forgetBacklog();
6324
+ };
6325
+ const streams = /* @__PURE__ */ new Set();
6326
+ changes.subscribe(async (paths) => {
6327
+ if (await revisions.settle(paths) === "foreign") forgetBacklog();
6328
+ else stats.forget();
6329
+ const revision = revisions.current();
6330
+ for (const send of streams) send(revision);
6331
+ });
5899
6332
  api.get("/projects", async (c) => {
5900
- const { projects, tasks } = await backlog();
6333
+ const { projects, tasks, revision } = await backlog();
5901
6334
  const withGraph = async (project) => {
5902
6335
  const codeGraph2 = await graphStates.get(project.id, async () => (await projectGraphHealth(project, tasks, home2)).state);
5903
6336
  return { ...project, codeGraph: codeGraph2 };
5904
6337
  };
5905
- return c.json(await Promise.all(projects.map(withGraph)));
6338
+ return c.json({ projects: await Promise.all(projects.map(withGraph)), revision });
5906
6339
  });
5907
6340
  api.get("/tasks", async (c) => {
5908
- const [{ tasks, errors }, language] = await Promise.all([backlog(), readLanguage2()]);
6341
+ const [{ tasks, errors, revision }, language] = await Promise.all([backlog(), readLanguage2()]);
5909
6342
  const messages = coreMessages(language);
5910
- return c.json({ tasks, errors: errors.map(({ problems: problems3, ...error }) => ({ ...error, message: messages.problems(problems3) })) });
6343
+ return c.json({ tasks, errors: errors.map(({ problems: problems3, ...error }) => ({ ...error, message: messages.problems(problems3) })), revision });
5911
6344
  });
5912
6345
  api.route("/", stats.routes);
5913
6346
  api.get("/settings", async (c) => c.json({ language: await readLanguage2() }));
@@ -5923,13 +6356,26 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
5923
6356
  const id = c.req.param("id");
5924
6357
  const { index } = await backlog();
5925
6358
  const result = await updateTaskInIndex(index, { id, changes: body.data.changes, expectedVersion: body.data.version, now: now(), via: "web" });
5926
- forgetBacklog();
6359
+ await recordOwnWrites(result.ok ? [result.task] : []);
5927
6360
  if (result.ok) return c.json(result.task);
5928
6361
  const messages = serverMessages(body.language);
5929
6362
  if (result.reason === "not-found") return c.json({ errors: [messages.taskNotFound(id)] }, 404);
5930
6363
  if (result.reason === "conflict") return c.json({ errors: [messages.taskChangedOnDisk], current: result.current }, 409);
5931
6364
  return invalidResponse(c, result, coreMessages(body.language));
5932
6365
  });
6366
+ api.post("/tasks/batch", async (c) => {
6367
+ const body = await readBody(c, batchRequestSchema, readLanguage2);
6368
+ if (!body.ok) return body.response;
6369
+ const { index } = await backlog();
6370
+ const outcomes = await applyBatch(index, { ...body.data, now: now() }).catch((error) => {
6371
+ forgetBacklog();
6372
+ throw error;
6373
+ });
6374
+ await recordOwnWrites(outcomes.flatMap((outcome) => outcome.outcome === "done" ? [outcome.task] : []));
6375
+ const messages = serverMessages(body.language);
6376
+ const core = coreMessages(body.language);
6377
+ return c.json({ results: outcomes.map((outcome) => viewOf(outcome, messages, core)) });
6378
+ });
5933
6379
  api.patch("/projects/:id", async (c) => {
5934
6380
  const body = await readBody(c, projectActiveSchema, readLanguage2);
5935
6381
  if (!body.ok) return body.response;
@@ -5953,21 +6399,27 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
5953
6399
  api.get(
5954
6400
  "/events",
5955
6401
  (c) => streamSSE(c, async (stream) => {
5956
- const unsubscribe = changes.subscribe(() => void stream.writeSSE({ event: "change", data: "" }));
6402
+ const send = (revision) => void stream.writeSSE({ event: "change", data: JSON.stringify(revision) });
6403
+ streams.add(send);
5957
6404
  const clientGone = new Promise((resolve7) => stream.onAbort(resolve7));
5958
6405
  await Promise.race([clientGone, changes.closed]);
5959
- unsubscribe();
6406
+ streams.delete(send);
5960
6407
  })
5961
6408
  );
5962
6409
  return api;
5963
6410
  }
5964
- async function loadSnapshot(root) {
6411
+ async function loadSnapshot(root, revision) {
5965
6412
  const loaded = await loadBacklog(root);
5966
- return { ...loaded, index: buildIndex(loaded.tasks) };
6413
+ return { ...loaded, index: buildIndex(loaded.tasks), revision };
5967
6414
  }
5968
6415
  function invalidResponse(c, result, messages) {
5969
6416
  return c.json({ errors: result.errors.map(messages.problem) }, 422);
5970
6417
  }
6418
+ function viewOf(outcome, messages, core) {
6419
+ if (outcome.outcome === "done") return { id: outcome.id, outcome: "done", version: outcome.task.version, previous: outcome.previous };
6420
+ const message = outcome.reason === "invalid" && outcome.problems ? core.problems(outcome.problems) : messages.batchSkipped[outcome.reason](outcome.id);
6421
+ return { id: outcome.id, outcome: "skipped", reason: outcome.reason, message };
6422
+ }
5971
6423
  async function readBody(c, schema, readLanguage2) {
5972
6424
  const [body, language] = await Promise.all([readJson(c), readLanguage2()]);
5973
6425
  if (body.ok === false) return { ok: false, response: c.json({ errors: [serverMessages(language).bodyNotParsed] }, 400) };
@@ -6036,7 +6488,7 @@ function createApp({ root, readLanguage: readLanguage2, changes, allowedHosts, h
6036
6488
  });
6037
6489
  if (staticDir !== void 0) {
6038
6490
  app.use("*", serveStatic({ root: staticDir }));
6039
- app.get("*", serveStatic({ path: join22(staticDir, "index.html") }));
6491
+ app.get("*", serveStatic({ path: join24(staticDir, "index.html") }));
6040
6492
  }
6041
6493
  return app;
6042
6494
  }
@@ -6061,11 +6513,17 @@ function isHiddenPath(root, path) {
6061
6513
  function createChangeFeed({ root, debounceMs, messages, warn: warn2 }) {
6062
6514
  const listeners = /* @__PURE__ */ new Set();
6063
6515
  const { promise: closed, resolve: markClosed } = Promise.withResolvers();
6516
+ const changedPaths = /* @__PURE__ */ new Set();
6064
6517
  const debouncer = createDebouncer(debounceMs, () => {
6065
- for (const listener of listeners) listener();
6518
+ const paths = [...changedPaths];
6519
+ changedPaths.clear();
6520
+ for (const listener of listeners) void listener(paths);
6066
6521
  });
6067
6522
  const watcher = watch(root, { ignoreInitial: true, ignored: (path) => isHiddenPath(root, path) });
6068
- watcher.on("all", () => debouncer.schedule());
6523
+ watcher.on("all", (_event, path) => {
6524
+ changedPaths.add(path);
6525
+ debouncer.schedule();
6526
+ });
6069
6527
  watcher.on("error", (error) => {
6070
6528
  void messages().then((texts) => warn2(texts.watcherError(root, errorText(error))));
6071
6529
  });
@@ -6130,8 +6588,9 @@ function startSweeper({ sweep, intervalMs, log: log2, warn: warn2, messages }) {
6130
6588
  return current;
6131
6589
  };
6132
6590
  }
6133
- function logReport({ closedEpics, blockingFiles, deleted, conflicts, invalid: invalid2 }, messages, log2) {
6591
+ function logReport({ closedEpics, reopenedEpics, blockingFiles, deleted, conflicts, invalid: invalid2 }, messages, log2) {
6134
6592
  if (closedEpics.length > 0) log2(messages.closedEpics(closedEpics.join(", ")));
6593
+ if (reopenedEpics.length > 0) log2(messages.reopenedEpics(reopenedEpics.join(", ")));
6135
6594
  if (blockingFiles.length > 0) log2(messages.epicsBlockedByFiles(blockingFiles.join(", ")));
6136
6595
  if (deleted.length > 0) log2(messages.deletedClosedTasks(deleted.join(", ")));
6137
6596
  if (conflicts.length > 0) log2(messages.conflictedDuringSweep(conflicts.join(", ")));
@@ -6141,12 +6600,12 @@ function logReport({ closedEpics, blockingFiles, deleted, conflicts, invalid: in
6141
6600
  }
6142
6601
 
6143
6602
  // src/core/usage/transcripts.ts
6144
- 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";
6603
+ import { createHash as createHash5 } from "node:crypto";
6604
+ import { open as open2, stat as stat6 } from "node:fs/promises";
6605
+ import { basename as basename10, join as join26 } from "node:path";
6147
6606
 
6148
6607
  // src/core/stats/cost/attribute.ts
6149
- import { z as z16 } from "zod";
6608
+ import { z as z18 } from "zod";
6150
6609
 
6151
6610
  // src/core/stats/cost/shell-commands.ts
6152
6611
  var SEPARATORS = ["&&", "||", ";", "|", "&", "\n", "`"];
@@ -6339,21 +6798,21 @@ var FAST_SPEED = "fast";
6339
6798
  var CHARS_PER_TOKEN = 3;
6340
6799
  var PENDING_TOOL_LIMIT = 64;
6341
6800
  var ZONE_OFFSET_STEP_MS = 15 * 60 * 1e3;
6342
- var usageSchema = z16.object({
6343
- input_tokens: z16.number().optional(),
6344
- output_tokens: z16.number().optional(),
6345
- cache_read_input_tokens: z16.number().optional(),
6346
- cache_creation_input_tokens: z16.number().optional(),
6347
- speed: z16.string().optional(),
6348
- cache_creation: z16.object({ ephemeral_5m_input_tokens: z16.number().optional(), ephemeral_1h_input_tokens: z16.number().optional() }).passthrough().optional()
6801
+ var usageSchema = z18.object({
6802
+ input_tokens: z18.number().optional(),
6803
+ output_tokens: z18.number().optional(),
6804
+ cache_read_input_tokens: z18.number().optional(),
6805
+ cache_creation_input_tokens: z18.number().optional(),
6806
+ speed: z18.string().optional(),
6807
+ cache_creation: z18.object({ ephemeral_5m_input_tokens: z18.number().optional(), ephemeral_1h_input_tokens: z18.number().optional() }).passthrough().optional()
6349
6808
  }).passthrough();
6350
- var assistantMessageSchema = z16.object({ id: z16.string().optional(), model: z16.string().optional(), usage: usageSchema.optional(), content: z16.array(z16.unknown()).optional() }).passthrough();
6351
- var userMessageSchema = z16.object({ content: z16.unknown().optional() }).passthrough();
6352
- var toolUseBlockSchema = z16.object({ type: z16.literal("tool_use"), id: z16.string(), name: z16.string(), input: z16.unknown().optional() }).passthrough();
6353
- var toolResultBlockSchema = z16.object({ type: z16.literal("tool_result"), tool_use_id: z16.string().optional(), content: z16.unknown().optional() }).passthrough();
6354
- var textBlockSchema = z16.object({ type: z16.literal("text"), text: z16.string() }).passthrough();
6355
- var bashInputSchema = z16.object({ command: z16.string() }).passthrough();
6356
- var lineSchema = z16.object({ type: z16.string().optional(), timestamp: z16.string().optional(), cwd: z16.string().optional(), isMeta: z16.boolean().optional(), message: z16.unknown().optional() }).passthrough();
6809
+ var assistantMessageSchema = z18.object({ id: z18.string().optional(), model: z18.string().optional(), usage: usageSchema.optional(), content: z18.array(z18.unknown()).optional() }).passthrough();
6810
+ var userMessageSchema = z18.object({ content: z18.unknown().optional() }).passthrough();
6811
+ var toolUseBlockSchema = z18.object({ type: z18.literal("tool_use"), id: z18.string(), name: z18.string(), input: z18.unknown().optional() }).passthrough();
6812
+ var toolResultBlockSchema = z18.object({ type: z18.literal("tool_result"), tool_use_id: z18.string().optional(), content: z18.unknown().optional() }).passthrough();
6813
+ var textBlockSchema = z18.object({ type: z18.literal("text"), text: z18.string() }).passthrough();
6814
+ var bashInputSchema = z18.object({ command: z18.string() }).passthrough();
6815
+ var lineSchema = z18.object({ type: z18.string().optional(), timestamp: z18.string().optional(), cwd: z18.string().optional(), isMeta: z18.boolean().optional(), message: z18.unknown().optional() }).passthrough();
6357
6816
  function newTranscriptState() {
6358
6817
  return { hookOpen: false, lastModel: null, lastMessageId: null, lastMessageTokens: null, pending: {}, pendingEstimates: [] };
6359
6818
  }
@@ -6477,53 +6936,53 @@ function slotOf(timestamp) {
6477
6936
 
6478
6937
  // src/core/usage/usage-cache.ts
6479
6938
  import { mkdir as mkdir5 } from "node:fs/promises";
6480
- import { join as join23 } from "node:path";
6481
- import { z as z17 } from "zod";
6939
+ import { join as join25 } from "node:path";
6940
+ import { z as z19 } from "zod";
6482
6941
  var USAGE_CACHE_FILE = ".usage-cache.json";
6483
6942
  var USAGE_CACHE_VERSION = 6;
6484
- var usageCacheEntrySchema = z17.object({
6485
- size: z17.number(),
6486
- mtimeMs: z17.number().optional(),
6487
- offset: z17.number(),
6488
- fingerprint: z17.string(),
6943
+ var usageCacheEntrySchema = z19.object({
6944
+ size: z19.number(),
6945
+ mtimeMs: z19.number().optional(),
6946
+ offset: z19.number(),
6947
+ fingerprint: z19.string(),
6489
6948
  state: transcriptStateSchema,
6490
- buckets: z17.array(usageBucketSchema)
6949
+ buckets: z19.array(usageBucketSchema)
6491
6950
  });
6492
- var usageCacheSchema = z17.object({
6493
- version: z17.literal(USAGE_CACHE_VERSION),
6494
- files: z17.record(z17.string(), usageCacheEntrySchema)
6951
+ var usageCacheSchema = z19.object({
6952
+ version: z19.literal(USAGE_CACHE_VERSION),
6953
+ files: z19.record(z19.string(), usageCacheEntrySchema)
6495
6954
  });
6496
6955
  function emptyUsageCache() {
6497
6956
  return { version: USAGE_CACHE_VERSION, files: {} };
6498
6957
  }
6499
6958
  async function readUsageCache(root) {
6500
- return await readJsonFile(join23(root, USAGE_CACHE_FILE), usageCacheSchema) ?? emptyUsageCache();
6959
+ return await readJsonFile(join25(root, USAGE_CACHE_FILE), usageCacheSchema) ?? emptyUsageCache();
6501
6960
  }
6502
6961
  async function writeUsageCache(root, cache) {
6503
6962
  await mkdir5(root, { recursive: true });
6504
- await writeFileAtomic(join23(root, USAGE_CACHE_FILE), JSON.stringify(cache));
6963
+ await writeFileAtomic(join25(root, USAGE_CACHE_FILE), JSON.stringify(cache));
6505
6964
  }
6506
6965
 
6507
6966
  // src/core/usage/transcripts.ts
6508
6967
  var NEWLINE = 10;
6509
6968
  var FINGERPRINT_BYTES = 256;
6510
6969
  var ABANDONED_LINE_MS = 10 * 60 * 1e3;
6511
- var EMPTY_FINGERPRINT = createHash4("sha1").digest("hex");
6970
+ var EMPTY_FINGERPRINT = createHash5("sha1").digest("hex");
6512
6971
  var COUNTED_LINE_MARKERS = ['"type":"assistant"', '"type":"user"'];
6513
6972
  async function listTranscripts(claudeProjectsDir2) {
6514
6973
  const files = [];
6515
6974
  for (const projectEntry of await listDir(claudeProjectsDir2)) {
6516
6975
  if (!projectEntry.isDirectory()) continue;
6517
- const projectDir2 = join24(claudeProjectsDir2, projectEntry.name);
6976
+ const projectDir2 = join26(claudeProjectsDir2, projectEntry.name);
6518
6977
  for (const entry of await listDir(projectDir2)) {
6519
- const entryPath = join24(projectDir2, entry.name);
6978
+ const entryPath = join26(projectDir2, entry.name);
6520
6979
  if (entry.isFile() && entry.name.endsWith(".jsonl")) {
6521
6980
  files.push(...await listedFile(entryPath));
6522
6981
  continue;
6523
6982
  }
6524
6983
  if (!entry.isDirectory()) continue;
6525
- for (const subagentEntry of await listDir(join24(entryPath, "subagents"), { recursive: true })) {
6526
- if (subagentEntry.isFile() && subagentEntry.name.endsWith(".jsonl")) files.push(...await listedFile(join24(subagentEntry.parentPath, subagentEntry.name)));
6984
+ for (const subagentEntry of await listDir(join26(entryPath, "subagents"), { recursive: true })) {
6985
+ if (subagentEntry.isFile() && subagentEntry.name.endsWith(".jsonl")) files.push(...await listedFile(join26(subagentEntry.parentPath, subagentEntry.name)));
6527
6986
  }
6528
6987
  }
6529
6988
  }
@@ -6531,7 +6990,7 @@ async function listTranscripts(claudeProjectsDir2) {
6531
6990
  }
6532
6991
  async function listedFile(path) {
6533
6992
  try {
6534
- const { size, mtimeMs } = await stat4(path);
6993
+ const { size, mtimeMs } = await stat6(path);
6535
6994
  return [{ path, size, mtimeMs }];
6536
6995
  } catch {
6537
6996
  return [];
@@ -6560,9 +7019,9 @@ async function scanTranscripts({ files, cache, byteBudget, now }) {
6560
7019
  }
6561
7020
  function deletedStillReported(cache, listedFiles, now) {
6562
7021
  const reportStart = now.getTime() - COST_REPORT_DAYS * DAY_MS;
6563
- const listedSessions = new Set(Object.keys(listedFiles).map((path) => basename9(path)));
7022
+ const listedSessions = new Set(Object.keys(listedFiles).map((path) => basename10(path)));
6564
7023
  return Object.fromEntries(
6565
- Object.entries(cache.files).filter(([path, entry]) => !listedSessions.has(basename9(path)) && entry.buckets.some((bucket) => Date.parse(bucket.slot) >= reportStart))
7024
+ Object.entries(cache.files).filter(([path, entry]) => !listedSessions.has(basename10(path)) && entry.buckets.some((bucket) => Date.parse(bucket.slot) >= reportStart))
6566
7025
  );
6567
7026
  }
6568
7027
  async function scanChunk(file, start, chunkSize, { longestReadableLine, now }) {
@@ -6596,7 +7055,7 @@ async function fingerprintOf(path, offset) {
6596
7055
  return withFile(path, async (handle) => {
6597
7056
  const head = await readAt(handle, 0, edge);
6598
7057
  const tail = await readAt(handle, offset - edge, edge);
6599
- return createHash4("sha1").update(head).update(tail).digest("hex");
7058
+ return createHash5("sha1").update(head).update(tail).digest("hex");
6600
7059
  });
6601
7060
  }
6602
7061
  function readChunk(path, position, length) {
@@ -6714,7 +7173,7 @@ function progressBefore(files, cache) {
6714
7173
  // src/server/start.ts
6715
7174
  var SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
6716
7175
  var STOP_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
6717
- var BUNDLED_WEB_DIR = join25(import.meta.dirname, "web");
7176
+ var BUNDLED_WEB_DIR = join27(import.meta.dirname, "web");
6718
7177
  var log = (line) => void process.stdout.write(`${line}
6719
7178
  `);
6720
7179
  var warn = (line) => void process.stderr.write(`${line}
@@ -6815,7 +7274,7 @@ async function runServe(args, io) {
6815
7274
 
6816
7275
  // src/cli/service/launchd.ts
6817
7276
  import { mkdir as mkdir7, rm as rm5, writeFile as writeFile4 } from "node:fs/promises";
6818
- import { dirname as dirname8, join as join26, posix } from "node:path";
7277
+ import { dirname as dirname8, join as join28, posix } from "node:path";
6819
7278
  import { setTimeout as sleep3 } from "node:timers/promises";
6820
7279
 
6821
7280
  // src/cli/service/service.ts
@@ -6888,7 +7347,7 @@ ${entries}
6888
7347
  `;
6889
7348
  }
6890
7349
  function launchdManager(context, delay = sleep3) {
6891
- const file = join26(context.home, "Library/LaunchAgents", `${LABEL}.plist`);
7350
+ const file = join28(context.home, "Library/LaunchAgents", `${LABEL}.plist`);
6892
7351
  const domain = `gui/${context.uid}`;
6893
7352
  const bootout = () => context.exec("launchctl", ["bootout", `${domain}/${LABEL}`]);
6894
7353
  const bootstrap = () => context.exec("launchctl", ["bootstrap", domain, file]);
@@ -6923,7 +7382,7 @@ function launchdManager(context, delay = sleep3) {
6923
7382
 
6924
7383
  // src/cli/service/startup-folder.ts
6925
7384
  import { mkdir as mkdir8, rm as rm6, writeFile as writeFile5 } from "node:fs/promises";
6926
- import { dirname as dirname9, join as join27, win32 } from "node:path";
7385
+ import { dirname as dirname9, join as join29, win32 } from "node:path";
6927
7386
  var SCRIPT_NAME = "p-backlog.vbs";
6928
7387
  var COMMAND_LINE_ARGUMENT = /"[^"]*"|\S+/g;
6929
7388
  var PROCESS_QUERY_TIMEOUT_MS = 15e3;
@@ -6931,16 +7390,16 @@ function vbsString(value) {
6931
7390
  return `"${value.replaceAll('"', '""')}"`;
6932
7391
  }
6933
7392
  function appDataDir(context) {
6934
- return join27(context.env.LOCALAPPDATA ?? join27(context.home, "AppData", "Local"), "p-backlog");
7393
+ return join29(context.env.LOCALAPPDATA ?? join29(context.home, "AppData", "Local"), "p-backlog");
6935
7394
  }
6936
7395
  function startupFolder(context) {
6937
- return join27(context.env.APPDATA ?? join27(context.home, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
7396
+ return join29(context.env.APPDATA ?? join29(context.home, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
6938
7397
  }
6939
7398
  function logPath2(context) {
6940
- return join27(appDataDir(context), "p-backlog.log");
7399
+ return join29(appDataDir(context), "p-backlog.log");
6941
7400
  }
6942
7401
  function pidFilePath(context) {
6943
- return join27(appDataDir(context), "server.pid");
7402
+ return join29(appDataDir(context), "server.pid");
6944
7403
  }
6945
7404
  var NODE_PATH_ENV = "P_BACKLOG_NODE";
6946
7405
  var CLI_PATH_ENV = "P_BACKLOG_CLI";
@@ -6991,7 +7450,7 @@ function sameWindowsPath(left, right) {
6991
7450
  return win32.normalize(left).toLowerCase() === win32.normalize(right).toLowerCase();
6992
7451
  }
6993
7452
  function startupFolderManager(context) {
6994
- const file = join27(startupFolder(context), SCRIPT_NAME);
7453
+ const file = join29(startupFolder(context), SCRIPT_NAME);
6995
7454
  return {
6996
7455
  file,
6997
7456
  logs: logPath2(context),
@@ -7018,7 +7477,7 @@ function startupFolderManager(context) {
7018
7477
 
7019
7478
  // src/cli/service/systemd.ts
7020
7479
  import { mkdir as mkdir9, rm as rm7, writeFile as writeFile6 } from "node:fs/promises";
7021
- import { dirname as dirname10, join as join28 } from "node:path";
7480
+ import { dirname as dirname10, join as join30 } from "node:path";
7022
7481
  var UNIT = "p-backlog.service";
7023
7482
  function quoted(value) {
7024
7483
  return `"${value.replace(/[\\"]/g, "\\$&").replaceAll("%", "%%")}"`;
@@ -7052,7 +7511,7 @@ async function systemctlSteps(exec, steps) {
7052
7511
  return "done";
7053
7512
  }
7054
7513
  function systemdManager(context) {
7055
- const file = join28(context.home, ".config/systemd/user", UNIT);
7514
+ const file = join30(context.home, ".config/systemd/user", UNIT);
7056
7515
  return {
7057
7516
  file,
7058
7517
  logs: "journalctl --user -u p-backlog",
@@ -7170,50 +7629,43 @@ function reportFailure({ failed, code, output }, io) {
7170
7629
  }
7171
7630
 
7172
7631
  // src/cli/commands/setup.ts
7173
- import { join as join29 } from "node:path";
7632
+ import { join as join32 } from "node:path";
7174
7633
 
7175
- // 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";
7634
+ // src/cli/agents/agent-hooks.ts
7635
+ import { join as join31 } from "node:path";
7636
+
7637
+ // src/cli/agents/grouped-stop-hooks.ts
7638
+ import { z as z20 } from "zod";
7639
+
7640
+ // src/cli/agents/json-config.ts
7641
+ import { mkdir as mkdir10, readFile as readFile5, readlink as readlink2, realpath as realpath2, stat as stat7 } from "node:fs/promises";
7177
7642
  import { dirname as dirname11, resolve as resolve4 } from "node:path";
7178
- import { z as z18 } from "zod";
7179
- var POSIX_COMMAND = "command -v backlog >/dev/null && backlog hook stop || true";
7180
- var POWERSHELL_COMMAND = "if (Get-Command backlog.cmd -ErrorAction SilentlyContinue) { backlog.cmd hook stop }";
7181
7643
  var MAX_LINK_HOPS = 40;
7182
- var stopHookGroupSchema = z18.object({ hooks: z18.array(z18.unknown()).optional() }).passthrough();
7183
- var settingsSchema2 = z18.object({ hooks: z18.object({ Stop: z18.array(stopHookGroupSchema).optional() }).passthrough().optional() }).passthrough();
7184
- function stopHookFor(platform) {
7185
- return platform === "win32" ? { type: "command", shell: "powershell", command: POWERSHELL_COMMAND } : { type: "command", command: POSIX_COMMAND };
7186
- }
7187
- function isOurStopHook(hook) {
7188
- if (typeof hook !== "object" || hook === null) return false;
7189
- const command = hook.command;
7190
- return command === POSIX_COMMAND || command === POWERSHELL_COMMAND;
7191
- }
7192
- async function addStopHook(settingsPath, platform) {
7193
- const text = await readSettingsText(settingsPath);
7644
+ async function readJsonConfig(path, schema) {
7645
+ const text = await readConfigText(path);
7194
7646
  if (typeof text !== "string") return text;
7195
- const settings = validatedSettings(text);
7196
- if (settings === null) return { failed: "invalid" };
7197
- const hooks = settings.hooks ??= {};
7198
- const stopGroups = hooks.Stop ??= [];
7199
- const installed = stopGroups.some((group) => group.hooks?.some(isOurStopHook) ?? false);
7200
- if (installed) return "exists";
7201
- stopGroups.push({ hooks: [stopHookFor(platform)] });
7202
- await mkdir10(dirname11(settingsPath), { recursive: true });
7203
- const target = await writeTargetOf(settingsPath);
7204
- const mode = await stat5(target).then(({ mode: mode2 }) => mode2 & 511, () => void 0);
7205
- await writeFileAtomic(target, `${JSON.stringify(settings, null, 2)}
7206
- `, mode);
7207
- return "added";
7208
- }
7209
- function validatedSettings(text) {
7210
7647
  let value;
7211
7648
  try {
7212
7649
  value = JSON.parse(text);
7213
7650
  } catch {
7214
- return null;
7651
+ return { failed: "invalid" };
7652
+ }
7653
+ return schema.safeParse(value).success ? { config: value } : { failed: "invalid" };
7654
+ }
7655
+ async function writeJsonConfig(path, config) {
7656
+ await mkdir10(dirname11(path), { recursive: true });
7657
+ const target = await writeTargetOf(path);
7658
+ const mode = await stat7(target).then(({ mode: mode2 }) => mode2 & 511, () => void 0);
7659
+ await writeFileAtomic(target, `${JSON.stringify(config, null, 2)}
7660
+ `, mode);
7661
+ }
7662
+ async function readConfigText(path) {
7663
+ try {
7664
+ return await readFile5(path, "utf8");
7665
+ } catch (error) {
7666
+ if (hasErrorCode(error, "ENOENT")) return "{}";
7667
+ return { failed: "unreadable", code: errorCodeOrText(error) };
7215
7668
  }
7216
- return settingsSchema2.safeParse(value).success ? value : null;
7217
7669
  }
7218
7670
  async function writeTargetOf(path) {
7219
7671
  try {
@@ -7232,62 +7684,240 @@ async function danglingLinkTarget(path) {
7232
7684
  }
7233
7685
  return current;
7234
7686
  }
7235
- async function readSettingsText(settingsPath) {
7236
- try {
7237
- return await readFile5(settingsPath, "utf8");
7238
- } catch (error) {
7239
- if (hasErrorCode(error, "ENOENT")) return "{}";
7240
- return { failed: "unreadable", code: errorCodeOrText(error) };
7687
+
7688
+ // src/cli/agents/grouped-stop-hooks.ts
7689
+ var stopGroupSchema = z20.object({ hooks: z20.array(z20.unknown()).optional() }).passthrough();
7690
+ var groupedConfigSchema = z20.object({ hooks: z20.object({ Stop: z20.array(stopGroupSchema).optional() }).passthrough().optional() }).passthrough();
7691
+ function refreshOurHook(lists, hook, { isOurs, isCurrent }) {
7692
+ const ours = lists.flatMap((list) => list.flatMap((entry, index) => isOurs(entry) ? [{ list, index, entry }] : []));
7693
+ if (ours.some(({ entry }) => isCurrent(entry))) return "exists";
7694
+ const stale = ours[0];
7695
+ if (stale === void 0) return "missing";
7696
+ stale.list[stale.index] = { ...stale.entry, ...hook };
7697
+ return "updated";
7698
+ }
7699
+ async function addGroupedStopHook(path, hook, ourHook) {
7700
+ const read = await readJsonConfig(path, groupedConfigSchema);
7701
+ if (!("config" in read)) return read;
7702
+ const stopGroups = (read.config.hooks ??= {}).Stop ??= [];
7703
+ const refreshed = refreshOurHook(stopGroups.flatMap((group) => group.hooks === void 0 ? [] : [group.hooks]), hook, ourHook);
7704
+ if (refreshed === "exists") return "exists";
7705
+ if (refreshed === "missing") stopGroups.push({ hooks: [hook] });
7706
+ await writeJsonConfig(path, read.config);
7707
+ return refreshed === "missing" ? "added" : "updated";
7708
+ }
7709
+ async function removeGroupedStopHook(path, isOurs) {
7710
+ const read = await readJsonConfig(path, groupedConfigSchema);
7711
+ if (!("config" in read)) return read;
7712
+ const hooks = read.config.hooks;
7713
+ if (hooks?.Stop === void 0 || !hooks.Stop.some((group) => hasOurHook(group, isOurs))) return "absent";
7714
+ const kept = hooks.Stop.flatMap((group) => {
7715
+ if (!hasOurHook(group, isOurs)) return [group];
7716
+ const others = (group.hooks ?? []).filter((hook) => !isOurs(hook));
7717
+ return others.length > 0 ? [{ ...group, hooks: others }] : [];
7718
+ });
7719
+ if (kept.length > 0) hooks.Stop = kept;
7720
+ else delete hooks.Stop;
7721
+ await writeJsonConfig(path, read.config);
7722
+ return "removed";
7723
+ }
7724
+ function hasOurHook(group, isOurs) {
7725
+ return group.hooks?.some(isOurs) ?? false;
7726
+ }
7727
+
7728
+ // src/cli/stop-hook.ts
7729
+ var POSIX_COMMAND = "command -v backlog >/dev/null && backlog hook stop || true";
7730
+ var POWERSHELL_COMMAND = "if (Get-Command backlog.cmd -ErrorAction SilentlyContinue) { backlog.cmd hook stop }";
7731
+ function stopHookFor(platform) {
7732
+ return platform === "win32" ? { type: "command", shell: "powershell", command: POWERSHELL_COMMAND } : { type: "command", command: POSIX_COMMAND };
7733
+ }
7734
+ function isOurStopHook(hook) {
7735
+ if (typeof hook !== "object" || hook === null) return false;
7736
+ const command = hook.command;
7737
+ return command === POSIX_COMMAND || command === POWERSHELL_COMMAND;
7738
+ }
7739
+ function addStopHook(settingsPath, platform) {
7740
+ return addGroupedStopHook(settingsPath, stopHookFor(platform), { isOurs: isOurStopHook, isCurrent: isOurStopHook });
7741
+ }
7742
+ function removeStopHook(settingsPath) {
7743
+ return removeGroupedStopHook(settingsPath, isOurStopHook);
7744
+ }
7745
+
7746
+ // src/cli/agents/cursor-hooks.ts
7747
+ import { z as z21 } from "zod";
7748
+ var CURSOR_HOOKS_VERSION = 1;
7749
+ var cursorConfigSchema = z21.object({ version: z21.number().optional(), hooks: z21.object({ stop: z21.array(z21.unknown()).optional() }).passthrough().optional() }).passthrough();
7750
+ async function addCursorStopHook(path, hook, ourHook) {
7751
+ const read = await readJsonConfig(path, cursorConfigSchema);
7752
+ if (!("config" in read)) return read;
7753
+ const stop = (read.config.hooks ??= {}).stop ??= [];
7754
+ const refreshed = refreshOurHook([stop], hook, ourHook);
7755
+ if (refreshed === "exists") return "exists";
7756
+ if (refreshed === "missing") stop.push(hook);
7757
+ read.config.version ??= CURSOR_HOOKS_VERSION;
7758
+ await writeJsonConfig(path, read.config);
7759
+ return refreshed === "missing" ? "added" : "updated";
7760
+ }
7761
+ async function removeCursorStopHook(path, isOurs) {
7762
+ const read = await readJsonConfig(path, cursorConfigSchema);
7763
+ if (!("config" in read)) return read;
7764
+ const hooks = read.config.hooks;
7765
+ if (hooks?.stop === void 0 || !hooks.stop.some(isOurs)) return "absent";
7766
+ const kept = hooks.stop.filter((hook) => !isOurs(hook));
7767
+ if (kept.length > 0) hooks.stop = kept;
7768
+ else delete hooks.stop;
7769
+ await writeJsonConfig(path, read.config);
7770
+ return "removed";
7771
+ }
7772
+
7773
+ // src/cli/agents/agent-hooks.ts
7774
+ var CODEX_HOOK_TIMEOUT_SECONDS = 30;
7775
+ var AGENT_HOOKS_FILE = "hooks.json";
7776
+ function agentHookConfigPath(agent, env, home2) {
7777
+ return agent === "claude" ? claudeSettingsPath(env, home2) : join31(agentHomeDir(agent, env, home2), AGENT_HOOKS_FILE);
7778
+ }
7779
+ function installAgentHook(agent, site) {
7780
+ const path = agentHookConfigPath(agent, site.env, site.home);
7781
+ switch (agent) {
7782
+ case "claude":
7783
+ return addStopHook(path, site.platform);
7784
+ case "codex": {
7785
+ const hook = { type: "command", command: posixCommand(agent), commandWindows: windowsCommand(agent, site.cliPath), timeout: CODEX_HOOK_TIMEOUT_SECONDS };
7786
+ return addGroupedStopHook(path, hook, ourCurrentHook(agent, site.cliPath, hook));
7787
+ }
7788
+ case "cursor": {
7789
+ const hook = { command: site.platform === "win32" ? windowsCommand(agent, site.cliPath) : posixCommand(agent) };
7790
+ return addCursorStopHook(path, hook, ourCurrentHook(agent, site.cliPath, hook));
7791
+ }
7241
7792
  }
7242
7793
  }
7794
+ function removeAgentHook(agent, site) {
7795
+ const path = agentHookConfigPath(agent, site.env, site.home);
7796
+ switch (agent) {
7797
+ case "claude":
7798
+ return removeStopHook(path);
7799
+ case "codex":
7800
+ return removeGroupedStopHook(path, ourHookOf(agent));
7801
+ case "cursor":
7802
+ return removeCursorStopHook(path, ourHookOf(agent));
7803
+ }
7804
+ }
7805
+ function agentStopCommand(agent) {
7806
+ return `${HOOK_STOP_COMMAND} --agent ${agent}`;
7807
+ }
7808
+ function posixCommand(agent) {
7809
+ return `command -v backlog >/dev/null && backlog ${agentStopCommand(agent)} || true`;
7810
+ }
7811
+ function windowsCommand(agent, cliPath2) {
7812
+ return `node "${cliPath2}" ${agentStopCommand(agent)}`;
7813
+ }
7814
+ function windowsCommandPattern(agent) {
7815
+ return new RegExp(`^node "[^"]*cli\\.js" ${agentStopCommand(agent)}$`);
7816
+ }
7817
+ function ourHookOf(agent, cliPath2) {
7818
+ return (hook) => {
7819
+ const command = typeof hook === "object" && hook !== null ? hook.command : void 0;
7820
+ if (typeof command !== "string") return false;
7821
+ return command === posixCommand(agent) || cliPath2 !== void 0 && command === windowsCommand(agent, cliPath2) || windowsCommandPattern(agent).test(command);
7822
+ };
7823
+ }
7824
+ function ourCurrentHook(agent, cliPath2, wanted) {
7825
+ return {
7826
+ isOurs: ourHookOf(agent, cliPath2),
7827
+ isCurrent: (hook) => Object.entries(wanted).every(([key, value]) => hook[key] === value)
7828
+ };
7829
+ }
7243
7830
 
7244
7831
  // src/cli/commands/setup.ts
7245
7832
  var setupCommand = {
7246
7833
  name: "setup",
7247
- usage: () => ["[--service]"],
7834
+ usage: () => [`[--agent ${AGENTS.join("|")}] [--service]`, `--remove-manual [--agent ${AGENTS.join("|")}]`],
7248
7835
  run: runSetup
7249
7836
  };
7250
7837
  async function runSetup(args, io) {
7251
- const { service } = parseOptions(io.language, args, { service: { type: "boolean" } });
7252
- const setupDone = await runSetupSteps(io);
7253
- const serviceCode = service ? await installService(io) : EXIT.ok;
7254
- return setupDone ? serviceCode : EXIT.failed;
7838
+ const options = parseOptions(io.language, args, { service: { type: "boolean" }, agent: { type: "string" }, "remove-manual": { type: "boolean" } });
7839
+ if (options["remove-manual"] && options.service) throw new UsageError(cliMessages(io.language).removeManualWithService);
7840
+ const agents = await targetAgents(options.agent, io);
7841
+ const step = options["remove-manual"] ? removeManualSetup : setUpAgent;
7842
+ const outcomes = [];
7843
+ for (const agent of agents) outcomes.push(await step(agent, io));
7844
+ const serviceCode = options.service ? await installService(io) : EXIT.ok;
7845
+ return outcomes.every(Boolean) ? serviceCode : EXIT.failed;
7846
+ }
7847
+ async function targetAgents(option, io) {
7848
+ const messages = cliMessages(io.language);
7849
+ if (option !== void 0) return [parseChoice(io.language, option, AGENTS, messages.optionLabel.agent)];
7850
+ const detection = await detectAgents(io.env, io.home);
7851
+ for (const { agent, dir } of detection.missing) agentVoice(agent, io).print(messages.agentNotFound(dir));
7852
+ return detection.found;
7853
+ }
7854
+ function agentVoice(agent, io) {
7855
+ const label = AGENT_LABELS[agent];
7856
+ return { print: (line) => io.print(`${label}: ${line}`), warn: (line) => io.warn(`${label}: ${line}`) };
7857
+ }
7858
+ async function setUpAgent(agent, io) {
7859
+ const voice = agentVoice(agent, io);
7860
+ const plugin = await agentPlugin(agent, io.env, io.home);
7861
+ if (plugin !== null) {
7862
+ voice.print(cliMessages(io.language).pluginManages(plugin));
7863
+ return true;
7864
+ }
7865
+ if (!await linkAgentSkill(agent, io, voice)) return false;
7866
+ const hook = await installAgentHook(agent, io);
7867
+ const reported = reportHook(hook, agentHookConfigPath(agent, io.env, io.home), io, voice);
7868
+ if (agent === "codex" && (hook === "added" || hook === "updated")) voice.print(cliMessages(io.language).codexHookApproval);
7869
+ return reported;
7255
7870
  }
7256
- async function runSetupSteps(io) {
7871
+ async function linkAgentSkill(agent, io, voice) {
7257
7872
  const messages = cliMessages(io.language);
7258
- const skillsDir = claudeSkillsDir(io.env, io.home);
7259
- const target = join29(skillsDir, "backlog");
7873
+ const skillsDir = agentSkillsDir(agent, io.env, io.home);
7874
+ const target = join32(skillsDir, "backlog");
7260
7875
  const source = skillSourceDir(io.packageRoot, io.language);
7261
7876
  let link2;
7262
7877
  try {
7263
7878
  link2 = await linkSkillFor(io.language, { skillsDir, packageRoot: io.packageRoot, platform: io.platform });
7264
7879
  } catch (error) {
7265
- io.warn(messages.installSkillLinkFailed(target, errorCodeOrText(error)));
7880
+ voice.warn(messages.installSkillLinkFailed(target, errorCodeOrText(error)));
7266
7881
  return false;
7267
7882
  }
7268
7883
  if (link2 === "foreign") {
7269
- io.warn(messages.installSkillForeign(target, source));
7884
+ voice.warn(messages.installSkillForeign(target, source));
7270
7885
  return false;
7271
7886
  }
7272
- io.print(link2 === "linked" ? messages.installSkillLinked(target, source) : messages.installSkillKept(target));
7273
- const settingsPath = claudeSettingsPath(io.env, io.home);
7274
- return reportHook(await addStopHook(settingsPath, io.platform), settingsPath, io);
7887
+ voice.print(link2 === "linked" ? messages.installSkillLinked(target, source) : messages.installSkillKept(target));
7888
+ return true;
7275
7889
  }
7276
- function reportHook(result, settingsPath, io) {
7890
+ function reportHook(result, configPath, io, voice) {
7277
7891
  const messages = cliMessages(io.language);
7278
- if (result === "added") {
7279
- io.print(messages.installHookAdded(settingsPath));
7280
- return true;
7281
- }
7282
- if (result === "exists") {
7283
- io.print(messages.installHookExists(settingsPath));
7892
+ if (typeof result === "string") {
7893
+ const report = { added: messages.installHookAdded, exists: messages.installHookExists, updated: messages.installHookUpdated }[result];
7894
+ voice.print(report(configPath));
7284
7895
  return true;
7285
7896
  }
7286
7897
  if (result.failed === "unreadable") {
7287
- io.warn(messages.installSettingsUnreadable(settingsPath, result.code));
7898
+ voice.warn(messages.installSettingsUnreadable(configPath, result.code));
7288
7899
  return false;
7289
7900
  }
7290
- io.warn(messages.installSettingsInvalid(settingsPath));
7901
+ voice.warn(messages.installSettingsInvalid(configPath));
7902
+ return false;
7903
+ }
7904
+ async function removeManualSetup(agent, io) {
7905
+ const voice = agentVoice(agent, io);
7906
+ const messages = cliMessages(io.language);
7907
+ const skillsDir = agentSkillsDir(agent, io.env, io.home);
7908
+ voice.print(messages.manualSkillRemoval[await unlinkOurSkill(skillsDir)](join32(skillsDir, "backlog")));
7909
+ for (const legacyDir of legacySkillsDirs(agent, io.env, io.home)) {
7910
+ if (await unlinkOurSkill(legacyDir) === "removed") voice.print(messages.manualSkillRemoval.removed(join32(legacyDir, "backlog")));
7911
+ }
7912
+ return reportHookRemoval(await removeAgentHook(agent, io), agentHookConfigPath(agent, io.env, io.home), io, voice);
7913
+ }
7914
+ function reportHookRemoval(result, configPath, io, voice) {
7915
+ const messages = cliMessages(io.language);
7916
+ if (result === "removed" || result === "absent") {
7917
+ voice.print(messages.manualHookRemoval[result](configPath));
7918
+ return true;
7919
+ }
7920
+ voice.warn(result.failed === "unreadable" ? messages.hookConfigUnreadable(configPath, result.code) : messages.hookConfigInvalid(configPath));
7291
7921
  return false;
7292
7922
  }
7293
7923