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.
- package/CHANGELOG.md +44 -0
- package/README.md +46 -30
- package/README.ru.md +46 -30
- package/dist/cli.js +930 -300
- package/dist/web/assets/{ChartTooltip-DwQKHXkN.js → ChartTooltip-b-yZtIRd.js} +3 -3
- package/dist/web/assets/CodeTab-BU7UU1i4.js +1 -0
- package/dist/web/assets/{CostTab-B21EB-7P.js → CostTab-Be8xR9DU.js} +1 -1
- package/dist/web/assets/EffectTab-D0kaZiSl.js +1 -0
- package/dist/web/assets/Figure-B6Ry1zXo.js +1 -0
- package/dist/web/assets/OverviewTab-DqU7o9Ug.js +1 -0
- package/dist/web/assets/QualityTab-DtnTgxXj.js +1 -0
- package/dist/web/assets/StatsPage-BLZKtSL6.js +1 -0
- package/dist/web/assets/StatsTabState-CYkDWYp7.js +1 -0
- package/dist/web/assets/{StatsTable-Bd3vAxKU.js → StatsTable-C0coWtNM.js} +1 -1
- package/dist/web/assets/UnavailableRepos-DDHmBHg0.js +1 -0
- package/dist/web/assets/cx-Cfec8QGz.js +58 -0
- package/dist/web/assets/index-DN4lSwOL.js +2 -0
- package/dist/web/assets/{index-CAKUAPJF.css → start-Cy19NMke.css} +1 -1
- package/dist/web/assets/start-sbZVqXmP.js +38 -0
- package/dist/web/assets/{value-dot-kiMizZCu.js → value-dot-CfFRNT_m.js} +1 -1
- package/dist/web/index.html +1 -5
- package/package.json +2 -1
- package/skill/backlog/SKILL.md +5 -4
- package/skill/backlog-en/SKILL.md +5 -4
- package/dist/web/assets/CodeTab-BGxsIZMH.js +0 -1
- package/dist/web/assets/EffectTab-97OFZkWY.js +0 -1
- package/dist/web/assets/Figure-CiIqHKnb.js +0 -1
- package/dist/web/assets/OverviewTab-X_g8WfUl.js +0 -1
- package/dist/web/assets/QualityTab-DYnFz37G.js +0 -1
- package/dist/web/assets/StatsPage-D9gJHHQg.js +0 -1
- package/dist/web/assets/StatsTabState-DbuUIt-D.js +0 -1
- package/dist/web/assets/UnavailableRepos-D2iehY7u.js +0 -1
- package/dist/web/assets/cx-BZTW4xgb.js +0 -58
- package/dist/web/assets/index-BrkoyuWi.js +0 -38
- 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(
|
|
940
|
-
const base = prefixBase(
|
|
986
|
+
function derivePrefix(basename11, taken) {
|
|
987
|
+
const base = prefixBase(basename11);
|
|
941
988
|
return firstFree(base, taken, (n) => `${base}${n}`);
|
|
942
989
|
}
|
|
943
|
-
function deriveProjectId(
|
|
944
|
-
const slug =
|
|
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(
|
|
950
|
-
const words =
|
|
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 (
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
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
|
|
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 &&
|
|
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 (
|
|
3418
|
-
|
|
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
|
|
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
|
|
3811
|
-
import { z as
|
|
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 =
|
|
3988
|
+
var packageManifestSchema = z9.object({ name: z9.string() });
|
|
3814
3989
|
function skillSourceDir(packageRoot2, language) {
|
|
3815
|
-
return
|
|
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(
|
|
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 =
|
|
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
|
|
3865
|
-
const
|
|
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
|
-
|
|
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
|
|
3925
|
-
import { z as
|
|
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 =
|
|
4148
|
+
var shownSchema = z11.record(z11.string(), z11.string());
|
|
3928
4149
|
async function readSignalsShown(projectDir2) {
|
|
3929
|
-
return await readJsonFile(
|
|
4150
|
+
return await readJsonFile(join20(projectDir2, SIGNALS_SHOWN_FILE), shownSchema) ?? {};
|
|
3930
4151
|
}
|
|
3931
4152
|
async function writeSignalsShown(projectDir2, shown) {
|
|
3932
|
-
await writeJsonFile(
|
|
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
|
|
3937
|
-
import { z as
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
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.
|
|
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
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
};
|
|
4286
|
-
if (
|
|
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
|
|
4990
|
+
import { join as join27 } from "node:path";
|
|
4690
4991
|
|
|
4691
4992
|
// src/core/store/sweep.ts
|
|
4692
|
-
import { dirname as dirname7, join as
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
4833
|
-
var idList =
|
|
4834
|
-
var taskChangesSchema =
|
|
4835
|
-
title:
|
|
4836
|
-
type:
|
|
4837
|
-
status:
|
|
4838
|
-
priority:
|
|
4839
|
-
tags:
|
|
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:
|
|
4844
|
-
category:
|
|
5158
|
+
body: z14.string().optional(),
|
|
5159
|
+
category: z14.enum(TASK_CATEGORIES).nullable().optional()
|
|
4845
5160
|
});
|
|
4846
|
-
var projectActiveSchema =
|
|
4847
|
-
var projectDeleteSchema =
|
|
4848
|
-
var settingsRequestSchema =
|
|
4849
|
-
var updateTaskRequestSchema =
|
|
4850
|
-
version:
|
|
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
|
|
4906
|
-
import { z as
|
|
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 =
|
|
4910
|
-
commits:
|
|
4911
|
-
lines:
|
|
4912
|
-
units:
|
|
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 =
|
|
4915
|
-
var snapshotSchema =
|
|
4916
|
-
version:
|
|
4917
|
-
repos:
|
|
4918
|
-
fixes:
|
|
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 =
|
|
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
|
|
5322
|
-
var tokenCountsSchema =
|
|
5323
|
-
input:
|
|
5324
|
-
cacheWrite5m:
|
|
5325
|
-
cacheWrite1h:
|
|
5326
|
-
cacheRead:
|
|
5327
|
-
output:
|
|
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
|
|
5764
|
+
import { z as z17 } from "zod";
|
|
5343
5765
|
var COST_REPORT_DAYS = 30;
|
|
5344
|
-
var estimatedKindSchema =
|
|
5345
|
-
var usageBucketSchema =
|
|
5346
|
-
slot:
|
|
5347
|
-
cwd:
|
|
5348
|
-
model:
|
|
5349
|
-
kind:
|
|
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:
|
|
5773
|
+
hookTurns: z17.number()
|
|
5352
5774
|
});
|
|
5353
|
-
var transcriptStateSchema =
|
|
5354
|
-
hookOpen:
|
|
5355
|
-
lastModel:
|
|
5356
|
-
lastMessageId:
|
|
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:
|
|
5359
|
-
pendingEstimates:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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", () =>
|
|
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
|
|
6145
|
-
import { open as open2, stat as
|
|
6146
|
-
import { basename as
|
|
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
|
|
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 =
|
|
6343
|
-
input_tokens:
|
|
6344
|
-
output_tokens:
|
|
6345
|
-
cache_read_input_tokens:
|
|
6346
|
-
cache_creation_input_tokens:
|
|
6347
|
-
speed:
|
|
6348
|
-
cache_creation:
|
|
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 =
|
|
6351
|
-
var userMessageSchema =
|
|
6352
|
-
var toolUseBlockSchema =
|
|
6353
|
-
var toolResultBlockSchema =
|
|
6354
|
-
var textBlockSchema =
|
|
6355
|
-
var bashInputSchema =
|
|
6356
|
-
var lineSchema =
|
|
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
|
|
6481
|
-
import { z as
|
|
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 =
|
|
6485
|
-
size:
|
|
6486
|
-
mtimeMs:
|
|
6487
|
-
offset:
|
|
6488
|
-
fingerprint:
|
|
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:
|
|
6949
|
+
buckets: z19.array(usageBucketSchema)
|
|
6491
6950
|
});
|
|
6492
|
-
var usageCacheSchema =
|
|
6493
|
-
version:
|
|
6494
|
-
files:
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
6976
|
+
const projectDir2 = join26(claudeProjectsDir2, projectEntry.name);
|
|
6518
6977
|
for (const entry of await listDir(projectDir2)) {
|
|
6519
|
-
const entryPath =
|
|
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(
|
|
6526
|
-
if (subagentEntry.isFile() && subagentEntry.name.endsWith(".jsonl")) files.push(...await listedFile(
|
|
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
|
|
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) =>
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
7393
|
+
return join29(context.env.LOCALAPPDATA ?? join29(context.home, "AppData", "Local"), "p-backlog");
|
|
6935
7394
|
}
|
|
6936
7395
|
function startupFolder(context) {
|
|
6937
|
-
return
|
|
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
|
|
7399
|
+
return join29(appDataDir(context), "p-backlog.log");
|
|
6941
7400
|
}
|
|
6942
7401
|
function pidFilePath(context) {
|
|
6943
|
-
return
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
7632
|
+
import { join as join32 } from "node:path";
|
|
7174
7633
|
|
|
7175
|
-
// src/cli/
|
|
7176
|
-
import {
|
|
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
|
-
|
|
7183
|
-
|
|
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
|
|
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
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
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
|
|
7252
|
-
|
|
7253
|
-
const
|
|
7254
|
-
|
|
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
|
|
7871
|
+
async function linkAgentSkill(agent, io, voice) {
|
|
7257
7872
|
const messages = cliMessages(io.language);
|
|
7258
|
-
const skillsDir =
|
|
7259
|
-
const target =
|
|
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
|
-
|
|
7880
|
+
voice.warn(messages.installSkillLinkFailed(target, errorCodeOrText(error)));
|
|
7266
7881
|
return false;
|
|
7267
7882
|
}
|
|
7268
7883
|
if (link2 === "foreign") {
|
|
7269
|
-
|
|
7884
|
+
voice.warn(messages.installSkillForeign(target, source));
|
|
7270
7885
|
return false;
|
|
7271
7886
|
}
|
|
7272
|
-
|
|
7273
|
-
|
|
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,
|
|
7890
|
+
function reportHook(result, configPath, io, voice) {
|
|
7277
7891
|
const messages = cliMessages(io.language);
|
|
7278
|
-
if (result === "
|
|
7279
|
-
|
|
7280
|
-
|
|
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
|
-
|
|
7898
|
+
voice.warn(messages.installSettingsUnreadable(configPath, result.code));
|
|
7288
7899
|
return false;
|
|
7289
7900
|
}
|
|
7290
|
-
|
|
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
|
|